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