oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/editor-entry.js — the editor bundle's entry: wiring only.
2// fixtures/build-bundles.mjs bundles this file and everything it
3// imports into internal/slides/static/editor.<hash>.js, the one
4// script the app serves (spec §8: the editor is the one surface that
5// needs it).
6//
7// The shape is Docs' editor-entry.js, adopted: load the log into a
8// Y.Doc, bind the editor, save what this tab produces through the
9// save queue, hear about everybody else's writes on the stream and
10// fetch them through live-updates, rebuild from server truth through
11// the resync controller when a batch is refused. What differs is what
12// the editor IS: not one ProseMirror over one fragment but a stage
13// (stage.mjs) binding a view to every text block of the server's own
14// slide HTML, a strip (strip.mjs) of the server's SVG thumbnails, an
15// inspector (inspector.mjs) whose every control is one op, a canvas
16// overlay (canvas.mjs), and the desktop/phone switcher (switcher.mjs).
17// Every structural edit goes through the same ops vocabulary the API
18// uses (ops.mjs) and ends in a re-fetch of the slide once the save has
19// landed — the server renders; this tab never does.
20//
21// yjs stays at the version fixtures/package.json pins for the vectors:
22// this bundle and core/'s Rust CRDT agree on one wire format.
23import * as Y from 'yjs'
24import { ySyncPluginKey } from 'y-prosemirror'
25import { parseFrames } from './frames.mjs'
26import { createSaveQueue } from './save-queue.mjs'
27import { mountSaveStatus, queueStatusState } from './status.mjs'
28import { createLiveUpdates, updatesURL } from './live-updates.mjs'
29import { createResyncController } from './resync-controller.mjs'
30import { mountStage, slideIDs, slideAfter } from './stage.mjs'
31import { mountStrip } from './strip.mjs'
32import { mountReorder } from './strip-reorder.mjs'
33import { classify } from './changes.mjs'
34import { textKeys } from './text-keys.mjs'
35import { mountLinkDialog, linkPrompt } from './link-dialog.mjs'
36import { applyOps, view } from './ops.mjs'
37import { mountSelection, parseHash, formatHash } from './selection.mjs'
38import { mountInspector } from './inspector.mjs'
39import { mountCanvas } from './canvas.mjs'
40import { mountSwitcher, routeEdit } from './switcher.mjs'
41import { createCommands } from './commands.mjs'
42import { createPresence, newClientId } from './presence.mjs'
43import { tabClientId, claimClientId, clientIdKey } from './client-id.mjs'
44import { mountPresenceView } from './presence-view.mjs'
45import { createUploader, refusal } from './uploads.mjs'
46
47// Coalesce a burst of typing into one request. Docs' number and Docs'
48// reasoning: under the ~300ms at which a delay stops reading as "the
49// app responded", and the floor on how fast an edit reaches anyone.
50const DEBOUNCE_MS = 250
51
52// Origins. Bytes the server sent are 'remote' and are never saved
53// back; this tab's structural ops are 'local'; y-prosemirror tags its
54// own transactions with ySyncPluginKey; an undo's origin is the undo
55// manager itself. The undo manager tracks the middle two and only
56// those, so undo is this person's edits and never somebody else's
57// (spec §3.1).
58const REMOTE = 'remote'
59const LOCAL = 'local'
60
61// How long after a change a thumbnail is re-fetched.
62const THUMB_MS = 600
63const NOTES_KEY = 'slides.editor.notes'
64
65async function fetchFrames (url) {
66 const res = await fetch(url, { credentials: 'same-origin' })
67 if (!res.ok) throw new Error(`load ${url}: ${res.status}`)
68 return {
69 frames: parseFrames(new Uint8Array(await res.arrayBuffer())),
70 seq: Number(res.headers.get('Slides-Seq'))
71 }
72}
73
74export async function mountEditor (root) {
75 const sqid = root.dataset.deck
76 const base = `/d/${sqid}/updates`
77 const streamURL = `/d/${sqid}/stream`
78 const fetchSlide = async (id, vp) => {
79 const res = await fetch(`/d/${sqid}/slides/${id}.html?vp=${vp}`, { credentials: 'same-origin' })
80 if (!res.ok) throw new Error(`slide ${id}: ${res.status}`)
81 return res.text()
82 }
83
84 // A thumbnail is an <img> of the server's SVG (internal/slides/
85 // thumbs.go). The deck's seq is on the URL so the browser asks again
86 // after a change and reads its own cache in between; the route
87 // revalidates on an ETag, so the ask is a 304 for every slide that
88 // did not actually move. noteSeq is called wherever a seq arrives —
89 // this tab's own save, and a batch pulled in from somebody else's.
90 let thumbSeq = Number(root.dataset.seq) || 0
91 const noteSeq = seq => {
92 if (Number.isFinite(seq) && seq > thumbSeq) thumbSeq = seq
93 }
94 const thumbURL = id => `/d/${sqid}/slides/${id}.svg?seq=${thumbSeq}`
95
96 const status = await mountSaveStatus(root)
97 const titleEl = root.querySelector('[slides-editor-title]')
98 const stripEl = root.querySelector('[slides-strip]')
99 const stagePane = root.querySelector('[slides-stage-pane]')
100 const stageEl = stagePane.querySelector('[slides-stage]')
101 const phoneEl = stagePane.querySelector('[slides-stage-phone]')
102 const notesPane = stagePane.querySelector('[slides-notes-pane]')
103 const inspectorEl = root.querySelector('[slides-inspector]')
104 const presentForm = root.querySelector('[slides-present-form]')
105 const dialog = mountLinkDialog(root.querySelector('[slides-link-dialog]'))
106 const text = {
107 slide: root.dataset.textSlide
108 }
109 const presenceText = {
110 slide: root.dataset.textPresenceSlide,
111 block: root.dataset.textPresenceBlock,
112 more: root.dataset.textPresenceMore,
113 joined: root.dataset.textPresenceJoined,
114 left: root.dataset.textPresenceLeft
115 }
116 const types = {}
117 for (const k of Object.keys(root.dataset)) if (k.startsWith('type')) types[k.slice(4).toLowerCase()] = root.dataset[k]
118 const uploadText = {
119 uploading: root.dataset.textUploadUploading,
120 checking: root.dataset.textUploadChecking,
121 done: root.dataset.textUploadDone,
122 failed: root.dataset.textUploadFailed,
123 toolarge: root.dataset.textUploadToolarge,
124 type: root.dataset.textUploadType,
125 altfirst: root.dataset.textUploadAltfirst,
126 remove: root.dataset.textAssetRemove
127 }
128
129 // Images (assets.go, spec §2.7). The three requests are here — this
130 // is the only file that knows this deck's URLs — and the state
131 // machine that sequences them is uploads.mjs.
132 //
133 // The PUT goes wherever the sign step said: the bucket itself with a
134 // real store, or back to this app in development, where blobs.Dir has
135 // nothing to presign. Either way it carries no credentials of ours —
136 // a presigned URL is the credential, and sending a cookie to a bucket
137 // would be a mistake with no upside.
138 const assetJSON = async (path, body) => {
139 const res = await fetch(`/d/${sqid}${path}`, {
140 method: 'POST',
141 credentials: 'same-origin',
142 headers: { 'Content-Type': 'application/json' },
143 body: JSON.stringify(body)
144 })
145 if (!res.ok) throw await refusal(res)
146 return res.json()
147 }
148 const uploader = createUploader({
149 sign: body => assetJSON('/uploads', body),
150 put: async (url, file, headers) => {
151 const sameOrigin = url.startsWith('/')
152 const res = await fetch(url, {
153 method: 'PUT',
154 credentials: sameOrigin ? 'same-origin' : 'omit',
155 headers,
156 body: file
157 })
158 if (!res.ok) throw await refusal(res)
159 },
160 register: body => assetJSON('/assets', body),
161 applyOp: () => {},
162 onState: (state, detail) => { if (inspector) inspector.onUploadState(state, detail) }
163 })
164
165 // A dropped batch is stickier than any one save queue's status: once
166 // a batch is ever dropped, the person keeps seeing it (Docs' rule) —
167 // as `failed`, its own state, because a dropped batch is not being
168 // retried and "Offline, retrying" would say it is. A slide that
169 // would not load is said in its own line until one does. The rules
170 // are status.mjs, where they are tested.
171 const setStatus = state => status.save(state)
172
173 // Whichever of these is live right now. A resync replaces them all.
174 let ydoc = null
175 let undoManager = null
176 let stage = null
177 let phoneStage = null
178 let strip = null
179 let selection = null
180 let inspector = null
181 let canvas = null
182 let currentQueue = null
183 let currentLive = null
184 let thumbTimer = null
185 let currentView = 'desktop'
186
187 // --- presence: who else has this deck open, and where (spec §3.3) ---
188 //
189 // Presence outlives any one Y.Doc — it is not document state, and a
190 // resync (a new stage, a new selection, a new everything above) is
191 // not somebody leaving the deck. So this is mounted once, over the
192 // strip's own <ol> and the stage's own element, and never destroyed
193 // or recreated the way stage/strip/selection/inspector are inside
194 // attemptResync. getSlideId and getState read `stage` and
195 // `selection` by closure, live, whichever resync's mount is current.
196 const presenceView = mountPresenceView({
197 stripEl,
198 stageEl,
199 liveEl: root.querySelector('[slides-presence-live]'),
200 text: presenceText,
201 getSlideId: () => (stage ? stage.slideId : '')
202 })
203 // One id per tab, kept across a reload (client-id.mjs): a fresh one
204 // per load drew this tab's own previous self as a colleague until the
205 // server expired it. A duplicated tab copies the stored id, so the
206 // claim below makes it give the copy up for a fresh one.
207 let tabStorage = null
208 try { tabStorage = globalThis.sessionStorage } catch { /* a fresh id per load then */ }
209 const presence = createPresence({
210 clientId: tabClientId({ storage: tabStorage, deck: sqid }),
211 post: body => fetch(`/d/${sqid}/presence`, {
212 method: 'POST',
213 credentials: 'same-origin',
214 headers: { 'Content-Type': 'application/json' },
215 body: JSON.stringify(body)
216 }),
217 getState: () => ({
218 slide: stage ? stage.slideId : '',
219 block: selection ? selection.current : ''
220 })
221 })
222
223 let idChannel = null
224 try { idChannel = new BroadcastChannel(clientIdKey(sqid)) } catch { /* no rival check */ }
225 claimClientId({
226 channel: idChannel,
227 getId: () => presence.clientId,
228 onTaken: () => {
229 const id = newClientId()
230 try { if (tabStorage) tabStorage.setItem(clientIdKey(sqid), id) } catch { /* per-load then */ }
231 presence.setClientId(id)
232 }
233 }).claim()
234
235 const keys = textKeys({
236 undo: () => undoManager && undoManager.undo(),
237 redo: () => undoManager && undoManager.redo(),
238 link: view => linkPrompt(view, dialog)
239 })
240
241 // --- the one write path: every structural edit is a batch of ops ---
242
243 // A batch is one undo step: the undo manager would otherwise fold
244 // two commands made within its capture window (500 ms — a menu
245 // press right after a field change) into one, and undo would take
246 // both back at once.
247 function commit (ops) {
248 try {
249 if (undoManager) undoManager.stopCapturing()
250 applyOps(ydoc, ops, LOCAL)
251 } catch (err) {
252 console.error('ops:', err)
253 if (inspector) inspector.fill()
254 }
255 }
256 const slideJSON = id => view(ydoc).slides.find(s => s.id === id) || null
257 const blockJSON = (slideId, id) => {
258 const s = slideJSON(slideId)
259 return s ? s.blocks.find(b => b.id === id) || null : null
260 }
261
262 // --- the stage follows the URL hash: #<slide id>[/<block id>] ---
263
264 function wantedSlide () {
265 const ids = slideIDs(ydoc)
266 const { slide } = parseHash(location.hash)
267 return ids.includes(slide) ? slide : (ids[0] || '')
268 }
269 async function showSlide (id) {
270 if (!stage) return
271 try {
272 await stage.load(id)
273 if (phoneStage && currentView === 'both') await phoneStage.load(id)
274 } catch (err) {
275 console.error('stage:', err)
276 }
277 }
278 async function refreshStages () {
279 if (!stage) return
280 try {
281 await stage.refresh()
282 if (phoneStage && currentView === 'both') await phoneStage.refresh()
283 } catch (err) {
284 console.error('stage:', err)
285 }
286 }
287 function onLoaded (id) {
288 status.loadFailed(false)
289 if (strip) strip.setCurrent(id)
290 if (notesPane) notesPane.hidden = !id || !notesOn()
291 const dup = root.querySelector('[data-command="duplicate-slide"]')
292 if (dup) dup.disabled = !id
293 if (selection) selection.reapply()
294 if (inspector) inspector.fill()
295 if (canvas) canvas.place()
296 // The stage's whole DOM was just replaced, presence's own overlay
297 // element with it: place() re-attaches it, and a slide change is
298 // somewhere this tab went.
299 presenceView.place()
300 presence.changed()
301 }
302 function onLoadError () {
303 status.loadFailed(true)
304 }
305
306 // Thumbnails re-fetch a little after the change that dirtied them,
307 // and never before a local change has been saved: flushSoon is what
308 // remote changes call, flushNow is what a landed save calls.
309 function flushSoon () {
310 if (thumbTimer) clearTimeout(thumbTimer)
311 thumbTimer = setTimeout(() => { thumbTimer = null; strip && strip.flush() }, THUMB_MS)
312 }
313
314 // A local structural change is re-fetched once its save has landed
315 // (the server cannot render what it has not received yet). Deferred
316 // a tick, because observers run before the doc's 'update' event
317 // hands the bytes to the queue; one refresh per burst.
318 let refreshPending = false
319 function refreshAfterSave () {
320 if (refreshPending) return
321 refreshPending = true
322 setTimeout(async () => {
323 try {
324 if (currentQueue) await currentQueue.flush()
325 } finally {
326 refreshPending = false
327 }
328 await refreshStages()
329 }, 0)
330 }
331 let fillPending = false
332 function fillSoon () {
333 if (fillPending) return
334 fillPending = true
335 requestAnimationFrame(() => { fillPending = false; inspector && inspector.fill() })
336 }
337
338 function watchDoc (doc, slides, meta) {
339 let order = slideIDs(doc)
340 slides.observeDeep((events, tr) => {
341 const c = classify(events, slides)
342 const current = stage ? stage.slideId : ''
343 if (c.order) {
344 strip.sync()
345 const ids = slideIDs(doc)
346 if (!ids.includes(current)) {
347 const next = slideAfter(order, ids, current)
348 location.hash = next ? '#' + next : ''
349 if (!next) showSlide('')
350 }
351 order = ids
352 }
353 for (const id of c.text) strip.mark(id)
354 for (const id of c.structure) {
355 strip.mark(id)
356 // The server has already rendered a remote change; a local one
357 // — this tab's op, or its undo — is re-fetched once saved.
358 if (id !== current) continue
359 if (tr.origin === REMOTE) showSlide(current)
360 else refreshAfterSave()
361 }
362 if (tr.origin === REMOTE) flushSoon()
363 fillSoon()
364 })
365 // The theme, accent and fonts are on every section's style; the
366 // title is the page's heading.
367 meta.observe((event, tr) => {
368 const title = meta.get('title')
369 if (typeof title === 'string' && titleEl) {
370 titleEl.textContent = title
371 document.title = title
372 }
373 if ([...event.keysChanged].some(k => k !== 'title')) {
374 for (const id of slideIDs(doc)) strip.mark(id)
375 if (tr.origin === REMOTE) { refreshStages(); flushSoon() } else refreshAfterSave()
376 }
377 fillSoon()
378 })
379 // The assets map is the third root (spec §2.7), and it is watched
380 // for the same reason meta is: nothing else observes it, so
381 // registering or removing an image would otherwise change the
382 // document and leave the Unused images list — and every slide that
383 // draws the picture — showing what was true a moment ago.
384 doc.getMap('assets').observe((event, tr) => {
385 for (const id of slideIDs(doc)) strip.mark(id)
386 if (tr.origin === REMOTE) { refreshStages(); flushSoon() } else refreshAfterSave()
387 fillSoon()
388 })
389 }
390
391 function makeSaveQueue (getLive) {
392 return createSaveQueue({
393 post: async bytes => {
394 const res = await fetch(base, {
395 method: 'POST',
396 credentials: 'same-origin',
397 headers: { 'Content-Type': 'application/octet-stream' },
398 body: bytes
399 })
400 const live = getLive()
401 if (res.ok) {
402 const seq = Number(res.headers.get('Slides-Seq'))
403 if (live && Number.isFinite(seq)) live.noteOwnSeq(seq)
404 noteSeq(seq)
405 }
406 if (res.ok && strip) strip.flush()
407 return res
408 },
409 mergeUpdates: Y.mergeUpdates,
410 debounceMs: DEBOUNCE_MS,
411 onStatus: state => {
412 if (state === 'failed') {
413 status.drop()
414 resyncController.reportDivergence()
415 }
416 setStatus(queueStatusState(state))
417 }
418 })
419 }
420
421 // --- the views: desktop, phone, both (switcher.mjs) ---
422
423 function notesOn () {
424 try { return localStorage.getItem(NOTES_KEY) !== 'off' } catch { return true }
425 }
426 const notesToggle = stagePane.querySelector('[slides-notes-toggle] input')
427 if (notesToggle) {
428 notesToggle.checked = notesOn()
429 notesToggle.addEventListener('change', () => {
430 try { localStorage.setItem(NOTES_KEY, notesToggle.checked ? 'on' : 'off') } catch { /* per-session then */ }
431 if (notesPane) notesPane.hidden = !notesToggle.checked || !(stage && stage.slideId)
432 })
433 }
434 async function applyView (v) {
435 currentView = v
436 stagePane.dataset.view = v
437 phoneEl.hidden = v !== 'both'
438 if (!stage) return
439 await stage.setViewport(v === 'phone' ? 'phone' : 'desktop')
440 if (v === 'both') await phoneStage.load(stage.slideId)
441 if (inspector) inspector.fill()
442 if (canvas) canvas.place()
443 }
444
445 // attemptResync builds the whole editor from the server's log — the
446 // first mount and every later resync alike (Docs' shape; see its
447 // resync-controller.mjs header for why a rebuild is the only way
448 // back once a batch has been refused).
449 async function attemptResync () {
450 const { frames, seq } = await fetchFrames(updatesURL(base, { since: 0 }))
451 noteSeq(seq)
452 const doc = new Y.Doc()
453 for (const f of frames) Y.applyUpdate(doc, f, REMOTE)
454 const slides = doc.getArray('slides')
455
456 let lifecycleLive = null
457 const saveQueue = makeSaveQueue(() => lifecycleLive)
458 doc.on('update', (update, origin) => {
459 if (origin === REMOTE) return
460 if (resyncController.diverged) return
461 setStatus('saving')
462 saveQueue.enqueue(update)
463 })
464
465 // The slide whose HTML is on the stage right now: the server's on
466 // the first mount, the previous stage's on a resync. The new stage
467 // binds to that DOM, so it has to be told which slide it is.
468 const shown = stage ? stage.slideId : root.dataset.slide
469 const selected = selection ? selection.current : parseHash(location.hash).block
470 // Everything mounted on the page's own long-lived elements — the
471 // strip's <ol>, the stage, the inspector's forms — is let go of
472 // before its replacement is mounted on the same element. Without
473 // this a resync leaves the previous mount's listeners behind, and
474 // a click reaches one handler per resync this session has had,
475 // each holding a Y.Doc nobody is editing any more.
476 if (stage) stage.destroy()
477 if (phoneStage) phoneStage.destroy()
478 if (canvas) canvas.destroy()
479 if (strip) strip.destroy()
480 if (selection) selection.destroy()
481 if (inspector) inspector.destroy()
482 if (currentLive) currentLive.stop()
483 if (undoManager) undoManager.destroy()
484
485 ydoc = doc
486 undoManager = new Y.UndoManager([slides, doc.getMap('meta'), doc.getMap('assets')], {
487 trackedOrigins: new Set([ySyncPluginKey, LOCAL]),
488 captureTimeout: 500
489 })
490 currentQueue = saveQueue
491 strip = mountStrip({
492 root: stripEl, ydoc: doc, thumbURL, slideLabel: text.slide,
493 template: root.querySelector('[slides-strip-item-template]'),
494 onCommand: (name, id) => commands.strip(name, id)
495 })
496 strip.sync()
497 // Mounted as the desktop rendering the server painted; applyView
498 // below re-fetches if the stored view is another one.
499 stage = mountStage({ root: stagePane, ydoc: doc, slideId: shown, fetchSlide, keys, onLoaded, onError: onLoadError })
500 phoneStage = mountStage({ el: phoneEl, ydoc: doc, slideId: '', fetchSlide, keys, vp: 'phone', readOnly: true })
501 selection = mountSelection({
502 stageEl,
503 getSlideId: () => stage.slideId,
504 onChange: () => { inspector.fill(); canvas.place(); presenceView.place(); presence.changed() }
505 })
506 inspector = mountInspector({
507 root: inspectorEl, ydoc: doc, stageEl,
508 getSlideId: () => stage.slideId,
509 getSelected: () => selection.current,
510 getView: () => currentView,
511 commit,
512 onCommand: name => commands.run(name),
513 select: id => { selection.select(id); if (stage.slideId && canvas) canvas.focus() },
514 labels: { types, upload: uploadText },
515 upload: (file, opts) => uploader.upload(file, opts)
516 })
517 canvas = mountCanvas({
518 stageEl,
519 getSelected: () => selection.current,
520 label: root.dataset.textCanvasSelected,
521 onCommit: (id, box) => {
522 const block = blockJSON(stage.slideId, id)
523 if (block) commit([routeEdit({ view: currentView, slide: stage.slideId, block, field: 'box', value: box })])
524 }
525 })
526 watchDoc(doc, slides, doc.getMap('meta'))
527 const wanted = wantedSlide()
528 if (wanted !== stage.slideId) await showSlide(wanted)
529 else onLoaded(wanted)
530 if (selected) selection.select(selected)
531 await applyView(currentView)
532
533 lifecycleLive = createLiveUpdates({
534 initialSeq: Number.isFinite(seq) ? seq : 0,
535 openStream: (onEvent, onStreamError) => {
536 const es = new EventSource(streamURL, { withCredentials: true })
537 es.addEventListener('update', ev => onEvent(Number(ev.data)))
538 es.addEventListener('error', ev => onStreamError(ev))
539 // The same stream carries `event: presence` (live.go), the
540 // whole set every time. live-updates.mjs is Docs' module,
541 // copied verbatim, and knows nothing of it — this is the thin
542 // wrapper the plan calls for, added onto the one EventSource
543 // rather than a second connection.
544 es.addEventListener('presence', ev => {
545 let set = []
546 try { set = JSON.parse(ev.data) } catch { /* a bad frame draws nothing, not an error */ }
547 const diff = presence.applySet(set)
548 presenceView.render(diff.people)
549 presenceView.announce(diff)
550 })
551 return { close: () => es.close() }
552 },
553 fetchSince: async since => {
554 const r = await fetchFrames(updatesURL(base, { since }))
555 noteSeq(r.seq)
556 return { bytes: r.frames, seq: r.seq }
557 },
558 onUpdates: frames => {
559 if (resyncController.diverged) return
560 for (const f of frames) Y.applyUpdate(doc, f, REMOTE)
561 },
562 onError: err => {
563 // A dead stream is lost freshness, not a lost save: POST is a
564 // separate path. EventSource also reports its ordinary close
565 // during a page reload as an Event; that is expected teardown,
566 // not a console error. A real fetch/decode Error is still
567 // useful diagnostic evidence.
568 if (err instanceof Error) console.error('live updates:', err)
569 }
570 })
571 currentLive = lifecycleLive
572 currentLive.start()
573 if (document.hidden) currentLive.noteHidden()
574 // Presence outlives a resync (it is mounted once, above), but a
575 // reconnect is still the moment to say "I am here" again — the
576 // same reasoning currentLive's own start() follows.
577 presence.start()
578 if (document.hidden) presence.noteHidden()
579 }
580
581 const resyncController = createResyncController({
582 attemptResync,
583 onDiverged: () => setStatus('retrying'),
584 // 'saved' as the queue's word; the line still says failed after a
585 // drop, because statusState ranks it first.
586 onRecovered: () => setStatus('saved')
587 })
588
589 // Reading localStorage AT ALL throws where site data is blocked, so
590 // the access is guarded here as well as inside switcher.mjs — the
591 // module's try/catch cannot help with an argument that threw before
592 // the call was made.
593 let storage = null
594 try { storage = globalThis.localStorage } catch { /* the choice is for this session then */ }
595 mountSwitcher({ root: stagePane.querySelector('[slides-switcher]'), storage, onChange: v => { applyView(v) } })
596 await attemptResync()
597 setStatus('saved')
598
599 // --- structural commands: the menu's, the inspector's, the strip's ---
600
601 const commands = createCommands({
602 ydoc: () => ydoc,
603 commit,
604 flush: () => currentQueue.flush(),
605 slideId: () => stage.slideId,
606 selected: () => selection.current,
607 slideJSON,
608 blockJSON,
609 undo: () => undoManager.undo(),
610 redo: () => undoManager.redo(),
611 present: () => presentForm && presentForm.requestSubmit(),
612 clearSelection: () => selection.clear(),
613 confirmEl: inspectorEl.querySelector('[slides-confirm]')
614 })
615 mountReorder({ root: stripEl, getIDs: () => slideIDs(ydoc), onMove: op => commit([op]), onNudge: commands.moveSlide })
616
617 root.addEventListener('click', e => {
618 const btn = e.target.closest('[data-command]')
619 if (!btn || inspectorEl.contains(btn) || !commands.has(btn.dataset.command)) return
620 const menu = btn.closest('details')
621 if (menu) menu.open = false
622 commands.run(btn.dataset.command)
623 })
624
625 // A native modal makes the page inert visually, but its key and
626 // clipboard events still bubble to document. Stop them at window's
627 // capture phase while a dialog is open so editor shortcuts cannot
628 // write behind the link prompt. Do not prevent the default: typing,
629 // Tab, Enter and Escape remain the dialog's native behaviour.
630 const modalGuard = e => {
631 if (document.querySelector('dialog[open]')) e.stopPropagation()
632 }
633 for (const type of ['keydown', 'keyup', 'beforeinput', 'input', 'copy', 'cut', 'paste']) {
634 addEventListener(type, modalGuard, { capture: true })
635 }
636
637 // The document-level keys. Inside a text block ProseMirror has
638 // already handled (and defaultPrevented) undo/redo; the slide keys
639 // are not bound there, so they arrive here from anywhere.
640 document.addEventListener('keydown', e => {
641 if (e.defaultPrevented) return
642 const mod = e.ctrlKey || e.metaKey
643 if (!mod) return
644 const key = e.key.toLowerCase()
645 let cmd = null
646 if (key === 'enter' && !e.shiftKey) cmd = 'new-slide'
647 else if (key === 'd' && !e.shiftKey) cmd = 'duplicate-slide'
648 else if (key === 'f' && e.shiftKey) cmd = 'present'
649 else if (key === 'z' && !e.target.closest('.ProseMirror')) cmd = e.shiftKey ? 'redo' : 'undo'
650 else if (key === 'y' && !e.target.closest('.ProseMirror')) cmd = 'redo'
651 if (!cmd) return
652 e.preventDefault()
653 commands.run(cmd)
654 })
655
656 // A hash naming another slide loads it; one naming a block on this
657 // slide selects it (the strip's links name a slide only).
658 addEventListener('hashchange', async () => {
659 const wanted = wantedSlide()
660 const { block } = parseHash(location.hash)
661 if (wanted !== stage.slideId) await showSlide(wanted)
662 if (block) selection.select(block)
663 else if (location.hash === formatHash(stage.slideId)) selection.clear()
664 })
665
666 // Activity keeps the stream open; a hidden tab lets it close sooner
667 // (live-updates.mjs has the measurements).
668 addEventListener('visibilitychange', () => {
669 if (document.hidden) {
670 if (currentLive) currentLive.noteHidden()
671 presence.noteHidden()
672 } else {
673 if (currentLive) currentLive.noteActivity()
674 presence.noteVisible()
675 }
676 })
677 for (const name of ['pointermove', 'scroll', 'keydown', 'focus']) {
678 addEventListener(name, () => currentLive && currentLive.noteActivity(), { passive: true, capture: true })
679 }
680
681 // A close with unsent bytes beacons them (Docs' rule, and its
682 // keepalive fallback for a beacon the browser refuses).
683 addEventListener('beforeunload', () => {
684 const bytes = currentQueue && currentQueue.pendingBytes()
685 if (!bytes) return
686 const blob = new Blob([bytes], { type: 'application/octet-stream' })
687 if (!navigator.sendBeacon(base, blob)) {
688 fetch(base, {
689 method: 'POST',
690 credentials: 'same-origin',
691 keepalive: true,
692 headers: { 'Content-Type': 'application/octet-stream' },
693 body: bytes
694 }).catch(() => {})
695 }
696 })
697}
698
699const mount = document.querySelector('[slides-editor]')
700if (mount) {
701 mountEditor(mount).catch(err => {
702 // The server-rendered page stays readable; only editing is lost.
703 mount.setAttribute('data-editor-failed', '1')
704 console.error(err)
705 })
706}
707