1- import { useState , useCallback , useRef , useEffect } from "react" ;
1+ import { useState , useCallback , useRef , useEffect , type ReactNode } from "react" ;
2+ import {
3+ Duckling ,
4+ type RenderMapFn ,
5+ } from "@claudiu-ceia/ts-duckling" ;
26import { Header } from "./components/Header" ;
37import { ParserSidebar } from "./components/ParserSidebar" ;
48import { AnnotatedText } from "./components/AnnotatedText" ;
59import { Hovercard } from "./components/Hovercard" ;
6- import { extract , stopWorker , type EntityResult } from "./worker-client" ;
10+ import { extract , type ExtractResult } from "./extract-client" ;
11+ import type { EntityResult } from "./types" ;
712import { PARSER_PRIORITY , PRESETS , ALL_IDS } from "./registry" ;
8- import { fmtDuration , loadSelection , saveSelection } from "./utils" ;
13+ import { fmtDuration , loadSelection , saveSelection , kindClasses } from "./utils" ;
914import { loadUrlText } from "./fetch-url" ;
15+ import { registry } from "./parsers" ;
1016
1117export function App ( ) {
1218 const [ selected , setSelected ] = useState ( ( ) => loadSelection ( ALL_IDS ) ) ;
1319 const [ input , setInput ] = useState ( "" ) ;
1420 const [ entities , setEntities ] = useState < EntityResult [ ] > ( [ ] ) ;
21+ const [ segments , setSegments ] = useState < ( string | ReactNode ) [ ] > ( [ ] ) ;
1522 const [ timing , setTiming ] = useState ( "" ) ;
1623 const [ status , setStatus ] = useState ( "" ) ;
1724 const [ spinning , setSpinning ] = useState ( false ) ;
@@ -22,46 +29,143 @@ export function App() {
2229 const [ url , setUrl ] = useState ( "" ) ;
2330 const [ loading , setLoading ] = useState ( false ) ;
2431
25- // Hovercard state
2632 const [ hovercardEntities , setHovercardEntities ] = useState < EntityResult [ ] > ( [ ] ) ;
2733 const [ hovercardAnchor , setHovercardAnchor ] = useState < HTMLElement | null > ( null ) ;
2834
29- const reqIdRef = useRef ( 0 ) ;
35+ const abortRef = useRef < AbortController | null > ( null ) ;
3036 const timerRef = useRef < ReturnType < typeof setTimeout > > ( undefined ) ;
37+ const inputSnapshotRef = useRef ( "" ) ;
38+ const elapsedRef = useRef < ReturnType < typeof setInterval > > ( undefined ) ;
39+ const [ elapsed , setElapsed ] = useState ( "" ) ;
3140
3241 const onSelectionChange = useCallback ( ( next : Set < string > ) => {
3342 setSelected ( next ) ;
3443 saveSelection ( next ) ;
3544 } , [ ] ) ;
3645
37- const doExtract = useCallback ( ( ) => {
46+ const cancelExtract = useCallback ( ( ) => {
47+ abortRef . current ?. abort ( ) ;
48+ abortRef . current = null ;
49+ } , [ ] ) ;
50+
51+ // Build renderMapAsync callback that produces highlighted React spans
52+ const buildSegments = useCallback (
53+ async ( text : string , allEntities : EntityResult [ ] , ids : string [ ] ) => {
54+ const parsers = ids . map ( ( id ) => registry [ id ] ) . filter ( Boolean ) ;
55+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56+ const d = parsers . length > 0 ? Duckling ( parsers as any ) : Duckling ( ) ;
57+
58+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59+ const mapFn : RenderMapFn < any , ReactNode > = ( {
60+ entity,
61+ children,
62+ } : { entity : EntityResult ; children : ( string | ReactNode ) [ ] } ) => {
63+ // Collect all entities whose range overlaps this entity's range
64+ const overlapping = allEntities . filter (
65+ ( e ) => e . start >= entity . start && e . end <= entity . end ,
66+ ) ;
67+ const hasChildren = overlapping . length > 1 ;
68+
69+ return (
70+ < span
71+ key = { `${ entity . start } -${ entity . end } -${ entity . kind } ` }
72+ className = { `ent relative cursor-pointer rounded-md px-1 py-0.5 text-slate-900 outline-1 ${ kindClasses ( entity . kind ) } ${ hasChildren ? " border-b-2 border-dashed border-slate-400/40" : "" } ` }
73+ onClick = { ( ev : React . MouseEvent < HTMLSpanElement > ) => {
74+ ev . stopPropagation ( ) ;
75+ // Show all entities contained within this span
76+ const group = allEntities . filter (
77+ ( e ) => e . start >= entity . start && e . end <= entity . end ,
78+ ) ;
79+ group . sort (
80+ ( a , b ) => ( b . end - b . start ) - ( a . end - a . start ) ,
81+ ) ;
82+ setHovercardEntities ( group ) ;
83+ setHovercardAnchor ( ev . currentTarget ) ;
84+ } }
85+ >
86+ { children }
87+ </ span >
88+ ) ;
89+ } ;
90+
91+ return await d . renderMapAsync < ReactNode > ( text , mapFn ) ;
92+ } ,
93+ [ ] ,
94+ ) ;
95+
96+ const doExtract = useCallback ( async ( ) => {
3897 if ( ! input . trim ( ) ) {
3998 setEntities ( [ ] ) ;
99+ setSegments ( [ ] ) ;
40100 setTiming ( "" ) ;
101+ setElapsed ( "" ) ;
41102 setStatus ( "" ) ;
42103 setSpinning ( false ) ;
43104 return ;
44105 }
45106
46- const id = ++ reqIdRef . current ;
107+ cancelExtract ( ) ;
108+ const controller = new AbortController ( ) ;
109+ abortRef . current = controller ;
110+
111+ // Snapshot the input so stale results don't override later edits
112+ inputSnapshotRef . current = input ;
113+
47114 const max = fullText ? 0 : Math . max ( 500 , maxChars ) ;
48115 const ids = PARSER_PRIORITY . filter ( ( p ) => selected . has ( p ) ) ;
49116
50- stopWorker ( ) ;
51117 setStatus ( "Parsing…" ) ;
52118 setSpinning ( true ) ;
53119 setHovercardEntities ( [ ] ) ;
54120 setHovercardAnchor ( null ) ;
55121
56- extract ( { reqId : id , text : input , ids, maxChars : max } , ( resp ) => {
57- if ( resp . reqId !== id ) return ;
58- setEntities ( resp . entities ) ;
59- const suffix = resp . truncated ? ` (prefix ${ resp . length } chars)` : "" ;
60- setTiming ( `${ fmtDuration ( resp . ms ) } ${ suffix } ` ) ;
122+ // Live elapsed timer
123+ const t0 = performance . now ( ) ;
124+ clearInterval ( elapsedRef . current ) ;
125+ setElapsed ( "0ms" ) ;
126+ elapsedRef . current = setInterval ( ( ) => {
127+ setElapsed ( fmtDuration ( performance . now ( ) - t0 ) ) ;
128+ } , 50 ) ;
129+
130+ try {
131+ const result : ExtractResult = await extract ( {
132+ text : input ,
133+ ids,
134+ maxChars : max ,
135+ signal : controller . signal ,
136+ } ) ;
137+
138+ if ( controller . signal . aborted ) return ;
139+
140+ // If the user changed input while we were parsing, discard stale results
141+ if ( inputSnapshotRef . current !== input ) return ;
142+
143+ setEntities ( result . entities ) ;
144+
145+ const truncatedInput = max > 0 && input . length > max
146+ ? input . slice ( 0 , max )
147+ : input ;
148+ const segs = await buildSegments ( truncatedInput , result . entities , ids ) ;
149+ if ( controller . signal . aborted ) return ;
150+
151+ setSegments ( segs ) ;
152+ const suffix = result . truncated
153+ ? ` (prefix ${ result . length } chars)`
154+ : "" ;
155+ setTiming ( `${ fmtDuration ( result . ms ) } ${ suffix } ` ) ;
61156 setStatus ( "" ) ;
157+ } catch ( err ) {
158+ if ( err instanceof DOMException && err . name === "AbortError" ) return ;
159+ const msg = err instanceof Error ? err . message : String ( err ) ;
160+ console . error ( "[ts-duckling] extraction error:" , err ) ;
161+ setStatus ( `Error: ${ msg . length > 120 ? msg . slice ( 0 , 120 ) + "…" : msg } ` ) ;
162+ setTimeout ( ( ) => setStatus ( "" ) , 5000 ) ;
163+ } finally {
164+ clearInterval ( elapsedRef . current ) ;
165+ setElapsed ( "" ) ;
62166 setSpinning ( false ) ;
63- } ) ;
64- } , [ input , selected , maxChars , fullText ] ) ;
167+ }
168+ } , [ input , selected , maxChars , fullText , cancelExtract , buildSegments ] ) ;
65169
66170 // Debounced auto-extract
67171 useEffect ( ( ) => {
@@ -80,7 +184,7 @@ export function App() {
80184 setLoading ( true ) ;
81185 setStatus ( "Fetching…" ) ;
82186 setSpinning ( true ) ;
83- stopWorker ( ) ;
187+ cancelExtract ( ) ;
84188 try {
85189 const result = await loadUrlText ( url ) ;
86190 setInput ( result . text ) ;
@@ -89,32 +193,32 @@ export function App() {
89193 setTimeout ( ( ) => setStatus ( "" ) , 1500 ) ;
90194 } catch {
91195 setStatus ( "Load failed (likely CORS)" ) ;
196+ setSpinning ( false ) ;
92197 setTimeout ( ( ) => setStatus ( "" ) , 2500 ) ;
93198 } finally {
94199 setLoading ( false ) ;
95- setSpinning ( false ) ;
200+ // Don't reset spinning here — doExtract will be triggered by setInput
201+ // and will manage spinning on its own
96202 }
97203 } ;
98204
99- const handleEntityClick = ( entity : EntityResult , el : HTMLElement ) => {
100- const group = entities . filter ( ( e ) => e . start === entity . start ) ;
101- group . sort ( ( a , b ) => ( b . end - b . start ) - ( a . end - a . start ) ) ;
102- setHovercardEntities ( group ) ;
103- setHovercardAnchor ( el ) ;
104- } ;
105-
106205 const handleStop = ( ) => {
107- stopWorker ( ) ;
206+ cancelExtract ( ) ;
207+ clearInterval ( elapsedRef . current ) ;
208+ setElapsed ( "" ) ;
108209 setStatus ( "Stopped" ) ;
109210 setSpinning ( false ) ;
110211 setTimeout ( ( ) => setStatus ( "" ) , 900 ) ;
111212 } ;
112213
113214 const handleClear = ( ) => {
114- stopWorker ( ) ;
215+ cancelExtract ( ) ;
216+ clearInterval ( elapsedRef . current ) ;
217+ setElapsed ( "" ) ;
115218 setInput ( "" ) ;
116219 setUrl ( "" ) ;
117220 setEntities ( [ ] ) ;
221+ setSegments ( [ ] ) ;
118222 setTiming ( "" ) ;
119223 setStatus ( "" ) ;
120224 setSpinning ( false ) ;
@@ -233,8 +337,9 @@ export function App() {
233337 < textarea
234338 value = { input }
235339 onChange = { ( e ) => { setInput ( e . target . value ) ; setPreset ( "" ) ; } }
340+ disabled = { spinning }
236341 spellCheck = { false }
237- className = "mt-4 h-56 w-full resize-y rounded-2xl border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 shadow-sm focus:border-teal-300 focus:outline-none focus:ring-4 focus:ring-teal-200/40"
342+ className = "mt-4 h-56 w-full resize-y rounded-2xl border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 shadow-sm focus:border-teal-300 focus:outline-none focus:ring-4 focus:ring-teal-200/40 disabled:bg-slate-50 disabled:text-slate-500 disabled:cursor-not-allowed "
238343 placeholder = "Try: Email me at no-reply+foo@some.domain.dev, call +14155552671, visit https://duckling.deno.dev/, SSN 123-45-6789, CC 4242 4242 4242 4242"
239344 />
240345 </ div >
@@ -246,7 +351,13 @@ export function App() {
246351 < h2 className = "text-sm font-semibold tracking-wide text-slate-900" > Preview</ h2 >
247352 < div className = "flex items-center gap-3 text-sm text-slate-500" >
248353 < span > { entities . length } matches</ span >
249- { timing && (
354+ { elapsed && (
355+ < >
356+ < span className = "text-slate-300" > •</ span >
357+ < span className = "font-mono text-teal-600 tabular-nums" > { elapsed } </ span >
358+ </ >
359+ ) }
360+ { timing && ! elapsed && (
250361 < >
251362 < span className = "text-slate-300" > •</ span >
252363 < span className = "font-mono text-slate-600" > { timing } </ span >
@@ -268,11 +379,7 @@ export function App() {
268379 Annotated text
269380 </ div >
270381 < div className = "mt-2" >
271- < AnnotatedText
272- text = { input }
273- entities = { entities }
274- onEntityClick = { handleEntityClick }
275- />
382+ < AnnotatedText segments = { segments . length > 0 ? segments : [ input ] } />
276383 </ div >
277384
278385 { showJson && (
0 commit comments