dsh-cait 1.0.3 → 1.0.4

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,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,.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,.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
+ }