import { CompositionEventHandler, UIEventHandler, useEffect, useRef, useState, } from "react"; import { useSearchParams } from "react-router"; import { toHiragana, toKana } from "wanakana"; import { Box, Card, CloseButton, Field, Input, InputGroup, Stack, Switch, } from "@chakra-ui/react"; import { invoke } from "@tauri-apps/api/core"; import { readText } from "@tauri-apps/plugin-clipboard-manager"; import Glossary from "../components/Glossary"; interface Entry { term: string; reading: string; glossary_json: string; def_tags: string; rules: string; score: number; sequence: number; term_tags: string; dict_title: string; } function SearchPage() { const [searchParams, set_searchParams] = useSearchParams(); useEffect(() => { let q = searchParams.get("q") || ""; if (q) set_q(q); }, [searchParams]); function searchParams_setQ(q: string) { if (searchParams.get("q") === q) return; set_searchParams({ q }); } const [q, set_q] = useState(""); const [entries, set_entries] = useState([]); const [searchTimeout, set_searchTimeout] = useState(0); const [furigana, set_furigana] = useState(""); const [isAutoKana, set_isAutoKana] = useState(true); const [isScrollEnd, set_isScrollEnd] = useState(false); const nSearch = useRef(0); const searchboxRef = useRef(null); const resultScrollRef = useRef(null); const lang = "ja-JP"; const [isMonitorClipboard, set_isMonitorclipboard] = useState(false); const [, set_clipboardText] = useState(""); useEffect(() => { if (!isMonitorClipboard) return; const intervalId = setInterval(async () => { const newText = (await readText()).replace(/ /g, ""); set_clipboardText((clipboardText) => { if (clipboardText !== newText) { searchParams_setQ(newText + " "); return newText; } return clipboardText; }); }, 500); return () => clearInterval(intervalId); }, [isMonitorClipboard]); useEffect(() => { trySearch(); }, [q]); function trySearch() { if (!q.trim()) { set_entries([]); return; } if (searchTimeout) { clearTimeout(searchTimeout); } else if (nSearch.current === 0) { // If new, just run immediately. No need for throttling. runSearch(); } nSearch.current += 1; set_searchTimeout( setTimeout(() => { if (nSearch.current > 1) runSearch(); }, 250), ); } async function runSearch(isNew = true) { let norm_q = q.split(" ")[0].trim(); const isFromSplit = norm_q !== q.trim(); if (isAutoKana) { norm_q = norm_q.replace(/n$/i, "ん").replace(/[a-z]$/i, ""); } norm_q = norm_q.replace(/*/g, "*").replace(/?/g, "?").replace(/^〜/, "~"); if (!norm_q) return; const ender = /\p{Z}$/u.test(q) || [..."*?[]"].some((c) => q.includes(c)) ? "" : "*"; const re = /\<(.+?)\>{(.+?)}/g; let qTerm = ""; let qReading = ""; norm_q.split(re).map((s, i) => { switch (i % 3) { case 1: qTerm += s; break; case 2: qReading += s; break; default: qTerm += s; qReading += s; } }); qTerm += ender; qReading += ender; const result = await invoke("search_yomitan", { qTerm, qReading, limit: 10, offset: isNew ? 0 : entries.length, }); if (isNew) { const [s1, prefix = "", s2 = ""] = q.split(/^([\*\?\[\]\~]*)/); const text = s1 || s2; if (!result.length && !isFromSplit && /^[^ ]{3,} $/.test(text)) { const segs = await invoke<{ surface: string }[]>("tokenize", { text, }); if (segs.length > 1) { const newQ = segs.shift()!.surface + " " + segs.map((s) => s.surface).join("") + " "; searchParams_setQ(prefix + newQ); return; } } if (resultScrollRef.current) { resultScrollRef.current.scrollTop = 0; set_isScrollEnd(false); } set_entries(result); } else { set_entries([...entries, ...result]); } return result; } const onEntriesScrollEnd: UIEventHandler = async ({ target }) => { if (!(target instanceof HTMLElement)) return; const { scrollHeight, scrollTop } = target; if ( isScrollEnd || scrollHeight - window.innerHeight / 2 > scrollTop + target.getBoundingClientRect().height ) return; const results = await runSearch(false); if (!results?.length) { target.style.paddingBottom = "50vh"; set_isScrollEnd(true); return; } }; function onSearchboxChange(q: string) { if (isAutoKana) { q = toKana(q, { useObsoleteKana: true, IMEMode: true, customKanaMapping: Object.fromEntries([..."<>{}"].map((c) => [c, c])), }); } set_q(q); } const FURIGANA_REGEX = /^[\p{scx=Hiragana}\p{scx=Katakana}]+$/u; const KANJI_REGEX = /([\p{sc=Han}\p{N}々〆ヵヶ]+)/u; const updateFurigana: CompositionEventHandler = ({ data: compositionData, }) => { if (FURIGANA_REGEX.test(compositionData)) { set_furigana(compositionData); } }; const addFurigana: CompositionEventHandler = ({ data: compositionData, target, }) => { const cleanedFuri = [...furigana.replace(/n/g, "ん")] .map((c) => toHiragana(c, { passRomaji: true })) .join(""); let parts = compositionData.split(KANJI_REGEX); if (parts.length === 1) return; const hiraganaParts = parts.map((p) => [...p].map((c) => toHiragana(c, { passRomaji: true })).join(""), ); const regex = new RegExp( `^${hiraganaParts.map((p, idx) => `(${idx & 1 ? ".+" : p})`).join("")}$`, ); let rt: (string | null)[] = furigana.match(regex) || []; if (!rt.length) { parts = [compositionData]; rt = [null, cleanedFuri]; } rt.shift(); const markup = parts .map((p, idx) => (idx & 1 ? `<${p}>{${rt[idx]}}` : p)) .join(""); set_q((q) => { if (!(target instanceof HTMLInputElement)) return markup; let { selectionStart } = target; selectionStart = selectionStart || 0; if (selectionStart < compositionData.length) return markup; return ( q.substring(0, selectionStart - compositionData.length) + markup + q.substring(selectionStart) ); }); }; const clearButton = q.trim() ? ( { searchParams_setQ(q); searchParams_setQ(""); set_q(""); searchboxRef.current ? searchboxRef.current.focus() : null; }} /> ) : null; function onTermClicked(t: string) { searchParams_setQ(q); searchParams_setQ(t + " "); } function JSONdumpClean(o: Record) { const p = Object.entries(o).filter(([, v]) => v || v === 0); return p.length ? JSON.stringify(Object.fromEntries(p), null, 1) : null; } return (
{ e.preventDefault(); }} > onSearchboxChange(e.currentTarget.value)} onCompositionUpdate={updateFurigana} onCompositionEnd={addFurigana} autoComplete="off" spellCheck={false} placeholder="Search..." /> {q.includes(" ") ? ( Space (" ") to mark word boundary for search ) : null} {[..."*?[]"].some((c) => q.includes(c)) ? ( {"*?[]"} can be used as glob ) : null}
set_isAutoKana(d.checked)} > Auto-convert Kana set_isMonitorclipboard(d.checked)} > Monitor clipboard
{entries.map( ({ term, reading, dict_title, glossary_json, ...it }, i) => (
  • onTermClicked( reading && reading !== term ? `<${term}>{${reading}}` : term, ) } > {term} {reading && reading !== term ? ( <> {"("} onTermClicked(reading)}>{reading} {")"} ) : null} 【{dict_title}】 {JSONdumpClean(it)}
    {JSON.parse(glossary_json).map((g: any, i: number) => ( ))}
  • ), )}
    ); } export default SearchPage;