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