@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.
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,2328 @@
1
+ import { applyDocumentCaptionTiming } from '@lincyaw/kernel'
2
+ import type { DocumentCaptionTimingAction } from '@lincyaw/kernel'
3
+ import { reconcileCaptionTiming } from './caption-timing.ts'
4
+ import type { ProjectOperation, ProjectPlan } from '@lincyaw/kernel'
5
+ import { undoDepth, redoDepth } from '@tiptap/pm/history'
6
+ import { TextSelection } from '@tiptap/pm/state'
7
+ import { CommentDraftBank } from './document-editor/comment-drafts.ts'
8
+ import { ComponentPropertyDrafts } from './component-property-drafts.ts'
9
+ import type { ComponentPropertyDraft } from './component-property-drafts.ts'
10
+ import { componentSource, reconcileComponentAction, sameComponentSource } from './component-properties.ts'
11
+ import { applyDocumentComponentAction, documentComponentSection, effectiveComponentParameters } from '@lincyaw/kernel'
12
+ import type { DocumentAuthoringActionPlan, DocumentComponentAction, AppliedDocumentAuthoringAction } from '@lincyaw/kernel'
13
+ import { inspectCommentDrafts } from './document-editor/comment-draft-inspection.ts'
14
+ import { mutateCommentDrafts } from './document-editor/comment-draft-actions.ts'
15
+ import { inspectComponentDrafts } from './component-draft-inspection.ts'
16
+ import { mutateComponentDrafts } from './component-draft-actions.ts'
17
+ import { DocumentSession } from './document-session.ts'
18
+ import { reconcileRecordingAdoption } from './recording-adoption.ts'
19
+ import { disposeTranscriptionSessions } from './transcription-session.ts'
20
+ import { documentRecordingActive, disposeDocumentRecordings } from './audio-recording.ts'
21
+ import { DOCUMENT_AUDIO_MAX_BYTES } from '@lincyaw/kernel'
22
+ import type { DocumentSessionState } from './document-session.ts'
23
+ import type { DocumentDraftReference } from './document-drafts.ts'
24
+ import { DocumentReturnPoints, documentReturnStep, documentViewportOffset, restoreDocumentViewport } from './document-return-point.ts'
25
+ import type { DocumentReturnPoint } from './document-return-point.ts'
26
+ import type { DocumentRecordingAdoption, VerifiedDocumentRecordingAdoption, DocumentAudioImportAnswer, DocumentAudioInspectionAnswer, DocumentChangeAnswer, MediaClip, NarrationNode, TeachingDocument, DocumentTranscriptionRequest, TranscriptionTask, TranscriptionAdoptionDraft, TranscriptionAdoptionApply, TranscriptionAdoptionPlan, TranscriptionAdoptionAnswer } from '@lincyaw/kernel'
27
+ import type { NativeDocumentEditor } from './document-editor/schema.tsx'
28
+ import type { DocumentEditorCommands } from './document-editor/DocumentEditor.tsx'
29
+ import { nativeTextPoint, nativeTextPosition } from './document-editor/selection.ts'
30
+ import type { NativeTextPoint } from './document-editor/selection.ts'
31
+ import type { DocumentFieldBookmark, DocumentFieldRegistration, DocumentHistoryEditor } from './document-editor/focus.ts'
32
+ import { duplicateDocumentPage, moveDocumentPage, pageDeletion, removeDocumentPage, renameDocumentPage } from '@lincyaw/kernel'
33
+ import { changeStepTiming } from './step-production.ts'
34
+ import { changeDocumentVoice } from './document-voices.ts'
35
+ import type { DocumentVoiceOptions, DocumentVoiceScope } from './document-voices.ts'
36
+ import type { CanonicalSource, VoiceoverJob, VoiceSource } from '@lincyaw/kernel'
37
+ import type { StepDurationDraft, StepDurationKey, StepTimingPatch } from './step-production.ts'
38
+ import { DocumentTimingDrafts } from './document-timing-drafts.ts'
39
+ import { CaptionTimingDrafts } from './caption-timing-drafts.ts'
40
+ import type { CaptionTimingDraft } from './caption-timing-drafts.ts'
41
+ import { compileDocumentPreview, readPreviewWhenReady, waitForPreviewRequest } from './document-preview.ts'
42
+ import { readDocumentSnapshot } from './document-preview.ts'
43
+ import { errorMessage, sameProductionBinding, slideShape, stepKeys, stepTrail, trailStep } from '@lincyaw/kernel'
44
+ import type { Diagnostic, DiagnosticSnapshot, DocumentOperation, DocumentsAnswer, Focus, FocusReport, IrAnswer, IrEvent, IRDocument, JobsAnswer, OperationAnswer, PreviewAnchor, PreviewAnswer, PreviewCommand, PreviewDiagnostics, PreviewPoint, PreviewPosition, PreviewRect, PreviewRenderer, PreviewReport, ReviewChange, ReviewSnapshot, RevisionsAnswer, UncommittedFile } from '@lincyaw/kernel'
45
+ import { DocumentDiagnostics } from './diagnostics.ts'
46
+ import { DocumentHistory } from './history.ts'
47
+ import { DocumentJobs } from './jobs.ts'
48
+ import { VoiceoverJobs } from './voiceover-jobs.ts'
49
+ import { contentText, localeOf, MESSAGES } from './messages.ts'
50
+ import type { AgentPlace, Locale } from './messages.ts'
51
+ import { changedBlocks, changedPlaces, findPlaceAtLine, findPlaceSource } from './places.ts'
52
+ import { OperationLog } from './telemetry.ts'
53
+ import type { OperationValue } from './telemetry.ts'
54
+ import { PreviewPlayback } from './preview-playback.ts'
55
+ import { ProductionPlayback } from './production-playback.ts'
56
+ import type { ProductionBinding, ProductionPlan, ProductionPlaybackCommand, ProductionPlaybackState } from '@lincyaw/kernel'
57
+ import { workspaceClient, WorkspaceServerError } from './workspace-client.ts'
58
+ import type { WorkspaceClient } from './workspace-client.ts'
59
+ import type { PageDeletePlan } from '@lincyaw/kernel'
60
+ import type { AuthoringClientAction, AuthoringClientActionResult, AuthoringClientSnapshot } from '@lincyaw/kernel'
61
+ import { PRODUCTION_PLAYBACK_RATES } from '@lincyaw/kernel'
62
+
63
+ /** `editing`: the canonical document is edited in the parent; `viewing`: a derived snapshot is presented. */
64
+ export type WorkbenchMode = 'editing' | 'viewing'
65
+
66
+ /**
67
+ * How the web form is shown: as a page or as its presentation, whose controls can play a configured lecture.
68
+ * Both share one preview page and switch in place with `go.view`.
69
+ */
70
+ export type WebView = 'document' | 'present'
71
+
72
+ /** A playback key pressed in a preview that the workbench plays: Space, and the left and right arrows. */
73
+ export type PlaybackKey = 'play-pause' | 'previous' | 'next'
74
+
75
+ /** What the author points at in the document: a block (or a section), or a passage selected in it. */
76
+ export interface Pointer {
77
+ target: PreviewAnchor
78
+ step?: number
79
+ /** The selected passage. */
80
+ quote?: string
81
+ /** Its box in the preview's viewport; none while it is out of sight. */
82
+ rect?: PreviewRect
83
+ obstacles?: PreviewRect[]
84
+ point?: PreviewPoint
85
+ /** Its text can be edited in place. */
86
+ isEditable: boolean
87
+ }
88
+
89
+ export type SaveState =
90
+ | { kind: 'idle' }
91
+ | { kind: 'saving' }
92
+ | { kind: 'saved', at: string }
93
+ | { kind: 'locked' }
94
+ | { kind: 'failed', message: string }
95
+
96
+ /** A request for the agent of the conversation: its text and what it is about. */
97
+ export interface AgentRequest {
98
+ text: string
99
+ focus: FocusReport[]
100
+ }
101
+
102
+ /** Where the host put a request: the conversation's session and the request's id, which its user message carries. */
103
+ export interface AgentAnswer {
104
+ session: string
105
+ request: string
106
+ }
107
+
108
+ export interface WorkbenchState {
109
+ locale: Locale
110
+ authoring?: DocumentSessionState
111
+ /** Unaccepted numeric form input is retained separately from saved canonical timing. */
112
+ hasDocumentTimingDrafts?: boolean
113
+ documentTimingDraftStorageFailure?: boolean
114
+ captionTimingDraftSteps?: string[]
115
+ captionTimingDraftStorageFailure?: boolean
116
+ isDark: boolean
117
+ documents?: DocumentsAnswer
118
+ documentsFailure?: string
119
+ /** The workspace's routes answer 404: `@lincyaw/dsh` is turned off on the plugins page. */
120
+ isBundleDisabled?: boolean
121
+ document?: string
122
+ ir?: IrAnswer
123
+ mode: WorkbenchMode
124
+ previewRenderer: PreviewRenderer
125
+ /** In the web form: the page or the presentation, both in the same page, switched without loading it again. */
126
+ webView: WebView
127
+ previewReload: number
128
+ preview?: PreviewAnswer
129
+ previewFailure?: string
130
+ /** The IR revision the preview shows. */
131
+ previewIrRevision?: string
132
+ isPreparingPresentation?: boolean
133
+ presentationFailure?: string
134
+ presentationSnapshot?: { version: string, fingerprint: string, revision: string, project: number }
135
+ productionPlan?: ProductionPlan
136
+ productionPlayback?: ProductionPlaybackState
137
+ productionFailure?: string
138
+ voiceoverJobs?: VoiceoverJob[]
139
+ voiceoverJobsFailure?: string
140
+ /** A cache refresh that actually changed the current production binding; it never starts playback. */
141
+ voiceoverCacheRefresh?: { source: CanonicalSource, revision: string, sequence: number }
142
+ /** Changes when an acknowledged preview connection is attached or detached, including same-revision reloads. */
143
+ previewConnection: number
144
+ /** Where the preview is: the section the author reads, or the slide and its step. */
145
+ position?: PreviewPosition
146
+ /** Changes on every position intent, including A → B → A, without changing the source. */
147
+ documentPositionToken: string
148
+ /** Explicit author navigation, distinct from the player's passive cursor and frame reload reports. */
149
+ productionNavigationVersion: number
150
+ documentPositionIntent: 'seek' | 'pause'
151
+ /** An explicit source navigation request; scrolling updates position without issuing another request. */
152
+ documentNavigation?: { section: string, block?: string, request: string }
153
+ documentPositionFailure?: 'removed'
154
+ isCreatingPage?: boolean
155
+ isEditingPage?: boolean
156
+ editorHistory?: EditorHistoryState
157
+ isHistoryPreparing?: boolean
158
+ pageEditFailure?: string
159
+ diagnostics?: DiagnosticSnapshot
160
+ isDiagnosticsUnavailable: boolean
161
+ canRepairDiagnostics: boolean
162
+ isRepairingDiagnostics: boolean
163
+ diagnosticsRepairFailure?: string
164
+ pointer?: Pointer
165
+ /** Where the blocks the AI just changed are in the preview, for the kitten to walk to; `id` is new for each change. */
166
+ changeTrail?: { id: number, rects: PreviewRect[] }
167
+ /** The places the AI just changed, for the kitten in the editor to walk to; `id` is new for each change. */
168
+ changedPlaces?: { id: number, targets: PreviewAnchor[] }
169
+ save: SaveState
170
+ /** What the conversation's agent is told the author points at. */
171
+ focus: FocusReport[]
172
+ isFocusPending: boolean
173
+ focusFailure?: string
174
+ review?: ReviewSnapshot
175
+ history?: RevisionsAnswer
176
+ /** Files changed elsewhere and not yet in the history. */
177
+ outside?: UncommittedFile[]
178
+ historyFailure?: string
179
+ isHistoryBusy: boolean
180
+ isAsking: boolean
181
+ askFailure?: string
182
+ /** What the document can be made into, and its builds and exports (the author's and the AI's). */
183
+ jobs?: JobsAnswer
184
+ jobsFailure?: string
185
+ voiceServiceVersion?: number
186
+ jobPreparing?: { target: string, canCancel: boolean }
187
+ }
188
+
189
+ export interface PreviewSink {
190
+ readonly generation?: number
191
+ readonly capabilities?: readonly PreviewCommand['type'][]
192
+ send(command: PreviewCommand): void
193
+ }
194
+
195
+ export interface EditorHistoryState { section: string, undo: boolean, redo: boolean, field?: boolean }
196
+
197
+ type Listener = () => void
198
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
199
+
200
+ /** The workbench's state as its parts read and change it. */
201
+ export interface WorkbenchStore {
202
+ readonly state: WorkbenchState
203
+ set(patch: Partial<WorkbenchState>): void
204
+ }
205
+
206
+ // The export of the lecture as a video (`@lincyaw/addon-lecture`).
207
+ export const VIDEO_TARGET = 'video'
208
+
209
+ /** The document the conversation works on: none when no document is open. */
210
+ export function selectedDocument(state: WorkbenchState): string | null {
211
+ return state.document ?? null
212
+ }
213
+
214
+ /** Whether the AI is changing the workspace now, or its change waits for the author. */
215
+ export function isAgentChanging(state: WorkbenchState): boolean {
216
+ return Boolean(state.review && (state.review.running || state.review.changes.length > 0))
217
+ }
218
+
219
+ /** Whether the document has a slides form (no renderers listed: every form). */
220
+ export function hasSlides(ir: IrAnswer | undefined): boolean {
221
+ const renderers = ir?.doc.config.renderers
222
+ return !renderers || Object.keys(renderers).length === 0 || 'slides' in renderers
223
+ }
224
+
225
+ /** The width of the document's slides over their height (`renderers.slides.aspectRatio`), 16:9 by default. */
226
+ export function slideAspect(ir: IrAnswer | undefined): number {
227
+ if (!ir) return 16 / 9
228
+ const { width, height } = slideShape(ir.doc)
229
+ return width / height
230
+ }
231
+
232
+ /** What opening or closing a document clears. */
233
+ const DOCUMENT_RESET = {
234
+ mode: 'editing',
235
+ authoring: undefined,
236
+ isCreatingPage: false,
237
+ isEditingPage: false,
238
+ editorHistory: undefined,
239
+ isHistoryPreparing: false,
240
+ pageEditFailure: undefined,
241
+ ir: undefined, preview: undefined, previewFailure: undefined, previewIrRevision: undefined, position: undefined, documentNavigation: undefined, documentPositionFailure: undefined,
242
+ diagnostics: undefined, isDiagnosticsUnavailable: false, isRepairingDiagnostics: false, diagnosticsRepairFailure: undefined,
243
+ pointer: undefined, save: { kind: 'idle' },
244
+ focus: [], isFocusPending: false, focusFailure: undefined,
245
+ review: undefined, history: undefined, outside: undefined, historyFailure: undefined, isHistoryBusy: false, askFailure: undefined,
246
+ jobs: undefined, jobsFailure: undefined, jobPreparing: undefined,
247
+ voiceoverJobs: undefined, voiceoverJobsFailure: undefined, voiceoverCacheRefresh: undefined,
248
+ } satisfies Partial<WorkbenchState>
249
+
250
+ /** A free directory for a new document called `title`, beside most of the existing ones. */
251
+ export function documentDirFor(title: string, taken: readonly string[]): string {
252
+ const parents = new Map<string, number>()
253
+ for (const dir of taken) {
254
+ const parent = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
255
+ parents.set(parent, (parents.get(parent) ?? 0) + 1)
256
+ }
257
+ const parent = [...parents].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''
258
+ const slug = title.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'document'
259
+ const base = parent ? `${parent}/${slug}` : slug
260
+ let dir = base
261
+ for (let n = 2; taken.includes(dir); n++) dir = `${base}-${n}`
262
+ return dir
263
+ }
264
+
265
+ /**
266
+ * The state of the workbench and its actions: the documents, the open document's preview, what the author points at
267
+ * in it; canonical source editing, diagnostics and history are parts of their own
268
+ * (`diagnostics.ts`, `history.ts`).
269
+ */
270
+ export class Workbench {
271
+ readonly client: WorkspaceClient
272
+ readonly #documentSession: DocumentSession
273
+ readonly #audioImports = new WeakMap<File, { identity: string, version: string, id: string }>()
274
+ readonly #audioFingerprints = new WeakMap<File, Promise<string>>()
275
+ readonly #documentTimingDrafts = new DocumentTimingDrafts()
276
+ readonly #captionTimingDrafts = new CaptionTimingDrafts()
277
+ readonly #removedCueTimings = new Map<string, { pauseMs: number }>()
278
+ #cueTimingScope?: string
279
+ #documentEditor?: NativeDocumentEditor
280
+ #documentEditorCommands?: DocumentEditorCommands
281
+ #documentEditorBinding?: { identity: string, reset: number }
282
+ readonly #documentReturnPoints = new DocumentReturnPoints()
283
+ #documentViewport?: { element: HTMLElement, identity: string, returnScrollTop?: number, stop(): void }
284
+ #stopDocumentReturnScroll?: () => void
285
+ #openingDocument?: { directory: string, scope: number, generation: number, focus?: 'body', initial?: boolean, stop(): void,
286
+ basis?: { identity: string, reset: number, version?: string, content: TeachingDocument }, point?: DocumentReturnPoint, fallback?: DocumentReturnPoint }
287
+ #stopDocumentLayoutScroll?: () => void
288
+ #activeDocumentHistory?: DocumentHistoryEditor
289
+ #unsubscribeDocumentHistory?: () => void
290
+ #unsubscribeDocumentSelection?: () => void
291
+ #documentSelectionGeneration = 0
292
+ #removedDocumentPosition?: { identity: string, documentId: string, sectionId: string, stepId?: string, version: string, fingerprint: string, scope: number, generation: number, token: string }
293
+ #pendingDocumentFocus?: { documentId: string, nodeId: string, generation: number, afterSave?: true }
294
+ #pendingDocumentSelection?: { documentId: string, anchor: NativeTextPoint, head: NativeTextPoint, focused: boolean }
295
+ readonly #documentFields = new Map<string, DocumentFieldRegistration>()
296
+ #pendingDocumentField?: { field: Omit<DocumentFieldRegistration, 'focus' | 'editor'>, bookmark: DocumentFieldBookmark, reset: number, generation: number }
297
+ #state: WorkbenchState
298
+ readonly #listeners = new Set<Listener>()
299
+ readonly #commentDrafts = new CommentDraftBank()
300
+ readonly #componentPropertyDrafts = new ComponentPropertyDrafts()
301
+ readonly #positionSeed = Array.from(crypto.getRandomValues(new Uint32Array(2)), value => value.toString(36)).join('-')
302
+ #positionSequence = 0
303
+ readonly #selectedSteps = new Map<string, number>()
304
+ #pendingGo?: { document: string | undefined, command: Extract<PreviewCommand, { type: 'go' }> }
305
+ readonly #diagnostics: DocumentDiagnostics
306
+ readonly #history: DocumentHistory
307
+ readonly #jobs: DocumentJobs
308
+ readonly #voiceover: VoiceoverJobs
309
+ readonly #askers = new Set<(request: AgentRequest) => Promise<AgentAnswer | void>>()
310
+ readonly #previewSinks = new Set<PreviewSink>()
311
+ readonly #playbackKeys = new Set<(command: PlaybackKey) => void>()
312
+ #unsubscribeIr?: () => void
313
+ #runtimeDocument?: string
314
+ #jobsDocument?: string
315
+ readonly #jobObservers = new Set<symbol>()
316
+ readonly #previewObservers = new Set<symbol>()
317
+ #previewReads = 0
318
+ #presentationPreparation = 0
319
+ #presentationController?: AbortController
320
+ #irReads = 0
321
+ #focusVersion = 0
322
+ #focusWriting: Promise<void> = Promise.resolve()
323
+ #documentScope = 0
324
+ #jobPreparation?: AbortController
325
+ readonly #playback = new PreviewPlayback(() => ({ document: this.#state.document, scope: this.#documentScope, sinks: this.#previewSinks }))
326
+ readonly #production = new ProductionPlayback(() => ({ document: this.#state.document, scope: this.#documentScope, sinks: this.#previewSinks,
327
+ binding: this.#state.productionPlan?.binding, ready: this.#productionReady() }), state => this.#set({ productionPlayback: state }))
328
+ /** A place to show that no preview could take yet (none was attached, e.g. the chat covered the document). */
329
+ #pendingReveal?: PreviewAnchor
330
+ /** The AI's changes to show on the document once the preview has them. */
331
+ #pendingFlash?: ReviewChange[]
332
+ #trail = 0
333
+ #lastPosition?: string
334
+ #awaitingView?: WebView
335
+ /** The forms asked for ahead of the author (`web:<dir>`, `slides:<dir>`), and the wait before asking for the slides. */
336
+ /** What the author does, for the operator's collector when the host has one. */
337
+ readonly telemetry: OperationLog
338
+ readonly #preparationTimeoutMs: number
339
+
340
+ constructor(options: { client?: WorkspaceClient, locale?: Locale, isDark?: boolean, preparationTimeoutMs?: number } = {}) {
341
+ this.client = options.client ?? workspaceClient()
342
+ this.#preparationTimeoutMs = Number.isFinite(options.preparationTimeoutMs) ? Math.max(1000, Math.min(300_000, options.preparationTimeoutMs!)) : 45_000
343
+ this.#state = {
344
+ locale: options.locale ?? localeOf(globalThis.navigator?.language) ?? 'en',
345
+ isDark: options.isDark ?? false,
346
+ mode: 'editing',
347
+ previewRenderer: 'web',
348
+ webView: 'document',
349
+ previewReload: 0,
350
+ previewConnection: 0,
351
+ documentPositionToken: this.#nextDocumentPosition(),
352
+ productionNavigationVersion: 0,
353
+ documentPositionIntent: 'seek',
354
+ isDiagnosticsUnavailable: false,
355
+ canRepairDiagnostics: false,
356
+ isRepairingDiagnostics: false,
357
+ save: { kind: 'idle' },
358
+ focus: [],
359
+ isFocusPending: false,
360
+ isHistoryBusy: false,
361
+ isAsking: false,
362
+ }
363
+ const workbench = this
364
+ const store: WorkbenchStore = {
365
+ get state() {
366
+ return workbench.#state
367
+ },
368
+ set: patch => workbench.#set(patch),
369
+ }
370
+ this.#documentSession = new DocumentSession(this.client, authoring => {
371
+ if (this.#state.document !== authoring.directory) return
372
+ const pendingField = this.#pendingDocumentField
373
+ if (pendingField && (pendingField.field.identity !== authoring.identity || authoring.reset > pendingField.reset
374
+ || pendingField.field.blockId !== undefined && !authoring.content?.blocks[pendingField.field.blockId]
375
+ || !authoring.content?.steps[pendingField.field.stepId])) this.#pendingDocumentField = undefined
376
+ const cueTimingScope = JSON.stringify([authoring.identity, authoring.reset])
377
+ if (this.#cueTimingScope !== cueTimingScope) { this.#removedCueTimings.clear(); this.#cueTimingScope = cueTimingScope }
378
+ const previous = this.#state.authoring
379
+ // A saved timestamp belongs to this client's acknowledged source version.
380
+ // Remote adoption has no server modification time to display in its place.
381
+ const adoptedCleanVersion = Boolean(previous?.identity && previous.identity === authoring.identity
382
+ && previous.version && authoring.version && previous.version !== authoring.version
383
+ && !previous.loading && !authoring.loading && !previous.dirty && !authoring.dirty && !previous.saving && !authoring.saving)
384
+ const position = this.#state.position
385
+ const selected = position && previous && previous.identity === authoring.identity ? previous.content?.sections[position.section]?.stepOrder[position.step] : undefined
386
+ const selectedStep = selected ? authoring.content?.steps[selected] : undefined, selectedPage = selectedStep ? authoring.content?.sections[selectedStep.sectionId] : undefined
387
+ const selectedIndex = selectedPage?.stepOrder.indexOf(selected!) ?? -1
388
+ const moved = position && selectedStep && selectedIndex >= 0 && (position.section !== selectedStep.sectionId || position.step !== selectedIndex)
389
+ const removed = this.#state.webView === 'document' && position?.view !== 'present' && position && previous?.identity === authoring.identity
390
+ && (selected ? !selectedStep || selectedIndex < 0 : !authoring.content?.sectionOrder.includes(position.section))
391
+ const restoredPosition = !removed && this.#restoredDocumentPosition(authoring)
392
+ const scope = this.#documentScope, generation = this.#documentSelectionGeneration
393
+ if (removed && authoring.identity) this.#documentReturnPoints.forget(authoring.identity)
394
+ this.#set({ authoring,
395
+ ...removed ? { position: undefined, documentNavigation: undefined, documentPositionFailure: 'removed' as const } : {},
396
+ ...restoredPosition ? { documentPositionFailure: undefined } : {},
397
+ ...moved ? { position: { ...position, section: selectedStep.sectionId, step: selectedIndex, trail: [] } } : {},
398
+ hasDocumentTimingDrafts: Boolean(authoring.identity && authoring.content && this.#documentTimingDrafts.has(authoring.identity, authoring.content)),
399
+ documentTimingDraftStorageFailure: this.#documentTimingDrafts.failed,
400
+ captionTimingDraftSteps: authoring.identity && authoring.member?.id && authoring.content ? this.#captionTimingDrafts.steps({ identity: authoring.identity, memberId: authoring.member.id }, authoring.content) : [],
401
+ captionTimingDraftStorageFailure: Boolean(authoring.identity && authoring.member?.id && this.#captionTimingDrafts.failed({ identity: authoring.identity, memberId: authoring.member.id })),
402
+ save: authoring.error ? { kind: 'failed', message: authoring.error } : authoring.saving ? { kind: 'saving' } : !authoring.dirty && (previous?.dirty || previous?.saving) ? { kind: 'saved', at: new Date().toISOString() } : adoptedCleanVersion || this.#state.save.kind === 'failed' ? { kind: 'idle' } : this.#state.save })
403
+ // The deletion owns the token produced above; source restoration only retires its notice.
404
+ if (removed && previous?.identity && previous.content && previous.version && previous.fingerprint
405
+ && !previous.loading && !previous.dirty && !previous.saving && !previous.restored && !previous.composing && !previous.recovering && !previous.error
406
+ && scope === this.#documentScope && generation === this.#documentSelectionGeneration && !this.#state.position && this.#state.documentPositionFailure === 'removed') {
407
+ this.#removedDocumentPosition = { identity: previous.identity, documentId: previous.content.id, sectionId: position.section,
408
+ ...selected ? { stepId: selected } : {}, version: previous.version, fingerprint: previous.fingerprint, scope, generation, token: this.#state.documentPositionToken }
409
+ }
410
+ this.#finishOpeningDocument()
411
+ // Native insertion can select a page before the source actor receives that new entity.
412
+ if (position && previous && previous.identity === authoring.identity && !previous.content?.sections[position.section]
413
+ && authoring.content?.sections[position.section]) this.#saveDocumentReturnPoint()
414
+ if (this.#production.changed()) void this.pausePlayback().catch(() => {})
415
+ const acknowledgedSourceChanged = !authoring.dirty && !authoring.loading && !authoring.error && !previous?.loading
416
+ && previous?.identity === authoring.identity && Boolean(previous?.version && authoring.version)
417
+ && (previous?.version !== authoring.version || previous?.fingerprint !== authoring.fingerprint)
418
+ if (!authoring.dirty && previous?.dirty || acknowledgedSourceChanged) { void this.#history.read(authoring.directory); void this.loadDocuments() }
419
+ // The source changed under an author with nothing unsaved while the AI holds a review: the AI changed it. The
420
+ // editor's kitten walks to the blocks whose content differs.
421
+ if (acknowledgedSourceChanged && !previous?.dirty && !previous?.saving && this.#state.review && previous?.content && authoring.content) {
422
+ const targets = changedBlocks(previous.content, authoring.content)
423
+ if (targets.length) this.#set({ changedPlaces: { id: ++this.#trail, targets } })
424
+ }
425
+ })
426
+ this.#diagnostics = new DocumentDiagnostics(this.client, store)
427
+ this.#jobs = new DocumentJobs(this.client, store)
428
+ this.#voiceover = new VoiceoverJobs(this.client, store, (job, signal) => this.#refreshVoiceover(job, signal))
429
+ this.#history = new DocumentHistory(this.client, store, (changes) => {
430
+ this.#record('ai.change', { 'presmium.files': changes.map(change => change.path).join('\n'), 'presmium.added': changes.reduce((n, c) => n + c.added, 0), 'presmium.removed': changes.reduce((n, c) => n + c.removed, 0) })
431
+ this.#pendingFlash = changes
432
+ this.#flashChanges()
433
+ })
434
+ this.telemetry = new OperationLog(() => this.client.readTelemetry(), (batch, beacon) => this.client.sendTelemetry(batch, beacon), this.#state.locale)
435
+ void this.telemetry.start()
436
+ }
437
+
438
+ /** Records an operation of the author, with the document, the view and the mode it was done in. */
439
+ #record(name: string, attributes: Record<string, OperationValue> = {}): void {
440
+ this.telemetry.record(name, { 'presmium.document': this.#state.document, 'presmium.view': this.#state.previewRenderer, 'presmium.mode': this.#state.mode, ...attributes })
441
+ }
442
+
443
+ /** Does an operation that takes time and records it once done, with its outcome and how long it took. */
444
+ async #timed<T>(name: string, attributes: Record<string, OperationValue>, work: () => Promise<T>, outcome: (value: T) => string = () => 'done'): Promise<T> {
445
+ const started = Date.now()
446
+ try {
447
+ const value = await work()
448
+ this.#record(name, { ...attributes, 'presmium.outcome': outcome(value), 'presmium.ms': Date.now() - started })
449
+ return value
450
+ } catch (error) {
451
+ this.#record(name, { ...attributes, 'presmium.outcome': 'failed', 'presmium.error': errorMessage(error), 'presmium.ms': Date.now() - started })
452
+ throw error
453
+ }
454
+ }
455
+
456
+ get state(): WorkbenchState {
457
+ return this.#state
458
+ }
459
+
460
+ /** Page-lifetime document navigation only; not a canonical source or playback revision. */
461
+ get documentNavigationVersion(): number { return this.#documentScope }
462
+
463
+ documentCommentDrafts(identity: string) {
464
+ const source = this.#state.authoring
465
+ return source?.identity === identity && source.member?.id ? this.#commentDrafts.get(identity, source.member.id) : undefined
466
+ }
467
+ /** A status counter only: passive client reports never enumerate or serialize drafts. */
468
+ authoringCommentDraftRevision(): number {
469
+ const source = this.#state.authoring
470
+ return source?.identity && source.member?.id ? this.#commentDrafts.peek(source.identity, source.member.id)?.revision ?? 0 : 0
471
+ }
472
+ documentPropertyDrafts(identity: string): ComponentPropertyDrafts | undefined { return this.#state.authoring?.identity === identity ? this.#componentPropertyDrafts : undefined }
473
+ /** Only counters enter passive client state; no new form payload is exposed to MCP. */
474
+ authoringDraftRevision(): string {
475
+ const source = this.#state.authoring
476
+ return JSON.stringify([this.authoringCommentDraftRevision(), source?.identity && source.member?.id ? this.#componentPropertyDrafts.revision(source.identity, source.member.id) : 0])
477
+ }
478
+ subscribeAuthoringDraftChanges(listener: () => void): () => void {
479
+ const comments = this.#commentDrafts.subscribe(listener), properties = this.#componentPropertyDrafts.subscribe(listener)
480
+ return () => { comments(); properties() }
481
+ }
482
+
483
+ getState = (): WorkbenchState => this.#state
484
+
485
+ subscribe = (listener: Listener): (() => void) => {
486
+ this.#listeners.add(listener)
487
+ return () => this.#listeners.delete(listener)
488
+ }
489
+
490
+ onRepairDiagnostics = (repairer: (diagnostics: readonly Diagnostic[]) => void | Promise<void>): (() => void) => this.#diagnostics.onRepair(repairer)
491
+
492
+ requestRepairDiagnostics(): Promise<void> {
493
+ this.#record('diagnostics.repair')
494
+ return this.#diagnostics.requestRepair()
495
+ }
496
+
497
+ /** Settings changed without changing document content; open voice surfaces read fresh capabilities. */
498
+ refreshVoiceServices(): void {
499
+ this.stopAuditions()
500
+ this.#set({ voiceServiceVersion: (this.#state.voiceServiceVersion ?? 0) + 1 })
501
+ }
502
+
503
+ /** The host's way to hand a request to the conversation's agent. */
504
+ onAgentRequest = (asker: (request: AgentRequest) => Promise<AgentAnswer | void>): (() => void) => {
505
+ this.#askers.add(asker)
506
+ return () => this.#askers.delete(asker)
507
+ }
508
+
509
+ #nextDocumentPosition(): string { return `${this.#positionSeed}:${++this.#positionSequence}` }
510
+
511
+ #restoredDocumentPosition(source: DocumentSessionState): boolean {
512
+ const removed = this.#removedDocumentPosition
513
+ if (!removed) return false
514
+ if (removed.scope !== this.#documentScope || removed.generation !== this.#documentSelectionGeneration || removed.token !== this.#state.documentPositionToken
515
+ || removed.identity !== source.identity || this.#state.documentPositionFailure !== 'removed' || source.error) {
516
+ this.#removedDocumentPosition = undefined
517
+ return false
518
+ }
519
+ const section = source.content?.sections[removed.sectionId]
520
+ return !source.loading && !source.dirty && !source.saving && !source.restored && !source.composing && !source.recovering
521
+ && source.version === removed.version && source.fingerprint === removed.fingerprint && source.content?.id === removed.documentId
522
+ && Boolean(section && source.content?.sectionOrder.includes(removed.sectionId)
523
+ && (!removed.stepId || section.stepOrder.includes(removed.stepId) && source.content.steps[removed.stepId]?.sectionId === removed.sectionId))
524
+ }
525
+
526
+ #set(patch: Partial<WorkbenchState>): void {
527
+ if (Object.hasOwn(patch, 'position') || Object.hasOwn(patch, 'documentPositionFailure')) this.#removedDocumentPosition = undefined
528
+ if (Object.hasOwn(patch, 'position')) { this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.() }
529
+ this.#state = { ...this.#state, ...patch, ...Object.hasOwn(patch, 'position') ? { documentPositionToken: this.#nextDocumentPosition(), documentPositionIntent: patch.documentPositionIntent ?? 'seek' } : {} }
530
+ if (Object.hasOwn(patch, 'position')) this.#saveDocumentReturnPoint()
531
+ for (const listener of this.#listeners) listener()
532
+ }
533
+
534
+ get #messages() {
535
+ return MESSAGES[this.#state.locale]
536
+ }
537
+
538
+ setLocale(locale: Locale): void {
539
+ if (locale === this.#state.locale) return
540
+ this.#set({ locale })
541
+ this.#record('locale.set', { 'presmium.locale': locale })
542
+ }
543
+
544
+ setDark(isDark: boolean): void {
545
+ if (isDark === this.#state.isDark) return
546
+ this.#set({ isDark })
547
+ this.#broadcast({ type: 'appearance', dark: isDark })
548
+ this.#record('appearance.set', { 'presmium.dark': isDark })
549
+ }
550
+
551
+ async loadDocuments(): Promise<void> {
552
+ try {
553
+ this.#set({ documents: await this.client.readDocuments(), documentsFailure: undefined, isBundleDisabled: false })
554
+ } catch (error) {
555
+ this.#set({ documentsFailure: errorMessage(error), isBundleDisabled: error instanceof WorkspaceServerError && error.status === 404 })
556
+ }
557
+ }
558
+
559
+ planDocumentOperation(operation: ProjectOperation): Promise<ProjectPlan> {
560
+ return this.client.planProjectChange({ id: crypto.randomUUID(), operation })
561
+ }
562
+
563
+ operateDocument(operation: ProjectOperation, planned?: ProjectPlan): Promise<OperationAnswer> {
564
+ const attributes = Object.fromEntries(Object.entries(operation).filter(([, value]) => typeof value === 'string').map(([key, value]) => [`presmium.op.${key}`, value as string]))
565
+ return this.#timed(`document.${operation.op}`, attributes, async () => {
566
+ const scope = this.#documentScope
567
+ if (this.#state.document === operation.dir) await this.flushPendingEdits()
568
+ const answer = await this.client.operateDocument(operation, planned)
569
+ if ((operation.op === 'rename' || operation.op === 'delete') && scope === this.#documentScope && this.#state.document === operation.dir) this.closeDocument()
570
+ await this.loadDocuments()
571
+ return answer
572
+ })
573
+ }
574
+
575
+ /** Makes a document called `title` from the template, in a directory of its own; answers the directory. */
576
+ async createDocument(title: string, template?: string): Promise<string> {
577
+ const taken = this.#state.documents?.documents.map(doc => doc.dir) ?? []
578
+ const answer = await this.operateDocument({ op: 'create', dir: documentDirFor(title, taken), title, ...template ? { template } : {} })
579
+ return answer.dir!
580
+ }
581
+
582
+ /** Copies a document; the copy is called `title`. */
583
+ async copyDocument(dir: string, title: string): Promise<string> {
584
+ const taken = this.#state.documents?.documents.map(doc => doc.dir) ?? []
585
+ let to = `${dir}-copy`
586
+ for (let n = 2; taken.includes(to); n++) to = `${dir}-copy-${n}`
587
+ const answer = await this.operateDocument({ op: 'copy', dir, to, title })
588
+ return answer.dir ?? to
589
+ }
590
+
591
+ /** Title changes use the same fixed project plan as directory management. */
592
+ async retitleDocument(dir: string, title: string): Promise<void> {
593
+ await this.operateDocument({ op: 'retitle', dir, title })
594
+ }
595
+
596
+ #watchOpeningInteraction(cancel: () => void): () => void {
597
+ if (typeof document === 'undefined') return () => {}
598
+ const focused = document.activeElement, dialog = focused?.closest('[role="dialog"]')
599
+ const interact = (event: Event) => {
600
+ if (!event.isTrusted) return
601
+ if (event.type === 'focusin' && (!(event.target instanceof Element) || event.target === focused || dialog?.contains(event.target))) return
602
+ cancel()
603
+ }
604
+ const events = ['pointerdown', 'keydown', 'beforeinput', 'wheel', 'touchmove', 'focusin']
605
+ for (const event of events) document.addEventListener(event, interact, true)
606
+ return () => { for (const event of events) document.removeEventListener(event, interact, true) }
607
+ }
608
+
609
+ #cancelOpeningDocument(): void {
610
+ const pending = this.#openingDocument
611
+ this.#openingDocument = undefined
612
+ pending?.stop()
613
+ }
614
+
615
+ #beginOpeningDocument(directory: string, focus?: 'body', fallback?: DocumentReturnPoint): void {
616
+ this.#cancelOpeningDocument()
617
+ this.#openingDocument = { directory, focus, fallback: fallback && { ...fallback }, scope: this.#documentScope, generation: this.#documentSelectionGeneration,
618
+ stop: this.#watchOpeningInteraction(() => this.#cancelOpeningDocument()) }
619
+ }
620
+
621
+ #saveDocumentReturnPoint(): void {
622
+ const source = this.#state.authoring, position = this.#state.position
623
+ if (!source?.identity || !source.content || source.loading || !position || !source.content.sectionOrder.includes(position.section)) return
624
+ const section = source.content.sections[position.section]
625
+ let stepId = section?.stepOrder[position.step]
626
+ if (this.#state.webView === 'present' || position.view === 'present') {
627
+ const snapshot = this.#state.presentationSnapshot, ir = this.#state.ir
628
+ if (!snapshot || !ir || source.dirty || source.saving || source.error || snapshot.version !== source.version || snapshot.fingerprint !== source.fingerprint
629
+ || snapshot.project !== (source.projectRevision ?? 0) || snapshot.revision !== ir.revision || snapshot.revision !== this.#state.previewIrRevision) return
630
+ const ids = ir.doc.sections.find(page => page.id === position.section)?.stepIds
631
+ if (!ids || ids.length && !ids[position.step]) return
632
+ stepId = ids[position.step]
633
+ if (stepId && (!section?.stepOrder.includes(stepId) || source.content.steps[stepId]?.sectionId !== position.section)) return
634
+ }
635
+ if (!section || !stepId && section.stepOrder.length) return
636
+ const viewport = this.#documentViewport, previous = this.#documentReturnPoints.read(source.identity)
637
+ const measured = this.#state.webView === 'document' && viewport?.identity === source.identity ? documentViewportOffset(viewport.element, section.id) : undefined
638
+ const offset = measured ?? (previous?.sectionId === section.id ? previous.offset : undefined)
639
+ this.#documentReturnPoints.write(source.identity, { sectionId: section.id, ...stepId ? { stepId } : {}, ...offset !== undefined ? { offset } : {} })
640
+ }
641
+
642
+ #finishOpeningDocument(): void {
643
+ const pending = this.#openingDocument, source = this.#state.authoring
644
+ if (!pending || source?.loading || !source?.identity || !source.content) return
645
+ if (source.error === 'document-identity-changed') { this.#cancelOpeningDocument(); return }
646
+ if (pending.scope !== this.#documentScope || pending.directory !== this.#state.document || pending.generation !== this.#documentSelectionGeneration) { this.#cancelOpeningDocument(); return }
647
+ if (!pending.basis) {
648
+ pending.basis = { identity: source.identity, reset: source.reset, version: source.version, content: source.content }
649
+ if (!pending.focus) {
650
+ pending.point = this.#documentReturnPoints.read(source.identity) ?? pending.fallback
651
+ if (!pending.point) {
652
+ const section = source.content.sections[source.content.sectionOrder[0]!]
653
+ if (!section) { this.#cancelOpeningDocument(); return }
654
+ pending.point = { sectionId: section.id, ...section.stepOrder[0] ? { stepId: section.stepOrder[0] } : {} }
655
+ pending.initial = true
656
+ }
657
+ if (documentReturnStep(source.content, pending.point) === undefined) {
658
+ this.#documentReturnPoints.forget(source.identity); this.#cancelOpeningDocument()
659
+ this.#set({ documentPositionFailure: 'removed' }); return
660
+ }
661
+ }
662
+ }
663
+ const basis = pending.basis
664
+ if (source.identity !== basis.identity || source.reset !== basis.reset || source.version !== basis.version || source.content !== basis.content) { this.#cancelOpeningDocument(); return }
665
+ const editor = this.#documentEditor, binding = this.#documentEditorBinding, viewport = this.#documentViewport
666
+ if (!editor || editor.prosemirrorView.isDestroyed || binding?.identity !== source.identity || binding.reset !== source.reset || viewport?.identity !== source.identity) return
667
+ // The editor can mount read-only while the source is still loading; a read-only body cannot take the caret yet.
668
+ if (pending.focus && !editor.isEditable) return
669
+ this.#cancelOpeningDocument()
670
+ if (pending.focus) {
671
+ const body = (blocks: typeof editor.document, sectionId?: string): { nodeId: string, sectionId: string } | undefined => {
672
+ for (const block of blocks) {
673
+ if (block.type === 'presmiumPage') sectionId = block.props.sectionId
674
+ else if (sectionId && editor.schema.blockSchema[block.type].content === 'inline') return { nodeId: block.id, sectionId }
675
+ const nested = body(block.children, sectionId)
676
+ if (nested) return nested
677
+ }
678
+ }
679
+ const target = body(editor.document)
680
+ if (target && source.content.sections[target.sectionId]) {
681
+ this.#set({ position: { section: target.sectionId, step: 0, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: undefined })
682
+ this.#focusDocumentNode(editor, target.nodeId)
683
+ }
684
+ return
685
+ }
686
+ const point = pending.point!, step = documentReturnStep(source.content, point)
687
+ if (step === undefined) return
688
+ this.#selectedSteps.set(`${pending.directory}\0${point.sectionId}`, step)
689
+ this.#set({ position: { section: point.sectionId, step, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: undefined })
690
+ if (pending.initial) return
691
+ const current = () => this.#state.document === pending.directory && this.#state.authoring?.identity === basis.identity
692
+ && this.#state.authoring.reset === basis.reset && this.#state.authoring.version === basis.version && this.#state.authoring.content === basis.content
693
+ && this.#documentViewport === viewport && this.#state.position?.section === point.sectionId && this.#state.position.step === step
694
+ const restore = () => {
695
+ if (!current()) { stop(); return }
696
+ const top = viewport.element.scrollTop
697
+ restoreDocumentViewport(viewport.element, point)
698
+ if (viewport.element.scrollTop !== top) viewport.returnScrollTop = viewport.element.scrollTop
699
+ }
700
+ const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(restore)
701
+ let stopInteraction: () => void = () => {}
702
+ const stop = () => { observer?.disconnect(); stopInteraction(); viewport.returnScrollTop = undefined; if (this.#stopDocumentReturnScroll === stop) this.#stopDocumentReturnScroll = undefined }
703
+ this.#stopDocumentReturnScroll?.(); this.#stopDocumentReturnScroll = stop
704
+ stopInteraction = this.#watchOpeningInteraction(stop)
705
+ observer?.observe(viewport.element)
706
+ observer?.observe(editor.prosemirrorView.dom)
707
+ restore()
708
+ }
709
+
710
+ /** Consume only this viewport owner's actual programmatic destination; user gestures retire it. */
711
+ consumeDocumentReturnScroll(element: HTMLElement): boolean {
712
+ const viewport = this.#documentViewport, top = viewport?.returnScrollTop
713
+ if (viewport?.element !== element || top === undefined) return false
714
+ viewport.returnScrollTop = undefined
715
+ return element.scrollTop === top
716
+ }
717
+
718
+ attachDocumentViewport(element: HTMLElement, identity: string): () => void {
719
+ this.#documentViewport?.stop()
720
+ const scroll = () => { if (!this.#openingDocument) this.#saveDocumentReturnPoint() }
721
+ const viewport = { element, identity, stop: () => element.removeEventListener('scroll', scroll) }
722
+ this.#documentViewport = viewport
723
+ element.addEventListener('scroll', scroll, { passive: true })
724
+ this.#finishOpeningDocument()
725
+ return () => { if (this.#documentViewport === viewport) { this.#saveDocumentReturnPoint(); this.#stopDocumentReturnScroll?.(); this.#documentViewport = undefined }; viewport.stop() }
726
+ }
727
+
728
+ /** The body editor became editable: an opening that waits to put the caret in it can finish now. */
729
+ documentEditorEditable(): void { this.#finishOpeningDocument() }
730
+
731
+ async openDocument(dir: string, options: { focus?: 'body', expectedIdentity?: string, fallbackReturnPoint?: DocumentReturnPoint } = {}): Promise<void> {
732
+ if (this.#state.document === dir) {
733
+ if (options.focus) { this.#beginOpeningDocument(dir, options.focus); this.#finishOpeningDocument() }
734
+ return
735
+ }
736
+ this.#saveDocumentReturnPoint()
737
+ this.#documentScope++
738
+ const scope = this.#documentScope
739
+ this.#stopSubscriptions()
740
+ this.#history.subscribeReview(dir)
741
+ this.#set({ ...DOCUMENT_RESET, document: dir, previewRenderer: 'web', webView: 'document' })
742
+ this.#beginOpeningDocument(dir, options.focus, options.fallbackReturnPoint)
743
+ this.#record('document.open')
744
+ this.#diagnostics.subscribe(dir)
745
+ // Authoring is ready from source JSON; compilation and preview are independent.
746
+ const [opened] = await Promise.all([this.#documentSession.open(dir, options.expectedIdentity), this.#readFocus(dir), this.#history.read(dir)])
747
+ if (opened === 'identity-changed' && scope === this.#documentScope && this.#state.document === dir) { this.closeDocument(); return }
748
+ this.#finishOpeningDocument()
749
+ }
750
+
751
+ closeDocument(): void {
752
+ this.#saveDocumentReturnPoint()
753
+ this.#documentScope++
754
+ if (this.#state.document !== undefined) this.#record('document.close')
755
+ this.#stopSubscriptions()
756
+ this.#documentSession.close()
757
+ this.#set({ ...DOCUMENT_RESET, document: undefined })
758
+ void this.loadDocuments()
759
+ }
760
+
761
+ async #readFocus(dir: string): Promise<void> {
762
+ const version = this.#focusVersion
763
+ try {
764
+ const answer = await this.client.readFocus(dir)
765
+ if (this.#state.document === dir && version === this.#focusVersion) this.#set({ focus: answer.focus ?? [] })
766
+ } catch {
767
+ }
768
+ }
769
+
770
+ async #readIr(dir: string): Promise<void> {
771
+ const read = ++this.#irReads
772
+ try {
773
+ const ir = await this.client.readIr(dir)
774
+ if (this.#state.document === dir && read === this.#irReads) {
775
+ this.#set({ ir })
776
+ this.#flashChanges()
777
+ }
778
+ } catch {
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Makes the blocks the AI changed blink on the document, once the preview shows the revision the workbench has: the
784
+ * author sees what changed where they read it, not as files.
785
+ */
786
+ #flashChanges(): void {
787
+ const changes = this.#pendingFlash
788
+ const ir = this.#state.ir
789
+ if (!changes || !ir || this.#state.previewIrRevision !== ir.revision || this.#previewSinks.size === 0) return
790
+ this.#pendingFlash = undefined
791
+ const targets = changedPlaces(ir.doc, changes)
792
+ if (targets.length === 0) return
793
+ this.#trail++
794
+ this.#broadcast({ type: 'flash', targets })
795
+ this.#record('ai.change.shown', { 'presmium.places': targets.map(target => [target.section, target.block].filter(Boolean).join(':')).join('\n') })
796
+ }
797
+
798
+ retryDiagnostics(): void {
799
+ this.#record('diagnostics.retry')
800
+ this.#diagnostics.retry()
801
+ }
802
+
803
+ reportPreviewDiagnostics(report: PreviewDiagnostics): Promise<void> {
804
+ return this.#diagnostics.reportPreview(report)
805
+ }
806
+
807
+ async #readPreview(dir: string): Promise<void> {
808
+ const read = ++this.#previewReads
809
+ const renderer = this.#state.previewRenderer
810
+ const current = () => this.#state.document === dir && this.#state.previewRenderer === renderer && read === this.#previewReads
811
+ try {
812
+ const preview = await readPreviewWhenReady(this.client, dir, renderer, { waiting: current })
813
+ if (current()) this.#set({ preview, previewFailure: undefined })
814
+ } catch (error) {
815
+ if (current()) this.#set({ previewFailure: error instanceof Error && error.name === 'TimeoutError' ? 'preview-timeout' : errorMessage(error) })
816
+ }
817
+ }
818
+
819
+ async #handleIrEvent(dir: string, event: IrEvent): Promise<void> {
820
+ if (this.#state.document !== dir) return
821
+ const renderers = this.#state.ir?.doc.config.renderers
822
+ if (event.revision !== this.#state.ir?.revision) {
823
+ await this.#readIr(dir)
824
+ // Opening the document and its initial SSE snapshot share one request.
825
+ // A save can arrive while that request still describes the previous revision.
826
+ if (this.#state.document === dir && event.revision !== this.#state.ir?.revision) await this.#readIr(dir)
827
+ }
828
+ if (this.#state.document !== dir) return
829
+ // The view switch goes with the slides: a document that lost them shows its web form.
830
+ if (this.#state.previewRenderer === 'slides' && !hasSlides(this.#state.ir)) this.setPreviewRenderer('web')
831
+ else if (renderers && JSON.stringify(renderers) !== JSON.stringify(this.#state.ir?.doc.config.renderers)) void this.#readPreview(dir)
832
+ if (event.files.length > 0) void this.#history.read(dir)
833
+ }
834
+
835
+ setMode(mode: WorkbenchMode): void {
836
+ if (mode === this.#state.mode) return
837
+ this.#set({ mode })
838
+ this.#record('mode.set')
839
+ this.point(undefined)
840
+ this.#broadcast({ type: 'pick', on: false })
841
+ }
842
+
843
+ setPreviewRenderer(renderer: PreviewRenderer): void {
844
+ if (renderer === this.#state.previewRenderer) return
845
+ this.#set({ previewRenderer: renderer, preview: undefined, previewFailure: undefined, previewIrRevision: undefined, pointer: undefined })
846
+ this.#record('view.set')
847
+ this.#syncRuntimeStreams()
848
+ const document = this.#state.document
849
+ if (document !== undefined) void this.#readPreview(document)
850
+ }
851
+
852
+ /** Only public modal semantics are inspected; no field values or editor selection are read. */
853
+ #previewModalOpen(): boolean {
854
+ return typeof document !== 'undefined' && [...document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]')]
855
+ .some(element => !element.closest('[hidden], [inert]') && element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden')
856
+ }
857
+
858
+ #canPreparePresentation(owned = false): boolean {
859
+ const state = this.#state, source = state.authoring
860
+ return Boolean(state.document !== undefined && source?.identity && source.version && source.fingerprint && source.content
861
+ && !source.loading && !source.dirty && !source.saving && !source.restored && !source.error && !source.composing && !source.recovering
862
+ && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure && !state.isCreatingPage && !state.isEditingPage
863
+ && !state.isHistoryBusy && !state.isHistoryPreparing && !state.jobPreparing && !state.isBundleDisabled && !isAgentChanging(state)
864
+ && (owned || !state.isPreparingPresentation) && !this.#previewModalOpen())
865
+ }
866
+
867
+ /** One explicit preparation owner; caller cancellation stops adoption without changing the current mode. */
868
+ async preparePresentation(signal?: AbortSignal, deadline?: number): Promise<boolean> {
869
+ const directory = this.#state.document, source = this.#state.authoring
870
+ if (!this.#canPreparePresentation() || directory === undefined || !source?.identity || !source.version || !source.fingerprint || !source.content) {
871
+ this.#set({ presentationFailure: this.#state.hasDocumentTimingDrafts ? this.#messages.timingDraftPending : this.#messages.presentationPending })
872
+ return false
873
+ }
874
+ signal?.throwIfAborted()
875
+ this.#presentationController?.abort(new Error('presentation-superseded'))
876
+ const request = ++this.#presentationPreparation, scope = this.#documentScope
877
+ const controller = this.#presentationController = new AbortController()
878
+ const cancel = () => controller.abort(signal?.reason)
879
+ signal?.addEventListener('abort', cancel, { once: true })
880
+ const { identity, version, fingerprint } = source, project = source.projectRevision ?? 0
881
+ const current = () => request === this.#presentationPreparation && scope === this.#documentScope && this.#state.document === directory && this.#state.authoring?.identity === identity
882
+ const unchanged = () => {
883
+ const latest = this.#state.authoring
884
+ return current() && !controller.signal.aborted && (deadline === undefined || Date.now() < deadline) && this.#canPreparePresentation(true)
885
+ && latest?.version === version && latest.fingerprint === fingerprint && (latest.projectRevision ?? 0) === project
886
+ }
887
+ this.#set({ isPreparingPresentation: true, presentationFailure: undefined })
888
+ const timeout = setTimeout(() => controller.abort(deadline === undefined ? new DOMException('Presentation preparation timed out', 'TimeoutError') : new Error('client-request-expired')), Math.max(0, Math.min(this.#preparationTimeoutMs, deadline === undefined ? this.#preparationTimeoutMs : deadline - Date.now())))
889
+ const stop = this.subscribe(() => { if (!unchanged()) controller.abort(new Error('presentation-source-changed')) })
890
+ const input = () => controller.abort(new Error('client-preview-input'))
891
+ const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') controller.abort(new Error('client-preview-cancelled')) }
892
+ const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(() => { if (this.#previewModalOpen()) controller.abort(new Error('client-preview-modal-open')) })
893
+ if (typeof document !== 'undefined') {
894
+ document.addEventListener('beforeinput', input, true); document.addEventListener('compositionstart', input, true); document.addEventListener('keydown', escape, true)
895
+ observer?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'inert'] })
896
+ }
897
+ try {
898
+ await waitForPreviewRequest(this.pausePlayback(), controller.signal)
899
+ if (!unchanged()) return false
900
+ controller.signal.throwIfAborted()
901
+ const snapshot = await compileDocumentPreview(this.client, { directory, version, fingerprint, project }, controller.signal)
902
+ controller.signal.throwIfAborted()
903
+ const ir = snapshot.ir
904
+ if (!current()) return false
905
+ const entry = directory ? `${directory}/lecture.json` : 'lecture.json'
906
+ if (!unchanged() || ir.revision !== snapshot.revision || ir.sourceFingerprints?.[entry] !== fingerprint
907
+ || ir.doc.source?.version !== version || ir.doc.source.fingerprint !== fingerprint) throw new Error('presentation-source-changed')
908
+ if (ir.diagnostics.some(finding => finding.severity === 'error')) throw new Error('document-preview-compile-failed')
909
+ this.#set({ ir, preview: snapshot.preview, previewRenderer: 'web', previewFailure: undefined, presentationSnapshot: { version, fingerprint, revision: ir.revision, project } })
910
+ return true
911
+ } catch (error) {
912
+ // Editing supersedes this snapshot; the existing stale status owns its recovery action.
913
+ const cancelled = controller.signal.aborted && !(controller.signal.reason instanceof Error && controller.signal.reason.name === 'TimeoutError')
914
+ if (current()) this.#set({ presentationFailure: cancelled || error instanceof Error && error.message === 'presentation-source-changed' ? undefined : this.#messages.presentationCompileFailed })
915
+ return false
916
+ } finally {
917
+ clearTimeout(timeout); stop(); signal?.removeEventListener('abort', cancel); observer?.disconnect()
918
+ if (typeof document !== 'undefined') { document.removeEventListener('beforeinput', input, true); document.removeEventListener('compositionstart', input, true); document.removeEventListener('keydown', escape, true) }
919
+ if (this.#presentationController === controller) this.#presentationController = undefined
920
+ if (current()) this.#set({ isPreparingPresentation: false })
921
+ }
922
+ }
923
+
924
+ /** Closing an explicit preview cancels its pending adoption, while source saves retain their owner. */
925
+ cancelPreviewPreparation(): void {
926
+ if (!this.#state.isPreparingPresentation) return
927
+ this.#presentationController?.abort(new Error('client-preview-cancelled'))
928
+ this.#presentationPreparation++
929
+ this.#set({ isPreparingPresentation: false, presentationFailure: undefined })
930
+ }
931
+
932
+ /**
933
+ * Shows the web form as a page or as its presentation. The page is the same (and stays loaded): the view changes there,
934
+ * at the place the author is; from the slides, the web form is shown first.
935
+ */
936
+ setWebView(view: WebView): void {
937
+ if (view === this.#state.webView && this.#state.previewRenderer === 'web') return
938
+ this.#awaitingView = view
939
+ this.#set({ webView: view })
940
+ this.#syncRuntimeStreams()
941
+ this.#record('view.set', { 'presmium.preview.view': view })
942
+ if (this.#state.previewRenderer !== 'web') {
943
+ this.setPreviewRenderer('web')
944
+ } else { if (view === 'present' && this.#state.document !== undefined) void this.#readPreview(this.#state.document); this.#showWebView() }
945
+ }
946
+
947
+ /** Source editing never owns IR or job streams. Export and presentation share one optional runtime stream. */
948
+ #syncRuntimeStreams(): void {
949
+ const document = this.#state.document
950
+ const jobs = document !== undefined && this.#jobObservers.size > 0
951
+ const runtime = document !== undefined && !jobs && (this.#previewObservers.size > 0 || this.#state.webView === 'present' || this.#state.previewRenderer === 'slides')
952
+ if (!runtime || this.#runtimeDocument !== document) {
953
+ this.#unsubscribeIr?.(); this.#unsubscribeIr = undefined; this.#runtimeDocument = undefined
954
+ }
955
+ if (!jobs || this.#jobsDocument !== document) { this.#jobs.stop(); this.#jobsDocument = undefined }
956
+ if (jobs && this.#jobsDocument !== document) { this.#jobsDocument = document; this.#jobs.subscribe(document!) }
957
+ if (runtime && !this.#unsubscribeIr) {
958
+ this.#runtimeDocument = document
959
+ this.#unsubscribeIr = this.client.subscribeIr(document!, event => void this.#handleIrEvent(document!, event), () => void this.#readIr(document!))
960
+ void this.#readIr(document!)
961
+ }
962
+ }
963
+
964
+ /** A visible reader or presentation shares the existing optional IR stream. */
965
+ observePreviewRuntime(): () => void {
966
+ const token = Symbol('preview')
967
+ this.#previewObservers.add(token)
968
+ this.#syncRuntimeStreams()
969
+ return () => { if (this.#previewObservers.delete(token)) this.#syncRuntimeStreams() }
970
+ }
971
+
972
+ /** Follows export status only while a consumer is visible; it displaces the optional IR stream. */
973
+ observeJobs(): () => void {
974
+ const token = Symbol('exports')
975
+ this.#jobObservers.add(token)
976
+ this.#syncRuntimeStreams()
977
+ if (this.#state.document !== undefined) void this.#readIr(this.#state.document)
978
+ return () => { if (this.#jobObservers.delete(token)) this.#syncRuntimeStreams() }
979
+ }
980
+
981
+ /** Space and the left and right arrows pressed in a preview that the workbench plays (Video Production's). */
982
+ observePlaybackKeys(listener: (command: PlaybackKey) => void): () => void {
983
+ this.#playbackKeys.add(listener)
984
+ return () => { this.#playbackKeys.delete(listener) }
985
+ }
986
+
987
+ /** Video and its dialogs borrow one owner; its idle reads never prepare a snapshot or stop media. */
988
+ observeVoiceoverJobs(): () => void {
989
+ return this.#state.document === undefined ? () => {} : this.#voiceover.observe(this.#state.document)
990
+ }
991
+
992
+ rememberVoiceoverJob(document: string, job: VoiceoverJob): void { this.#voiceover.remember(document, job) }
993
+
994
+ #voiceoverPlaybackCurrent(job: VoiceoverJob): boolean {
995
+ const state = this.#state, snapshot = state.presentationSnapshot, binding = state.productionPlan?.binding
996
+ return Boolean(binding && this.#productionSourceCurrent(binding) && sameSource(binding.source, job.source) && binding.irRevision === job.revision
997
+ && snapshot?.version === job.source.version && snapshot.fingerprint === job.source.fingerprint && snapshot.revision === job.revision
998
+ && snapshot.project === (state.authoring?.projectRevision ?? 0) && state.previewIrRevision === job.revision
999
+ && state.webView === 'present' && state.previewRenderer === 'web' && state.preview?.web && !state.isPreparingPresentation
1000
+ && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure && !documentRecordingActive(this))
1001
+ }
1002
+
1003
+ async #refreshVoiceover(job: VoiceoverJob, signal: AbortSignal): Promise<boolean> {
1004
+ if (!this.#voiceoverPlaybackCurrent(job)) return false
1005
+ const document = this.#state.document!, scope = this.#documentScope, identity = this.#state.authoring?.identity, initialBinding = this.#state.productionPlan!.binding
1006
+ // This is the existing cache-only plan read: no compile, discovery, generation or draft flush.
1007
+ const plan = await this.client.readProductionPlan(document, { source: job.source, irRevision: job.revision }, signal)
1008
+ signal.throwIfAborted()
1009
+ const current = () => scope === this.#documentScope && identity === this.#state.authoring?.identity && this.#voiceoverPlaybackCurrent(job)
1010
+ && Boolean(this.#state.productionPlan && sameProductionBinding(initialBinding, this.#state.productionPlan.binding))
1011
+ if (!current()) return false
1012
+ if (!sameSource(plan.binding.source, job.source) || plan.binding.irRevision !== job.revision) throw new Error('voice-context-changed')
1013
+ if (sameProductionBinding(plan.binding, this.#state.productionPlan!.binding)) return true
1014
+ this.#playback.stopPreviewOutputs()
1015
+ await this.pausePlayback()
1016
+ signal.throwIfAborted()
1017
+ if (!current()) return false
1018
+ this.#production.reset()
1019
+ this.#set({ productionPlan: plan, voiceoverCacheRefresh: { source: job.source, revision: job.revision, sequence: (this.#state.voiceoverCacheRefresh?.sequence ?? 0) + 1 } })
1020
+ // The mounted production loader fetches this new binding in the existing frame.
1021
+ // Replacing the iframe for every clip would interrupt progressive playback and its connection.
1022
+ return true
1023
+ }
1024
+
1025
+ #showWebView(sinks: Iterable<PreviewSink> = this.#previewSinks): void {
1026
+ const selected = this.#state.position
1027
+ const section = selected?.section ?? this.#state.ir?.doc.sections[0]?.id ?? this.#state.authoring?.content?.sectionOrder[0]
1028
+ if (section === undefined) return
1029
+ // A fresh or reloaded frame reports its initial document view before applying this command.
1030
+ this.#awaitingView = this.#state.webView
1031
+ for (const sink of sinks) sink.send({ type: 'go', section, step: selected?.step ?? 0, view: this.#state.webView })
1032
+ }
1033
+
1034
+ reloadPreview(): void {
1035
+ this.#record('preview.reload')
1036
+ this.#reloadPreview()
1037
+ }
1038
+
1039
+ /** New exact audio caches can refresh an existing current preview, without compiling or starting playback. */
1040
+ refreshVoicePlayback(source: CanonicalSource, revision: string): boolean {
1041
+ const state = this.#state, authoring = state.authoring, snapshot = state.presentationSnapshot
1042
+ if (!authoring || authoring.content?.id !== source.documentId || authoring.version !== source.version || authoring.fingerprint !== source.fingerprint
1043
+ || authoring.loading || authoring.dirty || authoring.saving || authoring.composing || authoring.restored || authoring.error
1044
+ || !snapshot || snapshot.version !== source.version || snapshot.fingerprint !== source.fingerprint || snapshot.revision !== revision
1045
+ || snapshot.project !== (authoring.projectRevision ?? 0) || state.webView !== 'present' || state.previewRenderer !== 'web' || !state.preview?.web) return false
1046
+ this.#reloadPreview()
1047
+ return true
1048
+ }
1049
+
1050
+ #productionSourceCurrent(binding: ProductionBinding): boolean {
1051
+ const source = this.#state.authoring
1052
+ return Boolean(source?.content?.id === binding.source.documentId && source.version === binding.source.version && source.fingerprint === binding.source.fingerprint
1053
+ && !source.loading && !source.dirty && !source.saving && !source.composing && !source.restored && !source.error && !isAgentChanging(this.#state))
1054
+ }
1055
+
1056
+ #productionReady(): boolean {
1057
+ const state = this.#state, binding = state.productionPlan?.binding, snapshot = state.presentationSnapshot
1058
+ return Boolean(binding && this.#productionSourceCurrent(binding) && snapshot?.revision === binding.irRevision
1059
+ && snapshot.version === binding.source.version && snapshot.fingerprint === binding.source.fingerprint && snapshot.project === (state.authoring?.projectRevision ?? 0)
1060
+ && state.previewIrRevision === binding.irRevision && state.webView === 'present' && state.previewRenderer === 'web'
1061
+ && !state.hasDocumentTimingDrafts && !state.documentTimingDraftStorageFailure)
1062
+ }
1063
+
1064
+ /** Bounded observations, without reading or publishing any unsubmitted editor content. */
1065
+ authoringClientSnapshot(revision: number): AuthoringClientSnapshot | undefined {
1066
+ const state = this.#state, source = state.authoring, content = source?.content
1067
+ if (state.document === undefined || !content || !source?.identity || !source.version || !source.fingerprint) return undefined
1068
+ const locked = isAgentChanging(state), conflicted = Boolean(source.error || source.restored || source.recovering)
1069
+ const editing = { dirty: source.dirty, saving: source.saving, composing: source.composing, conflicted, locked, loading: source.loading }
1070
+ const safe = !source.loading && !source.composing && !conflicted && !locked && !state.isCreatingPage && !state.isEditingPage
1071
+ const clean = safe && !source.dirty && !source.saving && !state.hasDocumentTimingDrafts
1072
+ const plan = state.productionPlan, playback = state.productionPlayback
1073
+ const ready = clean && plan?.canPreview && this.#productionReady() && playback?.status === 'ready' && sameProductionBinding(plan.binding, playback.binding)
1074
+ const position = state.position, page = position && content.sections[position.section]
1075
+ const commentDrafts = source.member?.id ? this.#commentDrafts.peek(source.identity, source.member.id) : undefined
1076
+ const prepared = state.presentationSnapshot, actual = { documentId: content.id, version: source.version, fingerprint: source.fingerprint }
1077
+ const preparedCurrent = clean && prepared && prepared.version === source.version && prepared.fingerprint === source.fingerprint && prepared.project === (source.projectRevision ?? 0)
1078
+ && state.ir?.revision === prepared.revision && state.ir.doc.source?.version === source.version && state.ir.doc.source.fingerprint === source.fingerprint
1079
+ return { revision, source: { documentId: content.id, version: source.version, fingerprint: source.fingerprint }, view: state.webView, editing,
1080
+ interaction: state.documentPositionToken,
1081
+ ...page && position ? { position: { sectionId: page.id, ...page.stepOrder[position.step] ? { stepId: page.stepOrder[position.step] } : {} } } : {},
1082
+ ...plan ? { binding: plan.binding } : {}, ...playback ? { playback } : {},
1083
+ preview: preparedCurrent ? { status: 'prepared', source: actual, irRevision: prepared.revision, projectRevision: prepared.project }
1084
+ : { status: state.isPreparingPresentation ? 'preparing' : state.presentationFailure ? 'unavailable' : 'unprepared' },
1085
+ actions: ['state.read', 'playback.pause', ...safe ? ['source.flush' as const] : [],
1086
+ ...commentDrafts?.readable ? ['comment-drafts.inspect' as const, 'comment-drafts.read' as const] : [],
1087
+ ...clean && !state.isHistoryBusy && !state.isHistoryPreparing && commentDrafts?.readable && commentDrafts.ownersKnown ? ['comment-drafts.discard' as const, ...state.webView === 'document' && commentDrafts.hasRestoreComposer() ? ['comment-drafts.restore' as const] : []] : [],
1088
+ ...source.member?.id && this.#componentPropertyDrafts.readable(source.identity, source.member.id) ? ['component-drafts.inspect' as const, 'component-drafts.read' as const] : [],
1089
+ ...clean && !state.isHistoryBusy && !state.isHistoryPreparing && source.member?.id && this.#componentPropertyDrafts.readable(source.identity, source.member.id) ? ['component-drafts.restore' as const, 'component-drafts.discard' as const] : [],
1090
+ ...this.#canPreparePresentation() ? ['preview.prepare' as const] : [],
1091
+ ...clean && (state.webView === 'document' || ready) ? ['position.step' as const] : [],
1092
+ ...ready ? ['playback.play' as const, 'playback.rate' as const, ...plan?.timing === 'confirmed' ? ['playback.seek' as const] : []] : []], unsubmittedContent: 'not-inspected' }
1093
+ }
1094
+
1095
+ /** Reuses the same source actor, mutual-audio gate and frame acknowledgements as the human controls. */
1096
+ async executeAuthoringClientAction(action: AuthoringClientAction, expected: CanonicalSource, signal: AbortSignal, deadline?: number, fits?: (result: AuthoringClientActionResult) => boolean): Promise<void | AuthoringClientActionResult> {
1097
+ const scope = this.#documentScope, identity = this.#state.authoring?.identity, document = this.#state.document, interaction = this.#state.documentPositionToken
1098
+ const owned = () => { signal.throwIfAborted(); if (scope !== this.#documentScope || identity !== this.#state.authoring?.identity || document !== this.#state.document) throw new Error('client-document-changed') }
1099
+ const current = () => { owned(); const state = this.authoringClientSnapshot(0); if (!state || !sameSource(state.source, expected) || !state.actions.includes(action.type)) throw new Error('client-action-unavailable'); return state }
1100
+ current()
1101
+ if (action.type === 'state.read') return
1102
+ if (action.type === 'comment-drafts.inspect' || action.type === 'comment-drafts.read') {
1103
+ const drafts = identity ? this.documentCommentDrafts(identity) : undefined
1104
+ if (!drafts?.readable || !fits) throw new Error('client-comment-drafts-unavailable')
1105
+ const result = await inspectCommentDrafts(drafts, action, signal, fits)
1106
+ current()
1107
+ return result
1108
+ }
1109
+ if (action.type === 'comment-drafts.restore' || action.type === 'comment-drafts.discard') {
1110
+ const memberId = this.#state.authoring?.member?.id, drafts = identity ? this.documentCommentDrafts(identity) : undefined
1111
+ if (!identity || !memberId || !drafts || !fits) throw new Error('client-comment-drafts-unavailable')
1112
+ const guard = () => {
1113
+ owned()
1114
+ const state = this.#state, source = state.authoring
1115
+ if (!source?.content || source.member?.id !== memberId || !source.version || !source.fingerprint
1116
+ || !sameSource({ documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, expected)
1117
+ || source.loading || source.dirty || source.saving || source.composing || source.restored || source.recovering || source.error || isAgentChanging(state)
1118
+ || state.isCreatingPage || state.isEditingPage || state.isHistoryBusy || state.isHistoryPreparing || state.hasDocumentTimingDrafts
1119
+ || action.type === 'comment-drafts.restore' && state.webView !== 'document') throw new Error('client-source-changed')
1120
+ }
1121
+ return mutateCommentDrafts(drafts, action, signal, guard, fits)
1122
+ }
1123
+ if (action.type === 'component-drafts.inspect' || action.type === 'component-drafts.read') {
1124
+ const memberId = this.#state.authoring?.member?.id
1125
+ if (!identity || !memberId || !fits || !this.#componentPropertyDrafts.readable(identity, memberId)) throw new Error('client-component-drafts-unavailable')
1126
+ const result = await inspectComponentDrafts(this.#componentPropertyDrafts, { identity, memberId, source: expected }, action, signal, fits)
1127
+ current()
1128
+ return result
1129
+ }
1130
+ if (action.type === 'component-drafts.restore' || action.type === 'component-drafts.discard') {
1131
+ const memberId = this.#state.authoring?.member?.id
1132
+ if (!identity || !memberId || !fits) throw new Error('client-component-drafts-unavailable')
1133
+ const guard = (draft?: ComponentPropertyDraft) => {
1134
+ owned()
1135
+ const state = this.#state, source = state.authoring
1136
+ if (!source?.content || source.identity !== identity || source.member?.id !== memberId || !source.version || !source.fingerprint
1137
+ || !sameSource({ documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, expected)
1138
+ || source.loading || source.dirty || source.saving || source.composing || source.restored || source.recovering || source.error || isAgentChanging(state)
1139
+ || state.isCreatingPage || state.isEditingPage || state.isHistoryBusy || state.isHistoryPreparing || state.hasDocumentTimingDrafts) throw new Error('client-source-changed')
1140
+ if (action.type === 'component-drafts.restore') {
1141
+ const block = source.content.blocks[action.draft.blockId]
1142
+ if (block?.type !== 'component' || draft && block.definitionId !== draft.definitionId) throw new Error('client-component-target-unavailable')
1143
+ // The source-bound bank checks the saved Definition again before adopting its strict fork.
1144
+ try { effectiveComponentParameters(source.content, block.id, action.draft.stepId) } catch { throw new Error('client-component-target-unavailable') }
1145
+ }
1146
+ }
1147
+ return mutateComponentDrafts(this.#componentPropertyDrafts, { identity, memberId, source: expected }, action, signal, guard, fits)
1148
+ }
1149
+ if (action.type === 'preview.prepare') {
1150
+ const view = this.#state.webView
1151
+ const stop = this.subscribe(() => { if (interaction !== this.#state.documentPositionToken || view !== this.#state.webView) this.cancelPreviewPreparation() })
1152
+ try {
1153
+ const prepared = await this.preparePresentation(signal, deadline)
1154
+ owned(); signal.throwIfAborted()
1155
+ const result = this.authoringClientSnapshot(0)
1156
+ if (!prepared || result?.preview?.status !== 'prepared' || !result.preview.source || !sameSource(result.preview.source, expected)
1157
+ || result.view !== view || result.interaction !== interaction) throw new Error('client-preview-unavailable')
1158
+ } finally { stop() }
1159
+ return
1160
+ }
1161
+ if (action.type === 'source.flush') {
1162
+ await this.flushPendingEdits(signal); owned()
1163
+ const source = this.#state.authoring
1164
+ if (!source || source.dirty || source.saving || source.composing || source.error || source.restored) throw new Error('client-source-not-saved')
1165
+ return
1166
+ }
1167
+ if (action.type === 'playback.pause') { this.#interruptProductionNavigation(); await this.pausePlayback(); owned(); return }
1168
+ if (action.type === 'position.step') {
1169
+ const content = this.#state.authoring?.content, section = content?.sections[action.sectionId]
1170
+ if (!section?.stepOrder.includes(action.stepId) || content?.steps[action.stepId]?.sectionId !== action.sectionId) throw new Error('client-step-not-found')
1171
+ await this.pausePlayback(); current()
1172
+ if (interaction !== this.#state.documentPositionToken) throw new Error('client-position-changed')
1173
+ const present = this.#state.webView === 'present'
1174
+ const result = await this.selectDocumentStep(action.sectionId, action.stepId)
1175
+ owned()
1176
+ const state = this.authoringClientSnapshot(0)
1177
+ if (!state || !sameSource(state.source, expected) || state.position?.sectionId !== action.sectionId || state.position.stepId !== action.stepId
1178
+ || present && (!result || result.status !== 'ready' || result.position?.sectionId !== action.sectionId || result.position.stepId !== action.stepId || result.position.playing)) throw new Error('client-step-unverified')
1179
+ return
1180
+ }
1181
+ const plan = this.#state.productionPlan
1182
+ if (!plan || !sameProductionBinding(plan.binding, action.binding)) throw new Error('client-playback-binding-changed')
1183
+ if (action.type === 'playback.seek' && (plan.timing !== 'confirmed' || plan.durationMs === undefined || action.timeMs > plan.durationMs)) throw new Error('client-seek-out-of-range')
1184
+ if (action.type === 'playback.rate' && !(PRODUCTION_PLAYBACK_RATES as readonly number[]).includes(action.rate)) throw new Error('client-rate-unavailable')
1185
+ const control: ProductionPlaybackCommand = action.type === 'playback.play' ? { action: 'play', binding: action.binding }
1186
+ : action.type === 'playback.seek' ? { action: 'seek', binding: action.binding, timeMs: action.timeMs } : { action: 'rate', binding: action.binding, rate: action.rate }
1187
+ const result = await this.controlProduction(control, signal); owned()
1188
+ if (result.status !== 'ready' || !result.position || action.type === 'playback.play' && !result.position.playing
1189
+ || action.type === 'playback.rate' && result.position.rate !== action.rate) throw new Error('client-playback-unverified')
1190
+ }
1191
+
1192
+ /** Reads the actual derived plan without building the visuals or synthesizing speech. */
1193
+ async prepareProductionPlan(signal: AbortSignal): Promise<ProductionPlan> {
1194
+ const initial = this.#state, source = initial.authoring, identity = source?.identity, directory = initial.document, scope = this.#documentScope
1195
+ if (!identity || directory === undefined || !source?.version || !source.fingerprint || source.loading || source.dirty || source.saving || source.composing || source.restored || source.error) throw new Error('production-source-changed')
1196
+ const snapshot = await readDocumentSnapshot(this.client, { directory, version: source.version, fingerprint: source.fingerprint }, signal)
1197
+ signal.throwIfAborted()
1198
+ if (!snapshot.doc.source || scope !== this.#documentScope || this.#state.authoring?.identity !== identity) throw new Error('production-source-changed')
1199
+ const plan = await this.client.readProductionPlan(directory, { source: snapshot.doc.source, irRevision: snapshot.revision }, signal)
1200
+ signal.throwIfAborted()
1201
+ if (scope !== this.#documentScope || this.#state.authoring?.identity !== identity || !this.#productionSourceCurrent(plan.binding)) throw new Error('production-source-changed')
1202
+ this.#set({ productionPlan: plan })
1203
+ if (this.#production.changed()) void this.pausePlayback().catch(() => {})
1204
+ return plan
1205
+ }
1206
+
1207
+ #interruptProductionNavigation(): void {
1208
+ this.#set({ productionNavigationVersion: this.#state.productionNavigationVersion + 1 })
1209
+ }
1210
+
1211
+ /** The child confirms seeks and playback; the parent never estimates a playhead using a timer. */
1212
+ async controlProduction(control: ProductionPlaybackCommand, signal?: AbortSignal): Promise<ProductionPlaybackState> {
1213
+ if (control.action === 'pause' || control.action === 'seek' || control.action === 'step' && !control.utteranceId) this.#interruptProductionNavigation()
1214
+ if (control.action === 'seek' && this.#state.productionPlan?.timing !== 'confirmed') throw new Error('production-plan-unavailable')
1215
+ const scope = this.#documentScope
1216
+ this.#set({ productionFailure: undefined })
1217
+ try {
1218
+ if (control.action === 'play' || control.action === 'load' || control.action === 'seek' || control.action === 'step') {
1219
+ if (control.action === 'load') this.#playback.stopPreviewOutputs()
1220
+ else this.stopAuditions()
1221
+ await this.pausePlayback()
1222
+ signal?.throwIfAborted()
1223
+ }
1224
+ return await this.#production.control(control, signal)
1225
+ } catch (error) {
1226
+ const code = errorMessage(error), current = this.#state.productionPlan?.binding
1227
+ if (scope === this.#documentScope && !signal?.aborted && current && sameProductionBinding(current, control.binding) && this.#productionReady()
1228
+ && !(control.action === 'load' && code === 'production-playback-cancelled')
1229
+ && !['production-source-changed', 'production-request-superseded', 'production-request-cancelled'].includes(code)) this.#set({ productionFailure: code })
1230
+ throw error
1231
+ }
1232
+ }
1233
+
1234
+ closeProduction(): void {
1235
+ this.#interruptProductionNavigation()
1236
+ this.#production.reset()
1237
+ this.#set({ productionPlan: undefined, productionFailure: undefined })
1238
+ }
1239
+
1240
+ /** Explicitly enables the standard production capability in this document's one authoritative source. */
1241
+ async enableDocumentProduction(): Promise<void> {
1242
+ const scope = this.#documentScope, identity = this.#state.authoring?.identity
1243
+ if (!identity) throw new Error('production-source-changed')
1244
+ await this.pausePlayback()
1245
+ await this.flushPendingEdits()
1246
+ const source = this.#state.authoring, document = this.getDocument(identity)
1247
+ if (scope !== this.#documentScope || !document || !source?.version || !source.fingerprint || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('production-source-changed')
1248
+ const plugins = [...new Set([...(document.metadata.plugins ?? []), '@lincyaw/addon-lecture', '@lincyaw/addon-speech'])]
1249
+ const next = { ...document, metadata: { ...document.metadata, plugins, renderers: { ...document.metadata.renderers, lecture: document.metadata.renderers?.lecture ?? {} } } }
1250
+ this.#documentSession.record(next)
1251
+ await this.flushPendingEdits()
1252
+ if (scope !== this.#documentScope || this.#state.authoring?.identity !== identity || this.#state.authoring.dirty || this.#state.authoring.saving || this.#state.authoring.error) throw new Error('production-source-changed')
1253
+ }
1254
+
1255
+ #reloadPreview(): void {
1256
+ this.#set({ previewReload: this.#state.previewReload + 1 })
1257
+ const address = this.#state.previewRenderer === 'slides' ? this.#state.preview?.slides : this.#state.preview?.web
1258
+ if (!address) this.retryPreview()
1259
+ }
1260
+
1261
+ retryPreview(): void {
1262
+ this.#record('preview.retry')
1263
+ if (this.#state.document !== undefined) void this.#readPreview(this.#state.document)
1264
+ }
1265
+
1266
+ #pageEditMessage(reason: string): string {
1267
+ const t = this.#messages
1268
+ if (reason === 'native-unavailable') return t.nativeEditUnavailable
1269
+ if (reason.startsWith('native-')) return t.nativeEditDraft
1270
+ switch (reason) {
1271
+ case 'page-multiple-files': return t.pageEditFiles
1272
+ case 'page-boundary': return t.pageEditBoundary
1273
+ case 'page-hierarchy': return t.pageEditHierarchy
1274
+ case 'page-implicit-title': return t.pageEditImplicitTitle
1275
+ case 'page-formatted-title': return t.pageEditFormattedTitle
1276
+ case 'page-title': return t.pageEditInvalidTitle
1277
+ case 'page-compiler': return t.pageEditCompiler
1278
+ case 'page-referenced': return t.pageDeleteReferenced
1279
+ case 'page-external-bindings': return t.pageDeleteExternalBindings
1280
+ case 'page-references-unavailable': return t.pageDeleteReferencesUnavailable
1281
+ case 'page-busy': return t.pageEditBusy
1282
+ case 'preview-flush-unavailable': return t.pageEditPreview
1283
+ case 'page-context-changed': return t.pageEditChanged
1284
+ case 'save-timeout': return t.pageEditFlushTimeout
1285
+ case 'source-draft': return t.pageEditSourceDraft
1286
+ case 'source-refresh': return t.pageEditRefresh
1287
+ case 'page-draft':
1288
+ case 'source-conflict':
1289
+ case 'narration-conflict':
1290
+ case 'revision-conflict': return t.pageEditDraft
1291
+ case 'save-locked': return t.saveLocked
1292
+ default: return t.pageEditRequestFailed
1293
+ }
1294
+ }
1295
+
1296
+ pageEditCapability(section: string): { rename: boolean, move: boolean, reason?: string } {
1297
+ const exists = Boolean(this.#state.authoring?.content?.sections[section])
1298
+ const capability = { rename: exists, move: exists }
1299
+ if (isAgentChanging(this.#state)) return { rename: false, move: false, reason: this.#messages.saveLocked }
1300
+ if (this.#state.isCreatingPage || this.#state.isEditingPage || this.#state.isHistoryBusy) return { rename: false, move: false, reason: this.#messages.pageEditBusy }
1301
+ return capability
1302
+ }
1303
+
1304
+ clearPageEditFailure(): void { this.#set({ pageEditFailure: undefined }) }
1305
+
1306
+ /** The preview acknowledges that all registered lecture playback has paused before audition audio starts. */
1307
+ pausePlayback(interactive = false): Promise<void> { if (interactive) this.#interruptProductionNavigation(); return this.#playback.pause() }
1308
+
1309
+ /** The owner must invalidate pending audio responses as well as stop its current audio element. */
1310
+ registerAudition(stop: () => void): () => void { return this.#playback.registerAudition(stop) }
1311
+ registerAudioCapture(stop: () => void): () => void { this.#interruptProductionNavigation(); return this.#playback.registerAudioCapture(stop) }
1312
+
1313
+ /** Inline runtimes join the same exclusive playback group as author auditions. */
1314
+ startDocumentPlayback(stop: () => Promise<void>): Promise<() => void> { this.#interruptProductionNavigation(); return this.#playback.startDocumentPlayback(stop) }
1315
+
1316
+ stopAuditions(): void { this.#playback.stopAuditions() }
1317
+
1318
+ /** The latest source actor value, scoped to the field/editor that captured its identity. */
1319
+ getDocument(identity?: string): TeachingDocument | undefined {
1320
+ const source = this.#state.authoring
1321
+ if (!source || source.loading || source.directory !== this.#state.document || identity !== undefined && identity !== source.identity) return undefined
1322
+ return source.content
1323
+ }
1324
+ /** Native image uploads commit the original file without interrupting later text input. */
1325
+ async importDocumentImage(file: File, identity: string, signal?: AbortSignal): Promise<string> {
1326
+ if (!this.getDocument(identity) || isAgentChanging(this.#state)) throw new Error('document-identity-changed')
1327
+ this.stopAuditions()
1328
+ await this.pausePlayback()
1329
+ await this.flushPendingEdits(signal)
1330
+ if (!this.getDocument(identity) || isAgentChanging(this.#state)) throw new Error('document-identity-changed')
1331
+ const answer = await this.#documentSession.importImage(file, identity, signal)
1332
+ signal?.throwIfAborted()
1333
+ const asset = this.getDocument(identity)?.assets[answer.assetId]
1334
+ if (!asset || asset.kind !== 'image' || asset.location !== answer.content.assets[answer.assetId]?.location) throw new Error('document-identity-changed')
1335
+ return asset.location
1336
+ }
1337
+ #mediaSource(expected: { identity: string, version: string }): DocumentSessionState {
1338
+ const source = this.#state.authoring
1339
+ if (!source || !this.getDocument(expected.identity) || source.version !== expected.version || !source.fingerprint
1340
+ || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('production-source-changed')
1341
+ return source
1342
+ }
1343
+ /** Inspects the saved declaration, including a local asset not yet attached to a step. */
1344
+ async inspectDocumentAudio(assetId: string, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioInspectionAnswer> {
1345
+ const source = this.#mediaSource(expected), scope = this.#documentScope
1346
+ const answer = await this.client.readDocumentAudio(source.directory, assetId, expected.version, signal)
1347
+ signal?.throwIfAborted()
1348
+ if (scope !== this.#documentScope || answer.document !== source.directory || answer.version !== expected.version || answer.fingerprint !== source.fingerprint || answer.inspection.assetId !== assetId) throw new Error('production-source-changed')
1349
+ this.#mediaSource(expected)
1350
+ return answer
1351
+ }
1352
+ /** Recognition keeps its captured source even if subsequent author input moves to a newer version. */
1353
+ async startDocumentTranscription(request: Omit<DocumentTranscriptionRequest, 'source'>, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionTask> {
1354
+ const source = this.#mediaSource(expected)
1355
+ if (!source.content || !source.fingerprint) throw new Error('production-source-changed')
1356
+ const sent: DocumentTranscriptionRequest = { ...structuredClone(request), source: { documentId: source.content.id, version: expected.version, fingerprint: source.fingerprint } }
1357
+ const answer = await this.client.startTranscription(source.directory, sent, signal)
1358
+ signal?.throwIfAborted()
1359
+ if (answer.document !== source.directory || answer.intent !== sent.intent || answer.source.documentId !== sent.source.documentId
1360
+ || answer.source.version !== sent.source.version || answer.source.fingerprint !== sent.source.fingerprint || answer.audio.assetId !== sent.assetId || answer.audio.sha256 !== sent.audioSha256) throw new Error('transcription-response-changed')
1361
+ return answer
1362
+ }
1363
+ async planDocumentTranscription(draft: TranscriptionAdoptionDraft, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionAdoptionPlan> {
1364
+ const source = this.#mediaSource(expected), sent = structuredClone(draft)
1365
+ if (sent.source.documentId !== source.content?.id || sent.source.version !== expected.version || sent.source.fingerprint !== source.fingerprint) throw new Error('production-source-changed')
1366
+ const answer = await this.client.planTranscriptionAdoption(source.directory, sent, signal)
1367
+ signal?.throwIfAborted()
1368
+ this.#mediaSource(expected)
1369
+ if (answer.document !== source.directory || answer.changeId !== sent.id || answer.source.documentId !== sent.source.documentId
1370
+ || answer.source.version !== sent.source.version || answer.source.fingerprint !== sent.source.fingerprint || answer.candidate.id !== sent.candidate.id
1371
+ || answer.candidate.revision !== sent.candidate.revision || answer.draftRevision !== sent.draftRevision) throw new Error('transcription-response-changed')
1372
+ return answer
1373
+ }
1374
+ async adoptDocumentTranscription(request: TranscriptionAdoptionApply, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<TranscriptionAdoptionAnswer> {
1375
+ const source = this.#mediaSource(expected), sent = structuredClone(request)
1376
+ if (sent.source.documentId !== source.content?.id || sent.source.version !== expected.version || sent.source.fingerprint !== source.fingerprint) throw new Error('production-source-changed')
1377
+ const answer = await this.#writeDocumentMedia(expected, directory => this.client.applyTranscriptionAdoption(directory, sent, signal), signal)
1378
+ if (answer.adoption.planId !== sent.planId || answer.adoption.candidate.id !== sent.candidate.id || answer.adoption.candidate.revision !== sent.candidate.revision
1379
+ || answer.adoption.draftRevision !== sent.draftRevision) throw new Error('transcription-response-changed')
1380
+ return answer
1381
+ }
1382
+ async #writeDocumentMedia<T extends DocumentChangeAnswer>(expected: { identity: string, version: string }, write: (directory: string) => Promise<T>, signal?: AbortSignal): Promise<T> {
1383
+ const scope = this.#documentScope, source = this.#mediaSource(expected)
1384
+ this.stopAuditions()
1385
+ await this.pausePlayback()
1386
+ await this.flushPendingEdits(signal)
1387
+ signal?.throwIfAborted()
1388
+ if (scope !== this.#documentScope) throw new Error('production-source-changed')
1389
+ this.#mediaSource(expected)
1390
+ let answer: T
1391
+ try { answer = await write(source.directory) }
1392
+ finally { await this.#documentSession.refresh(source.directory) }
1393
+ signal?.throwIfAborted()
1394
+ if (scope !== this.#documentScope || answer.document !== source.directory || answer.content.id !== source.content?.id) throw new Error('production-source-changed')
1395
+ this.#mediaSource({ identity: expected.identity, version: answer.version })
1396
+ return answer
1397
+ }
1398
+ /** Import commits the original file and its declaration together, without creating a clip or narration. */
1399
+ async importDocumentAudio(file: File, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioImportAnswer> {
1400
+ let request: { identity: string, version: string, id: string } | undefined
1401
+ const answer = await this.#writeDocumentMedia(expected, async directory => {
1402
+ const existing = this.#audioImports.get(file), first = !existing || existing.identity !== expected.identity
1403
+ request = first ? { identity: expected.identity, version: expected.version, id: crypto.randomUUID() } : existing!
1404
+ this.#audioImports.set(file, request)
1405
+ try { return await this.client.importDocumentAudio(directory, file, { id: request.id, baseDocumentVersion: request.version }, signal) }
1406
+ catch (error) {
1407
+ if (first && error instanceof WorkspaceServerError && error.status >= 400 && error.status < 500 && this.#audioImports.get(file) === request) this.#audioImports.delete(file)
1408
+ throw error
1409
+ }
1410
+ }, signal)
1411
+ if (answer.changeId !== request?.id || answer.inspection.assetId !== answer.assetId || !answer.content.assets[answer.assetId]) throw new Error('production-source-changed')
1412
+ if (this.#audioImports.get(file) === request) this.#audioImports.delete(file)
1413
+ return answer
1414
+ }
1415
+ /** Reconcile an ambiguous save against real asset bytes before admitting a new source-bound import. */
1416
+ async ensureDocumentAudio(file: File, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentAudioInspectionAnswer> {
1417
+ const source = this.#mediaSource(expected)
1418
+ if (!file.size || file.size > DOCUMENT_AUDIO_MAX_BYTES) throw new Error('production.asset-size')
1419
+ let pending = this.#audioFingerprints.get(file)
1420
+ if (!pending) {
1421
+ pending = file.arrayBuffer().then(bytes => crypto.subtle.digest('SHA-256', bytes)).then(bytes => [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join(''))
1422
+ this.#audioFingerprints.set(file, pending)
1423
+ }
1424
+ const hash = await pending
1425
+ signal?.throwIfAborted(); this.#mediaSource(expected)
1426
+ for (const asset of Object.values(source.content?.assets ?? {})) {
1427
+ if (asset.kind !== 'audio' || asset.fingerprint !== `sha256:${hash}`) continue
1428
+ try {
1429
+ const answer = await this.inspectDocumentAudio(asset.id, expected, signal)
1430
+ if (answer.inspection.sha256 === hash) return answer
1431
+ } catch (error) {
1432
+ if (!(error instanceof WorkspaceServerError) || ![404, 413, 422].includes(error.status)) throw error
1433
+ }
1434
+ }
1435
+ signal?.throwIfAborted(); this.#mediaSource(expected)
1436
+ const previous = this.#audioImports.get(file)
1437
+ if (previous?.identity === expected.identity && previous.version !== expected.version) this.#audioImports.delete(file)
1438
+ let imported: DocumentAudioImportAnswer
1439
+ try { imported = await this.importDocumentAudio(file, expected, signal) }
1440
+ catch (error) {
1441
+ if (!previous || this.#audioImports.get(file) !== previous || !(error instanceof WorkspaceServerError)
1442
+ || error.status !== 409 || error.message !== 'document-version-changed') throw error
1443
+ signal?.throwIfAborted(); this.#mediaSource(expected)
1444
+ this.#audioImports.delete(file)
1445
+ imported = await this.importDocumentAudio(file, expected, signal)
1446
+ }
1447
+ return { document: imported.document, version: imported.version, fingerprint: imported.fingerprint, inspection: imported.inspection }
1448
+ }
1449
+ /** The source actor owns this external transaction; capture is independent from preview output. */
1450
+ async adoptDocumentRecording(request: DocumentRecordingAdoption, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<VerifiedDocumentRecordingAdoption> {
1451
+ const scope = this.#documentScope, source = this.#mediaSource(expected), sent = structuredClone(request)
1452
+ if (sent.source.documentId !== source.content?.id || sent.source.version !== source.version || sent.source.fingerprint !== source.fingerprint
1453
+ || !source.content.steps[sent.stepId] || !source.content.assets[sent.assetId]) throw new Error('production-source-changed')
1454
+ this.#playback.stopPreviewOutputs()
1455
+ await this.pausePlayback()
1456
+ await this.flushPendingEdits(signal)
1457
+ signal?.throwIfAborted()
1458
+ if (scope !== this.#documentScope) throw new Error('production-source-changed')
1459
+ this.#mediaSource(expected)
1460
+ const answer = await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
1461
+ const saved = await this.client.adoptRecording(source.directory, sent, ownedSignal)
1462
+ if (saved.changeId !== sent.id || saved.verification.stepId !== sent.stepId || saved.verification.assetId !== sent.assetId || saved.verification.sha256 !== sent.sha256) throw new Error('document-confirmation')
1463
+ return saved
1464
+ }, (before, remote, current) => reconcileRecordingAdoption(sent, before, remote, current), signal)
1465
+ signal?.throwIfAborted()
1466
+ if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) throw new Error('production-source-changed')
1467
+ return answer
1468
+ }
1469
+ async applyDocumentMediaClip(clip: MediaClip, expected: { identity: string, version: string, binding: ProductionBinding }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
1470
+ this.#mediaSource(expected)
1471
+ if (!this.#state.productionPlan || !sameProductionBinding(this.#state.productionPlan.binding, expected.binding) || !this.#productionSourceCurrent(expected.binding)) throw new Error('production-source-changed')
1472
+ const id = crypto.randomUUID(), value = structuredClone(clip)
1473
+ const answer = await this.#writeDocumentMedia(expected, directory => this.client.changeDocumentMediaClip(directory, { id, baseDocumentVersion: expected.version, binding: expected.binding, clip: value }, signal), signal)
1474
+ if (answer.changeId !== id) throw new Error('production-source-changed')
1475
+ return answer
1476
+ }
1477
+ async removeDocumentMediaClip(clipId: string, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
1478
+ const id = crypto.randomUUID()
1479
+ const answer = await this.#writeDocumentMedia(expected, directory => this.client.changeDocumentMediaClip(directory, { id, baseDocumentVersion: expected.version, removeClipId: clipId }, signal), signal)
1480
+ if (answer.changeId !== id) throw new Error('production-source-changed')
1481
+ return answer
1482
+ }
1483
+ /** Applies a reviewed voice scope to the current source actor, without replacing body or narration. */
1484
+ async applyDocumentVoice(scope: DocumentVoiceScope, value: VoiceSource | null, options: DocumentVoiceOptions, expected: { identity: string, version: string, fingerprint: string }): Promise<boolean> {
1485
+ const source = this.#state.authoring, document = this.getDocument(expected.identity), context = this.#documentScope
1486
+ if (!source || !document || source.version !== expected.version || source.fingerprint !== expected.fingerprint
1487
+ || source.dirty || source.saving || source.composing || source.restored || source.error || isAgentChanging(this.#state)) throw new Error('voice-context-changed')
1488
+ const next = changeDocumentVoice(document, scope, value, options)
1489
+ if (next === document) return true
1490
+ this.stopAuditions()
1491
+ this.#documentSession.record(next)
1492
+ await this.flushPendingEdits()
1493
+ if (context !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) return false
1494
+ const saved = this.#state.authoring
1495
+ if (!saved || saved.dirty || saved.saving || saved.composing || saved.restored || saved.error) throw new Error('voice-save-unconfirmed')
1496
+ return true
1497
+ }
1498
+ /** Parameter planning is an authoring read; it does not compile, change modes or write the document. */
1499
+ async planDocumentComponentAction(action: DocumentComponentAction, id: string, expected: { identity: string, version: string, fingerprint: string }, signal?: AbortSignal): Promise<DocumentAuthoringActionPlan> {
1500
+ const source = this.#mediaSource(expected), scope = this.#documentScope
1501
+ if (!source.content || !source.fingerprint || source.fingerprint !== expected.fingerprint) throw new Error('component-source-changed')
1502
+ const request = { id, source: componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint }), action: structuredClone(action) }
1503
+ const answer = await this.client.planDocumentAction(source.directory, request, signal)
1504
+ signal?.throwIfAborted()
1505
+ const current = this.#mediaSource(expected)
1506
+ if (scope !== this.#documentScope || !current.content || !current.fingerprint || answer.document !== source.directory || answer.changeId !== id || answer.effect.kind !== 'component'
1507
+ || !sameComponentSource(answer.source, request.source) || JSON.stringify(answer.action) !== JSON.stringify(request.action)) throw new Error('component-source-changed')
1508
+ return answer
1509
+ }
1510
+ /** The existing source actor owns adoption and ambiguous-response recovery, including later native input. */
1511
+ async applyDocumentComponentPlan(plan: DocumentAuthoringActionPlan, expected: { identity: string, version: string }, signal?: AbortSignal): Promise<{ answer: DocumentChangeAnswer, commitId?: string }> {
1512
+ if (plan.effect.kind !== 'component' || !plan.action.type.startsWith('component.')) throw new Error('component-plan-invalid')
1513
+ const source = this.#mediaSource(expected), action = plan.action as DocumentComponentAction, scope = this.#documentScope
1514
+ if (!source.content || !source.fingerprint || !sameComponentSource(plan.source, componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint }))) throw new Error('component-source-changed')
1515
+ this.#playback.stopPreviewOutputs()
1516
+ await this.pausePlayback()
1517
+ let receipt: AppliedDocumentAuthoringAction | undefined
1518
+ const remember = (answer: DocumentChangeAnswer) => {
1519
+ if (this.#documentEditorBinding?.identity === expected.identity) this.#documentEditorCommands?.rememberComponentConfiguration(answer.content)
1520
+ return { answer, ...receipt?.commit ? { commitId: receipt.commit.id } : {} }
1521
+ }
1522
+ try {
1523
+ const answer = await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
1524
+ receipt = await this.client.applyDocumentAction(source.directory, { planId: plan.planId, digest: plan.digest, source: plan.source }, ownedSignal)
1525
+ const current = await this.client.readDocument(source.directory)
1526
+ if (receipt.planId !== plan.planId || receipt.digest !== plan.digest || receipt.changeId !== plan.changeId || receipt.action !== plan.action.type
1527
+ || !receipt.verification.saved || !receipt.verification.actionStateVerified || !sameComponentSource(receipt.verification.source, componentSource(current))) throw new Error('document-confirmation')
1528
+ return { ...current, changeId: receipt.changeId, files: receipt.files }
1529
+ }, (before, answer, current) => reconcileComponentAction(action, before, answer, current), signal)
1530
+ if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity) throw new Error('component-source-changed')
1531
+ return remember(answer)
1532
+ } catch (error) {
1533
+ const current = this.#state.authoring, file = plan.files.find(value => value.path === (source.directory ? `${source.directory}/lecture.json` : 'lecture.json'))
1534
+ // commitExternal has independently read and reconciled this exact predicted canonical version.
1535
+ if (scope === this.#documentScope && current?.identity === expected.identity && current.content && current.version && current.fingerprint && !current.error
1536
+ && file?.version === current.version && current.version !== plan.source.version) {
1537
+ // Canonical acceptance is already known even when later native input has
1538
+ // deleted the target. Undoing that deletion must restore its accepted configuration.
1539
+ if (this.#documentEditorBinding?.identity === expected.identity) this.#documentEditorCommands?.rememberComponentConfiguration(plan.content)
1540
+ try {
1541
+ if (!applyDocumentComponentAction(current.content, action).effect.changed) return remember({ document: source.directory, content: plan.content,
1542
+ version: current.version, fingerprint: current.fingerprint, issues: plan.issues, changeId: plan.changeId, files: plan.files.map(value => ({ path: value.path, version: value.version })) })
1543
+ } catch { /* A later conflicting input keeps the original request failure and form draft. */ }
1544
+ }
1545
+ throw error
1546
+ }
1547
+ }
1548
+ /** Manual caption times reuse the canonical action plan, media preflight and original source actor. */
1549
+ async applyDocumentCaptionTiming(action: DocumentCaptionTimingAction, id: string, expected: { identity: string, version: string, fingerprint: string }, signal?: AbortSignal): Promise<DocumentChangeAnswer> {
1550
+ const source = this.#mediaSource(expected), scope = this.#documentScope
1551
+ if (!source.content || source.fingerprint !== expected.fingerprint) throw new Error('caption-source-changed')
1552
+ const request = { id, source: componentSource({ content: source.content, version: source.version!, fingerprint: source.fingerprint! }), action }
1553
+ const plan = await this.client.planDocumentAction(source.directory, request, signal)
1554
+ signal?.throwIfAborted()
1555
+ if (scope !== this.#documentScope || this.#state.authoring?.identity !== expected.identity || plan.effect.kind !== 'caption'
1556
+ || plan.changeId !== id || JSON.stringify(plan.action) !== JSON.stringify(action) || !sameComponentSource(plan.source, request.source)) throw new Error('caption-source-changed')
1557
+ this.#playback.stopPreviewOutputs()
1558
+ await this.pausePlayback()
1559
+ try {
1560
+ return await this.#documentSession.commitExternal(expected, async (_before, ownedSignal) => {
1561
+ const receipt = await this.client.applyDocumentAction(source.directory, { planId: plan.planId, digest: plan.digest, source: plan.source }, ownedSignal)
1562
+ const actual = await this.client.readDocument(source.directory)
1563
+ if (receipt.planId !== plan.planId || receipt.digest !== plan.digest || receipt.changeId !== id || !receipt.verification.saved || !receipt.verification.actionStateVerified
1564
+ || !sameComponentSource(receipt.verification.source, componentSource(actual))) throw new Error('document-confirmation')
1565
+ return { ...actual, changeId: id, files: receipt.files }
1566
+ }, (before, answer, current) => reconcileCaptionTiming(action, before, answer, current), signal)
1567
+ } catch (error) {
1568
+ const current = this.#state.authoring, file = plan.files.find(file => file.path === (source.directory ? `${source.directory}/lecture.json` : 'lecture.json'))
1569
+ // Lost ACK was independently read by commitExternal; only the exact predicted saved version can settle it.
1570
+ if (scope === this.#documentScope && current?.identity === expected.identity && current.content && current.version === file?.version && current.fingerprint && !current.error) {
1571
+ try {
1572
+ if (!applyDocumentCaptionTiming(current.content, action).effect.changed) return { document: source.directory, content: plan.content, version: current.version!, fingerprint: current.fingerprint, changeId: id, issues: plan.issues, files: plan.files.map(({ path, version }) => ({ path, version })) }
1573
+ } catch { /* A newer target stays pending with the actual request error. */ }
1574
+ }
1575
+ throw error
1576
+ }
1577
+ }
1578
+ /** Uses the current actor revision so timing edits cannot replace later body or narration input. */
1579
+ setDocumentStepProduction(stepId: string, patch: StepTimingPatch, identity?: string): void {
1580
+ const document = this.getDocument(identity)
1581
+ if (!document || isAgentChanging(this.#state)) return
1582
+ const next = changeStepTiming(document, stepId, patch)
1583
+ if (next === document) return
1584
+ this.stopAuditions()
1585
+ this.#documentSession.record(next)
1586
+ }
1587
+ documentTimingDraft(stepId: string, key: StepDurationKey, identity: string, reset: number): StepDurationDraft | undefined {
1588
+ const document = this.getDocument(identity)
1589
+ return document && this.#state.authoring?.reset === reset ? this.#documentTimingDrafts.get(identity, document, stepId, key) : undefined
1590
+ }
1591
+ /** Retains unaccepted numeric input by stable target; it is not authoring content or a second timing source. */
1592
+ setDocumentTimingDraft(stepId: string, key: StepDurationKey, draft: StepDurationDraft | undefined, identity: string, reset: number): void {
1593
+ const source = this.#state.authoring, document = this.getDocument(identity)
1594
+ if (!document?.steps[stepId] || source?.reset !== reset || isAgentChanging(this.#state)) return
1595
+ if (!this.#documentTimingDrafts.set(identity, document, stepId, key, draft)) return
1596
+ this.#set({ authoring: { ...source }, hasDocumentTimingDrafts: this.#documentTimingDrafts.has(identity, document), documentTimingDraftStorageFailure: this.#documentTimingDrafts.failed })
1597
+ }
1598
+ captionTimingDraft(stepId: string, identity: string): CaptionTimingDraft | undefined {
1599
+ const source = this.#state.authoring
1600
+ return source?.identity === identity && source.member?.id ? this.#captionTimingDrafts.get({ identity, memberId: source.member.id }, stepId) : undefined
1601
+ }
1602
+ setCaptionTimingDraft(stepId: string, draft: CaptionTimingDraft | undefined, identity: string): void {
1603
+ const source = this.#state.authoring, document = this.getDocument(identity)
1604
+ if (!document?.steps[stepId] || source?.identity !== identity || !source.member?.id) return
1605
+ const scope = { identity, memberId: source.member.id }
1606
+ this.#captionTimingDrafts.set(scope, stepId, draft)
1607
+ this.#set({ captionTimingDraftSteps: this.#captionTimingDrafts.steps(scope, document), captionTimingDraftStorageFailure: this.#captionTimingDrafts.failed(scope) })
1608
+ }
1609
+ /** Changes one stable narration target without replacing the body editor or derived captions. */
1610
+ setDocumentNarration(stepId: string, narrationId: string | undefined, content: NarrationNode[], identity?: string): string | undefined {
1611
+ const document = this.getDocument(identity)
1612
+ if (!document || isAgentChanging(this.#state) || !Object.hasOwn(document.steps, stepId)) return undefined
1613
+ const step = document.steps[stepId]!
1614
+ if (!document.sections[step.sectionId]?.stepOrder.includes(stepId)) return undefined
1615
+ if (!Array.isArray(content)) throw new Error('document-narration-content')
1616
+ let meaningful = false
1617
+ for (const node of content) {
1618
+ if (!node || typeof node !== 'object') throw new Error('document-narration-content')
1619
+ if (node.type === 'text' && typeof node.text === 'string') meaningful ||= Boolean(node.text.trim())
1620
+ else if (node.type === 'pronunciation' && typeof node.display === 'string' && typeof node.spoken === 'string') meaningful ||= Boolean(node.display.trim() || node.spoken.trim())
1621
+ else if (node.type === 'cue' && typeof node.id === 'string' && node.id && typeof node.action === 'string' && node.action) meaningful = true
1622
+ else throw new Error('document-narration-content')
1623
+ }
1624
+ const target = narrationId ?? step.narrationIds[0]
1625
+ const existing = target !== undefined && Object.hasOwn(document.narrations, target) ? document.narrations[target] : undefined
1626
+ if (existing && !step.narrationIds.includes(existing.id)) throw new Error('document-narration-target')
1627
+ if (!existing && !meaningful) return undefined
1628
+ const id = target ?? crypto.randomUUID()
1629
+ if (!id || id.length > 200 || /[\u0000-\u001f]/.test(id) || ['__proto__', 'constructor', 'prototype'].includes(id)) throw new Error('document-narration-target')
1630
+ if (!existing && (document.id === id || [document.sections, document.blocks, document.steps, document.assets, document.definitions].some(map => Object.hasOwn(map, id)))) throw new Error('document-narration-target')
1631
+ if (existing && JSON.stringify(existing.content) === JSON.stringify(content)) return id
1632
+ const next: TeachingDocument = { ...document,
1633
+ narrations: { ...document.narrations, [id]: { ...existing, id, content: structuredClone(content) } },
1634
+ ...step.narrationIds.includes(id) ? {} : { steps: { ...document.steps, [stepId]: { ...step, narrationIds: [...step.narrationIds, id] } } },
1635
+ }
1636
+ const cueIds = new Set(content.flatMap(node => node.type === 'cue' ? [node.id] : []))
1637
+ const previousCues = new Set(existing?.content.flatMap(node => node.type === 'cue' ? [node.id] : []) ?? [])
1638
+ if (document.production.cueTimings) {
1639
+ const removed = [...previousCues].filter(cue => !cueIds.has(cue) && Object.hasOwn(document.production.cueTimings!, cue)
1640
+ && !Object.values(next.narrations).some(narration => narration.content.some(node => node.type === 'cue' && node.id === cue)))
1641
+ if (removed.length) {
1642
+ const cueTimings = { ...document.production.cueTimings }
1643
+ for (const cue of removed) {
1644
+ this.#removedCueTimings.delete(cue)
1645
+ this.#removedCueTimings.set(cue, { ...cueTimings[cue]! })
1646
+ delete cueTimings[cue]
1647
+ }
1648
+ while (this.#removedCueTimings.size > 512) this.#removedCueTimings.delete(this.#removedCueTimings.keys().next().value!)
1649
+ next.production = { ...document.production, cueTimings }
1650
+ }
1651
+ }
1652
+ for (const cue of cueIds) {
1653
+ const timing = this.#removedCueTimings.get(cue)
1654
+ if (!previousCues.has(cue) && timing && !Object.hasOwn(next.production.cueTimings ?? {}, cue)) {
1655
+ next.production = { ...next.production, cueTimings: { ...next.production.cueTimings, [cue]: { ...timing } } }
1656
+ this.#removedCueTimings.delete(cue)
1657
+ }
1658
+ }
1659
+ this.stopAuditions()
1660
+ this.#documentSession.record(next)
1661
+ return id
1662
+ }
1663
+ recordDocument(content: TeachingDocument): void { if (!isAgentChanging(this.#state)) this.#documentSession.record(content) }
1664
+ documentComposition(composing: boolean, owner = 'body', identity?: string): void { this.#documentSession.composition(composing, owner, identity) }
1665
+ retryDocument(): Promise<void> { return this.#documentSession.retry() }
1666
+ discardDocument(): Promise<void> { return this.#documentSession.discard() }
1667
+ recoverDocumentDraft(value: DocumentDraftReference, identity: string): Promise<void> {
1668
+ return this.#state.authoring?.identity === identity && !isAgentChanging(this.#state) ? this.#documentSession.recoverDraft(value) : Promise.resolve()
1669
+ }
1670
+ forgetDocumentDraft(value: DocumentDraftReference, identity: string): void {
1671
+ if (this.#state.authoring?.identity === identity && !isAgentChanging(this.#state)) void this.#documentSession.forgetDraft(value)
1672
+ }
1673
+ documentError(error: Error): void { this.#set({ save: { kind: 'failed', message: error.message } }) }
1674
+ documentInteraction(): void {
1675
+ this.#documentSelectionGeneration++; this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.()
1676
+ if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
1677
+ }
1678
+ pendingDocumentField(blockId: string | undefined, identity?: string): Pick<DocumentFieldRegistration, 'stepId' | 'narrationId' | 'slot'> | undefined {
1679
+ const pending = this.#pendingDocumentField, source = this.#state.authoring
1680
+ return pending && pending.generation === this.#documentSelectionGeneration && pending.field.blockId === blockId
1681
+ && pending.field.identity === (identity ?? source?.identity) && pending.reset === source?.reset ? pending.field : undefined
1682
+ }
1683
+ /** A live native field owns its target only while it still has focus in this source. */
1684
+ focusedDocumentField(): Readonly<Pick<DocumentFieldRegistration, 'identity' | 'reset' | 'blockId' | 'stepId' | 'narrationId' | 'slot'>> & { readonly editor: DocumentHistoryEditor } | undefined {
1685
+ const source = this.#state.authoring, editor = this.#activeDocumentHistory
1686
+ if (!source?.identity || !source.content || !editor || editor === this.#documentEditor || editor.prosemirrorView.isDestroyed || !editor.prosemirrorView.hasFocus()) return
1687
+ for (const field of this.#documentFields.values()) {
1688
+ if (field.editor !== editor || field.identity !== source.identity || field.reset !== source.reset) continue
1689
+ const step = source.content.steps[field.stepId], section = step && source.content.sections[step.sectionId]
1690
+ if (!step || !section?.stepOrder.includes(field.stepId) || field.slot < 0 || field.slot >= Math.max(1, step.narrationIds.length)
1691
+ || step.narrationIds[field.slot] !== undefined && step.narrationIds[field.slot] !== field.narrationId) return
1692
+ const { identity, reset, blockId, stepId, narrationId, slot } = field
1693
+ return { identity, reset, blockId, stepId, narrationId, slot, editor }
1694
+ }
1695
+ return undefined
1696
+ }
1697
+ registerDocumentField(field: DocumentFieldRegistration): () => void {
1698
+ if (field.identity !== this.#state.authoring?.identity || field.reset !== this.#state.authoring.reset) return () => {}
1699
+ const key = JSON.stringify([field.identity, field.blockId, field.stepId, field.slot, field.narrationId])
1700
+ this.#documentFields.set(key, field)
1701
+ const pending = this.#pendingDocumentField
1702
+ if (pending && pending.reset === field.reset && JSON.stringify([pending.field.identity, pending.field.blockId, pending.field.stepId, pending.field.slot, pending.field.narrationId]) === key) {
1703
+ this.#pendingDocumentField = undefined
1704
+ if (pending.generation === this.#documentSelectionGeneration && field.identity === this.#state.authoring?.identity && field.reset === this.#state.authoring.reset) field.focus.restore(pending.bookmark)
1705
+ }
1706
+ return () => {
1707
+ if (this.#documentFields.get(key) !== field) return
1708
+ this.#documentFields.delete(key)
1709
+ if (field.editor && this.#activeDocumentHistory === field.editor) this.activateDocumentHistory(this.#documentEditor)
1710
+ }
1711
+ }
1712
+ /** Toolbar commands use the focused native editor's own history, without replacing the body editor. */
1713
+ activateDocumentHistory(editor: DocumentHistoryEditor | undefined): void {
1714
+ if (editor && editor !== this.#documentEditor && ![...this.#documentFields.values()].some(field => field.editor === editor && field.identity === this.#state.authoring?.identity)) return
1715
+ if (editor === this.#activeDocumentHistory) return
1716
+ this.#unsubscribeDocumentHistory?.()
1717
+ this.#activeDocumentHistory = editor
1718
+ const update = () => this.#set({ editorHistory: editor ? { section: this.#state.position?.section ?? '', undo: undoDepth(editor.prosemirrorState) > 0, redo: redoDepth(editor.prosemirrorState) > 0, field: editor !== this.#documentEditor } : undefined })
1719
+ this.#unsubscribeDocumentHistory = editor?.onChange(update)
1720
+ update()
1721
+ }
1722
+ attachDocumentEditor(editor: NativeDocumentEditor | undefined, commands?: DocumentEditorCommands, binding?: { identity: string, reset: number }): void {
1723
+ if (binding && (binding.identity !== this.#state.authoring?.identity || binding.reset !== this.#state.authoring.reset)) return
1724
+ const wasBody = !this.#activeDocumentHistory || this.#activeDocumentHistory === this.#documentEditor
1725
+ if (editor !== this.#documentEditor) this.#documentSession.composition(false, 'body')
1726
+ this.#unsubscribeDocumentSelection?.(); this.#stopDocumentLayoutScroll?.(); this.#stopDocumentLayoutScroll = undefined
1727
+ this.#documentEditor = editor; this.#documentEditorCommands = commands; this.#documentEditorBinding = editor ? binding : undefined
1728
+ if (editor) {
1729
+ const focus = this.#pendingDocumentFocus, selection = this.#pendingDocumentSelection
1730
+ this.#pendingDocumentFocus = undefined; this.#pendingDocumentSelection = undefined
1731
+ if (focus && focus.documentId === this.#state.authoring?.content?.id && focus.generation === this.#documentSelectionGeneration && editor.getBlock(focus.nodeId)) {
1732
+ // A deferred page operation adopts its selection only after the source write is acknowledged.
1733
+ if (!focus.afterSave) {
1734
+ this.#focusDocumentNode(editor, focus.nodeId)
1735
+ }
1736
+ } else if (!this.#pendingDocumentField && selection && selection.documentId === this.#state.authoring?.content?.id) {
1737
+ const anchor = nativeTextPosition(editor, selection.anchor), head = nativeTextPosition(editor, selection.head)
1738
+ if (anchor !== undefined && head !== undefined) {
1739
+ editor.transact(transaction => transaction.setSelection(TextSelection.create(transaction.doc, anchor, head)))
1740
+ if (selection.focused) editor.focus()
1741
+ }
1742
+ }
1743
+ }
1744
+ if (wasBody) this.activateDocumentHistory(editor)
1745
+ if (editor) {
1746
+ const focus = () => this.activateDocumentHistory(editor)
1747
+ const element = editor.prosemirrorView.dom
1748
+ element.addEventListener('focusin', focus)
1749
+ if (editor.prosemirrorView.hasFocus()) focus()
1750
+ const stopSelection = editor.onSelectionChange(() => { this.#documentSelectionGeneration++ })
1751
+ this.#unsubscribeDocumentSelection = () => { this.#stopDocumentLayoutScroll?.(); stopSelection(); element.removeEventListener('focusin', focus) }
1752
+ } else this.#unsubscribeDocumentSelection = undefined
1753
+ this.#finishOpeningDocument()
1754
+ }
1755
+ #focusDocumentNode(editor: NativeDocumentEditor, nodeId: string): void {
1756
+ this.#stopDocumentLayoutScroll?.(); this.#stopDocumentLayoutScroll = undefined
1757
+ editor.setTextCursorPosition(nodeId, 'start')
1758
+ editor.focus()
1759
+ editor.prosemirrorView.dispatch(editor.prosemirrorState.tr.scrollIntoView())
1760
+ // React node views can finish sizing after the native editor mounts.
1761
+ // Keep only this untouched focus visible through those layout changes.
1762
+ if (typeof ResizeObserver !== 'undefined') {
1763
+ const view = editor.prosemirrorView, document = view.dom.ownerDocument
1764
+ const source = this.#state.authoring!, identity = source.identity, reset = source.reset, documentId = source.content!.id
1765
+ const request = this.#state.documentNavigation?.request, selected = editor.prosemirrorState.selection
1766
+ let size = view.dom.getBoundingClientRect(), active = true
1767
+ const events = ['pointerdown', 'wheel', 'touchmove', 'keydown', 'beforeinput']
1768
+ const cancel = () => {
1769
+ if (!active) return
1770
+ active = false; observer.disconnect(); stopFocus(); stopSelection()
1771
+ for (const event of events) document.removeEventListener(event, interact, true)
1772
+ }
1773
+ const interact = (event: Event) => { if (event.isTrusted) cancel() }
1774
+ const stopFocus = editor.onFocusChange((_, { focused }) => { if (!focused) cancel() })
1775
+ const stopSelection = editor.onSelectionChange(cancel)
1776
+ const observer = new ResizeObserver(() => {
1777
+ if (!active) return
1778
+ const current = this.#state.authoring
1779
+ if (this.#documentEditor !== editor || view.isDestroyed || !current || current.identity !== identity || current.reset !== reset
1780
+ || current?.content?.id !== documentId || this.#state.documentNavigation?.request !== request
1781
+ || !editor.isFocused() || !editor.prosemirrorState.selection.eq(selected) || editor.getTextCursorPosition().block.id !== nodeId) { cancel(); return }
1782
+ const next = view.dom.getBoundingClientRect()
1783
+ if (next.width === size.width && next.height === size.height) return
1784
+ size = next
1785
+ view.dispatch(editor.prosemirrorState.tr.scrollIntoView())
1786
+ })
1787
+ for (const event of events) document.addEventListener(event, interact, true)
1788
+ observer.observe(view.dom)
1789
+ this.#stopDocumentLayoutScroll = cancel
1790
+ }
1791
+ }
1792
+ async flushPendingEdits(signal?: AbortSignal): Promise<void> {
1793
+ if (isAgentChanging(this.#state)) throw new Error('save-locked')
1794
+ await this.#documentSession.flush(signal)
1795
+ }
1796
+ async #changeDocumentPage(change: (document: TeachingDocument) => void): Promise<boolean> {
1797
+ const source = this.#state.authoring, directory = this.#state.document
1798
+ if (!source?.content || directory === undefined || isAgentChanging(this.#state)) return false
1799
+ const scope = this.#documentScope, identity = source.identity, documentId = source.content.id
1800
+ const current = () => scope === this.#documentScope && directory === this.#state.document && identity === this.#state.authoring?.identity && documentId === this.#state.authoring?.content?.id
1801
+ this.#set({ isEditingPage: true, pageEditFailure: undefined })
1802
+ try {
1803
+ await this.flushPendingEdits()
1804
+ if (!current()) throw new Error('page-context-changed')
1805
+ const document = structuredClone(this.#state.authoring!.content!)
1806
+ change(document)
1807
+ const editor = this.#documentEditor
1808
+ this.#pendingDocumentField = undefined
1809
+ const plannedFocus = this.#pendingDocumentFocus
1810
+ if (!plannedFocus || plannedFocus.generation !== this.#documentSelectionGeneration) for (const field of this.#documentFields.values()) {
1811
+ if (field.identity !== source.identity || field.reset !== this.#state.authoring!.reset || field.blockId !== undefined && !document.blocks[field.blockId] || !document.steps[field.stepId]) continue
1812
+ const bookmark = field.focus.capture()
1813
+ if (bookmark) {
1814
+ const { focus: _focus, editor: _editor, ...target } = field
1815
+ this.#pendingDocumentField = { field: target, bookmark, reset: this.#state.authoring!.reset + 1, generation: this.#documentSelectionGeneration }
1816
+ this.#pendingDocumentSelection = undefined
1817
+ break
1818
+ }
1819
+ }
1820
+ if (editor && !this.#pendingDocumentField) {
1821
+ const state = editor.prosemirrorState, anchor = nativeTextPoint(editor, state.selection.anchor), head = nativeTextPoint(editor, state.selection.head)
1822
+ if (anchor && head) this.#pendingDocumentSelection = { documentId: document.id, anchor, head, focused: editor.prosemirrorView.hasFocus() }
1823
+ }
1824
+ this.#documentSession.replace(document)
1825
+ await this.#documentSession.flush()
1826
+ return current()
1827
+ } catch (error) { if (current()) this.#set({ pageEditFailure: this.#pageEditMessage(errorMessage(error)) }); return false }
1828
+ finally { if (current()) this.#set({ isEditingPage: false }) }
1829
+ }
1830
+ renamePage(section: string, title: string): Promise<boolean> {
1831
+ return this.#changeDocumentPage(document => { Object.assign(document, renameDocumentPage(document, section, title)) })
1832
+ }
1833
+ async duplicatePage(section: string): Promise<boolean> {
1834
+ if (this.#state.isEditingPage || this.#state.isCreatingPage || this.#state.isHistoryBusy) return false
1835
+ const scope = this.#documentScope, identity = this.#state.authoring?.identity, generation = this.#documentSelectionGeneration, navigation = this.#state.documentNavigation?.request
1836
+ let copied: string | undefined
1837
+ const saved = await this.#changeDocumentPage(document => {
1838
+ if (scope !== this.#documentScope || identity !== this.#state.authoring?.identity) throw new Error('page-context-changed')
1839
+ const page = document.sections[section]
1840
+ if (!page) throw new Error('page-boundary')
1841
+ const copy = duplicateDocumentPage(document, section, contentText(document.metadata.lang).copyTitle(page.title), () => crypto.randomUUID())
1842
+ copied = copy.section
1843
+ Object.assign(document, copy.document)
1844
+ if (generation === this.#documentSelectionGeneration) this.#pendingDocumentFocus = { documentId: document.id, nodeId: `page:${copied}`, generation, afterSave: true }
1845
+ })
1846
+ // Source confirmation owns editing. A later author selection takes precedence over this response.
1847
+ if (!saved || !copied || scope !== this.#documentScope || generation !== this.#documentSelectionGeneration || navigation !== this.#state.documentNavigation?.request || !this.#state.authoring?.content?.sections[copied]) return saved
1848
+ this.goToSection(copied)
1849
+ const editor = this.#documentEditor
1850
+ if (editor) {
1851
+ const blocks = editor.document, start = blocks.findIndex(block => block.id === `page:${copied}`)
1852
+ const first = (nodes: typeof blocks): string | undefined => {
1853
+ for (const node of nodes) { if (node.type === 'presmiumPage') break; if (node.content !== undefined) return node.id; const child = first(node.children); if (child) return child }
1854
+ return undefined
1855
+ }
1856
+ const target = start < 0 ? undefined : first(blocks.slice(start + 1)) ?? blocks[start]!.id
1857
+ if (target) { editor.setTextCursorPosition(target, 'start'); editor.focus() }
1858
+ }
1859
+ return true
1860
+ }
1861
+ movePage(section: string, before: string | null): Promise<boolean> {
1862
+ return this.#changeDocumentPage(document => { Object.assign(document, moveDocumentPage(document, section, before)) })
1863
+ }
1864
+ pageDeleteMessage(reason: string): string { return this.#pageEditMessage(reason) }
1865
+ async preparePageDeletion(section: string): Promise<PageDeletePlan> {
1866
+ await this.flushPendingEdits()
1867
+ const source = this.#state.authoring
1868
+ if (!source?.content || !source.version || this.#state.document === undefined) throw new Error('document-unavailable')
1869
+ return pageDeletion(source.content, this.#state.document, source.version, section)
1870
+ }
1871
+ async deletePage(plan: PageDeletePlan): Promise<boolean> {
1872
+ if (!plan.allowed || plan.document !== this.#state.document) return false
1873
+ return this.#changeDocumentPage(document => {
1874
+ if (this.#state.authoring?.version !== plan.source.version) throw new Error('revision-conflict')
1875
+ const current = pageDeletion(document, plan.document, plan.source.version, plan.section)
1876
+ if (!current.allowed) throw new Error(current.reason)
1877
+ Object.assign(document, removeDocumentPage(document, plan.section, () => crypto.randomUUID()))
1878
+ this.#set({ position: { section: document.sectionOrder[0]!, step: 0, trail: [], view: 'document' }, documentNavigation: { section: document.sectionOrder[0]!, request: crypto.randomUUID() } })
1879
+ })
1880
+ }
1881
+ createPage(after = this.#state.position?.section): boolean {
1882
+ const source = this.#state.authoring, editor = this.#documentEditor, commands = this.#documentEditorCommands
1883
+ if (!source?.content || source.loading || source.composing || source.restored || source.error || !editor || !commands
1884
+ || editor.prosemirrorView.isDestroyed || isAgentChanging(this.#state) || this.#state.isEditingPage || this.#state.isCreatingPage
1885
+ || this.#state.isHistoryBusy || this.#state.isHistoryPreparing) return false
1886
+ try {
1887
+ this.#set({ pageEditFailure: undefined })
1888
+ const created = commands.insertPage(after, contentText(source.content.metadata.lang).newPage)
1889
+ this.#pendingDocumentField = undefined; this.#pendingDocumentSelection = undefined; this.#pendingDocumentFocus = undefined
1890
+ this.#set({ position: { section: created.sectionId, step: 0, trail: [], view: 'document' }, documentNavigation: { section: created.sectionId, request: crypto.randomUUID() } })
1891
+ this.#focusDocumentNode(editor, created.nodeId)
1892
+ return true
1893
+ } catch (error) { this.#set({ pageEditFailure: this.#pageEditMessage(errorMessage(error)) }); return false }
1894
+ }
1895
+
1896
+ /** Points at a block or a passage (none: stops pointing): the preview outlines it and the conversation is told. */
1897
+ point(pointer: Pointer | undefined): void {
1898
+ const pointed = this.#state.pointer !== undefined || this.#state.focus.length > 0
1899
+ if (pointer) this.#record('point', { 'presmium.section': pointer.target.section, 'presmium.block': pointer.target.block, 'presmium.step': pointer.step, 'presmium.quote': pointer.quote, 'presmium.editable': pointer.isEditable })
1900
+ this.#set({ pointer, askFailure: undefined })
1901
+ this.#broadcast({ type: 'highlight', target: pointer?.target ?? null })
1902
+ if (pointer) this.setFocus([this.#focusReportOf(pointer)])
1903
+ else if (pointed) this.setFocus([])
1904
+ }
1905
+
1906
+ #focusReportOf(pointer: Pointer): FocusReport {
1907
+ const { target, step, quote } = pointer
1908
+ return { id: 'selection', from: this.#state.previewRenderer, section: target.section, ...target.block ? { block: target.block } : {}, ...step === undefined ? {} : { step }, ...quote ? { quote: quote.slice(0, 2000) } : {} }
1909
+ }
1910
+
1911
+ setFocus(focus: FocusReport[]): void {
1912
+ const document = this.#state.document
1913
+ if (document === undefined) return
1914
+ const version = ++this.#focusVersion
1915
+ this.#set({ focus, isFocusPending: true, focusFailure: undefined })
1916
+ this.#focusWriting = this.#focusWriting.then(async () => {
1917
+ if (version !== this.#focusVersion) return
1918
+ try {
1919
+ const answer = await this.client.writeFocus(document, focus)
1920
+ if (version === this.#focusVersion && document === this.#state.document) this.#set({ focus: answer.focus ?? [], isFocusPending: false })
1921
+ } catch (error) {
1922
+ if (version === this.#focusVersion && document === this.#state.document) this.#set({ isFocusPending: false, focusFailure: errorMessage(error) })
1923
+ }
1924
+ })
1925
+ }
1926
+
1927
+ /** Hands `instruction` about what the author points at (or `focus`) to the conversation's agent. */
1928
+ askAgent(instruction: string, focus: FocusReport[] = this.#state.pointer ? [this.#focusReportOf(this.#state.pointer)] : []): Promise<boolean> {
1929
+ return this.#ask('request', focus, places => this.#messages.agentRequest(instruction, [...places.values()]))
1930
+ }
1931
+
1932
+ /**
1933
+ * Sets `focus` as the document's focus, then sends the agent the message `compose` writes from the places, by focus id.
1934
+ * The message names places as the author sees them; the agent's document context has their source ranges.
1935
+ */
1936
+ async #ask(kind: 'request' | 'comments', focus: FocusReport[], compose: (places: Map<string, AgentPlace>) => string): Promise<boolean> {
1937
+ const document = this.#state.document
1938
+ const asker = [...this.#askers][0]
1939
+ if (document === undefined) return false
1940
+ // The request's id joins the operation to the conversation: the session's user message carries it, and its turn follows.
1941
+ const asked = (attributes: Record<string, OperationValue>) => this.#record('agent.ask', { 'presmium.ask.kind': kind, 'presmium.focus': focus.map(item => [item.id, item.section, item.block].filter(Boolean).join(':')).join('\n'), ...attributes })
1942
+ if (!asker) {
1943
+ asked({ 'presmium.outcome': 'no-conversation' })
1944
+ this.#set({ askFailure: 'no-conversation' })
1945
+ return false
1946
+ }
1947
+ this.#set({ isAsking: true, askFailure: undefined })
1948
+ let text: string | undefined
1949
+ try {
1950
+ if (focus.length > 0) {
1951
+ this.setFocus(focus)
1952
+ await this.#focusWriting
1953
+ }
1954
+ const located = this.#state.focus as (FocusReport & Partial<Focus>)[]
1955
+ const places = new Map(located.map(item => [item.id, this.#describe(item)]))
1956
+ text = compose(places)
1957
+ const answer = await asker({ text, focus })
1958
+ asked({ 'presmium.ask.text': text, 'presmium.outcome': 'sent', 'dsh.session.id': answer?.session, 'dsh.request.id': answer?.request })
1959
+ if (this.#state.document === document) this.#set({ isAsking: false, pointer: undefined })
1960
+ this.#broadcast({ type: 'highlight', target: null })
1961
+ return true
1962
+ } catch (error) {
1963
+ asked({ 'presmium.ask.text': text, 'presmium.outcome': 'failed', 'presmium.error': errorMessage(error) })
1964
+ if (this.#state.document === document) this.#set({ isAsking: false, askFailure: errorMessage(error) })
1965
+ return false
1966
+ }
1967
+ }
1968
+
1969
+ /** A place as the author sees it: the section's title and the passage. */
1970
+ #describe(item: FocusReport & Partial<Focus>): AgentPlace {
1971
+ const doc = this.#state.ir?.doc
1972
+ const section = item.section ? doc?.sections.find(s => s.id === item.section)?.title ?? item.section : undefined
1973
+ const quote = item.quote ?? item.excerpt
1974
+ return { ...section ? { section } : {}, ...quote ? { quote: quote.slice(0, 1500) } : {} }
1975
+ }
1976
+
1977
+ /** Shows a section, or a block of it, in the preview (the next one attached, when none is). */
1978
+ revealPlace(section: string, block?: string): void {
1979
+ this.#record('reveal.place', { 'presmium.section': section, 'presmium.block': block })
1980
+ const target: PreviewAnchor = { section, ...block ? { block } : {} }
1981
+ if (this.#previewSinks.size === 0) {
1982
+ this.#pendingReveal = target
1983
+ return
1984
+ }
1985
+ this.#broadcast({ type: 'go', section })
1986
+ this.#broadcast({ type: 'highlight', target })
1987
+ }
1988
+
1989
+ /** Shows a section in the preview: from the navigation pane, the section to read or the slide to show. */
1990
+ goToSection(section: string): void {
1991
+ if (this.#state.webView === 'document' && this.#state.authoring?.content?.sections[section]) {
1992
+ this.#set({ position: { section, step: 0, trail: [], view: 'document' }, documentPositionFailure: undefined, documentNavigation: { section, request: crypto.randomUUID() } }); return
1993
+ }
1994
+ this.goToStep(section, this.#selectedSteps.get(`${this.#state.document}\0${section}`) ?? 0)
1995
+ }
1996
+
1997
+ /** Material references locate a canonical block in Doc without entering a runtime view or rewriting selection. */
1998
+ revealDocumentComponent(blockId: string, identity: string): void {
1999
+ const source = this.#state.authoring, document = this.getDocument(identity)
2000
+ if (!document || source?.identity !== identity || this.#state.webView !== 'document' || document.blocks[blockId]?.type !== 'component') return
2001
+ const sectionId = documentComponentSection(document, blockId), section = document.sections[sectionId]!
2002
+ const current = this.#state.position?.section === sectionId ? this.#state.position.step : this.#selectedSteps.get(`${this.#state.document}\0${sectionId}`) ?? 0
2003
+ const visible = (index: number) => Boolean(section.stepOrder[index] && effectiveComponentParameters(document, blockId, section.stepOrder[index]).visual?.visible)
2004
+ const step = visible(current) ? current : Math.max(0, section.stepOrder.findIndex((_, index) => visible(index)))
2005
+ this.#set({ position: { section: sectionId, step, trail: [], view: 'document' }, documentPositionFailure: undefined,
2006
+ documentNavigation: { section: sectionId, block: blockId, request: crypto.randomUUID() } })
2007
+ }
2008
+
2009
+ observeDocumentSection(section: string): void {
2010
+ if (this.#state.webView !== 'document' || !this.#state.authoring?.content?.sections[section]) return
2011
+ if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
2012
+ if (this.#state.position?.section === section) return
2013
+ this.#set({ position: { section, step: this.#selectedSteps.get(`${this.#state.document}\0${section}`) ?? 0, trail: [], view: 'document' } })
2014
+ }
2015
+
2016
+ /** A read-only inline runtime chooses a source step without navigating or editing the document. */
2017
+ observeDocumentStep(sectionId: string, stepId: string): void {
2018
+ this.#documentStep(sectionId, stepId, false)
2019
+ }
2020
+
2021
+ /** An author freezes this target even when its step has not changed. */
2022
+ pauseDocumentStep(sectionId: string, stepId: string): void {
2023
+ this.#interruptProductionNavigation()
2024
+ this.#documentStep(sectionId, stepId, true)
2025
+ }
2026
+
2027
+ /** A canonical step remains selectable while its next runtime snapshot is still compiling. */
2028
+ selectDocumentStep(sectionId: string, stepId: string): Promise<ProductionPlaybackState | undefined> | void {
2029
+ this.#interruptProductionNavigation()
2030
+ const source = this.#state.authoring, section = source?.content?.sections[sectionId]
2031
+ const step = section?.stepOrder.indexOf(stepId) ?? -1
2032
+ if (!section || step < 0) return
2033
+ this.#pendingGo = undefined
2034
+ if (this.#state.webView === 'document') { this.pauseDocumentStep(sectionId, stepId); return }
2035
+ const plan = this.#state.productionPlan, range = plan?.pages.find(page => page.sectionId === sectionId)?.steps.find(step => step.stepId === stepId)
2036
+ if (plan?.canPreview && this.#productionReady() && range?.startMs !== undefined) {
2037
+ this.stopAuditions()
2038
+ if (this.#production.selecting(sectionId, stepId)) return
2039
+ this.#selectedSteps.set(`${this.#state.document}\0${sectionId}`, step)
2040
+ this.#set({ position: { section: sectionId, step, trail: [], view: 'present' } })
2041
+ return this.controlProduction({ binding: plan.binding, action: 'step', sectionId, stepId }).catch(() => undefined)
2042
+ }
2043
+ const snapshot = this.#state.presentationSnapshot
2044
+ const compiled = this.#state.ir?.doc.sections.find(page => page.id === sectionId)?.stepIds?.indexOf(stepId) ?? -1
2045
+ if (snapshot && snapshot.version === source?.version && snapshot.fingerprint === source.fingerprint && compiled >= 0) this.goToStep(sectionId, compiled)
2046
+ else this.#set({ position: { section: sectionId, step, trail: [], view: this.#state.webView } })
2047
+ }
2048
+
2049
+ #documentStep(sectionId: string, stepId: string, pause: boolean): void {
2050
+ const content = this.#state.authoring?.content, section = content?.sections[sectionId]
2051
+ if (this.#state.webView !== 'document' || !content?.sectionOrder.includes(sectionId) || !section) return
2052
+ const step = section.stepOrder.indexOf(stepId)
2053
+ if (step < 0 || content.steps[stepId]?.sectionId !== sectionId) return
2054
+ if (this.#state.documentPositionFailure) this.#set({ documentPositionFailure: undefined })
2055
+ this.#selectedSteps.set(`${this.#state.document}\0${sectionId}`, step)
2056
+ const same = this.#state.position?.section === sectionId && this.#state.position.step === step
2057
+ if (same && !pause) return
2058
+ this.#set({ position: { section: sectionId, step, trail: section.stepOrder.slice(1, step + 1).map(id => `step:${id}`), view: 'document' }, documentPositionIntent: same && pause ? 'pause' : 'seek' })
2059
+ }
2060
+
2061
+ /** Complete-IR position; trail maps the same step into a filtered preview without reusing its ordinal. */
2062
+ goToStep(section: string, step: number): void {
2063
+ this.#interruptProductionNavigation()
2064
+ const target = this.#state.ir?.doc.sections.find(item => item.id === section)
2065
+ if (!target || !Number.isInteger(step) || step < 0 || step > target.steps) return
2066
+ const trail = stepTrail(stepKeys(target), step)
2067
+ this.#set({ position: { section, step, trail, view: this.#state.webView } })
2068
+ this.#record('navigate', { 'presmium.section': section, 'presmium.step': step })
2069
+ this.#selectedSteps.set(`${this.#state.document}\0${section}`, step)
2070
+ this.#lastPosition = undefined
2071
+ const command = { type: 'go', section, step, trail, view: this.#state.webView } as const
2072
+ if (this.#previewSinks.size === 0) {
2073
+ this.#pendingGo = { document: this.#state.document, command }
2074
+ return
2075
+ }
2076
+ this.#broadcast(command)
2077
+ }
2078
+
2079
+ /** Shows the place of a line of a source file: in the document holding the file, opened when it is another. */
2080
+ async revealSource(path: string, line = 1): Promise<boolean> {
2081
+ this.#record('reveal.source', { 'presmium.path': path, 'presmium.line': line })
2082
+ const documents = this.#state.documents?.documents ?? []
2083
+ const holder = [...documents].sort((a, b) => b.dir.length - a.dir.length).find(doc => !doc.dir || path.startsWith(`${doc.dir}/`))
2084
+ if (!holder) return false
2085
+ if (holder.dir !== this.#state.document) await this.openDocument(holder.dir)
2086
+ const doc = this.#state.ir?.doc
2087
+ const place = doc && findPlaceAtLine(doc, path, line)
2088
+ if (!place) return false
2089
+ this.revealPlace(place.section, place.block)
2090
+ return true
2091
+ }
2092
+
2093
+ undo(): Promise<boolean> { if (this.#state.mode !== 'editing') return Promise.resolve(false); if (this.#activeDocumentHistory && (this.#state.editorHistory?.undo || this.#state.editorHistory?.field)) { this.#activeDocumentHistory.focus(); return Promise.resolve(this.#activeDocumentHistory.undo()) } return this.#changeHistory('undo') }
2094
+ redo(): Promise<boolean> { if (this.#state.mode !== 'editing') return Promise.resolve(false); if (this.#activeDocumentHistory && (this.#state.editorHistory?.redo || this.#state.editorHistory?.field)) { this.#activeDocumentHistory.focus(); return Promise.resolve(this.#activeDocumentHistory.redo()) } return this.#changeHistory('redo') }
2095
+
2096
+ async #changeHistory(action: 'undo' | 'redo' | 'revert', revision?: string): Promise<boolean> {
2097
+ if (this.#state.mode !== 'editing' || this.#state.isCreatingPage || this.#state.isEditingPage || this.#state.isHistoryBusy || this.#state.isHistoryPreparing) return false
2098
+ const document = this.#state.document, scope = this.#documentScope
2099
+ if (document === undefined) return false
2100
+ const current = () => this.#state.document === document && this.#documentScope === scope
2101
+ this.#set({ isHistoryPreparing: true, historyFailure: undefined })
2102
+ try {
2103
+ if (!current()) return false
2104
+ await this.flushPendingEdits()
2105
+ if (!current()) return false
2106
+ await this.#history.read(document)
2107
+ if (!current()) return false
2108
+ const done = await this.#timed(`history.${action}`, revision ? { 'presmium.revision': revision } : {}, () => action === 'revert' ? this.#history.revert(revision!) : this.#history[action](), done => done ? 'done' : 'refused')
2109
+ if (done && current()) await this.#documentSession.refresh(document)
2110
+ return done
2111
+ } catch {
2112
+ if (current()) this.#set({ historyFailure: this.#messages.historyPendingSave })
2113
+ return false
2114
+ } finally { if (current()) this.#set({ isHistoryPreparing: false }) }
2115
+ }
2116
+
2117
+ readHistoryPage(offset: number): Promise<void> { return this.#history.page(offset) }
2118
+
2119
+ revertRevision(id: string): Promise<boolean> {
2120
+ return this.#changeHistory('revert', id)
2121
+ }
2122
+
2123
+ acceptReview(): Promise<boolean> {
2124
+ return this.#timed('review.accept', {}, () => this.#history.acceptReview(), done => (done ? 'done' : 'refused'))
2125
+ }
2126
+
2127
+ rejectReview(): Promise<boolean> {
2128
+ return this.#timed('review.reject', {}, () => this.#history.rejectReview(), done => (done ? 'done' : 'refused'))
2129
+ }
2130
+
2131
+ /** Makes the document into `target` (a renderer, or an export of its plugins) as a job of the workspace server. */
2132
+ startJob(target: string): Promise<void> {
2133
+ const document = this.#state.document, scope = this.#documentScope, identity = this.#state.authoring?.identity
2134
+ if (document === undefined || !identity || this.#state.jobPreparing) return Promise.resolve()
2135
+ const controller = this.#jobPreparation = new AbortController()
2136
+ const current = () => document === this.#state.document && scope === this.#documentScope && identity === this.#state.authoring?.identity && !controller.signal.aborted
2137
+ this.#set({ jobPreparing: { target, canCancel: true }, jobsFailure: undefined })
2138
+ return this.#timed('job.start', { 'presmium.job.target': target }, async () => {
2139
+ const stop = this.observeJobs()
2140
+ try {
2141
+ if (this.#state.hasDocumentTimingDrafts || this.#state.documentTimingDraftStorageFailure) throw new Error('job-timing-draft')
2142
+ if (this.#state.captionTimingDraftSteps?.length || this.#state.captionTimingDraftStorageFailure) throw new Error('job-caption-draft')
2143
+ await this.flushPendingEdits(controller.signal)
2144
+ if (!current()) return
2145
+ const source = this.#state.authoring
2146
+ if (!source?.version || !source.fingerprint || source.dirty || source.saving || source.composing || source.restored || source.error) throw new Error('job-save-required')
2147
+ if (this.#state.hasDocumentTimingDrafts || this.#state.documentTimingDraftStorageFailure) throw new Error('job-timing-draft')
2148
+ if (this.#state.captionTimingDraftSteps?.length || this.#state.captionTimingDraftStorageFailure) throw new Error('job-caption-draft')
2149
+ this.#set({ jobPreparing: { target, canCancel: false } })
2150
+ await this.#jobs.start(document, target, { version: source.version, fingerprint: source.fingerprint })
2151
+ } catch (error) {
2152
+ if (current()) this.#set({ jobsFailure: errorMessage(error) })
2153
+ } finally {
2154
+ stop()
2155
+ if (this.#jobPreparation === controller) {
2156
+ this.#jobPreparation = undefined
2157
+ if (current()) this.#set({ jobPreparing: undefined })
2158
+ }
2159
+ }
2160
+ })
2161
+ }
2162
+
2163
+ /** Cancels the pending export intent; a document save already sent retains its own acknowledgement. */
2164
+ cancelJobPreparation(): void {
2165
+ if (!this.#state.jobPreparing?.canCancel) return
2166
+ this.#jobPreparation?.abort()
2167
+ this.#jobPreparation = undefined
2168
+ this.#set({ jobPreparing: undefined })
2169
+ }
2170
+
2171
+ cancelJob(id: string): Promise<void> {
2172
+ return this.#timed('job.cancel', { 'presmium.job.id': id }, async () => { const stop = this.observeJobs(); try { await this.#jobs.cancel(id) } finally { stop() } })
2173
+ }
2174
+
2175
+ recordOutsideChanges(): Promise<boolean> {
2176
+ return this.#timed('changes.record', {}, () => this.#history.recordOutside(this.#messages.outsideChange), done => (done ? 'done' : 'refused'))
2177
+ }
2178
+
2179
+ /** Attaches the shown preview and selects the requested view in its existing runtime. */
2180
+ attachPreview(sink: PreviewSink): () => void {
2181
+ this.#previewSinks.add(sink)
2182
+ this.#set({ previewConnection: this.#state.previewConnection + 1 })
2183
+ if (this.#state.previewRenderer === 'web') this.#showWebView([sink])
2184
+ const pending = this.#pendingGo
2185
+ if (pending && pending.document === this.#state.document) sink.send(pending.command)
2186
+ this.#pendingGo = undefined
2187
+ sink.send({ type: 'appearance', dark: this.#state.isDark })
2188
+ sink.send({ type: 'pick', on: false })
2189
+ const reveal = this.#pendingReveal
2190
+ this.#pendingReveal = undefined
2191
+ if (reveal) sink.send({ type: 'go', section: reveal.section })
2192
+ sink.send({ type: 'highlight', target: reveal ?? this.#state.pointer?.target ?? null })
2193
+ return () => {
2194
+ this.#previewSinks.delete(sink)
2195
+ this.#production.detach(sink)
2196
+ this.#set({ previewConnection: this.#state.previewConnection + 1 })
2197
+ this.#playback.detach(sink)
2198
+ }
2199
+ }
2200
+
2201
+ /** A report of the shown preview. */
2202
+ handlePreviewReport(document: string, renderer: PreviewRenderer, report: PreviewReport, origin?: PreviewSink): void {
2203
+ const unsupported = 'document-authoring-required'
2204
+ if (report.type === 'edited' && report.request !== undefined) { origin?.send({ type: 'edit-result', request: report.request, target: report.target, error: unsupported }); return }
2205
+ if (report.type === 'blocks-edited') { origin?.send({ type: 'blocks-result', section: report.section, request: report.request, error: unsupported }); return }
2206
+ if (report.type === 'source-read') { origin?.send({ type: 'source-result', section: report.section, block: report.block, request: report.request, error: unsupported }); return }
2207
+ if (report.type === 'source-save') { origin?.send({ type: 'source-saved', section: report.section, block: report.block, request: report.request, error: unsupported }); return }
2208
+ if (report.type === 'source-create') { origin?.send({ type: 'source-created', section: report.section, request: report.request, error: unsupported }); return }
2209
+ if (report.type === 'page-create') { origin?.send({ type: 'page-created', section: report.section, request: report.request, error: unsupported }); return }
2210
+ if (document !== this.#state.document || renderer !== this.#state.previewRenderer) return
2211
+ this.#playback.report(document, report, origin)
2212
+ this.#production.report(document, report, origin)
2213
+ switch (report.type) {
2214
+ case 'ready':
2215
+ case 'updated':
2216
+ if (report.revision !== this.#state.previewIrRevision) this.#playback.stopPreviewOutputs()
2217
+ this.#set({ previewIrRevision: report.revision })
2218
+ this.#flashChanges()
2219
+ if (report.type === 'ready' && renderer === 'web' && this.#state.webView !== 'document') this.#showWebView()
2220
+ break
2221
+ case 'picked': {
2222
+ if (this.#state.mode !== 'editing') break
2223
+ const target: PreviewAnchor = { section: report.section, ...report.block ? { block: report.block } : {} }
2224
+ const isEditable = false
2225
+ const section = this.#state.ir?.doc.sections.find(s => s.id === report.section)
2226
+ const step = section && report.trail ? trailStep(stepKeys(section), report.trail) : report.step
2227
+ this.point({ target, ...step === undefined ? {} : { step }, ...report.rect ? { rect: report.rect } : {}, ...report.point ? { point: report.point } : {}, isEditable })
2228
+ break
2229
+ }
2230
+ case 'selection': {
2231
+ if (this.#state.mode !== 'editing') break
2232
+ const selected = report.selection
2233
+ if (selected?.text.trim()) {
2234
+ const target: PreviewAnchor = { section: selected.section, ...selected.block ? { block: selected.block } : {} }
2235
+ this.point({ target, quote: selected.text.trim(), ...selected.rect ? { rect: selected.rect } : {}, ...selected.obstacles ? { obstacles: selected.obstacles } : {}, isEditable: false })
2236
+ } else if (this.#state.pointer?.quote) this.point(undefined)
2237
+ break
2238
+ }
2239
+ case 'flashed':
2240
+ if (this.#trail) this.#set({ changeTrail: { id: this.#trail, rects: report.rects } })
2241
+ break
2242
+ case 'rect':
2243
+ if (this.#state.pointer) this.#set({ pointer: { ...this.#state.pointer, rect: report.rect ?? undefined, obstacles: report.obstacles } })
2244
+ break
2245
+ case 'position': {
2246
+ const snapshot = this.#state.presentationSnapshot, source = this.#state.authoring
2247
+ if (snapshot && source && (source.dirty || source.loading || source.version !== snapshot.version || source.fingerprint !== snapshot.fingerprint)) break
2248
+ // An initial loader can report its old cursor while a newer stable selection is preparing.
2249
+ // Explicit native exits still reach the outer document workspace.
2250
+ if (report.view !== 'document' && this.#production.selecting()) break
2251
+ // A fresh frame announces its document view before applying the queued
2252
+ // presentation command. Only the requested view completes that switch.
2253
+ if (renderer === 'web' && this.#awaitingView) {
2254
+ if (report.view !== this.#awaitingView) break
2255
+ this.#awaitingView = undefined
2256
+ }
2257
+ // Where the author reads or presents: recorded when it moves to another section, step or view.
2258
+ const at = `${report.section}\0${report.step}\0${report.view ?? ''}`
2259
+ if (at === this.#lastPosition) break
2260
+ this.#lastPosition = at
2261
+ const section = this.#state.ir?.doc.sections.find(section => section.id === report.section)
2262
+ const completeStep = section && report.trail.length ? trailStep(stepKeys(section), report.trail) : report.step
2263
+ this.#selectedSteps.set(`${this.#state.document}\0${report.section}`, completeStep)
2264
+ this.#set({ position: { section: report.section, step: completeStep, trail: report.trail, ...report.view ? { view: report.view } : {} } })
2265
+ if (renderer === 'web' && report.view && (report.view === 'present') !== (this.#state.webView === 'present')) this.setWebView(report.view === 'present' ? 'present' : 'document')
2266
+ this.#record('preview.position', { 'presmium.section': report.section, 'presmium.step': report.step, 'presmium.preview.view': report.view })
2267
+ break
2268
+ }
2269
+ case 'shortcut':
2270
+ this.#record('shortcut', { 'presmium.command': report.command })
2271
+ if (report.command === 'dismiss') {
2272
+ if (this.#state.pointer) this.point(undefined)
2273
+ } else if (report.command === 'undo') void this.undo()
2274
+ else if (report.command === 'redo') void this.redo()
2275
+ else if (report.command === 'save') void this.flushPendingEdits().catch(() => {})
2276
+ else for (const listener of this.#playbackKeys) listener(report.command)
2277
+ break
2278
+ default:
2279
+ break
2280
+ }
2281
+ }
2282
+
2283
+ #broadcast(command: PreviewCommand): void {
2284
+ for (const sink of this.#previewSinks) sink.send(command)
2285
+ }
2286
+
2287
+ #stopSubscriptions(): void {
2288
+ this.#componentPropertyDrafts.stopAgentRead()
2289
+ this.#saveDocumentReturnPoint(); this.#cancelOpeningDocument(); this.#stopDocumentReturnScroll?.()
2290
+ this.#documentViewport?.stop(); this.#documentViewport = undefined; this.#documentEditorBinding = undefined
2291
+ this.#presentationPreparation++
2292
+ this.#presentationController?.abort(new Error('client-document-changed'))
2293
+ this.#voiceover.stop()
2294
+ this.#production.reset()
2295
+ this.#set({ isPreparingPresentation: false, presentationFailure: undefined, presentationSnapshot: undefined, productionPlan: undefined, productionFailure: undefined })
2296
+ this.#removedCueTimings.clear(); this.#cueTimingScope = undefined
2297
+ this.#documentSession.close()
2298
+ this.#unsubscribeDocumentHistory?.(); this.#unsubscribeDocumentHistory = undefined; this.#activeDocumentHistory = undefined; this.#documentEditor = undefined
2299
+ this.#unsubscribeDocumentSelection?.(); this.#unsubscribeDocumentSelection = undefined
2300
+ this.#pendingDocumentFocus = undefined; this.#pendingDocumentSelection = undefined; this.#documentSelectionGeneration++
2301
+ this.#documentFields.clear(); this.#pendingDocumentField = undefined
2302
+ this.#playback.reset()
2303
+ this.#focusVersion++
2304
+ this.#pendingReveal = undefined
2305
+ this.#awaitingView = undefined
2306
+ this.#pendingFlash = undefined
2307
+ this.#unsubscribeIr?.()
2308
+ this.#unsubscribeIr = undefined
2309
+ this.#runtimeDocument = undefined
2310
+ this.#jobObservers.clear()
2311
+ this.#jobsDocument = undefined
2312
+ this.#history.stop()
2313
+ this.#diagnostics.stop()
2314
+ this.#jobs.stop()
2315
+ }
2316
+
2317
+ dispose(): void {
2318
+ this.telemetry.stop()
2319
+ this.#stopSubscriptions()
2320
+ this.#diagnostics.dispose()
2321
+ this.#documentSession.dispose()
2322
+ this.#commentDrafts.close()
2323
+ this.#componentPropertyDrafts.close()
2324
+ this.#captionTimingDrafts.close()
2325
+ disposeTranscriptionSessions(this.client)
2326
+ disposeDocumentRecordings(this)
2327
+ }
2328
+ }