@@ -10,16 +10,22 @@ import {
1010 Suspense ,
1111 Show ,
1212} from "solid-js" ;
13+
1314import 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
1518import { TextField , TextFieldTextArea } from "./ui/TextField" ;
19+ import { Button } from "./ui/Button" ;
1620import {
1721 DropdownMenu ,
1822 DropdownMenuContent ,
1923 DropdownMenuItem ,
2024 DropdownMenuTrigger ,
2125} from "./ui/DropdownMenu" ;
2226
27+ import { createStorageSignal } from "~/utils" ;
28+
2329type 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
4352async 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
9493export 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