| 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. |
| 23 | import * as Y from 'yjs' |
| 24 | import { ySyncPluginKey } from 'y-prosemirror' |
| 25 | import { parseFrames } from './frames.mjs' |
| 26 | import { createSaveQueue } from './save-queue.mjs' |
| 27 | import { mountSaveStatus, queueStatusState } from './status.mjs' |
| 28 | import { createLiveUpdates, updatesURL } from './live-updates.mjs' |
| 29 | import { createResyncController } from './resync-controller.mjs' |
| 30 | import { mountStage, slideIDs, slideAfter } from './stage.mjs' |
| 31 | import { mountStrip } from './strip.mjs' |
| 32 | import { mountReorder } from './strip-reorder.mjs' |
| 33 | import { classify } from './changes.mjs' |
| 34 | import { textKeys } from './text-keys.mjs' |
| 35 | import { mountLinkDialog, linkPrompt } from './link-dialog.mjs' |
| 36 | import { applyOps, view } from './ops.mjs' |
| 37 | import { mountSelection, parseHash, formatHash } from './selection.mjs' |
| 38 | import { mountInspector } from './inspector.mjs' |
| 39 | import { mountCanvas } from './canvas.mjs' |
| 40 | import { mountSwitcher, routeEdit } from './switcher.mjs' |
| 41 | import { createCommands } from './commands.mjs' |
| 42 | import { createPresence, newClientId } from './presence.mjs' |
| 43 | import { tabClientId, claimClientId, clientIdKey } from './client-id.mjs' |
| 44 | import { mountPresenceView } from './presence-view.mjs' |
| 45 | import { 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. |
| 50 | const 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). |
| 58 | const REMOTE = 'remote' |
| 59 | const LOCAL = 'local' |
| 60 | |
| 61 | // How long after a change a thumbnail is re-fetched. |
| 62 | const THUMB_MS = 600 |
| 63 | const NOTES_KEY = 'slides.editor.notes' |
| 64 | |
| 65 | async 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 | |
| 74 | export 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 | |
| 699 | const mount = document.querySelector('[slides-editor]') |
| 700 | if (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 | |