@lincyaw/kernel 1.0.0-rc.13 → 1.0.0-rc.14

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