@lincyaw/workbench 0.1.0 → 0.2.0-rc.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.
- package/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- 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
|
+
}
|