@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
@@ -0,0 +1,1220 @@
1
+ import type { PreviewEvents } from './preview-events.ts'
2
+ import { AUTHOR_MARKS_CLASS, PREVIEW_APPEARANCE_PARAM, PREVIEW_KEYS_PARAM, PREVIEW_NAME, PREVIEW_PARAM, previewEnvelope, toPreviewCommand } from './contracts/preview.ts'
3
+ import { keyBelongsToTarget, watchFocusSource } from './keyboard.ts'
4
+ import type { PreviewAnchor, PreviewCommand, PreviewCommandType, PreviewPin, PreviewPoint, PreviewPosition, PreviewRect, PreviewReport } from './contracts/preview.ts'
5
+ import { errorMessage } from './errors.ts'
6
+ import { sameProductionBinding } from './contracts/production.ts'
7
+ import type { ProductionBinding, ProductionPlaybackCommand, ProductionPlaybackState } from './contracts/production.ts'
8
+
9
+ /** A source-bound native text draft, owned by the page rather than its DOM adapter. */
10
+ export interface PreviewTextEdit {
11
+ target: PreviewAnchor
12
+ before: string
13
+ revision: string
14
+ binding: string
15
+ }
16
+ export interface PreviewTextHandle {
17
+ change(after: string, composing: boolean): void
18
+ finish(after: string, interrupted?: boolean): void
19
+ cancel(): void
20
+ }
21
+
22
+ /** What a web or slides page gives the preview frame: its position, navigation and DOM. */
23
+ export interface PreviewPage {
24
+ renderer: 'web' | 'slides'
25
+ events?: PreviewEvents
26
+ confirmPresentation?(revision: string): void
27
+ document: string
28
+ origins: readonly string[]
29
+ revision(): string | undefined
30
+ position(): PreviewPosition | undefined
31
+ go(command: Extract<PreviewCommand, { type: 'go' }>): void
32
+ reveal(target: PreviewAnchor): void
33
+ setDark(dark: boolean): void
34
+ setActive?(active: boolean): void | Promise<void>
35
+ activityChanged?(active: boolean): void
36
+ /** Finishes only the page's own authored edits; the host confirms its source writes separately. */
37
+ flush?(): void | Promise<void>
38
+ documentHistory?(action: 'undo' | 'redo'): boolean
39
+ resetDocumentHistory?(): void
40
+ /** Visible editor tools that the embedding workbench should leave unobscured. */
41
+ selectionTools?(): PreviewRect[]
42
+ /** Present only when the page can retain text until a request-bound source acknowledgement. */
43
+ nativeText?(edit: PreviewTextEdit): PreviewTextHandle | undefined
44
+ pausePlayback?(): void | Promise<void>
45
+ /** Installs author permission for a formal playback start; never owns a media clock. */
46
+ setPlaybackGate?(authorize: (signal: AbortSignal) => Promise<void>): () => void
47
+ productionPlayback?(command: ProductionPlaybackCommand, signal: AbortSignal): Promise<ProductionPlaybackState>
48
+ subscribeProductionPlayback?(listener: (state: ProductionPlaybackState | undefined) => void): () => void
49
+ sectionOf?(element: Element): string | undefined
50
+ sectionElement?(section: string): HTMLElement | undefined
51
+ seen?(element: Element): boolean
52
+ stepOf?(anchor: Required<PreviewAnchor>): { step: number, trail: string[] } | undefined
53
+ }
54
+
55
+ export interface PreviewProblem {
56
+ level: 'error' | 'warning'
57
+ plugin?: string
58
+ message: string
59
+ resolved?: boolean
60
+ }
61
+
62
+ /** The page's side of the preview protocol, connected to the embedding workbench. */
63
+ export interface PreviewFrame {
64
+ moved(): void
65
+ updated(): void
66
+ redraw(): void
67
+ report(problem: PreviewProblem): void
68
+ stop(): void
69
+ }
70
+
71
+ let editorReport: ((message: PreviewReport) => void) | undefined
72
+ /** Sends a typed editor operation through the authenticated connection of this preview. */
73
+ export function reportPreview(message: PreviewReport): void { editorReport?.(message) }
74
+
75
+ // A function, not a constant: bundlers keep a module-level template over an imported binding, and static builds must hold no preview code.
76
+ const framePrefix = () => `${PREVIEW_NAME}:`
77
+ const PAUSED = ':paused'
78
+ const STYLE_ID = 'presmium-preview-style'
79
+ const HIGHLIGHT = 'data-preview-highlight'
80
+ const HOVER = 'data-preview-hover'
81
+ const EDITING = 'data-preview-editing'
82
+ const POSITIONED = 'data-preview-positioned'
83
+ const PINNED = 'data-preview-pinned'
84
+ const PIN_LABELS = 'data-preview-pin-labels'
85
+ const FLASH = 'data-preview-flash'
86
+ // Three blinks of 0.8 s, the length of the animation below.
87
+ const FLASH_MS = 2400
88
+ /** How long the changed blocks' boxes keep being reported after they blink. */
89
+ const TRAIL_MS = 15000
90
+ const PIN_CLASS = 'pm-preview-pin'
91
+ const CONTROLS = 'a, button, input, select, textarea, label, summary, [role="dialog"], [role="menu"], [role="menuitem"], [role="listbox"], [role="option"]'
92
+ const HEADINGS = 'h1, h2, h3, h4, h5, h6'
93
+ // Inside an edited element: the frame's pins and a heading's anchor link, which the caret does not enter. A function for
94
+ // the same reason as `framePrefix`: a module-level template keeps this module in static builds.
95
+ const notText = () => `.${PIN_CLASS}, .header-anchor, .pm-content-marks`
96
+ const COMMANDS: PreviewCommandType[] = ['go', 'highlight', 'pick', 'pins', 'marks', 'appearance', 'edit', 'flash', 'blocks-result', 'source-result', 'source-saved', 'source-created', 'page-insert', 'page-created']
97
+
98
+ // A function for the same reason as `notText`.
99
+ const frameStyle = () => `
100
+ [${HIGHLIGHT}] { outline: var(--pm-size-stroke) solid var(--pm-color-accent) !important; outline-offset: calc(-1 * var(--pm-size-stroke)); border-radius: var(--pm-radius-control); }
101
+ [${HOVER}] { outline: var(--pm-size-hairline) solid color-mix(in srgb, var(--pm-color-accent) 50%, transparent) !important; outline-offset: calc(-1 * var(--pm-size-hairline)); border-radius: var(--pm-radius-control); cursor: pointer; }
102
+ [${EDITING}] { outline: var(--pm-size-stroke) solid var(--pm-color-accent) !important; outline-offset: calc(-1 * var(--pm-size-stroke)); border-radius: var(--pm-radius-control); cursor: text; }
103
+ [${POSITIONED}] { position: relative; }
104
+ [${PINNED}] { background: color-mix(in srgb, var(--pm-color-accent, #1d6b7a) 7%, transparent); box-shadow: inset 3px 0 0 var(--pm-color-accent, #1d6b7a); border-radius: 2px; }
105
+ @keyframes pm-preview-flash { 0%, 100% { box-shadow: none; } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--pm-color-accent, #1d6b7a) 35%, transparent), inset 0 0 0 100vmax color-mix(in srgb, var(--pm-color-accent, #1d6b7a) 16%, transparent); } }
106
+ [${FLASH}] { animation: pm-preview-flash 0.8s ease-in-out 3; border-radius: 2px; }
107
+ @media (prefers-reduced-motion: reduce) { [${FLASH}] { animation: none; box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--pm-color-accent, #1d6b7a) 12%, transparent); } }
108
+ .${PIN_CLASS} { position: absolute; top: 4px; right: 4px; z-index: 50; min-width: 20px; height: 20px; padding: 0 5px; box-sizing: border-box; border-radius: 10px; background: var(--pm-color-accent, #1d6b7a); color: #fff; font: 600 12px/20px system-ui, sans-serif; text-align: center; pointer-events: none; }
109
+ [data-presmium-editor-block][${PIN_LABELS}] { box-sizing: border-box; padding-inline-end: calc(var(--pm-size-row) + var(--pm-space-2)); }
110
+ [data-presmium-editor-block][${PIN_LABELS}]::after { content: attr(${PIN_LABELS}); position: absolute; top: var(--pm-space-1); right: var(--pm-space-1); z-index: var(--pm-z-site-header); min-width: var(--pm-size-icon-lg); max-width: var(--pm-size-row); min-height: var(--pm-size-icon-lg); padding-inline: var(--pm-space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; border-radius: var(--pm-radius-control); background: var(--pm-color-accent); color: var(--pm-color-on-accent); font-family: var(--pm-font-sans); font-size: var(--pm-text-xs); font-weight: var(--pm-weight-semibold); line-height: var(--pm-size-icon-lg); text-align: center; pointer-events: none; }
111
+ `
112
+
113
+ export function previewNonce(): string | undefined {
114
+ if (typeof window === 'undefined') return undefined
115
+ const given = new URL(window.location.href).searchParams.get(PREVIEW_PARAM)
116
+ if (given) return given
117
+ if (!window.name.startsWith(framePrefix())) return undefined
118
+ const own = window.name.slice(framePrefix().length)
119
+ return (own.endsWith(PAUSED) ? own.slice(0, -PAUSED.length) : own) || undefined
120
+ }
121
+
122
+ /** The appearance the workbench loaded the preview with, if it said. */
123
+ export function previewAppearance(): 'dark' | 'light' | undefined {
124
+ if (typeof window === 'undefined') return undefined
125
+ const given = new URL(window.location.href).searchParams.get(PREVIEW_APPEARANCE_PARAM)
126
+ return given === 'dark' || given === 'light' ? given : undefined
127
+ }
128
+
129
+ /** The Content-Security-Policy that lets only the workbench's origins embed a preview page. */
130
+ export function previewFrameAncestors(origins: readonly string[]): string {
131
+ return `frame-ancestors ${origins.length > 0 ? origins.join(' ') : "'none'"}`
132
+ }
133
+
134
+ /** Whether the page is embedded by a workbench as its preview (the frame may still be refused when no origin is allowed). */
135
+ export function isPreviewEmbedded(): boolean {
136
+ return typeof window !== 'undefined' && window.parent !== window && previewNonce() !== undefined
137
+ }
138
+
139
+ export function isPreviewActive(): boolean {
140
+ if (typeof window === 'undefined' || window.parent === window) return true
141
+ const nonce = previewNonce()
142
+ return !nonce || window.name !== `${framePrefix()}${nonce}${PAUSED}`
143
+ }
144
+
145
+ interface FrameState {
146
+ active: boolean
147
+ stopped: boolean
148
+ }
149
+
150
+ const isLive = (state: FrameState) => state.active && !state.stopped
151
+
152
+ /** Finds the sections and blocks the workbench names, among the elements a reader can see. */
153
+ function createLocator(page: PreviewPage) {
154
+ const blocks = '.pm-block[data-block], [data-presmium-editor-block][data-block]'
155
+ const seen = (element: Element): boolean =>
156
+ !element.closest('[inert], .pm-hidden') && element.getClientRects().length > 0 && (page.seen?.(element) ?? true)
157
+ const seenBlock = (element: Element): HTMLElement | undefined => {
158
+ for (let block = element.closest<HTMLElement>(blocks); block; block = block.parentElement?.closest<HTMLElement>(blocks) ?? null) {
159
+ if (seen(block)) return block
160
+ }
161
+ return undefined
162
+ }
163
+ const sectionOf = (element: Element): string | undefined =>
164
+ element.closest<HTMLElement>('[data-section]')?.dataset.section ?? page.sectionOf?.(element)
165
+ const blockElement = (section: string, block: string): HTMLElement | undefined => {
166
+ const candidates = [
167
+ ...document.querySelectorAll<HTMLElement>(`.pm-block[data-block="${CSS.escape(block)}"]`),
168
+ ...document.querySelectorAll<HTMLElement>(`[data-presmium-editor-block][data-block="${CSS.escape(block)}"]`),
169
+ ]
170
+ for (const element of candidates) {
171
+ if (sectionOf(element) === section && seen(element)) return element
172
+ }
173
+ return undefined
174
+ }
175
+ const sectionElement = (section: string): HTMLElement | undefined => {
176
+ const own = page.sectionElement?.(section)
177
+ if (own) return own
178
+ const all = [...document.querySelectorAll<HTMLElement>(`[data-section="${CSS.escape(section)}"]`)]
179
+ return all.find(el => el.classList.contains('pm-section') && seen(el)) ?? all.find(seen)
180
+ }
181
+ return {
182
+ seen,
183
+ seenBlock,
184
+ blockElement,
185
+ sectionElement,
186
+ elementOf: (target: PreviewAnchor) => (target.block ? blockElement(target.section, target.block) : sectionElement(target.section)),
187
+ anchorOf: (element: Element): PreviewAnchor | undefined => {
188
+ const block = seenBlock(element)
189
+ const section = sectionOf(block ?? element)
190
+ if (!section) return undefined
191
+ return block ? { section, block: block.dataset.block! } : { section }
192
+ },
193
+ }
194
+ }
195
+
196
+ type Locator = ReturnType<typeof createLocator>
197
+
198
+ const toRect = (box: DOMRect): PreviewRect => ({ x: box.x, y: box.y, width: box.width, height: box.height })
199
+ const isEmpty = (box: DOMRect) => box.width === 0 && box.height === 0
200
+
201
+ /** A range's bounding box, or its last line's box when the bounding box is empty. */
202
+ function rangeBox(range: Range): DOMRect | undefined {
203
+ const box = range.getBoundingClientRect()
204
+ if (!isEmpty(box)) return box
205
+ const lines = range.getClientRects()
206
+ return lines.length > 0 ? lines[lines.length - 1] : undefined
207
+ }
208
+
209
+ type Followed = { element: HTMLElement, anchor: PreviewAnchor } | { range: Range }
210
+
211
+ /**
212
+ * Follows the picked block or the selected text while the page scrolls and resizes, so the workbench can keep its toolbar
213
+ * beside it. After each scroll or resize it looks again every frame until the box stays put for two, since a page may lay
214
+ * itself out again a frame later (the slides scale to the new size).
215
+ */
216
+ function createRectReport(page: PreviewPage, state: FrameState, locate: Locator, post: (message: PreviewReport) => void) {
217
+ let followed: Followed | undefined
218
+ let sent: string | undefined
219
+ let frame = 0
220
+ let still = 0
221
+ const box = (): DOMRect | undefined => {
222
+ if (!followed) return undefined
223
+ if ('range' in followed) return rangeBox(followed.range)
224
+ const element = followed.element.isConnected ? followed.element : locate.elementOf(followed.anchor)
225
+ if (!element) return undefined
226
+ followed.element = element
227
+ return element.getBoundingClientRect()
228
+ }
229
+ const send = () => {
230
+ frame = 0
231
+ if (!followed || !isLive(state)) return
232
+ const found = box()
233
+ const shown = found && !isEmpty(found) && found.bottom > 0 && found.right > 0 && found.top < window.innerHeight && found.left < window.innerWidth
234
+ const rect = shown ? toRect(found) : null
235
+ const obstacles = page.selectionTools?.().slice(0, 16) ?? []
236
+ const key = JSON.stringify({ rect, obstacles })
237
+ if (key === sent) still++
238
+ else {
239
+ still = 0
240
+ sent = key
241
+ post({ type: 'rect', rect, obstacles })
242
+ }
243
+ if (still < 2) frame = requestAnimationFrame(send)
244
+ }
245
+ const moved = () => {
246
+ still = 0
247
+ if (followed && !frame && isLive(state)) frame = requestAnimationFrame(send)
248
+ }
249
+ window.addEventListener('scroll', moved, { capture: true, passive: true })
250
+ window.addEventListener('resize', moved, { passive: true })
251
+ const cancel = () => {
252
+ cancelAnimationFrame(frame)
253
+ frame = 0
254
+ }
255
+ return {
256
+ moved,
257
+ /** Follows a target from now on; `rect` is the box its pick or selection already reported. */
258
+ follow(next: Followed, rect: PreviewRect | undefined) {
259
+ followed = next
260
+ sent = JSON.stringify({ rect: rect ?? null, obstacles: page.selectionTools?.().slice(0, 16) ?? [] })
261
+ moved()
262
+ },
263
+ /** Stops following the selection when it is gone; a picked block stays followed. */
264
+ unselected() {
265
+ if (!followed || !('range' in followed)) return
266
+ followed = undefined
267
+ cancel()
268
+ },
269
+ cancel,
270
+ stop() {
271
+ window.removeEventListener('scroll', moved, { capture: true })
272
+ window.removeEventListener('resize', moved)
273
+ cancel()
274
+ },
275
+ }
276
+ }
277
+
278
+ type RectReport = ReturnType<typeof createRectReport>
279
+
280
+ /** Outlines the block or section the workbench points at; retries while the page brings it into view. */
281
+ function createHighlight(page: PreviewPage, state: FrameState, locate: Locator) {
282
+ let target: PreviewAnchor | null = null
283
+ let retrying: ReturnType<typeof setTimeout> | undefined
284
+ const draw = (scroll: boolean) => {
285
+ const element = target && locate.elementOf(target)
286
+ for (const other of document.querySelectorAll(`[${HIGHLIGHT}]`)) if (other !== element) other.removeAttribute(HIGHLIGHT)
287
+ if (!element) return false
288
+ element.setAttribute(HIGHLIGHT, '')
289
+ if (scroll) {
290
+ const box = element.getBoundingClientRect()
291
+ if (box.bottom < 0 || box.top > window.innerHeight) element.scrollIntoView({ block: 'center' })
292
+ }
293
+ return true
294
+ }
295
+ const show = (next: PreviewAnchor | null) => {
296
+ clearTimeout(retrying)
297
+ target = next
298
+ if (!isLive(state)) return
299
+ if (!next) return void draw(false)
300
+ if (draw(true)) return
301
+ page.reveal(next)
302
+ const until = Date.now() + 5000
303
+ const retry = () => {
304
+ retrying = undefined
305
+ if (!isLive(state) || target !== next || draw(true) || Date.now() > until) return
306
+ retrying = setTimeout(retry, 100)
307
+ }
308
+ retrying = setTimeout(retry, 50)
309
+ }
310
+ return {
311
+ draw,
312
+ show,
313
+ isSet: () => target !== null,
314
+ cancel: () => clearTimeout(retrying),
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Makes the blocks the workbench names blink (a steady tint when the reader asks for less motion), after bringing the first
320
+ * into view; retries while the page brings it there, as the highlight does.
321
+ */
322
+ function createFlash(page: PreviewPage, state: FrameState, locate: Locator, post: (message: PreviewReport) => void) {
323
+ let ending: ReturnType<typeof setTimeout> | undefined
324
+ let retrying: ReturnType<typeof setTimeout> | undefined
325
+ let frame = 0
326
+ // The changed blocks' boxes go to the workbench while they blink and for a while after, as the page scrolls, so its
327
+ // kitten can walk to them.
328
+ let trail: HTMLElement[] = [], trailUntil = 0, trailFrame = 0
329
+ const reportTrail = () => {
330
+ trailFrame = 0
331
+ if (!isLive(state) || Date.now() > trailUntil) return
332
+ const rects = trail.filter(element => element.isConnected).slice(0, 16).map(element => toRect(element.getBoundingClientRect()))
333
+ post({ type: 'flashed', rects })
334
+ }
335
+ const trailMoved = () => { if (!trailFrame && Date.now() <= trailUntil) trailFrame = requestAnimationFrame(reportTrail) }
336
+ window.addEventListener('scroll', trailMoved, { capture: true, passive: true })
337
+ window.addEventListener('resize', trailMoved, { passive: true })
338
+ const clear = () => {
339
+ clearTimeout(ending)
340
+ cancelAnimationFrame(frame)
341
+ for (const element of document.querySelectorAll(`[${FLASH}]`)) element.removeAttribute(FLASH)
342
+ }
343
+ const play = (targets: PreviewAnchor[]): boolean => {
344
+ const first = locate.elementOf(targets[0]!)
345
+ if (!first) return false
346
+ clear()
347
+ const box = first.getBoundingClientRect()
348
+ if (box.bottom < 0 || box.top > window.innerHeight) first.scrollIntoView({ block: 'center' })
349
+ const elements = targets.map(target => locate.elementOf(target)).filter((element): element is HTMLElement => Boolean(element))
350
+ // A frame after the old attributes went, so that a block still blinking starts again.
351
+ frame = requestAnimationFrame(() => {
352
+ for (const element of elements) element.setAttribute(FLASH, '')
353
+ ending = setTimeout(clear, FLASH_MS)
354
+ trail = elements; trailUntil = Date.now() + TRAIL_MS
355
+ reportTrail()
356
+ })
357
+ return true
358
+ }
359
+ return {
360
+ flash(targets: PreviewAnchor[]) {
361
+ clearTimeout(retrying)
362
+ if (!isLive(state) || targets.length === 0 || play(targets)) return
363
+ page.reveal(targets[0]!)
364
+ const until = Date.now() + 5000
365
+ const retry = () => {
366
+ retrying = undefined
367
+ if (!isLive(state) || play(targets) || Date.now() > until) return
368
+ retrying = setTimeout(retry, 100)
369
+ }
370
+ retrying = setTimeout(retry, 50)
371
+ },
372
+ cancel() {
373
+ clearTimeout(retrying)
374
+ clear()
375
+ trailUntil = 0
376
+ cancelAnimationFrame(trailFrame)
377
+ trailFrame = 0
378
+ },
379
+ }
380
+ }
381
+
382
+ /** Numbered badges on the blocks that carry the workbench's annotations. */
383
+ function createPins(locate: Locator) {
384
+ let pins: PreviewPin[] = []
385
+ const draw = () => {
386
+ const wanted = new Map<HTMLElement, PreviewPin[]>()
387
+ for (const pin of pins) {
388
+ const element = locate.blockElement(pin.section, pin.block)
389
+ if (element) wanted.set(element, [...wanted.get(element) ?? [], pin])
390
+ }
391
+ for (const old of document.querySelectorAll<HTMLElement>(`.${PIN_CLASS}`)) {
392
+ const host = old.parentElement
393
+ if (!host || !wanted.get(host)?.some(pin => pin.id === old.dataset.pin && pin.label === old.textContent)) old.remove()
394
+ }
395
+ for (const host of document.querySelectorAll<HTMLElement>(`[${POSITIONED}], [${PINNED}]`)) {
396
+ if (!wanted.has(host)) {
397
+ host.removeAttribute(POSITIONED)
398
+ host.removeAttribute(PINNED)
399
+ host.removeAttribute(PIN_LABELS)
400
+ }
401
+ }
402
+ for (const [element, own] of wanted) {
403
+ element.setAttribute(PINNED, '')
404
+ if (getComputedStyle(element).position === 'static') element.setAttribute(POSITIONED, '')
405
+ if (element.hasAttribute('data-presmium-editor-block')) {
406
+ // Child DOM belongs to BlockNote. Appending a badge there is parsed as
407
+ // author input by ProseMirror; a CSS label cannot enter the document.
408
+ element.setAttribute(PIN_LABELS, own.map(pin => pin.label).join(' · '))
409
+ continue
410
+ }
411
+ own.forEach((pin, i) => {
412
+ if ([...element.children].some(child => child.classList.contains(PIN_CLASS) && (child as HTMLElement).dataset.pin === pin.id)) return
413
+ const mark = document.createElement('span')
414
+ mark.className = PIN_CLASS
415
+ mark.dataset.pin = pin.id
416
+ mark.textContent = pin.label
417
+ mark.setAttribute('aria-hidden', 'true')
418
+ if (i > 0) mark.style.right = `${4 + 24 * i}px`
419
+ element.append(mark)
420
+ })
421
+ }
422
+ }
423
+ return {
424
+ draw,
425
+ set(next: PreviewPin[]) {
426
+ pins = next
427
+ draw()
428
+ },
429
+ isEmpty: () => pins.length === 0,
430
+ }
431
+ }
432
+
433
+ /**
434
+ * While picking, hovering outlines a block and a click reports it, where it is and where the mouse was, instead of acting on
435
+ * the page; a double click reports a second click and selects no word.
436
+ */
437
+ function createPicking(page: PreviewPage, state: FrameState, locate: Locator, rects: RectReport, post: (message: PreviewReport) => void) {
438
+ let picking = false
439
+ let hovered: Element | undefined
440
+ const pickable = (target: EventTarget | null): Element | undefined => {
441
+ if (!(target instanceof Element) || target.closest(CONTROLS)) return undefined
442
+ // A BlockNote text surface owns its editing. An opaque NodeView explicitly
443
+ // closes that boundary and can still use the source-preserving text lens.
444
+ const editable = target.closest('[contenteditable]')
445
+ return editable && editable.getAttribute('contenteditable') !== 'false' ? undefined : target
446
+ }
447
+ const clearHover = () => {
448
+ hovered?.removeAttribute(HOVER)
449
+ hovered = undefined
450
+ }
451
+ const hover = (event: MouseEvent) => {
452
+ if (!isLive(state)) return
453
+ const target = pickable(event.target)
454
+ const block = target ? locate.seenBlock(target) : undefined
455
+ if (block === hovered) return
456
+ hovered?.removeAttribute(HOVER)
457
+ hovered = block
458
+ hovered?.setAttribute(HOVER, '')
459
+ }
460
+ const press = (event: MouseEvent) => {
461
+ if (!isLive(state) || event.button !== 0 || event.detail < 2) return
462
+ const target = pickable(event.target)
463
+ if (target && locate.anchorOf(target)) event.preventDefault()
464
+ }
465
+ const click = (event: MouseEvent) => {
466
+ if (!isLive(state)) return
467
+ const content = event.target instanceof Element ? event.target.closest<HTMLElement>('[data-content-step]') : null
468
+ if (content && event.button === 0) {
469
+ event.preventDefault()
470
+ event.stopImmediatePropagation()
471
+ const section = content.dataset.contentSection, step = Number(content.dataset.contentStep)
472
+ if (section && Number.isSafeInteger(step) && step >= 0) page.go({ type: 'go', section, step })
473
+ return
474
+ }
475
+ const mark = event.target instanceof Element ? event.target.closest<HTMLElement>('[data-narration-step]') : null
476
+ if (mark && event.button === 0) {
477
+ event.preventDefault()
478
+ event.stopImmediatePropagation()
479
+ if (document.fullscreenElement) void document.exitFullscreen().catch(() => {})
480
+ post({ type: 'narrate', section: mark.dataset.narrationSection!, step: Number(mark.dataset.narrationStep) })
481
+ return
482
+ }
483
+ const target = pickable(event.target)
484
+ if (!target || event.button !== 0) return
485
+ if (event.detail < 2 && document.getSelection()?.isCollapsed === false) return
486
+ const anchor = locate.anchorOf(target)
487
+ if (!anchor) return
488
+ event.preventDefault()
489
+ event.stopImmediatePropagation()
490
+ const element = locate.seenBlock(target) ?? locate.sectionElement(anchor.section)
491
+ const box = element?.getBoundingClientRect()
492
+ const rect = box && toRect(box)
493
+ const at = anchor.block ? page.stepOf?.({ section: anchor.section, block: anchor.block }) : undefined
494
+ const clicks = Math.min(3, Math.max(1, event.detail))
495
+ post({ type: 'picked', ...anchor, ...at ? { step: at.step, trail: at.trail } : {}, ...rect ? { rect } : {}, point: { x: event.clientX, y: event.clientY }, clicks })
496
+ if (element) rects.follow({ element, anchor }, rect)
497
+ }
498
+ return {
499
+ clearHover,
500
+ set(on: boolean) {
501
+ if (on === picking) return
502
+ picking = on
503
+ document.documentElement.toggleAttribute('data-preview-picking', on)
504
+ if (on) {
505
+ document.addEventListener('mouseover', hover, true)
506
+ document.addEventListener('mousedown', press, true)
507
+ document.addEventListener('click', click, true)
508
+ return
509
+ }
510
+ document.removeEventListener('mouseover', hover, true)
511
+ document.removeEventListener('mousedown', press, true)
512
+ document.removeEventListener('click', click, true)
513
+ clearHover()
514
+ },
515
+ }
516
+ }
517
+
518
+ type Picking = ReturnType<typeof createPicking>
519
+
520
+ interface Edited {
521
+ target: PreviewAnchor
522
+ element: HTMLElement
523
+ html: string
524
+ before: string
525
+ after: string
526
+ native?: PreviewTextHandle
527
+ attributes: [string, string | null][]
528
+ closed: HTMLElement[]
529
+ changes: MutationObserver
530
+ records: MutationRecord[]
531
+ composing: boolean
532
+ finishAfterComposition?: boolean
533
+ }
534
+
535
+ /**
536
+ * The text of an edited element, without the frame's pins. Non-breaking spaces read as spaces: a browser types one for a
537
+ * space that would otherwise collapse (at the end of the text), which the source should not get.
538
+ */
539
+ function textOf(element: HTMLElement): string {
540
+ let value = ''
541
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
542
+ acceptNode: node => (node instanceof Element && node.matches(notText()) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
543
+ })
544
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node instanceof Text) value += node.data
545
+ return value.replaceAll(' ', ' ')
546
+ }
547
+
548
+ /** Where the caret goes: under the point when that lies in the element's text, else after its last character. */
549
+ function caretIn(element: HTMLElement, at: PreviewPoint | undefined): Range {
550
+ const inText = (node: Node) => element.contains(node) && !(node instanceof Element ? node : node.parentElement)?.closest(notText())
551
+ if (at) {
552
+ let range: Range | undefined
553
+ if (typeof document.caretPositionFromPoint === 'function') {
554
+ const caret = document.caretPositionFromPoint(at.x, at.y)
555
+ if (caret) {
556
+ range = document.createRange()
557
+ range.setStart(caret.offsetNode, caret.offset)
558
+ }
559
+ } else range = document.caretRangeFromPoint?.(at.x, at.y) ?? undefined
560
+ if (range && inText(range.startContainer)) return range
561
+ }
562
+ const range = document.createRange()
563
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
564
+ let last: Text | undefined
565
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) if (node instanceof Text && node.data.trim() && inText(node)) last = node
566
+ if (last) range.setStart(last, last.data.trimEnd().length)
567
+ else {
568
+ range.selectNodeContents(element)
569
+ range.collapse(false)
570
+ }
571
+ return range
572
+ }
573
+
574
+ /** Puts back what the browser changed in an element, in reverse, so the page's own nodes (those its framework holds) stay. */
575
+ function undoChanges(records: MutationRecord[]): void {
576
+ for (const record of [...records].reverse()) {
577
+ if (record.type === 'characterData') {
578
+ (record.target as CharacterData).data = record.oldValue ?? ''
579
+ continue
580
+ }
581
+ for (const node of record.addedNodes) if (node.parentNode === record.target) record.target.removeChild(node)
582
+ for (const node of record.removedNodes) record.target.insertBefore(node, record.nextSibling?.parentNode === record.target ? record.nextSibling : null)
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Edits a block's text, or a section's heading, in place, as a word processor would: only plain text, Enter keeps it and
588
+ * Escape puts the text back; the page's own keys do not see the keys typed into it.
589
+ */
590
+ function createEditing(page: PreviewPage, state: FrameState, locate: Locator, picking: Picking, post: (message: PreviewReport) => void) {
591
+ let current: Edited | undefined
592
+ const waiting = new Set<() => void>()
593
+ const changed = () => { for (const check of [...waiting]) check() }
594
+ const editable = (target: PreviewAnchor): HTMLElement | undefined => {
595
+ if (target.block) return locate.blockElement(target.section, target.block)
596
+ const section = locate.sectionElement(target.section)
597
+ return section && [...section.querySelectorAll<HTMLElement>(HEADINGS)].find(locate.seen)
598
+ }
599
+ const inside = (target: EventTarget | null) => current !== undefined && target instanceof Node && current.element.contains(target)
600
+ const keys = (event: KeyboardEvent) => {
601
+ if (!inside(event.target)) return
602
+ event.stopImmediatePropagation()
603
+ if (event.type !== 'keydown' || event.isComposing || event.keyCode === 229) return
604
+ if (event.key === 'Enter') {
605
+ event.preventDefault()
606
+ finish(true)
607
+ } else if (event.key === 'Escape') {
608
+ event.preventDefault()
609
+ finish(false)
610
+ }
611
+ }
612
+ const input = (event: InputEvent) => {
613
+ if (event.inputType.startsWith('format') || event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') event.preventDefault()
614
+ }
615
+ const capture = () => {
616
+ if (!current) return
617
+ current.after = textOf(current.element)
618
+ current.native?.change(current.after, current.composing)
619
+ }
620
+ const paste = (event: ClipboardEvent) => {
621
+ event.preventDefault()
622
+ const value = event.clipboardData?.getData('text/plain').replace(/\s*[\r\n]+\s*/g, ' ') ?? ''
623
+ if (!value || document.execCommand('insertText', false, value)) return
624
+ const range = document.getSelection()?.rangeCount ? document.getSelection()!.getRangeAt(0) : undefined
625
+ if (!range || !inside(range.startContainer)) return
626
+ range.deleteContents()
627
+ const text = document.createTextNode(value)
628
+ range.insertNode(text)
629
+ range.setStartAfter(text)
630
+ range.collapse(true)
631
+ capture()
632
+ }
633
+ const drop = (event: DragEvent) => event.preventDefault()
634
+ const leave = (event: FocusEvent) => {
635
+ if (!inside(event.relatedTarget)) finish(true)
636
+ }
637
+ const composition = (event: CompositionEvent) => {
638
+ if (!current || !inside(event.target)) return
639
+ const edited = current
640
+ edited.composing = event.type === 'compositionstart'
641
+ capture()
642
+ if (!edited.composing) queueMicrotask(() => {
643
+ if (current === edited) capture()
644
+ if (current === edited && edited.finishAfterComposition) finish(true)
645
+ else changed()
646
+ })
647
+ }
648
+ const listen = (element: HTMLElement, on: boolean) => {
649
+ const method = on ? 'addEventListener' : 'removeEventListener'
650
+ for (const type of ['keydown', 'keyup', 'keypress'] as const) window[method](type, keys as EventListener, true)
651
+ element[method]('beforeinput', input as EventListener)
652
+ element[method]('input', capture)
653
+ element[method]('paste', paste as EventListener)
654
+ element[method]('drop', drop as EventListener)
655
+ element[method]('focusout', leave as EventListener)
656
+ element[method]('compositionstart', composition as EventListener)
657
+ element[method]('compositionend', composition as EventListener)
658
+ }
659
+ const place = (element: HTMLElement, at: PreviewPoint | undefined) => {
660
+ element.focus({ preventScroll: true })
661
+ const selection = document.getSelection()
662
+ selection?.removeAllRanges()
663
+ selection?.addRange(caretIn(element, at))
664
+ }
665
+ /** Ends editing: keeps the text and reports it when it changed and `keep`, else puts the page's nodes back. */
666
+ const finish = (keep: boolean, interrupt = false) => {
667
+ const edited = current
668
+ if (!edited) return
669
+ if (keep && edited.composing && !interrupt) {
670
+ edited.finishAfterComposition = true
671
+ return
672
+ }
673
+ current = undefined
674
+ const { element, target, before } = edited
675
+ listen(element, false)
676
+ const records = [...edited.records, ...edited.changes.takeRecords()]
677
+ edited.changes.disconnect()
678
+ // Framework re-renders are not author input. The input event records the
679
+ // draft before an unrelated IR update can replace this element's text.
680
+ const after = edited.native ? edited.after : textOf(element)
681
+ element.removeAttribute(EDITING)
682
+ for (const [name, value] of edited.attributes) {
683
+ if (value === null) element.removeAttribute(name)
684
+ else element.setAttribute(name, value)
685
+ }
686
+ for (const closed of edited.closed) closed.removeAttribute('contenteditable')
687
+ if (edited.native) {
688
+ if (keep) edited.native.finish(after, interrupt && edited.composing)
689
+ else edited.native.cancel()
690
+ }
691
+ if (keep && after !== before && !edited.native) post({ type: 'edited', target, before, after })
692
+ else {
693
+ // A source-bound page retains unconfirmed text in its recovery field.
694
+ // Its rich document DOM updates only from the confirmed IR, so discarding
695
+ // a draft never has to reconstruct source formatting from plain text.
696
+ undoChanges(records)
697
+ if (element.innerHTML !== edited.html) element.innerHTML = edited.html
698
+ }
699
+ if (document.activeElement === element) element.blur()
700
+ post({ type: 'editing', target, active: false })
701
+ changed()
702
+ }
703
+ const start = (command: Extract<PreviewCommand, { type: 'edit' }> & { target: PreviewAnchor }) => {
704
+ const { target, at } = command
705
+ if (current && current.target.section === target.section && current.target.block === target.block) {
706
+ if (at) place(current.element, at)
707
+ return post({ type: 'editing', target, active: true })
708
+ }
709
+ finish(true)
710
+ if (current) return post({ type: 'editing', target, active: false })
711
+ // A binding for a newer source must never authorize edits against stale
712
+ // rendered text. Once started, unrelated source changes are checked by the
713
+ // target binding at write time rather than this document-wide revision.
714
+ if (command.revision !== undefined && command.revision !== page.revision()) return post({ type: 'editing', target, active: false })
715
+ const element = editable(target)
716
+ if (!element) return post({ type: 'editing', target, active: false })
717
+ if (element.hasAttribute('data-presmium-editor-block') && element.isContentEditable) {
718
+ // This text already has a BlockNote editor. Focus its existing cursor
719
+ // instead of installing a second native editor and a second save path.
720
+ element.closest<HTMLElement>('[contenteditable="true"]')?.focus({ preventScroll: true })
721
+ const selection = document.getSelection()
722
+ selection?.removeAllRanges()
723
+ selection?.addRange(caretIn(element, at))
724
+ return post({ type: 'editing', target, active: false })
725
+ }
726
+ picking.clearHover()
727
+ const html = element.innerHTML
728
+ const before = textOf(element)
729
+ const bound = page.nativeText && command.revision !== undefined && command.binding !== undefined
730
+ const native = bound ? page.nativeText!({ target, before, revision: command.revision!, binding: command.binding! }) : undefined
731
+ if (bound && !native) return post({ type: 'editing', target, active: false })
732
+ const closed = [...element.querySelectorAll<HTMLElement>(notText())].filter(child => !child.hasAttribute('contenteditable'))
733
+ for (const child of closed) child.setAttribute('contenteditable', 'false')
734
+ const records: MutationRecord[] = []
735
+ const changes = new MutationObserver(found => void records.push(...found))
736
+ current = {
737
+ target,
738
+ element,
739
+ html,
740
+ before,
741
+ after: before,
742
+ native,
743
+ attributes: ['contenteditable', 'spellcheck'].map(name => [name, element.getAttribute(name)]),
744
+ closed,
745
+ changes,
746
+ records,
747
+ composing: false,
748
+ }
749
+ element.setAttribute('contenteditable', 'true')
750
+ element.setAttribute('spellcheck', 'false')
751
+ element.setAttribute(EDITING, '')
752
+ changes.observe(element, { childList: true, characterData: true, characterDataOldValue: true, subtree: true })
753
+ listen(element, true)
754
+ place(element, at)
755
+ post({ type: 'editing', target, active: true })
756
+ }
757
+ return {
758
+ isEditing: () => current !== undefined,
759
+ flush(): Promise<void> {
760
+ return new Promise((resolve, reject) => {
761
+ let done = false
762
+ const finishWait = (error?: string) => {
763
+ if (done) return
764
+ done = true
765
+ clearTimeout(timeout)
766
+ waiting.delete(check)
767
+ if (error) reject(new Error(error))
768
+ else resolve()
769
+ }
770
+ const check = () => {
771
+ if (!isLive(state)) return finishWait('preview-flush-unavailable')
772
+ if (current?.composing) return
773
+ finish(true)
774
+ finishWait()
775
+ }
776
+ const timeout = setTimeout(() => finishWait('save-timeout'), 30_000)
777
+ waiting.add(check)
778
+ check()
779
+ })
780
+ },
781
+ edit(command: Extract<PreviewCommand, { type: 'edit' }>) {
782
+ if (!isLive(state)) return
783
+ if (command.target) start({ ...command, target: command.target })
784
+ else finish(false)
785
+ },
786
+ /** Keeps an edit whose element the page took away. */
787
+ redraw() {
788
+ if (current && !current.element.isConnected) finish(true)
789
+ },
790
+ cancel: () => finish(false),
791
+ preserve: () => finish(true, true),
792
+ }
793
+ }
794
+
795
+ /** Reports the text the reader selects, with the block or section it lies in and where it is; not the text being edited. */
796
+ function watchSelection(page: PreviewPage, state: FrameState, locate: Locator, rects: RectReport, isEditing: () => boolean, post: (message: PreviewReport) => void) {
797
+ let selected = false
798
+ let settling: ReturnType<typeof setTimeout> | undefined
799
+ const changed = () => {
800
+ clearTimeout(settling)
801
+ if (!isLive(state) || isEditing()) return
802
+ settling = setTimeout(() => {
803
+ if (!isLive(state) || isEditing()) return
804
+ const range = document.getSelection()
805
+ const value = range && !range.isCollapsed ? range.toString().trim() : ''
806
+ const node = range?.rangeCount ? range.getRangeAt(0).commonAncestorContainer : undefined
807
+ const element = node instanceof Element ? node : node?.parentElement
808
+ const selectionRange = range?.rangeCount ? range.getRangeAt(0) : undefined
809
+ const editor = element?.closest('[contenteditable="true"]')
810
+ const pending = Boolean(selectionRange && [...document.querySelectorAll('[data-comment-pending]')].some(node => selectionRange.intersectsNode(node)))
811
+ const source = value && element && !element.closest('input, textarea') && !pending
812
+ && (!editor || element.closest('[data-presmium-editor-block]')) ? element : undefined
813
+ const anchor = source ? locate.anchorOf(source) : undefined
814
+ if (anchor) {
815
+ selected = true
816
+ const own = range!.getRangeAt(0).cloneRange()
817
+ const box = rangeBox(own)
818
+ const rect = box && toRect(box)
819
+ post({ type: 'selection', selection: { ...anchor, text: value.slice(0, 4000), ...rect ? { rect } : {}, obstacles: page.selectionTools?.().slice(0, 16) ?? [] } })
820
+ rects.follow({ range: own }, rect)
821
+ } else if (selected) {
822
+ selected = false
823
+ post({ type: 'selection', selection: null })
824
+ rects.unselected()
825
+ }
826
+ }, 200)
827
+ }
828
+ document.addEventListener('selectionchange', changed)
829
+ return {
830
+ cancel: () => clearTimeout(settling),
831
+ stop() {
832
+ document.removeEventListener('selectionchange', changed)
833
+ clearTimeout(settling)
834
+ },
835
+ }
836
+ }
837
+
838
+ /** Sends `ready` once, then the page's position whenever it changes, at most every 150 ms. */
839
+ function createPositionReport(page: PreviewPage, state: FrameState, post: (message: PreviewReport) => void, capabilities: PreviewCommandType[]) {
840
+ let ready = false
841
+ let revision: string | undefined
842
+ let sent: string | undefined
843
+ let last = 0
844
+ let timer: ReturnType<typeof setTimeout> | undefined
845
+ const waiting: Extract<PreviewReport, { type: 'report' }>[] = []
846
+ const send = () => {
847
+ timer = undefined
848
+ if (state.stopped) return
849
+ if (!ready) {
850
+ ready = true
851
+ revision = page.revision()
852
+ post({ type: 'ready', renderer: page.renderer, document: page.document, ...revision ? { revision } : {}, capabilities, ...page.events ? { eventConnection: page.events.connection } : {} })
853
+ page.activityChanged?.(state.active)
854
+ }
855
+ if (!state.active) return
856
+ for (const problem of waiting.splice(0)) post(problem)
857
+ const position = page.position()
858
+ if (!position) return
859
+ const key = JSON.stringify(position)
860
+ if (key === sent) return
861
+ sent = key
862
+ last = Date.now()
863
+ post({ type: 'position', ...position })
864
+ }
865
+ return {
866
+ flush() { clearTimeout(timer); send() },
867
+ moved() {
868
+ if (state.stopped || timer || (!state.active && ready)) return
869
+ timer = setTimeout(send, Math.max(0, 150 - (Date.now() - last)))
870
+ },
871
+ /** Returns whether the revision changed since the last report. */
872
+ updated(): boolean {
873
+ const next = page.revision()
874
+ if (!ready || next === revision) return false
875
+ revision = next
876
+ post({ type: 'updated', ...next ? { revision: next } : {} })
877
+ return true
878
+ },
879
+ report(problem: PreviewProblem) {
880
+ const message = { type: 'report' as const, ...problem }
881
+ if (ready && state.active) post(message)
882
+ else waiting.push(message)
883
+ },
884
+ resend() {
885
+ sent = undefined
886
+ last = 0
887
+ },
888
+ cancel() {
889
+ clearTimeout(timer)
890
+ timer = undefined
891
+ },
892
+ }
893
+ }
894
+
895
+ function installStyle(): void {
896
+ if (document.getElementById(STYLE_ID)) return
897
+ const style = document.createElement('style')
898
+ style.id = STYLE_ID
899
+ style.textContent = frameStyle()
900
+ document.head.append(style)
901
+ }
902
+
903
+ /** Connects a page embedded by the workbench to the preview protocol; undefined when the page is not embedded. */
904
+ export function connectPreviewFrame(page: PreviewPage): PreviewFrame | undefined {
905
+ if (typeof window === 'undefined' || window.parent === window || page.origins.length === 0) return undefined
906
+ const nonce = previewNonce()
907
+ if (!nonce) return undefined
908
+ const state: FrameState = { active: isPreviewActive(), stopped: false }
909
+ const playbackSupported = Boolean(page.pausePlayback && page.setPlaybackGate)
910
+ const capabilities: PreviewCommandType[] = [...COMMANDS, ...page.confirmPresentation ? ['presentation-confirmed' as const] : [], ...page.nativeText ? ['edit-result' as const] : [], ...page.setActive ? ['activity' as const] : [], ...page.flush ? ['flush' as const] : [], ...page.documentHistory ? ['document-history' as const] : [], ...playbackSupported ? ['pause-playback' as const, 'playback-granted' as const] : [], ...page.productionPlayback && page.subscribeProductionPlayback ? ['production-playback' as const] : []]
911
+ const nameWindow = (active: boolean) => {
912
+ window.name = `${framePrefix()}${nonce}${active ? '' : PAUSED}`
913
+ }
914
+ nameWindow(state.active)
915
+ const root = document.documentElement
916
+ root.dataset.presmiumPreview = 'on'
917
+ installStyle()
918
+
919
+ const post = (message: PreviewReport) => {
920
+ if (state.stopped || (!state.active && message.type !== 'ready' && message.type !== 'activity' && message.type !== 'playback-paused' && message.type !== 'production-playback-result')) return
921
+ for (const origin of page.origins) window.parent.postMessage(previewEnvelope(nonce, message), origin)
922
+ }
923
+ editorReport = post
924
+ // Stored drafts must wait until the parent's first ready handshake, and an
925
+ // inactive retained frame must not begin another author write.
926
+ page.activityChanged?.(false)
927
+
928
+ // Requests are one-shot and cancelled on pause/unmount; a late grant cannot start a newer intent.
929
+ let playbackRequest = Date.now()
930
+ const pendingPlayback = new Map<number, (error?: Error) => void>()
931
+ const cancelPlayback = () => {
932
+ for (const finish of [...pendingPlayback.values()]) finish(new DOMException('Playback cancelled', 'AbortError'))
933
+ }
934
+ const authorizePlayback = (signal: AbortSignal): Promise<void> => {
935
+ if (!isLive(state) || signal.aborted) return Promise.reject(new DOMException('Playback cancelled', 'AbortError'))
936
+ const request = ++playbackRequest
937
+ return new Promise<void>((resolve, reject) => {
938
+ const abort = () => finish(new DOMException('Playback cancelled', 'AbortError'))
939
+ const finish = (error?: Error) => {
940
+ if (!pendingPlayback.delete(request)) return
941
+ clearTimeout(timeout)
942
+ signal.removeEventListener('abort', abort)
943
+ error ? reject(error) : resolve()
944
+ }
945
+ const timeout = setTimeout(() => finish(new Error('playback-timeout')), 15_000)
946
+ pendingPlayback.set(request, finish)
947
+ signal.addEventListener('abort', abort, { once: true })
948
+ post({ type: 'playback-request', request })
949
+ })
950
+ }
951
+ // An older parent does not understand playback requests. Opt in only when a
952
+ // parent first pauses formal playback before starting its own audition.
953
+ let stopPlaybackGate: (() => void) | undefined
954
+ const enablePlaybackGate = () => {
955
+ if (playbackSupported && !stopPlaybackGate) stopPlaybackGate = page.setPlaybackGate!(authorizePlayback)
956
+ }
957
+
958
+ let productionRequest = -1
959
+ let pendingProduction: { request: number, binding: ProductionBinding, controller: AbortController } | undefined
960
+ const cancelProduction = () => {
961
+ const pending = pendingProduction
962
+ if (!pending) return
963
+ pendingProduction = undefined; pending.controller.abort()
964
+ post({ type: 'production-playback-result', request: pending.request, binding: pending.binding, error: 'production-playback-cancelled' })
965
+ }
966
+ let productionSent: ProductionPlaybackState | undefined, productionAt = 0
967
+ const stopProduction = page.subscribeProductionPlayback?.(next => {
968
+ if (!next || !isLive(state) || page.revision() !== next.binding.irRevision) return
969
+ const previous = productionSent, before = previous?.position, position = next.position
970
+ const urgent = !previous || !sameProductionBinding(previous.binding, next.binding) || previous.status !== next.status || previous.error !== next.error
971
+ || !position?.playing || before?.playing !== position.playing || before?.sectionId !== position.sectionId || before?.stepId !== position.stepId || before?.rate !== position.rate || before?.trying !== position.trying
972
+ const now = performance.now()
973
+ if (!urgent && now - productionAt < 100) return
974
+ productionSent = next; productionAt = now
975
+ post({ type: 'production-playback-state', state: next })
976
+ })
977
+ const productionControl = (command: Extract<PreviewCommand, { type: 'production-playback' }>) => {
978
+ if (command.request <= productionRequest) return post({ type: 'production-playback-result', request: command.request, binding: command.control.binding, error: 'production-request-stale' })
979
+ productionRequest = command.request; cancelProduction()
980
+ if (!isLive(state) || !page.productionPlayback) return post({ type: 'production-playback-result', request: command.request, binding: command.control.binding, error: 'production-playback-unavailable' })
981
+ if (page.revision() !== command.control.binding.irRevision) return post({ type: 'production-playback-result', request: command.request, binding: command.control.binding, error: 'production-binding-stale' })
982
+ enablePlaybackGate()
983
+ const pending = { request: command.request, binding: command.control.binding, controller: new AbortController() }
984
+ pendingProduction = pending
985
+ void page.productionPlayback(command.control, pending.controller.signal).then(answer => {
986
+ if (pendingProduction !== pending || pending.controller.signal.aborted) return
987
+ pendingProduction = undefined
988
+ if (!isLive(state) || page.revision() !== pending.binding.irRevision || !sameProductionBinding(answer.binding, pending.binding)) return post({ type: 'production-playback-result', request: pending.request, binding: pending.binding, error: 'production-binding-stale' })
989
+ position.flush()
990
+ post({ type: 'production-playback-result', request: pending.request, binding: pending.binding, state: answer })
991
+ }, error => {
992
+ if (pendingProduction !== pending || pending.controller.signal.aborted) return
993
+ pendingProduction = undefined
994
+ const code = error instanceof Error && /^[a-z][a-z0-9-]{0,99}$/.test(error.message) ? error.message : 'production-playback-failed'
995
+ post({ type: 'production-playback-result', request: pending.request, binding: pending.binding, error: code })
996
+ })
997
+ }
998
+ window.addEventListener('pagehide', cancelProduction)
999
+
1000
+ // Undo, redo and Escape belong to the workbench, except where the page's own fields take typing.
1001
+ const shortcut = (event: KeyboardEvent) => {
1002
+ if (!isLive(state)) return
1003
+ if (event.defaultPrevented || event.isComposing || event.altKey) return
1004
+ if (event.target instanceof Element && event.target.closest('input, textarea, select, [contenteditable]')) return
1005
+ if (event.key === 'Escape' && !event.ctrlKey && !event.metaKey) return post({ type: 'shortcut', command: 'dismiss' })
1006
+ if (!(event.ctrlKey || event.metaKey)) return
1007
+ const key = event.key.toLowerCase()
1008
+ if (key !== 'z' && key !== 'y') return
1009
+ event.preventDefault()
1010
+ post({ type: 'shortcut', command: key === 'y' || event.shiftKey ? 'redo' : 'undo' })
1011
+ }
1012
+ window.addEventListener('keydown', shortcut)
1013
+ // Saving belongs to the workbench everywhere, fields included, so the browser never offers to save the page itself.
1014
+ const saveShortcut = (event: KeyboardEvent) => {
1015
+ if (!isLive(state) || event.isComposing || !(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 's') return
1016
+ event.preventDefault()
1017
+ if (!event.repeat) post({ type: 'shortcut', command: 'save' })
1018
+ }
1019
+ window.addEventListener('keydown', saveShortcut, true)
1020
+ // A workbench that plays this page also owns its playback keys. Listening on the document lets the page's own controls
1021
+ // keep their keys first, and stops the press before the viewer's window listener turns Space into the next step.
1022
+ const playbackShortcut = (event: KeyboardEvent) => {
1023
+ const command = event.key === ' ' ? 'play-pause' : event.key === 'ArrowLeft' ? 'previous' : event.key === 'ArrowRight' ? 'next' : undefined
1024
+ if (!command || !isLive(state) || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || keyBelongsToTarget(event)) return
1025
+ event.preventDefault(); event.stopPropagation()
1026
+ if (!(event.repeat && command === 'play-pause')) post({ type: 'shortcut', command })
1027
+ }
1028
+ if (new URL(window.location.href).searchParams.get(PREVIEW_KEYS_PARAM) === 'playback') {
1029
+ watchFocusSource(document)
1030
+ document.addEventListener('keydown', playbackShortcut)
1031
+ }
1032
+
1033
+ const locate = createLocator(page)
1034
+ const highlight = createHighlight(page, state, locate)
1035
+ const pins = createPins(locate)
1036
+ const flash = createFlash(page, state, locate, post)
1037
+ const rects = createRectReport(page, state, locate, post)
1038
+ const picking = createPicking(page, state, locate, rects, post)
1039
+ const editing = createEditing(page, state, locate, picking, post)
1040
+ window.addEventListener('pagehide', editing.preserve)
1041
+ const position = createPositionReport(page, state, post, capabilities)
1042
+ const selection = watchSelection(page, state, locate, rects, editing.isEditing, post)
1043
+
1044
+ let frame = 0
1045
+ const redraw = () => {
1046
+ if (!isLive(state)) return
1047
+ cancelAnimationFrame(frame)
1048
+ frame = requestAnimationFrame(() => {
1049
+ frame = 0
1050
+ if (!isLive(state)) return
1051
+ highlight.draw(false)
1052
+ pins.draw()
1053
+ editing.redraw()
1054
+ rects.moved()
1055
+ })
1056
+ }
1057
+
1058
+ const observer = new MutationObserver(() => {
1059
+ if (highlight.isSet() || !pins.isEmpty()) redraw()
1060
+ })
1061
+ const observeBody = () => observer.observe(document.body, { childList: true, subtree: true })
1062
+ if (state.active) observeBody()
1063
+
1064
+ let activity = Promise.resolve()
1065
+ const pause = () => {
1066
+ cancelProduction()
1067
+ cancelPlayback()
1068
+ editing.preserve()
1069
+ state.active = false
1070
+ page.activityChanged?.(false)
1071
+ nameWindow(false)
1072
+ position.cancel()
1073
+ selection.cancel()
1074
+ highlight.cancel()
1075
+ flash.cancel()
1076
+ cancelAnimationFrame(frame)
1077
+ frame = 0
1078
+ observer.disconnect()
1079
+ picking.clearHover()
1080
+ rects.cancel()
1081
+ }
1082
+ const changeActivity = (command: Extract<PreviewCommand, { type: 'activity' }>) => {
1083
+ if (!page.setActive) return
1084
+ activity = activity.then(async () => {
1085
+ if (state.stopped) return
1086
+ pause()
1087
+ try {
1088
+ await page.setActive!(command.active)
1089
+ if (state.stopped) return
1090
+ state.active = command.active
1091
+ nameWindow(state.active)
1092
+ const current = page.revision()
1093
+ post({ type: 'activity', active: state.active, request: command.request, ...current === undefined ? {} : { revision: current } })
1094
+ if (state.active) {
1095
+ page.activityChanged?.(true)
1096
+ observeBody()
1097
+ position.resend()
1098
+ position.moved()
1099
+ redraw()
1100
+ }
1101
+ } catch (error) {
1102
+ state.active = false
1103
+ nameWindow(false)
1104
+ post({ type: 'activity', active: false, request: command.request, error: errorMessage(error) })
1105
+ }
1106
+ }).catch((error) => {
1107
+ console.error(`[presmium] the preview activity could not be reported: ${errorMessage(error)}`)
1108
+ })
1109
+ }
1110
+
1111
+ let flushing = Promise.resolve()
1112
+ const flush = (command: Extract<PreviewCommand, { type: 'flush' }>) => {
1113
+ flushing = flushing.then(async () => {
1114
+ if (!isLive(state) || !page.flush) throw new Error('preview-flush-unavailable')
1115
+ await editing.flush()
1116
+ if (!isLive(state)) throw new Error('preview-flush-unavailable')
1117
+ await page.flush()
1118
+ if (!isLive(state)) throw new Error('preview-flush-unavailable')
1119
+ position.flush()
1120
+ post({ type: 'flushed', request: command.request })
1121
+ }).catch(error => {
1122
+ post({ type: 'flushed', request: command.request, error: errorMessage(error) })
1123
+ })
1124
+ }
1125
+
1126
+ const receive = (event: MessageEvent) => {
1127
+ if (event.source !== window.parent || !page.origins.includes(event.origin)) return
1128
+ const command = toPreviewCommand(event.data, nonce)
1129
+ if (!command) return
1130
+ try {
1131
+ switch (command.type) {
1132
+ case 'presentation-confirmed':
1133
+ if (isLive(state) && command.revision === page.revision()) page.confirmPresentation?.(command.revision)
1134
+ return
1135
+ case 'events-ready': case 'events-message': return page.events?.receive(command)
1136
+ case 'production-playback': return productionControl(command)
1137
+ case 'blocks-result': return void window.dispatchEvent(new CustomEvent('presmium:blocks-result', { detail: command }))
1138
+ case 'edit-result': return void window.dispatchEvent(new CustomEvent('presmium:edit-result', { detail: command }))
1139
+ case 'source-result': return void window.dispatchEvent(new CustomEvent('presmium:source-result', { detail: command }))
1140
+ case 'source-saved': return void window.dispatchEvent(new CustomEvent('presmium:source-saved', { detail: command }))
1141
+ case 'source-created': return void window.dispatchEvent(new CustomEvent('presmium:source-created', { detail: command }))
1142
+ case 'page-insert': return void window.dispatchEvent(new CustomEvent('presmium:page-insert', { detail: command }))
1143
+ case 'page-created': return void window.dispatchEvent(new CustomEvent('presmium:page-created', { detail: command }))
1144
+ case 'go': cancelProduction(); return page.go(command)
1145
+ case 'highlight': return highlight.show(command.target)
1146
+ case 'pick': return picking.set(command.on)
1147
+ case 'pins': return pins.set(command.targets)
1148
+ case 'marks': return void root.classList.toggle(AUTHOR_MARKS_CLASS, command.on)
1149
+ case 'appearance': return page.setDark(command.dark)
1150
+ case 'activity': return changeActivity(command)
1151
+ case 'pause-playback':
1152
+ if (!playbackSupported) return
1153
+ cancelProduction()
1154
+ enablePlaybackGate()
1155
+ cancelPlayback()
1156
+ return void Promise.resolve().then(() => page.pausePlayback!()).then(() => {
1157
+ position.flush()
1158
+ post({ type: 'playback-paused', request: command.request })
1159
+ }, error => post({ type: 'playback-paused', request: command.request, error: errorMessage(error) }))
1160
+ case 'playback-granted':
1161
+ if (isLive(state)) pendingPlayback.get(command.request)?.()
1162
+ return
1163
+ case 'flush': return flush(command)
1164
+ case 'document-history':
1165
+ try { post({ type: 'document-history-result', request: command.request, handled: Boolean(page.documentHistory?.(command.action)) }) }
1166
+ catch { post({ type: 'document-history-result', request: command.request, handled: false, error: 'document-history-failed' }) }
1167
+ return
1168
+ case 'document-history-reset': return page.resetDocumentHistory?.()
1169
+ case 'edit': return editing.edit(command)
1170
+ case 'flash': return flash.flash(command.targets)
1171
+ }
1172
+ } catch (error) {
1173
+ console.error(`[presmium] the preview could not take ${command.type}: ${errorMessage(error)}`)
1174
+ }
1175
+ }
1176
+ window.addEventListener('message', receive)
1177
+
1178
+ position.moved()
1179
+
1180
+ return {
1181
+ moved: position.moved,
1182
+ updated() {
1183
+ if (!isLive(state)) return
1184
+ if (position.updated()) {
1185
+ if (pendingProduction && pendingProduction.binding.irRevision !== page.revision()) cancelProduction()
1186
+ redraw()
1187
+ }
1188
+ },
1189
+ redraw,
1190
+ report(problem) {
1191
+ if (state.stopped) return
1192
+ position.report(problem)
1193
+ },
1194
+ stop() {
1195
+ page.events?.stop()
1196
+ cancelProduction(); stopProduction?.()
1197
+ cancelPlayback()
1198
+ stopPlaybackGate?.()
1199
+ editing.preserve()
1200
+ window.removeEventListener('pagehide', editing.preserve)
1201
+ window.removeEventListener('pagehide', cancelProduction)
1202
+ if (editorReport === post) editorReport = undefined
1203
+ state.stopped = true
1204
+ page.activityChanged?.(false)
1205
+ window.removeEventListener('message', receive)
1206
+ window.removeEventListener('keydown', shortcut)
1207
+ window.removeEventListener('keydown', saveShortcut, true)
1208
+ document.removeEventListener('keydown', playbackShortcut)
1209
+ selection.stop()
1210
+ rects.stop()
1211
+ observer.disconnect()
1212
+ picking.set(false)
1213
+ position.cancel()
1214
+ highlight.cancel()
1215
+ flash.cancel()
1216
+ cancelAnimationFrame(frame)
1217
+ delete root.dataset.presmiumPreview
1218
+ },
1219
+ }
1220
+ }