@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1220 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. package/src/voice.ts +86 -0
@@ -1,4 +1,5 @@
1
- import { errorMessage } from '../errors.ts'
1
+ import { isRecord } from '../errors.ts'
2
+ import type { ProductionBinding, ProductionPlaybackCommand, ProductionPlaybackState } from './production.ts'
2
3
 
3
4
  export const PREVIEW_PROTOCOL = 1
4
5
 
@@ -6,12 +7,60 @@ export const PREVIEW_NAME = 'presmium-preview'
6
7
 
7
8
  export const PREVIEW_PARAM = 'presmium-preview'
8
9
 
10
+ /** The workbench's appearance when it loads a preview (`dark` or `light`), so that the page's first paint matches it. */
11
+ export const PREVIEW_APPEARANCE_PARAM = 'presmium-appearance'
12
+
9
13
  export const PREVIEW_REVISION = 'presmiumRevision'
10
14
 
11
15
  export const AUTHOR_MARKS_CLASS = 'pm-author-marks'
12
16
 
13
- export function previewFrameAncestors(origins: readonly string[]): string {
14
- return `frame-ancestors ${origins.length > 0 ? origins.join(' ') : "'none'"}`
17
+ /** Read-only, version-bound control of an individual document widget. */
18
+ export interface BlockPreviewCommand {
19
+ type: 'presmium:block-preview-command'
20
+ document: string
21
+ blockId: string
22
+ revision: string
23
+ sectionId: string
24
+ stepId: string
25
+ action: 'seek' | 'pause' | 'sync'
26
+ /** The parent's current navigation intent, including repeat visits to the same step. */
27
+ navigation?: string
28
+ /** Omitted and document initial/reload seeks show the completed step; start is an explicit playback boundary. */
29
+ at?: 'start' | 'end'
30
+ }
31
+
32
+ export interface BlockPreviewPosition {
33
+ type: 'presmium:block-preview-position'
34
+ document: string
35
+ blockId: string
36
+ revision: string
37
+ sectionId: string
38
+ stepId: string
39
+ navigation: string
40
+ }
41
+
42
+ export interface BlockPreviewPlaybackIdentity {
43
+ document: string
44
+ blockId: string
45
+ revision: string
46
+ request: number
47
+ }
48
+
49
+ export interface BlockPreviewPlaybackRequest extends BlockPreviewPlaybackIdentity {
50
+ type: 'presmium:block-preview-playback-request'
51
+ sectionId: string
52
+ stepId: string
53
+ navigation: string
54
+ }
55
+
56
+ export interface BlockPreviewPlaybackGranted extends BlockPreviewPlaybackIdentity {
57
+ type: 'presmium:block-preview-playback-granted'
58
+ error?: string
59
+ navigation: string
60
+ }
61
+
62
+ export interface BlockPreviewPlaybackCancelled extends BlockPreviewPlaybackIdentity {
63
+ type: 'presmium:block-preview-playback-cancelled'
15
64
  }
16
65
 
17
66
  export interface PreviewAnchor {
@@ -19,6 +68,20 @@ export interface PreviewAnchor {
19
68
  block?: string
20
69
  }
21
70
 
71
+ /** A box in the preview page's viewport, in CSS pixels. */
72
+ export interface PreviewRect {
73
+ x: number
74
+ y: number
75
+ width: number
76
+ height: number
77
+ }
78
+
79
+ /** A point in the preview page's viewport, in CSS pixels. */
80
+ export interface PreviewPoint {
81
+ x: number
82
+ y: number
83
+ }
84
+
22
85
  export interface PreviewPin {
23
86
  id: string
24
87
  section: string
@@ -33,359 +96,291 @@ export interface PreviewPosition {
33
96
  view?: string
34
97
  }
35
98
 
99
+ /** Source-oriented edits from the block editor. Opaque blocks retain their source verbatim. */
100
+ export interface PreviewEditedBlock {
101
+ id: string
102
+ source?: string
103
+ at?: number
104
+ markdown?: string
105
+ scene?: string
106
+ durations?: number[]
107
+ }
108
+
109
+ /** The source of one anchored block, read by the workbench from its current IR binding. */
110
+ export interface PreviewSource {
111
+ part: 'definition' | 'block'
112
+ path: string
113
+ language: string
114
+ content: string
115
+ version: string
116
+ shared: number
117
+ }
118
+
119
+ /** Fixed live-viewer channels; no arbitrary upstream URL crosses the preview boundary. */
120
+ export const PREVIEW_PRESENTATION_PARAM = 'presmium-presentation'
121
+ /** `playback`: the workbench plays this page, so Space and the left and right arrows pressed in it go to the workbench. */
122
+ export const PREVIEW_KEYS_PARAM = 'presmium-keys'
123
+ export const PREVIEW_EVENTS_PARAM = 'presmium-events'
124
+ export type PreviewEventTarget =
125
+ | { topic: 'viewer-site', document: string, app: string, lang: string, form: 'web' | 'slides' }
126
+ | { topic: 'viewer-control', document: string, window: string }
127
+ export type PreviewEventMessage = { type: 'open' | 'error' } | { type: 'event', event: string, data: string }
128
+ export type PreviewEventSubscription = (target: PreviewEventTarget, received: (message: PreviewEventMessage) => void) => () => void
129
+
130
+ export function isPreviewEventTarget(value: unknown): value is PreviewEventTarget {
131
+ if (!isRecord(value) || !(typeof value.document === 'string' && value.document.length <= 1024)) return false
132
+ if (value.topic === 'viewer-site') return Object.keys(value).every(key => ['topic', 'document', 'app', 'lang', 'form'].includes(key))
133
+ && bounded(value.app, 200) && (typeof value.lang === 'string' && value.lang.length <= 80) && (value.form === 'web' || value.form === 'slides')
134
+ return value.topic === 'viewer-control' && Object.keys(value).every(key => ['topic', 'document', 'window'].includes(key))
135
+ && typeof value.window === 'string' && /^[a-zA-Z0-9-]{1,80}$/.test(value.window)
136
+ }
137
+ const eventMessage = (value: unknown): value is PreviewEventMessage => isRecord(value) && (value.type === 'open' || value.type === 'error'
138
+ || value.type === 'event' && bounded(value.event, 40) && (typeof value.data === 'string' && value.data.length <= (2 << 20)))
139
+
36
140
  export type PreviewCommand =
141
+ | { type: 'presentation-confirmed', revision: string }
142
+ | { type: 'events-ready', connection: string }
143
+ | { type: 'events-message', connection: string, id: string, message: PreviewEventMessage }
144
+ | { type: 'production-playback', request: number, control: ProductionPlaybackCommand }
145
+ | { type: 'document-history', action: 'undo' | 'redo', request: number }
146
+ | { type: 'document-history-reset' }
147
+ | { type: 'pause-playback', request: number }
148
+ | { type: 'playback-granted', request: number }
149
+ | { type: 'flush', request: number }
150
+ | { type: 'edit-result', target: PreviewAnchor, request: number, revision?: string, binding?: string, error?: string }
151
+ | { type: 'blocks-result', section: string, request: number, revision?: string, error?: string }
152
+ | { type: 'source-result', section: string, block: string, request: number, source?: PreviewSource, error?: string }
153
+ | { type: 'source-saved', section: string, block: string, request: number, sourceVersion?: string, error?: string }
154
+ | { type: 'source-created', section: string, request: number, block?: string, error?: string }
155
+ | { type: 'page-insert', section: string, id: string, request: number }
156
+ | { type: 'page-created', section: string, request: number, page?: string, error?: string }
37
157
  | { type: 'go', section: string, step?: number, trail?: string[], view?: string }
38
158
  | { type: 'highlight', target: PreviewAnchor | null }
39
159
  | { type: 'pick', on: boolean }
40
160
  | { type: 'pins', targets: PreviewPin[] }
41
161
  | { type: 'marks', on: boolean }
42
162
  | { type: 'appearance', dark: boolean }
163
+ | { type: 'activity', active: boolean, request: number }
164
+ /** Edits the text of a block (a section's heading when there is no block) in place, the caret at `at`; null ends editing and puts the text back. */
165
+ | { type: 'edit', target: PreviewAnchor | null, at?: PreviewPoint, revision?: string, binding?: string }
166
+ /** Makes the blocks (or sections) blink a few times, the first brought into view: what the AI just changed. */
167
+ | { type: 'flash', targets: PreviewAnchor[] }
43
168
 
44
169
  export type PreviewCommandType = PreviewCommand['type']
45
170
 
171
+ /** A workbench command the page's keyboard asks for while it has the focus. */
172
+ export type PreviewShortcut = 'undo' | 'redo' | 'dismiss' | 'save' | 'play-pause' | 'previous' | 'next'
173
+ const PREVIEW_SHORTCUTS: readonly string[] = ['undo', 'redo', 'dismiss', 'save', 'play-pause', 'previous', 'next'] satisfies readonly PreviewShortcut[]
174
+
46
175
  export type PreviewReport =
47
- | { type: 'ready', renderer: 'web' | 'slides', document: string, revision?: string, capabilities: PreviewCommandType[] }
176
+ | { type: 'events-subscribe', connection: string, id: string, target: PreviewEventTarget }
177
+ | { type: 'events-unsubscribe', connection: string, id: string }
178
+ | { type: 'presentation-action', action: 'refresh' | 'exit' }
179
+ | { type: 'production-playback-state', state: ProductionPlaybackState }
180
+ | { type: 'production-playback-result', request: number, binding: ProductionBinding, state?: ProductionPlaybackState, error?: string }
181
+ | { type: 'document-history-state', section?: string, undo: boolean, redo: boolean }
182
+ | { type: 'document-history-result', request: number, handled: boolean, error?: string }
183
+ | { type: 'playback-paused', request: number, error?: string }
184
+ | { type: 'playback-request', request: number }
185
+ | { type: 'flushed', request: number, error?: string }
186
+ | { type: 'blocks-edited', section: string, request: number, revision: string, blocks: PreviewEditedBlock[] }
187
+ | { type: 'source-read', section: string, block: string, request: number, part?: 'definition' | 'block' }
188
+ | { type: 'source-save', section: string, block: string, request: number, path: string, base: string, content: string, part?: 'definition' | 'block' }
189
+ | { type: 'source-create', section: string, id: string, after?: string, request: number, revision: string }
190
+ | { type: 'source-draft', dirty: boolean }
191
+ /** Aggregate unsaved body blocks, independent of generated-source and narration drafts. */
192
+ | { type: 'body-draft', dirty: boolean }
193
+ /** Aggregate unacknowledged native title and complex-text drafts. */
194
+ | { type: 'native-draft', dirty: boolean }
195
+ | { type: 'page-create', section: string, id: string, request: number, revision: string }
196
+ | { type: 'page-create-error', request: number, error: string }
197
+ | { type: 'narrate', section: string, step: number, navigate?: boolean }
198
+ | { type: 'ready', renderer: 'web' | 'slides', document: string, revision?: string, capabilities: PreviewCommandType[], eventConnection?: string }
48
199
  | ({ type: 'position' } & PreviewPosition)
49
- | { type: 'picked', section: string, block?: string, step?: number, trail?: string[] }
50
- | { type: 'selection', selection: (PreviewAnchor & { text: string }) | null }
200
+ | { type: 'picked', section: string, block?: string, step?: number, trail?: string[], rect?: PreviewRect, point?: PreviewPoint, clicks?: number }
201
+ | { type: 'selection', selection: (PreviewAnchor & { text: string, rect?: PreviewRect, obstacles?: PreviewRect[] }) | null }
202
+ /** Where the picked block or the selection is after the page scrolled or resized; null when it is out of sight. */
203
+ | { type: 'rect', rect: PreviewRect | null, obstacles?: PreviewRect[] }
204
+ /** Where the blocks the AI changed are in the preview's viewport while they blink, and again as the page scrolls (at most 16). */
205
+ | { type: 'flashed', rects: PreviewRect[] }
206
+ | { type: 'editing', target: PreviewAnchor, active: boolean }
207
+ /** The text of a block edited in place, before and after (its `textContent`); sent only when it changed. */
208
+ | { type: 'edited', target: PreviewAnchor, before: string, after: string, request?: number, revision?: string, binding?: string }
51
209
  | { type: 'updated', revision?: string }
52
- | { type: 'report', level: 'error' | 'warning', plugin?: string, message: string }
210
+ | { type: 'activity', active: boolean, request: number, revision?: string, error?: string }
211
+ | { type: 'report', level: 'error' | 'warning', plugin?: string, message: string, resolved?: boolean }
212
+ | { type: 'shortcut', command: PreviewShortcut }
53
213
 
54
214
  export type PreviewEnvelope<M> = M & { protocol: typeof PREVIEW_NAME, version: number, nonce: string }
55
215
 
56
216
  export function previewEnvelope<M extends PreviewCommand | PreviewReport>(nonce: string, message: M): PreviewEnvelope<M> {
57
- return { protocol: PREVIEW_NAME, version: PREVIEW_PROTOCOL, nonce, ...message }
217
+ return { ...message, protocol: PREVIEW_NAME, version: PREVIEW_PROTOCOL, nonce }
58
218
  }
59
219
 
60
- const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
61
220
  const text = (value: unknown): value is string => typeof value === 'string'
62
221
  const optional = <T>(value: unknown, is: (v: unknown) => v is T) => value === undefined || is(value)
222
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
223
+ const positive = (value: unknown): value is number => finite(value) && value > 0
224
+ const nonnegative = (value: unknown): value is number => finite(value) && value >= 0
225
+ const productionId = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 200
226
+ const productionBinding = (value: unknown): value is ProductionBinding => isRecord(value) && isRecord(value.source)
227
+ && typeof value.source.documentId === 'string' && value.source.documentId.length > 0 && value.source.documentId.length <= 200
228
+ && typeof value.source.version === 'string' && /^[\da-f]{20}$/.test(value.source.version)
229
+ && typeof value.source.fingerprint === 'string' && /^[\da-f]{16}$/.test(value.source.fingerprint)
230
+ && typeof value.irRevision === 'string' && value.irRevision.length > 0 && value.irRevision.length <= 200
231
+ && typeof value.planId === 'string' && value.planId.length > 0 && value.planId.length <= 200
232
+ const productionState = (value: unknown): value is ProductionPlaybackState => {
233
+ if (!isRecord(value) || !productionBinding(value.binding) || !['loading', 'ready', 'estimated', 'unavailable', 'failed'].includes(String(value.status)) || !optional(value.error, text)) return false
234
+ if (value.position === undefined) return true
235
+ const position = value.position
236
+ return isRecord(position) && nonnegative(position.timeMs) && nonnegative(position.durationMs) && position.timeMs <= position.durationMs
237
+ && optional(position.stepTimeMs, nonnegative) && optional(position.sectionId, text) && optional(position.stepId, text) && optional(position.waitingFor, text)
238
+ && typeof position.playing === 'boolean' && positive(position.rate) && typeof position.trying === 'boolean'
239
+ }
240
+ const productionCommand = (value: unknown): value is ProductionPlaybackCommand => isRecord(value) && productionBinding(value.binding)
241
+ && (value.action === 'load' || value.action === 'pause' || value.action === 'play' || value.action === 'seek' && nonnegative(value.timeMs)
242
+ || value.action === 'step' && productionId(value.sectionId) && productionId(value.stepId) && optional(value.utteranceId, productionId) || value.action === 'rate' && positive(value.rate))
63
243
  const step = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0
244
+ const request = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0
64
245
  const strings = (value: unknown): value is string[] => Array.isArray(value) && value.length <= 10_000 && value.every(text)
65
- const anchor = (value: unknown): value is PreviewAnchor => record(value) && text(value.section) && optional(value.block, text)
246
+ const anchor = (value: unknown): value is PreviewAnchor => isRecord(value) && text(value.section) && optional(value.block, text)
247
+ const length = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
248
+ const rect = (value: unknown): value is PreviewRect => isRecord(value) && length(value.x) && length(value.y) && length(value.width) && length(value.height) && value.width >= 0 && value.height >= 0
249
+ const obstacles = (value: unknown): value is PreviewRect[] => Array.isArray(value) && value.length <= 16 && value.every(rect)
250
+ const point = (value: unknown): value is PreviewPoint => isRecord(value) && length(value.x) && length(value.y)
251
+ const clicks = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 3
252
+ const toAnchor = (value: PreviewAnchor): PreviewAnchor => ({ section: value.section, ...value.block ? { block: value.block } : {} })
253
+ const toRect = (value: PreviewRect): PreviewRect => ({ x: value.x, y: value.y, width: value.width, height: value.height })
254
+ const toPoint = (value: PreviewPoint): PreviewPoint => ({ x: value.x, y: value.y })
255
+ const MAX_EDITED = 200_000
256
+ const bounded = (value: unknown, max: number): value is string => text(value) && value.length > 0 && value.length <= max
257
+ const editBaseline = (m: Record<string, unknown>) => m.revision === undefined && m.binding === undefined || bounded(m.revision, 200) && bounded(m.binding, 200)
258
+ const editedRequest = (m: Record<string, unknown>) => m.request === undefined && m.revision === undefined && m.binding === undefined || request(m.request) && bounded(m.revision, 200) && bounded(m.binding, 200)
259
+ const sourceTarget = (m: Record<string, unknown>) => bounded(m.section, 200) && bounded(m.block, 200) && request(m.request)
260
+ const sourcePart = (value: unknown): value is 'definition' | 'block' => value === 'definition' || value === 'block'
261
+ const pageId = (value: unknown): value is string => text(value) && /^page-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)
262
+ const sourceValue = (value: unknown): value is PreviewSource => isRecord(value) && sourcePart(value.part) && bounded(value.path, 4096) && bounded(value.language, 100) && text(value.content) && value.content.length <= MAX_EDITED && bounded(value.version, 200) && request(value.shared) && value.shared > 0
263
+
264
+ export function toBlockPreviewCommand(value: unknown): BlockPreviewCommand | undefined {
265
+ if (!isRecord(value) || value.type !== 'presmium:block-preview-command' || !text(value.document)
266
+ || !text(value.blockId) || !value.blockId || !text(value.revision) || !value.revision
267
+ || !text(value.sectionId) || !value.sectionId || !text(value.stepId) || !value.stepId
268
+ || value.action !== 'seek' && value.action !== 'pause' && value.action !== 'sync'
269
+ || (value.action === 'pause' ? !optional(value.navigation, value => bounded(value, 200)) : !bounded(value.navigation, 200))
270
+ || value.at !== undefined && value.at !== 'start' && value.at !== 'end') return undefined
271
+ return { type: value.type, document: value.document, blockId: value.blockId, revision: value.revision, sectionId: value.sectionId, stepId: value.stepId, action: value.action, ...value.navigation !== undefined ? { navigation: value.navigation as string } : {}, ...value.at !== undefined ? { at: value.at } : {} }
272
+ }
273
+
274
+ export function toBlockPreviewPosition(value: unknown): BlockPreviewPosition | undefined {
275
+ if (!isRecord(value) || value.type !== 'presmium:block-preview-position' || !text(value.document)
276
+ || !bounded(value.blockId, 4096) || !bounded(value.revision, 200) || !bounded(value.sectionId, 4096)
277
+ || !bounded(value.stepId, 4096) || !bounded(value.navigation, 200)) return undefined
278
+ return { type: value.type, document: value.document, blockId: value.blockId, revision: value.revision, sectionId: value.sectionId, stepId: value.stepId, navigation: value.navigation }
279
+ }
280
+
281
+ const playbackIdentity = (value: unknown): value is Record<string, unknown> & BlockPreviewPlaybackIdentity => isRecord(value) && text(value.document) && bounded(value.blockId, 4096) && bounded(value.revision, 200) && request(value.request)
282
+ const toPlaybackIdentity = (value: BlockPreviewPlaybackIdentity): BlockPreviewPlaybackIdentity => ({ document: value.document, blockId: value.blockId, revision: value.revision, request: value.request })
283
+
284
+ export function toBlockPreviewPlaybackRequest(value: unknown): BlockPreviewPlaybackRequest | undefined {
285
+ return playbackIdentity(value) && value.type === 'presmium:block-preview-playback-request' && bounded(value.sectionId, 4096) && bounded(value.stepId, 4096) && bounded(value.navigation, 200)
286
+ ? { ...toPlaybackIdentity(value), type: value.type, sectionId: value.sectionId, stepId: value.stepId, navigation: value.navigation } : undefined
287
+ }
288
+
289
+ export function toBlockPreviewPlaybackGranted(value: unknown): BlockPreviewPlaybackGranted | undefined {
290
+ return playbackIdentity(value) && value.type === 'presmium:block-preview-playback-granted' && optional(value.error, text) && bounded(value.navigation, 200)
291
+ ? { ...toPlaybackIdentity(value), type: value.type, navigation: value.navigation, ...value.error !== undefined ? { error: value.error as string } : {} } : undefined
292
+ }
293
+
294
+ export function toBlockPreviewPlaybackCancelled(value: unknown): BlockPreviewPlaybackCancelled | undefined {
295
+ return playbackIdentity(value) && value.type === 'presmium:block-preview-playback-cancelled' ? { ...toPlaybackIdentity(value), type: value.type } : undefined
296
+ }
66
297
 
67
298
  export function isPreviewEnvelope(data: unknown, nonce: string): data is PreviewEnvelope<{ type: string }> {
68
- return record(data) && data.protocol === PREVIEW_NAME && data.nonce === nonce && typeof data.version === 'number' && text(data.type)
299
+ return isRecord(data) && data.protocol === PREVIEW_NAME && data.nonce === nonce && typeof data.version === 'number' && text(data.type)
69
300
  }
70
301
 
71
- export function readPreviewCommand(data: unknown, nonce: string): PreviewCommand | undefined {
302
+ export function toPreviewCommand(data: unknown, nonce: string): PreviewCommand | undefined {
72
303
  if (!isPreviewEnvelope(data, nonce) || data.version !== PREVIEW_PROTOCOL) return undefined
73
304
  const m = data as Record<string, unknown>
74
305
  switch (m.type) {
306
+ case 'presentation-confirmed': return productionId(m.revision) ? { type: 'presentation-confirmed', revision: m.revision } : undefined
307
+ case 'events-ready': return productionId(m.connection) ? { type: 'events-ready', connection: m.connection } : undefined
308
+ case 'events-message': return productionId(m.connection) && productionId(m.id) && eventMessage(m.message) ? { type: 'events-message', connection: m.connection, id: m.id, message: m.message } : undefined
309
+ case 'production-playback': return request(m.request) && productionCommand(m.control) ? { type: 'production-playback', request: m.request, control: m.control } : undefined
310
+ case 'document-history': return request(m.request) && (m.action === 'undo' || m.action === 'redo') ? { type: 'document-history', request: m.request, action: m.action } : undefined
311
+ case 'document-history-reset': return { type: 'document-history-reset' }
312
+ case 'pause-playback':
313
+ case 'playback-granted': return request(m.request) ? { type: m.type, request: m.request } : undefined
314
+ case 'flush': return request(m.request) ? { type: 'flush', request: m.request } : undefined
315
+ case 'edit-result': return anchor(m.target) && request(m.request) && editBaseline(m) && optional(m.error, text) ? { type: 'edit-result', target: toAnchor(m.target), request: m.request, ...m.revision === undefined ? {} : { revision: m.revision as string, binding: m.binding as string }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
316
+ case 'blocks-result': return text(m.section) && request(m.request) && optional(m.revision, text) && optional(m.error, text) ? { type: 'blocks-result', section: m.section, request: m.request, ...m.revision === undefined ? {} : { revision: m.revision as string }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
317
+ case 'source-result': return sourceTarget(m) && optional(m.source, sourceValue) && optional(m.error, text) ? { type: 'source-result', section: m.section as string, block: m.block as string, request: m.request as number, ...m.source === undefined ? {} : { source: m.source as PreviewSource }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
318
+ case 'source-saved': return sourceTarget(m) && optional(m.sourceVersion, (value): value is string => bounded(value, 200)) && optional(m.error, text) ? { type: 'source-saved', section: m.section as string, block: m.block as string, request: m.request as number, ...m.sourceVersion === undefined ? {} : { sourceVersion: m.sourceVersion as string }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
319
+ case 'source-created': return bounded(m.section, 200) && request(m.request) && optional(m.block, (value): value is string => bounded(value, 200)) && optional(m.error, text) ? { type: 'source-created', section: m.section, request: m.request, ...m.block === undefined ? {} : { block: m.block as string }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
320
+ case 'page-insert': return bounded(m.section, 200) && pageId(m.id) && request(m.request) ? { type: 'page-insert', section: m.section, id: m.id, request: m.request } : undefined
321
+ case 'page-created': return bounded(m.section, 200) && request(m.request) && optional(m.page, pageId) && optional(m.error, text) ? { type: 'page-created', section: m.section, request: m.request, ...m.page === undefined ? {} : { page: m.page as string }, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
75
322
  case 'go': return text(m.section) && optional(m.step, step) && optional(m.trail, strings) && optional(m.view, text) ? { type: 'go', section: m.section, ...m.step === undefined ? {} : { step: m.step as number }, ...m.trail ? { trail: m.trail as string[] } : {}, ...m.view ? { view: m.view as string } : {} } : undefined
76
323
  case 'highlight': return m.target === null || anchor(m.target) ? { type: 'highlight', target: m.target === null ? null : { section: m.target.section, ...m.target.block ? { block: m.target.block } : {} } } : undefined
77
324
  case 'pick':
78
325
  case 'marks': return typeof m.on === 'boolean' ? { type: m.type, on: m.on } : undefined
79
326
  case 'appearance': return typeof m.dark === 'boolean' ? { type: 'appearance', dark: m.dark } : undefined
80
- case 'pins': return Array.isArray(m.targets) && m.targets.length <= 1000 && m.targets.every(t => record(t) && text(t.id) && text(t.section) && text(t.block) && text(t.label))
327
+ case 'activity': return typeof m.active === 'boolean' && request(m.request) ? { type: 'activity', active: m.active, request: m.request } : undefined
328
+ case 'edit': return (m.target === null || anchor(m.target)) && optional(m.at, point) && editBaseline(m) ? { type: 'edit', target: m.target === null ? null : toAnchor(m.target), ...m.at ? { at: toPoint(m.at as PreviewPoint) } : {}, ...m.revision === undefined ? {} : { revision: m.revision as string, binding: m.binding as string } } : undefined
329
+ case 'pins': return Array.isArray(m.targets) && m.targets.length <= 1000 && m.targets.every(t => isRecord(t) && text(t.id) && text(t.section) && text(t.block) && text(t.label))
81
330
  ? { type: 'pins', targets: (m.targets as PreviewPin[]).map(({ id, section, block, label }) => ({ id, section, block, label })) }
82
331
  : undefined
332
+ case 'flash': return Array.isArray(m.targets) && m.targets.length <= 1000 && m.targets.every(anchor) ? { type: 'flash', targets: (m.targets as PreviewAnchor[]).map(toAnchor) } : undefined
83
333
  default: return undefined
84
334
  }
85
335
  }
86
336
 
87
- export function readPreviewReport(data: unknown, nonce: string): PreviewReport | undefined {
337
+ export function toPreviewReport(data: unknown, nonce: string): PreviewReport | undefined {
88
338
  if (!isPreviewEnvelope(data, nonce) || data.version !== PREVIEW_PROTOCOL) return undefined
89
339
  const m = data as Record<string, unknown>
90
340
  switch (m.type) {
91
- case 'ready': return (m.renderer === 'web' || m.renderer === 'slides') && text(m.document) && optional(m.revision, text) && strings(m.capabilities) ? { type: 'ready', renderer: m.renderer, document: m.document, ...m.revision ? { revision: m.revision as string } : {}, capabilities: m.capabilities as PreviewCommandType[] } : undefined
341
+ case 'events-subscribe': return productionId(m.connection) && productionId(m.id) && isPreviewEventTarget(m.target) ? { type: 'events-subscribe', connection: m.connection, id: m.id, target: m.target } : undefined
342
+ case 'events-unsubscribe': return productionId(m.connection) && productionId(m.id) ? { type: 'events-unsubscribe', connection: m.connection, id: m.id } : undefined
343
+ case 'presentation-action': return m.action === 'refresh' || m.action === 'exit' ? { type: 'presentation-action', action: m.action } : undefined
344
+ case 'production-playback-state': return productionState(m.state) ? { type: 'production-playback-state', state: m.state } : undefined
345
+ case 'production-playback-result': return request(m.request) && productionBinding(m.binding) && optional(m.state, productionState) && optional(m.error, text)
346
+ ? { type: 'production-playback-result', request: m.request, binding: m.binding, ...m.state ? { state: m.state as ProductionPlaybackState } : {}, ...m.error !== undefined ? { error: m.error as string } : {} } : undefined
347
+ case 'document-history-state': return optional(m.section, text) && typeof m.undo === 'boolean' && typeof m.redo === 'boolean' ? { type: 'document-history-state', ...m.section === undefined ? {} : { section: m.section as string }, undo: m.undo, redo: m.redo } : undefined
348
+ case 'document-history-result': return request(m.request) && typeof m.handled === 'boolean' && optional(m.error, text) ? { type: 'document-history-result', request: m.request, handled: m.handled, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
349
+ case 'playback-request': return request(m.request) ? { type: 'playback-request', request: m.request } : undefined
350
+ case 'playback-paused': return request(m.request) && optional(m.error, text) ? { type: 'playback-paused', request: m.request, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
351
+ case 'flushed': return request(m.request) && optional(m.error, text) ? { type: 'flushed', request: m.request, ...m.error === undefined ? {} : { error: m.error as string } } : undefined
352
+ case 'blocks-edited': return text(m.section) && request(m.request) && text(m.revision) && Array.isArray(m.blocks) && m.blocks.length <= 2000 && m.blocks.every(b => isRecord(b) && text(b.id) && b.id.length <= 200 && optional(b.source, text) && optional(b.at, step) && optional(b.markdown, (v): v is string => text(v) && v.length <= MAX_EDITED) && optional(b.scene, text) && optional(b.durations, (v): v is number[] => Array.isArray(v) && v.length <= 1000 && v.every(n => typeof n === 'number' && Number.isFinite(n) && n >= 0))) ? { type: 'blocks-edited', section: m.section, request: m.request, revision: m.revision, blocks: m.blocks as unknown as PreviewEditedBlock[] } : undefined
353
+ case 'source-read': return sourceTarget(m) && optional(m.part, sourcePart) ? { type: 'source-read', section: m.section as string, block: m.block as string, request: m.request as number, ...m.part === undefined ? {} : { part: m.part as 'definition' | 'block' } } : undefined
354
+ case 'source-save': return sourceTarget(m) && optional(m.part, sourcePart) && bounded(m.path, 4096) && bounded(m.base, 200) && text(m.content) && m.content.length <= MAX_EDITED ? { type: 'source-save', section: m.section as string, block: m.block as string, request: m.request as number, path: m.path as string, base: m.base as string, content: m.content, ...m.part === undefined ? {} : { part: m.part as 'definition' | 'block' } } : undefined
355
+ case 'source-create': return bounded(m.section, 200) && text(m.id) && /^scene-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(m.id) && request(m.request) && bounded(m.revision, 200) && optional(m.after, (value): value is string => bounded(value, 200)) ? { type: 'source-create', section: m.section, id: m.id, request: m.request, revision: m.revision, ...m.after === undefined ? {} : { after: m.after as string } } : undefined
356
+ case 'source-draft': return typeof m.dirty === 'boolean' ? { type: 'source-draft', dirty: m.dirty } : undefined
357
+ case 'body-draft': return typeof m.dirty === 'boolean' ? { type: 'body-draft', dirty: m.dirty } : undefined
358
+ case 'native-draft': return typeof m.dirty === 'boolean' ? { type: 'native-draft', dirty: m.dirty } : undefined
359
+ case 'page-create': return bounded(m.section, 200) && pageId(m.id) && request(m.request) && bounded(m.revision, 200) ? { type: 'page-create', section: m.section, id: m.id, request: m.request, revision: m.revision } : undefined
360
+ case 'page-create-error': return request(m.request) && bounded(m.error, 4000) ? { type: 'page-create-error', request: m.request, error: m.error } : undefined
361
+ case 'narrate': return text(m.section) && step(m.step) && optional(m.navigate, (value): value is boolean => typeof value === 'boolean') ? { type: 'narrate', section: m.section, step: m.step, ...m.navigate === undefined ? {} : { navigate: m.navigate as boolean } } : undefined
362
+ case 'ready': return (m.renderer === 'web' || m.renderer === 'slides') && text(m.document) && optional(m.revision, text) && strings(m.capabilities) && optional(m.eventConnection, productionId) ? { type: 'ready', renderer: m.renderer, document: m.document, ...m.revision ? { revision: m.revision as string } : {}, capabilities: m.capabilities as PreviewCommandType[], ...m.eventConnection ? { eventConnection: m.eventConnection as string } : {} } : undefined
92
363
  case 'position': return text(m.section) && step(m.step) && strings(m.trail) && optional(m.view, text) ? { type: 'position', section: m.section, step: m.step, trail: m.trail, ...m.view ? { view: m.view as string } : {} } : undefined
93
- case 'picked': return text(m.section) && optional(m.block, text) && optional(m.step, step) && optional(m.trail, strings) ? { type: 'picked', section: m.section, ...m.block ? { block: m.block as string } : {}, ...m.step === undefined ? {} : { step: m.step as number }, ...m.trail ? { trail: m.trail as string[] } : {} } : undefined
94
- case 'selection': return m.selection === null || (record(m.selection) && text(m.selection.text) && anchor(m.selection)) ? { type: 'selection', selection: m.selection as (PreviewAnchor & { text: string }) | null } : undefined
364
+ case 'picked': return text(m.section) && optional(m.block, text) && optional(m.step, step) && optional(m.trail, strings) && optional(m.rect, rect) && optional(m.point, point) && optional(m.clicks, clicks)
365
+ ? { type: 'picked', section: m.section, ...m.block ? { block: m.block as string } : {}, ...m.step === undefined ? {} : { step: m.step as number }, ...m.trail ? { trail: m.trail as string[] } : {}, ...m.rect ? { rect: toRect(m.rect as PreviewRect) } : {}, ...m.point ? { point: toPoint(m.point as PreviewPoint) } : {}, ...m.clicks === undefined ? {} : { clicks: m.clicks as number } }
366
+ : undefined
367
+ case 'selection': {
368
+ if (m.selection === null) return { type: 'selection', selection: null }
369
+ const selected = m.selection
370
+ return isRecord(selected) && text(selected.text) && anchor(selected) && optional(selected.rect, rect) && optional(selected.obstacles, obstacles)
371
+ ? { type: 'selection', selection: { ...toAnchor(selected), text: selected.text, ...selected.rect ? { rect: toRect(selected.rect as PreviewRect) } : {}, ...selected.obstacles !== undefined ? { obstacles: (selected.obstacles as PreviewRect[]).map(toRect) } : {} } }
372
+ : undefined
373
+ }
374
+ case 'flashed': return Array.isArray(m.rects) && m.rects.length <= 16 && m.rects.every(rect) ? { type: 'flashed', rects: (m.rects as PreviewRect[]).map(toRect) } : undefined
375
+ case 'rect': return (m.rect === null || rect(m.rect)) && optional(m.obstacles, obstacles) ? { type: 'rect', rect: m.rect === null ? null : toRect(m.rect), ...m.obstacles !== undefined ? { obstacles: (m.obstacles as PreviewRect[]).map(toRect) } : {} } : undefined
376
+ case 'editing': return anchor(m.target) && typeof m.active === 'boolean' ? { type: 'editing', target: toAnchor(m.target), active: m.active } : undefined
377
+ case 'edited': return anchor(m.target) && text(m.before) && text(m.after) && m.before.length <= MAX_EDITED && m.after.length <= MAX_EDITED && editedRequest(m) ? { type: 'edited', target: toAnchor(m.target), before: m.before, after: m.after, ...m.request === undefined ? {} : { request: m.request as number, revision: m.revision as string, binding: m.binding as string } } : undefined
95
378
  case 'updated': return optional(m.revision, text) ? { type: 'updated', ...m.revision ? { revision: m.revision as string } : {} } : undefined
96
- case 'report': return (m.level === 'error' || m.level === 'warning') && text(m.message) && optional(m.plugin, text) ? { type: 'report', level: m.level, message: m.message, ...m.plugin ? { plugin: m.plugin as string } : {} } : undefined
379
+ case 'activity': return typeof m.active === 'boolean' && request(m.request) && optional(m.revision, text) && optional(m.error, text)
380
+ ? { type: 'activity', active: m.active, request: m.request, ...m.revision === undefined ? {} : { revision: m.revision as string }, ...m.error === undefined ? {} : { error: m.error as string } }
381
+ : undefined
382
+ case 'report': return (m.level === 'error' || m.level === 'warning') && text(m.message) && optional(m.plugin, text) && (m.resolved === undefined || typeof m.resolved === 'boolean') ? { type: 'report', level: m.level, message: m.message, ...m.plugin ? { plugin: m.plugin as string } : {}, ...m.resolved === undefined ? {} : { resolved: m.resolved as boolean } } : undefined
383
+ case 'shortcut': return typeof m.command === 'string' && PREVIEW_SHORTCUTS.includes(m.command) ? { type: 'shortcut', command: m.command as PreviewShortcut } : undefined
97
384
  default: return undefined
98
385
  }
99
386
  }
100
-
101
- export interface PreviewPage {
102
- renderer: 'web' | 'slides'
103
- document: string
104
- origins: readonly string[]
105
- revision(): string | undefined
106
- position(): PreviewPosition | undefined
107
- go(command: Extract<PreviewCommand, { type: 'go' }>): void
108
- reveal(target: PreviewAnchor): void
109
- setDark(dark: boolean): void
110
- sectionOf?(element: Element): string | undefined
111
- sectionElement?(section: string): HTMLElement | undefined
112
- seen?(element: Element): boolean
113
- stepOf?(anchor: Required<PreviewAnchor>): { step: number, trail: string[] } | undefined
114
- }
115
-
116
- export interface PreviewAgent {
117
- moved(): void
118
- updated(): void
119
- redraw(): void
120
- report(problem: { level: 'error' | 'warning', plugin?: string, message: string }): void
121
- stop(): void
122
- }
123
-
124
- const FRAME_NAME = `${PREVIEW_NAME}:`
125
- const STYLE_ID = 'presmium-preview-style'
126
- const HIGHLIGHT = 'data-preview-highlight'
127
- const HOVER = 'data-preview-hover'
128
- const POSITIONED = 'data-preview-positioned'
129
- const PIN_CLASS = 'pm-preview-pin'
130
- const CONTROLS = 'a, button, input, select, textarea, label, summary, [contenteditable], [role="dialog"]'
131
- const COMMANDS: PreviewCommandType[] = ['go', 'highlight', 'pick', 'pins', 'marks', 'appearance']
132
-
133
- const STYLE = `
134
- [${HIGHLIGHT}] { outline: 2px solid var(--pm-color-accent, #1d6b7a) !important; outline-offset: 4px; border-radius: 2px; }
135
- [${HOVER}] { outline: 2px dashed var(--pm-color-accent, #1d6b7a) !important; outline-offset: 2px; cursor: crosshair; }
136
- [${POSITIONED}] { position: relative; }
137
- .${PIN_CLASS} { position: absolute; top: -10px; left: -10px; 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; }
138
- `
139
-
140
- function previewNonce(): string | undefined {
141
- if (typeof window === 'undefined') return undefined
142
- const given = new URL(window.location.href).searchParams.get(PREVIEW_PARAM)
143
- if (given) return given
144
- return window.name.startsWith(FRAME_NAME) ? window.name.slice(FRAME_NAME.length) || undefined : undefined
145
- }
146
-
147
- export function startPreviewAgent(page: PreviewPage): PreviewAgent | undefined {
148
- if (typeof window === 'undefined' || window.parent === window || page.origins.length === 0) return undefined
149
- const nonce = previewNonce()
150
- if (!nonce) return undefined
151
- window.name = `${FRAME_NAME}${nonce}`
152
- const root = document.documentElement
153
- root.dataset.presmiumPreview = 'on'
154
- if (!document.getElementById(STYLE_ID)) {
155
- const style = document.createElement('style')
156
- style.id = STYLE_ID
157
- style.textContent = STYLE
158
- document.head.append(style)
159
- }
160
-
161
- const post = (message: PreviewReport) => {
162
- for (const origin of page.origins) window.parent.postMessage(previewEnvelope(nonce, message), origin)
163
- }
164
-
165
- let highlight: PreviewAnchor | null = null
166
- let pins: PreviewPin[] = []
167
- let picking = false
168
- let ready = false
169
- const waiting: Extract<PreviewReport, { type: 'report' }>[] = []
170
- let sent: string | undefined
171
- let revision: string | undefined
172
-
173
- const seen = (element: Element): boolean =>
174
- !element.closest('[inert], .pm-hidden') && element.getClientRects().length > 0 && (page.seen?.(element) ?? true)
175
- const seenBlock = (element: Element): HTMLElement | undefined => {
176
- for (let block = element.closest<HTMLElement>('.pm-block[data-block]'); block; block = block.parentElement?.closest<HTMLElement>('.pm-block[data-block]') ?? null) {
177
- if (seen(block)) return block
178
- }
179
- return undefined
180
- }
181
- const sectionOf = (element: Element): string | undefined =>
182
- element.closest<HTMLElement>('[data-section]')?.dataset.section ?? page.sectionOf?.(element)
183
- const blockElement = (section: string, block: string): HTMLElement | undefined => {
184
- for (const element of document.querySelectorAll<HTMLElement>(`.pm-block[data-block="${CSS.escape(block)}"]`)) {
185
- if (sectionOf(element) === section && seen(element)) return element
186
- }
187
- return undefined
188
- }
189
- const sectionElement = (section: string): HTMLElement | undefined => {
190
- const own = page.sectionElement?.(section)
191
- if (own) return own
192
- const all = [...document.querySelectorAll<HTMLElement>(`[data-section="${CSS.escape(section)}"]`)]
193
- return all.find(el => el.classList.contains('pm-section') && seen(el)) ?? all.find(seen)
194
- }
195
- const elementOf = (target: PreviewAnchor) => (target.block ? blockElement(target.section, target.block) : sectionElement(target.section))
196
- const anchorOf = (element: Element): PreviewAnchor | undefined => {
197
- const block = seenBlock(element)
198
- const section = sectionOf(block ?? element)
199
- if (!section) return undefined
200
- return block ? { section, block: block.dataset.block! } : { section }
201
- }
202
-
203
- const drawHighlight = (scroll: boolean) => {
204
- const element = highlight && elementOf(highlight)
205
- for (const other of document.querySelectorAll(`[${HIGHLIGHT}]`)) if (other !== element) other.removeAttribute(HIGHLIGHT)
206
- if (!element) return false
207
- element.setAttribute(HIGHLIGHT, '')
208
- if (scroll) {
209
- const box = element.getBoundingClientRect()
210
- if (box.bottom < 0 || box.top > window.innerHeight) element.scrollIntoView({ block: 'center' })
211
- }
212
- return true
213
- }
214
- const drawPins = () => {
215
- const wanted = new Map<HTMLElement, PreviewPin[]>()
216
- for (const pin of pins) {
217
- const element = blockElement(pin.section, pin.block)
218
- if (element) wanted.set(element, [...wanted.get(element) ?? [], pin])
219
- }
220
- for (const old of document.querySelectorAll<HTMLElement>(`.${PIN_CLASS}`)) {
221
- const host = old.parentElement
222
- if (!host || !wanted.get(host)?.some(pin => pin.id === old.dataset.pin && pin.label === old.textContent)) old.remove()
223
- }
224
- for (const host of document.querySelectorAll<HTMLElement>(`[${POSITIONED}]`)) {
225
- if (!wanted.has(host)) host.removeAttribute(POSITIONED)
226
- }
227
- for (const [element, own] of wanted) {
228
- if (getComputedStyle(element).position === 'static') element.setAttribute(POSITIONED, '')
229
- own.forEach((pin, i) => {
230
- if ([...element.children].some(child => child.classList.contains(PIN_CLASS) && (child as HTMLElement).dataset.pin === pin.id)) return
231
- const mark = document.createElement('span')
232
- mark.className = PIN_CLASS
233
- mark.dataset.pin = pin.id
234
- mark.textContent = pin.label
235
- mark.setAttribute('aria-hidden', 'true')
236
- if (i > 0) mark.style.left = `${-10 + 24 * i}px`
237
- element.append(mark)
238
- })
239
- }
240
- }
241
- let frame = 0
242
- const redraw = () => {
243
- cancelAnimationFrame(frame)
244
- frame = requestAnimationFrame(() => {
245
- drawHighlight(false)
246
- drawPins()
247
- })
248
- }
249
- const showHighlight = (target: PreviewAnchor | null) => {
250
- highlight = target
251
- if (!target) return void drawHighlight(false)
252
- if (drawHighlight(true)) return
253
- page.reveal(target)
254
- const until = Date.now() + 5000
255
- const retry = () => {
256
- if (highlight !== target || drawHighlight(true) || Date.now() > until) return
257
- setTimeout(retry, 100)
258
- }
259
- setTimeout(retry, 50)
260
- }
261
-
262
- let hovered: Element | undefined
263
- const hover = (event: MouseEvent) => {
264
- const target = event.target instanceof Element ? event.target : undefined
265
- const block = target && !target.closest(CONTROLS) ? seenBlock(target) : undefined
266
- if (block === hovered) return
267
- hovered?.removeAttribute(HOVER)
268
- hovered = block
269
- hovered?.setAttribute(HOVER, '')
270
- }
271
- const click = (event: MouseEvent) => {
272
- const target = event.target instanceof Element ? event.target : undefined
273
- if (!target || event.button !== 0 || target.closest(CONTROLS)) return
274
- if (document.getSelection()?.isCollapsed === false) return
275
- const anchor = anchorOf(target)
276
- if (!anchor) return
277
- event.preventDefault()
278
- event.stopImmediatePropagation()
279
- const at = anchor.block ? page.stepOf?.({ section: anchor.section, block: anchor.block }) : undefined
280
- post({ type: 'picked', ...anchor, ...at ? { step: at.step, trail: at.trail } : {} })
281
- }
282
- const setPicking = (on: boolean) => {
283
- if (on === picking) return
284
- picking = on
285
- if (on) {
286
- document.addEventListener('mouseover', hover, true)
287
- document.addEventListener('click', click, true)
288
- return
289
- }
290
- document.removeEventListener('mouseover', hover, true)
291
- document.removeEventListener('click', click, true)
292
- hovered?.removeAttribute(HOVER)
293
- hovered = undefined
294
- }
295
-
296
- let selected = false
297
- let selecting: ReturnType<typeof setTimeout> | undefined
298
- const selection = () => {
299
- clearTimeout(selecting)
300
- selecting = setTimeout(() => {
301
- const range = document.getSelection()
302
- const value = range && !range.isCollapsed ? range.toString().trim() : ''
303
- const node = range?.rangeCount ? range.getRangeAt(0).commonAncestorContainer : undefined
304
- const anchor = value && node ? anchorOf(node instanceof Element ? node : node.parentElement ?? document.body) : undefined
305
- if (anchor) {
306
- selected = true
307
- post({ type: 'selection', selection: { ...anchor, text: value.slice(0, 4000) } })
308
- } else if (selected) {
309
- selected = false
310
- post({ type: 'selection', selection: null })
311
- }
312
- }, 200)
313
- }
314
- document.addEventListener('selectionchange', selection)
315
-
316
- const observer = new MutationObserver(() => {
317
- if (highlight || pins.length > 0) redraw()
318
- })
319
- observer.observe(document.body, { childList: true, subtree: true })
320
-
321
- const receive = (event: MessageEvent) => {
322
- if (event.source !== window.parent || !page.origins.includes(event.origin)) return
323
- const command = readPreviewCommand(event.data, nonce)
324
- if (!command) return
325
- try {
326
- switch (command.type) {
327
- case 'go': return page.go(command)
328
- case 'highlight': return showHighlight(command.target)
329
- case 'pick': return setPicking(command.on)
330
- case 'pins':
331
- pins = command.targets
332
- return drawPins()
333
- case 'marks': return void root.classList.toggle(AUTHOR_MARKS_CLASS, command.on)
334
- case 'appearance': return page.setDark(command.dark)
335
- }
336
- } catch (error) {
337
- console.error(`[presmium] the preview could not take ${command.type}: ${errorMessage(error)}`)
338
- }
339
- }
340
- window.addEventListener('message', receive)
341
-
342
- let timer: ReturnType<typeof setTimeout> | undefined
343
- let last = 0
344
- const sendPosition = () => {
345
- timer = undefined
346
- const position = page.position()
347
- if (!position) return
348
- if (!ready) {
349
- ready = true
350
- revision = page.revision()
351
- post({ type: 'ready', renderer: page.renderer, document: page.document, ...revision ? { revision } : {}, capabilities: COMMANDS })
352
- for (const problem of waiting.splice(0)) post(problem)
353
- }
354
- const key = JSON.stringify(position)
355
- if (key === sent) return
356
- sent = key
357
- last = Date.now()
358
- post({ type: 'position', ...position })
359
- }
360
- const moved = () => {
361
- if (timer) return
362
- timer = setTimeout(sendPosition, Math.max(0, 150 - (Date.now() - last)))
363
- }
364
- moved()
365
-
366
- return {
367
- moved,
368
- updated() {
369
- const next = page.revision()
370
- if (!ready || next === revision) return
371
- revision = next
372
- post({ type: 'updated', ...next ? { revision: next } : {} })
373
- redraw()
374
- },
375
- redraw,
376
- report(problem) {
377
- const message = { type: 'report' as const, ...problem }
378
- if (ready) post(message)
379
- else waiting.push(message)
380
- },
381
- stop() {
382
- window.removeEventListener('message', receive)
383
- document.removeEventListener('selectionchange', selection)
384
- observer.disconnect()
385
- setPicking(false)
386
- clearTimeout(timer)
387
- clearTimeout(selecting)
388
- delete root.dataset.presmiumPreview
389
- },
390
- }
391
- }