Skip to content

Commit 1ed5adf

Browse files
committed
copy button, better keyshortcuts, caching and more
1 parent 6e6627c commit 1ed5adf

File tree

1 file changed

+118
-104
lines changed

1 file changed

+118
-104
lines changed

src/components/IMEField.tsx

Lines changed: 118 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,22 @@ import {
1010
Suspense,
1111
Show,
1212
} from "solid-js";
13+
1314
import SvgSpinners180Ring from "~icons/svg-spinners/180-ring";
15+
import CopyIcon from "~icons/material-symbols/content-copy";
16+
import CheckIcon from "~icons/material-symbols/check";
1417

1518
import { TextField, TextFieldTextArea } from "./ui/TextField";
19+
import { Button } from "./ui/Button";
1620
import {
1721
DropdownMenu,
1822
DropdownMenuContent,
1923
DropdownMenuItem,
2024
DropdownMenuTrigger,
2125
} from "./ui/DropdownMenu";
2226

27+
import { createStorageSignal } from "~/utils";
28+
2329
type JishoJapanese = {
2430
word?: string;
2531
reading: string;
@@ -38,49 +44,42 @@ type LastConversion = {
3844
end: number;
3945
};
4046

41-
const JISHO_PROXY_BASE = "https://cors-anywhere.com/";
47+
const [jishoCache, setJishoCache] = createStorageSignal<Record<string, string[]>>(
48+
"jisho-cache",
49+
{}
50+
);
4251

4352
async function fetchKanjiFromJisho(reading: string): Promise<string[]> {
44-
if (!reading) {
45-
return [];
46-
}
53+
if (!reading) return [];
4754

48-
const jishoTargetUrl =
49-
"https://jisho.org/api/v1/search/words?keyword=" + encodeURIComponent(reading);
55+
const cache = jishoCache();
56+
if (cache[reading]) {
57+
return cache[reading];
58+
}
5059

51-
const proxyUrl = JISHO_PROXY_BASE + jishoTargetUrl;
60+
const JISHO_PROXY_BASE = "https://cors-anywhere.com/";
61+
const jishoUrl = "https://jisho.org/api/v1/search/words?keyword=" + encodeURIComponent(reading);
5262

63+
const proxyUrl = JISHO_PROXY_BASE + jishoUrl;
5364
const hiragana = wanakana.toHiragana(reading);
5465
const katakana = wanakana.toKatakana(reading);
5566

5667
try {
5768
const res = await fetch(proxyUrl);
58-
5969
if (!res.ok) {
60-
console.error(
61-
`Error fetching from CORS Anywhere proxy: ${res.status} ` + `${res.statusText}`
62-
);
63-
try {
64-
const errorText = await res.text();
65-
console.error("Proxy error response:", errorText);
66-
} catch (e) {
67-
console.error("Could not read error response text:", e);
68-
}
70+
console.error(`Error fetching from CORS proxy: ${res.status} ${res.statusText}`);
6971
return [hiragana, katakana];
7072
}
71-
7273
const json = (await res.json()) as JishoResponse;
74+
if (!json?.data) return [hiragana, katakana];
7375

74-
if (!json?.data) {
75-
return [hiragana, katakana];
76-
}
77-
78-
const uniqueWords = new Set(json.data.map((e) => e.japanese[0].word || e.japanese[0].reading));
76+
const unique = new Set(json.data.map((e) => e.japanese[0].word || e.japanese[0].reading));
77+
const results = [...new Set([hiragana, katakana, ...Array.from(unique)])];
7978

80-
const results = [hiragana, katakana, ...Array.from(uniqueWords)];
81-
return [...new Set(results)];
82-
} catch (error) {
83-
console.error("Error in fetchKanjiFromJisho:", error);
79+
setJishoCache((prev) => ({ ...prev, [reading]: results }));
80+
return results;
81+
} catch (e) {
82+
console.error("fetchKanjiFromJisho error", e);
8483
return [hiragana, katakana];
8584
}
8685
}
@@ -92,17 +91,18 @@ const Spinner = () => (
9291
);
9392

9493
export function IMEField() {
95-
const [input, setInput] = createSignal("");
96-
const [compositionStart, setCompositionStart] = createSignal(0);
9794
const [lookupReading, setLookupReading] = createSignal<string | null>(null);
9895
const [suggestions] = createResource(lookupReading, fetchKanjiFromJisho, {
9996
initialValue: [],
10097
});
98+
const [input, setInput] = createSignal("");
99+
const [compositionStart, setCompositionStart] = createSignal(0);
101100
const [selectedIndex, setSelectedIndex] = createSignal(0);
102101
const [isMenuOpen, setIsMenuOpen] = createSignal(false);
103102
const [confirmedIndex, setConfirmedIndex] = createSignal(0);
104103
const [isComposing, setIsComposing] = createSignal(false);
105-
const [lastConversion, setLastConversion] = createSignal<LastConversion | null>(null);
104+
const [conversionHistory, setConversionHistory] = createSignal<LastConversion[]>([]);
105+
const [copied, setCopied] = createSignal(false);
106106

107107
let ta: HTMLTextAreaElement | undefined;
108108
let listRef: HTMLDivElement | undefined;
@@ -130,24 +130,17 @@ export function IMEField() {
130130
});
131131

132132
createEffect(() => {
133-
const index = selectedIndex();
134133
if (!isMenuOpen() || !listRef || itemRefs.length === 0) return;
135-
136-
const item = itemRefs[index];
137-
const container = listRef;
138-
139-
if (item && container) {
140-
const itemTop = item.offsetTop;
141-
const itemBottom = itemTop + item.offsetHeight;
142-
const containerTop = container.scrollTop;
143-
const containerHeight = container.clientHeight;
144-
145-
if (itemBottom > containerTop + containerHeight) {
146-
container.scrollTop = itemBottom - containerHeight;
147-
} else if (itemTop < containerTop) {
148-
container.scrollTop = itemTop;
149-
}
150-
}
134+
const idx = selectedIndex();
135+
const item = itemRefs[idx];
136+
const container = listRef!;
137+
if (!item) return;
138+
const it = item.offsetTop,
139+
ib = it + item.offsetHeight,
140+
ct = container.scrollTop,
141+
ch = container.clientHeight;
142+
if (ib > ct + ch) container.scrollTop = ib - ch;
143+
else if (it < ct) container.scrollTop = it;
151144
});
152145

153146
function commitSuggestion(idx: number) {
@@ -166,12 +159,7 @@ export function IMEField() {
166159
ta.value = newVal;
167160
ta.setSelectionRange(newPos, newPos);
168161
}
169-
setLastConversion({
170-
confirmed: cand,
171-
reading,
172-
start,
173-
end: newPos,
174-
});
162+
setConversionHistory((prev) => [...prev, { confirmed: cand, reading, start, end: newPos }]);
175163
setLookupReading(null);
176164
setSelectedIndex(0);
177165
setIsMenuOpen(false);
@@ -180,16 +168,18 @@ export function IMEField() {
180168
setTimeout(() => ta?.focus(), 0);
181169
}
182170

183-
function handleKeyDown(
184-
e: KeyboardEvent & {
185-
currentTarget: HTMLTextAreaElement;
186-
}
187-
) {
171+
function handleKeyDown(e: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) {
188172
if (isMenuOpen()) {
173+
if (e.key === "Enter") {
174+
e.preventDefault();
175+
commitSuggestion(selectedIndex());
176+
}
189177
return;
190178
}
191179

192-
const lc = lastConversion();
180+
const history = conversionHistory();
181+
const lc = history.length > 0 ? history[history.length - 1] : null;
182+
193183
if (
194184
e.key === "Backspace" &&
195185
lc &&
@@ -208,39 +198,32 @@ export function IMEField() {
208198
}
209199
setConfirmedIndex(lc.start);
210200
setIsComposing(true);
211-
setLastConversion(null);
201+
setConversionHistory((prev) => prev.slice(0, -1));
212202
return;
213203
}
214204

215205
if (e.key === "Enter" && isComposing()) {
216206
e.preventDefault();
207+
const end = e.currentTarget.selectionStart;
208+
setConfirmedIndex(end);
217209
setIsComposing(false);
218-
setConfirmedIndex(e.currentTarget.selectionStart);
219-
setLastConversion(null);
220-
return;
210+
setConversionHistory([]);
221211
}
222212
}
223213

224-
function handleInput(
225-
e: InputEvent & {
226-
currentTarget: HTMLTextAreaElement;
227-
}
228-
) {
214+
function handleInput(e: InputEvent & { currentTarget: HTMLTextAreaElement }) {
229215
const val = e.currentTarget.value;
230216
const pos = e.currentTarget.selectionStart;
231-
232217
if (isComposing() && e.inputType === "insertText" && (e.data === " " || e.data === null)) {
233218
const start = confirmedIndex();
234219
const reading = val.slice(start, pos - 1);
235-
236220
if (wanakana.isHiragana(reading) && reading.length) {
237221
const newVal = val.slice(0, pos - 1) + val.slice(pos);
238222
setInput(newVal);
239223
if (ta) {
240224
ta.value = newVal;
241225
ta.setSelectionRange(newVal.length, newVal.length);
242226
}
243-
244227
setCompositionStart(start);
245228
setLookupReading(reading);
246229
setSelectedIndex(0);
@@ -250,24 +233,28 @@ export function IMEField() {
250233
}
251234

252235
setInput(val);
253-
setIsComposing(val.length > confirmedIndex());
254-
setLastConversion(null);
255-
}
256236

257-
function handleCompositionStart(
258-
e: CompositionEvent & {
259-
currentTarget: HTMLTextAreaElement;
237+
const lastCommittedCharIndex = confirmedIndex();
238+
if (input().length < lastCommittedCharIndex) {
239+
setConversionHistory([]);
240+
}
241+
242+
let currentConfirmedIndex = confirmedIndex();
243+
244+
if (val.length < currentConfirmedIndex) {
245+
currentConfirmedIndex = val.length;
246+
setConfirmedIndex(currentConfirmedIndex);
260247
}
261-
) {
248+
249+
setIsComposing(val.length > currentConfirmedIndex);
250+
}
251+
252+
function handleCompositionStart(e: CompositionEvent & { currentTarget: HTMLTextAreaElement }) {
262253
setIsComposing(true);
263254
setCompositionStart(e.currentTarget.selectionStart);
264255
}
265256

266-
function handleCompositionEnd(
267-
e: CompositionEvent & {
268-
currentTarget: HTMLTextAreaElement;
269-
}
270-
) {
257+
function handleCompositionEnd(e: CompositionEvent & { currentTarget: HTMLTextAreaElement }) {
271258
setIsComposing(false);
272259
const start = compositionStart();
273260
const pos = e.currentTarget.selectionStart;
@@ -279,12 +266,42 @@ export function IMEField() {
279266
}
280267
}
281268

269+
async function handleCopy() {
270+
try {
271+
await navigator.clipboard.writeText(input());
272+
setCopied(true);
273+
ta?.focus();
274+
setTimeout(() => setCopied(false), 2000);
275+
} catch (err) {
276+
console.error("Clipboard write failed", err);
277+
}
278+
}
279+
282280
return (
283281
<div class="relative w-full">
284282
<DropdownMenu open={isMenuOpen()} onOpenChange={setIsMenuOpen} placement="bottom-start">
285283
<DropdownMenuTrigger as="div" class="w-full outline-none" disabled>
286284
<TextField>
287285
<div class="relative w-full">
286+
<Show when={input().length > 0}>
287+
<Button
288+
onClick={handleCopy}
289+
size="sm"
290+
variant="outline"
291+
class="absolute top-2 right-2 z-20 flex items-center space-x-1">
292+
<Show
293+
when={copied()}
294+
fallback={
295+
<>
296+
<CopyIcon class="h-4 w-4" />
297+
<span>Copy</span>
298+
</>
299+
}>
300+
<CheckIcon class="h-4 w-4" />
301+
<span>Copied!</span>
302+
</Show>
303+
</Button>
304+
</Show>
288305
<div
289306
aria-hidden="true"
290307
class="pointer-events-none absolute inset-0 px-3 py-2 text-base whitespace-pre-wrap select-none">
@@ -312,33 +329,30 @@ export function IMEField() {
312329
onCloseAutoFocus={(e) => {
313330
e.preventDefault();
314331
ta?.focus();
315-
}}
316-
class="w-[var(--kb-popper-content-width)]">
332+
}}>
317333
<Suspense fallback={<Spinner />}>
318334
<Show
319335
when={suggestions()?.length > 0}
320336
fallback={
321337
<div class="text-muted-foreground px-2 py-1.5 text-sm">No results found.</div>
322338
}>
323-
<>
324-
<div ref={listRef} class="max-h-[13rem] overflow-y-auto">
325-
<For each={suggestions()}>
326-
{(s, idx) => (
327-
<DropdownMenuItem
328-
ref={(el) => (itemRefs[idx()] = el)}
329-
onSelect={() => commitSuggestion(idx())}
330-
onFocus={() => setSelectedIndex(idx())}
331-
data-highlighted={selectedIndex() === idx()}
332-
class="data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground scroll-m-1">
333-
{s}
334-
</DropdownMenuItem>
335-
)}
336-
</For>
337-
</div>
338-
<div class="text-muted-foreground flex items-center justify-end border-t px-2 py-1.5 text-xs">
339-
{selectedIndex() + 1} / {suggestions().length}
340-
</div>
341-
</>
339+
<div ref={listRef} class="max-h-[13rem] overflow-y-auto">
340+
<For each={suggestions()}>
341+
{(s, idx) => (
342+
<DropdownMenuItem
343+
ref={(el) => (itemRefs[idx()] = el)}
344+
onSelect={() => commitSuggestion(idx())}
345+
onFocus={() => setSelectedIndex(idx())}
346+
data-highlighted={selectedIndex() === idx()}
347+
class="data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground scroll-m-1">
348+
{s}
349+
</DropdownMenuItem>
350+
)}
351+
</For>
352+
</div>
353+
<div class="text-muted-foreground flex items-center justify-end border-t px-2 py-1.5 text-xs">
354+
{selectedIndex() + 1} / {suggestions().length}
355+
</div>
342356
</Show>
343357
</Suspense>
344358
</DropdownMenuContent>

0 commit comments

Comments
 (0)