@khorsheed/dsh-reader 0.2.1 → 0.3.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The detail view's notes, as one hook: loading an article's notes, handing
3
+ * the host its text, painting the highlights, the note sheet, and the
4
+ * gestures on notes.
5
+ *
6
+ * Kept out of `ReaderPane.tsx` on purpose: the pane already owns the reading
7
+ * position, the translation and the fetch lifecycle, and the notes only READ
8
+ * those (the article element, the segmentation, the view) — they never drive
9
+ * them.
10
+ *
11
+ * @module @khorsheed/dsh-reader/client/useNotes
12
+ */
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
+ import { translationHash } from "./translate.js";
15
+ import { mergedDraft } from "./quote.js";
16
+ import { discussionCardText, lastCanvas, noteCardText, rememberCanvas, sourceOf, } from "./canvas-sync.js";
17
+ import { blockElements, captureSelection, formatNotesDraft, NOTE_FLASH_HIGHLIGHT, NOTE_HIGHLIGHT, noteRanges, paintHighlight, paragraphsOf, registerNoteTarget, } from "./notes.js";
18
+ /** How long a jumped-to excerpt stays lit. */
19
+ const FLASH_MS = 1400;
20
+ /** How long the "select a passage first" hint stays up. */
21
+ const HINT_MS = 2600;
22
+ /**
23
+ * How often the notes face re-reads the host while it shows: the model writes
24
+ * conclusions from the conversation, and they should appear without a reload
25
+ * (the canvas board's 4-second precedent).
26
+ */
27
+ const NOTES_POLL_MS = 5000;
28
+ /** How long the 「已同步」 line stays. */
29
+ const SYNC_DONE_MS = 5000;
30
+ /** True when two sets hold the same ids. */
31
+ function sameSet(left, right) {
32
+ if (left.size !== right.size)
33
+ return false;
34
+ for (const id of left)
35
+ if (!right.has(id))
36
+ return false;
37
+ return true;
38
+ }
39
+ /**
40
+ * The notes of the open article, and every gesture on them.
41
+ *
42
+ * @param input - what the pane already holds.
43
+ */
44
+ export function useNotes(input) {
45
+ const { props, entry, sourceLabel, articleHtml, articleRef, builtRef, detailRef, paintKey, translateView, speakerLabel } = input;
46
+ const { t, sessionId } = props;
47
+ const entryId = entry?.id;
48
+ const [tab, setTab] = useState('article');
49
+ const [notes, setNotes] = useState(undefined);
50
+ const [missing, setMissing] = useState(new Set());
51
+ const [sheet, setSheet] = useState(null);
52
+ const [saving, setSaving] = useState(false);
53
+ const [failed, setFailed] = useState(false);
54
+ const [hint, setHint] = useState(false);
55
+ const snapshotted = useRef(new Set());
56
+ const articleScroll = useRef(0);
57
+ const sessions = props.useSessions(state => state.byId);
58
+ const identity = useMemo(() => entry === undefined ? undefined : {
59
+ entryId: entry.id,
60
+ title: entry.title,
61
+ ...(entry.link === undefined ? {} : { url: entry.link }),
62
+ ...(sourceLabel === '' ? {} : { source: sourceLabel }),
63
+ }, [entry, sourceLabel]);
64
+ const reload = useCallback(async (id) => {
65
+ const loaded = await props.entryNotes(id);
66
+ if (loaded.ok)
67
+ setNotes(loaded.value.entry);
68
+ }, [props]);
69
+ // A different article: back to its text, and its own notes.
70
+ useEffect(() => {
71
+ setTab('article');
72
+ setSheet(null);
73
+ setNotes(undefined);
74
+ setMissing(new Set());
75
+ if (entryId !== undefined)
76
+ void reload(entryId);
77
+ }, [entryId, reload]);
78
+ // Hand the host the article's ORIGINAL text, once per body: the model reads
79
+ // it from there (the host never parses an article itself).
80
+ useEffect(() => {
81
+ if (identity === undefined || articleHtml === null || articleHtml.length === 0)
82
+ return;
83
+ const paragraphs = paragraphsOf(articleHtml, speakerLabel);
84
+ const key = `${identity.entryId}\n${translationHash(paragraphs.join('\n'))}`;
85
+ if (snapshotted.current.has(key))
86
+ return;
87
+ snapshotted.current.add(key);
88
+ if (paragraphs.length === 0)
89
+ return;
90
+ void props.snapshotEntry({ ...identity, paragraphs });
91
+ }, [identity, articleHtml, props, speakerLabel]);
92
+ // Paint the excerpts on whatever text is on screen now. Recomputed after
93
+ // every rebuild: a translation replaces the text nodes a range points into.
94
+ useEffect(() => {
95
+ const article = articleRef.current;
96
+ if (article === null || tab !== 'article') {
97
+ paintHighlight(NOTE_HIGHLIGHT, []);
98
+ return;
99
+ }
100
+ const found = noteRanges(article, builtRef.current, notes?.notes ?? []);
101
+ paintHighlight(NOTE_HIGHLIGHT, [...found.values()].flat());
102
+ const lost = new Set([...found].filter(([, ranges]) => ranges.length === 0).map(([id]) => id));
103
+ setMissing(current => sameSet(current, lost) ? current : lost);
104
+ }, [notes, articleHtml, paintKey, translateView, tab, articleRef, builtRef]);
105
+ // While the notes face shows, follow what the conversation writes into it.
106
+ useEffect(() => {
107
+ if (tab !== 'notes' || entryId === undefined)
108
+ return;
109
+ void reload(entryId);
110
+ const timer = setInterval(() => {
111
+ if (document.visibilityState === 'visible')
112
+ void reload(entryId);
113
+ }, NOTES_POLL_MS);
114
+ return () => { clearInterval(timer); };
115
+ }, [tab, entryId, reload]);
116
+ // Nothing stays painted once the pane is gone.
117
+ useEffect(() => () => {
118
+ paintHighlight(NOTE_HIGHLIGHT, []);
119
+ paintHighlight(NOTE_FLASH_HIGHLIGHT, []);
120
+ }, []);
121
+ const startNote = useCallback(() => {
122
+ const article = articleRef.current;
123
+ const selection = article === null ? null : captureSelection(article, builtRef.current);
124
+ if (selection === null) {
125
+ setHint(true);
126
+ setTimeout(() => { setHint(false); }, HINT_MS);
127
+ return;
128
+ }
129
+ setFailed(false);
130
+ setSheet(selection);
131
+ }, [articleRef, builtRef]);
132
+ // The quote menu's「记笔记」finds this pane through the target registry.
133
+ useEffect(() => registerNoteTarget({
134
+ owns: node => articleRef.current?.contains(node) === true,
135
+ start: startNote,
136
+ }), [articleRef, startNote]);
137
+ const saveNote = useCallback((comment) => {
138
+ if (identity === undefined || sheet === null)
139
+ return;
140
+ setSaving(true);
141
+ void props.addNote({
142
+ ...identity,
143
+ block: sheet.block,
144
+ ...(sheet.start === undefined ? {} : { start: sheet.start }),
145
+ quote: sheet.quote,
146
+ ...(sheet.translated === undefined ? {} : { translated: sheet.translated }),
147
+ ...(sheet.time === undefined ? {} : { time: sheet.time }),
148
+ ...(comment.trim() === '' ? {} : { comment }),
149
+ }).then(async (result) => {
150
+ setSaving(false);
151
+ if (!result.ok || typeof result.value === 'string') {
152
+ setFailed(true);
153
+ return;
154
+ }
155
+ setSheet(null);
156
+ window.getSelection()?.removeAllRanges();
157
+ await reload(identity.entryId);
158
+ });
159
+ }, [identity, sheet, props, reload]);
160
+ const switchTab = useCallback((next) => {
161
+ const scroller = detailRef.current;
162
+ if (next === 'notes' && scroller !== null)
163
+ articleScroll.current = scroller.scrollTop;
164
+ setTab(next);
165
+ if (next === 'article') {
166
+ requestAnimationFrame(() => {
167
+ if (detailRef.current !== null)
168
+ detailRef.current.scrollTop = articleScroll.current;
169
+ });
170
+ }
171
+ }, [detailRef]);
172
+ const jump = useCallback((note) => {
173
+ setTab('article');
174
+ // Two frames: one for the article face to come back, one for its layout.
175
+ requestAnimationFrame(() => requestAnimationFrame(() => {
176
+ const article = articleRef.current;
177
+ if (article === null)
178
+ return;
179
+ const ranges = noteRanges(article, builtRef.current, [note]).get(note.id) ?? [];
180
+ const first = ranges[0];
181
+ if (first === undefined)
182
+ return;
183
+ const target = first.startContainer instanceof Element ? first.startContainer : first.startContainer.parentElement;
184
+ target?.scrollIntoView({ block: 'center' });
185
+ paintHighlight(NOTE_FLASH_HIGHLIGHT, ranges);
186
+ setTimeout(() => { paintHighlight(NOTE_FLASH_HIGHLIGHT, []); }, FLASH_MS);
187
+ }));
188
+ }, [articleRef, builtRef]);
189
+ const edit = useCallback(async (note, comment) => {
190
+ if (entryId === undefined)
191
+ return false;
192
+ const result = await props.updateNote({ entryId, noteId: note.id, comment });
193
+ if (!result.ok || result.value !== 'ok')
194
+ return false;
195
+ await reload(entryId);
196
+ return true;
197
+ }, [entryId, props, reload]);
198
+ const remove = useCallback((note) => {
199
+ if (entryId === undefined)
200
+ return;
201
+ void props.deleteNote({ entryId, noteId: note.id }).then(() => reload(entryId));
202
+ }, [entryId, props, reload]);
203
+ /** The conversation this pane belongs to, remembered on the article. */
204
+ const remember = useCallback(() => {
205
+ if (identity === undefined)
206
+ return;
207
+ const sessionTitle = sessions[sessionId]?.displayTitle;
208
+ void props.recordDiscussion({
209
+ ...identity,
210
+ sessionId: String(sessionId),
211
+ ...(sessionTitle === undefined ? {} : { sessionTitle }),
212
+ }).then(() => reload(identity.entryId));
213
+ }, [identity, sessions, sessionId, props, reload]);
214
+ const discuss = useCallback((noteIds) => {
215
+ if (identity === undefined || notes === undefined)
216
+ return;
217
+ const chosen = noteIds.length === 0 ? notes.notes : notes.notes.filter(note => noteIds.includes(note.id));
218
+ if (chosen.length === 0)
219
+ return;
220
+ const block = formatNotesDraft({
221
+ entryId: identity.entryId,
222
+ title: identity.title,
223
+ source: identity.source ?? '',
224
+ ...(identity.url === undefined ? {} : { link: identity.url }),
225
+ }, chosen, {
226
+ heading: count => t('notes.draftHeading', { count }),
227
+ comment: t('notes.draftComment'),
228
+ hint: id => t('notes.draftHint', { id }),
229
+ });
230
+ props.setDraft(mergedDraft(props.readDraft(), block));
231
+ remember();
232
+ }, [identity, notes, props, t, remember]);
233
+ const addReply = useCallback(async (note, text) => {
234
+ if (entryId === undefined)
235
+ return false;
236
+ const result = await props.addReply({ entryId, noteId: note.id, text });
237
+ if (!result.ok || result.value !== 'ok')
238
+ return false;
239
+ await reload(entryId);
240
+ return true;
241
+ }, [entryId, props, reload]);
242
+ const editReply = useCallback(async (note, index, text) => {
243
+ if (entryId === undefined)
244
+ return false;
245
+ const result = await props.editReply({ entryId, noteId: note.id, index, text });
246
+ if (!result.ok || result.value !== 'ok')
247
+ return false;
248
+ await reload(entryId);
249
+ return true;
250
+ }, [entryId, props, reload]);
251
+ const deleteDiscussion = useCallback((discussion) => {
252
+ if (entryId === undefined)
253
+ return;
254
+ void props.deleteDiscussion({ entryId, discussionId: discussion.id }).then(() => reload(entryId));
255
+ }, [entryId, props, reload]);
256
+ const jumpPara = useCallback((para) => {
257
+ setTab('article');
258
+ requestAnimationFrame(() => requestAnimationFrame(() => {
259
+ const article = articleRef.current;
260
+ const block = article === null ? undefined : blockElements(article)[para - 1];
261
+ if (block === undefined)
262
+ return;
263
+ block.scrollIntoView({ block: 'center' });
264
+ const range = block.ownerDocument.createRange();
265
+ range.selectNodeContents(block);
266
+ paintHighlight(NOTE_FLASH_HIGHLIGHT, [range]);
267
+ setTimeout(() => { paintHighlight(NOTE_FLASH_HIGHLIGHT, []); }, FLASH_MS);
268
+ }));
269
+ }, [articleRef]);
270
+ /* ------------------------------------------------------- 同步到画布 */
271
+ const [syncTarget, setSyncTarget] = useState(null);
272
+ const [canvases, setCanvases] = useState(null);
273
+ const [pick, setPick] = useState(null);
274
+ const [withReplies, setWithReplies] = useState(true);
275
+ const [kinds, setKinds] = useState(null);
276
+ const [kind, setKind] = useState(null);
277
+ const [syncBusy, setSyncBusy] = useState(false);
278
+ const [syncError, setSyncError] = useState(null);
279
+ const [syncDone, setSyncDone] = useState(null);
280
+ const inbox = props.canvasInbox();
281
+ const openSync = useCallback((target) => {
282
+ const current = props.canvasInbox();
283
+ if (current === undefined)
284
+ return;
285
+ setSyncTarget(target);
286
+ setCanvases(null);
287
+ setKinds(null);
288
+ setKind(null);
289
+ setSyncError(null);
290
+ void current.listCanvases().then(list => {
291
+ setCanvases(list);
292
+ const remembered = lastCanvas();
293
+ const showing = current.current();
294
+ const preferred = [remembered, showing].find(id => id !== null && list.some(item => item.id === id));
295
+ setPick(preferred ?? list[0]?.id ?? null);
296
+ }, () => { setCanvases([]); });
297
+ }, [props]);
298
+ const runSync = useCallback(() => {
299
+ const current = props.canvasInbox();
300
+ if (current === undefined || syncTarget === null || pick === null || identity === undefined || notes === undefined)
301
+ return;
302
+ const canvas = canvases?.find(item => item.id === pick);
303
+ const words = {
304
+ conclusion: t('notes.syncConclusion'),
305
+ original: t('notes.syncOriginal'),
306
+ kindCheck: t('notes.kindCheck'),
307
+ kindDiscussion: t('notes.kindDiscussion'),
308
+ };
309
+ const article = { title: identity.title, ...(identity.url === undefined ? {} : { url: identity.url }) };
310
+ const items = syncTarget.kind === 'notes'
311
+ ? notes.notes.filter(note => syncTarget.ids.includes(note.id))
312
+ .map(note => ({ text: noteCardText(note, article, words, withReplies), mark: { noteId: note.id } }))
313
+ : (notes.discussions ?? []).filter(item => item.id === syncTarget.id)
314
+ .map(item => ({ text: discussionCardText(item, article, words), mark: { discussionId: item.id } }));
315
+ setSyncBusy(true);
316
+ setSyncError(null);
317
+ void (async () => {
318
+ let landed;
319
+ for (const item of items) {
320
+ const outcome = await current.addCard(String(sessionId), {
321
+ canvasId: pick,
322
+ text: item.text,
323
+ ...(kind === null ? {} : { kind }),
324
+ source: sourceOf(article),
325
+ });
326
+ if (!outcome.ok) {
327
+ if (outcome.error === 'needs-kind' && 'kinds' in outcome) {
328
+ setKinds(outcome.kinds);
329
+ setKind(outcome.kinds[0]?.id ?? null);
330
+ }
331
+ else {
332
+ setSyncError(outcome.error);
333
+ }
334
+ setSyncBusy(false);
335
+ await reload(identity.entryId);
336
+ return;
337
+ }
338
+ landed = { cardId: outcome.cardId };
339
+ await props.markSynced({ entryId: identity.entryId, ...item.mark, canvasId: pick, cardId: outcome.cardId, canvasTitle: canvas?.title ?? pick });
340
+ }
341
+ rememberCanvas(pick);
342
+ setSyncBusy(false);
343
+ setSyncTarget(null);
344
+ await reload(identity.entryId);
345
+ if (landed !== undefined) {
346
+ setSyncDone({ canvasId: pick, cardId: landed.cardId, title: canvas?.title ?? pick });
347
+ setTimeout(() => { setSyncDone(null); }, SYNC_DONE_MS);
348
+ }
349
+ })();
350
+ }, [props, syncTarget, pick, identity, notes, canvases, t, withReplies, kind, sessionId, reload]);
351
+ const sync = {
352
+ available: inbox !== undefined,
353
+ target: syncTarget,
354
+ canvases,
355
+ pick,
356
+ last: lastCanvas(),
357
+ withReplies,
358
+ kinds,
359
+ kind,
360
+ busy: syncBusy,
361
+ error: syncError,
362
+ done: syncDone,
363
+ open: openSync,
364
+ setPick: id => { setPick(id); setKinds(null); setKind(null); },
365
+ setWithReplies,
366
+ setKind,
367
+ run: runSync,
368
+ cancel: () => { setSyncTarget(null); },
369
+ openDone: () => { if (syncDone !== null)
370
+ props.canvasInbox()?.openCard(syncDone.canvasId, syncDone.cardId); },
371
+ };
372
+ // 「让Agent考考我」: the request (with the round's contract, so it works even
373
+ // where the skill is not loaded) goes into the draft; the reader sends it.
374
+ const check = useCallback(() => {
375
+ if (identity === undefined)
376
+ return;
377
+ const count = notes?.notes.length ?? 0;
378
+ const request = [
379
+ t('notes.checkDraft', { title: identity.title, id: identity.entryId }),
380
+ ...(count > 0 ? [t('notes.checkDraftNotes', { count })] : []),
381
+ ].join('\n');
382
+ props.setDraft(mergedDraft(props.readDraft(), request));
383
+ remember();
384
+ }, [identity, notes, t, props, remember]);
385
+ // 让Agent润色译文: the request into the draft, like 考考我 (the reader adds a wish and sends it).
386
+ const polish = useCallback((total, from) => {
387
+ if (identity === undefined)
388
+ return;
389
+ const request = from === undefined
390
+ ? t('polish.draft', { title: identity.title, id: identity.entryId, count: total })
391
+ : t('polish.continueDraft', { title: identity.title, id: identity.entryId, count: total, from });
392
+ props.setDraft(mergedDraft(props.readDraft(), request));
393
+ remember();
394
+ }, [identity, t, props, remember]);
395
+ return {
396
+ polish,
397
+ sync,
398
+ addReply,
399
+ editReply,
400
+ deleteDiscussion,
401
+ jumpPara,
402
+ check,
403
+ tab,
404
+ setTab: switchTab,
405
+ entry: notes,
406
+ count: notes?.notes.length ?? 0,
407
+ missing,
408
+ sheet,
409
+ saving,
410
+ failed,
411
+ hint,
412
+ startNote,
413
+ saveNote,
414
+ cancelSheet: () => { setSheet(null); },
415
+ jump,
416
+ edit,
417
+ remove,
418
+ discuss,
419
+ canOpenSession: props.canOpenSession(),
420
+ openSession: id => { props.openSession(id); },
421
+ sessionTitle: id => sessions[id]?.displayTitle,
422
+ };
423
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The 润色 layer in the detail view: the agent's paragraph translations
3
+ * (written back through `reader_translate`) shown in place of the browser's
4
+ * machine translation.
5
+ *
6
+ * Each polished block gets, once: its own content wrapped in
7
+ * `span[data-reader-orig-run]` (whatever the machine translation does inside
8
+ * it), the 润色 text in `div[data-reader-polish]` with a 「✦ 润色」 tag, and
9
+ * the original text in `div[data-reader-polish-orig]` — the article's
10
+ * `data-polish-view` then decides which of them show:
11
+ * - `trans`: the 润色 alone; `both`: the 润色 over the original;
12
+ * - `orig` / none: the block as it was.
13
+ * Without a machine translation running the 润色 shows side by side (both);
14
+ * with one, the globe's view applies. 「看机翻」 hides the layer for this
15
+ * visit only (not remembered — the 润色 is the default, 2026-10-20).
16
+ *
17
+ * @module @khorsheed/dsh-reader/client/usePolish
18
+ */
19
+ import { type MutableRefObject } from 'react';
20
+ import type { ReaderPolishView } from '../types.ts';
21
+ import type { ReaderPaneProps } from './contract.ts';
22
+ export interface UsePolishInput {
23
+ readonly entryId: string | undefined;
24
+ readonly articleHtml: string | null;
25
+ readonly articleRef: MutableRefObject<HTMLDivElement | null>;
26
+ /** The globe's state: the view applies only while a machine translation runs. */
27
+ readonly translating: boolean;
28
+ readonly translateView: 'trans' | 'both' | 'orig';
29
+ readonly remote: Pick<ReaderPaneProps, 'polish' | 'setPolishOff'>;
30
+ readonly tagLabel: string;
31
+ /** The tag's tooltip: clicking it puts this paragraph back to the machine translation. */
32
+ readonly tagTitle: string;
33
+ }
34
+ export interface PolishFace {
35
+ readonly view: ReaderPolishView | undefined;
36
+ /** Polishing has stopped short (no write for a while): offer to continue. */
37
+ readonly stalled: boolean;
38
+ /** The layer is hidden for this visit (「看机翻」). */
39
+ readonly hidden: boolean;
40
+ readonly setHidden: (hidden: boolean) => void;
41
+ /** The reader sent the request: watch for the first batch. */
42
+ readonly requested: () => void;
43
+ /** A request went into the draft and nothing has come back yet. */
44
+ readonly awaiting: boolean;
45
+ }
46
+ export declare function usePolish(input: UsePolishInput): PolishFace;
47
+ //# sourceMappingURL=usePolish.d.ts.map
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The 润色 layer in the detail view: the agent's paragraph translations
3
+ * (written back through `reader_translate`) shown in place of the browser's
4
+ * machine translation.
5
+ *
6
+ * Each polished block gets, once: its own content wrapped in
7
+ * `span[data-reader-orig-run]` (whatever the machine translation does inside
8
+ * it), the 润色 text in `div[data-reader-polish]` with a 「✦ 润色」 tag, and
9
+ * the original text in `div[data-reader-polish-orig]` — the article's
10
+ * `data-polish-view` then decides which of them show:
11
+ * - `trans`: the 润色 alone; `both`: the 润色 over the original;
12
+ * - `orig` / none: the block as it was.
13
+ * Without a machine translation running the 润色 shows side by side (both);
14
+ * with one, the globe's view applies. 「看机翻」 hides the layer for this
15
+ * visit only (not remembered — the 润色 is the default, 2026-10-20).
16
+ *
17
+ * @module @khorsheed/dsh-reader/client/usePolish
18
+ */
19
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
+ import { blockElements } from "./notes.js";
21
+ /** How often an article being polished is re-read (ms). */
22
+ const POLL_MS = 5000;
23
+ /** After the reader sends the request, poll this long for the first batch (ms). */
24
+ const AWAIT_FIRST_MS = 20 * 60_000;
25
+ /** A layer untouched this long, and not complete, has stalled (ms). */
26
+ const STALLED_MS = 3 * 60_000;
27
+ /** Decorations that are not the paragraph's own content. */
28
+ const DECORATION = '[data-reader-ts],[data-reader-spk],[data-reader-polish],[data-reader-polish-orig],[data-reader-orig-run]';
29
+ /** The original text of each top-level block of an article body. */
30
+ function originals(html) {
31
+ const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html');
32
+ const root = doc.body.firstElementChild;
33
+ return root === null ? [] : [...root.children].map(child => (child.textContent ?? '').replace(/\s+/g, ' ').trim());
34
+ }
35
+ export function usePolish(input) {
36
+ const { entryId, articleHtml, articleRef, translating, translateView, remote, tagLabel, tagTitle } = input;
37
+ const [viewOf, setViewOf] = useState(undefined);
38
+ const view = viewOf !== undefined && viewOf.entryId === entryId ? viewOf.view : undefined;
39
+ const [hiddenFor, setHiddenFor] = useState(null);
40
+ const hidden = hiddenFor !== null && hiddenFor === entryId;
41
+ const [awaitingSince, setAwaitingSince] = useState(null);
42
+ const remoteRef = useRef(remote);
43
+ remoteRef.current = remote;
44
+ const read = useCallback((id) => {
45
+ void remoteRef.current.polish({ entryId: id }).then(answer => {
46
+ if (answer.ok)
47
+ setViewOf({ entryId: id, view: answer.value ?? undefined });
48
+ });
49
+ }, []);
50
+ useEffect(() => {
51
+ setAwaitingSince(null);
52
+ if (entryId !== undefined && articleHtml !== null && articleHtml !== '')
53
+ read(entryId);
54
+ }, [entryId, articleHtml, read]);
55
+ // Poll while the agent is (or may soon be) writing.
56
+ const now = Date.now();
57
+ const fresh = view !== undefined && !view.complete && now - Date.parse(view.updatedAt) < STALLED_MS;
58
+ const awaiting = awaitingSince !== null && now - awaitingSince < AWAIT_FIRST_MS && (view === undefined || !view.complete);
59
+ useEffect(() => {
60
+ if (entryId === undefined || (!fresh && !awaiting))
61
+ return undefined;
62
+ const timer = setInterval(() => { read(entryId); }, POLL_MS);
63
+ return () => { clearInterval(timer); };
64
+ }, [entryId, fresh, awaiting, read]);
65
+ const sources = useMemo(() => (articleHtml === null ? [] : originals(articleHtml)), [articleHtml]);
66
+ // Decorate polished blocks; undecorate ones that are no longer.
67
+ useEffect(() => {
68
+ const article = articleRef.current;
69
+ if (article === null)
70
+ return;
71
+ const blocks = blockElements(article);
72
+ const off = new Set(view?.off ?? []);
73
+ blocks.forEach((block, index) => {
74
+ const text = view?.paragraphs[String(index + 1)];
75
+ const polishedHere = text !== undefined && !off.has(index + 1);
76
+ let polish = block.querySelector(':scope > [data-reader-polish]');
77
+ if (!polishedHere) {
78
+ if (polish !== null) {
79
+ block.querySelector(':scope > [data-reader-polish-orig]')?.remove();
80
+ polish.remove();
81
+ block.removeAttribute('data-polished');
82
+ }
83
+ return;
84
+ }
85
+ const doc = block.ownerDocument;
86
+ // The block's own content, wrapped once so CSS can hide it.
87
+ if (block.querySelector(':scope > [data-reader-orig-run]') === null) {
88
+ const run = doc.createElement('span');
89
+ run.setAttribute('data-reader-orig-run', '1');
90
+ for (const node of [...block.childNodes]) {
91
+ if (node instanceof Element && node.matches(DECORATION))
92
+ continue;
93
+ run.append(node);
94
+ }
95
+ block.append(run);
96
+ }
97
+ if (polish === null) {
98
+ polish = doc.createElement('div');
99
+ polish.setAttribute('data-reader-polish', '1');
100
+ polish.setAttribute('data-reader-no-translate', '1');
101
+ const orig = doc.createElement('div');
102
+ orig.setAttribute('data-reader-polish-orig', '1');
103
+ orig.setAttribute('data-reader-no-translate', '1');
104
+ block.append(polish, orig);
105
+ }
106
+ const body = text.replace(/^\[\d+(?::\d+){1,2}\]\s*/, '');
107
+ if (polish.getAttribute('data-text') !== body) {
108
+ polish.setAttribute('data-text', body);
109
+ polish.textContent = body;
110
+ const tag = doc.createElement('button');
111
+ tag.type = 'button';
112
+ tag.setAttribute('data-reader-polish-tag', String(index + 1));
113
+ tag.setAttribute('data-label', tagLabel);
114
+ tag.title = tagTitle;
115
+ polish.append(tag);
116
+ }
117
+ const orig = block.querySelector(':scope > [data-reader-polish-orig]');
118
+ if (orig !== null)
119
+ orig.textContent = (sources[index] ?? '').replace(/^\d+:\d+(?::\d+)?\s*/, '');
120
+ block.setAttribute('data-polished', '1');
121
+ });
122
+ }, [view, articleHtml, articleRef, sources, tagLabel, tagTitle]);
123
+ // Which layer the article shows.
124
+ useEffect(() => {
125
+ const article = articleRef.current;
126
+ if (article === null)
127
+ return;
128
+ const mode = view === undefined || hidden ? null : translating ? translateView : 'both';
129
+ if (mode === null || mode === 'orig')
130
+ article.removeAttribute('data-polish-view');
131
+ else
132
+ article.setAttribute('data-polish-view', mode);
133
+ }, [view, hidden, translating, translateView, articleRef, articleHtml]);
134
+ // The 「✦ 润色」 tag: this paragraph back to the machine translation.
135
+ useEffect(() => {
136
+ const article = articleRef.current;
137
+ if (article === null || entryId === undefined)
138
+ return undefined;
139
+ const onClick = (event) => {
140
+ const tag = event.target?.closest?.('[data-reader-polish-tag]');
141
+ if (tag === null || tag === undefined)
142
+ return;
143
+ event.preventDefault();
144
+ event.stopPropagation();
145
+ const paragraph = Number(tag.getAttribute('data-reader-polish-tag'));
146
+ void remoteRef.current.setPolishOff({ entryId, paragraph, off: true }).then(answer => {
147
+ if (answer.ok)
148
+ setViewOf({ entryId, view: answer.value ?? undefined });
149
+ });
150
+ };
151
+ article.addEventListener('click', onClick, true);
152
+ return () => { article.removeEventListener('click', onClick, true); };
153
+ }, [entryId, articleRef]);
154
+ return {
155
+ view,
156
+ stalled: view !== undefined && !view.complete && !fresh,
157
+ hidden,
158
+ setHidden: (value) => { setHiddenFor(value && entryId !== undefined ? entryId : null); },
159
+ requested: () => { setAwaitingSince(Date.now()); },
160
+ awaiting: awaiting && view === undefined,
161
+ };
162
+ }