Skip to content

Commit f26c558

Browse files
committed
add jisho, keyboard stuff etc
1 parent 5dbf0e3 commit f26c558

File tree

1 file changed

+315
-14
lines changed

1 file changed

+315
-14
lines changed

src/components/IMEField.tsx

Lines changed: 315 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,330 @@
11
import * as wanakana from "wanakana";
2-
import { createSignal, onMount } from "solid-js";
2+
import {
3+
createSignal,
4+
createResource,
5+
createEffect,
6+
createMemo,
7+
For,
8+
onMount,
9+
onCleanup,
10+
Suspense,
11+
Show,
12+
} from "solid-js";
13+
import SvgSpinners180Ring from "~icons/svg-spinners/180-ring";
314

415
import { TextField, TextFieldTextArea } from "./ui/TextField";
16+
import {
17+
DropdownMenu,
18+
DropdownMenuContent,
19+
DropdownMenuItem,
20+
DropdownMenuTrigger,
21+
} from "./ui/DropdownMenu";
22+
23+
type JishoJapanese = { word?: string; reading: string };
24+
type JishoEntry = { japanese: JishoJapanese[] };
25+
type JishoResponse = { data: JishoEntry[] };
26+
27+
type LastConversion = {
28+
confirmed: string;
29+
reading: string;
30+
start: number;
31+
end: number;
32+
};
33+
34+
async function fetchKanjiFromJisho(reading: string): Promise<string[]> {
35+
if (!reading) return [];
36+
const jishoUrl = "https://jisho.org/api/v1/search/words?keyword=" + encodeURIComponent(reading);
37+
const proxyUrl = "https://thingproxy.freeboard.io/fetch/" + encodeURIComponent(jishoUrl);
38+
try {
39+
const res = await fetch(proxyUrl);
40+
if (!res.ok) return [];
41+
const json = (await res.json()) as JishoResponse;
42+
if (!json?.data) return [];
43+
const set = new Set(json.data.map((e) => e.japanese[0].word || e.japanese[0].reading));
44+
return Array.from(set);
45+
} catch {
46+
return [];
47+
}
48+
}
49+
50+
const Spinner = () => (
51+
<div class="flex items-center justify-center p-2">
52+
<SvgSpinners180Ring class="size-5 animate-spin" />
53+
</div>
54+
);
555

656
export function IMEField() {
757
const [input, setInput] = createSignal("");
58+
const [compositionStart, setCompositionStart] = createSignal(0);
59+
const [lookupReading, setLookupReading] = createSignal<string | null>(null);
60+
const [suggestions] = createResource(lookupReading, fetchKanjiFromJisho, {
61+
initialValue: [],
62+
});
63+
const [selectedIndex, setSelectedIndex] = createSignal(0);
64+
const [isMenuOpen, setIsMenuOpen] = createSignal(false);
65+
const [confirmedIndex, setConfirmedIndex] = createSignal(0);
66+
const [isComposing, setIsComposing] = createSignal(false);
67+
const [lastConversion, setLastConversion] = createSignal<LastConversion | null>(null);
868

9-
let textArea: HTMLTextAreaElement | undefined;
69+
let ta: HTMLTextAreaElement | undefined;
70+
let itemRefs: HTMLDivElement[] = [];
71+
let listRef: HTMLDivElement | undefined;
72+
73+
const confirmedText = createMemo(() => input().slice(0, confirmedIndex()));
74+
const unconfirmedText = createMemo(() => input().slice(confirmedIndex()));
1075

1176
onMount(() => {
12-
if (!textArea) {
13-
console.error("TextArea not found, could not bind wanakana");
14-
return;
77+
if (ta) {
78+
wanakana.bind(ta);
79+
ta.value = "";
80+
setInput("");
81+
ta.focus();
1582
}
16-
wanakana.bind(textArea);
1783
});
1884

85+
onCleanup(() => {
86+
if (ta) wanakana.unbind(ta);
87+
});
88+
89+
createEffect(() => {
90+
suggestions();
91+
itemRefs = [];
92+
setSelectedIndex(0);
93+
});
94+
95+
function commitSuggestion(idx: number) {
96+
const val = input();
97+
const start = compositionStart();
98+
const cands = suggestions();
99+
const reading = lookupReading();
100+
if (!reading) return;
101+
const cand = idx >= 0 && cands[idx] ? cands[idx] : reading;
102+
const before = val.slice(0, start);
103+
const after = val.slice(start + reading.length);
104+
const newVal = before + cand + after;
105+
setInput(newVal);
106+
const newPos = before.length + cand.length;
107+
if (ta) {
108+
ta.value = newVal;
109+
ta.setSelectionRange(newPos, newPos);
110+
}
111+
setLastConversion({
112+
confirmed: cand,
113+
reading,
114+
start,
115+
end: newPos,
116+
});
117+
setLookupReading(null);
118+
setSelectedIndex(0);
119+
setIsMenuOpen(false);
120+
setIsComposing(false);
121+
setConfirmedIndex(newPos);
122+
setTimeout(() => ta?.focus(), 0);
123+
}
124+
125+
function handleKeyDown(
126+
e: KeyboardEvent & {
127+
currentTarget: HTMLTextAreaElement;
128+
}
129+
) {
130+
if (isMenuOpen()) {
131+
const len = suggestions().length;
132+
if (len > 0) {
133+
if (e.key === "ArrowDown") {
134+
e.preventDefault();
135+
const newIndex = (selectedIndex() + 1) % len;
136+
setSelectedIndex(newIndex);
137+
const item = itemRefs[newIndex];
138+
const container = listRef;
139+
if (item && container) {
140+
const itop = item.offsetTop;
141+
const ibot = itop + item.offsetHeight;
142+
const ctop = container.scrollTop;
143+
const cheight = container.clientHeight;
144+
if (ibot > ctop + cheight) {
145+
container.scrollTop = ibot - cheight;
146+
} else if (itop < ctop) {
147+
container.scrollTop = itop;
148+
}
149+
}
150+
return;
151+
}
152+
if (e.key === "ArrowUp") {
153+
e.preventDefault();
154+
const newIndex = (selectedIndex() - 1 + len) % len;
155+
setSelectedIndex(newIndex);
156+
const item = itemRefs[newIndex];
157+
const container = listRef;
158+
if (item && container) {
159+
const itop = item.offsetTop;
160+
const ibot = itop + item.offsetHeight;
161+
const ctop = container.scrollTop;
162+
const cheight = container.clientHeight;
163+
if (itop < ctop) {
164+
container.scrollTop = itop;
165+
} else if (ibot > ctop + cheight) {
166+
container.scrollTop = ibot - cheight;
167+
}
168+
}
169+
return;
170+
}
171+
}
172+
if (e.key === "Enter" || e.key === " ") {
173+
e.preventDefault();
174+
commitSuggestion(selectedIndex());
175+
return;
176+
}
177+
}
178+
179+
if (
180+
e.key === "Backspace" &&
181+
lastConversion() &&
182+
e.currentTarget.selectionStart === lastConversion()!.end &&
183+
e.currentTarget.selectionEnd === lastConversion()!.end
184+
) {
185+
e.preventDefault();
186+
const conv = lastConversion()!;
187+
const before = input().slice(0, conv.start);
188+
const after = input().slice(conv.end);
189+
const newVal = before + conv.reading + after;
190+
setInput(newVal);
191+
const newPos = conv.start + conv.reading.length;
192+
if (ta) {
193+
ta.value = newVal;
194+
ta.setSelectionRange(newPos, newPos);
195+
}
196+
setConfirmedIndex(conv.start);
197+
setIsComposing(true);
198+
setLastConversion(null);
199+
return;
200+
}
201+
202+
if (e.key === "Enter" && isComposing()) {
203+
e.preventDefault();
204+
setIsComposing(false);
205+
setConfirmedIndex(e.currentTarget.selectionStart);
206+
setLastConversion(null);
207+
return;
208+
}
209+
210+
if (e.key === " " && e.shiftKey && isComposing()) {
211+
e.preventDefault();
212+
const start = confirmedIndex();
213+
const pos = e.currentTarget.selectionStart;
214+
const reading = input().slice(start, pos);
215+
if (wanakana.isHiragana(reading)) {
216+
const katakana = wanakana.toKatakana(reading);
217+
const before = input().slice(0, start);
218+
const after = input().slice(pos);
219+
const newVal = before + katakana + after;
220+
setInput(newVal);
221+
const end = before.length + katakana.length;
222+
if (ta) {
223+
ta.value = newVal;
224+
ta.setSelectionRange(end, end);
225+
}
226+
setLastConversion({
227+
confirmed: katakana,
228+
reading,
229+
start,
230+
end,
231+
});
232+
setConfirmedIndex(end);
233+
setIsComposing(false);
234+
}
235+
return;
236+
}
237+
238+
if (e.key === " ") {
239+
const pos = e.currentTarget.selectionStart;
240+
const reading = input().slice(confirmedIndex(), pos);
241+
if (wanakana.isHiragana(reading) && reading.length) {
242+
e.preventDefault();
243+
setCompositionStart(confirmedIndex());
244+
setLookupReading(reading);
245+
setSelectedIndex(0);
246+
setIsMenuOpen(true);
247+
return;
248+
}
249+
}
250+
}
251+
252+
function handleInput(
253+
e: InputEvent & {
254+
currentTarget: HTMLTextAreaElement;
255+
}
256+
) {
257+
const val = e.currentTarget.value;
258+
setInput(val);
259+
setIsComposing(val.length > confirmedIndex());
260+
setLastConversion(null);
261+
if (isMenuOpen()) {
262+
setIsMenuOpen(false);
263+
setLookupReading(null);
264+
}
265+
}
266+
19267
return (
20-
<TextField class="w-full">
21-
<TextFieldTextArea
22-
autoResize
23-
placeholder="Type your message here"
24-
ref={textArea}
25-
onInput={(e) => setInput(e.currentTarget.value)}
26-
/>
27-
</TextField>
268+
<div class="relative w-full">
269+
<DropdownMenu open={isMenuOpen()} onOpenChange={setIsMenuOpen} placement="bottom-start">
270+
<DropdownMenuTrigger as="div" class="w-full outline-none" disabled>
271+
<TextField>
272+
<div class="relative w-full">
273+
<div
274+
aria-hidden="true"
275+
class="pointer-events-none absolute inset-0 px-3 py-2 text-base whitespace-pre-wrap select-none">
276+
<span>{confirmedText()}</span>
277+
<span class="border-b border-dotted border-current">{unconfirmedText()}</span>
278+
</div>
279+
<TextFieldTextArea
280+
autoResize
281+
placeholder="Type here..."
282+
ref={ta}
283+
value={input()}
284+
onInput={handleInput}
285+
onKeyDown={handleKeyDown}
286+
class="caret-foreground bg-transparent text-transparent"
287+
/>
288+
</div>
289+
</TextField>
290+
</DropdownMenuTrigger>
291+
<DropdownMenuContent
292+
onCloseAutoFocus={(e) => {
293+
e.preventDefault();
294+
ta?.focus();
295+
}}
296+
class="w-[var(--kb-popper-content-width)]">
297+
<Suspense fallback={<Spinner />}>
298+
<Show
299+
when={suggestions()?.length > 0}
300+
fallback={
301+
<div class="text-muted-foreground px-2 py-1.5 text-sm">No results found.</div>
302+
}>
303+
<>
304+
<div ref={listRef} class="max-h-[13rem] overflow-y-auto">
305+
<For each={suggestions()}>
306+
{(s, idx) => (
307+
<DropdownMenuItem
308+
ref={(el) => (itemRefs[idx()] = el)}
309+
onSelect={() => commitSuggestion(idx())}
310+
onFocus={() => setSelectedIndex(idx())}
311+
data-highlighted={selectedIndex() === idx()}
312+
class="data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground scroll-m-1">
313+
{s}
314+
</DropdownMenuItem>
315+
)}
316+
</For>
317+
</div>
318+
<div class="text-muted-foreground flex items-center justify-end border-t px-2 py-1.5 text-xs">
319+
<span>
320+
{selectedIndex() + 1} / {suggestions().length}
321+
</span>
322+
</div>
323+
</>
324+
</Show>
325+
</Suspense>
326+
</DropdownMenuContent>
327+
</DropdownMenu>
328+
</div>
28329
);
29330
}

0 commit comments

Comments
 (0)