@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.
- package/LICENSE +202 -0
- package/README.md +66 -2
- package/package.json +68 -3
- 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 +631 -0
- 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 +36 -0
- 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 +781 -0
- 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 +462 -0
- 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 +102 -0
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +110 -0
- 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 +1025 -0
- 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
|
@@ -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
|
+
}
|