dsh-cait 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2377 +1,2408 @@
1
- function Spinner() {
2
- return h('span', { className: 'cait-spinner', 'aria-hidden': true })
3
- }
4
-
5
- // SVG width/height attributes are fixed pixels; route them through the same
6
- // unit as the CSS so icons grow with the panel.
7
- function scaledLength(value) {
8
- return `calc(${value}px * var(${CAIT_UNIT_VAR}, 1))`
9
- }
10
-
11
- function Icon({ name, size = 16 }) {
12
- const paths = {
13
- plus: ['M8 3v10', 'M3 8h10'],
14
- upload: ['M8 11V2', 'M5 5l3-3 3 3', 'M2 12v1.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V12'],
15
- download: ['M8 2v9', 'M5 8l3 3 3-3', 'M2 12v1.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V12'],
16
- settings: ['M2 4h12', 'M2 8h12', 'M2 12h12', 'M5 2.5v3', 'M11 6.5v3', 'M7 10.5v3'],
17
- check: ['M3 8.6l3.1 3.1L13 4.8'],
18
- chevronRight: ['M6 3l5 5-5 5'],
19
- chevronLeft: ['M10 3L5 8l5 5'],
20
- chevronDown: ['M3 6l5 5 5-5'],
21
- search: ['M7.2 12.4a5.2 5.2 0 1 0 0-10.4 5.2 5.2 0 0 0 0 10.4Z', 'M10.9 10.9L14 14'],
22
- sparkles: ['M8 2l1.3 3.7L13 7l-3.7 1.3L8 12l-1.3-3.7L3 7l3.7-1.3L8 2Z', 'M13 12l.6 1.4L15 14l-1.4.6L13 16l-.6-1.4L11 14l1.4-.6L13 12Z'],
23
- book: ['M2 3.5A1.5 1.5 0 0 1 3.5 2H7v12H3.5A1.5 1.5 0 0 1 2 12.5v-9Z', 'M14 3.5A1.5 1.5 0 0 0 12.5 2H9v12h3.5a1.5 1.5 0 0 0 1.5-1.5v-9Z'],
24
- database: ['M8 2c3.3 0 5.5.9 5.5 2s-2.2 2-5.5 2-5.5-.9-5.5-2S4.7 2 8 2Z', 'M2.5 4v8c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2V4', 'M2.5 8c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2'],
25
- close: ['M3 3l10 10', 'M13 3L3 13'],
26
- trash: ['M3 4h10', 'M6 4V2.5h4V4', 'M5 4l.6 9.5h4.8L11 4'],
27
- copy: ['M5.5 5.5h7v8h-7z', 'M3.5 3.5h7v2'],
28
- file: ['M4 1.5h5L13 5v9.5H4z', 'M9 1.5V5h4'],
29
- refresh: ['M13 8a5 5 0 1 1-1.5-3.6', 'M13 2v3h-3'],
30
- arrowRight: ['M2 8h11', 'M9 4l4 4-4 4'],
31
- play: ['M4 2l9 6-9 6z'],
32
- globe: ['M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Z', 'M2.5 8h11', 'M8 2c2 2 2 10 0 12', 'M8 2c-2 2-2 10 0 12'],
33
- alert: ['M8 2L14 13H2L8 2Z', 'M8 6v3.5', 'M8 11.5v.2'],
34
- save: ['M3 2h8l2 2v10H3z', 'M5 2v4h6V2', 'M5 14v-4h6v4'],
35
- languages: ['M2 4h7', 'M5.5 4v2c0 2-1.5 4-3.5 5', 'M4 8c0 1.5 2 3 4.5 3.5', 'M9 13l3-7 3 7', 'M10 11h4'],
36
- }
37
- const shape = paths[name] || paths.file
38
- return h(
39
- 'svg',
40
- {
41
- width: size,
42
- height: size,
43
- viewBox: '0 0 16 16',
44
- fill: 'none',
45
- style: { width: scaledLength(size), height: scaledLength(size), flex: '0 0 auto' },
46
- stroke: 'currentColor',
47
- strokeWidth: 1.6,
48
- strokeLinecap: 'round',
49
- strokeLinejoin: 'round',
50
- 'aria-hidden': true,
51
- focusable: false,
52
- },
53
- shape.map((d, index) => h('path', { key: index, d, ...(name === 'play' ? { fill: 'currentColor', stroke: 'none' } : {}) })),
54
- )
55
- }
56
-
57
- function CaitMark({ size = 16 }) {
58
- return h(
59
- 'svg',
60
- {
61
- width: size,
62
- height: size,
63
- viewBox: '0 0 32 32',
64
- fill: 'none',
65
- style: { width: scaledLength(size), height: scaledLength(size), flex: '0 0 auto' },
66
- 'aria-hidden': true,
67
- focusable: false,
68
- },
69
- h('path', { d: 'M6.3 5.8H15.2C17 5.8 18.4 7.2 18.4 9V14.8C18.4 16.6 17 18 15.2 18H9.3L5.9 21.6V18H6.3C4.5 18 3.1 16.6 3.1 14.8V9C3.1 7.2 4.5 5.8 6.3 5.8Z', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }),
70
- h('path', { d: 'M5.4 16.4 9.2 8.5 13 16.4M6.8 13.4h4.8', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }),
71
- h('path', { d: 'M17.4 14.2H27.4C29 14.2 30.2 15.4 30.2 17V22.2C30.2 23.8 29 25 27.4 25H27.2L27 28.8 23.2 25H17.4C15.8 25 14.6 23.8 14.6 22.2V17C14.6 15.4 15.8 14.2 17.4 14.2Z', fill: 'var(--dsw-alias-bg-base, #ffffff)', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }),
72
- h('circle', { cx: 23, cy: 17.2, r: 1.4, fill: 'currentColor' }),
73
- h('path', { d: 'M18.6 20.6H27.4M23 20.6l-4.2 4.2M23 20.6l4.2 4.2', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }),
74
- )
75
- }
76
-
77
- function CaitSidebarIcon({ size = 18, active }) {
78
- return h(
79
- 'span',
80
- {
81
- style: {
82
- display: 'inline-flex',
83
- alignItems: 'center',
84
- justifyContent: 'center',
85
- width: size,
86
- height: size,
87
- color: active ? 'var(--dsw-alias-brand-primary)' : 'currentColor',
88
- },
89
- 'aria-hidden': true,
90
- },
91
- h(CaitMark, { size }),
92
- )
93
- }
94
-
95
- function highlightSource(text, hits) {
96
- const terms = [...new Set((hits || []).map((hit) => hit.term).filter(Boolean))]
97
- .sort((a, b) => b.length - a.length)
98
- if (!terms.length) return text
99
- const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi')
100
- return String(text).split(pattern).map((part, index) => {
101
- const hit = (hits || []).find((item) => item.term.toLowerCase() === String(part).toLowerCase())
102
- if (!hit) return part
103
- return h('mark', { key: `m${index}`, title: `${hit.term} → ${hit.target}` }, part)
104
- })
105
- }
106
-
107
- function Modal({ title, children, onClose, wide }) {
108
- useEffect(() => {
109
- const onKey = (event) => {
110
- if (event.key === 'Escape') onClose()
111
- }
112
- window.addEventListener('keydown', onKey)
113
- return () => window.removeEventListener('keydown', onKey)
114
- }, [onClose])
115
-
116
- return h(
117
- 'div',
118
- {
119
- className: 'cait-overlay',
120
- onMouseDown: (event) => {
121
- if (event.target === event.currentTarget) onClose()
122
- },
123
- },
124
- h(
125
- 'div',
126
- { className: `cait-modal${wide ? ' cait-modal-wide' : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-label': title },
127
- h(
128
- 'div',
129
- { className: 'cait-modal-head' },
130
- h('h3', null, title),
131
- h('button', { className: 'cait-iconbtn', onClick: onClose, 'aria-label': 'Close' }, h(Icon, { name: 'close', size: 14 })),
132
- ),
133
- h('div', { className: 'cait-modal-body' }, children),
134
- ),
135
- )
136
- }
137
-
138
- function Toast({ toast }) {
139
- const icon = toast.kind === 'error' ? 'alert' : toast.kind === 'info' ? 'sparkles' : 'check'
140
- return h('div', { className: `cait-toast cait-toast-${toast.kind || 'success'}`, role: 'status' }, h(Icon, { name: icon, size: 14 }), h('span', null, toast.message))
141
- }
142
-
143
- function SaveBanner({ t, storage, stale, onRefresh }) {
144
- const error = storage?.error ? String(storage.error) : ''
145
- if (!error && !stale) return null
146
- const message = error
147
- ? `${t('storage.saveFailed')}${error}${stale ? ` ${t('storage.stale')}` : ''}`
148
- : t('storage.stale')
149
- return h(
150
- 'div',
151
- { className: `cait-savebanner${error ? ' is-error' : ''}`, role: 'alert' },
152
- h('span', { className: 'cait-savebanner-icon' }, h(Icon, { name: error ? 'alert' : 'refresh', size: 14 })),
153
- h('span', { className: 'cait-savebanner-text' }, message),
154
- h('button', { className: 'cait-btn cait-btn-ghost', onClick: onRefresh }, t('common.refresh')),
155
- )
156
- }
157
-
158
- function DocTabs({ t, documents, activeId, busy, onOpen, onClose, onNew }) {
159
- return h(
160
- 'div',
161
- { className: 'cait-doctabs', role: 'tablist', 'aria-label': t('tabs.title') },
162
- documents.map((doc) =>
163
- h(
164
- 'div',
165
- { key: doc.id, className: `cait-doctab${doc.id === activeId ? ' active' : ''}` },
166
- h(
167
- 'button',
168
- {
169
- type: 'button',
170
- role: 'tab',
171
- className: 'cait-doctab-main',
172
- 'aria-selected': doc.id === activeId,
173
- title: `${doc.name} · ${doc.progress.percent}%`,
174
- onClick: () => onOpen(doc.id),
175
- },
176
- h('span', { className: 'cait-doctab-name' }, doc.name),
177
- h('span', { className: 'cait-doctab-progress' }, `${doc.progress.percent}%`),
178
- ),
179
- h(
180
- 'button',
181
- {
182
- type: 'button',
183
- className: 'cait-doctab-close',
184
- title: t('tabs.close'),
185
- 'aria-label': t('tabs.close'),
186
- disabled: Boolean(busy),
187
- onClick: (event) => {
188
- event.stopPropagation()
189
- onClose(doc.id)
190
- },
191
- },
192
- h(Icon, { name: 'close', size: 11 }),
193
- ),
194
- ),
195
- ),
196
- h(
197
- 'button',
198
- {
199
- type: 'button',
200
- className: 'cait-doctab-new',
201
- title: t('tabs.new'),
202
- 'aria-label': t('tabs.new'),
203
- disabled: Boolean(busy),
204
- onClick: onNew,
205
- },
206
- h(Icon, { name: 'plus', size: 13 }),
207
- h('span', null, t('tabs.new')),
208
- ),
209
- )
210
- }
211
-
212
- function ClosedDocPicker({ t, documents, busy, onOpen }) {
213
- if (!documents?.length) return null
214
- return h(
215
- 'label',
216
- { className: 'cait-doc-select-wrap cait-closed-docs' },
217
- h('span', { className: 'cait-muted cait-tiny' }, t('doc.openExisting')),
218
- h(
219
- 'select',
220
- {
221
- className: 'cait-select cait-doc-select',
222
- value: '',
223
- disabled: Boolean(busy),
224
- onChange: (event) => {
225
- if (event.target.value) onOpen(event.target.value)
226
- },
227
- },
228
- h('option', { value: '' }, t('doc.openExisting')),
229
- documents.map((doc) =>
230
- h('option', { key: doc.id, value: doc.id }, `${doc.name} · ${doc.progress.percent}%`),
231
- ),
232
- ),
233
- )
234
- }
235
-
236
- function RecentDocsModal({ t, documents, busy, onClose, onDelete, onClear }) {
237
- return h(
238
- Modal,
239
- { title: t('doc.cleanTitle'), onClose },
240
- h('p', { className: 'cait-modal-hint' }, t('doc.cleanHint')),
241
- documents.length
242
- ? h(
243
- 'div',
244
- { className: 'cait-recent-list' },
245
- documents.map((doc) =>
246
- h(
247
- 'div',
248
- { key: doc.id, className: 'cait-recent-row' },
249
- h(
250
- 'div',
251
- { className: 'cait-recent-meta' },
252
- h('strong', null, doc.name),
253
- h('small', null, `${doc.sourceLang} → ${doc.targetLang} · ${doc.progress.confirmed}/${doc.progress.total}`),
254
- ),
255
- h(
256
- 'button',
257
- {
258
- type: 'button',
259
- className: 'cait-iconbtn cait-recent-del',
260
- title: t('doc.delete'),
261
- 'aria-label': t('doc.delete'),
262
- disabled: Boolean(busy),
263
- onClick: () => onDelete(doc),
264
- },
265
- h(Icon, { name: 'trash', size: 12 }),
266
- ),
267
- ),
268
- ),
269
- )
270
- : h('p', { className: 'cait-muted cait-tiny' }, t('doc.cleanEmpty')),
271
- h(
272
- 'div',
273
- { className: 'cait-modal-actions' },
274
- h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
275
- h(
276
- 'button',
277
- { className: 'cait-btn cait-btn-danger', onClick: onClear, disabled: Boolean(busy) || !documents.length },
278
- busy ? h(Spinner) : null,
279
- t('doc.cleanAll'),
280
- ),
281
- ),
282
- )
283
- }
284
-
285
- function Topbar({
286
- t,
287
- doc,
288
- data,
289
- busy,
290
- onNew,
291
- onImport,
292
- onExport,
293
- onSettings,
294
- onOpenDoc,
295
- onCloseDoc,
296
- onNewTab,
297
- onCleanClosed,
298
- }) {
299
- const progress = doc?.progress || { percent: 0, confirmed: 0, total: 0 }
300
- const openIds = new Set((data.openDocuments || []).map((item) => item.id))
301
- const closedDocuments = (data.documents || []).filter((item) => !openIds.has(item.id))
302
- return h(
303
- 'header',
304
- { className: 'cait-topbar' },
305
- h(
306
- 'div',
307
- { className: 'cait-brand' },
308
- h('span', { className: 'cait-logo' }, h(CaitMark, { size: 17 })),
309
- h('span', { className: 'cait-brand-text' }, h('strong', null, 'CAIT'), h('span', null, t('app.subtitle'))),
310
- ),
311
- h(
312
- 'div',
313
- { className: 'cait-docbar' },
314
- h(DocTabs, {
315
- t,
316
- documents: data.openDocuments || data.documents || [],
317
- activeId: doc?.id,
318
- busy,
319
- onOpen: onOpenDoc,
320
- onClose: onCloseDoc,
321
- onNew: onNewTab,
322
- }),
323
- h(ClosedDocPicker, { t, documents: closedDocuments, busy, onOpen: onOpenDoc }),
324
- closedDocuments.length
325
- ? h(
326
- 'button',
327
- {
328
- type: 'button',
329
- className: 'cait-doc-clean',
330
- title: t('doc.clean'),
331
- 'aria-label': t('doc.clean'),
332
- disabled: Boolean(busy),
333
- onClick: onCleanClosed,
334
- },
335
- h(Icon, { name: 'trash', size: 13 }),
336
- )
337
- : null,
338
- doc
339
- ? h('span', { className: 'cait-doc-progress' }, `${progress.confirmed}/${progress.total} · ${progress.percent}%`)
340
- : null,
341
- ),
342
- h(
343
- 'div',
344
- { className: 'cait-actions' },
345
- h(
346
- 'button',
347
- { className: 'cait-btn', onClick: onNew, disabled: Boolean(busy), title: t('nav.new') },
348
- h(Icon, { name: 'plus', size: 14 }),
349
- h('span', { className: 'cait-btn-label' }, t('nav.new')),
350
- ),
351
- h(
352
- 'button',
353
- { className: 'cait-btn', onClick: onImport, disabled: Boolean(busy), title: t('nav.import') },
354
- h(Icon, { name: 'upload', size: 14 }),
355
- h('span', { className: 'cait-btn-label' }, t('nav.import')),
356
- ),
357
- h(
358
- 'button',
359
- { className: 'cait-btn', onClick: onExport, disabled: Boolean(busy) || !doc, title: t('nav.export') },
360
- h(Icon, { name: 'download', size: 14 }),
361
- h('span', { className: 'cait-btn-label' }, t('nav.export')),
362
- ),
363
- h('button', { className: 'cait-iconbtn', onClick: onSettings, disabled: Boolean(busy), title: t('nav.settings') }, h(Icon, { name: 'settings', size: 15 })),
364
- ),
365
- )
366
- }
367
-
368
- function SegmentRail({ t, doc, selected, query, setQuery, onSelect }) {
369
- const activeRef = useRef(null)
370
- useEffect(() => {
371
- if (activeRef.current) activeRef.current.scrollIntoView({ block: 'nearest' })
372
- }, [selected])
373
-
374
- const needle = query.trim().toLowerCase()
375
- const items = doc.segments
376
- .map((segment, index) => ({ segment, index }))
377
- .filter(({ segment }) => {
378
- if (!needle) return true
379
- return segment.source.toLowerCase().includes(needle) || (segment.target || '').toLowerCase().includes(needle)
380
- })
381
-
382
- return h(
383
- 'aside',
384
- { className: 'cait-rail' },
385
- h(
386
- 'div',
387
- { className: 'cait-rail-head' },
388
- h('div', { className: 'cait-rail-title' }, h('span', null, t('rail.title')), h('span', { className: 'cait-count' }, doc.progress.total)),
389
- h('div', { className: 'cait-mini-progress' }, h('i', { style: { width: `${doc.progress.percent}%` } })),
390
- ),
391
- h(
392
- 'div',
393
- { className: 'cait-rail-search' },
394
- h(Icon, { name: 'search', size: 13 }),
395
- h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('rail.search'), 'aria-label': t('rail.search') }),
396
- ),
397
- h(
398
- 'div',
399
- { className: 'cait-rail-list' },
400
- items.length
401
- ? items.map(({ segment, index }) =>
402
- h(
403
- 'button',
404
- {
405
- key: segment.id,
406
- ref: index === selected ? activeRef : null,
407
- className: `cait-segitem${index === selected ? ' active' : ''}`,
408
- onClick: () => onSelect(index),
409
- title: segment.source,
410
- },
411
- h(
412
- 'span',
413
- { className: 'cait-seg-main' },
414
- h(
415
- 'span',
416
- { className: 'cait-seg-line' },
417
- h('span', { className: 'cait-seg-index' }, String(index + 1).padStart(2, '0')),
418
- h('span', { className: `cait-dot ${segment.status}` }),
419
- ),
420
- h('span', { className: 'cait-seg-text' }, segment.source),
421
- segment.target ? h('span', { className: 'cait-seg-target' }, segment.target) : null,
422
- ),
423
- ),
424
- )
425
- : h('div', { className: 'cait-empty-note' }, t('rail.empty')),
426
- ),
427
- )
428
- }
429
-
430
- function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onNext, onPrev, onSave, onApplyTerm, textareaRef }) {
431
- const hits = detail?.index === index ? detail.glossaryHits : []
432
- const charCount = Array.from(draft || '').length
433
- const progress = doc.progress
434
-
435
- const onKeyDown = (event) => {
436
- const mod = event.metaKey || event.ctrlKey
437
- if (mod && event.key === 'Enter') {
438
- event.preventDefault()
439
- onConfirm()
440
- } else if (mod && event.key.toLowerCase() === 's') {
441
- event.preventDefault()
442
- onSave()
443
- } else if (event.altKey && event.key === 'ArrowDown') {
444
- event.preventDefault()
445
- onNext()
446
- } else if (event.altKey && event.key === 'ArrowUp') {
447
- event.preventDefault()
448
- onPrev()
449
- }
450
- }
451
-
452
- return h(
453
- 'div',
454
- { className: 'cait-editor-col' },
455
- h(
456
- 'div',
457
- { className: 'cait-doc-head' },
458
- h(
459
- 'div',
460
- null,
461
- h('h2', { className: 'cait-doc-title' }, doc.name),
462
- h(
463
- 'div',
464
- { className: 'cait-doc-meta' },
465
- h('span', { className: 'cait-langchip' }, doc.sourceLang),
466
- h(Icon, { name: 'arrowRight', size: 11 }),
467
- h('span', { className: 'cait-langchip' }, doc.targetLang),
468
- h('span', null, `· ${doc.segments.length} ${t('doc.segments')}`),
469
- ),
470
- ),
471
- h(
472
- 'div',
473
- { className: 'cait-progress-box' },
474
- h('span', { className: 'cait-pct' }, `${progress.percent}%`),
475
- h('div', { className: 'cait-progress' }, h('i', { style: { width: `${progress.percent}%` } })),
476
- h('span', { className: 'cait-meta' }, `${progress.confirmed}/${progress.total} ${t('progress.confirmed')}`),
477
- ),
478
- ),
479
- h(
480
- 'div',
481
- { className: 'cait-seg-head' },
482
- h('span', { className: 'cait-seg-label' }, `${t('editor.segment')} ${index + 1} / ${doc.segments.length}`),
483
- h('span', { className: `cait-badge ${segment.status}` }, segment.status === 'confirmed' ? t('status.confirmed') : t('status.pending')),
484
- ),
485
- h(
486
- 'div',
487
- { className: 'cait-source-card' },
488
- h(
489
- 'div',
490
- { className: 'cait-labelrow' },
491
- h('span', { className: 'cait-label' }, t('editor.source')),
492
- h('span', { className: 'cait-langchip' }, doc.sourceLang),
493
- ),
494
- h('div', { className: 'cait-source-text' }, highlightSource(segment.source, hits)),
495
- hits.length
496
- ? h(
497
- 'div',
498
- { className: 'cait-hitrow' },
499
- hits.map((hit) =>
500
- h(
501
- 'button',
502
- {
503
- key: hit.id,
504
- className: 'cait-chip cait-chip-action',
505
- onClick: () => onApplyTerm(hit),
506
- title: hit.note || `${hit.term} → ${hit.target}`,
507
- },
508
- h(Icon, { name: 'book', size: 11 }),
509
- `${hit.term} → ${hit.target}`,
510
- ),
511
- ),
512
- )
513
- : null,
514
- ),
515
- h(
516
- 'div',
517
- { className: 'cait-target-area' },
518
- h(
519
- 'div',
520
- { className: 'cait-labelrow' },
521
- h('span', { className: 'cait-label' }, t('editor.target')),
522
- h(
523
- 'span',
524
- { className: 'cait-meta' },
525
- `${charCount} ${t('editor.chars')}${dirty ? ` · ${t('editor.unsaved')}` : ''}`,
526
- ),
527
- ),
528
- h('textarea', {
529
- ref: textareaRef,
530
- className: 'cait-textarea',
531
- value: draft,
532
- onChange: (event) => setDraft(event.target.value),
533
- onKeyDown,
534
- placeholder: t('editor.targetPlaceholder'),
535
- spellCheck: false,
536
- rows: 8,
537
- }),
538
- ),
539
- h(
540
- 'div',
541
- { className: 'cait-actionbar' },
542
- h(
543
- 'button',
544
- { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || !draft.trim() },
545
- busy === 'confirm' ? h(Spinner) : h(Icon, { name: 'check', size: 14 }),
546
- segment.status === 'confirmed' ? t('editor.update') : t('editor.confirm'),
547
- ),
548
- h(
549
- 'button',
550
- { className: 'cait-btn', onClick: onNext, disabled: Boolean(busy) },
551
- t('editor.next'),
552
- h(Icon, { name: 'chevronRight', size: 13 }),
553
- ),
554
- h(
555
- 'button',
556
- { className: 'cait-btn cait-btn-ghost', onClick: onPrev, disabled: Boolean(busy) },
557
- h(Icon, { name: 'chevronLeft', size: 13 }),
558
- t('editor.prev'),
559
- ),
560
- h(
561
- 'button',
562
- { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || !dirty, title: t('editor.save') },
563
- h(Icon, { name: 'save', size: 14 }),
564
- ),
565
- h('span', { className: 'cait-hint' }, t('editor.hint')),
566
- ),
567
- h(
568
- 'div',
569
- { className: 'cait-editor-foot' },
570
- progress.pending === 0
571
- ? h('span', { className: 'cait-donechip' }, h(Icon, { name: 'check', size: 11 }), t('editor.done'))
572
- : h('span', null, `${progress.pending} ${t('status.pending')}`),
573
- segment.confirmedAt ? h('span', null, `${t('editor.confirmedAt')} ${new Date(segment.confirmedAt).toLocaleString()}`) : null,
574
- ),
575
- )
576
- }
577
-
578
- function TmMatchCard({ t, match, onApply }) {
579
- const matchType = match.matchType || (match.exact ? 'exact' : 'low_fuzzy')
580
- return h(
581
- 'article',
582
- { className: 'cait-card' },
583
- h(
584
- 'div',
585
- { className: 'cait-card-head' },
586
- h('span', { className: `cait-score ${matchType}` }, `${match.percent}%`),
587
- h('span', { className: 'cait-score-label' }, t(`match.${matchType}`)),
588
- h('button', { className: 'cait-minibtn', onClick: () => onApply(match) }, t('panel.apply')),
589
- ),
590
- h('p', { className: 'cait-card-source' }, match.source),
591
- h('p', { className: 'cait-card-target' }, match.target),
592
- )
593
- }
594
-
595
- function CatPanel({
596
- t,
597
- doc,
598
- data,
599
- settings,
600
- mtRoutes,
601
- tab,
602
- setTab,
603
- tmQuery,
604
- setTmQuery,
605
- tmResults,
606
- tmError,
607
- onApplyTm,
608
- onImportTmx,
609
- onClearTm,
610
- onAddTerm,
611
- onDeleteTerm,
612
- onApplyTerm,
613
- onImportGlossary,
614
- onClearGlossary,
615
- mt,
616
- mtError,
617
- mtBusy,
618
- onGenerateMt,
619
- onApplyMt,
620
- onCopy,
621
- onOpenSettings,
622
- onOpenLibraries,
623
- busy,
624
- }) {
625
- const [termQuery, setTermQuery] = useState('')
626
- const [showTermForm, setShowTermForm] = useState(false)
627
- const [termForm, setTermForm] = useState({ source: '', target: '', note: '' })
628
- const tmxInput = useRef(null)
629
- const glossaryInput = useRef(null)
630
- const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
631
- const currentRoute = routes.find((route) => route.id === settings.mt.provider)
632
- const mtProviderLabel =
633
- settings.mt.provider === 'mock'
634
- ? t('mt.mock')
635
- : currentRoute?.name || settings.mt.provider || t('mt.mock')
636
-
637
- const current = doc.current
638
- const tmMatches = tmResults?.matches ?? current?.tmMatches ?? []
639
- const hits = current?.glossaryHits ?? []
640
- const needle = termQuery.trim().toLowerCase()
641
- const terms = (data.glossary || [])
642
- .filter((term) => !needle || term.source.toLowerCase().includes(needle) || term.target.toLowerCase().includes(needle))
643
- .slice(0, 200)
644
-
645
- const submitTerm = async (event) => {
646
- event.preventDefault()
647
- if (!termForm.source.trim() || !termForm.target.trim()) return
648
- const ok = await onAddTerm(termForm)
649
- if (ok) {
650
- setTermForm({ source: '', target: '', note: '' })
651
- setShowTermForm(false)
652
- }
653
- }
654
-
655
- return h(
656
- 'aside',
657
- { className: 'cait-cat' },
658
- h(
659
- 'div',
660
- { className: 'cait-cat-head' },
661
- h('span', { className: 'cait-cat-title' }, t('panel.title')),
662
- h(
663
- 'div',
664
- { className: 'cait-cat-head-actions' },
665
- h(
666
- 'button',
667
- {
668
- className: 'cait-btn cait-libopen',
669
- onClick: onOpenLibraries,
670
- disabled: Boolean(busy),
671
- title: t('libraries.open'),
672
- },
673
- h(Icon, { name: 'database', size: 12 }),
674
- t('libraries.short'),
675
- ),
676
- h('span', { className: 'cait-live-dot', title: t('panel.live') }),
677
- ),
678
- ),
679
- h(
680
- 'div',
681
- { className: 'cait-tabs' },
682
- h(
683
- 'button',
684
- { className: `cait-tab${tab === 'tm' ? ' active' : ''}`, onClick: () => setTab('tm') },
685
- h(Icon, { name: 'database', size: 13 }),
686
- t('panel.tm'),
687
- h('span', { className: 'cait-count' }, data.tm.count),
688
- ),
689
- h(
690
- 'button',
691
- { className: `cait-tab${tab === 'glossary' ? ' active' : ''}`, onClick: () => setTab('glossary') },
692
- h(Icon, { name: 'book', size: 13 }),
693
- t('panel.glossary'),
694
- h('span', { className: 'cait-count' }, data.glossary.length),
695
- ),
696
- h(
697
- 'button',
698
- { className: `cait-tab${tab === 'mt' ? ' active' : ''}`, onClick: () => setTab('mt') },
699
- h(Icon, { name: 'sparkles', size: 13 }),
700
- t('panel.mt'),
701
- ),
702
- ),
703
- h(
704
- 'div',
705
- { className: 'cait-tabbody' },
706
- tab === 'tm'
707
- ? h(
708
- 'div',
709
- null,
710
- h(
711
- 'form',
712
- {
713
- className: 'cait-search',
714
- onSubmit: (event) => {
715
- event.preventDefault()
716
- setTmQuery(tmQuery)
717
- },
718
- },
719
- h(Icon, { name: 'search', size: 13 }),
720
- h('input', {
721
- value: tmQuery,
722
- onChange: (event) => {
723
- setTmQuery(event.target.value)
724
- if (tmResults) {
725
- // clearing the field returns to current-segment matches
726
- }
727
- },
728
- placeholder: t('panel.searchPlaceholder'),
729
- 'aria-label': t('panel.searchPlaceholder'),
730
- }),
731
- h('button', { className: 'cait-iconbtn', type: 'submit', title: t('panel.search') }, h(Icon, { name: 'arrowRight', size: 13 })),
732
- ),
733
- h(
734
- 'div',
735
- { className: 'cait-listhead' },
736
- h('span', null, tmResults ? t('panel.searchResults') : t('panel.currentMatches')),
737
- h('span', { className: 'cait-count' }, tmError ? '!' : tmMatches.length),
738
- ),
739
- tmError ? h('div', { className: 'cait-mt-error' }, tmError) : null,
740
- tmMatches.length
741
- ? tmMatches.map((match) => h(TmMatchCard, { key: match.id, t, match, onApply: onApplyTm }))
742
- : h('p', { className: 'cait-empty-note' }, tmResults ? t('panel.noMatches') : t('panel.noCurrentMatches')),
743
- h('p', { className: 'cait-mt-hint' }, t('panel.tmHint')),
744
- h(
745
- 'div',
746
- { className: 'cait-panel-foot' },
747
- h('input', {
748
- ref: tmxInput,
749
- type: 'file',
750
- accept: '.tmx,application/xml,text/xml',
751
- hidden: true,
752
- onChange: (event) => {
753
- const file = event.target.files?.[0]
754
- if (file) onImportTmx(file)
755
- event.target.value = ''
756
- },
757
- }),
758
- h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importTmx')),
759
- h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearTm, disabled: Boolean(busy) || data.tm.count === 0 }, t('panel.clearTm')),
760
- ),
761
- )
762
- : null,
763
- tab === 'glossary'
764
- ? h(
765
- 'div',
766
- null,
767
- h('div', { className: 'cait-subhead' }, h('span', null, t('panel.termHits')), h('span', { className: 'cait-count' }, hits.length)),
768
- hits.length
769
- ? h(
770
- 'div',
771
- { className: 'cait-hitrow', style: { marginTop: 0, marginBottom: 10 } },
772
- hits.map((hit) =>
773
- h(
774
- 'button',
775
- { key: hit.id, className: 'cait-chip cait-chip-action', onClick: () => onApplyTerm(hit), title: hit.note || hit.target },
776
- `${hit.term} → ${hit.target}`,
777
- ),
778
- ),
779
- )
780
- : h('p', { className: 'cait-empty-note' }, t('panel.noHits')),
781
- h(
782
- 'div',
783
- { className: 'cait-subhead' },
784
- h('span', null, t('panel.allTerms')),
785
- h('button', { className: 'cait-linkbtn', onClick: () => setShowTermForm((value) => !value) }, `+ ${t('panel.addTerm')}`),
786
- ),
787
- showTermForm
788
- ? h(
789
- 'form',
790
- { className: 'cait-termform', onSubmit: submitTerm },
791
- h('input', {
792
- className: 'cait-input',
793
- value: termForm.source,
794
- onChange: (event) => setTermForm({ ...termForm, source: event.target.value }),
795
- placeholder: t('panel.termSource'),
796
- autoFocus: true,
797
- }),
798
- h(
799
- 'div',
800
- { className: 'cait-formrow' },
801
- h('input', {
802
- className: 'cait-input',
803
- value: termForm.target,
804
- onChange: (event) => setTermForm({ ...termForm, target: event.target.value }),
805
- placeholder: t('panel.termTarget'),
806
- }),
807
- h('input', {
808
- className: 'cait-input',
809
- value: termForm.note,
810
- onChange: (event) => setTermForm({ ...termForm, note: event.target.value }),
811
- placeholder: t('panel.termNote'),
812
- }),
813
- ),
814
- h(
815
- 'div',
816
- { className: 'cait-formrow' },
817
- h('button', { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !termForm.source.trim() || !termForm.target.trim() }, t('panel.saveTerm')),
818
- h('button', { className: 'cait-btn cait-btn-ghost', type: 'button', onClick: () => setShowTermForm(false) }, t('panel.cancel')),
819
- ),
820
- )
821
- : null,
822
- h(
823
- 'div',
824
- { className: 'cait-search' },
825
- h(Icon, { name: 'search', size: 13 }),
826
- h('input', { value: termQuery, onChange: (event) => setTermQuery(event.target.value), placeholder: t('panel.termSearch'), 'aria-label': t('panel.termSearch') }),
827
- ),
828
- terms.length
829
- ? terms.map((term) =>
830
- h(
831
- 'div',
832
- { key: term.id, className: 'cait-term' },
833
- h(
834
- 'div',
835
- { className: 'cait-term-main' },
836
- h('strong', null, term.source),
837
- h(Icon, { name: 'arrowRight', size: 11, className: 'cait-term-arrow' }),
838
- h('span', null, term.target),
839
- ),
840
- term.note ? h('div', { className: 'cait-muted cait-tiny', style: { marginTop: 2 } }, term.note) : null,
841
- h(
842
- 'div',
843
- { className: 'cait-term-actions' },
844
- h('button', { className: 'cait-minibtn', onClick: () => onApplyTerm(term) }, t('panel.apply')),
845
- h('button', { className: 'cait-deletebtn', onClick: () => onDeleteTerm(term), title: t('panel.delete') }, h(Icon, { name: 'trash', size: 11 })),
846
- ),
847
- ),
848
- )
849
- : h('p', { className: 'cait-empty-note' }, t('panel.noTerms')),
850
- h(
851
- 'div',
852
- { className: 'cait-panel-foot' },
853
- h('input', {
854
- ref: glossaryInput,
855
- type: 'file',
856
- accept: '.csv,.tsv,.txt',
857
- hidden: true,
858
- onChange: (event) => {
859
- const file = event.target.files?.[0]
860
- if (file) onImportGlossary(file)
861
- event.target.value = ''
862
- },
863
- }),
864
- h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importCsv')),
865
- h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearGlossary, disabled: Boolean(busy) || data.glossary.length === 0 }, t('panel.clearGlossary')),
866
- ),
867
- )
868
- : null,
869
- tab === 'mt'
870
- ? h(
871
- 'div',
872
- null,
873
- h(
874
- 'div',
875
- { className: 'cait-mt-head' },
876
- h('span', { className: 'cait-mt-mode' }, mtProviderLabel),
877
- h(
878
- 'span',
879
- { className: 'cait-muted cait-tiny' },
880
- `${t('panel.mtModel')}: ${settings.mt.model || '—'}`,
881
- ),
882
- ),
883
- h(
884
- 'div',
885
- { className: 'cait-card' },
886
- h('div', { className: 'cait-labelrow' }, h('span', { className: 'cait-label' }, t('panel.mtRoute')), h('span', { className: 'cait-meta' }, settings.mt.provider || '—')),
887
- h(
888
- 'button',
889
- { className: 'cait-btn cait-btn-primary', onClick: onGenerateMt, disabled: Boolean(busy) || mtBusy, style: { width: '100%' } },
890
- mtBusy ? h(Spinner) : h(Icon, { name: 'sparkles', size: 14 }),
891
- mtBusy ? t('panel.mtGenerating') : t('panel.mtGenerate'),
892
- ),
893
- ),
894
- mtError ? h('div', { className: 'cait-mt-error' }, mtError) : null,
895
- mt
896
- ? h(
897
- 'article',
898
- { className: 'cait-card cait-mt-card' },
899
- h(
900
- 'div',
901
- { className: 'cait-card-head' },
902
- h('span', { className: 'cait-score exact' }, t('panel.mtSuggestion')),
903
- h('span', { className: 'cait-score-label' }, `${mt.latencyMs} ms`),
904
- ),
905
- h('p', { className: 'cait-mt-text' }, mt.text),
906
- mt.note ? h('p', { className: 'cait-mt-hint' }, mt.note) : null,
907
- h(
908
- 'div',
909
- { className: 'cait-mt-actions' },
910
- h('button', { className: 'cait-minibtn', onClick: () => onCopy(mt.text) }, t('panel.copy')),
911
- h('button', { className: 'cait-minibtn', onClick: onApplyMt }, t('panel.mtInsert')),
912
- ),
913
- )
914
- : h('p', { className: 'cait-empty-note' }, t('panel.mtEmpty')),
915
- h(
916
- 'p',
917
- { className: 'cait-mt-hint' },
918
- settings.mt.provider === 'mock'
919
- ? t('panel.mtMockHint')
920
- : t('panel.mtHint'),
921
- ),
922
- h(
923
- 'div',
924
- { className: 'cait-panel-foot' },
925
- h('button', { className: 'cait-btn', onClick: onOpenSettings }, h(Icon, { name: 'settings', size: 12 }), t('panel.mtSettings')),
926
- ),
927
- )
928
- : null,
929
- ),
930
- )
931
- }
932
-
933
- function DocumentSourceForm({ t, settings, busy, submitLabel, onSubmit, defaultMode = 'text' }) {
934
- const [mode, setMode] = useState(defaultMode)
935
- const [name, setName] = useState('')
936
- const [text, setText] = useState('')
937
- const [file, setFile] = useState(null)
938
- const [sourceLang, setSourceLang] = useState(settings.sourceLang || 'en')
939
- const [targetLang, setTargetLang] = useState(settings.targetLang || 'zh-CN')
940
- const [csvSourceColumn, setCsvSourceColumn] = useState('')
941
- const [csvTargetColumn, setCsvTargetColumn] = useState('')
942
- const inputRef = useRef(null)
943
-
944
- useEffect(() => {
945
- if (settings.sourceLang) setSourceLang(settings.sourceLang)
946
- if (settings.targetLang) setTargetLang(settings.targetLang)
947
- }, [settings.sourceLang, settings.targetLang])
948
-
949
- const pickFile = (nextFile) => {
950
- if (!nextFile) return
951
- setFile(nextFile)
952
- setMode('file')
953
- if (!name.trim()) setName(stripExtension(nextFile.name))
954
- }
955
-
956
- const submit = (event) => {
957
- event.preventDefault()
958
- onSubmit({
959
- mode,
960
- name,
961
- text,
962
- file,
963
- sourceLang,
964
- targetLang,
965
- csvSourceColumn,
966
- csvTargetColumn,
967
- })
968
- }
969
-
970
- const canSubmit = mode === 'file' ? Boolean(file) : Boolean(text.trim())
971
- const isCsv = mode === 'file' && /\.csv$/i.test(file?.name || '')
972
-
973
- return h(
974
- 'form',
975
- { className: 'cait-docform', onSubmit: submit },
976
- h(
977
- 'div',
978
- { className: 'cait-segmented' },
979
- h('button', { type: 'button', className: mode === 'text' ? 'active' : '', onClick: () => setMode('text') }, t('empty.paste')),
980
- h('button', { type: 'button', className: mode === 'file' ? 'active' : '', onClick: () => setMode('file') }, t('empty.file')),
981
- ),
982
- h(
983
- 'label',
984
- { className: 'cait-field' },
985
- h('span', null, t('modal.new.name')),
986
- h('input', {
987
- className: 'cait-input',
988
- value: name,
989
- onChange: (event) => setName(event.target.value),
990
- placeholder: t('modal.new.namePlaceholder'),
991
- }),
992
- ),
993
- h(
994
- 'div',
995
- { className: 'cait-grid2' },
996
- h(
997
- 'label',
998
- { className: 'cait-field' },
999
- h('span', null, t('modal.new.sourceLang')),
1000
- h(
1001
- 'select',
1002
- { className: 'cait-input', value: sourceLang, onChange: (event) => setSourceLang(event.target.value) },
1003
- LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1004
- ),
1005
- ),
1006
- h(
1007
- 'label',
1008
- { className: 'cait-field' },
1009
- h('span', null, t('modal.new.targetLang')),
1010
- h(
1011
- 'select',
1012
- { className: 'cait-input', value: targetLang, onChange: (event) => setTargetLang(event.target.value) },
1013
- LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1014
- ),
1015
- ),
1016
- ),
1017
- isCsv
1018
- ? h(
1019
- 'div',
1020
- { className: 'cait-grid2' },
1021
- h(
1022
- 'label',
1023
- { className: 'cait-field' },
1024
- h('span', null, t('modal.new.csvSource')),
1025
- h('input', {
1026
- className: 'cait-input',
1027
- value: csvSourceColumn,
1028
- onChange: (event) => setCsvSourceColumn(event.target.value),
1029
- placeholder: t('modal.new.csvColumnPlaceholder'),
1030
- }),
1031
- ),
1032
- h(
1033
- 'label',
1034
- { className: 'cait-field' },
1035
- h('span', null, t('modal.new.csvTarget')),
1036
- h('input', {
1037
- className: 'cait-input',
1038
- value: csvTargetColumn,
1039
- onChange: (event) => setCsvTargetColumn(event.target.value),
1040
- placeholder: t('modal.new.csvColumnPlaceholder'),
1041
- }),
1042
- ),
1043
- h('p', { className: 'cait-modal-hint' }, t('modal.new.csvHint')),
1044
- )
1045
- : null,
1046
- mode === 'text'
1047
- ? h('textarea', {
1048
- className: 'cait-formtext',
1049
- value: text,
1050
- onChange: (event) => setText(event.target.value),
1051
- placeholder: t('empty.textPlaceholder'),
1052
- rows: 6,
1053
- })
1054
- : h(
1055
- 'label',
1056
- {
1057
- className: 'cait-drop',
1058
- onDragOver: (event) => event.preventDefault(),
1059
- onDrop: (event) => {
1060
- event.preventDefault()
1061
- pickFile(event.dataTransfer?.files?.[0])
1062
- },
1063
- },
1064
- h('input', {
1065
- ref: inputRef,
1066
- type: 'file',
1067
- accept: '.txt,.md,.markdown,.tmx,.docx,.csv,.srt,.vtt,.po,.pot,.xliff,.xlf,.sdlxliff,.html,.htm,.json,.rtf,.xlsx,.pptx',
1068
- hidden: true,
1069
- onChange: (event) => pickFile(event.target.files?.[0]),
1070
- }),
1071
- h(Icon, { name: file ? 'file' : 'upload', size: 22 }),
1072
- h('strong', null, file ? file.name : t('empty.drop')),
1073
- h('span', null, file ? formatBytes(file.size) : t('modal.new.dropHint')),
1074
- ),
1075
- h(
1076
- 'div',
1077
- { className: 'cait-formfoot' },
1078
- h('span', { className: 'cait-muted' }, t('empty.formats')),
1079
- h(
1080
- 'button',
1081
- { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !canSubmit },
1082
- busy === 'create' ? h(Spinner) : h(Icon, { name: 'plus', size: 13 }),
1083
- busy === 'create' ? t('modal.new.importing') : submitLabel,
1084
- ),
1085
- ),
1086
- )
1087
- }
1088
-
1089
- function NewDocModal({ t, settings, busy, defaultMode, onClose, onCreate }) {
1090
- return h(
1091
- Modal,
1092
- { title: t('modal.new.title'), onClose },
1093
- h(DocumentSourceForm, { t, settings, busy, submitLabel: t('modal.new.create'), onSubmit: onCreate, defaultMode }),
1094
- h(
1095
- 'div',
1096
- { className: 'cait-demo-row' },
1097
- h('span', null, t('modal.new.sample')),
1098
- h('button', { className: 'cait-linkbtn', onClick: () => onCreate({ demo: true }), disabled: Boolean(busy) }, t('modal.new.demo')),
1099
- ),
1100
- )
1101
- }
1102
-
1103
- function ExportModal({ t, busy, onClose, onExport }) {
1104
- const items = [
1105
- ['bilingual-md', t('modal.export.bilingualMd'), t('modal.export.bilingual'), 'file'],
1106
- ['bilingual-csv', t('modal.export.bilingualCsv'), t('modal.export.bilingual'), 'file'],
1107
- ['tmx', t('modal.export.tmx'), t('modal.export.bilingual'), 'database'],
1108
- ['target-txt', t('modal.export.targetTxt'), t('modal.export.target'), 'download'],
1109
- ['target-md', t('modal.export.targetMd'), t('modal.export.target'), 'download'],
1110
- ['docx', t('modal.export.docx'), t('modal.export.docxGroup'), 'file'],
1111
- ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
1112
- ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
1113
- ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
1114
- ['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
1115
- ['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
1116
- ['sdlxliff', t('modal.export.sdlxliff'), t('modal.export.localizationGroup'), 'file'],
1117
- ['html', t('modal.export.html'), t('modal.export.webGroup'), 'file'],
1118
- ['json-tree', t('modal.export.jsonTree'), t('modal.export.webGroup'), 'file'],
1119
- ['rtf', t('modal.export.rtf'), t('modal.export.officeGroup'), 'file'],
1120
- ['xlsx', t('modal.export.xlsx'), t('modal.export.officeGroup'), 'file'],
1121
- ['pptx', t('modal.export.pptx'), t('modal.export.officeGroup'), 'file'],
1122
- ['document-json', t('modal.export.json'), t('modal.export.current'), 'save'],
1123
- ]
1124
- return h(
1125
- Modal,
1126
- { title: t('modal.export.title'), onClose, wide: true },
1127
- h('p', { className: 'cait-confirm-text' }, t('modal.export.desc')),
1128
- h(
1129
- 'div',
1130
- { className: 'cait-export-grid' },
1131
- items.map(([format, label, group, icon]) =>
1132
- h(
1133
- 'button',
1134
- { key: format, className: 'cait-export-item', onClick: () => onExport(format), disabled: Boolean(busy) },
1135
- h(Icon, { name: icon, size: 15 }),
1136
- h('span', null, h('strong', null, label), h('small', null, group)),
1137
- ),
1138
- ),
1139
- ),
1140
- )
1141
- }
1142
-
1143
- function SettingsModal({ t, settings, mtRoutes, busy, onClose, onSave }) {
1144
- const [form, setForm] = useState({
1145
- sourceLang: settings.sourceLang,
1146
- targetLang: settings.targetLang,
1147
- fuzzyThreshold: settings.fuzzyThreshold,
1148
- tmLimit: settings.tmLimit,
1149
- uiLang: settings.uiLang || 'auto',
1150
- mt: {
1151
- provider: settings.mt.provider,
1152
- model: settings.mt.model,
1153
- temperature: settings.mt.temperature,
1154
- },
1155
- })
1156
- const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
1157
- const route = routes.find((item) => item.id === form.mt.provider)
1158
- const models = Array.isArray(route?.models) ? route.models : []
1159
- const routeMissing = form.mt.provider !== 'mock' && !route
1160
-
1161
- const setMt = (patch) => setForm((current) => ({ ...current, mt: { ...current.mt, ...patch } }))
1162
- const selectProvider = (provider) => {
1163
- const nextRoute = routes.find((item) => item.id === provider)
1164
- setForm((current) => ({
1165
- ...current,
1166
- mt: {
1167
- ...current.mt,
1168
- provider,
1169
- model:
1170
- provider === 'mock'
1171
- ? 'cait-mock'
1172
- : nextRoute?.models?.[0]?.id || '',
1173
- },
1174
- }))
1175
- }
1176
-
1177
- const submit = (event) => {
1178
- event.preventDefault()
1179
- const patch = {
1180
- sourceLang: form.sourceLang,
1181
- targetLang: form.targetLang,
1182
- fuzzyThreshold: Number(form.fuzzyThreshold),
1183
- tmLimit: Number(form.tmLimit),
1184
- uiLang: form.uiLang,
1185
- mt: {
1186
- provider: form.mt.provider,
1187
- model: form.mt.model,
1188
- temperature: Number(form.mt.temperature),
1189
- },
1190
- }
1191
- onSave(patch)
1192
- }
1193
-
1194
- return h(
1195
- Modal,
1196
- { title: t('modal.settings.title'), onClose, wide: true },
1197
- h(
1198
- 'form',
1199
- { onSubmit: submit },
1200
- h(
1201
- 'div',
1202
- { className: 'cait-settings-section' },
1203
- h('h4', { className: 'cait-settings-title' }, t('modal.settings.ui')),
1204
- h(
1205
- 'label',
1206
- { className: 'cait-field' },
1207
- h('span', null, t('modal.settings.uiLanguage')),
1208
- h(
1209
- 'select',
1210
- {
1211
- className: 'cait-input',
1212
- value: form.uiLang,
1213
- onChange: (event) => setForm({ ...form, uiLang: event.target.value }),
1214
- },
1215
- UI_LANGS.map(([code, label]) =>
1216
- h('option', { key: code, value: code }, label || t('modal.settings.uiAuto')),
1217
- ),
1218
- ),
1219
- ),
1220
- h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.uiHint')),
1221
- ),
1222
- h(
1223
- 'div',
1224
- { className: 'cait-settings-section' },
1225
- h('h4', { className: 'cait-settings-title' }, t('modal.settings.languages')),
1226
- h(
1227
- 'div',
1228
- { className: 'cait-grid2' },
1229
- h(
1230
- 'label',
1231
- { className: 'cait-field' },
1232
- h('span', null, t('modal.new.sourceLang')),
1233
- h(
1234
- 'select',
1235
- { className: 'cait-input', value: form.sourceLang, onChange: (event) => setForm({ ...form, sourceLang: event.target.value }) },
1236
- LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1237
- ),
1238
- ),
1239
- h(
1240
- 'label',
1241
- { className: 'cait-field' },
1242
- h('span', null, t('modal.new.targetLang')),
1243
- h(
1244
- 'select',
1245
- { className: 'cait-input', value: form.targetLang, onChange: (event) => setForm({ ...form, targetLang: event.target.value }) },
1246
- LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1247
- ),
1248
- ),
1249
- ),
1250
- ),
1251
- h(
1252
- 'div',
1253
- { className: 'cait-settings-section' },
1254
- h('h4', { className: 'cait-settings-title' }, t('modal.settings.tm')),
1255
- h(
1256
- 'label',
1257
- { className: 'cait-field' },
1258
- h('span', null, `${t('modal.settings.fuzzy')} · ${Math.round(Number(form.fuzzyThreshold) * 100)}%`),
1259
- h('input', {
1260
- className: 'cait-range',
1261
- type: 'range',
1262
- min: 0.3,
1263
- max: 1,
1264
- step: 0.05,
1265
- value: form.fuzzyThreshold,
1266
- onChange: (event) => setForm({ ...form, fuzzyThreshold: event.target.value }),
1267
- }),
1268
- ),
1269
- h(
1270
- 'label',
1271
- { className: 'cait-field' },
1272
- h('span', null, t('modal.settings.limit')),
1273
- h('input', {
1274
- className: 'cait-input',
1275
- type: 'number',
1276
- min: 1,
1277
- max: 20,
1278
- value: form.tmLimit,
1279
- onChange: (event) => setForm({ ...form, tmLimit: event.target.value }),
1280
- }),
1281
- ),
1282
- ),
1283
- h(
1284
- 'div',
1285
- { className: 'cait-settings-section' },
1286
- h('h4', { className: 'cait-settings-title' }, t('modal.settings.mt')),
1287
- h(
1288
- 'label',
1289
- { className: 'cait-field' },
1290
- h('span', null, t('modal.settings.provider')),
1291
- h(
1292
- 'select',
1293
- {
1294
- className: 'cait-input',
1295
- value: form.mt.provider,
1296
- onChange: (event) => selectProvider(event.target.value),
1297
- },
1298
- h('option', { value: 'mock' }, t('mt.mock')),
1299
- routes.map((item) =>
1300
- h('option', { key: item.id, value: item.id }, `${item.name} (${item.id})`),
1301
- ),
1302
- routeMissing
1303
- ? h('option', { value: form.mt.provider }, t('modal.settings.routeMissing'))
1304
- : null,
1305
- ),
1306
- ),
1307
- h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.routeHint')),
1308
- routes.length === 0 || mtRoutes?.fallback
1309
- ? h('p', { className: 'cait-mt-hint' }, mtRoutes?.message || t('modal.settings.noRoutes'))
1310
- : null,
1311
- form.mt.provider === 'mock'
1312
- ? h('p', { className: 'cait-mt-hint', style: { marginTop: 0, marginBottom: 9 } }, t('modal.settings.mockHint'))
1313
- : h(
1314
- 'div',
1315
- { className: 'cait-grid2' },
1316
- h(
1317
- 'label',
1318
- { className: 'cait-field' },
1319
- h('span', null, t('modal.settings.model')),
1320
- models.length
1321
- ? h(
1322
- 'select',
1323
- {
1324
- className: 'cait-input',
1325
- value: form.mt.model,
1326
- onChange: (event) => setMt({ model: event.target.value }),
1327
- },
1328
- models.map((model) =>
1329
- h('option', { key: model.id, value: model.id }, `${model.name} (${model.id})`),
1330
- ),
1331
- )
1332
- : h('input', {
1333
- className: 'cait-input',
1334
- value: form.mt.model,
1335
- readOnly: true,
1336
- placeholder: t('modal.settings.noModels'),
1337
- }),
1338
- ),
1339
- h(
1340
- 'label',
1341
- { className: 'cait-field' },
1342
- h('span', null, `${t('modal.settings.temperature')} · ${form.mt.temperature}`),
1343
- h('input', {
1344
- className: 'cait-range',
1345
- type: 'range',
1346
- min: 0,
1347
- max: 1,
1348
- step: 0.1,
1349
- value: form.mt.temperature,
1350
- onChange: (event) => setMt({ temperature: event.target.value }),
1351
- }),
1352
- ),
1353
- ),
1354
- ),
1355
- h(
1356
- 'div',
1357
- { className: 'cait-modal-actions' },
1358
- h('button', { type: 'button', className: 'cait-btn cait-btn-ghost', onClick: onClose }, t('modal.settings.cancel')),
1359
- h('button', { type: 'submit', className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) }, busy === 'settings' ? h(Spinner) : null, t('modal.settings.save')),
1360
- ),
1361
- ),
1362
- )
1363
- }
1364
-
1365
- function LibrariesModal({ t, data, busy, onClose, onSetMounts, onCreate }) {
1366
- const libraries = data.libraries || { tm: [], glossary: [] }
1367
- const mounts = data.mounts || {
1368
- tmIds: [],
1369
- glossaryIds: [],
1370
- defaultTmId: '',
1371
- defaultGlossaryId: '',
1372
- }
1373
- const [tmName, setTmName] = useState('')
1374
- const [glossaryName, setGlossaryName] = useState('')
1375
-
1376
- const toggle = (kind, id, checked) => {
1377
- const current = kind === 'tm' ? mounts.tmIds : mounts.glossaryIds
1378
- const next = computeNextMountIds(current, id, checked)
1379
- if (!next) return
1380
- onSetMounts(kind === 'tm' ? { tmIds: next } : { glossaryIds: next })
1381
- }
1382
-
1383
- const create = async (kind, name, reset) => {
1384
- if (!name.trim()) return
1385
- const ok = await onCreate({ kind, name: name.trim() })
1386
- if (ok) reset('')
1387
- }
1388
-
1389
- const section = (kind, title, list, defaultId, ids) =>
1390
- h(
1391
- 'div',
1392
- { className: 'cait-libsection' },
1393
- h(
1394
- 'div',
1395
- { className: 'cait-libhead' },
1396
- h('h4', { className: 'cait-settings-title' }, title),
1397
- h('span', { className: 'cait-count' }, `${ids.length}/${list.length}`),
1398
- ),
1399
- list.length
1400
- ? list.map((library) => {
1401
- const id = kind === 'tm' ? library.tmId : library.glossaryId
1402
- const mounted = ids.includes(id)
1403
- return h(
1404
- 'div',
1405
- { className: 'cait-librow', key: id },
1406
- h(
1407
- 'label',
1408
- { className: 'cait-libcheck' },
1409
- h('input', {
1410
- type: 'checkbox',
1411
- checked: mounted,
1412
- disabled: isMountToggleDisabled({
1413
- mounted,
1414
- mountedCount: ids.length,
1415
- busy: Boolean(busy),
1416
- }),
1417
- onChange: (event) => toggle(kind, id, event.target.checked),
1418
- }),
1419
- h(
1420
- 'span',
1421
- { className: 'cait-libmeta' },
1422
- h('strong', null, library.name),
1423
- h(
1424
- 'small',
1425
- null,
1426
- `${library.entryCount || 0} ${t('libraries.entries')}${
1427
- library.mounted ? '' : ` · ${t('libraries.notMounted')}`
1428
- }`,
1429
- ),
1430
- ),
1431
- ),
1432
- h(
1433
- 'label',
1434
- { className: 'cait-libdefault' },
1435
- h('input', {
1436
- type: 'radio',
1437
- name: `cait-default-${kind}`,
1438
- checked: defaultId === id,
1439
- disabled: Boolean(busy) || !mounted,
1440
- onChange: () =>
1441
- onSetMounts(
1442
- kind === 'tm'
1443
- ? { defaultTmId: id }
1444
- : { defaultGlossaryId: id },
1445
- ),
1446
- }),
1447
- t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
1448
- ),
1449
- )
1450
- })
1451
- : h('p', { className: 'cait-empty-note' }, t('libraries.empty')),
1452
- h(
1453
- 'form',
1454
- {
1455
- className: 'cait-libcreate',
1456
- onSubmit: (event) => {
1457
- event.preventDefault()
1458
- create(kind, kind === 'tm' ? tmName : glossaryName, kind === 'tm' ? setTmName : setGlossaryName)
1459
- },
1460
- },
1461
- h('input', {
1462
- className: 'cait-input',
1463
- value: kind === 'tm' ? tmName : glossaryName,
1464
- onChange: (event) =>
1465
- kind === 'tm' ? setTmName(event.target.value) : setGlossaryName(event.target.value),
1466
- placeholder: t('libraries.namePlaceholder'),
1467
- disabled: Boolean(busy),
1468
- }),
1469
- h(
1470
- 'button',
1471
- { className: 'cait-btn', type: 'submit', disabled: Boolean(busy) },
1472
- h(Icon, { name: 'plus', size: 12 }),
1473
- t('libraries.create'),
1474
- ),
1475
- ),
1476
- )
1477
-
1478
- return h(
1479
- Modal,
1480
- { title: t('libraries.title'), onClose, wide: true },
1481
- h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('libraries.desc')),
1482
- section('tm', t('libraries.tm'), libraries.tm, mounts.defaultTmId, mounts.tmIds),
1483
- section(
1484
- 'glossary',
1485
- t('libraries.glossary'),
1486
- libraries.glossary,
1487
- mounts.defaultGlossaryId,
1488
- mounts.glossaryIds,
1489
- ),
1490
- h(
1491
- 'div',
1492
- { className: 'cait-modal-actions' },
1493
- h('button', { className: 'cait-btn cait-btn-primary', onClick: onClose }, t('libraries.done')),
1494
- ),
1495
- )
1496
- }
1497
-
1498
- function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
1499
- return h(
1500
- Modal,
1501
- { title: state.title || t('modal.confirm.title'), onClose },
1502
- h('p', { className: 'cait-confirm-text' }, state.message),
1503
- h(
1504
- 'div',
1505
- { className: 'cait-modal-actions' },
1506
- h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
1507
- h('button', { className: 'cait-btn cait-btn-danger', onClick: onConfirm, disabled: Boolean(busy) }, busy ? h(Spinner) : null, state.confirmLabel || t('modal.confirm.ok')),
1508
- ),
1509
- )
1510
- }
1511
-
1512
- function EmptyState({ t, settings, busy, onCreate, onDemo }) {
1513
- return h(
1514
- 'div',
1515
- { className: 'cait-empty' },
1516
- h(
1517
- 'div',
1518
- { className: 'cait-empty-inner' },
1519
- h('span', { className: 'cait-kicker' }, 'CAIT · CAT WORKBENCH'),
1520
- h('h1', null, t('empty.title')),
1521
- h('p', { className: 'cait-lede' }, t('empty.desc')),
1522
- h(
1523
- 'div',
1524
- { className: 'cait-features' },
1525
- h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'database', size: 12 }), t('empty.features.tm')), h('span', null, t('empty.features.tmDesc'))),
1526
- h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'book', size: 12 }), t('empty.features.glossary')), h('span', null, t('empty.features.glossaryDesc'))),
1527
- h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'sparkles', size: 12 }), t('empty.features.mt')), h('span', null, t('empty.features.mtDesc'))),
1528
- ),
1529
- h(DocumentSourceForm, { t, settings, busy, submitLabel: t('empty.create'), onSubmit: onCreate }),
1530
- h(
1531
- 'div',
1532
- { className: 'cait-demo-row' },
1533
- h('span', null, t('empty.demoHint')),
1534
- h('button', { className: 'cait-linkbtn', onClick: onDemo, disabled: Boolean(busy) }, t('empty.demo')),
1535
- ),
1536
- ),
1537
- )
1538
- }
1539
-
1540
- function Workbench({ i18n, translate }) {
1541
- const [data, setData] = useState(null)
1542
- const [loading, setLoading] = useState(true)
1543
- const [loadError, setLoadError] = useState('')
1544
- const [busy, setBusy] = useState('')
1545
- const [selected, setSelected] = useState(0)
1546
- const [draft, setDraft] = useState('')
1547
- const [dirty, setDirty] = useState(false)
1548
- const [tab, setTab] = useState('tm')
1549
- const [railQuery, setRailQuery] = useState('')
1550
- const [tmQuery, setTmQuery] = useState('')
1551
- const [tmResults, setTmResults] = useState(null)
1552
- const [tmError, setTmError] = useState('')
1553
- const [mt, setMt] = useState(null)
1554
- const [mtError, setMtError] = useState('')
1555
- const [mtBusy, setMtBusy] = useState(false)
1556
- const [mtRoutes, setMtRoutes] = useState({ providers: [], fallback: true, message: '' })
1557
- const [stale, setStale] = useState(false)
1558
- const [toast, setToast] = useState(null)
1559
- const [modal, setModal] = useState(null)
1560
- const textareaRef = useRef(null)
1561
- const dirtyRef = useRef(false)
1562
- const busyRef = useRef(false)
1563
- const rootRef = useRef(null)
1564
- const unitRef = useRef(0)
1565
-
1566
- const doc = data?.activeDocument || null
1567
- const segments = doc?.segments || []
1568
- const index = doc ? Math.min(selected, Math.max(0, segments.length - 1)) : 0
1569
- const segment = segments[index] || null
1570
- const detail = doc?.current?.index === index ? doc.current : null
1571
- const settings = data?.settings || {
1572
- sourceLang: 'en',
1573
- targetLang: 'zh-CN',
1574
- fuzzyThreshold: 0.55,
1575
- tmLimit: 5,
1576
- uiLang: 'auto',
1577
- mt: { provider: 'mock', model: 'cait-mock', temperature: 0.2 },
1578
- }
1579
-
1580
- // The UI language is a plugin setting; 'auto' follows the DSH locale.
1581
- const uiLang = UI_LANG_CODES.includes(settings.uiLang) ? settings.uiLang : 'auto'
1582
- // A DSH language switch must re-render the workbench while 'auto' is
1583
- // selected, so subscribe to the locale service through the entry seam.
1584
- const [localeRevision, setLocaleRevision] = useState(0)
1585
- useEffect(
1586
- () => (i18n.subscribe ? i18n.subscribe(() => setLocaleRevision(revision => revision + 1)) : undefined),
1587
- [i18n],
1588
- )
1589
- const t = useMemo(
1590
- () => (key, vars) => translate(uiLang, key, vars),
1591
- [translate, uiLang, localeRevision],
1592
- )
1593
- useEffect(() => {
1594
- // Keep the sidebar label in sync with the in-app switch.
1595
- i18n.set(uiLang)
1596
- }, [i18n, uiLang])
1597
-
1598
- const adoptState = (next, forcedIndex) => {
1599
- setData(next)
1600
- setStale(false)
1601
- const nextDoc = next?.activeDocument
1602
- if (!nextDoc) {
1603
- setSelected(0)
1604
- setDraft('')
1605
- setDirty(false)
1606
- setMt(null)
1607
- setMtError('')
1608
- return
1609
- }
1610
- const nextIndex = Number.isInteger(forcedIndex)
1611
- ? Math.min(Math.max(forcedIndex, 0), nextDoc.segments.length - 1)
1612
- : Math.min(nextDoc.cursor, nextDoc.segments.length - 1)
1613
- setSelected(nextIndex)
1614
- setDraft(nextDoc.segments[nextIndex]?.target || '')
1615
- setDirty(false)
1616
- setMt(null)
1617
- setMtError('')
1618
- }
1619
-
1620
- const notify = (message, kind = 'success') => setToast({ message, kind, id: Date.now() })
1621
- const report = (error) => notify(error?.message || t('toast.error'), 'error')
1622
- const importNotice = (result, fallback) => {
1623
- const warning = result?.warnings?.[0]
1624
- if (warning) {
1625
- const message = warning.code === 'TMX_INLINE_MARKUP_LOST'
1626
- ? t('toast.tmxMarkupLost').replace('{count}', warning.count)
1627
- : warning.message || t('toast.tmxMarkupLost').replace('{count}', warning.count)
1628
- notify(message, 'info')
1629
- } else {
1630
- notify(fallback)
1631
- }
1632
- }
1633
-
1634
- useEffect(() => {
1635
- dirtyRef.current = dirty
1636
- }, [dirty])
1637
-
1638
- useEffect(() => {
1639
- busyRef.current = Boolean(busy)
1640
- }, [busy])
1641
-
1642
- const refreshStorage = async () => {
1643
- try {
1644
- const next = await rpc('bootstrap')
1645
- setData((current) => (current ? { ...current, storage: next.storage } : next))
1646
- } catch {
1647
- // The banner already reports the persistence error; a failed refresh
1648
- // must not replace the editor state or the user's draft.
1649
- }
1650
- }
1651
-
1652
- const refreshState = async () => {
1653
- try {
1654
- if (dirty) {
1655
- const saved = await saveDraft(true)
1656
- if (!saved) return
1657
- }
1658
- const next = await rpc('bootstrap')
1659
- adoptState(next)
1660
- setStale(false)
1661
- } catch (error) {
1662
- report(error)
1663
- }
1664
- }
1665
-
1666
- useEffect(() => {
1667
- let alive = true
1668
- setLoading(true)
1669
- rpc('bootstrap')
1670
- .then((state) => {
1671
- if (!alive) return
1672
- adoptState(state)
1673
- setLoadError('')
1674
- })
1675
- .catch((error) => {
1676
- if (!alive) return
1677
- setLoadError(error?.message || t('error.load'))
1678
- })
1679
- .finally(() => {
1680
- if (alive) setLoading(false)
1681
- })
1682
- return () => {
1683
- alive = false
1684
- }
1685
- }, [])
1686
-
1687
- useEffect(() => {
1688
- let alive = true
1689
- let inFlight = false
1690
- const tick = async () => {
1691
- if (!alive || inFlight || busyRef.current) return
1692
- inFlight = true
1693
- try {
1694
- const next = await rpc('bootstrap')
1695
- if (!alive) return
1696
- if (Number(next.revision) !== Number(data?.revision)) {
1697
- if (
1698
- shouldAdoptRemote({
1699
- currentRevision: data?.revision,
1700
- nextRevision: next.revision,
1701
- dirty: dirtyRef.current,
1702
- })
1703
- ) {
1704
- adoptState(next)
1705
- } else if (dirtyRef.current) {
1706
- setStale(true)
1707
- }
1708
- }
1709
- } catch {
1710
- // A transient poll failure should not replace the workbench state.
1711
- } finally {
1712
- inFlight = false
1713
- }
1714
- }
1715
- const timer = setInterval(tick, SYNC_POLL_MS)
1716
- return () => {
1717
- alive = false
1718
- clearInterval(timer)
1719
- }
1720
- }, [data?.revision])
1721
-
1722
- useEffect(() => {
1723
- let alive = true
1724
- rpc('listMtRoutes')
1725
- .then((routes) => {
1726
- if (alive) setMtRoutes(routes || { providers: [], fallback: true, message: '' })
1727
- })
1728
- .catch(() => {
1729
- if (alive) setMtRoutes({ providers: [], fallback: true, message: '' })
1730
- })
1731
- return () => {
1732
- alive = false
1733
- }
1734
- }, [])
1735
-
1736
- useEffect(() => {
1737
- if (!toast) return undefined
1738
- const timer = setTimeout(() => setToast(null), 3200)
1739
- return () => clearTimeout(timer)
1740
- }, [toast])
1741
-
1742
- useEffect(() => {
1743
- setRailQuery('')
1744
- setTmQuery('')
1745
- setTmResults(null)
1746
- setTmError('')
1747
- }, [doc?.id])
1748
-
1749
- useEffect(() => {
1750
- const query = tmQuery.trim()
1751
- if (!query) {
1752
- setTmResults(null)
1753
- setTmError('')
1754
- return undefined
1755
- }
1756
- let cancelled = false
1757
- const timer = setTimeout(() => {
1758
- rpc('lookupTM', {
1759
- query,
1760
- sourceLang: doc?.sourceLang,
1761
- targetLang: doc?.targetLang,
1762
- threshold: settings.fuzzyThreshold,
1763
- limit: settings.tmLimit,
1764
- })
1765
- .then((result) => {
1766
- if (!cancelled) {
1767
- setTmResults(result)
1768
- setTmError('')
1769
- }
1770
- })
1771
- .catch((error) => {
1772
- if (!cancelled) setTmError(error?.message || t('toast.error'))
1773
- })
1774
- }, 260)
1775
- return () => {
1776
- cancelled = true
1777
- clearTimeout(timer)
1778
- }
1779
- }, [tmQuery, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
1780
-
1781
- const saveDraft = async (silent = true) => {
1782
- if (!doc || !dirty) return true
1783
- try {
1784
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1785
- setDirty(false)
1786
- if (!silent) notify(t('toast.draftSaved'))
1787
- return true
1788
- } catch (error) {
1789
- report(error)
1790
- return false
1791
- }
1792
- }
1793
-
1794
- const chooseSegment = async (nextIndex) => {
1795
- if (!doc || busy || nextIndex === selected) return
1796
- setBusy('select')
1797
- try {
1798
- if (dirty) {
1799
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1800
- setDirty(false)
1801
- }
1802
- const result = await rpc('selectSegment', { documentId: doc.id, index: nextIndex })
1803
- adoptState(result.state)
1804
- } catch (error) {
1805
- report(error)
1806
- } finally {
1807
- setBusy('')
1808
- }
1809
- }
1810
-
1811
- const confirmSegment = async () => {
1812
- if (!doc || busy) return
1813
- if (!draft.trim()) {
1814
- notify(t('editor.emptyTarget'), 'error')
1815
- textareaRef.current?.focus()
1816
- return
1817
- }
1818
- setBusy('confirm')
1819
- try {
1820
- const result = await rpc('confirmSegment', {
1821
- documentId: doc.id,
1822
- index: selected,
1823
- target: draft,
1824
- targetTmId: data?.mounts?.defaultTmId,
1825
- })
1826
- adoptState(result.state)
1827
- notify(result.done ? t('editor.done') : t('toast.confirmed'))
1828
- } catch (error) {
1829
- report(error)
1830
- if (error?.code === 'PERSISTENCE_FAILED') await refreshStorage()
1831
- } finally {
1832
- setBusy('')
1833
- }
1834
- }
1835
-
1836
- const moveSegment = async (delta) => {
1837
- if (!doc || busy) return
1838
- setBusy('move')
1839
- try {
1840
- if (dirty) {
1841
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1842
- setDirty(false)
1843
- }
1844
- const result = await rpc('moveSegment', { documentId: doc.id, delta })
1845
- adoptState(result.state)
1846
- } catch (error) {
1847
- report(error)
1848
- } finally {
1849
- setBusy('')
1850
- }
1851
- }
1852
-
1853
- const openDocument = async (documentId) => {
1854
- if (!documentId || documentId === doc?.id || busy) return
1855
- setBusy('open')
1856
- try {
1857
- if (dirty) {
1858
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1859
- setDirty(false)
1860
- }
1861
- const result = await rpc('openDocument', { documentId })
1862
- adoptState(result.state)
1863
- } catch (error) {
1864
- report(error)
1865
- } finally {
1866
- setBusy('')
1867
- }
1868
- }
1869
-
1870
- const closeDocumentTab = async (documentId) => {
1871
- if (!documentId || busy) return
1872
- setBusy('close')
1873
- try {
1874
- if (dirty) {
1875
- const saved = await saveDraft(true)
1876
- if (!saved) return
1877
- }
1878
- const result = await rpc('closeDocument', { documentId })
1879
- adoptState(result.state)
1880
- notify(t('toast.docClosed'))
1881
- } catch (error) {
1882
- report(error)
1883
- } finally {
1884
- setBusy('')
1885
- }
1886
- }
1887
-
1888
- const createFromForm = async (form) => {
1889
- if (form.demo) {
1890
- await loadDemo()
1891
- return
1892
- }
1893
- setBusy('create')
1894
- try {
1895
- let result
1896
- if (form.mode === 'file' && form.file) {
1897
- const payload = await filePayload(form.file)
1898
- result = await rpc('importFile', {
1899
- ...payload,
1900
- name: form.name.trim() || form.file.name,
1901
- sourceLang: form.sourceLang,
1902
- targetLang: form.targetLang,
1903
- csvSourceColumn: form.csvSourceColumn,
1904
- csvTargetColumn: form.csvTargetColumn,
1905
- })
1906
- } else {
1907
- result = await rpc('createDocument', {
1908
- name: form.name,
1909
- text: form.text,
1910
- sourceLang: form.sourceLang,
1911
- targetLang: form.targetLang,
1912
- })
1913
- }
1914
- adoptState(result.state)
1915
- setModal(null)
1916
- if (result.reused) notify(t('toast.docAlreadyOpen'))
1917
- else importNotice(result, t('toast.imported'))
1918
- } catch (error) {
1919
- report(error)
1920
- } finally {
1921
- setBusy('')
1922
- }
1923
- }
1924
-
1925
- const loadDemo = async () => {
1926
- setBusy('demo')
1927
- try {
1928
- const result = await rpc('loadDemo')
1929
- adoptState(result.state)
1930
- setModal(null)
1931
- notify(result.reused ? t('toast.docAlreadyOpen') : t('toast.created'))
1932
- } catch (error) {
1933
- report(error)
1934
- } finally {
1935
- setBusy('')
1936
- }
1937
- }
1938
-
1939
- const applyTm = (match) => {
1940
- setDraft(match.target)
1941
- setDirty(true)
1942
- setMt(null)
1943
- setTimeout(() => textareaRef.current?.focus(), 0)
1944
- }
1945
-
1946
- const applyTerm = (term) => {
1947
- const separator = /^(zh|ja|ko)/i.test(doc?.targetLang || '') ? '' : ' '
1948
- setDraft((current) => {
1949
- const value = current.replace(/\s+$/, '')
1950
- return value ? `${value}${separator}${term.target}` : term.target
1951
- })
1952
- setDirty(true)
1953
- setTimeout(() => textareaRef.current?.focus(), 0)
1954
- }
1955
-
1956
- const addTerm = async (form) => {
1957
- setBusy('term')
1958
- try {
1959
- const result = await rpc('addGlossary', {
1960
- ...form,
1961
- sourceLang: doc?.sourceLang || settings.sourceLang,
1962
- glossaryId: data?.mounts?.defaultGlossaryId,
1963
- })
1964
- setData(result.state)
1965
- notify(t('toast.termSaved'))
1966
- return true
1967
- } catch (error) {
1968
- report(error)
1969
- return false
1970
- } finally {
1971
- setBusy('')
1972
- }
1973
- }
1974
-
1975
- const deleteTerm = (term) => {
1976
- setModal({
1977
- type: 'confirm',
1978
- title: t('modal.confirm.title'),
1979
- message: `${t('modal.confirm.deleteTerm')} “${term.source} → ${term.target}”?`,
1980
- confirmLabel: t('panel.delete'),
1981
- action: async () => {
1982
- const result = await rpc('deleteGlossary', { id: term.id })
1983
- setData(result.state)
1984
- notify(t('toast.termDeleted'))
1985
- },
1986
- })
1987
- }
1988
-
1989
- const importTmxFile = async (file) => {
1990
- setBusy('tm-import')
1991
- try {
1992
- const text = await file.text()
1993
- const result = await rpc('importTmx', {
1994
- text,
1995
- importAs: 'tm',
1996
- sourceLang: doc?.sourceLang || settings.sourceLang,
1997
- targetLang: doc?.targetLang || settings.targetLang,
1998
- tmId: data?.mounts?.defaultTmId,
1999
- })
2000
- setData(result.state)
2001
- importNotice(
2002
- result,
2003
- `${t('toast.tmImported')} · +${result.added} / ~${result.updated}`,
2004
- )
2005
- } catch (error) {
2006
- report(error)
2007
- } finally {
2008
- setBusy('')
2009
- }
2010
- }
2011
-
2012
- const importGlossaryFile = async (file) => {
2013
- setBusy('glossary-import')
2014
- try {
2015
- const text = await file.text()
2016
- const result = await rpc('importGlossary', {
2017
- text,
2018
- glossaryId: data?.mounts?.defaultGlossaryId,
2019
- })
2020
- setData(result.state)
2021
- notify(`${t('toast.glossaryImported')} · +${result.added} / ~${result.updated}`)
2022
- } catch (error) {
2023
- report(error)
2024
- } finally {
2025
- setBusy('')
2026
- }
2027
- }
2028
-
2029
- const generateMt = async () => {
2030
- if (!doc || !segment || mtBusy) return
2031
- setMtBusy(true)
2032
- setMtError('')
2033
- try {
2034
- const result = await rpc('mtSuggest', {
2035
- text: segment.source,
2036
- documentId: doc.id,
2037
- index: selected,
2038
- sourceLang: doc.sourceLang,
2039
- targetLang: doc.targetLang,
2040
- })
2041
- setMt(result)
2042
- notify(t('toast.mtReady'), 'info')
2043
- } catch (error) {
2044
- setMtError(error?.message || t('toast.error'))
2045
- } finally {
2046
- setMtBusy(false)
2047
- }
2048
- }
2049
-
2050
- const saveSettings = async (patch) => {
2051
- setBusy('settings')
2052
- try {
2053
- const result = await rpc('saveSettings', patch)
2054
- setData(result.state)
2055
- setModal(null)
2056
- notify(t('toast.settingsSaved'))
2057
- return true
2058
- } catch (error) {
2059
- report(error)
2060
- return false
2061
- } finally {
2062
- setBusy('')
2063
- }
2064
- }
2065
-
2066
- const setMounts = async (patch) => {
2067
- setBusy('mounts')
2068
- try {
2069
- const result = await rpc('setMounts', patch)
2070
- setData(result.state)
2071
- setTmResults(null)
2072
- notify(t('libraries.mountSaved'))
2073
- return true
2074
- } catch (error) {
2075
- report(error)
2076
- return false
2077
- } finally {
2078
- setBusy('')
2079
- }
2080
- }
2081
-
2082
- const createLibrary = async ({ kind, name }) => {
2083
- setBusy('library')
2084
- try {
2085
- const result = await rpc('createLibrary', { kind, name })
2086
- setData(result.state)
2087
- notify(t('libraries.created'))
2088
- return true
2089
- } catch (error) {
2090
- report(error)
2091
- return false
2092
- } finally {
2093
- setBusy('')
2094
- }
2095
- }
2096
-
2097
- const exportAs = async (format) => {
2098
- if (!doc) return
2099
- setBusy('export')
2100
- try {
2101
- const result = await rpc('exportDocument', { format, documentId: doc.id })
2102
- const content = result.encoding === 'base64'
2103
- ? decodeBase64(result.content)
2104
- : result.content
2105
- downloadFile(result.filename, content, result.mime)
2106
- notify(t('toast.exported'))
2107
- setModal(null)
2108
- } catch (error) {
2109
- report(error)
2110
- } finally {
2111
- setBusy('')
2112
- }
2113
- }
2114
-
2115
- const openConfirm = (state) => setModal({ type: 'confirm', ...state })
2116
-
2117
- const clearTm = () =>
2118
- openConfirm({
2119
- title: t('modal.confirm.title'),
2120
- message: t('modal.confirm.clearTm'),
2121
- confirmLabel: t('panel.clearTm'),
2122
- action: async () => {
2123
- const result = await rpc('clearTM', { tmIds: data?.mounts?.tmIds || [], confirm: true })
2124
- setData(result.state)
2125
- setTmResults(null)
2126
- notify(t('toast.tmCleared'))
2127
- },
2128
- })
2129
-
2130
- const clearGlossary = () =>
2131
- openConfirm({
2132
- title: t('modal.confirm.title'),
2133
- message: t('modal.confirm.clearGlossary'),
2134
- confirmLabel: t('panel.clearGlossary'),
2135
- action: async () => {
2136
- const result = await rpc('clearGlossary', {
2137
- glossaryIds: data?.mounts?.glossaryIds || [],
2138
- confirm: true,
2139
- })
2140
- setData(result.state)
2141
- notify(t('toast.glossaryCleared'))
2142
- },
2143
- })
2144
-
2145
- const runConfirm = async () => {
2146
- if (!modal?.action) return
2147
- setBusy('confirm-action')
2148
- try {
2149
- await modal.action()
2150
- setModal(null)
2151
- } catch (error) {
2152
- report(error)
2153
- } finally {
2154
- setBusy('')
2155
- }
2156
- }
2157
-
2158
- const copy = async (value) => {
2159
- try {
2160
- await copyText(value)
2161
- notify(t('toast.copied'))
2162
- } catch (error) {
2163
- report(error)
2164
- }
2165
- }
2166
-
2167
- const openIds = new Set((data?.openDocuments || []).map((item) => item.id))
2168
- const closedDocuments = (data?.documents || []).filter((item) => !openIds.has(item.id))
2169
-
2170
- const deleteClosedDocument = (document) =>
2171
- openConfirm({
2172
- title: t('doc.deleteTitle'),
2173
- message: t('doc.deleteMessage').replace('{name}', document.name),
2174
- confirmLabel: t('doc.delete'),
2175
- action: async () => {
2176
- const result = await rpc('deleteDocument', { documentId: document.id })
2177
- adoptState(result.state)
2178
- notify(t('toast.docDeleted'))
2179
- },
2180
- })
2181
-
2182
- const clearClosedDocuments = () =>
2183
- openConfirm({
2184
- title: t('doc.cleanTitle'),
2185
- message: t('doc.cleanAllMessage').replace('{count}', String(closedDocuments.length)),
2186
- confirmLabel: t('doc.cleanAll'),
2187
- action: async () => {
2188
- let lastState = null
2189
- for (const document of closedDocuments) {
2190
- const result = await rpc('deleteDocument', { documentId: document.id })
2191
- lastState = result.state
2192
- }
2193
- if (lastState) adoptState(lastState)
2194
- notify(t('toast.docsCleared').replace('{count}', String(closedDocuments.length)))
2195
- },
2196
- })
2197
-
2198
- // Track the size DSH gives the panel and publish it as a CSS unit. The
2199
- // observer watches the stable root element and writes the property
2200
- // directly, so resizing never triggers a React render.
2201
- useEffect(() => {
2202
- const element = rootRef.current
2203
- if (!element) return undefined
2204
- const measure = () => {
2205
- const rect = element.getBoundingClientRect()
2206
- const unit = computeCaitUnit({ width: rect.width, height: rect.height })
2207
- if (Math.abs(unit - unitRef.current) < 0.002) return
2208
- unitRef.current = unit
2209
- element.style.setProperty(CAIT_UNIT_VAR, String(unit))
2210
- }
2211
- measure()
2212
- if (typeof ResizeObserver === 'undefined') {
2213
- window.addEventListener('resize', measure)
2214
- return () => window.removeEventListener('resize', measure)
2215
- }
2216
- const observer = new ResizeObserver(measure)
2217
- observer.observe(element)
2218
- return () => observer.disconnect()
2219
- }, [])
2220
-
2221
- if (loading) {
2222
- return h(
2223
- 'div',
2224
- { className: 'cait-root', ref: rootRef },
2225
- h('style', null, CSS_SCALED),
2226
- h('div', { className: 'cait-center' }, h(Spinner), t('common.loading')),
2227
- )
2228
- }
2229
-
2230
- if (loadError) {
2231
- return h(
2232
- 'div',
2233
- { className: 'cait-root', ref: rootRef },
2234
- h('style', null, CSS_SCALED),
2235
- h(
2236
- 'div',
2237
- { className: 'cait-center' },
2238
- h(
2239
- 'div',
2240
- { className: 'cait-errorcard' },
2241
- h('p', null, `${t('error.load')}: ${loadError}`),
2242
- h('button', { className: 'cait-btn cait-btn-primary', onClick: () => window.location.reload() }, h(Icon, { name: 'refresh', size: 13 }), t('common.retry')),
2243
- ),
2244
- ),
2245
- )
2246
- }
2247
-
2248
- return h(
2249
- 'div',
2250
- { className: 'cait-root', ref: rootRef },
2251
- h('style', null, CSS_SCALED),
2252
- h(Topbar, {
2253
- t,
2254
- doc,
2255
- data,
2256
- busy,
2257
- onNew: () => setModal({ type: 'new', defaultMode: 'text' }),
2258
- onImport: () => setModal({ type: 'new', defaultMode: 'file' }),
2259
- onExport: () => setModal({ type: 'export' }),
2260
- onSettings: () => setModal({ type: 'settings' }),
2261
- onOpenDoc: openDocument,
2262
- onCloseDoc: closeDocumentTab,
2263
- onNewTab: () => setModal({ type: 'new', defaultMode: 'file' }),
2264
- onCleanClosed: () => setModal({ type: 'recent' }),
2265
- }),
2266
- h(SaveBanner, { t, storage: data?.storage, stale, onRefresh: refreshState }),
2267
- h(
2268
- 'div',
2269
- { className: `cait-body${doc ? '' : ' cait-no-doc'}` },
2270
- doc ? h(SegmentRail, { t, doc, selected: index, query: railQuery, setQuery: setRailQuery, onSelect: chooseSegment }) : null,
2271
- h(
2272
- 'div',
2273
- { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
2274
- doc
2275
- ? h(EditorPane, {
2276
- t,
2277
- doc,
2278
- index,
2279
- segment,
2280
- detail,
2281
- draft,
2282
- setDraft: (value) => {
2283
- setDraft(value)
2284
- setDirty(true)
2285
- },
2286
- dirty,
2287
- busy,
2288
- onConfirm: confirmSegment,
2289
- onNext: () => moveSegment(1),
2290
- onPrev: () => moveSegment(-1),
2291
- onSave: () => saveDraft(false),
2292
- onApplyTerm: applyTerm,
2293
- textareaRef,
2294
- })
2295
- : h(EmptyState, { t, settings, busy, onCreate: createFromForm, onDemo: loadDemo }),
2296
- doc
2297
- ? h(CatPanel, {
2298
- t,
2299
- doc,
2300
- data,
2301
- settings,
2302
- mtRoutes,
2303
- tab,
2304
- setTab,
2305
- tmQuery,
2306
- setTmQuery,
2307
- tmResults,
2308
- tmError,
2309
- onApplyTm: applyTm,
2310
- onImportTmx: importTmxFile,
2311
- onClearTm: clearTm,
2312
- onAddTerm: addTerm,
2313
- onDeleteTerm: deleteTerm,
2314
- onApplyTerm: applyTerm,
2315
- onImportGlossary: importGlossaryFile,
2316
- onClearGlossary: clearGlossary,
2317
- mt,
2318
- mtError,
2319
- mtBusy,
2320
- onGenerateMt: generateMt,
2321
- onApplyMt: () => {
2322
- if (mt?.text) {
2323
- setDraft(mt.text)
2324
- setDirty(true)
2325
- setTimeout(() => textareaRef.current?.focus(), 0)
2326
- }
2327
- },
2328
- onCopy: copy,
2329
- onOpenSettings: () => setModal({ type: 'settings' }),
2330
- onOpenLibraries: () => setModal({ type: 'libraries' }),
2331
- busy,
2332
- })
2333
- : null,
2334
- ),
2335
- ),
2336
- modal?.type === 'new'
2337
- ? h(NewDocModal, {
2338
- t,
2339
- settings,
2340
- busy,
2341
- defaultMode: modal.defaultMode,
2342
- onClose: () => setModal(null),
2343
- onCreate: createFromForm,
2344
- })
2345
- : null,
2346
- modal?.type === 'export'
2347
- ? h(ExportModal, { t, busy, onClose: () => setModal(null), onExport: exportAs })
2348
- : null,
2349
- modal?.type === 'settings'
2350
- ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })
2351
- : null,
2352
- modal?.type === 'libraries'
2353
- ? h(LibrariesModal, {
2354
- t,
2355
- data,
2356
- busy,
2357
- onClose: () => setModal(null),
2358
- onSetMounts: setMounts,
2359
- onCreate: createLibrary,
2360
- })
2361
- : null,
2362
- modal?.type === 'recent'
2363
- ? h(RecentDocsModal, {
2364
- t,
2365
- documents: closedDocuments,
2366
- busy,
2367
- onClose: () => setModal(null),
2368
- onDelete: deleteClosedDocument,
2369
- onClear: clearClosedDocuments,
2370
- })
2371
- : null,
2372
- modal?.type === 'confirm'
2373
- ? h(ConfirmModal, { t, state: modal, busy, onClose: () => setModal(null), onConfirm: runConfirm })
2374
- : null,
2375
- toast ? h(Toast, { toast }) : null,
2376
- )
2377
- }
1
+ function Spinner() {
2
+ return h('span', { className: 'cait-spinner', 'aria-hidden': true })
3
+ }
4
+
5
+ // SVG width/height attributes are fixed pixels; route them through the same
6
+ // unit as the CSS so icons grow with the panel.
7
+ function scaledLength(value) {
8
+ return `calc(${value}px * var(${CAIT_UNIT_VAR}, 1))`
9
+ }
10
+
11
+ function Icon({ name, size = 16 }) {
12
+ const paths = {
13
+ plus: ['M8 3v10', 'M3 8h10'],
14
+ upload: ['M8 11V2', 'M5 5l3-3 3 3', 'M2 12v1.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V12'],
15
+ download: ['M8 2v9', 'M5 8l3 3 3-3', 'M2 12v1.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5V12'],
16
+ settings: ['M2 4h12', 'M2 8h12', 'M2 12h12', 'M5 2.5v3', 'M11 6.5v3', 'M7 10.5v3'],
17
+ check: ['M3 8.6l3.1 3.1L13 4.8'],
18
+ chevronRight: ['M6 3l5 5-5 5'],
19
+ chevronLeft: ['M10 3L5 8l5 5'],
20
+ chevronDown: ['M3 6l5 5 5-5'],
21
+ search: ['M7.2 12.4a5.2 5.2 0 1 0 0-10.4 5.2 5.2 0 0 0 0 10.4Z', 'M10.9 10.9L14 14'],
22
+ sparkles: ['M8 2l1.3 3.7L13 7l-3.7 1.3L8 12l-1.3-3.7L3 7l3.7-1.3L8 2Z', 'M13 12l.6 1.4L15 14l-1.4.6L13 16l-.6-1.4L11 14l1.4-.6L13 12Z'],
23
+ book: ['M2 3.5A1.5 1.5 0 0 1 3.5 2H7v12H3.5A1.5 1.5 0 0 1 2 12.5v-9Z', 'M14 3.5A1.5 1.5 0 0 0 12.5 2H9v12h3.5a1.5 1.5 0 0 0 1.5-1.5v-9Z'],
24
+ database: ['M8 2c3.3 0 5.5.9 5.5 2s-2.2 2-5.5 2-5.5-.9-5.5-2S4.7 2 8 2Z', 'M2.5 4v8c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2V4', 'M2.5 8c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2'],
25
+ close: ['M3 3l10 10', 'M13 3L3 13'],
26
+ trash: ['M3 4h10', 'M6 4V2.5h4V4', 'M5 4l.6 9.5h4.8L11 4'],
27
+ copy: ['M5.5 5.5h7v8h-7z', 'M3.5 3.5h7v2'],
28
+ file: ['M4 1.5h5L13 5v9.5H4z', 'M9 1.5V5h4'],
29
+ refresh: ['M13 8a5 5 0 1 1-1.5-3.6', 'M13 2v3h-3'],
30
+ arrowRight: ['M2 8h11', 'M9 4l4 4-4 4'],
31
+ play: ['M4 2l9 6-9 6z'],
32
+ globe: ['M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Z', 'M2.5 8h11', 'M8 2c2 2 2 10 0 12', 'M8 2c-2 2-2 10 0 12'],
33
+ alert: ['M8 2L14 13H2L8 2Z', 'M8 6v3.5', 'M8 11.5v.2'],
34
+ save: ['M3 2h8l2 2v10H3z', 'M5 2v4h6V2', 'M5 14v-4h6v4'],
35
+ languages: ['M2 4h7', 'M5.5 4v2c0 2-1.5 4-3.5 5', 'M4 8c0 1.5 2 3 4.5 3.5', 'M9 13l3-7 3 7', 'M10 11h4'],
36
+ }
37
+ const shape = paths[name] || paths.file
38
+ return h(
39
+ 'svg',
40
+ {
41
+ width: size,
42
+ height: size,
43
+ viewBox: '0 0 16 16',
44
+ fill: 'none',
45
+ style: { width: scaledLength(size), height: scaledLength(size), flex: '0 0 auto' },
46
+ stroke: 'currentColor',
47
+ strokeWidth: 1.6,
48
+ strokeLinecap: 'round',
49
+ strokeLinejoin: 'round',
50
+ 'aria-hidden': true,
51
+ focusable: false,
52
+ },
53
+ shape.map((d, index) => h('path', { key: index, d, ...(name === 'play' ? { fill: 'currentColor', stroke: 'none' } : {}) })),
54
+ )
55
+ }
56
+
57
+ function CaitMark({ size = 16 }) {
58
+ return h(
59
+ 'svg',
60
+ {
61
+ width: size,
62
+ height: size,
63
+ viewBox: '0 0 32 32',
64
+ fill: 'none',
65
+ style: { width: scaledLength(size), height: scaledLength(size), flex: '0 0 auto' },
66
+ 'aria-hidden': true,
67
+ focusable: false,
68
+ },
69
+ h('path', { d: 'M6.3 5.8H15.2C17 5.8 18.4 7.2 18.4 9V14.8C18.4 16.6 17 18 15.2 18H9.3L5.9 21.6V18H6.3C4.5 18 3.1 16.6 3.1 14.8V9C3.1 7.2 4.5 5.8 6.3 5.8Z', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }),
70
+ h('path', { d: 'M5.4 16.4 9.2 8.5 13 16.4M6.8 13.4h4.8', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }),
71
+ h('path', { d: 'M17.4 14.2H27.4C29 14.2 30.2 15.4 30.2 17V22.2C30.2 23.8 29 25 27.4 25H27.2L27 28.8 23.2 25H17.4C15.8 25 14.6 23.8 14.6 22.2V17C14.6 15.4 15.8 14.2 17.4 14.2Z', fill: 'var(--dsw-alias-bg-base, #ffffff)', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }),
72
+ h('circle', { cx: 23, cy: 17.2, r: 1.4, fill: 'currentColor' }),
73
+ h('path', { d: 'M18.6 20.6H27.4M23 20.6l-4.2 4.2M23 20.6l4.2 4.2', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }),
74
+ )
75
+ }
76
+
77
+ function CaitSidebarIcon({ size = 18, active }) {
78
+ return h(
79
+ 'span',
80
+ {
81
+ style: {
82
+ display: 'inline-flex',
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ width: size,
86
+ height: size,
87
+ color: active ? 'var(--dsw-alias-brand-primary)' : 'currentColor',
88
+ },
89
+ 'aria-hidden': true,
90
+ },
91
+ h(CaitMark, { size }),
92
+ )
93
+ }
94
+
95
+ function highlightSource(text, hits) {
96
+ const terms = [...new Set((hits || []).map((hit) => hit.term).filter(Boolean))]
97
+ .sort((a, b) => b.length - a.length)
98
+ if (!terms.length) return text
99
+ const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi')
100
+ return String(text).split(pattern).map((part, index) => {
101
+ const hit = (hits || []).find((item) => item.term.toLowerCase() === String(part).toLowerCase())
102
+ if (!hit) return part
103
+ return h('mark', { key: `m${index}`, title: `${hit.term} → ${hit.target}` }, part)
104
+ })
105
+ }
106
+
107
+ function Modal({ title, children, onClose, wide }) {
108
+ useEffect(() => {
109
+ const onKey = (event) => {
110
+ if (event.key === 'Escape') onClose()
111
+ }
112
+ window.addEventListener('keydown', onKey)
113
+ return () => window.removeEventListener('keydown', onKey)
114
+ }, [onClose])
115
+
116
+ return h(
117
+ 'div',
118
+ {
119
+ className: 'cait-overlay',
120
+ onMouseDown: (event) => {
121
+ if (event.target === event.currentTarget) onClose()
122
+ },
123
+ },
124
+ h(
125
+ 'div',
126
+ { className: `cait-modal${wide ? ' cait-modal-wide' : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-label': title },
127
+ h(
128
+ 'div',
129
+ { className: 'cait-modal-head' },
130
+ h('h3', null, title),
131
+ h('button', { className: 'cait-iconbtn', onClick: onClose, 'aria-label': 'Close' }, h(Icon, { name: 'close', size: 14 })),
132
+ ),
133
+ h('div', { className: 'cait-modal-body' }, children),
134
+ ),
135
+ )
136
+ }
137
+
138
+ function Toast({ toast }) {
139
+ const icon = toast.kind === 'error' ? 'alert' : toast.kind === 'info' ? 'sparkles' : 'check'
140
+ return h('div', { className: `cait-toast cait-toast-${toast.kind || 'success'}`, role: 'status' }, h(Icon, { name: icon, size: 14 }), h('span', null, toast.message))
141
+ }
142
+
143
+ function SaveBanner({ t, storage, stale, onRefresh }) {
144
+ const error = storage?.error ? String(storage.error) : ''
145
+ if (!error && !stale) return null
146
+ const message = error
147
+ ? `${t('storage.saveFailed')}${error}${stale ? ` ${t('storage.stale')}` : ''}`
148
+ : t('storage.stale')
149
+ return h(
150
+ 'div',
151
+ { className: `cait-savebanner${error ? ' is-error' : ''}`, role: 'alert' },
152
+ h('span', { className: 'cait-savebanner-icon' }, h(Icon, { name: error ? 'alert' : 'refresh', size: 14 })),
153
+ h('span', { className: 'cait-savebanner-text' }, message),
154
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onRefresh }, t('common.refresh')),
155
+ )
156
+ }
157
+
158
+ function DocTabs({ t, documents, activeId, busy, onOpen, onClose, onNew }) {
159
+ return h(
160
+ 'div',
161
+ { className: 'cait-doctabs', role: 'tablist', 'aria-label': t('tabs.title') },
162
+ documents.map((doc) =>
163
+ h(
164
+ 'div',
165
+ { key: doc.id, className: `cait-doctab${doc.id === activeId ? ' active' : ''}` },
166
+ h(
167
+ 'button',
168
+ {
169
+ type: 'button',
170
+ role: 'tab',
171
+ className: 'cait-doctab-main',
172
+ 'aria-selected': doc.id === activeId,
173
+ title: `${doc.name} · ${doc.progress.percent}%`,
174
+ onClick: () => onOpen(doc.id),
175
+ },
176
+ h('span', { className: 'cait-doctab-name' }, doc.name),
177
+ h('span', { className: 'cait-doctab-progress' }, `${doc.progress.percent}%`),
178
+ ),
179
+ h(
180
+ 'button',
181
+ {
182
+ type: 'button',
183
+ className: 'cait-doctab-close',
184
+ title: t('tabs.close'),
185
+ 'aria-label': t('tabs.close'),
186
+ disabled: Boolean(busy),
187
+ onClick: (event) => {
188
+ event.stopPropagation()
189
+ onClose(doc.id)
190
+ },
191
+ },
192
+ h(Icon, { name: 'close', size: 11 }),
193
+ ),
194
+ ),
195
+ ),
196
+ h(
197
+ 'button',
198
+ {
199
+ type: 'button',
200
+ className: 'cait-doctab-new',
201
+ title: t('tabs.new'),
202
+ 'aria-label': t('tabs.new'),
203
+ disabled: Boolean(busy),
204
+ onClick: onNew,
205
+ },
206
+ h(Icon, { name: 'plus', size: 13 }),
207
+ h('span', null, t('tabs.new')),
208
+ ),
209
+ )
210
+ }
211
+
212
+ function ClosedDocPicker({ t, documents, busy, onOpen }) {
213
+ if (!documents?.length) return null
214
+ return h(
215
+ 'label',
216
+ { className: 'cait-doc-select-wrap cait-closed-docs' },
217
+ h('span', { className: 'cait-muted cait-tiny' }, t('doc.openExisting')),
218
+ h(
219
+ 'select',
220
+ {
221
+ className: 'cait-select cait-doc-select',
222
+ value: '',
223
+ disabled: Boolean(busy),
224
+ onChange: (event) => {
225
+ if (event.target.value) onOpen(event.target.value)
226
+ },
227
+ },
228
+ h('option', { value: '' }, t('doc.openExisting')),
229
+ documents.map((doc) =>
230
+ h('option', { key: doc.id, value: doc.id }, `${doc.name} · ${doc.progress.percent}%`),
231
+ ),
232
+ ),
233
+ )
234
+ }
235
+
236
+ function RecentDocsModal({ t, documents, busy, onClose, onDelete, onClear }) {
237
+ return h(
238
+ Modal,
239
+ { title: t('doc.cleanTitle'), onClose },
240
+ h('p', { className: 'cait-modal-hint' }, t('doc.cleanHint')),
241
+ documents.length
242
+ ? h(
243
+ 'div',
244
+ { className: 'cait-recent-list' },
245
+ documents.map((doc) =>
246
+ h(
247
+ 'div',
248
+ { key: doc.id, className: 'cait-recent-row' },
249
+ h(
250
+ 'div',
251
+ { className: 'cait-recent-meta' },
252
+ h('strong', null, doc.name),
253
+ h('small', null, `${doc.sourceLang} → ${doc.targetLang} · ${doc.progress.confirmed}/${doc.progress.total}`),
254
+ ),
255
+ h(
256
+ 'button',
257
+ {
258
+ type: 'button',
259
+ className: 'cait-iconbtn cait-recent-del',
260
+ title: t('doc.delete'),
261
+ 'aria-label': t('doc.delete'),
262
+ disabled: Boolean(busy),
263
+ onClick: () => onDelete(doc),
264
+ },
265
+ h(Icon, { name: 'trash', size: 12 }),
266
+ ),
267
+ ),
268
+ ),
269
+ )
270
+ : h('p', { className: 'cait-muted cait-tiny' }, t('doc.cleanEmpty')),
271
+ h(
272
+ 'div',
273
+ { className: 'cait-modal-actions' },
274
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
275
+ h(
276
+ 'button',
277
+ { className: 'cait-btn cait-btn-danger', onClick: onClear, disabled: Boolean(busy) || !documents.length },
278
+ busy ? h(Spinner) : null,
279
+ t('doc.cleanAll'),
280
+ ),
281
+ ),
282
+ )
283
+ }
284
+
285
+ function Topbar({
286
+ t,
287
+ doc,
288
+ data,
289
+ busy,
290
+ onNew,
291
+ onImport,
292
+ onExport,
293
+ onSettings,
294
+ onOpenDoc,
295
+ onCloseDoc,
296
+ onNewTab,
297
+ onCleanClosed,
298
+ }) {
299
+ const progress = doc?.progress || { percent: 0, confirmed: 0, total: 0 }
300
+ const openIds = new Set((data.openDocuments || []).map((item) => item.id))
301
+ const closedDocuments = (data.documents || []).filter((item) => !openIds.has(item.id))
302
+ return h(
303
+ 'header',
304
+ { className: 'cait-topbar' },
305
+ h(
306
+ 'div',
307
+ { className: 'cait-brand' },
308
+ h('span', { className: 'cait-logo' }, h(CaitMark, { size: 17 })),
309
+ h('span', { className: 'cait-brand-text' }, h('strong', null, 'CAIT'), h('span', null, t('app.subtitle'))),
310
+ ),
311
+ h(
312
+ 'div',
313
+ { className: 'cait-docbar' },
314
+ h(DocTabs, {
315
+ t,
316
+ documents: data.openDocuments || data.documents || [],
317
+ activeId: doc?.id,
318
+ busy,
319
+ onOpen: onOpenDoc,
320
+ onClose: onCloseDoc,
321
+ onNew: onNewTab,
322
+ }),
323
+ h(ClosedDocPicker, { t, documents: closedDocuments, busy, onOpen: onOpenDoc }),
324
+ closedDocuments.length
325
+ ? h(
326
+ 'button',
327
+ {
328
+ type: 'button',
329
+ className: 'cait-doc-clean',
330
+ title: t('doc.clean'),
331
+ 'aria-label': t('doc.clean'),
332
+ disabled: Boolean(busy),
333
+ onClick: onCleanClosed,
334
+ },
335
+ h(Icon, { name: 'trash', size: 13 }),
336
+ )
337
+ : null,
338
+ doc
339
+ ? h('span', { className: 'cait-doc-progress' }, `${progress.confirmed}/${progress.total} · ${progress.percent}%`)
340
+ : null,
341
+ ),
342
+ h(
343
+ 'div',
344
+ { className: 'cait-actions' },
345
+ h(
346
+ 'button',
347
+ { className: 'cait-btn', onClick: onNew, disabled: Boolean(busy), title: t('nav.new') },
348
+ h(Icon, { name: 'plus', size: 14 }),
349
+ h('span', { className: 'cait-btn-label' }, t('nav.new')),
350
+ ),
351
+ h(
352
+ 'button',
353
+ { className: 'cait-btn', onClick: onImport, disabled: Boolean(busy), title: t('nav.import') },
354
+ h(Icon, { name: 'upload', size: 14 }),
355
+ h('span', { className: 'cait-btn-label' }, t('nav.import')),
356
+ ),
357
+ h(
358
+ 'button',
359
+ { className: 'cait-btn', onClick: onExport, disabled: Boolean(busy) || !doc, title: t('nav.export') },
360
+ h(Icon, { name: 'download', size: 14 }),
361
+ h('span', { className: 'cait-btn-label' }, t('nav.export')),
362
+ ),
363
+ h('button', { className: 'cait-iconbtn', onClick: onSettings, disabled: Boolean(busy), title: t('nav.settings') }, h(Icon, { name: 'settings', size: 15 })),
364
+ ),
365
+ )
366
+ }
367
+
368
+ function SegmentRail({ t, doc, selected, query, setQuery, onSelect }) {
369
+ const activeRef = useRef(null)
370
+ useEffect(() => {
371
+ if (activeRef.current) activeRef.current.scrollIntoView({ block: 'nearest' })
372
+ }, [selected])
373
+
374
+ const needle = query.trim().toLowerCase()
375
+ const items = doc.segments
376
+ .map((segment, index) => ({ segment, index }))
377
+ .filter(({ segment }) => {
378
+ if (!needle) return true
379
+ return segment.source.toLowerCase().includes(needle) || (segment.target || '').toLowerCase().includes(needle)
380
+ })
381
+
382
+ return h(
383
+ 'aside',
384
+ { className: 'cait-rail' },
385
+ h(
386
+ 'div',
387
+ { className: 'cait-rail-head' },
388
+ h('div', { className: 'cait-rail-title' }, h('span', null, t('rail.title')), h('span', { className: 'cait-count' }, doc.progress.total)),
389
+ h('div', { className: 'cait-mini-progress' }, h('i', { style: { width: `${doc.progress.percent}%` } })),
390
+ ),
391
+ h(
392
+ 'div',
393
+ { className: 'cait-rail-search' },
394
+ h(Icon, { name: 'search', size: 13 }),
395
+ h('input', { value: query, onChange: (event) => setQuery(event.target.value), placeholder: t('rail.search'), 'aria-label': t('rail.search') }),
396
+ ),
397
+ h(
398
+ 'div',
399
+ { className: 'cait-rail-list' },
400
+ items.length
401
+ ? items.map(({ segment, index }) =>
402
+ h(
403
+ 'button',
404
+ {
405
+ key: segment.id,
406
+ ref: index === selected ? activeRef : null,
407
+ className: `cait-segitem${index === selected ? ' active' : ''}`,
408
+ onClick: () => onSelect(index),
409
+ title: segment.source,
410
+ },
411
+ h(
412
+ 'span',
413
+ { className: 'cait-seg-main' },
414
+ h(
415
+ 'span',
416
+ { className: 'cait-seg-line' },
417
+ h('span', { className: 'cait-seg-index' }, String(index + 1).padStart(2, '0')),
418
+ h('span', { className: `cait-dot ${segment.status}` }),
419
+ ),
420
+ h('span', { className: 'cait-seg-text' }, segment.source),
421
+ segment.target ? h('span', { className: 'cait-seg-target' }, segment.target) : null,
422
+ ),
423
+ ),
424
+ )
425
+ : h('div', { className: 'cait-empty-note' }, t('rail.empty')),
426
+ ),
427
+ )
428
+ }
429
+
430
+ function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onNext, onPrev, onSave, onApplyTerm, textareaRef }) {
431
+ const hits = detail?.index === index ? detail.glossaryHits : []
432
+ const charCount = Array.from(draft || '').length
433
+ const progress = doc.progress
434
+
435
+ const onKeyDown = (event) => {
436
+ const mod = event.metaKey || event.ctrlKey
437
+ if (mod && event.key === 'Enter') {
438
+ event.preventDefault()
439
+ onConfirm()
440
+ } else if (mod && event.key.toLowerCase() === 's') {
441
+ event.preventDefault()
442
+ onSave()
443
+ } else if (event.altKey && event.key === 'ArrowDown') {
444
+ event.preventDefault()
445
+ onNext()
446
+ } else if (event.altKey && event.key === 'ArrowUp') {
447
+ event.preventDefault()
448
+ onPrev()
449
+ }
450
+ }
451
+
452
+ return h(
453
+ 'div',
454
+ { className: 'cait-editor-col' },
455
+ h(
456
+ 'div',
457
+ { className: 'cait-doc-head' },
458
+ h(
459
+ 'div',
460
+ null,
461
+ h('h2', { className: 'cait-doc-title' }, doc.name),
462
+ h(
463
+ 'div',
464
+ { className: 'cait-doc-meta' },
465
+ h('span', { className: 'cait-langchip' }, doc.sourceLang),
466
+ h(Icon, { name: 'arrowRight', size: 11 }),
467
+ h('span', { className: 'cait-langchip' }, doc.targetLang),
468
+ h('span', null, `· ${doc.segments.length} ${t('doc.segments')}`),
469
+ ),
470
+ ),
471
+ h(
472
+ 'div',
473
+ { className: 'cait-progress-box' },
474
+ h('span', { className: 'cait-pct' }, `${progress.percent}%`),
475
+ h('div', { className: 'cait-progress' }, h('i', { style: { width: `${progress.percent}%` } })),
476
+ h('span', { className: 'cait-meta' }, `${progress.confirmed}/${progress.total} ${t('progress.confirmed')}`),
477
+ ),
478
+ ),
479
+ h(
480
+ 'div',
481
+ { className: 'cait-seg-head' },
482
+ h('span', { className: 'cait-seg-label' }, `${t('editor.segment')} ${index + 1} / ${doc.segments.length}`),
483
+ h('span', { className: `cait-badge ${segment.status}` }, segment.status === 'confirmed' ? t('status.confirmed') : t('status.pending')),
484
+ ),
485
+ h(
486
+ 'div',
487
+ { className: 'cait-source-card' },
488
+ h(
489
+ 'div',
490
+ { className: 'cait-labelrow' },
491
+ h('span', { className: 'cait-label' }, t('editor.source')),
492
+ h('span', { className: 'cait-langchip' }, doc.sourceLang),
493
+ ),
494
+ h('div', { className: 'cait-source-text' }, highlightSource(segment.source, hits)),
495
+ hits.length
496
+ ? h(
497
+ 'div',
498
+ { className: 'cait-hitrow' },
499
+ hits.map((hit) =>
500
+ h(
501
+ 'button',
502
+ {
503
+ key: hit.id,
504
+ className: 'cait-chip cait-chip-action',
505
+ onClick: () => onApplyTerm(hit),
506
+ title: hit.note || `${hit.term} → ${hit.target}`,
507
+ },
508
+ h(Icon, { name: 'book', size: 11 }),
509
+ `${hit.term} → ${hit.target}`,
510
+ ),
511
+ ),
512
+ )
513
+ : null,
514
+ ),
515
+ h(
516
+ 'div',
517
+ { className: 'cait-target-area' },
518
+ h(
519
+ 'div',
520
+ { className: 'cait-labelrow' },
521
+ h('span', { className: 'cait-label' }, t('editor.target')),
522
+ h(
523
+ 'span',
524
+ { className: 'cait-meta' },
525
+ `${charCount} ${t('editor.chars')}${dirty ? ` · ${t('editor.unsaved')}` : ''}`,
526
+ ),
527
+ ),
528
+ h('textarea', {
529
+ ref: textareaRef,
530
+ className: 'cait-textarea',
531
+ value: draft,
532
+ onChange: (event) => setDraft(event.target.value),
533
+ onKeyDown,
534
+ placeholder: t('editor.targetPlaceholder'),
535
+ spellCheck: false,
536
+ rows: 8,
537
+ }),
538
+ ),
539
+ h(
540
+ 'div',
541
+ { className: 'cait-actionbar' },
542
+ h(
543
+ 'button',
544
+ { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || !draft.trim() },
545
+ busy === 'confirm' ? h(Spinner) : h(Icon, { name: 'check', size: 14 }),
546
+ segment.status === 'confirmed' ? t('editor.update') : t('editor.confirm'),
547
+ ),
548
+ h(
549
+ 'button',
550
+ { className: 'cait-btn', onClick: onNext, disabled: Boolean(busy) },
551
+ t('editor.next'),
552
+ h(Icon, { name: 'chevronRight', size: 13 }),
553
+ ),
554
+ h(
555
+ 'button',
556
+ { className: 'cait-btn cait-btn-ghost', onClick: onPrev, disabled: Boolean(busy) },
557
+ h(Icon, { name: 'chevronLeft', size: 13 }),
558
+ t('editor.prev'),
559
+ ),
560
+ h(
561
+ 'button',
562
+ { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || !dirty, title: t('editor.save') },
563
+ h(Icon, { name: 'save', size: 14 }),
564
+ ),
565
+ h('span', { className: 'cait-hint' }, t('editor.hint')),
566
+ ),
567
+ h(
568
+ 'div',
569
+ { className: 'cait-editor-foot' },
570
+ progress.pending === 0
571
+ ? h('span', { className: 'cait-donechip' }, h(Icon, { name: 'check', size: 11 }), t('editor.done'))
572
+ : h('span', null, `${progress.pending} ${t('status.pending')}`),
573
+ segment.confirmedAt ? h('span', null, `${t('editor.confirmedAt')} ${new Date(segment.confirmedAt).toLocaleString()}`) : null,
574
+ ),
575
+ )
576
+ }
577
+
578
+ function TmMatchCard({ t, match, onApply }) {
579
+ const matchType = match.matchType || (match.exact ? 'exact' : 'low_fuzzy')
580
+ return h(
581
+ 'article',
582
+ { className: 'cait-card' },
583
+ h(
584
+ 'div',
585
+ { className: 'cait-card-head' },
586
+ h('span', { className: `cait-score ${matchType}` }, `${match.percent}%`),
587
+ h('span', { className: 'cait-score-label' }, t(`match.${matchType}`)),
588
+ h('button', { className: 'cait-minibtn', onClick: () => onApply(match) }, t('panel.apply')),
589
+ ),
590
+ h('p', { className: 'cait-card-source' }, match.source),
591
+ h('p', { className: 'cait-card-target' }, match.target),
592
+ )
593
+ }
594
+
595
+ function CatPanel({
596
+ t,
597
+ doc,
598
+ data,
599
+ settings,
600
+ mtRoutes,
601
+ tab,
602
+ setTab,
603
+ tmQuery,
604
+ setTmQuery,
605
+ tmResults,
606
+ tmError,
607
+ onApplyTm,
608
+ onImportTmx,
609
+ onClearTm,
610
+ onAddTerm,
611
+ onDeleteTerm,
612
+ onApplyTerm,
613
+ onImportGlossary,
614
+ onClearGlossary,
615
+ mt,
616
+ mtError,
617
+ mtBusy,
618
+ onGenerateMt,
619
+ onApplyMt,
620
+ onCopy,
621
+ onOpenSettings,
622
+ onOpenLibraries,
623
+ busy,
624
+ }) {
625
+ const [termQuery, setTermQuery] = useState('')
626
+ const [showTermForm, setShowTermForm] = useState(false)
627
+ const [termForm, setTermForm] = useState({ source: '', target: '', note: '' })
628
+ const tmxInput = useRef(null)
629
+ const glossaryInput = useRef(null)
630
+ const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
631
+ const currentRoute = routes.find((route) => route.id === settings.mt.provider)
632
+ const mtProviderLabel =
633
+ settings.mt.provider === 'mock'
634
+ ? t('mt.mock')
635
+ : currentRoute?.name || settings.mt.provider || t('mt.mock')
636
+
637
+ const current = doc.current
638
+ const tmMatches = tmResults?.matches ?? current?.tmMatches ?? []
639
+ const hits = current?.glossaryHits ?? []
640
+ const needle = termQuery.trim().toLowerCase()
641
+ const terms = (data.glossary || [])
642
+ .filter((term) => !needle || term.source.toLowerCase().includes(needle) || term.target.toLowerCase().includes(needle))
643
+ .slice(0, 200)
644
+
645
+ const submitTerm = async (event) => {
646
+ event.preventDefault()
647
+ if (!termForm.source.trim() || !termForm.target.trim()) return
648
+ const ok = await onAddTerm(termForm)
649
+ if (ok) {
650
+ setTermForm({ source: '', target: '', note: '' })
651
+ setShowTermForm(false)
652
+ }
653
+ }
654
+
655
+ return h(
656
+ 'aside',
657
+ { className: 'cait-cat' },
658
+ h(
659
+ 'div',
660
+ { className: 'cait-cat-head' },
661
+ h('span', { className: 'cait-cat-title' }, t('panel.title')),
662
+ h(
663
+ 'div',
664
+ { className: 'cait-cat-head-actions' },
665
+ h(
666
+ 'button',
667
+ {
668
+ className: 'cait-btn cait-libopen',
669
+ onClick: onOpenLibraries,
670
+ disabled: Boolean(busy),
671
+ title: t('libraries.open'),
672
+ },
673
+ h(Icon, { name: 'database', size: 12 }),
674
+ t('libraries.short'),
675
+ ),
676
+ h('span', { className: 'cait-live-dot', title: t('panel.live') }),
677
+ ),
678
+ ),
679
+ h(
680
+ 'div',
681
+ { className: 'cait-tabs' },
682
+ h(
683
+ 'button',
684
+ { className: `cait-tab${tab === 'tm' ? ' active' : ''}`, onClick: () => setTab('tm') },
685
+ h(Icon, { name: 'database', size: 13 }),
686
+ t('panel.tm'),
687
+ h('span', { className: 'cait-count' }, data.tm.count),
688
+ ),
689
+ h(
690
+ 'button',
691
+ { className: `cait-tab${tab === 'glossary' ? ' active' : ''}`, onClick: () => setTab('glossary') },
692
+ h(Icon, { name: 'book', size: 13 }),
693
+ t('panel.glossary'),
694
+ h('span', { className: 'cait-count' }, data.glossary.length),
695
+ ),
696
+ h(
697
+ 'button',
698
+ { className: `cait-tab${tab === 'mt' ? ' active' : ''}`, onClick: () => setTab('mt') },
699
+ h(Icon, { name: 'sparkles', size: 13 }),
700
+ t('panel.mt'),
701
+ ),
702
+ ),
703
+ h(
704
+ 'div',
705
+ { className: 'cait-tabbody' },
706
+ tab === 'tm'
707
+ ? h(
708
+ 'div',
709
+ null,
710
+ h(
711
+ 'form',
712
+ {
713
+ className: 'cait-search',
714
+ onSubmit: (event) => {
715
+ event.preventDefault()
716
+ setTmQuery(tmQuery)
717
+ },
718
+ },
719
+ h(Icon, { name: 'search', size: 13 }),
720
+ h('input', {
721
+ value: tmQuery,
722
+ onChange: (event) => {
723
+ setTmQuery(event.target.value)
724
+ if (tmResults) {
725
+ // clearing the field returns to current-segment matches
726
+ }
727
+ },
728
+ placeholder: t('panel.searchPlaceholder'),
729
+ 'aria-label': t('panel.searchPlaceholder'),
730
+ }),
731
+ h('button', { className: 'cait-iconbtn', type: 'submit', title: t('panel.search') }, h(Icon, { name: 'arrowRight', size: 13 })),
732
+ ),
733
+ h(
734
+ 'div',
735
+ { className: 'cait-listhead' },
736
+ h('span', null, tmResults ? t('panel.searchResults') : t('panel.currentMatches')),
737
+ h('span', { className: 'cait-count' }, tmError ? '!' : tmMatches.length),
738
+ ),
739
+ tmError ? h('div', { className: 'cait-mt-error' }, tmError) : null,
740
+ tmMatches.length
741
+ ? tmMatches.map((match) => h(TmMatchCard, { key: match.id, t, match, onApply: onApplyTm }))
742
+ : h('p', { className: 'cait-empty-note' }, tmResults ? t('panel.noMatches') : t('panel.noCurrentMatches')),
743
+ h('p', { className: 'cait-mt-hint' }, t('panel.tmHint')),
744
+ h(
745
+ 'div',
746
+ { className: 'cait-panel-foot' },
747
+ h('input', {
748
+ ref: tmxInput,
749
+ type: 'file',
750
+ accept: '.tmx,application/xml,text/xml',
751
+ hidden: true,
752
+ onChange: (event) => {
753
+ const file = event.target.files?.[0]
754
+ if (file) onImportTmx(file)
755
+ event.target.value = ''
756
+ },
757
+ }),
758
+ h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importTmx')),
759
+ h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearTm, disabled: Boolean(busy) || data.tm.count === 0 }, t('panel.clearTm')),
760
+ ),
761
+ )
762
+ : null,
763
+ tab === 'glossary'
764
+ ? h(
765
+ 'div',
766
+ null,
767
+ h('div', { className: 'cait-subhead' }, h('span', null, t('panel.termHits')), h('span', { className: 'cait-count' }, hits.length)),
768
+ hits.length
769
+ ? h(
770
+ 'div',
771
+ { className: 'cait-hitrow', style: { marginTop: 0, marginBottom: 10 } },
772
+ hits.map((hit) =>
773
+ h(
774
+ 'button',
775
+ { key: hit.id, className: 'cait-chip cait-chip-action', onClick: () => onApplyTerm(hit), title: hit.note || hit.target },
776
+ `${hit.term} → ${hit.target}`,
777
+ ),
778
+ ),
779
+ )
780
+ : h('p', { className: 'cait-empty-note' }, t('panel.noHits')),
781
+ h(
782
+ 'div',
783
+ { className: 'cait-subhead' },
784
+ h('span', null, t('panel.allTerms')),
785
+ h('button', { className: 'cait-linkbtn', onClick: () => setShowTermForm((value) => !value) }, `+ ${t('panel.addTerm')}`),
786
+ ),
787
+ showTermForm
788
+ ? h(
789
+ 'form',
790
+ { className: 'cait-termform', onSubmit: submitTerm },
791
+ h('input', {
792
+ className: 'cait-input',
793
+ value: termForm.source,
794
+ onChange: (event) => setTermForm({ ...termForm, source: event.target.value }),
795
+ placeholder: t('panel.termSource'),
796
+ autoFocus: true,
797
+ }),
798
+ h(
799
+ 'div',
800
+ { className: 'cait-formrow' },
801
+ h('input', {
802
+ className: 'cait-input',
803
+ value: termForm.target,
804
+ onChange: (event) => setTermForm({ ...termForm, target: event.target.value }),
805
+ placeholder: t('panel.termTarget'),
806
+ }),
807
+ h('input', {
808
+ className: 'cait-input',
809
+ value: termForm.note,
810
+ onChange: (event) => setTermForm({ ...termForm, note: event.target.value }),
811
+ placeholder: t('panel.termNote'),
812
+ }),
813
+ ),
814
+ h(
815
+ 'div',
816
+ { className: 'cait-formrow' },
817
+ h('button', { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !termForm.source.trim() || !termForm.target.trim() }, t('panel.saveTerm')),
818
+ h('button', { className: 'cait-btn cait-btn-ghost', type: 'button', onClick: () => setShowTermForm(false) }, t('panel.cancel')),
819
+ ),
820
+ )
821
+ : null,
822
+ h(
823
+ 'div',
824
+ { className: 'cait-search' },
825
+ h(Icon, { name: 'search', size: 13 }),
826
+ h('input', { value: termQuery, onChange: (event) => setTermQuery(event.target.value), placeholder: t('panel.termSearch'), 'aria-label': t('panel.termSearch') }),
827
+ ),
828
+ terms.length
829
+ ? terms.map((term) =>
830
+ h(
831
+ 'div',
832
+ { key: term.id, className: 'cait-term' },
833
+ h(
834
+ 'div',
835
+ { className: 'cait-term-main' },
836
+ h('strong', null, term.source),
837
+ h(Icon, { name: 'arrowRight', size: 11, className: 'cait-term-arrow' }),
838
+ h('span', null, term.target),
839
+ ),
840
+ term.note ? h('div', { className: 'cait-muted cait-tiny', style: { marginTop: 2 } }, term.note) : null,
841
+ h(
842
+ 'div',
843
+ { className: 'cait-term-actions' },
844
+ h('button', { className: 'cait-minibtn', onClick: () => onApplyTerm(term) }, t('panel.apply')),
845
+ h('button', { className: 'cait-deletebtn', onClick: () => onDeleteTerm(term), title: t('panel.delete') }, h(Icon, { name: 'trash', size: 11 })),
846
+ ),
847
+ ),
848
+ )
849
+ : h('p', { className: 'cait-empty-note' }, t('panel.noTerms')),
850
+ h(
851
+ 'div',
852
+ { className: 'cait-panel-foot' },
853
+ h('input', {
854
+ ref: glossaryInput,
855
+ type: 'file',
856
+ accept: '.csv,.tsv,.txt',
857
+ hidden: true,
858
+ onChange: (event) => {
859
+ const file = event.target.files?.[0]
860
+ if (file) onImportGlossary(file)
861
+ event.target.value = ''
862
+ },
863
+ }),
864
+ h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importCsv')),
865
+ h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearGlossary, disabled: Boolean(busy) || data.glossary.length === 0 }, t('panel.clearGlossary')),
866
+ ),
867
+ )
868
+ : null,
869
+ tab === 'mt'
870
+ ? h(
871
+ 'div',
872
+ null,
873
+ h(
874
+ 'div',
875
+ { className: 'cait-mt-head' },
876
+ h('span', { className: 'cait-mt-mode' }, mtProviderLabel),
877
+ h(
878
+ 'span',
879
+ { className: 'cait-muted cait-tiny' },
880
+ `${t('panel.mtModel')}: ${settings.mt.model || '—'}`,
881
+ ),
882
+ ),
883
+ h(
884
+ 'div',
885
+ { className: 'cait-card' },
886
+ h('div', { className: 'cait-labelrow' }, h('span', { className: 'cait-label' }, t('panel.mtRoute')), h('span', { className: 'cait-meta' }, settings.mt.provider || '—')),
887
+ h(
888
+ 'button',
889
+ { className: 'cait-btn cait-btn-primary', onClick: onGenerateMt, disabled: Boolean(busy) || mtBusy, style: { width: '100%' } },
890
+ mtBusy ? h(Spinner) : h(Icon, { name: 'sparkles', size: 14 }),
891
+ mtBusy ? t('panel.mtGenerating') : t('panel.mtGenerate'),
892
+ ),
893
+ ),
894
+ mtError ? h('div', { className: 'cait-mt-error' }, mtError) : null,
895
+ mt
896
+ ? h(
897
+ 'article',
898
+ { className: 'cait-card cait-mt-card' },
899
+ h(
900
+ 'div',
901
+ { className: 'cait-card-head' },
902
+ h('span', { className: 'cait-score exact' }, t('panel.mtSuggestion')),
903
+ h('span', { className: 'cait-score-label' }, `${mt.latencyMs} ms`),
904
+ ),
905
+ h('p', { className: 'cait-mt-text' }, mt.text),
906
+ mt.note ? h('p', { className: 'cait-mt-hint' }, mt.note) : null,
907
+ h(
908
+ 'div',
909
+ { className: 'cait-mt-actions' },
910
+ h('button', { className: 'cait-minibtn', onClick: () => onCopy(mt.text) }, t('panel.copy')),
911
+ h('button', { className: 'cait-minibtn', onClick: onApplyMt }, t('panel.mtInsert')),
912
+ ),
913
+ )
914
+ : h('p', { className: 'cait-empty-note' }, t('panel.mtEmpty')),
915
+ h(
916
+ 'p',
917
+ { className: 'cait-mt-hint' },
918
+ settings.mt.provider === 'mock'
919
+ ? t('panel.mtMockHint')
920
+ : t('panel.mtHint'),
921
+ ),
922
+ h(
923
+ 'div',
924
+ { className: 'cait-panel-foot' },
925
+ h('button', { className: 'cait-btn', onClick: onOpenSettings }, h(Icon, { name: 'settings', size: 12 }), t('panel.mtSettings')),
926
+ ),
927
+ )
928
+ : null,
929
+ ),
930
+ )
931
+ }
932
+
933
+ function DocumentSourceForm({ t, settings, busy, submitLabel, onSubmit, defaultMode = 'text' }) {
934
+ const [mode, setMode] = useState(defaultMode)
935
+ const [name, setName] = useState('')
936
+ const [text, setText] = useState('')
937
+ const [file, setFile] = useState(null)
938
+ const [sourceLang, setSourceLang] = useState(settings.sourceLang || 'en')
939
+ const [targetLang, setTargetLang] = useState(settings.targetLang || 'zh-CN')
940
+ const [csvSourceColumn, setCsvSourceColumn] = useState('')
941
+ const [csvTargetColumn, setCsvTargetColumn] = useState('')
942
+ const inputRef = useRef(null)
943
+
944
+ useEffect(() => {
945
+ if (settings.sourceLang) setSourceLang(settings.sourceLang)
946
+ if (settings.targetLang) setTargetLang(settings.targetLang)
947
+ }, [settings.sourceLang, settings.targetLang])
948
+
949
+ const pickFile = (nextFile) => {
950
+ if (!nextFile) return
951
+ setFile(nextFile)
952
+ setMode('file')
953
+ if (!name.trim()) setName(stripExtension(nextFile.name))
954
+ }
955
+
956
+ const submit = (event) => {
957
+ event.preventDefault()
958
+ onSubmit({
959
+ mode,
960
+ name,
961
+ text,
962
+ file,
963
+ sourceLang,
964
+ targetLang,
965
+ csvSourceColumn,
966
+ csvTargetColumn,
967
+ })
968
+ }
969
+
970
+ const canSubmit = mode === 'file' ? Boolean(file) : Boolean(text.trim())
971
+ const isCsv = mode === 'file' && /\.csv$/i.test(file?.name || '')
972
+
973
+ return h(
974
+ 'form',
975
+ { className: 'cait-docform', onSubmit: submit },
976
+ h(
977
+ 'div',
978
+ { className: 'cait-segmented' },
979
+ h('button', { type: 'button', className: mode === 'text' ? 'active' : '', onClick: () => setMode('text') }, t('empty.paste')),
980
+ h('button', { type: 'button', className: mode === 'file' ? 'active' : '', onClick: () => setMode('file') }, t('empty.file')),
981
+ ),
982
+ h(
983
+ 'label',
984
+ { className: 'cait-field' },
985
+ h('span', null, t('modal.new.name')),
986
+ h('input', {
987
+ className: 'cait-input',
988
+ value: name,
989
+ onChange: (event) => setName(event.target.value),
990
+ placeholder: t('modal.new.namePlaceholder'),
991
+ }),
992
+ ),
993
+ h(
994
+ 'div',
995
+ { className: 'cait-grid2' },
996
+ h(
997
+ 'label',
998
+ { className: 'cait-field' },
999
+ h('span', null, t('modal.new.sourceLang')),
1000
+ h(
1001
+ 'select',
1002
+ { className: 'cait-input', value: sourceLang, onChange: (event) => setSourceLang(event.target.value) },
1003
+ LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1004
+ ),
1005
+ ),
1006
+ h(
1007
+ 'label',
1008
+ { className: 'cait-field' },
1009
+ h('span', null, t('modal.new.targetLang')),
1010
+ h(
1011
+ 'select',
1012
+ { className: 'cait-input', value: targetLang, onChange: (event) => setTargetLang(event.target.value) },
1013
+ LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1014
+ ),
1015
+ ),
1016
+ ),
1017
+ isCsv
1018
+ ? h(
1019
+ 'div',
1020
+ { className: 'cait-grid2' },
1021
+ h(
1022
+ 'label',
1023
+ { className: 'cait-field' },
1024
+ h('span', null, t('modal.new.csvSource')),
1025
+ h('input', {
1026
+ className: 'cait-input',
1027
+ value: csvSourceColumn,
1028
+ onChange: (event) => setCsvSourceColumn(event.target.value),
1029
+ placeholder: t('modal.new.csvColumnPlaceholder'),
1030
+ }),
1031
+ ),
1032
+ h(
1033
+ 'label',
1034
+ { className: 'cait-field' },
1035
+ h('span', null, t('modal.new.csvTarget')),
1036
+ h('input', {
1037
+ className: 'cait-input',
1038
+ value: csvTargetColumn,
1039
+ onChange: (event) => setCsvTargetColumn(event.target.value),
1040
+ placeholder: t('modal.new.csvColumnPlaceholder'),
1041
+ }),
1042
+ ),
1043
+ h('p', { className: 'cait-modal-hint' }, t('modal.new.csvHint')),
1044
+ )
1045
+ : null,
1046
+ mode === 'text'
1047
+ ? h('textarea', {
1048
+ className: 'cait-formtext',
1049
+ value: text,
1050
+ onChange: (event) => setText(event.target.value),
1051
+ placeholder: t('empty.textPlaceholder'),
1052
+ rows: 6,
1053
+ })
1054
+ : h(
1055
+ 'label',
1056
+ {
1057
+ className: 'cait-drop',
1058
+ onDragOver: (event) => event.preventDefault(),
1059
+ onDrop: (event) => {
1060
+ event.preventDefault()
1061
+ pickFile(event.dataTransfer?.files?.[0])
1062
+ },
1063
+ },
1064
+ h('input', {
1065
+ ref: inputRef,
1066
+ type: 'file',
1067
+ accept: '.txt,.md,.markdown,.tmx,.docx,.epub,.csv,.srt,.vtt,.po,.pot,.xliff,.xlf,.sdlxliff,.html,.htm,.json,.rtf,.xlsx,.pptx',
1068
+ hidden: true,
1069
+ onChange: (event) => pickFile(event.target.files?.[0]),
1070
+ }),
1071
+ h(Icon, { name: file ? 'file' : 'upload', size: 22 }),
1072
+ h('strong', null, file ? file.name : t('empty.drop')),
1073
+ h('span', null, file ? formatBytes(file.size) : t('modal.new.dropHint')),
1074
+ ),
1075
+ h(
1076
+ 'div',
1077
+ { className: 'cait-formfoot' },
1078
+ h('span', { className: 'cait-muted' }, t('empty.formats')),
1079
+ h(
1080
+ 'button',
1081
+ { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !canSubmit },
1082
+ busy === 'create' ? h(Spinner) : h(Icon, { name: 'plus', size: 13 }),
1083
+ busy === 'create' ? t('modal.new.importing') : submitLabel,
1084
+ ),
1085
+ ),
1086
+ )
1087
+ }
1088
+
1089
+ function NewDocModal({ t, settings, busy, defaultMode, onClose, onCreate }) {
1090
+ return h(
1091
+ Modal,
1092
+ { title: t('modal.new.title'), onClose },
1093
+ h(DocumentSourceForm, { t, settings, busy, submitLabel: t('modal.new.create'), onSubmit: onCreate, defaultMode }),
1094
+ h(
1095
+ 'div',
1096
+ { className: 'cait-demo-row' },
1097
+ h('span', null, t('modal.new.sample')),
1098
+ h('button', { className: 'cait-linkbtn', onClick: () => onCreate({ demo: true }), disabled: Boolean(busy) }, t('modal.new.demo')),
1099
+ ),
1100
+ )
1101
+ }
1102
+
1103
+ function ExportModal({ t, busy, onClose, onExport }) {
1104
+ const items = [
1105
+ ['bilingual-md', t('modal.export.bilingualMd'), t('modal.export.bilingual'), 'file'],
1106
+ ['bilingual-csv', t('modal.export.bilingualCsv'), t('modal.export.bilingual'), 'file'],
1107
+ ['tmx', t('modal.export.tmx'), t('modal.export.bilingual'), 'database'],
1108
+ ['target-txt', t('modal.export.targetTxt'), t('modal.export.target'), 'download'],
1109
+ ['target-md', t('modal.export.targetMd'), t('modal.export.target'), 'download'],
1110
+ ['docx', t('modal.export.docx'), t('modal.export.docxGroup'), 'file'],
1111
+ ['epub', t('modal.export.epub'), t('modal.export.epubGroup'), 'file'],
1112
+ ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
1113
+ ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
1114
+ ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
1115
+ ['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
1116
+ ['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
1117
+ ['sdlxliff', t('modal.export.sdlxliff'), t('modal.export.localizationGroup'), 'file'],
1118
+ ['html', t('modal.export.html'), t('modal.export.webGroup'), 'file'],
1119
+ ['json-tree', t('modal.export.jsonTree'), t('modal.export.webGroup'), 'file'],
1120
+ ['rtf', t('modal.export.rtf'), t('modal.export.officeGroup'), 'file'],
1121
+ ['xlsx', t('modal.export.xlsx'), t('modal.export.officeGroup'), 'file'],
1122
+ ['pptx', t('modal.export.pptx'), t('modal.export.officeGroup'), 'file'],
1123
+ ['document-json', t('modal.export.json'), t('modal.export.current'), 'save'],
1124
+ ]
1125
+ return h(
1126
+ Modal,
1127
+ { title: t('modal.export.title'), onClose, wide: true },
1128
+ h('p', { className: 'cait-confirm-text' }, t('modal.export.desc')),
1129
+ h(
1130
+ 'div',
1131
+ { className: 'cait-export-grid' },
1132
+ items.map(([format, label, group, icon]) =>
1133
+ h(
1134
+ 'button',
1135
+ { key: format, className: 'cait-export-item', onClick: () => onExport(format), disabled: Boolean(busy) },
1136
+ h(Icon, { name: icon, size: 15 }),
1137
+ h('span', null, h('strong', null, label), h('small', null, group)),
1138
+ ),
1139
+ ),
1140
+ ),
1141
+ )
1142
+ }
1143
+
1144
+ function SettingsModal({ t, settings, mtRoutes, busy, onClose, onSave }) {
1145
+ const [form, setForm] = useState({
1146
+ sourceLang: settings.sourceLang,
1147
+ targetLang: settings.targetLang,
1148
+ fuzzyThreshold: settings.fuzzyThreshold,
1149
+ tmLimit: settings.tmLimit,
1150
+ uiLang: settings.uiLang || 'auto',
1151
+ preprocess: {
1152
+ normalizeWhitespace: settings.preprocess?.normalizeWhitespace !== false,
1153
+ stripInvisible: settings.preprocess?.stripInvisible !== false,
1154
+ collapseBlankLines: settings.preprocess?.collapseBlankLines !== false,
1155
+ },
1156
+ mt: {
1157
+ provider: settings.mt.provider,
1158
+ model: settings.mt.model,
1159
+ temperature: settings.mt.temperature,
1160
+ },
1161
+ })
1162
+ const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
1163
+ const route = routes.find((item) => item.id === form.mt.provider)
1164
+ const models = Array.isArray(route?.models) ? route.models : []
1165
+ const routeMissing = form.mt.provider !== 'mock' && !route
1166
+
1167
+ const setMt = (patch) => setForm((current) => ({ ...current, mt: { ...current.mt, ...patch } }))
1168
+ const setPre = (patch) =>
1169
+ setForm((current) => ({ ...current, preprocess: { ...current.preprocess, ...patch } }))
1170
+ const selectProvider = (provider) => {
1171
+ const nextRoute = routes.find((item) => item.id === provider)
1172
+ setForm((current) => ({
1173
+ ...current,
1174
+ mt: {
1175
+ ...current.mt,
1176
+ provider,
1177
+ model:
1178
+ provider === 'mock'
1179
+ ? 'cait-mock'
1180
+ : nextRoute?.models?.[0]?.id || '',
1181
+ },
1182
+ }))
1183
+ }
1184
+
1185
+ const submit = (event) => {
1186
+ event.preventDefault()
1187
+ const patch = {
1188
+ sourceLang: form.sourceLang,
1189
+ targetLang: form.targetLang,
1190
+ fuzzyThreshold: Number(form.fuzzyThreshold),
1191
+ tmLimit: Number(form.tmLimit),
1192
+ uiLang: form.uiLang,
1193
+ preprocess: {
1194
+ normalizeWhitespace: Boolean(form.preprocess.normalizeWhitespace),
1195
+ stripInvisible: Boolean(form.preprocess.stripInvisible),
1196
+ collapseBlankLines: Boolean(form.preprocess.collapseBlankLines),
1197
+ },
1198
+ mt: {
1199
+ provider: form.mt.provider,
1200
+ model: form.mt.model,
1201
+ temperature: Number(form.mt.temperature),
1202
+ },
1203
+ }
1204
+ onSave(patch)
1205
+ }
1206
+
1207
+ return h(
1208
+ Modal,
1209
+ { title: t('modal.settings.title'), onClose, wide: true },
1210
+ h(
1211
+ 'form',
1212
+ { onSubmit: submit },
1213
+ h(
1214
+ 'div',
1215
+ { className: 'cait-settings-section' },
1216
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.ui')),
1217
+ h(
1218
+ 'label',
1219
+ { className: 'cait-field' },
1220
+ h('span', null, t('modal.settings.uiLanguage')),
1221
+ h(
1222
+ 'select',
1223
+ {
1224
+ className: 'cait-input',
1225
+ value: form.uiLang,
1226
+ onChange: (event) => setForm({ ...form, uiLang: event.target.value }),
1227
+ },
1228
+ UI_LANGS.map(([code, label]) =>
1229
+ h('option', { key: code, value: code }, label || t('modal.settings.uiAuto')),
1230
+ ),
1231
+ ),
1232
+ ),
1233
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.uiHint')),
1234
+ ),
1235
+ h(
1236
+ 'div',
1237
+ { className: 'cait-settings-section' },
1238
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.languages')),
1239
+ h(
1240
+ 'div',
1241
+ { className: 'cait-grid2' },
1242
+ h(
1243
+ 'label',
1244
+ { className: 'cait-field' },
1245
+ h('span', null, t('modal.new.sourceLang')),
1246
+ h(
1247
+ 'select',
1248
+ { className: 'cait-input', value: form.sourceLang, onChange: (event) => setForm({ ...form, sourceLang: event.target.value }) },
1249
+ LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1250
+ ),
1251
+ ),
1252
+ h(
1253
+ 'label',
1254
+ { className: 'cait-field' },
1255
+ h('span', null, t('modal.new.targetLang')),
1256
+ h(
1257
+ 'select',
1258
+ { className: 'cait-input', value: form.targetLang, onChange: (event) => setForm({ ...form, targetLang: event.target.value }) },
1259
+ LANGUAGES.map(([code, label]) => h('option', { key: code, value: code }, `${label} (${code})`)),
1260
+ ),
1261
+ ),
1262
+ ),
1263
+ ),
1264
+ h(
1265
+ 'div',
1266
+ { className: 'cait-settings-section' },
1267
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.preprocess')),
1268
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.preprocessHint')),
1269
+ ['normalizeWhitespace', 'stripInvisible', 'collapseBlankLines'].map((key) =>
1270
+ h(
1271
+ 'label',
1272
+ { key, className: 'cait-checkline' },
1273
+ h('input', {
1274
+ type: 'checkbox',
1275
+ checked: Boolean(form.preprocess[key]),
1276
+ onChange: (event) => setPre({ [key]: event.target.checked }),
1277
+ }),
1278
+ t(`modal.settings.pre.${key}`),
1279
+ ),
1280
+ ),
1281
+ ),
1282
+ h(
1283
+ 'div',
1284
+ { className: 'cait-settings-section' },
1285
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.tm')),
1286
+ h(
1287
+ 'label',
1288
+ { className: 'cait-field' },
1289
+ h('span', null, `${t('modal.settings.fuzzy')} · ${Math.round(Number(form.fuzzyThreshold) * 100)}%`),
1290
+ h('input', {
1291
+ className: 'cait-range',
1292
+ type: 'range',
1293
+ min: 0.3,
1294
+ max: 1,
1295
+ step: 0.05,
1296
+ value: form.fuzzyThreshold,
1297
+ onChange: (event) => setForm({ ...form, fuzzyThreshold: event.target.value }),
1298
+ }),
1299
+ ),
1300
+ h(
1301
+ 'label',
1302
+ { className: 'cait-field' },
1303
+ h('span', null, t('modal.settings.limit')),
1304
+ h('input', {
1305
+ className: 'cait-input',
1306
+ type: 'number',
1307
+ min: 1,
1308
+ max: 20,
1309
+ value: form.tmLimit,
1310
+ onChange: (event) => setForm({ ...form, tmLimit: event.target.value }),
1311
+ }),
1312
+ ),
1313
+ ),
1314
+ h(
1315
+ 'div',
1316
+ { className: 'cait-settings-section' },
1317
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.mt')),
1318
+ h(
1319
+ 'label',
1320
+ { className: 'cait-field' },
1321
+ h('span', null, t('modal.settings.provider')),
1322
+ h(
1323
+ 'select',
1324
+ {
1325
+ className: 'cait-input',
1326
+ value: form.mt.provider,
1327
+ onChange: (event) => selectProvider(event.target.value),
1328
+ },
1329
+ h('option', { value: 'mock' }, t('mt.mock')),
1330
+ routes.map((item) =>
1331
+ h('option', { key: item.id, value: item.id }, `${item.name} (${item.id})`),
1332
+ ),
1333
+ routeMissing
1334
+ ? h('option', { value: form.mt.provider }, t('modal.settings.routeMissing'))
1335
+ : null,
1336
+ ),
1337
+ ),
1338
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.routeHint')),
1339
+ routes.length === 0 || mtRoutes?.fallback
1340
+ ? h('p', { className: 'cait-mt-hint' }, mtRoutes?.message || t('modal.settings.noRoutes'))
1341
+ : null,
1342
+ form.mt.provider === 'mock'
1343
+ ? h('p', { className: 'cait-mt-hint', style: { marginTop: 0, marginBottom: 9 } }, t('modal.settings.mockHint'))
1344
+ : h(
1345
+ 'div',
1346
+ { className: 'cait-grid2' },
1347
+ h(
1348
+ 'label',
1349
+ { className: 'cait-field' },
1350
+ h('span', null, t('modal.settings.model')),
1351
+ models.length
1352
+ ? h(
1353
+ 'select',
1354
+ {
1355
+ className: 'cait-input',
1356
+ value: form.mt.model,
1357
+ onChange: (event) => setMt({ model: event.target.value }),
1358
+ },
1359
+ models.map((model) =>
1360
+ h('option', { key: model.id, value: model.id }, `${model.name} (${model.id})`),
1361
+ ),
1362
+ )
1363
+ : h('input', {
1364
+ className: 'cait-input',
1365
+ value: form.mt.model,
1366
+ readOnly: true,
1367
+ placeholder: t('modal.settings.noModels'),
1368
+ }),
1369
+ ),
1370
+ h(
1371
+ 'label',
1372
+ { className: 'cait-field' },
1373
+ h('span', null, `${t('modal.settings.temperature')} · ${form.mt.temperature}`),
1374
+ h('input', {
1375
+ className: 'cait-range',
1376
+ type: 'range',
1377
+ min: 0,
1378
+ max: 1,
1379
+ step: 0.1,
1380
+ value: form.mt.temperature,
1381
+ onChange: (event) => setMt({ temperature: event.target.value }),
1382
+ }),
1383
+ ),
1384
+ ),
1385
+ ),
1386
+ h(
1387
+ 'div',
1388
+ { className: 'cait-modal-actions' },
1389
+ h('button', { type: 'button', className: 'cait-btn cait-btn-ghost', onClick: onClose }, t('modal.settings.cancel')),
1390
+ h('button', { type: 'submit', className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) }, busy === 'settings' ? h(Spinner) : null, t('modal.settings.save')),
1391
+ ),
1392
+ ),
1393
+ )
1394
+ }
1395
+
1396
+ function LibrariesModal({ t, data, busy, onClose, onSetMounts, onCreate }) {
1397
+ const libraries = data.libraries || { tm: [], glossary: [] }
1398
+ const mounts = data.mounts || {
1399
+ tmIds: [],
1400
+ glossaryIds: [],
1401
+ defaultTmId: '',
1402
+ defaultGlossaryId: '',
1403
+ }
1404
+ const [tmName, setTmName] = useState('')
1405
+ const [glossaryName, setGlossaryName] = useState('')
1406
+
1407
+ const toggle = (kind, id, checked) => {
1408
+ const current = kind === 'tm' ? mounts.tmIds : mounts.glossaryIds
1409
+ const next = computeNextMountIds(current, id, checked)
1410
+ if (!next) return
1411
+ onSetMounts(kind === 'tm' ? { tmIds: next } : { glossaryIds: next })
1412
+ }
1413
+
1414
+ const create = async (kind, name, reset) => {
1415
+ if (!name.trim()) return
1416
+ const ok = await onCreate({ kind, name: name.trim() })
1417
+ if (ok) reset('')
1418
+ }
1419
+
1420
+ const section = (kind, title, list, defaultId, ids) =>
1421
+ h(
1422
+ 'div',
1423
+ { className: 'cait-libsection' },
1424
+ h(
1425
+ 'div',
1426
+ { className: 'cait-libhead' },
1427
+ h('h4', { className: 'cait-settings-title' }, title),
1428
+ h('span', { className: 'cait-count' }, `${ids.length}/${list.length}`),
1429
+ ),
1430
+ list.length
1431
+ ? list.map((library) => {
1432
+ const id = kind === 'tm' ? library.tmId : library.glossaryId
1433
+ const mounted = ids.includes(id)
1434
+ return h(
1435
+ 'div',
1436
+ { className: 'cait-librow', key: id },
1437
+ h(
1438
+ 'label',
1439
+ { className: 'cait-libcheck' },
1440
+ h('input', {
1441
+ type: 'checkbox',
1442
+ checked: mounted,
1443
+ disabled: isMountToggleDisabled({
1444
+ mounted,
1445
+ mountedCount: ids.length,
1446
+ busy: Boolean(busy),
1447
+ }),
1448
+ onChange: (event) => toggle(kind, id, event.target.checked),
1449
+ }),
1450
+ h(
1451
+ 'span',
1452
+ { className: 'cait-libmeta' },
1453
+ h('strong', null, library.name),
1454
+ h(
1455
+ 'small',
1456
+ null,
1457
+ `${library.entryCount || 0} ${t('libraries.entries')}${
1458
+ library.mounted ? '' : ` · ${t('libraries.notMounted')}`
1459
+ }`,
1460
+ ),
1461
+ ),
1462
+ ),
1463
+ h(
1464
+ 'label',
1465
+ { className: 'cait-libdefault' },
1466
+ h('input', {
1467
+ type: 'radio',
1468
+ name: `cait-default-${kind}`,
1469
+ checked: defaultId === id,
1470
+ disabled: Boolean(busy) || !mounted,
1471
+ onChange: () =>
1472
+ onSetMounts(
1473
+ kind === 'tm'
1474
+ ? { defaultTmId: id }
1475
+ : { defaultGlossaryId: id },
1476
+ ),
1477
+ }),
1478
+ t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
1479
+ ),
1480
+ )
1481
+ })
1482
+ : h('p', { className: 'cait-empty-note' }, t('libraries.empty')),
1483
+ h(
1484
+ 'form',
1485
+ {
1486
+ className: 'cait-libcreate',
1487
+ onSubmit: (event) => {
1488
+ event.preventDefault()
1489
+ create(kind, kind === 'tm' ? tmName : glossaryName, kind === 'tm' ? setTmName : setGlossaryName)
1490
+ },
1491
+ },
1492
+ h('input', {
1493
+ className: 'cait-input',
1494
+ value: kind === 'tm' ? tmName : glossaryName,
1495
+ onChange: (event) =>
1496
+ kind === 'tm' ? setTmName(event.target.value) : setGlossaryName(event.target.value),
1497
+ placeholder: t('libraries.namePlaceholder'),
1498
+ disabled: Boolean(busy),
1499
+ }),
1500
+ h(
1501
+ 'button',
1502
+ { className: 'cait-btn', type: 'submit', disabled: Boolean(busy) },
1503
+ h(Icon, { name: 'plus', size: 12 }),
1504
+ t('libraries.create'),
1505
+ ),
1506
+ ),
1507
+ )
1508
+
1509
+ return h(
1510
+ Modal,
1511
+ { title: t('libraries.title'), onClose, wide: true },
1512
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('libraries.desc')),
1513
+ section('tm', t('libraries.tm'), libraries.tm, mounts.defaultTmId, mounts.tmIds),
1514
+ section(
1515
+ 'glossary',
1516
+ t('libraries.glossary'),
1517
+ libraries.glossary,
1518
+ mounts.defaultGlossaryId,
1519
+ mounts.glossaryIds,
1520
+ ),
1521
+ h(
1522
+ 'div',
1523
+ { className: 'cait-modal-actions' },
1524
+ h('button', { className: 'cait-btn cait-btn-primary', onClick: onClose }, t('libraries.done')),
1525
+ ),
1526
+ )
1527
+ }
1528
+
1529
+ function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
1530
+ return h(
1531
+ Modal,
1532
+ { title: state.title || t('modal.confirm.title'), onClose },
1533
+ h('p', { className: 'cait-confirm-text' }, state.message),
1534
+ h(
1535
+ 'div',
1536
+ { className: 'cait-modal-actions' },
1537
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
1538
+ h('button', { className: 'cait-btn cait-btn-danger', onClick: onConfirm, disabled: Boolean(busy) }, busy ? h(Spinner) : null, state.confirmLabel || t('modal.confirm.ok')),
1539
+ ),
1540
+ )
1541
+ }
1542
+
1543
+ function EmptyState({ t, settings, busy, onCreate, onDemo }) {
1544
+ return h(
1545
+ 'div',
1546
+ { className: 'cait-empty' },
1547
+ h(
1548
+ 'div',
1549
+ { className: 'cait-empty-inner' },
1550
+ h('span', { className: 'cait-kicker' }, 'CAIT · CAT WORKBENCH'),
1551
+ h('h1', null, t('empty.title')),
1552
+ h('p', { className: 'cait-lede' }, t('empty.desc')),
1553
+ h(
1554
+ 'div',
1555
+ { className: 'cait-features' },
1556
+ h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'database', size: 12 }), t('empty.features.tm')), h('span', null, t('empty.features.tmDesc'))),
1557
+ h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'book', size: 12 }), t('empty.features.glossary')), h('span', null, t('empty.features.glossaryDesc'))),
1558
+ h('div', { className: 'cait-feature' }, h('strong', null, h(Icon, { name: 'sparkles', size: 12 }), t('empty.features.mt')), h('span', null, t('empty.features.mtDesc'))),
1559
+ ),
1560
+ h(DocumentSourceForm, { t, settings, busy, submitLabel: t('empty.create'), onSubmit: onCreate }),
1561
+ h(
1562
+ 'div',
1563
+ { className: 'cait-demo-row' },
1564
+ h('span', null, t('empty.demoHint')),
1565
+ h('button', { className: 'cait-linkbtn', onClick: onDemo, disabled: Boolean(busy) }, t('empty.demo')),
1566
+ ),
1567
+ ),
1568
+ )
1569
+ }
1570
+
1571
+ function Workbench({ i18n, translate }) {
1572
+ const [data, setData] = useState(null)
1573
+ const [loading, setLoading] = useState(true)
1574
+ const [loadError, setLoadError] = useState('')
1575
+ const [busy, setBusy] = useState('')
1576
+ const [selected, setSelected] = useState(0)
1577
+ const [draft, setDraft] = useState('')
1578
+ const [dirty, setDirty] = useState(false)
1579
+ const [tab, setTab] = useState('tm')
1580
+ const [railQuery, setRailQuery] = useState('')
1581
+ const [tmQuery, setTmQuery] = useState('')
1582
+ const [tmResults, setTmResults] = useState(null)
1583
+ const [tmError, setTmError] = useState('')
1584
+ const [mt, setMt] = useState(null)
1585
+ const [mtError, setMtError] = useState('')
1586
+ const [mtBusy, setMtBusy] = useState(false)
1587
+ const [mtRoutes, setMtRoutes] = useState({ providers: [], fallback: true, message: '' })
1588
+ const [stale, setStale] = useState(false)
1589
+ const [toast, setToast] = useState(null)
1590
+ const [modal, setModal] = useState(null)
1591
+ const textareaRef = useRef(null)
1592
+ const dirtyRef = useRef(false)
1593
+ const busyRef = useRef(false)
1594
+ const rootRef = useRef(null)
1595
+ const unitRef = useRef(0)
1596
+
1597
+ const doc = data?.activeDocument || null
1598
+ const segments = doc?.segments || []
1599
+ const index = doc ? Math.min(selected, Math.max(0, segments.length - 1)) : 0
1600
+ const segment = segments[index] || null
1601
+ const detail = doc?.current?.index === index ? doc.current : null
1602
+ const settings = data?.settings || {
1603
+ sourceLang: 'en',
1604
+ targetLang: 'zh-CN',
1605
+ fuzzyThreshold: 0.55,
1606
+ tmLimit: 5,
1607
+ uiLang: 'auto',
1608
+ mt: { provider: 'mock', model: 'cait-mock', temperature: 0.2 },
1609
+ }
1610
+
1611
+ // The UI language is a plugin setting; 'auto' follows the DSH locale.
1612
+ const uiLang = UI_LANG_CODES.includes(settings.uiLang) ? settings.uiLang : 'auto'
1613
+ // A DSH language switch must re-render the workbench while 'auto' is
1614
+ // selected, so subscribe to the locale service through the entry seam.
1615
+ const [localeRevision, setLocaleRevision] = useState(0)
1616
+ useEffect(
1617
+ () => (i18n.subscribe ? i18n.subscribe(() => setLocaleRevision(revision => revision + 1)) : undefined),
1618
+ [i18n],
1619
+ )
1620
+ const t = useMemo(
1621
+ () => (key, vars) => translate(uiLang, key, vars),
1622
+ [translate, uiLang, localeRevision],
1623
+ )
1624
+ useEffect(() => {
1625
+ // Keep the sidebar label in sync with the in-app switch.
1626
+ i18n.set(uiLang)
1627
+ }, [i18n, uiLang])
1628
+
1629
+ const adoptState = (next, forcedIndex) => {
1630
+ setData(next)
1631
+ setStale(false)
1632
+ const nextDoc = next?.activeDocument
1633
+ if (!nextDoc) {
1634
+ setSelected(0)
1635
+ setDraft('')
1636
+ setDirty(false)
1637
+ setMt(null)
1638
+ setMtError('')
1639
+ return
1640
+ }
1641
+ const nextIndex = Number.isInteger(forcedIndex)
1642
+ ? Math.min(Math.max(forcedIndex, 0), nextDoc.segments.length - 1)
1643
+ : Math.min(nextDoc.cursor, nextDoc.segments.length - 1)
1644
+ setSelected(nextIndex)
1645
+ setDraft(nextDoc.segments[nextIndex]?.target || '')
1646
+ setDirty(false)
1647
+ setMt(null)
1648
+ setMtError('')
1649
+ }
1650
+
1651
+ const notify = (message, kind = 'success') => setToast({ message, kind, id: Date.now() })
1652
+ const report = (error) => notify(error?.message || t('toast.error'), 'error')
1653
+ const importNotice = (result, fallback) => {
1654
+ const warning = result?.warnings?.[0]
1655
+ if (warning) {
1656
+ const message = warning.code === 'TMX_INLINE_MARKUP_LOST'
1657
+ ? t('toast.tmxMarkupLost').replace('{count}', warning.count)
1658
+ : warning.message || t('toast.tmxMarkupLost').replace('{count}', warning.count)
1659
+ notify(message, 'info')
1660
+ } else {
1661
+ notify(fallback)
1662
+ }
1663
+ }
1664
+
1665
+ useEffect(() => {
1666
+ dirtyRef.current = dirty
1667
+ }, [dirty])
1668
+
1669
+ useEffect(() => {
1670
+ busyRef.current = Boolean(busy)
1671
+ }, [busy])
1672
+
1673
+ const refreshStorage = async () => {
1674
+ try {
1675
+ const next = await rpc('bootstrap')
1676
+ setData((current) => (current ? { ...current, storage: next.storage } : next))
1677
+ } catch {
1678
+ // The banner already reports the persistence error; a failed refresh
1679
+ // must not replace the editor state or the user's draft.
1680
+ }
1681
+ }
1682
+
1683
+ const refreshState = async () => {
1684
+ try {
1685
+ if (dirty) {
1686
+ const saved = await saveDraft(true)
1687
+ if (!saved) return
1688
+ }
1689
+ const next = await rpc('bootstrap')
1690
+ adoptState(next)
1691
+ setStale(false)
1692
+ } catch (error) {
1693
+ report(error)
1694
+ }
1695
+ }
1696
+
1697
+ useEffect(() => {
1698
+ let alive = true
1699
+ setLoading(true)
1700
+ rpc('bootstrap')
1701
+ .then((state) => {
1702
+ if (!alive) return
1703
+ adoptState(state)
1704
+ setLoadError('')
1705
+ })
1706
+ .catch((error) => {
1707
+ if (!alive) return
1708
+ setLoadError(error?.message || t('error.load'))
1709
+ })
1710
+ .finally(() => {
1711
+ if (alive) setLoading(false)
1712
+ })
1713
+ return () => {
1714
+ alive = false
1715
+ }
1716
+ }, [])
1717
+
1718
+ useEffect(() => {
1719
+ let alive = true
1720
+ let inFlight = false
1721
+ const tick = async () => {
1722
+ if (!alive || inFlight || busyRef.current) return
1723
+ inFlight = true
1724
+ try {
1725
+ const next = await rpc('bootstrap')
1726
+ if (!alive) return
1727
+ if (Number(next.revision) !== Number(data?.revision)) {
1728
+ if (
1729
+ shouldAdoptRemote({
1730
+ currentRevision: data?.revision,
1731
+ nextRevision: next.revision,
1732
+ dirty: dirtyRef.current,
1733
+ })
1734
+ ) {
1735
+ adoptState(next)
1736
+ } else if (dirtyRef.current) {
1737
+ setStale(true)
1738
+ }
1739
+ }
1740
+ } catch {
1741
+ // A transient poll failure should not replace the workbench state.
1742
+ } finally {
1743
+ inFlight = false
1744
+ }
1745
+ }
1746
+ const timer = setInterval(tick, SYNC_POLL_MS)
1747
+ return () => {
1748
+ alive = false
1749
+ clearInterval(timer)
1750
+ }
1751
+ }, [data?.revision])
1752
+
1753
+ useEffect(() => {
1754
+ let alive = true
1755
+ rpc('listMtRoutes')
1756
+ .then((routes) => {
1757
+ if (alive) setMtRoutes(routes || { providers: [], fallback: true, message: '' })
1758
+ })
1759
+ .catch(() => {
1760
+ if (alive) setMtRoutes({ providers: [], fallback: true, message: '' })
1761
+ })
1762
+ return () => {
1763
+ alive = false
1764
+ }
1765
+ }, [])
1766
+
1767
+ useEffect(() => {
1768
+ if (!toast) return undefined
1769
+ const timer = setTimeout(() => setToast(null), 3200)
1770
+ return () => clearTimeout(timer)
1771
+ }, [toast])
1772
+
1773
+ useEffect(() => {
1774
+ setRailQuery('')
1775
+ setTmQuery('')
1776
+ setTmResults(null)
1777
+ setTmError('')
1778
+ }, [doc?.id])
1779
+
1780
+ useEffect(() => {
1781
+ const query = tmQuery.trim()
1782
+ if (!query) {
1783
+ setTmResults(null)
1784
+ setTmError('')
1785
+ return undefined
1786
+ }
1787
+ let cancelled = false
1788
+ const timer = setTimeout(() => {
1789
+ rpc('lookupTM', {
1790
+ query,
1791
+ sourceLang: doc?.sourceLang,
1792
+ targetLang: doc?.targetLang,
1793
+ threshold: settings.fuzzyThreshold,
1794
+ limit: settings.tmLimit,
1795
+ })
1796
+ .then((result) => {
1797
+ if (!cancelled) {
1798
+ setTmResults(result)
1799
+ setTmError('')
1800
+ }
1801
+ })
1802
+ .catch((error) => {
1803
+ if (!cancelled) setTmError(error?.message || t('toast.error'))
1804
+ })
1805
+ }, 260)
1806
+ return () => {
1807
+ cancelled = true
1808
+ clearTimeout(timer)
1809
+ }
1810
+ }, [tmQuery, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
1811
+
1812
+ const saveDraft = async (silent = true) => {
1813
+ if (!doc || !dirty) return true
1814
+ try {
1815
+ await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1816
+ setDirty(false)
1817
+ if (!silent) notify(t('toast.draftSaved'))
1818
+ return true
1819
+ } catch (error) {
1820
+ report(error)
1821
+ return false
1822
+ }
1823
+ }
1824
+
1825
+ const chooseSegment = async (nextIndex) => {
1826
+ if (!doc || busy || nextIndex === selected) return
1827
+ setBusy('select')
1828
+ try {
1829
+ if (dirty) {
1830
+ await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1831
+ setDirty(false)
1832
+ }
1833
+ const result = await rpc('selectSegment', { documentId: doc.id, index: nextIndex })
1834
+ adoptState(result.state)
1835
+ } catch (error) {
1836
+ report(error)
1837
+ } finally {
1838
+ setBusy('')
1839
+ }
1840
+ }
1841
+
1842
+ const confirmSegment = async () => {
1843
+ if (!doc || busy) return
1844
+ if (!draft.trim()) {
1845
+ notify(t('editor.emptyTarget'), 'error')
1846
+ textareaRef.current?.focus()
1847
+ return
1848
+ }
1849
+ setBusy('confirm')
1850
+ try {
1851
+ const result = await rpc('confirmSegment', {
1852
+ documentId: doc.id,
1853
+ index: selected,
1854
+ target: draft,
1855
+ targetTmId: data?.mounts?.defaultTmId,
1856
+ })
1857
+ adoptState(result.state)
1858
+ notify(result.done ? t('editor.done') : t('toast.confirmed'))
1859
+ } catch (error) {
1860
+ report(error)
1861
+ if (error?.code === 'PERSISTENCE_FAILED') await refreshStorage()
1862
+ } finally {
1863
+ setBusy('')
1864
+ }
1865
+ }
1866
+
1867
+ const moveSegment = async (delta) => {
1868
+ if (!doc || busy) return
1869
+ setBusy('move')
1870
+ try {
1871
+ if (dirty) {
1872
+ await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1873
+ setDirty(false)
1874
+ }
1875
+ const result = await rpc('moveSegment', { documentId: doc.id, delta })
1876
+ adoptState(result.state)
1877
+ } catch (error) {
1878
+ report(error)
1879
+ } finally {
1880
+ setBusy('')
1881
+ }
1882
+ }
1883
+
1884
+ const openDocument = async (documentId) => {
1885
+ if (!documentId || documentId === doc?.id || busy) return
1886
+ setBusy('open')
1887
+ try {
1888
+ if (dirty) {
1889
+ await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
1890
+ setDirty(false)
1891
+ }
1892
+ const result = await rpc('openDocument', { documentId })
1893
+ adoptState(result.state)
1894
+ } catch (error) {
1895
+ report(error)
1896
+ } finally {
1897
+ setBusy('')
1898
+ }
1899
+ }
1900
+
1901
+ const closeDocumentTab = async (documentId) => {
1902
+ if (!documentId || busy) return
1903
+ setBusy('close')
1904
+ try {
1905
+ if (dirty) {
1906
+ const saved = await saveDraft(true)
1907
+ if (!saved) return
1908
+ }
1909
+ const result = await rpc('closeDocument', { documentId })
1910
+ adoptState(result.state)
1911
+ notify(t('toast.docClosed'))
1912
+ } catch (error) {
1913
+ report(error)
1914
+ } finally {
1915
+ setBusy('')
1916
+ }
1917
+ }
1918
+
1919
+ const createFromForm = async (form) => {
1920
+ if (form.demo) {
1921
+ await loadDemo()
1922
+ return
1923
+ }
1924
+ setBusy('create')
1925
+ try {
1926
+ let result
1927
+ if (form.mode === 'file' && form.file) {
1928
+ const payload = await filePayload(form.file)
1929
+ result = await rpc('importFile', {
1930
+ ...payload,
1931
+ name: form.name.trim() || form.file.name,
1932
+ sourceLang: form.sourceLang,
1933
+ targetLang: form.targetLang,
1934
+ csvSourceColumn: form.csvSourceColumn,
1935
+ csvTargetColumn: form.csvTargetColumn,
1936
+ })
1937
+ } else {
1938
+ result = await rpc('createDocument', {
1939
+ name: form.name,
1940
+ text: form.text,
1941
+ sourceLang: form.sourceLang,
1942
+ targetLang: form.targetLang,
1943
+ })
1944
+ }
1945
+ adoptState(result.state)
1946
+ setModal(null)
1947
+ if (result.reused) notify(t('toast.docAlreadyOpen'))
1948
+ else importNotice(result, t('toast.imported'))
1949
+ } catch (error) {
1950
+ report(error)
1951
+ } finally {
1952
+ setBusy('')
1953
+ }
1954
+ }
1955
+
1956
+ const loadDemo = async () => {
1957
+ setBusy('demo')
1958
+ try {
1959
+ const result = await rpc('loadDemo')
1960
+ adoptState(result.state)
1961
+ setModal(null)
1962
+ notify(result.reused ? t('toast.docAlreadyOpen') : t('toast.created'))
1963
+ } catch (error) {
1964
+ report(error)
1965
+ } finally {
1966
+ setBusy('')
1967
+ }
1968
+ }
1969
+
1970
+ const applyTm = (match) => {
1971
+ setDraft(match.target)
1972
+ setDirty(true)
1973
+ setMt(null)
1974
+ setTimeout(() => textareaRef.current?.focus(), 0)
1975
+ }
1976
+
1977
+ const applyTerm = (term) => {
1978
+ const separator = /^(zh|ja|ko)/i.test(doc?.targetLang || '') ? '' : ' '
1979
+ setDraft((current) => {
1980
+ const value = current.replace(/\s+$/, '')
1981
+ return value ? `${value}${separator}${term.target}` : term.target
1982
+ })
1983
+ setDirty(true)
1984
+ setTimeout(() => textareaRef.current?.focus(), 0)
1985
+ }
1986
+
1987
+ const addTerm = async (form) => {
1988
+ setBusy('term')
1989
+ try {
1990
+ const result = await rpc('addGlossary', {
1991
+ ...form,
1992
+ sourceLang: doc?.sourceLang || settings.sourceLang,
1993
+ glossaryId: data?.mounts?.defaultGlossaryId,
1994
+ })
1995
+ setData(result.state)
1996
+ notify(t('toast.termSaved'))
1997
+ return true
1998
+ } catch (error) {
1999
+ report(error)
2000
+ return false
2001
+ } finally {
2002
+ setBusy('')
2003
+ }
2004
+ }
2005
+
2006
+ const deleteTerm = (term) => {
2007
+ setModal({
2008
+ type: 'confirm',
2009
+ title: t('modal.confirm.title'),
2010
+ message: `${t('modal.confirm.deleteTerm')} “${term.source} → ${term.target}”?`,
2011
+ confirmLabel: t('panel.delete'),
2012
+ action: async () => {
2013
+ const result = await rpc('deleteGlossary', { id: term.id })
2014
+ setData(result.state)
2015
+ notify(t('toast.termDeleted'))
2016
+ },
2017
+ })
2018
+ }
2019
+
2020
+ const importTmxFile = async (file) => {
2021
+ setBusy('tm-import')
2022
+ try {
2023
+ const text = await file.text()
2024
+ const result = await rpc('importTmx', {
2025
+ text,
2026
+ importAs: 'tm',
2027
+ sourceLang: doc?.sourceLang || settings.sourceLang,
2028
+ targetLang: doc?.targetLang || settings.targetLang,
2029
+ tmId: data?.mounts?.defaultTmId,
2030
+ })
2031
+ setData(result.state)
2032
+ importNotice(
2033
+ result,
2034
+ `${t('toast.tmImported')} · +${result.added} / ~${result.updated}`,
2035
+ )
2036
+ } catch (error) {
2037
+ report(error)
2038
+ } finally {
2039
+ setBusy('')
2040
+ }
2041
+ }
2042
+
2043
+ const importGlossaryFile = async (file) => {
2044
+ setBusy('glossary-import')
2045
+ try {
2046
+ const text = await file.text()
2047
+ const result = await rpc('importGlossary', {
2048
+ text,
2049
+ glossaryId: data?.mounts?.defaultGlossaryId,
2050
+ })
2051
+ setData(result.state)
2052
+ notify(`${t('toast.glossaryImported')} · +${result.added} / ~${result.updated}`)
2053
+ } catch (error) {
2054
+ report(error)
2055
+ } finally {
2056
+ setBusy('')
2057
+ }
2058
+ }
2059
+
2060
+ const generateMt = async () => {
2061
+ if (!doc || !segment || mtBusy) return
2062
+ setMtBusy(true)
2063
+ setMtError('')
2064
+ try {
2065
+ const result = await rpc('mtSuggest', {
2066
+ text: segment.source,
2067
+ documentId: doc.id,
2068
+ index: selected,
2069
+ sourceLang: doc.sourceLang,
2070
+ targetLang: doc.targetLang,
2071
+ })
2072
+ setMt(result)
2073
+ notify(t('toast.mtReady'), 'info')
2074
+ } catch (error) {
2075
+ setMtError(error?.message || t('toast.error'))
2076
+ } finally {
2077
+ setMtBusy(false)
2078
+ }
2079
+ }
2080
+
2081
+ const saveSettings = async (patch) => {
2082
+ setBusy('settings')
2083
+ try {
2084
+ const result = await rpc('saveSettings', patch)
2085
+ setData(result.state)
2086
+ setModal(null)
2087
+ notify(t('toast.settingsSaved'))
2088
+ return true
2089
+ } catch (error) {
2090
+ report(error)
2091
+ return false
2092
+ } finally {
2093
+ setBusy('')
2094
+ }
2095
+ }
2096
+
2097
+ const setMounts = async (patch) => {
2098
+ setBusy('mounts')
2099
+ try {
2100
+ const result = await rpc('setMounts', patch)
2101
+ setData(result.state)
2102
+ setTmResults(null)
2103
+ notify(t('libraries.mountSaved'))
2104
+ return true
2105
+ } catch (error) {
2106
+ report(error)
2107
+ return false
2108
+ } finally {
2109
+ setBusy('')
2110
+ }
2111
+ }
2112
+
2113
+ const createLibrary = async ({ kind, name }) => {
2114
+ setBusy('library')
2115
+ try {
2116
+ const result = await rpc('createLibrary', { kind, name })
2117
+ setData(result.state)
2118
+ notify(t('libraries.created'))
2119
+ return true
2120
+ } catch (error) {
2121
+ report(error)
2122
+ return false
2123
+ } finally {
2124
+ setBusy('')
2125
+ }
2126
+ }
2127
+
2128
+ const exportAs = async (format) => {
2129
+ if (!doc) return
2130
+ setBusy('export')
2131
+ try {
2132
+ const result = await rpc('exportDocument', { format, documentId: doc.id })
2133
+ const content = result.encoding === 'base64'
2134
+ ? decodeBase64(result.content)
2135
+ : result.content
2136
+ downloadFile(result.filename, content, result.mime)
2137
+ notify(t('toast.exported'))
2138
+ setModal(null)
2139
+ } catch (error) {
2140
+ report(error)
2141
+ } finally {
2142
+ setBusy('')
2143
+ }
2144
+ }
2145
+
2146
+ const openConfirm = (state) => setModal({ type: 'confirm', ...state })
2147
+
2148
+ const clearTm = () =>
2149
+ openConfirm({
2150
+ title: t('modal.confirm.title'),
2151
+ message: t('modal.confirm.clearTm'),
2152
+ confirmLabel: t('panel.clearTm'),
2153
+ action: async () => {
2154
+ const result = await rpc('clearTM', { tmIds: data?.mounts?.tmIds || [], confirm: true })
2155
+ setData(result.state)
2156
+ setTmResults(null)
2157
+ notify(t('toast.tmCleared'))
2158
+ },
2159
+ })
2160
+
2161
+ const clearGlossary = () =>
2162
+ openConfirm({
2163
+ title: t('modal.confirm.title'),
2164
+ message: t('modal.confirm.clearGlossary'),
2165
+ confirmLabel: t('panel.clearGlossary'),
2166
+ action: async () => {
2167
+ const result = await rpc('clearGlossary', {
2168
+ glossaryIds: data?.mounts?.glossaryIds || [],
2169
+ confirm: true,
2170
+ })
2171
+ setData(result.state)
2172
+ notify(t('toast.glossaryCleared'))
2173
+ },
2174
+ })
2175
+
2176
+ const runConfirm = async () => {
2177
+ if (!modal?.action) return
2178
+ setBusy('confirm-action')
2179
+ try {
2180
+ await modal.action()
2181
+ setModal(null)
2182
+ } catch (error) {
2183
+ report(error)
2184
+ } finally {
2185
+ setBusy('')
2186
+ }
2187
+ }
2188
+
2189
+ const copy = async (value) => {
2190
+ try {
2191
+ await copyText(value)
2192
+ notify(t('toast.copied'))
2193
+ } catch (error) {
2194
+ report(error)
2195
+ }
2196
+ }
2197
+
2198
+ const openIds = new Set((data?.openDocuments || []).map((item) => item.id))
2199
+ const closedDocuments = (data?.documents || []).filter((item) => !openIds.has(item.id))
2200
+
2201
+ const deleteClosedDocument = (document) =>
2202
+ openConfirm({
2203
+ title: t('doc.deleteTitle'),
2204
+ message: t('doc.deleteMessage').replace('{name}', document.name),
2205
+ confirmLabel: t('doc.delete'),
2206
+ action: async () => {
2207
+ const result = await rpc('deleteDocument', { documentId: document.id })
2208
+ adoptState(result.state)
2209
+ notify(t('toast.docDeleted'))
2210
+ },
2211
+ })
2212
+
2213
+ const clearClosedDocuments = () =>
2214
+ openConfirm({
2215
+ title: t('doc.cleanTitle'),
2216
+ message: t('doc.cleanAllMessage').replace('{count}', String(closedDocuments.length)),
2217
+ confirmLabel: t('doc.cleanAll'),
2218
+ action: async () => {
2219
+ let lastState = null
2220
+ for (const document of closedDocuments) {
2221
+ const result = await rpc('deleteDocument', { documentId: document.id })
2222
+ lastState = result.state
2223
+ }
2224
+ if (lastState) adoptState(lastState)
2225
+ notify(t('toast.docsCleared').replace('{count}', String(closedDocuments.length)))
2226
+ },
2227
+ })
2228
+
2229
+ // Track the size DSH gives the panel and publish it as a CSS unit. The
2230
+ // observer watches the stable root element and writes the property
2231
+ // directly, so resizing never triggers a React render.
2232
+ useEffect(() => {
2233
+ const element = rootRef.current
2234
+ if (!element) return undefined
2235
+ const measure = () => {
2236
+ const rect = element.getBoundingClientRect()
2237
+ const unit = computeCaitUnit({ width: rect.width, height: rect.height })
2238
+ if (Math.abs(unit - unitRef.current) < 0.002) return
2239
+ unitRef.current = unit
2240
+ element.style.setProperty(CAIT_UNIT_VAR, String(unit))
2241
+ }
2242
+ measure()
2243
+ if (typeof ResizeObserver === 'undefined') {
2244
+ window.addEventListener('resize', measure)
2245
+ return () => window.removeEventListener('resize', measure)
2246
+ }
2247
+ const observer = new ResizeObserver(measure)
2248
+ observer.observe(element)
2249
+ return () => observer.disconnect()
2250
+ }, [])
2251
+
2252
+ if (loading) {
2253
+ return h(
2254
+ 'div',
2255
+ { className: 'cait-root', ref: rootRef },
2256
+ h('style', null, CSS_SCALED),
2257
+ h('div', { className: 'cait-center' }, h(Spinner), t('common.loading')),
2258
+ )
2259
+ }
2260
+
2261
+ if (loadError) {
2262
+ return h(
2263
+ 'div',
2264
+ { className: 'cait-root', ref: rootRef },
2265
+ h('style', null, CSS_SCALED),
2266
+ h(
2267
+ 'div',
2268
+ { className: 'cait-center' },
2269
+ h(
2270
+ 'div',
2271
+ { className: 'cait-errorcard' },
2272
+ h('p', null, `${t('error.load')}: ${loadError}`),
2273
+ h('button', { className: 'cait-btn cait-btn-primary', onClick: () => window.location.reload() }, h(Icon, { name: 'refresh', size: 13 }), t('common.retry')),
2274
+ ),
2275
+ ),
2276
+ )
2277
+ }
2278
+
2279
+ return h(
2280
+ 'div',
2281
+ { className: 'cait-root', ref: rootRef },
2282
+ h('style', null, CSS_SCALED),
2283
+ h(Topbar, {
2284
+ t,
2285
+ doc,
2286
+ data,
2287
+ busy,
2288
+ onNew: () => setModal({ type: 'new', defaultMode: 'text' }),
2289
+ onImport: () => setModal({ type: 'new', defaultMode: 'file' }),
2290
+ onExport: () => setModal({ type: 'export' }),
2291
+ onSettings: () => setModal({ type: 'settings' }),
2292
+ onOpenDoc: openDocument,
2293
+ onCloseDoc: closeDocumentTab,
2294
+ onNewTab: () => setModal({ type: 'new', defaultMode: 'file' }),
2295
+ onCleanClosed: () => setModal({ type: 'recent' }),
2296
+ }),
2297
+ h(SaveBanner, { t, storage: data?.storage, stale, onRefresh: refreshState }),
2298
+ h(
2299
+ 'div',
2300
+ { className: `cait-body${doc ? '' : ' cait-no-doc'}` },
2301
+ doc ? h(SegmentRail, { t, doc, selected: index, query: railQuery, setQuery: setRailQuery, onSelect: chooseSegment }) : null,
2302
+ h(
2303
+ 'div',
2304
+ { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
2305
+ doc
2306
+ ? h(EditorPane, {
2307
+ t,
2308
+ doc,
2309
+ index,
2310
+ segment,
2311
+ detail,
2312
+ draft,
2313
+ setDraft: (value) => {
2314
+ setDraft(value)
2315
+ setDirty(true)
2316
+ },
2317
+ dirty,
2318
+ busy,
2319
+ onConfirm: confirmSegment,
2320
+ onNext: () => moveSegment(1),
2321
+ onPrev: () => moveSegment(-1),
2322
+ onSave: () => saveDraft(false),
2323
+ onApplyTerm: applyTerm,
2324
+ textareaRef,
2325
+ })
2326
+ : h(EmptyState, { t, settings, busy, onCreate: createFromForm, onDemo: loadDemo }),
2327
+ doc
2328
+ ? h(CatPanel, {
2329
+ t,
2330
+ doc,
2331
+ data,
2332
+ settings,
2333
+ mtRoutes,
2334
+ tab,
2335
+ setTab,
2336
+ tmQuery,
2337
+ setTmQuery,
2338
+ tmResults,
2339
+ tmError,
2340
+ onApplyTm: applyTm,
2341
+ onImportTmx: importTmxFile,
2342
+ onClearTm: clearTm,
2343
+ onAddTerm: addTerm,
2344
+ onDeleteTerm: deleteTerm,
2345
+ onApplyTerm: applyTerm,
2346
+ onImportGlossary: importGlossaryFile,
2347
+ onClearGlossary: clearGlossary,
2348
+ mt,
2349
+ mtError,
2350
+ mtBusy,
2351
+ onGenerateMt: generateMt,
2352
+ onApplyMt: () => {
2353
+ if (mt?.text) {
2354
+ setDraft(mt.text)
2355
+ setDirty(true)
2356
+ setTimeout(() => textareaRef.current?.focus(), 0)
2357
+ }
2358
+ },
2359
+ onCopy: copy,
2360
+ onOpenSettings: () => setModal({ type: 'settings' }),
2361
+ onOpenLibraries: () => setModal({ type: 'libraries' }),
2362
+ busy,
2363
+ })
2364
+ : null,
2365
+ ),
2366
+ ),
2367
+ modal?.type === 'new'
2368
+ ? h(NewDocModal, {
2369
+ t,
2370
+ settings,
2371
+ busy,
2372
+ defaultMode: modal.defaultMode,
2373
+ onClose: () => setModal(null),
2374
+ onCreate: createFromForm,
2375
+ })
2376
+ : null,
2377
+ modal?.type === 'export'
2378
+ ? h(ExportModal, { t, busy, onClose: () => setModal(null), onExport: exportAs })
2379
+ : null,
2380
+ modal?.type === 'settings'
2381
+ ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })
2382
+ : null,
2383
+ modal?.type === 'libraries'
2384
+ ? h(LibrariesModal, {
2385
+ t,
2386
+ data,
2387
+ busy,
2388
+ onClose: () => setModal(null),
2389
+ onSetMounts: setMounts,
2390
+ onCreate: createLibrary,
2391
+ })
2392
+ : null,
2393
+ modal?.type === 'recent'
2394
+ ? h(RecentDocsModal, {
2395
+ t,
2396
+ documents: closedDocuments,
2397
+ busy,
2398
+ onClose: () => setModal(null),
2399
+ onDelete: deleteClosedDocument,
2400
+ onClear: clearClosedDocuments,
2401
+ })
2402
+ : null,
2403
+ modal?.type === 'confirm'
2404
+ ? h(ConfirmModal, { t, state: modal, busy, onClose: () => setModal(null), onConfirm: runConfirm })
2405
+ : null,
2406
+ toast ? h(Toast, { toast }) : null,
2407
+ )
2408
+ }