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