@lincyaw/workbench 0.1.0 → 0.2.0-rc.0

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 (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -0,0 +1,140 @@
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
2
+ import type { DocumentTranscriptionRequest, TeachingDocument, TranscriptionCapabilities, TranscriptionListAnswer, TranscriptionRevision, TranscriptionTask } from '@lincyaw/kernel'
3
+ import { PmBadge, PmButton, PmCard, PmDialog, PmField, PmIconButton, PmNotice, PmSelect } from '../components.tsx'
4
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { documentTranscriptionSession } from '../transcription-session.ts'
6
+ import { TRANSCRIPTION_MESSAGES, transcriptionFailure } from '../transcription-messages.ts'
7
+ import { TranscriptionAdoption } from './TranscriptionAdoption.tsx'
8
+
9
+ interface Props { identity: string, directory: string, document: TeachingDocument, sectionId: string, stepId: string, disabled: boolean, active: boolean, selectedAsset?: { id: string, revision: number }, onSelectAsset(): void }
10
+
11
+ /** ASR creates material; a separate, source-bound review is the only script write. */
12
+ export function ProductionTranscription({ identity, directory, document, sectionId, stepId, disabled, active, selectedAsset, onSelectAsset }: Props) {
13
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = TRANSCRIPTION_MESSAGES[locale]
14
+ const source = useWorkbenchState(state => state.authoring)
15
+ const session = useMemo(() => documentTranscriptionSession(workbench.client, identity, directory, document.id), [workbench, identity, directory, document.id])
16
+ const subscribe = useMemo(() => active ? session.subscribe : () => () => {}, [session, active])
17
+ const state = useSyncExternalStore(subscribe, session.getState, session.getState)
18
+ const assets = Object.values(document.assets).filter(asset => asset.kind === 'audio')
19
+ const [assetId, setAssetId] = useState(selectedAsset?.id ?? state.request?.assetId ?? state.task?.audio.assetId ?? assets.at(-1)?.id ?? '')
20
+ const receivedSelection = useRef<number>()
21
+ const [capability, setCapability] = useState<TranscriptionCapabilities>(), [engine, setEngine] = useState(''), [refresh, setRefresh] = useState(0)
22
+ const [candidate, setCandidate] = useState<TranscriptionRevision>(), [reading, setReading] = useState(false), [failure, setFailure] = useState<string>()
23
+ const [records, setRecords] = useState<TranscriptionListAnswer>({ tasks: [], issues: [] })
24
+ const [importing, setImporting] = useState(false), [inspecting, setInspecting] = useState(false), [cancel, setCancel] = useState(false), [cancelTarget, setCancelTarget] = useState<TranscriptionTask>(), [cancellingOther, setCancellingOther] = useState(false)
25
+ const owner = useRef({ active: true }), file = useRef<HTMLInputElement>(null), importRequest = useRef<AbortController>(), inspectionRequest = useRef<AbortController>(), pendingFile = useRef<File>()
26
+ const current = () => owner.current.active && workbench.getState().authoring?.identity === identity
27
+ const sourceReady = Boolean(source?.identity === identity && source.version && source.fingerprint && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.error)
28
+ const selectedBusy = state.sending || state.cancelling || Boolean(state.request) || Boolean(state.task && ['queued', 'running', 'cancelling'].includes(state.task.state)) || Boolean(session.intent && !state.verified && !state.failure)
29
+ const otherTask = records.tasks.find(task => task.id !== state.task?.id && ['queued', 'running', 'cancelling'].includes(task.state))
30
+ const busy = selectedBusy || Boolean(otherTask)
31
+ useEffect(() => {
32
+ if (selectedAsset && receivedSelection.current !== selectedAsset.revision && document.assets[selectedAsset.id]?.kind === 'audio') {
33
+ receivedSelection.current = selectedAsset.revision
34
+ inspectionRequest.current?.abort(); setAssetId(selectedAsset.id)
35
+ }
36
+ }, [selectedAsset, document.assets])
37
+ useEffect(() => { owner.current.active = true; return () => { owner.current.active = false; importRequest.current?.abort(); inspectionRequest.current?.abort() } }, [])
38
+ useEffect(() => { if (!active || disabled) inspectionRequest.current?.abort() }, [active, disabled])
39
+ useEffect(() => {
40
+ if (!active) return
41
+ const controller = new AbortController(); let timer: ReturnType<typeof setTimeout> | undefined
42
+ const read = async () => {
43
+ try {
44
+ const answer = await workbench.client.readTranscriptions(directory, controller.signal)
45
+ if (controller.signal.aborted || !current()) return
46
+ setRecords(answer)
47
+ if (!session.intent && !session.getState().failure && !session.getState().cancelled) {
48
+ const task = answer.tasks.find(task => task.source.documentId === document.id && ['queued', 'running', 'cancelling'].includes(task.state)) ?? answer.tasks.find(task => task.source.documentId === document.id)
49
+ if (task) session.select(task)
50
+ }
51
+ } catch (error) { if (!controller.signal.aborted && current()) setFailure(transcriptionFailure(error, locale)) }
52
+ finally { if (!controller.signal.aborted) timer = setTimeout(() => void read(), 2000) }
53
+ }
54
+ void read()
55
+ return () => { controller.abort(); clearTimeout(timer) }
56
+ }, [workbench, identity, directory, document.id, source?.version, active, refresh, locale, session])
57
+ useEffect(() => {
58
+ const controller = new AbortController()
59
+ if (!active) return
60
+ void workbench.client.readTranscriptionCapabilities(directory, controller.signal).then(answer => {
61
+ if (!controller.signal.aborted && current()) {
62
+ setCapability(answer)
63
+ setEngine(previous => answer.engines.some(value => value.id === previous) ? previous : answer.engines[0]?.id ?? '')
64
+ }
65
+ }, error => { if (!controller.signal.aborted && current()) setFailure(transcriptionFailure(error, locale)) })
66
+ return () => controller.abort()
67
+ }, [workbench, identity, directory, source?.version, active, refresh, locale])
68
+ useEffect(() => {
69
+ const result = state.verified && state.task?.state === 'done' ? state.task.result : undefined, controller = new AbortController()
70
+ if (!active || !result) { setReading(false); if (!result) setCandidate(undefined); return }
71
+ setReading(true)
72
+ void workbench.client.readTranscriptionResult(directory, result.transcriptionId, result.revision, controller.signal).then(answer => {
73
+ if (!controller.signal.aborted && current() && answer.source.documentId === document.id && answer.transcriptionId === result.transcriptionId && answer.revision === result.revision) { setCandidate(answer); setFailure(undefined) }
74
+ }, () => { if (!controller.signal.aborted && current()) setFailure(t.readFailed) }).finally(() => { if (!controller.signal.aborted && current()) setReading(false) })
75
+ return () => controller.abort()
76
+ }, [workbench, identity, directory, document.id, state.task?.result?.transcriptionId, state.task?.result?.revision, state.task?.state, state.verified, active, refresh, locale])
77
+ const importAudio = async (value: File) => {
78
+ const now = workbench.getState().authoring
79
+ if (!current() || importRequest.current || disabled || !now?.version) return
80
+ onSelectAsset()
81
+ const controller = new AbortController(); importRequest.current = controller; setImporting(true); setFailure(undefined)
82
+ pendingFile.current = value
83
+ try {
84
+ const answer = await workbench.ensureDocumentAudio(value, { identity, version: now.version }, controller.signal)
85
+ if (current() && !controller.signal.aborted) { setAssetId(answer.inspection.assetId); pendingFile.current = undefined }
86
+ } catch (error) { if (current() && !controller.signal.aborted) setFailure(transcriptionFailure(error, locale)) }
87
+ finally { if (importRequest.current === controller) { importRequest.current = undefined; if (current()) setImporting(false) } }
88
+ }
89
+ const start = async () => {
90
+ const now = workbench.getState().authoring
91
+ if (!current() || inspectionRequest.current || disabled || !sourceReady || busy || !now?.version || !now.content || !now.fingerprint || !assetId || !engine || !capability?.available) return
92
+ const controller = new AbortController(); inspectionRequest.current = controller; setInspecting(true)
93
+ setFailure(undefined)
94
+ try {
95
+ const expected = { identity, version: now.version }, audio = await workbench.inspectDocumentAudio(assetId, expected, controller.signal)
96
+ if (!current() || controller.signal.aborted) return
97
+ const request: DocumentTranscriptionRequest = { intent: `transcription_${crypto.randomUUID()}`, source: { documentId: now.content.id, version: now.version, fingerprint: now.fingerprint }, assetId, audioSha256: audio.inspection.sha256, engine, lang: now.content.metadata.lang, confirmed: true }
98
+ await session.start(request, () => workbench.startDocumentTranscription(request, expected))
99
+ } catch (error) { if (current() && !controller.signal.aborted) setFailure(transcriptionFailure(error, locale)) }
100
+ finally { if (inspectionRequest.current === controller) { inspectionRequest.current = undefined; if (current()) setInspecting(false) } }
101
+ }
102
+ const cancelJob = async (target?: TranscriptionTask) => {
103
+ if (!target || target.id === state.task?.id) return session.cancel()
104
+ setCancellingOther(true)
105
+ try { await workbench.client.cancelTranscription(directory, { id: target.id }); if (current()) setRefresh(value => value + 1) }
106
+ catch (error) { if (current()) setFailure(transcriptionFailure(error, locale)) }
107
+ finally { if (current()) setCancellingOther(false) }
108
+ }
109
+ const requestCancel = (target?: TranscriptionTask) => { if (target?.state === 'running') { setCancelTarget(target); setCancel(true) } else void cancelJob(target) }
110
+ const task = state.task, stale = task && (task.current === false || source?.version !== task.source.version || source?.fingerprint !== task.source.fingerprint)
111
+ const currentRecords = records.tasks.filter(value => value.source.documentId === document.id), recorded = currentRecords.some(value => value.id === task?.id)
112
+ const status = state.cancelled ? t.cancelled : state.sending || !task && state.request ? t.sending : task ? t[task.state] : undefined
113
+ return <PmCard className="pmw-transcription" data-transcription="true">
114
+ <div className="pmw-transcription__actions"><strong>{t.title}</strong><span className="pmw-spacer" />
115
+ <PmButton small disabled={disabled || !sourceReady || importing} loading={importing} onClick={() => file.current?.click()}>{t.import}</PmButton>
116
+ {pendingFile.current && failure && <PmButton small disabled={disabled || !sourceReady || importing} onClick={() => { if (pendingFile.current) void importAudio(pendingFile.current) }}>{t.retry}</PmButton>}
117
+ <input ref={file} hidden type="file" accept="audio/*,.wav,.ogg,.mp3,.m4a,.webm" onChange={event => { const value = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (value) void importAudio(value) }} />
118
+ <PmIconButton small icon="refresh" label={t.refresh} loading={state.checking && !busy} onClick={() => { setRefresh(value => value + 1); void session.refresh() }} />
119
+ </div>
120
+ {!assets.length ? <span>{t.noAudio}</span> : <>
121
+ <PmSelect label={t.audio} value={document.assets[assetId]?.kind === 'audio' ? assetId : ''} disabled={disabled || importing} onChange={event => { onSelectAsset(); inspectionRequest.current?.abort(); setAssetId(event.target.value) }}><option value="">{t.none}</option>{assets.map(asset => <option key={asset.id} value={asset.id}>{asset.title ?? asset.location.split('/').at(-1) ?? asset.id}</option>)}</PmSelect>
122
+ {capability?.engines.length ? <PmSelect label={t.service} value={engine} disabled={disabled || busy} onChange={event => { inspectionRequest.current?.abort(); setEngine(event.target.value) }}>{capability.engines.map(value => <option key={value.id} value={value.id}>{value.provider}{capability.engines.filter(other => other.provider === value.provider).length > 1 ? ` · ${value.id}` : ''}</option>)}</PmSelect> : capability ? <span>{capability.status === 'unknown' ? t.unresolved : t.unavailable}</span> : null}
123
+ {capability && !capability.available && capability.engines.length > 0 && <span>{capability.reason === 'audio-unavailable' ? t.audioUnavailable : t.unresolved}</span>}
124
+ <div className="pmw-transcription__actions">
125
+ <PmButton small disabled={disabled || !sourceReady || busy || inspecting || !assetId || !engine || !capability?.available} loading={state.sending || inspecting} onClick={() => void start()}>{t.start}</PmButton>
126
+ {(selectedBusy || session.intent && !state.verified && !state.cancelled) && <PmButton small loading={state.cancelling || task?.state === 'cancelling'} disabled={state.cancelling || task?.state === 'cancelling'} onClick={() => requestCancel(task)}>{t.cancel}</PmButton>}
127
+ {status && <span role="status" aria-live="polite"><PmBadge tone={task?.state === 'done' ? 'ok' : task?.state === 'failed' ? 'danger' : 'neutral'}>{status}</PmBadge></span>}
128
+ {stale && <PmBadge tone="warn">{t.stale}</PmBadge>}
129
+ </div>
130
+ </>}
131
+ {currentRecords.length > 0 && (currentRecords.length > 1 || !recorded) && <PmSelect label={t.records} disabled={selectedBusy || inspecting} value={recorded ? task?.id : ''} onChange={event => { const selected = currentRecords.find(task => task.id === event.target.value); if (selected) session.select(selected) }}>{!recorded && <option value="">{t.show}</option>}{currentRecords.map(task => <option key={task.id} value={task.id}>{t[task.state]} · {new Date(task.createdAt).toLocaleTimeString(locale)} · {document.assets[task.audio.assetId]?.title ?? t.audio}</option>)}</PmSelect>}
132
+ {otherTask && <div className="pmw-transcription__actions"><span>{t.other}</span>{otherTask.source.documentId === document.id && <PmButton small disabled={selectedBusy} onClick={() => session.select(otherTask)}>{t.show}</PmButton>}<PmButton small loading={cancellingOther} onClick={() => requestCancel(otherTask)}>{t.cancel}</PmButton></div>}
133
+ {(records.issues.length > 0 || records.truncated) && <PmNotice tone="warn">{t.partial}</PmNotice>}
134
+ {state.storageFailure && <PmNotice tone="warn">{t.storage}</PmNotice>}
135
+ {(failure || state.failure || task?.error) && <PmNotice tone="danger">{failure ?? transcriptionFailure(new Error(state.failure ?? task?.error), locale)}</PmNotice>}
136
+ {reading && <PmField label={t.text}><span aria-busy="true">{t.loading}</span></PmField>}
137
+ {candidate && <TranscriptionAdoption key={`${identity}:${candidate.transcriptionId}:${candidate.revision}:${stepId}`} identity={identity} candidate={candidate} document={document} sectionId={sectionId} stepId={stepId} disabled={disabled} />}
138
+ {cancel && <PmDialog open label={t.cancelTitle} closeLabel={t.close} onClose={() => setCancel(false)} actions={<><PmButton onClick={() => setCancel(false)}>{t.cancelKeep}</PmButton><PmButton variant="danger" onClick={() => { const target = cancelTarget; setCancel(false); setCancelTarget(undefined); void cancelJob(target) }}>{t.cancelConfirm}</PmButton></>} />}
139
+ </PmCard>
140
+ }
@@ -0,0 +1,215 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { hasRole } from '@lincyaw/kernel'
3
+ import { PmButton, PmNotice, PmSelect, PmSkeleton } from '../components.tsx'
4
+ import { useMessages, useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { DocumentEditor } from '../document-editor/DocumentEditor.tsx'
6
+ import { DOCUMENT_EDITOR_MESSAGES } from '../document-editor/messages.ts'
7
+ import { DocumentBlockFallback } from '../document-editor/DocumentBlockFallback.tsx'
8
+ import { DocumentBlockPreview } from '../document-editor/DocumentBlockPreview.tsx'
9
+ import { nativeEditingCursor } from '../document-editor/selection.ts'
10
+ import { hasBlockPreview } from '../document-editor/projection.ts'
11
+ import type { NativeDocumentEditor } from '../document-editor/schema.tsx'
12
+ import { isAgentChanging } from '../workbench.ts'
13
+ import { EditorChangeTrail } from './ChangeTrail.tsx'
14
+
15
+ /** Source editing is available before the first successful runtime compilation. */
16
+ export function SourceDocumentPanel({ commentsPortal, onCommentDraftRestore }: { commentsPortal?: HTMLElement | null, onCommentDraftRestore?(): void }) {
17
+ const workbench = useWorkbench(), t = useMessages()
18
+ const source = useWorkbenchState(state => state.authoring)
19
+ const sourceAcknowledged = () => {
20
+ const current = workbench.getState().authoring
21
+ return Boolean(current && current.identity === source?.identity && current.reset === source?.reset
22
+ && !current.loading && !current.dirty && !current.saving && !current.error && !current.restored && !current.recovering && !current.composing)
23
+ }
24
+ const positionFailure = useWorkbenchState(state => state.documentPositionFailure)
25
+ const locale = useWorkbenchState(state => state.locale), messages = DOCUMENT_EDITOR_MESSAGES[locale]
26
+ const navigation = useWorkbenchState(state => state.documentNavigation)
27
+ const readOnly = useWorkbenchState(state => state.mode !== 'editing' || workbench.client.capabilities?.readOnly === true || Boolean(state.authoring?.member && !hasRole(state.authoring.member, 'editor')) || Boolean(state.authoring?.loading) || isAgentChanging(state))
28
+ const container = useRef<HTMLElement>(null)
29
+ const sheet = useRef<HTMLDivElement>(null)
30
+ const consumedNavigation = useRef<string>()
31
+ const bodyEditor = useRef<NativeDocumentEditor>()
32
+ const editingPosition = useRef<{ nativeBlockId: string, sectionId: string, position: number, editor: NativeDocumentEditor, identity: string, reset: number | undefined }>()
33
+ const cancelReading = useRef<() => void>()
34
+ const [selectedDraft, setSelectedDraft] = useState('')
35
+ const drafts = source?.recoveryDrafts ?? []
36
+ const draftKey = (value: { id: string, revision: number, token: string }) => JSON.stringify([value.id, value.revision, value.token])
37
+ const draft = drafts.find(value => draftKey(value) === selectedDraft)
38
+ useEffect(() => { setSelectedDraft('') }, [source?.identity])
39
+ const recoveryDisabled = readOnly || source?.saving || source?.composing || source?.recovering
40
+ useEffect(() => {
41
+ const element = container.current
42
+ if (element && source?.identity && source.content) return workbench.attachDocumentViewport(element, source.identity)
43
+ }, [workbench, source?.identity, Boolean(source?.content)])
44
+ // The editor applies `readOnly` while committing, so it is editable by the time this runs.
45
+ useEffect(() => { if (!readOnly) workbench.documentEditorEditable() }, [workbench, readOnly])
46
+ useEffect(() => {
47
+ if (!navigation) return
48
+ const request = JSON.stringify([source?.identity, navigation.request])
49
+ if (consumedNavigation.current === request) return
50
+ const target = navigation.block ? container.current?.querySelector(`[data-block-preview="${CSS.escape(navigation.block)}"]`) : container.current?.querySelector(`[data-document-section="${CSS.escape(navigation.section)}"]`)
51
+ if (target) {
52
+ consumedNavigation.current = request; target.scrollIntoView({ block: navigation.block ? 'center' : 'start' })
53
+ if (navigation.block) target.querySelector<HTMLElement>('[data-component-properties]')?.focus({ preventScroll: true })
54
+ }
55
+ }, [navigation, source?.identity, source?.reset])
56
+ useEffect(() => {
57
+ const element = container.current
58
+ if (!element || !source?.content) return
59
+ let frame: number | undefined
60
+ const measure = () => ({ top: element.scrollTop, width: element.clientWidth, height: element.clientHeight, extent: element.scrollHeight })
61
+ type Viewport = ReturnType<typeof measure>
62
+ const sameGeometry = (a: Viewport, b: Viewport) => a.width === b.width && a.height === b.height && a.extent === b.extent
63
+ let baseline = measure(), reading: { generation: string, viewport: Viewport } | undefined
64
+ const cancel = () => { reading = undefined }
65
+ cancelReading.current = cancel
66
+ const read = () => {
67
+ frame = undefined
68
+ const gesture = reading
69
+ cancel()
70
+ if (!gesture || !element.clientHeight) return
71
+ const viewport = measure(), state = workbench.getState()
72
+ if (gesture.generation !== state.documentPositionToken || !sameGeometry(gesture.viewport, viewport) || gesture.viewport.top !== viewport.top) return
73
+ if (!state.authoring?.loading && !isAgentChanging(state)) {
74
+ const field = workbench.focusedDocumentField(), root = field?.editor.prosemirrorView.dom.getRootNode()
75
+ if (field && root instanceof ShadowRoot && element.contains(root.host)) {
76
+ try {
77
+ const point = field.editor.prosemirrorView.coordsAtPos(field.editor.prosemirrorState.selection.head), bounds = element.getBoundingClientRect()
78
+ if (![point.left, point.right, point.top, point.bottom].every(Number.isFinite)) return
79
+ const step = state.authoring?.content?.steps[field.stepId]
80
+ if (step && point.bottom >= bounds.top && point.top <= bounds.bottom && point.right >= bounds.left && point.left <= bounds.right) {
81
+ workbench.observeDocumentStep(step.sectionId, field.stepId)
82
+ return
83
+ }
84
+ } catch { return }
85
+ }
86
+ }
87
+ const editor = bodyEditor.current
88
+ if (editor?.prosemirrorView.isDestroyed) return
89
+ if (editor?.isFocused() && !state.authoring?.loading && !isAgentChanging(state)) {
90
+ const current = editingPosition.current, source = state.authoring
91
+ if (!source || !current || current.editor !== editor || current.identity !== source.identity || current.reset !== source.reset) return
92
+ try {
93
+ const cursor = nativeEditingCursor(editor)
94
+ if (!cursor || cursor.nativeBlockId !== current.nativeBlockId || cursor.position !== current.position) return
95
+ const node = cursor.objectSelected ? editor.prosemirrorView.nodeDOM(cursor.position) : undefined
96
+ const point = node instanceof Element ? node.getBoundingClientRect() : editor.prosemirrorView.coordsAtPos(cursor.position), bounds = element.getBoundingClientRect()
97
+ if (![point.left, point.right, point.top, point.bottom].every(Number.isFinite)) return
98
+ if (point.bottom >= bounds.top && point.top <= bounds.bottom && point.right >= bounds.left && point.left <= bounds.right) {
99
+ workbench.observeDocumentSection(current.sectionId)
100
+ return
101
+ }
102
+ } catch { return }
103
+ }
104
+ const headings = [...element.querySelectorAll<HTMLElement>('[data-document-section]')]
105
+ const band = element.getBoundingClientRect().top + element.clientHeight * 0.05
106
+ let current = headings[0]
107
+ for (const heading of headings) if (heading.getBoundingClientRect().top <= band) current = heading
108
+ if (element.scrollTop > 0 && element.scrollTop + element.clientHeight >= element.scrollHeight - 1) current = headings.at(-1)
109
+ if (current?.dataset.documentSection) workbench.observeDocumentSection(current.dataset.documentSection)
110
+ }
111
+ const queue = (viewport: Viewport) => {
112
+ reading = { generation: workbench.getState().documentPositionToken, viewport }
113
+ if (frame === undefined) frame = requestAnimationFrame(read)
114
+ }
115
+ const scroll = (event: Event) => {
116
+ const viewport = measure(), previous = baseline
117
+ baseline = viewport
118
+ if (workbench.consumeDocumentReturnScroll(element)) { cancel(); return }
119
+ // Native scroll chaining from an iframe has no parent wheel event.
120
+ if (!event.isTrusted) return
121
+ if (!sameGeometry(previous, viewport)) { cancel(); return }
122
+ if (viewport.top === previous.top) return
123
+ workbench.documentInteraction()
124
+ queue(viewport)
125
+ }
126
+ const interact = (event: Event) => {
127
+ if (!event.isTrusted) return
128
+ workbench.documentInteraction()
129
+ baseline = measure()
130
+ queue(baseline)
131
+ }
132
+ const keys = (event: KeyboardEvent) => {
133
+ if (event.defaultPrevented || event.isComposing || event.altKey || !['PageDown', 'PageUp', 'Home', 'End'].includes(event.key)) return
134
+ const target = event.target instanceof Element ? event.target : undefined
135
+ if (target?.closest('input, textarea, select, [data-narration-editor]')) return
136
+ if (['Home', 'End'].includes(event.key) && target instanceof HTMLElement && target.isContentEditable && !event.ctrlKey && !event.metaKey) return
137
+ interact(event)
138
+ }
139
+ const pointer = () => { cancel(); workbench.documentInteraction() }
140
+ element.addEventListener('scroll', scroll, { passive: true })
141
+ element.addEventListener('wheel', interact, { passive: true })
142
+ element.addEventListener('touchmove', interact, { passive: true })
143
+ element.addEventListener('pointerdown', pointer, true)
144
+ element.addEventListener('focusin', cancel, true)
145
+ element.addEventListener('keydown', keys)
146
+ const resize = new ResizeObserver(() => { baseline = measure(); cancel() }); resize.observe(element)
147
+ const sheet = element.querySelector('.pmw-source-document__sheet')
148
+ if (sheet) resize.observe(sheet)
149
+ return () => {
150
+ if (cancelReading.current === cancel) cancelReading.current = undefined
151
+ element.removeEventListener('scroll', scroll); element.removeEventListener('wheel', interact); element.removeEventListener('touchmove', interact)
152
+ element.removeEventListener('pointerdown', pointer, true); element.removeEventListener('focusin', cancel, true); element.removeEventListener('keydown', keys)
153
+ resize.disconnect(); if (frame !== undefined) cancelAnimationFrame(frame)
154
+ }
155
+ }, [workbench, navigation, source?.identity, source?.reset, Boolean(source?.content)])
156
+ const error = source?.error === 'document-version-changed' || source?.error === 'document-identity-changed' ? messages.conflict
157
+ : source?.error ? messages.saveFailure : undefined
158
+ return <section ref={container} className="pmw-source-document" aria-label={t.documentEditor} aria-busy={source?.loading || undefined}>
159
+ {Boolean(positionFailure || source?.storageFailure || source?.recoveryReadFailure || source?.restored || error || drafts.length || source?.recoveryChanged) && <div className="pmw-source-document__notices">
160
+ {positionFailure && <PmNotice tone="warn">{t.documentPositionRemoved}</PmNotice>}
161
+ {Boolean(source?.storageFailure || source?.recoveryReadFailure) && <PmNotice tone="warn">{source?.storageFailure ? t.narrationRecoveryUnavailable : t.documentDraftUnreadable}</PmNotice>}
162
+ {Boolean(drafts.length || source?.recoveryChanged) && <PmNotice tone="accent"><div className="pmw-source-document__recovery">
163
+ <span>{source?.recoveryChanged ? t.documentDraftChanged : t.documentDraftFound}</span>
164
+ <PmSelect label={t.documentDraftSelect} value={draft ? selectedDraft : ''} disabled={Boolean(recoveryDisabled)} onChange={event => setSelectedDraft(event.currentTarget.value)}>
165
+ <option value="">{t.documentDraftChoose}</option>
166
+ {drafts.map((value, index) => <option key={draftKey(value)} value={draftKey(value)}>{t.documentDraftOption(index + 1, new Intl.DateTimeFormat(locale, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(value.updatedAt), value.source)}</option>)}
167
+ </PmSelect>
168
+ <div className="pmw-source-document__recovery-actions">
169
+ <PmButton small variant="primary" disabled={!draft || Boolean(recoveryDisabled)} onClick={() => { if (draft && source?.identity) void workbench.recoverDocumentDraft(draft, source.identity) }}>{t.documentDraftRestore}</PmButton>
170
+ <PmButton small disabled={!draft || Boolean(recoveryDisabled)} onClick={() => { if (draft && source?.identity) workbench.forgetDocumentDraft(draft, source.identity) }}>{t.narrationDiscard}</PmButton>
171
+ </div>
172
+ </div></PmNotice>}
173
+ {source?.restored && !error && <PmNotice tone="accent"><span>{messages.restored}</span><span className="pmw-notice__actions"><PmButton small onClick={() => void workbench.retryDocument()}>{t.retry}</PmButton><PmButton small onClick={() => void workbench.discardDocument()}>{t.narrationDiscard}</PmButton></span></PmNotice>}
174
+ {error && <PmNotice tone="danger"><span>{error}</span><span className="pmw-notice__actions"><PmButton small onClick={() => void workbench.retryDocument()}>{t.retry}</PmButton>{source?.dirty && <PmButton small onClick={() => void workbench.discardDocument()}>{t.narrationDiscard}</PmButton>}</span></PmNotice>}
175
+ </div>}
176
+ {source?.issues.length ? <div className="pmw-source-document__diagnostics"><PmNotice tone="warn"><details><summary>{messages.diagnostics(source.issues.length)}</summary><ul>{source.issues.map((issue, index) => <li key={`${issue.path}:${index}`}><code>{issue.path}</code> {issue.message}</li>)}</ul></details></PmNotice></div> : null}
177
+ {!source?.content || !source.identity ? source?.loading
178
+ ? <div className="pmw-source-document__sheet pmw-source-document__sheet--loading"><PmSkeleton label={messages.loading} /></div>
179
+ : <PmNotice tone="warn"><span>{messages.unavailable}</span><span className="pmw-notice__actions"><PmButton small onClick={() => void workbench.retryDocument()}>{t.retry}</PmButton></span></PmNotice> : <div ref={sheet} className="pmw-source-document__sheet">
180
+ <EditorChangeTrail sheet={sheet} />
181
+ <DocumentEditor acknowledged={sourceAcknowledged()} getDocumentAcknowledged={sourceAcknowledged} onCommentDraftRestore={onCommentDraftRestore} commentDrafts={workbench.documentCommentDrafts(source.identity)} identity={source.identity} document={source.content} version={source.version} member={source.member} getDocument={() => workbench.getDocument(source.identity)}
182
+ getDocumentVersion={() => { const current = workbench.getState().authoring; return current && current.identity === source.identity && current.reset === source.reset ? current.version : undefined }} reset={source.reset} locale={locale} readOnly={readOnly}
183
+ uploadImage={(file, signal) => workbench.importDocumentImage(file, source.identity!, signal)}
184
+ getPositionToken={() => workbench.getState().documentPositionToken}
185
+ onSectionLink={sectionId => {
186
+ if (!workbench.getDocument(source.identity)?.sections[sectionId]) return false
187
+ workbench.goToSection(sectionId); return true
188
+ }}
189
+ resolveAssetURL={async location => {
190
+ if (/^(?:[a-z][a-z\d+.-]*:|\/)/i.test(location)) return location
191
+ const asset = Object.values((workbench.getDocument(source.identity) ?? source.content)?.assets ?? {}).find(asset => asset.location === location)
192
+ return asset ? workbench.client.documentAssetURL(source.directory, asset.id) : location
193
+ }}
194
+ nodeViews={{ renderBlock: id => {
195
+ const block = source.content?.blocks[id]
196
+ if (!block || !source.content) return undefined
197
+ const fallback = <DocumentBlockFallback block={block} document={source.content} locale={locale} />
198
+ return hasBlockPreview(block, source.content) ? <DocumentBlockPreview blockId={id} fallback={fallback} /> : fallback
199
+ } }}
200
+ commentsPortal={commentsPortal} onChange={document => workbench.recordDocument(document)} onReady={(editor, commands) => {
201
+ bodyEditor.current = editor
202
+ editingPosition.current = undefined
203
+ workbench.attachDocumentEditor(editor, commands, { identity: source.identity!, reset: source.reset })
204
+ }}
205
+ onEditingPosition={(position, editor) => {
206
+ const state = workbench.getState(), current = state.authoring
207
+ if (!current?.identity || current.loading || isAgentChanging(state) || editor !== bodyEditor.current || !editor.isFocused() || current.identity !== source.identity || current.reset !== source.reset) return
208
+ cancelReading.current?.()
209
+ editingPosition.current = { ...position, editor, identity: current.identity, reset: current.reset }
210
+ workbench.observeDocumentSection(position.sectionId)
211
+ }}
212
+ onComposition={composing => workbench.documentComposition(composing, 'body', source.identity)} onError={() => workbench.documentError(new Error(messages.adapterError))} />
213
+ </div>}
214
+ </section>
215
+ }
@@ -0,0 +1,64 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { SpeechSettingsAnswer } from '@lincyaw/kernel'
3
+ import { PmBadge, PmButton, PmCard, PmDialog, PmField, PmInput, PmNotice, PmPanelHeader } from '../components.tsx'
4
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
5
+ import { SPEECH_SETTINGS_MESSAGES } from '../speech-settings-messages.ts'
6
+ import { WorkspaceServerError } from '../workspace-client.ts'
7
+
8
+ /** Write-only credentials: values stay in this mounted form until an acknowledged save or close. */
9
+ export function SpeechSettingsPanel() {
10
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = SPEECH_SETTINGS_MESSAGES[locale]
11
+ const [settings, setSettings] = useState<SpeechSettingsAnswer>(), [key, setKey] = useState(''), [appId, setAppId] = useState('')
12
+ const [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>(), [status, setStatus] = useState<string>(), [confirm, setConfirm] = useState(false)
13
+ const owner = useRef(true), request = useRef<AbortController>(), dirty = useRef(false)
14
+ const load = async (checked = false) => {
15
+ request.current?.abort(); const controller = new AbortController(); request.current = controller
16
+ setLoading(true); setFailure(undefined); setStatus(undefined)
17
+ try {
18
+ if (!workbench.client.readSpeechSettings) throw new Error('unsupported')
19
+ const value = await workbench.client.readSpeechSettings(controller.signal)
20
+ if (!owner.current || controller.signal.aborted) return
21
+ setSettings(value)
22
+ if (checked) setStatus(value.configured ? t.checked : t.unconfigured)
23
+ } catch { if (owner.current && !controller.signal.aborted) setFailure(t.unavailable) }
24
+ finally { if (owner.current && !controller.signal.aborted) setLoading(false) }
25
+ }
26
+ useEffect(() => { owner.current = true; void load(); return () => { owner.current = false; request.current?.abort() } }, [workbench])
27
+ const save = async (clear = false) => {
28
+ if (!settings || !workbench.client.changeSpeechSettings) return
29
+ request.current?.abort(); const controller = new AbortController(); request.current = controller
30
+ setBusy(true); setFailure(undefined); setStatus(undefined)
31
+ try {
32
+ const value = await workbench.client.changeSpeechSettings(clear ? { action: 'clear', revision: settings.revision } : { action: 'save', revision: settings.revision, ...key.trim() ? { apiKey: key.trim() } : {}, ...appId.trim() ? { appId: appId.trim() } : {} }, controller.signal)
33
+ if (!owner.current || controller.signal.aborted) return
34
+ setSettings(value); setKey(''); setAppId(''); dirty.current = false; setConfirm(false)
35
+ setStatus(clear ? t.cleared : t.saved); workbench.refreshVoiceServices()
36
+ } catch (error) {
37
+ if (owner.current && !controller.signal.aborted) {
38
+ const code = error instanceof WorkspaceServerError ? error.answer.error : undefined
39
+ setFailure(code === 'speech-settings-conflict' ? t.conflict : code === 'speech-settings-invalid' ? t.invalid : code === 'speech-settings-busy' ? t.busy : t.failed)
40
+ }
41
+ } finally { if (owner.current && !controller.signal.aborted) setBusy(false) }
42
+ }
43
+ const disabled = busy || loading || !settings?.writable
44
+ return <section className="pmw-speech-settings pm-ui" aria-label={t.title}>
45
+ <PmPanelHeader title={t.title} />
46
+ <div className="pmw-speech-settings__content">
47
+ <p>{t.description}</p>
48
+ {loading && <PmNotice>{t.loading}</PmNotice>}
49
+ {failure && <PmNotice tone="danger">{failure} <PmButton small disabled={busy || loading} onClick={() => void load()}>{t.retry}</PmButton></PmNotice>}
50
+ {status && <PmNotice tone="accent">{status}</PmNotice>}
51
+ <PmCard><div className="pmw-speech-settings__fields">
52
+ <PmField label={t.service}><span>{t.provider} · {t.model}</span><PmBadge tone={settings?.configured ? 'accent' : 'neutral'}>{settings?.configured ? t.configured : t.unconfigured}</PmBadge></PmField>
53
+ {settings && <p>{t.source[settings.source]}</p>}
54
+ <PmInput type="password" label={t.key} hint={t.keyHint} placeholder={settings?.configured ? t.keySaved : t.keyPlaceholder} value={key} disabled={disabled} autoComplete="new-password" spellCheck={false} maxLength={8192} onChange={event => { dirty.current = Boolean(event.target.value.trim() || appId.trim()); setKey(event.target.value); setStatus(undefined) }} />
55
+ <PmInput label={t.appId} hint={t.appIdHint} placeholder={settings?.appIdConfigured ? t.keySaved : t.appIdPlaceholder} value={appId} disabled={disabled} autoComplete="off" inputMode="numeric" spellCheck={false} maxLength={32} onChange={event => { dirty.current = Boolean(event.target.value.trim() || key.trim()); setAppId(event.target.value); setStatus(undefined) }} />
56
+ <div className="pmw-speech-settings__actions"><PmButton variant="accent" loading={busy} disabled={disabled || !key.trim() && !settings?.configured} onClick={() => void save()}>{t.save}</PmButton><PmButton disabled={busy || loading || dirty.current} onClick={() => void load(true)}>{t.check}</PmButton></div>
57
+ </div></PmCard>
58
+ <PmNotice>{t.verification}</PmNotice>
59
+ <p>{t.privacy}</p>
60
+ <div className="pmw-speech-settings__actions"><a className="pm-ui-link" href="https://console.volcengine.com/speech/new/setting/apikeys" target="_blank" rel="noreferrer">{t.console}</a>{settings?.stored && <PmButton small variant="ghost" disabled={disabled} onClick={() => setConfirm(true)}>{t.remove}</PmButton>}</div>
61
+ </div>
62
+ <PmDialog open={confirm} label={t.removeTitle} closeLabel={t.cancel} busy={busy} onClose={() => setConfirm(false)} description={t.removeHint} footer={<><PmButton disabled={busy} onClick={() => setConfirm(false)}>{t.cancel}</PmButton><PmButton variant="danger" loading={busy} onClick={() => void save(true)}>{t.remove}</PmButton></>} />
63
+ </section>
64
+ }
@@ -0,0 +1,147 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { prepareTranscriptionAdoption } from '@lincyaw/kernel'
3
+ import type { CanonicalSource, NarrationNode, TeachingDocument, TranscriptionRevision, TranscriptionAdoptionDraft, TranscriptionAdoptionPart, TranscriptionAdoptionPlan } from '@lincyaw/kernel'
4
+ import { PmBadge, PmButton, PmDialog, PmField, PmNotice, PmSelect, PmTextarea } from '../components.tsx'
5
+ import { useWorkbench, useWorkbenchState } from '../context.tsx'
6
+ import { NarrationEditor } from '../document-editor/NarrationEditor.tsx'
7
+ import { narrationToNative } from '../document-editor/narration.ts'
8
+ import type { NativeNarrationEditor } from '../document-editor/narration-schema.tsx'
9
+ import { TRANSCRIPTION_MESSAGES } from '../transcription-messages.ts'
10
+
11
+ interface Props { identity: string, candidate: TranscriptionRevision, document: TeachingDocument, sectionId: string, stepId: string, disabled: boolean }
12
+ interface Draft { id: string, revision: number, nodes?: NarrationNode[], discarded: string[], shared: 'fork' | 'edit-shared', baseVersion?: string, applied?: boolean }
13
+ const ordered = (value: unknown): unknown => Array.isArray(value) ? value.map(ordered) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, ordered((value as Record<string, unknown>)[key])])) : value
14
+ const same = (a: unknown, b: unknown) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b))
15
+ const displayed = (nodes: readonly NarrationNode[]) => nodes.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
16
+ function restore(key: string): { draft: Draft, failed: boolean } {
17
+ const fallback = () => ({ draft: { id: crypto.randomUUID(), revision: 0, discarded: [], shared: 'fork' as const }, failed: false })
18
+ try {
19
+ const raw = localStorage.getItem(key)
20
+ if (!raw) return fallback()
21
+ const value = JSON.parse(raw)
22
+ if (!value || typeof value.id !== 'string' || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Array.isArray(value.discarded) || !value.discarded.every((key: unknown) => typeof key === 'string') || !['fork', 'edit-shared'].includes(value.shared)
23
+ || value.baseVersion !== undefined && typeof value.baseVersion !== 'string' || value.applied !== undefined && typeof value.applied !== 'boolean'
24
+ || value.nodes !== undefined && (!Array.isArray(value.nodes) || !value.nodes.every((node: NarrationNode) => node && (node.type === 'text' && typeof node.text === 'string' || node.type === 'pronunciation' && typeof node.display === 'string' && typeof node.spoken === 'string' || node.type === 'cue' && typeof node.id === 'string' && typeof node.action === 'string')))) throw new Error('invalid-draft')
25
+ return { draft: value, failed: false }
26
+ } catch { return { ...fallback(), failed: true } }
27
+ }
28
+
29
+ export function TranscriptionAdoption(props: Props) {
30
+ const locale = useWorkbenchState(state => state.locale), t = TRANSCRIPTION_MESSAGES[locale]
31
+ const ids = props.document.steps[props.stepId]?.narrationIds ?? []
32
+ const [selected, setSelected] = useState(ids[0] ?? ''), [segment, setSegment] = useState('all')
33
+ const narrationId = ids.includes(selected) ? selected : ids[0]
34
+ const segments = segment === 'all' ? props.candidate.result.segments.map(value => value.id) : [segment]
35
+ return <>
36
+ {ids.length > 1 && <PmSelect label={t.narration} value={narrationId ?? ''} disabled={props.disabled} onChange={event => setSelected(event.target.value)}>{ids.map((id, index) => <option key={id} value={id}>{t.narration} {index + 1}</option>)}</PmSelect>}
37
+ {props.candidate.result.segments.length > 1 && <PmSelect label={t.segments} value={segment} disabled={props.disabled} onChange={event => setSegment(event.target.value)}><option value="all">{t.allSegments}</option>{props.candidate.result.segments.map((value, index) => <option key={value.id} value={value.id}>{t.segment(index)} · {value.text.slice(0, 30)}</option>)}</PmSelect>}
38
+ <AdoptionTarget key={JSON.stringify([narrationId, segments])} {...props} narrationId={narrationId} segments={segments} />
39
+ </>
40
+ }
41
+
42
+ function AdoptionTarget({ identity, candidate, document, sectionId, stepId, disabled, narrationId, segments }: Props & { narrationId?: string, segments: string[] }) {
43
+ const workbench = useWorkbench(), locale = useWorkbenchState(state => state.locale), t = TRANSCRIPTION_MESSAGES[locale], source = useWorkbenchState(state => state.authoring)
44
+ const key = `presmium.transcription-draft.v1:${JSON.stringify([identity, candidate.transcriptionId, candidate.revision, stepId, narrationId, segments])}`
45
+ const [restored] = useState(() => restore(key)), [draft, setDraft] = useState(restored.draft), latest = useRef(draft)
46
+ const [storageFailed, setStorageFailed] = useState(restored.failed), [composing, setComposing] = useState(false)
47
+ const [plan, setPlan] = useState<TranscriptionAdoptionPlan>(), [reviewing, setReviewing] = useState(false), [applying, setApplying] = useState(false), [open, setOpen] = useState(false), [failure, setFailure] = useState<string>(), [status, setStatus] = useState<string>()
48
+ const editor = useRef<NativeNarrationEditor>(), generation = useRef(0)
49
+ useEffect(() => () => { generation.current++ }, [])
50
+ const binding: CanonicalSource = { documentId: document.id, version: source?.version ?? '', fingerprint: source?.fingerprint ?? '' }
51
+ const references = narrationId ? Object.values(document.steps).filter(step => step.narrationIds.includes(narrationId)).map(step => step.id) : []
52
+ const target = { sectionId, stepId, ...narrationId ? { narrationId } : {}, segmentIds: segments, ...references.length > 1 ? { shared: draft.shared, ...draft.shared === 'edit-shared' ? { affectedStepIds: references } : {} } : {} }
53
+ const baseline = useMemo(() => prepareTranscriptionAdoption(document, candidate, { id: draft.id, source: binding, candidate: { id: candidate.transcriptionId, revision: candidate.revision }, draftRevision: draft.revision, targets: [target] }), [document, candidate, draft.id, draft.shared, sectionId, stepId, narrationId, segments.join('\0'), binding.version, binding.fingerprint])
54
+ const original = baseline.targets[0], atoms = original?.atoms ?? []
55
+ const proposed = original?.proposed.map(node => {
56
+ if (node.type !== 'cue') return node
57
+ const previous = baseline.impact.forks.flatMap(fork => fork.cues).find(cue => cue.to === node.id)
58
+ return previous ? atoms.find(atom => atom.node.type === 'cue' && atom.node.id === previous.from)?.node ?? node : node
59
+ })
60
+ const nodes = draft.nodes ?? proposed ?? [{ type: 'text' as const, text: candidate.result.segments.filter(value => segments.includes(value.id)).map(value => value.text).join('\n') }]
61
+ const content = (): { parts: TranscriptionAdoptionPart[], used: Set<string>, invalid: boolean } => {
62
+ const used = new Set<string>(), parts: TranscriptionAdoptionPart[] = []; let invalid = false
63
+ for (const node of latest.current.nodes ?? nodes) {
64
+ if (node.type === 'text') parts.push({ type: 'text', text: node.text })
65
+ else {
66
+ const atom = atoms.find(atom => !used.has(atom.key) && same(atom.node, node))
67
+ if (!atom) invalid = true
68
+ else { used.add(atom.key); parts.push({ type: 'preserve', key: atom.key }) }
69
+ }
70
+ }
71
+ return { parts, used, invalid }
72
+ }
73
+ const parsed = content(), sourceReady = Boolean(source?.identity === identity && source.version && source.fingerprint && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.error)
74
+ const planCurrent = Boolean(plan && plan.source.version === source?.version && plan.source.fingerprint === source?.fingerprint)
75
+ const change = (patch: Partial<Draft>) => {
76
+ generation.current++
77
+ const next = { ...latest.current, ...patch, revision: latest.current.revision + 1, applied: false }
78
+ latest.current = next; setDraft(next); setPlan(undefined); setFailure(undefined); setStatus(undefined)
79
+ try { localStorage.setItem(key, JSON.stringify(next)); setStorageFailed(false) } catch { setStorageFailed(true) }
80
+ }
81
+ const pause = () => { workbench.stopAuditions(); void workbench.pausePlayback().catch(() => {}) }
82
+ const preview = async () => {
83
+ const now = workbench.getState().authoring
84
+ if (disabled || reviewing || applying || composing || !sourceReady || !now?.version || !now.fingerprint || parsed.invalid) return
85
+ pause()
86
+ if (latest.current.baseVersion && latest.current.baseVersion !== now.version) change({ id: crypto.randomUUID(), baseVersion: now.version })
87
+ else if (!latest.current.baseVersion) change({ baseVersion: now.version })
88
+ const value = latest.current, request = ++generation.current, normalized = content()
89
+ const retainsOriginal = references.length > 1 && value.shared === 'fork'
90
+ const removeCueTimings = atoms.filter(atom => value.discarded.includes(atom.key) && atom.node.type === 'cue' && !retainsOriginal && Object.hasOwn(document.production.cueTimings ?? {}, atom.node.id)).map(atom => (atom.node as Extract<NarrationNode, { type: 'cue' }>).id)
91
+ const sent: TranscriptionAdoptionDraft = { id: value.id, source: { documentId: document.id, version: now.version, fingerprint: now.fingerprint }, candidate: { id: candidate.transcriptionId, revision: candidate.revision }, draftRevision: value.revision, targets: [{ ...target, content: normalized.parts, discardAtoms: value.discarded.filter(key => atoms.some(atom => atom.key === key)) }], ...removeCueTimings.length ? { removeCueTimings } : {} }
92
+ setReviewing(true); setFailure(undefined)
93
+ try {
94
+ const answer = await workbench.planDocumentTranscription(sent, { identity, version: now.version })
95
+ if (request !== generation.current || latest.current.revision !== sent.draftRevision) return
96
+ setPlan(answer); setOpen(true)
97
+ } catch { if (request === generation.current) setFailure(t.previewFailed) }
98
+ finally { setReviewing(false) }
99
+ }
100
+ const apply = async () => {
101
+ if (!plan?.planId || !planCurrent || plan.conflicts.length || plan.draftRevision !== latest.current.revision || composing || applying || !sourceReady) return
102
+ const sent = plan, planId = plan.planId
103
+ setApplying(true); setFailure(undefined)
104
+ try {
105
+ const answer = await workbench.adoptDocumentTranscription({ planId, source: sent.source, candidate: sent.candidate, draftRevision: sent.draftRevision }, { identity, version: sent.source.version })
106
+ if (latest.current.id === sent.changeId && latest.current.revision === sent.draftRevision) {
107
+ const next: Draft = { ...latest.current, id: crypto.randomUUID(), revision: 0, discarded: [], baseVersion: answer.version, applied: true }
108
+ latest.current = next; setDraft(next)
109
+ try { localStorage.setItem(key, JSON.stringify(next)); setStorageFailed(false) } catch { setStorageFailed(true) }
110
+ }
111
+ setPlan(undefined); setOpen(false); setStatus(t.adopted)
112
+ } catch { setFailure(t.applyFailed) }
113
+ finally { setApplying(false) }
114
+ }
115
+ if (!displayed(nodes).trim() && !atoms.length) return <span>{t.empty}</span>
116
+ return <>
117
+ <PmField label={t.text} group><NarrationEditor identity={key} narrationId={original?.outputNarrationId ?? draft.id} content={nodes} getContent={() => latest.current.nodes ?? nodes} locale={locale} advanced={false} readOnly={disabled || applying}
118
+ onReady={value => { editor.current = value }} onComposition={setComposing} onFocus={pause} onChange={value => {
119
+ const used = new Set<string>()
120
+ for (const node of value) { const atom = atoms.find(atom => !used.has(atom.key) && same(atom.node, node)); if (atom) used.add(atom.key) }
121
+ change({ nodes: value, discarded: latest.current.discarded.filter(key => !used.has(key)) })
122
+ }} /></PmField>
123
+ {references.length > 1 && <PmSelect label={t.shared} value={draft.shared} disabled={disabled || applying} onChange={event => change({ shared: event.target.value as Draft['shared'] })}><option value="fork">{t.fork}</option><option value="edit-shared">{t.editShared}</option></PmSelect>}
124
+ {atoms.length > 0 && <details open={atoms.some(atom => !parsed.used.has(atom.key) && !draft.discarded.includes(atom.key)) || undefined}><summary>{t.atoms}</summary><div className="pmw-transcription__atoms">{atoms.map(atom => {
125
+ const used = parsed.used.has(atom.key), removed = draft.discarded.includes(atom.key)
126
+ const cleanup = atom.node.type === 'cue' && Object.hasOwn(document.production.cueTimings ?? {}, atom.node.id) && !(references.length > 1 && draft.shared === 'fork')
127
+ return <div key={atom.key} className="pmw-transcription__actions"><span>{atom.node.type === 'pronunciation' ? `${t.pronunciation} · ${atom.node.display} → ${atom.node.spoken}` : `${t.cue} ${atoms.indexOf(atom) + 1}`}</span>
128
+ {used ? <PmBadge tone="ok">{t.atoms}</PmBadge> : <>
129
+ <PmButton small disabled={disabled || applying || composing} onClick={() => { const field = editor.current; if (!field) return; field.focus(); field.insertInlineContent(narrationToNative('adoption-atom', [atom.node])[0]!.content ?? []) }}>{t.keepAtom}</PmButton>
130
+ <PmButton small aria-pressed={removed} disabled={disabled || applying} onClick={() => change({ discarded: removed ? draft.discarded.filter(key => key !== atom.key) : [...draft.discarded, atom.key] })}>{cleanup ? t.removeTiming : t.removeAtom}</PmButton>
131
+ </>}
132
+ </div>
133
+ })}</div></details>}
134
+ {parsed.invalid && <PmNotice tone="warn">{t.unknownAtom}</PmNotice>}
135
+ {storageFailed && <PmNotice tone="warn">{t.draftStorage}</PmNotice>}
136
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
137
+ {status && <span role="status">{status}</span>}
138
+ <div className="pmw-transcription__actions"><PmButton small variant="primary" loading={reviewing} disabled={disabled || !sourceReady || composing || applying || parsed.invalid || draft.applied} onClick={() => void preview()}>{t.review}</PmButton></div>
139
+ {open && plan && <PmDialog open label={t.confirm} closeLabel={t.edit} busy={applying} onClose={() => setOpen(false)} footer={<><PmButton disabled={applying} onClick={() => setOpen(false)}>{t.edit}</PmButton><PmButton variant="primary" loading={applying} disabled={!plan.planId || !planCurrent || plan.conflicts.length > 0 || !sourceReady || composing || plan.draftRevision !== draft.revision} onClick={() => void apply()}>{t.apply}</PmButton></>}>
140
+ <div className="pmw-transcription__review"><PmTextarea label={t.before} readOnly value={displayed(plan.targets[0]?.before ?? [])} /><PmTextarea label={t.after} readOnly value={displayed(plan.targets[0]?.proposed ?? [])} /></div>
141
+ {plan.impact.stepIds.length > 1 && <PmNotice tone="warn">{t.affected(plan.impact.stepIds.length)}</PmNotice>}
142
+ {plan.conflicts.length > 0 && <PmNotice tone="warn">{t.conflicts}</PmNotice>}
143
+ {!planCurrent && <PmNotice tone="warn">{t.sourceChanged}</PmNotice>}
144
+ {failure && <PmNotice tone="danger">{failure}</PmNotice>}
145
+ </PmDialog>}
146
+ </>
147
+ }