@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,125 @@
1
+ import type { PreviewReport } from '@lincyaw/kernel'
2
+ import type { PreviewSink } from './workbench.ts'
3
+
4
+ interface PlaybackContext { document?: string, scope: number, sinks: ReadonlySet<PreviewSink> }
5
+ interface PendingPause {
6
+ document: string
7
+ scope: number
8
+ sink: PreviewSink
9
+ generation?: number
10
+ finish(error?: string): void
11
+ }
12
+
13
+ /** Coordinates author auditions with the existing preview player; it never drives a second lecture clock. */
14
+ export class PreviewPlayback {
15
+ readonly #context: () => PlaybackContext
16
+ readonly #pauses = new Map<number, PendingPause>()
17
+ readonly #auditions = new Set<() => void>()
18
+ readonly #captures = new Set<() => void>()
19
+ readonly #documents = new Set<() => Promise<void>>()
20
+ readonly #stoppingDocuments = new Set<Promise<void>>()
21
+ #activation = 0
22
+ #sequence = Date.now()
23
+
24
+ constructor(context: () => PlaybackContext) { this.#context = context }
25
+
26
+ registerAudition(stop: () => void): () => void {
27
+ this.stopAuditions()
28
+ this.#auditions.add(stop)
29
+ return () => { this.#auditions.delete(stop) }
30
+ }
31
+
32
+ registerAudioCapture(stop: () => void): () => void {
33
+ this.stopAuditions()
34
+ this.#captures.add(stop)
35
+ return () => { this.#captures.delete(stop) }
36
+ }
37
+
38
+ stopAuditions(): void {
39
+ this.stopPreviewOutputs()
40
+ for (const stop of [...this.#captures]) stop()
41
+ }
42
+
43
+ /** A passive frame replacement invalidates output, without ending a newer microphone take. */
44
+ stopPreviewOutputs(): void {
45
+ this.#activation++
46
+ for (const stop of [...this.#auditions]) stop()
47
+ void this.#stopDocuments().catch(() => {})
48
+ }
49
+
50
+ #stopDocuments(): Promise<void> {
51
+ const pending = [...this.#stoppingDocuments]
52
+ for (const stop of [...this.#documents]) {
53
+ let work: Promise<void>
54
+ try { work = stop() } catch (error) { work = Promise.reject(error) }
55
+ this.#stoppingDocuments.add(work)
56
+ void work.then(() => this.#stoppingDocuments.delete(work), () => this.#stoppingDocuments.delete(work))
57
+ pending.push(work)
58
+ }
59
+ return Promise.all(pending).then(() => {})
60
+ }
61
+
62
+ /** A new local player waits until every previous iframe confirms it has stopped. */
63
+ async startDocumentPlayback(stop: () => Promise<void>): Promise<() => void> {
64
+ const activation = ++this.#activation
65
+ for (const audition of [...this.#auditions]) audition()
66
+ for (const capture of [...this.#captures]) capture()
67
+ await this.#stopDocuments()
68
+ if (activation !== this.#activation) throw new DOMException('Playback cancelled', 'AbortError')
69
+ this.#documents.add(stop)
70
+ return () => { this.#documents.delete(stop) }
71
+ }
72
+
73
+ pause(): Promise<void> {
74
+ this.#activation++
75
+ const documents = this.#stopDocuments()
76
+ const { document, scope, sinks } = this.#context()
77
+ const sink = [...sinks].find(sink => sink.capabilities?.includes('pause-playback') && sink.capabilities.includes('playback-granted'))
78
+ if (document === undefined || !sink) return documents
79
+ const request = ++this.#sequence
80
+ const preview = new Promise<void>((resolve, reject) => {
81
+ const timer = setTimeout(() => finish('preview-playback-timeout'), 5000)
82
+ const finish = (error?: string) => {
83
+ if (!this.#pauses.delete(request)) return
84
+ clearTimeout(timer)
85
+ error ? reject(new Error(error)) : resolve()
86
+ }
87
+ this.#pauses.set(request, { document, scope, sink, generation: sink.generation, finish })
88
+ try { sink.send({ type: 'pause-playback', request }) } catch { finish('preview-playback-unavailable') }
89
+ })
90
+ return Promise.all([documents, preview]).then(() => {})
91
+ }
92
+
93
+ report(document: string, report: PreviewReport, sink?: PreviewSink): void {
94
+ const context = this.#context()
95
+ if (!sink || document !== context.document || !context.sinks.has(sink)) return
96
+ if (report.type === 'playback-paused') {
97
+ const pause = this.#pauses.get(report.request)
98
+ if (!pause || pause.document !== document || pause.scope !== context.scope || pause.sink !== sink) return
99
+ pause.finish(pause.generation !== sink.generation ? 'preview-playback-changed' : report.error ? 'preview-playback-unavailable' : undefined)
100
+ } else if (report.type === 'playback-request' && sink.capabilities?.includes('playback-granted')) {
101
+ // Stop and invalidate pending audition requests before granting the child's existing player.
102
+ const activation = ++this.#activation, generation = sink.generation
103
+ for (const audition of [...this.#auditions]) audition()
104
+ for (const capture of [...this.#captures]) capture()
105
+ void this.#stopDocuments().then(() => {
106
+ const current = this.#context()
107
+ if (activation === this.#activation && current.document === context.document && current.scope === context.scope && current.sinks.has(sink) && sink.generation === generation)
108
+ sink.send({ type: 'playback-granted', request: report.request })
109
+ }).catch(() => {})
110
+ } else if (report.type === 'ready') {
111
+ for (const pause of this.#pauses.values()) if (pause.sink === sink && pause.generation !== sink.generation) pause.finish('preview-playback-changed')
112
+ this.stopPreviewOutputs()
113
+ }
114
+ }
115
+
116
+ detach(sink: PreviewSink): void {
117
+ for (const pause of this.#pauses.values()) if (pause.sink === sink) pause.finish('preview-playback-changed')
118
+ this.stopPreviewOutputs()
119
+ }
120
+
121
+ reset(): void {
122
+ for (const pause of this.#pauses.values()) pause.finish('preview-playback-changed')
123
+ this.stopAuditions()
124
+ }
125
+ }
@@ -0,0 +1,320 @@
1
+ import type { Finding, PreviewRenderer, PreviewReport } from '@lincyaw/kernel'
2
+ import { connectPreview } from './preview.ts'
3
+ import type { PreviewConnection } from './preview.ts'
4
+ import type { Workbench } from './workbench.ts'
5
+
6
+ // Retain loaded modules, not running documents. Unsupported previews are removed.
7
+ const LIMIT = 2
8
+ const IDLE_MS = 120_000
9
+ const ACTIVITY_MS = 1500
10
+ const READY_MS = 15_000
11
+ // A page loaded out of sight is removed this long after its load, its lazy modules and styles fetched; or at the latest.
12
+
13
+ export interface PreviewTarget {
14
+ document: string
15
+ renderer: PreviewRenderer
16
+ address?: string
17
+ title: string
18
+ reload: number
19
+ }
20
+
21
+ /** The state of the selected preview's iframe. */
22
+ export type PresentationAction = Extract<PreviewReport, { type: 'presentation-action' }>['action']
23
+
24
+ export interface PreviewFrameStatus {
25
+ ready: boolean
26
+ canvasGate?: boolean
27
+ mismatch?: boolean
28
+ identityFailure?: boolean
29
+ handshakeFailure?: boolean
30
+ }
31
+
32
+ interface Entry {
33
+ key: string
34
+ target: PreviewTarget & { address: string }
35
+ frame: HTMLIFrameElement
36
+ connection: PreviewConnection
37
+ ready: boolean
38
+ active: boolean | undefined
39
+ generation: number
40
+ used: number
41
+ irRevision?: string
42
+ problems: Map<string, Finding>
43
+ detach?: () => void
44
+ loading?: ReturnType<typeof setInterval>
45
+ expiry?: ReturnType<typeof setTimeout>
46
+ pending?: { request: number, generation: number, active: boolean, timer: ReturnType<typeof setTimeout>, resolve(): void, reject(error: Error): void }
47
+ }
48
+
49
+ /**
50
+ * Keeps the iframes of the last previews (at most two) in `container`, shows the selected one and pauses the others
51
+ * (or removes those that cannot pause). `switchDocument` opens another document when a preview navigates to it.
52
+ */
53
+ export function createPreviewPool(container: HTMLElement, workbench: Workbench, onStatus: (status: PreviewFrameStatus) => void, switchDocument: (document: string) => void, presentationAction?: (action: PresentationAction) => void, options: { presentation?: boolean, playbackKeys?: boolean } = {}) {
54
+ const entries = new Map<string, Entry>()
55
+ let confirmedRevision: string | undefined
56
+ const confirm = (entry: Entry) => {
57
+ if (options.presentation && selected(entry) && confirmedRevision && entry.irRevision === confirmedRevision && entry.connection.capabilities.includes('presentation-confirmed')) entry.connection.send({ type: 'presentation-confirmed', revision: confirmedRevision })
58
+ }
59
+ let desired: PreviewTarget | undefined
60
+ let signature = ''
61
+ let generation = 0
62
+ let serial = Promise.resolve()
63
+ let requests = 0
64
+ let uses = 0
65
+ let disposed = false
66
+ const keyOf = (target: PreviewTarget) => JSON.stringify([target.document, target.renderer])
67
+ const selected = (entry: Entry) => !disposed && desired !== undefined && keyOf(desired) === entry.key
68
+ && workbench.state.document === entry.target.document && workbench.state.previewRenderer === entry.target.renderer
69
+
70
+ const publishRuntimeFindings = (entry: Entry, clear = false) => {
71
+ if (!entry.irRevision) return
72
+ void workbench.reportPreviewDiagnostics({ document: entry.target.document, renderer: entry.target.renderer, instance: entry.connection.nonce,
73
+ revision: entry.irRevision, findings: clear ? [] : [...entry.problems.values()] })
74
+ }
75
+
76
+ const hide = (entry: Entry) => {
77
+ entry.detach?.()
78
+ entry.detach = undefined
79
+ entry.frame.hidden = true
80
+ entry.frame.style.display = 'none'
81
+ entry.frame.className = 'pmw-preview__cached-frame'
82
+ entry.frame.dataset.active = 'false'
83
+ entry.frame.tabIndex = -1
84
+ }
85
+ const destroy = (entry: Entry) => {
86
+ if (entries.get(entry.key) !== entry) return
87
+ entries.delete(entry.key)
88
+ hide(entry)
89
+ clearInterval(entry.loading)
90
+ clearTimeout(entry.expiry)
91
+ if (entry.pending) {
92
+ clearTimeout(entry.pending.timer)
93
+ entry.pending.reject(new Error('Preview removed'))
94
+ entry.pending = undefined
95
+ }
96
+ entry.connection.dispose()
97
+ entry.frame.remove()
98
+ entry.problems.clear()
99
+ publishRuntimeFindings(entry)
100
+ }
101
+ const show = (entry: Entry, irRevision?: string) => {
102
+ if (!selected(entry) || entries.get(entry.key) !== entry || !entry.active) return
103
+ if (entry.detach && !entry.frame.hidden && entry.frame.dataset.active === 'true' && (irRevision === undefined || irRevision === entry.irRevision)) return
104
+ clearTimeout(entry.expiry)
105
+ entry.used = ++uses
106
+ entry.frame.hidden = false
107
+ entry.frame.style.display = ''
108
+ entry.frame.className = 'pmw-preview__frame'
109
+ entry.frame.dataset.active = 'true'
110
+ entry.frame.tabIndex = 0
111
+ entry.detach ??= workbench.attachPreview(entry.connection)
112
+ if (irRevision !== undefined) entry.irRevision = irRevision
113
+ confirm(entry)
114
+ onStatus({ ready: true, canvasGate: entry.connection.capabilities.includes('presentation-confirmed') })
115
+ workbench.handlePreviewReport(entry.target.document, entry.target.renderer, { type: 'updated', revision: entry.irRevision }, entry.connection)
116
+ publishRuntimeFindings(entry)
117
+ }
118
+ /** Asks the page to run (`active`) or to pause its runtime; settles with its `activity` report. */
119
+ const requestActivity = (entry: Entry, active: boolean): Promise<void> => new Promise((resolve, reject) => {
120
+ const request = ++requests, generation = entry.generation
121
+ const timer = setTimeout(() => {
122
+ if (entry.pending?.request !== request || entry.generation !== generation) return
123
+ entry.pending = undefined
124
+ reject(new Error('Preview activity timed out'))
125
+ }, ACTIVITY_MS)
126
+ entry.pending = { request, generation, active, timer, resolve, reject }
127
+ entry.connection.send({ type: 'activity', active, request })
128
+ })
129
+ const pause = async (entry: Entry) => {
130
+ if (entries.get(entry.key) !== entry) return
131
+ const generation = entry.generation
132
+ hide(entry)
133
+ if (!entry.ready || !entry.connection.capabilities.includes('activity')) return destroy(entry)
134
+ if (entry.active !== false) {
135
+ publishRuntimeFindings(entry, true)
136
+ try {
137
+ await requestActivity(entry, false)
138
+ } catch {
139
+ if (generation === entry.generation && entries.get(entry.key) === entry) destroy(entry)
140
+ return
141
+ }
142
+ }
143
+ if (entries.get(entry.key) === entry && generation === entry.generation) {
144
+ clearTimeout(entry.expiry)
145
+ entry.expiry = setTimeout(() => { if (!entry.active) destroy(entry) }, IDLE_MS)
146
+ }
147
+ }
148
+ const enqueue = (work: () => Promise<void>) => {
149
+ serial = serial.then(work).catch(() => {})
150
+ }
151
+
152
+ const handleActivity = (entry: Entry, report: Extract<PreviewReport, { type: 'activity' }>) => {
153
+ const pending = entry.pending
154
+ if (!pending || pending.request !== report.request || pending.generation !== entry.generation) return
155
+ clearTimeout(pending.timer)
156
+ entry.pending = undefined
157
+ entry.active = report.active
158
+ if (report.error || report.active !== pending.active) pending.reject(new Error(report.error ?? 'Preview activity failed'))
159
+ else {
160
+ if (report.active && selected(entry)) show(entry, report.revision)
161
+ pending.resolve()
162
+ }
163
+ }
164
+
165
+ const handleReady = (entry: Entry, report: Extract<PreviewReport, { type: 'ready' }>) => {
166
+ const { target } = entry
167
+ if (report.document !== target.document || report.renderer !== target.renderer) {
168
+ const current = selected(entry)
169
+ const navigated = current && report.renderer === target.renderer && workbench.state.documents?.documents.some(doc => doc.dir === report.document)
170
+ destroy(entry)
171
+ if (navigated) switchDocument(report.document)
172
+ else if (current) onStatus({ ready: false, identityFailure: true })
173
+ return
174
+ }
175
+ const reloaded = entry.ready
176
+ entry.generation++
177
+ if (reloaded) {
178
+ hide(entry)
179
+ if (entry.pending) {
180
+ const pending = entry.pending
181
+ clearTimeout(pending.timer); entry.pending = undefined
182
+ pending.reject(new DOMException('Preview reloaded', 'AbortError'))
183
+ }
184
+ }
185
+ entry.ready = true
186
+ // A new window has not acknowledged an old window's running state.
187
+ entry.active = reloaded && entry.connection.capabilities.includes('activity') ? undefined : true
188
+ entry.irRevision = report.revision
189
+ entry.problems.clear()
190
+ publishRuntimeFindings(entry, true)
191
+ clearInterval(entry.loading)
192
+ if (!selected(entry)) enqueue(() => pause(entry))
193
+ else if (!entry.pending && entry.active) {
194
+ show(entry, report.revision)
195
+ workbench.handlePreviewReport(target.document, target.renderer, report, entry.connection)
196
+ } else if (!entry.pending) {
197
+ const generation = entry.generation
198
+ enqueue(async () => {
199
+ if (!selected(entry) || entry.active || generation !== entry.generation || entries.get(entry.key) !== entry) return
200
+ try { await requestActivity(entry, true) } catch {
201
+ if (generation === entry.generation && entries.get(entry.key) === entry) destroy(entry)
202
+ }
203
+ })
204
+ }
205
+ }
206
+
207
+ const handleReport = (entry: Entry, report: PreviewReport) => {
208
+ if (entries.get(entry.key) !== entry) return
209
+ if (report.type === 'presentation-action') {
210
+ if (entry.active && selected(entry)) presentationAction?.(report.action)
211
+ return
212
+ }
213
+ if (report.type === 'activity') return handleActivity(entry, report)
214
+ if (report.type === 'ready') return handleReady(entry, report)
215
+ // A write posted just before deactivation still needs a request-scoped
216
+ // answer; the workbench decides whether it may run or must be deferred.
217
+ if (report.type === 'blocks-edited' || report.type === 'edited') return workbench.handlePreviewReport(entry.target.document, entry.target.renderer, report, entry.connection)
218
+ if (report.type === 'position' && options.presentation && entry.irRevision !== confirmedRevision) return
219
+ if (report.type === 'updated') { entry.irRevision = report.revision; confirm(entry) }
220
+ if (report.type === 'report') {
221
+ const key = JSON.stringify([report.level, report.plugin, report.message])
222
+ if (report.resolved) entry.problems.delete(key)
223
+ else entry.problems.set(key, { checkId: report.plugin ?? 'presmium.runtime', severity: report.level, message: report.message })
224
+ }
225
+ if (entry.active && (report.type === 'updated' || report.type === 'report')) publishRuntimeFindings(entry)
226
+ if (entry.active && selected(entry)) workbench.handlePreviewReport(entry.target.document, entry.target.renderer, report, entry.connection)
227
+ }
228
+
229
+ const create = (target: PreviewTarget & { address: string }): Entry => {
230
+ const frame = document.createElement('iframe')
231
+ frame.allowFullscreen = true
232
+ frame.allow = "clipboard-write 'src'"
233
+ frame.title = target.title
234
+ frame.dataset.document = target.document
235
+ frame.dataset.renderer = target.renderer
236
+ frame.dataset.previewCache = ''
237
+ const key = keyOf(target)
238
+ const connection = connectPreview(() => frame, target.address, {
239
+ report: report => handleReport(entry, report),
240
+ mismatch() {
241
+ if (selected(entry)) onStatus({ ready: false, mismatch: true })
242
+ destroy(entry)
243
+ },
244
+ }, { dark: workbench.state.isDark, presentation: options.presentation, playbackKeys: options.playbackKeys, subscribeEvents: workbench.client.subscribePreviewEvents })
245
+ const entry: Entry = { key, target, frame, connection, ready: false, active: true, generation: 0, used: ++uses, problems: new Map() }
246
+ entries.set(key, entry)
247
+ hide(entry)
248
+ // Keep the selected loading frame discoverable without exposing another document.
249
+ frame.className = 'pmw-preview__frame'
250
+ frame.src = connection.src
251
+ container.append(frame)
252
+ entry.loading = setInterval(() => {
253
+ if (!selected(entry) || entry.ready) return
254
+ clearInterval(entry.loading)
255
+ entry.irRevision ??= workbench.state.ir?.revision
256
+ entry.problems.set('handshake', { checkId: 'presmium.preview.handshake', severity: 'error', message: `Preview did not acknowledge readiness within ${READY_MS}ms (${new URL(target.address).origin}).` })
257
+ publishRuntimeFindings(entry)
258
+ onStatus({ ready: false, handshakeFailure: true })
259
+ }, READY_MS)
260
+ return entry
261
+ }
262
+
263
+ return {
264
+ confirmPresentation(revision?: string) { confirmedRevision = revision; for (const entry of entries.values()) if (entry.active) confirm(entry) },
265
+ select(target?: PreviewTarget) {
266
+ if (disposed) return
267
+ const key = target && keyOf(target)
268
+ const known = key && entries.get(key)
269
+ const address = target?.address ?? (known ? known.target.address : undefined)
270
+ const next = target ? JSON.stringify([key, address, target.reload]) : ''
271
+ const reload = target !== undefined && desired !== undefined && key === keyOf(desired) && target.reload !== desired.reload
272
+ desired = target
273
+ if (signature === next) return
274
+ signature = next
275
+ const own = ++generation
276
+ onStatus({ ready: false })
277
+ for (const entry of entries.values()) hide(entry)
278
+ enqueue(async () => {
279
+ if (own !== generation || disposed) return
280
+ let wanted = key ? entries.get(key) : undefined
281
+ if (wanted && (reload || (address && wanted.target.address !== address))) {
282
+ destroy(wanted)
283
+ wanted = undefined
284
+ }
285
+ // Reserve the selected shell before waiting for another runtime to stop.
286
+ // Its idle timer must not remove it during that asynchronous pause.
287
+ if (wanted) {
288
+ clearTimeout(wanted.expiry)
289
+ if (target) {
290
+ wanted.target = { ...target, address: wanted.target.address }
291
+ wanted.frame.title = target.title
292
+ }
293
+ }
294
+ for (const entry of [...entries.values()]) if (entry !== wanted) await pause(entry)
295
+ if (own !== generation || disposed || !target) return
296
+ if (wanted && entries.get(wanted.key) !== wanted) wanted = undefined
297
+ if (wanted?.ready) {
298
+ const readyGeneration = wanted.generation
299
+ clearTimeout(wanted.expiry)
300
+ try {
301
+ if (!wanted.active) await requestActivity(wanted, true)
302
+ if (own === generation && readyGeneration === wanted.generation) show(wanted)
303
+ return
304
+ } catch {
305
+ if (readyGeneration !== wanted.generation || entries.get(wanted.key) !== wanted) return
306
+ destroy(wanted)
307
+ }
308
+ } else if (wanted) return
309
+ if (!address || own !== generation) return
310
+ while (entries.size >= LIMIT) destroy([...entries.values()].sort((a, b) => a.used - b.used)[0]!)
311
+ create({ ...target, address })
312
+ })
313
+ },
314
+ dispose() {
315
+ disposed = true
316
+ generation++
317
+ for (const entry of [...entries.values()]) destroy(entry)
318
+ },
319
+ }
320
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { isPreviewEnvelope, PREVIEW_KEYS_PARAM, PREVIEW_PRESENTATION_PARAM, PREVIEW_EVENTS_PARAM, PREVIEW_APPEARANCE_PARAM, PREVIEW_PARAM, PREVIEW_PROTOCOL, previewEnvelope, toPreviewReport } from '@lincyaw/kernel'
2
+ import type { PreviewCommand, PreviewCommandType, PreviewReport, PreviewEventSubscription } from '@lincyaw/kernel'
3
+
4
+ export interface PreviewHandlers {
5
+ report(report: PreviewReport): void
6
+ mismatch?(version: number): void
7
+ }
8
+
9
+ export interface PreviewConnection {
10
+ readonly src: string
11
+ readonly nonce: string
12
+ readonly ready: boolean
13
+ readonly capabilities: readonly PreviewCommandType[]
14
+ /** Changes when the same iframe reloads and completes a new handshake. */
15
+ readonly generation?: number
16
+ send(command: PreviewCommand): void
17
+ dispose(): void
18
+ }
19
+
20
+ function nonce(): string {
21
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
22
+ }
23
+
24
+ /**
25
+ * `dark`: the workbench's appearance as the page loads, which its first paint takes (the `appearance` command follows it).
26
+ * `playbackKeys`: the workbench plays the page, so the page sends it Space and the left and right arrows.
27
+ */
28
+ export function connectPreview(frame: () => HTMLIFrameElement | null, address: string, handlers: PreviewHandlers, options: { dark?: boolean, presentation?: boolean, playbackKeys?: boolean, subscribeEvents?: PreviewEventSubscription } = {}): PreviewConnection {
29
+ const url = new URL(address)
30
+ const own = nonce()
31
+ url.searchParams.set(PREVIEW_PARAM, own)
32
+ if (options.dark !== undefined) url.searchParams.set(PREVIEW_APPEARANCE_PARAM, options.dark ? 'dark' : 'light')
33
+ if (options.presentation) url.searchParams.set(PREVIEW_PRESENTATION_PARAM, 'confirmed')
34
+ if (options.playbackKeys) url.searchParams.set(PREVIEW_KEYS_PARAM, 'playback')
35
+ if (options.subscribeEvents) url.searchParams.set(PREVIEW_EVENTS_PARAM, 'shared')
36
+ const boundDocument = url.searchParams.get('document') ?? ''
37
+ const readers = new Map<string, { topic: string, stop(): void }>()
38
+ let eventConnection: string | undefined
39
+ const stopEvents = () => { for (const reader of readers.values()) reader.stop(); readers.clear() }
40
+ const origin = url.origin
41
+ const latest = new Map<string, PreviewCommand>()
42
+ const replies = new Set<PreviewCommand['type']>(['edit-result', 'blocks-result', 'source-result', 'source-saved', 'source-created', 'page-created', 'playback-granted'])
43
+ const transient = new Set<PreviewCommand['type']>(['page-insert', 'flush', 'pause-playback', 'playback-granted', 'production-playback', 'document-history', 'document-history-reset', 'events-message', 'events-ready', 'edit', 'edit-result', 'blocks-result', 'source-result', 'source-saved', 'source-created', 'page-created'])
44
+ const oneShot = (command: PreviewCommand) => transient.has(command.type)
45
+ let ready = false
46
+ let generation = 0
47
+ let capabilities: readonly PreviewCommandType[] = []
48
+ const post = (command: PreviewCommand) => frame()?.contentWindow?.postMessage(previewEnvelope(own, command), origin)
49
+ const listener = (event: MessageEvent) => {
50
+ const window_ = frame()?.contentWindow
51
+ if (!window_ || event.source !== window_ || event.origin !== origin) return
52
+ if (isPreviewEnvelope(event.data, own) && event.data.version !== PREVIEW_PROTOCOL) {
53
+ handlers.mismatch?.(event.data.version)
54
+ return
55
+ }
56
+ const report = toPreviewReport(event.data, own)
57
+ if (!report) return
58
+ if (report.type === 'ready') {
59
+ stopEvents()
60
+ eventConnection = report.document === boundDocument ? report.eventConnection : undefined
61
+ if (options.subscribeEvents && eventConnection) post({ type: 'events-ready', connection: eventConnection })
62
+ generation++
63
+ ready = true
64
+ capabilities = Object.freeze([...report.capabilities])
65
+ for (const command of latest.values()) {
66
+ post(command)
67
+ if (oneShot(command)) latest.delete(command.type)
68
+ }
69
+ }
70
+ if (report.type === 'events-subscribe') {
71
+ if (!options.subscribeEvents || report.connection !== eventConnection || report.target.document !== boundDocument || readers.has(report.id) || readers.size >= 2 || [...readers.values()].some(reader => reader.topic === report.target.topic)) return
72
+ const entry = { topic: report.target.topic, stop: () => {} }
73
+ readers.set(report.id, entry)
74
+ entry.stop = options.subscribeEvents(report.target, message => {
75
+ if (eventConnection === report.connection && readers.get(report.id) === entry) post({ type: 'events-message', connection: report.connection, id: report.id, message })
76
+ })
77
+ return
78
+ }
79
+ if (report.type === 'events-unsubscribe') {
80
+ if (report.connection === eventConnection) { readers.get(report.id)?.stop(); readers.delete(report.id) }
81
+ return
82
+ }
83
+ handlers.report(report)
84
+ }
85
+ window.addEventListener('message', listener)
86
+ return {
87
+ src: url.href,
88
+ nonce: own,
89
+ get ready() {
90
+ return ready
91
+ },
92
+ get capabilities() {
93
+ return capabilities
94
+ },
95
+ get generation() { return generation },
96
+ send(command) {
97
+ if (!ready && replies.has(command.type)) return
98
+ // Queue a mutation until the first ready report, but never replay it after a reload.
99
+ if (!oneShot(command) || !ready) latest.set(command.type, command)
100
+ if (ready) post(command)
101
+ },
102
+ dispose() {
103
+ stopEvents(); eventConnection = undefined
104
+ ready = false
105
+ generation++
106
+ latest.clear()
107
+ window.removeEventListener('message', listener)
108
+ },
109
+ }
110
+ }