// fixtures/editor-entry.js — the editor bundle's entry: wiring only. // fixtures/build-bundles.mjs bundles this file and everything it // imports into internal/slides/static/editor..js, the one // script the app serves (spec §8: the editor is the one surface that // needs it). // // The shape is Docs' editor-entry.js, adopted: load the log into a // Y.Doc, bind the editor, save what this tab produces through the // save queue, hear about everybody else's writes on the stream and // fetch them through live-updates, rebuild from server truth through // the resync controller when a batch is refused. What differs is what // the editor IS: not one ProseMirror over one fragment but a stage // (stage.mjs) binding a view to every text block of the server's own // slide HTML, a strip (strip.mjs) of the server's SVG thumbnails, an // inspector (inspector.mjs) whose every control is one op, a canvas // overlay (canvas.mjs), and the desktop/phone switcher (switcher.mjs). // Every structural edit goes through the same ops vocabulary the API // uses (ops.mjs) and ends in a re-fetch of the slide once the save has // landed — the server renders; this tab never does. // // yjs stays at the version fixtures/package.json pins for the vectors: // this bundle and core/'s Rust CRDT agree on one wire format. import * as Y from 'yjs' import { ySyncPluginKey } from 'y-prosemirror' import { parseFrames } from './frames.mjs' import { createSaveQueue } from './save-queue.mjs' import { mountSaveStatus, queueStatusState } from './status.mjs' import { createLiveUpdates, updatesURL } from './live-updates.mjs' import { createResyncController } from './resync-controller.mjs' import { mountStage, slideIDs, slideAfter } from './stage.mjs' import { mountStrip } from './strip.mjs' import { mountReorder } from './strip-reorder.mjs' import { classify } from './changes.mjs' import { textKeys } from './text-keys.mjs' import { mountLinkDialog, linkPrompt } from './link-dialog.mjs' import { applyOps, view } from './ops.mjs' import { mountSelection, parseHash, formatHash } from './selection.mjs' import { mountInspector } from './inspector.mjs' import { mountCanvas } from './canvas.mjs' import { mountSwitcher, routeEdit } from './switcher.mjs' import { createCommands } from './commands.mjs' import { createPresence, newClientId } from './presence.mjs' import { tabClientId, claimClientId, clientIdKey } from './client-id.mjs' import { mountPresenceView } from './presence-view.mjs' import { createUploader, refusal } from './uploads.mjs' // Coalesce a burst of typing into one request. Docs' number and Docs' // reasoning: under the ~300ms at which a delay stops reading as "the // app responded", and the floor on how fast an edit reaches anyone. const DEBOUNCE_MS = 250 // Origins. Bytes the server sent are 'remote' and are never saved // back; this tab's structural ops are 'local'; y-prosemirror tags its // own transactions with ySyncPluginKey; an undo's origin is the undo // manager itself. The undo manager tracks the middle two and only // those, so undo is this person's edits and never somebody else's // (spec §3.1). const REMOTE = 'remote' const LOCAL = 'local' // How long after a change a thumbnail is re-fetched. const THUMB_MS = 600 const NOTES_KEY = 'slides.editor.notes' async function fetchFrames (url) { const res = await fetch(url, { credentials: 'same-origin' }) if (!res.ok) throw new Error(`load ${url}: ${res.status}`) return { frames: parseFrames(new Uint8Array(await res.arrayBuffer())), seq: Number(res.headers.get('Slides-Seq')) } } export async function mountEditor (root) { const sqid = root.dataset.deck const base = `/d/${sqid}/updates` const streamURL = `/d/${sqid}/stream` const fetchSlide = async (id, vp) => { const res = await fetch(`/d/${sqid}/slides/${id}.html?vp=${vp}`, { credentials: 'same-origin' }) if (!res.ok) throw new Error(`slide ${id}: ${res.status}`) return res.text() } // A thumbnail is an of the server's SVG (internal/slides/ // thumbs.go). The deck's seq is on the URL so the browser asks again // after a change and reads its own cache in between; the route // revalidates on an ETag, so the ask is a 304 for every slide that // did not actually move. noteSeq is called wherever a seq arrives — // this tab's own save, and a batch pulled in from somebody else's. let thumbSeq = Number(root.dataset.seq) || 0 const noteSeq = seq => { if (Number.isFinite(seq) && seq > thumbSeq) thumbSeq = seq } const thumbURL = id => `/d/${sqid}/slides/${id}.svg?seq=${thumbSeq}` const status = await mountSaveStatus(root) const titleEl = root.querySelector('[slides-editor-title]') const stripEl = root.querySelector('[slides-strip]') const stagePane = root.querySelector('[slides-stage-pane]') const stageEl = stagePane.querySelector('[slides-stage]') const phoneEl = stagePane.querySelector('[slides-stage-phone]') const notesPane = stagePane.querySelector('[slides-notes-pane]') const inspectorEl = root.querySelector('[slides-inspector]') const presentForm = root.querySelector('[slides-present-form]') const dialog = mountLinkDialog(root.querySelector('[slides-link-dialog]')) const text = { slide: root.dataset.textSlide } const presenceText = { slide: root.dataset.textPresenceSlide, block: root.dataset.textPresenceBlock, more: root.dataset.textPresenceMore, joined: root.dataset.textPresenceJoined, left: root.dataset.textPresenceLeft } const types = {} for (const k of Object.keys(root.dataset)) if (k.startsWith('type')) types[k.slice(4).toLowerCase()] = root.dataset[k] const uploadText = { uploading: root.dataset.textUploadUploading, checking: root.dataset.textUploadChecking, done: root.dataset.textUploadDone, failed: root.dataset.textUploadFailed, toolarge: root.dataset.textUploadToolarge, type: root.dataset.textUploadType, altfirst: root.dataset.textUploadAltfirst, remove: root.dataset.textAssetRemove } // Images (assets.go, spec §2.7). The three requests are here — this // is the only file that knows this deck's URLs — and the state // machine that sequences them is uploads.mjs. // // The PUT goes wherever the sign step said: the bucket itself with a // real store, or back to this app in development, where blobs.Dir has // nothing to presign. Either way it carries no credentials of ours — // a presigned URL is the credential, and sending a cookie to a bucket // would be a mistake with no upside. const assetJSON = async (path, body) => { const res = await fetch(`/d/${sqid}${path}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) if (!res.ok) throw await refusal(res) return res.json() } const uploader = createUploader({ sign: body => assetJSON('/uploads', body), put: async (url, file, headers) => { const sameOrigin = url.startsWith('/') const res = await fetch(url, { method: 'PUT', credentials: sameOrigin ? 'same-origin' : 'omit', headers, body: file }) if (!res.ok) throw await refusal(res) }, register: body => assetJSON('/assets', body), applyOp: () => {}, onState: (state, detail) => { if (inspector) inspector.onUploadState(state, detail) } }) // A dropped batch is stickier than any one save queue's status: once // a batch is ever dropped, the person keeps seeing it (Docs' rule) — // as `failed`, its own state, because a dropped batch is not being // retried and "Offline, retrying" would say it is. A slide that // would not load is said in its own line until one does. The rules // are status.mjs, where they are tested. const setStatus = state => status.save(state) // Whichever of these is live right now. A resync replaces them all. let ydoc = null let undoManager = null let stage = null let phoneStage = null let strip = null let selection = null let inspector = null let canvas = null let currentQueue = null let currentLive = null let thumbTimer = null let currentView = 'desktop' // --- presence: who else has this deck open, and where (spec §3.3) --- // // Presence outlives any one Y.Doc — it is not document state, and a // resync (a new stage, a new selection, a new everything above) is // not somebody leaving the deck. So this is mounted once, over the // strip's own
    and the stage's own element, and never destroyed // or recreated the way stage/strip/selection/inspector are inside // attemptResync. getSlideId and getState read `stage` and // `selection` by closure, live, whichever resync's mount is current. const presenceView = mountPresenceView({ stripEl, stageEl, liveEl: root.querySelector('[slides-presence-live]'), text: presenceText, getSlideId: () => (stage ? stage.slideId : '') }) // One id per tab, kept across a reload (client-id.mjs): a fresh one // per load drew this tab's own previous self as a colleague until the // server expired it. A duplicated tab copies the stored id, so the // claim below makes it give the copy up for a fresh one. let tabStorage = null try { tabStorage = globalThis.sessionStorage } catch { /* a fresh id per load then */ } const presence = createPresence({ clientId: tabClientId({ storage: tabStorage, deck: sqid }), post: body => fetch(`/d/${sqid}/presence`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), getState: () => ({ slide: stage ? stage.slideId : '', block: selection ? selection.current : '' }) }) let idChannel = null try { idChannel = new BroadcastChannel(clientIdKey(sqid)) } catch { /* no rival check */ } claimClientId({ channel: idChannel, getId: () => presence.clientId, onTaken: () => { const id = newClientId() try { if (tabStorage) tabStorage.setItem(clientIdKey(sqid), id) } catch { /* per-load then */ } presence.setClientId(id) } }).claim() const keys = textKeys({ undo: () => undoManager && undoManager.undo(), redo: () => undoManager && undoManager.redo(), link: view => linkPrompt(view, dialog) }) // --- the one write path: every structural edit is a batch of ops --- // A batch is one undo step: the undo manager would otherwise fold // two commands made within its capture window (500 ms — a menu // press right after a field change) into one, and undo would take // both back at once. function commit (ops) { try { if (undoManager) undoManager.stopCapturing() applyOps(ydoc, ops, LOCAL) } catch (err) { console.error('ops:', err) if (inspector) inspector.fill() } } const slideJSON = id => view(ydoc).slides.find(s => s.id === id) || null const blockJSON = (slideId, id) => { const s = slideJSON(slideId) return s ? s.blocks.find(b => b.id === id) || null : null } // --- the stage follows the URL hash: #[/] --- function wantedSlide () { const ids = slideIDs(ydoc) const { slide } = parseHash(location.hash) return ids.includes(slide) ? slide : (ids[0] || '') } async function showSlide (id) { if (!stage) return try { await stage.load(id) if (phoneStage && currentView === 'both') await phoneStage.load(id) } catch (err) { console.error('stage:', err) } } async function refreshStages () { if (!stage) return try { await stage.refresh() if (phoneStage && currentView === 'both') await phoneStage.refresh() } catch (err) { console.error('stage:', err) } } function onLoaded (id) { status.loadFailed(false) if (strip) strip.setCurrent(id) if (notesPane) notesPane.hidden = !id || !notesOn() const dup = root.querySelector('[data-command="duplicate-slide"]') if (dup) dup.disabled = !id if (selection) selection.reapply() if (inspector) inspector.fill() if (canvas) canvas.place() // The stage's whole DOM was just replaced, presence's own overlay // element with it: place() re-attaches it, and a slide change is // somewhere this tab went. presenceView.place() presence.changed() } function onLoadError () { status.loadFailed(true) } // Thumbnails re-fetch a little after the change that dirtied them, // and never before a local change has been saved: flushSoon is what // remote changes call, flushNow is what a landed save calls. function flushSoon () { if (thumbTimer) clearTimeout(thumbTimer) thumbTimer = setTimeout(() => { thumbTimer = null; strip && strip.flush() }, THUMB_MS) } // A local structural change is re-fetched once its save has landed // (the server cannot render what it has not received yet). Deferred // a tick, because observers run before the doc's 'update' event // hands the bytes to the queue; one refresh per burst. let refreshPending = false function refreshAfterSave () { if (refreshPending) return refreshPending = true setTimeout(async () => { try { if (currentQueue) await currentQueue.flush() } finally { refreshPending = false } await refreshStages() }, 0) } let fillPending = false function fillSoon () { if (fillPending) return fillPending = true requestAnimationFrame(() => { fillPending = false; inspector && inspector.fill() }) } function watchDoc (doc, slides, meta) { let order = slideIDs(doc) slides.observeDeep((events, tr) => { const c = classify(events, slides) const current = stage ? stage.slideId : '' if (c.order) { strip.sync() const ids = slideIDs(doc) if (!ids.includes(current)) { const next = slideAfter(order, ids, current) location.hash = next ? '#' + next : '' if (!next) showSlide('') } order = ids } for (const id of c.text) strip.mark(id) for (const id of c.structure) { strip.mark(id) // The server has already rendered a remote change; a local one // — this tab's op, or its undo — is re-fetched once saved. if (id !== current) continue if (tr.origin === REMOTE) showSlide(current) else refreshAfterSave() } if (tr.origin === REMOTE) flushSoon() fillSoon() }) // The theme, accent and fonts are on every section's style; the // title is the page's heading. meta.observe((event, tr) => { const title = meta.get('title') if (typeof title === 'string' && titleEl) { titleEl.textContent = title document.title = title } if ([...event.keysChanged].some(k => k !== 'title')) { for (const id of slideIDs(doc)) strip.mark(id) if (tr.origin === REMOTE) { refreshStages(); flushSoon() } else refreshAfterSave() } fillSoon() }) // The assets map is the third root (spec §2.7), and it is watched // for the same reason meta is: nothing else observes it, so // registering or removing an image would otherwise change the // document and leave the Unused images list — and every slide that // draws the picture — showing what was true a moment ago. doc.getMap('assets').observe((event, tr) => { for (const id of slideIDs(doc)) strip.mark(id) if (tr.origin === REMOTE) { refreshStages(); flushSoon() } else refreshAfterSave() fillSoon() }) } function makeSaveQueue (getLive) { return createSaveQueue({ post: async bytes => { const res = await fetch(base, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/octet-stream' }, body: bytes }) const live = getLive() if (res.ok) { const seq = Number(res.headers.get('Slides-Seq')) if (live && Number.isFinite(seq)) live.noteOwnSeq(seq) noteSeq(seq) } if (res.ok && strip) strip.flush() return res }, mergeUpdates: Y.mergeUpdates, debounceMs: DEBOUNCE_MS, onStatus: state => { if (state === 'failed') { status.drop() resyncController.reportDivergence() } setStatus(queueStatusState(state)) } }) } // --- the views: desktop, phone, both (switcher.mjs) --- function notesOn () { try { return localStorage.getItem(NOTES_KEY) !== 'off' } catch { return true } } const notesToggle = stagePane.querySelector('[slides-notes-toggle] input') if (notesToggle) { notesToggle.checked = notesOn() notesToggle.addEventListener('change', () => { try { localStorage.setItem(NOTES_KEY, notesToggle.checked ? 'on' : 'off') } catch { /* per-session then */ } if (notesPane) notesPane.hidden = !notesToggle.checked || !(stage && stage.slideId) }) } async function applyView (v) { currentView = v stagePane.dataset.view = v phoneEl.hidden = v !== 'both' if (!stage) return await stage.setViewport(v === 'phone' ? 'phone' : 'desktop') if (v === 'both') await phoneStage.load(stage.slideId) if (inspector) inspector.fill() if (canvas) canvas.place() } // attemptResync builds the whole editor from the server's log — the // first mount and every later resync alike (Docs' shape; see its // resync-controller.mjs header for why a rebuild is the only way // back once a batch has been refused). async function attemptResync () { const { frames, seq } = await fetchFrames(updatesURL(base, { since: 0 })) noteSeq(seq) const doc = new Y.Doc() for (const f of frames) Y.applyUpdate(doc, f, REMOTE) const slides = doc.getArray('slides') let lifecycleLive = null const saveQueue = makeSaveQueue(() => lifecycleLive) doc.on('update', (update, origin) => { if (origin === REMOTE) return if (resyncController.diverged) return setStatus('saving') saveQueue.enqueue(update) }) // The slide whose HTML is on the stage right now: the server's on // the first mount, the previous stage's on a resync. The new stage // binds to that DOM, so it has to be told which slide it is. const shown = stage ? stage.slideId : root.dataset.slide const selected = selection ? selection.current : parseHash(location.hash).block // Everything mounted on the page's own long-lived elements — the // strip's
      , the stage, the inspector's forms — is let go of // before its replacement is mounted on the same element. Without // this a resync leaves the previous mount's listeners behind, and // a click reaches one handler per resync this session has had, // each holding a Y.Doc nobody is editing any more. if (stage) stage.destroy() if (phoneStage) phoneStage.destroy() if (canvas) canvas.destroy() if (strip) strip.destroy() if (selection) selection.destroy() if (inspector) inspector.destroy() if (currentLive) currentLive.stop() if (undoManager) undoManager.destroy() ydoc = doc undoManager = new Y.UndoManager([slides, doc.getMap('meta'), doc.getMap('assets')], { trackedOrigins: new Set([ySyncPluginKey, LOCAL]), captureTimeout: 500 }) currentQueue = saveQueue strip = mountStrip({ root: stripEl, ydoc: doc, thumbURL, slideLabel: text.slide, template: root.querySelector('[slides-strip-item-template]'), onCommand: (name, id) => commands.strip(name, id) }) strip.sync() // Mounted as the desktop rendering the server painted; applyView // below re-fetches if the stored view is another one. stage = mountStage({ root: stagePane, ydoc: doc, slideId: shown, fetchSlide, keys, onLoaded, onError: onLoadError }) phoneStage = mountStage({ el: phoneEl, ydoc: doc, slideId: '', fetchSlide, keys, vp: 'phone', readOnly: true }) selection = mountSelection({ stageEl, getSlideId: () => stage.slideId, onChange: () => { inspector.fill(); canvas.place(); presenceView.place(); presence.changed() } }) inspector = mountInspector({ root: inspectorEl, ydoc: doc, stageEl, getSlideId: () => stage.slideId, getSelected: () => selection.current, getView: () => currentView, commit, onCommand: name => commands.run(name), select: id => { selection.select(id); if (stage.slideId && canvas) canvas.focus() }, labels: { types, upload: uploadText }, upload: (file, opts) => uploader.upload(file, opts) }) canvas = mountCanvas({ stageEl, getSelected: () => selection.current, label: root.dataset.textCanvasSelected, onCommit: (id, box) => { const block = blockJSON(stage.slideId, id) if (block) commit([routeEdit({ view: currentView, slide: stage.slideId, block, field: 'box', value: box })]) } }) watchDoc(doc, slides, doc.getMap('meta')) const wanted = wantedSlide() if (wanted !== stage.slideId) await showSlide(wanted) else onLoaded(wanted) if (selected) selection.select(selected) await applyView(currentView) lifecycleLive = createLiveUpdates({ initialSeq: Number.isFinite(seq) ? seq : 0, openStream: (onEvent, onStreamError) => { const es = new EventSource(streamURL, { withCredentials: true }) es.addEventListener('update', ev => onEvent(Number(ev.data))) es.addEventListener('error', ev => onStreamError(ev)) // The same stream carries `event: presence` (live.go), the // whole set every time. live-updates.mjs is Docs' module, // copied verbatim, and knows nothing of it — this is the thin // wrapper the plan calls for, added onto the one EventSource // rather than a second connection. es.addEventListener('presence', ev => { let set = [] try { set = JSON.parse(ev.data) } catch { /* a bad frame draws nothing, not an error */ } const diff = presence.applySet(set) presenceView.render(diff.people) presenceView.announce(diff) }) return { close: () => es.close() } }, fetchSince: async since => { const r = await fetchFrames(updatesURL(base, { since })) noteSeq(r.seq) return { bytes: r.frames, seq: r.seq } }, onUpdates: frames => { if (resyncController.diverged) return for (const f of frames) Y.applyUpdate(doc, f, REMOTE) }, onError: err => { // A dead stream is lost freshness, not a lost save: POST is a // separate path. EventSource also reports its ordinary close // during a page reload as an Event; that is expected teardown, // not a console error. A real fetch/decode Error is still // useful diagnostic evidence. if (err instanceof Error) console.error('live updates:', err) } }) currentLive = lifecycleLive currentLive.start() if (document.hidden) currentLive.noteHidden() // Presence outlives a resync (it is mounted once, above), but a // reconnect is still the moment to say "I am here" again — the // same reasoning currentLive's own start() follows. presence.start() if (document.hidden) presence.noteHidden() } const resyncController = createResyncController({ attemptResync, onDiverged: () => setStatus('retrying'), // 'saved' as the queue's word; the line still says failed after a // drop, because statusState ranks it first. onRecovered: () => setStatus('saved') }) // Reading localStorage AT ALL throws where site data is blocked, so // the access is guarded here as well as inside switcher.mjs — the // module's try/catch cannot help with an argument that threw before // the call was made. let storage = null try { storage = globalThis.localStorage } catch { /* the choice is for this session then */ } mountSwitcher({ root: stagePane.querySelector('[slides-switcher]'), storage, onChange: v => { applyView(v) } }) await attemptResync() setStatus('saved') // --- structural commands: the menu's, the inspector's, the strip's --- const commands = createCommands({ ydoc: () => ydoc, commit, flush: () => currentQueue.flush(), slideId: () => stage.slideId, selected: () => selection.current, slideJSON, blockJSON, undo: () => undoManager.undo(), redo: () => undoManager.redo(), present: () => presentForm && presentForm.requestSubmit(), clearSelection: () => selection.clear(), confirmEl: inspectorEl.querySelector('[slides-confirm]') }) mountReorder({ root: stripEl, getIDs: () => slideIDs(ydoc), onMove: op => commit([op]), onNudge: commands.moveSlide }) root.addEventListener('click', e => { const btn = e.target.closest('[data-command]') if (!btn || inspectorEl.contains(btn) || !commands.has(btn.dataset.command)) return const menu = btn.closest('details') if (menu) menu.open = false commands.run(btn.dataset.command) }) // A native modal makes the page inert visually, but its key and // clipboard events still bubble to document. Stop them at window's // capture phase while a dialog is open so editor shortcuts cannot // write behind the link prompt. Do not prevent the default: typing, // Tab, Enter and Escape remain the dialog's native behaviour. const modalGuard = e => { if (document.querySelector('dialog[open]')) e.stopPropagation() } for (const type of ['keydown', 'keyup', 'beforeinput', 'input', 'copy', 'cut', 'paste']) { addEventListener(type, modalGuard, { capture: true }) } // The document-level keys. Inside a text block ProseMirror has // already handled (and defaultPrevented) undo/redo; the slide keys // are not bound there, so they arrive here from anywhere. document.addEventListener('keydown', e => { if (e.defaultPrevented) return const mod = e.ctrlKey || e.metaKey if (!mod) return const key = e.key.toLowerCase() let cmd = null if (key === 'enter' && !e.shiftKey) cmd = 'new-slide' else if (key === 'd' && !e.shiftKey) cmd = 'duplicate-slide' else if (key === 'f' && e.shiftKey) cmd = 'present' else if (key === 'z' && !e.target.closest('.ProseMirror')) cmd = e.shiftKey ? 'redo' : 'undo' else if (key === 'y' && !e.target.closest('.ProseMirror')) cmd = 'redo' if (!cmd) return e.preventDefault() commands.run(cmd) }) // A hash naming another slide loads it; one naming a block on this // slide selects it (the strip's links name a slide only). addEventListener('hashchange', async () => { const wanted = wantedSlide() const { block } = parseHash(location.hash) if (wanted !== stage.slideId) await showSlide(wanted) if (block) selection.select(block) else if (location.hash === formatHash(stage.slideId)) selection.clear() }) // Activity keeps the stream open; a hidden tab lets it close sooner // (live-updates.mjs has the measurements). addEventListener('visibilitychange', () => { if (document.hidden) { if (currentLive) currentLive.noteHidden() presence.noteHidden() } else { if (currentLive) currentLive.noteActivity() presence.noteVisible() } }) for (const name of ['pointermove', 'scroll', 'keydown', 'focus']) { addEventListener(name, () => currentLive && currentLive.noteActivity(), { passive: true, capture: true }) } // A close with unsent bytes beacons them (Docs' rule, and its // keepalive fallback for a beacon the browser refuses). addEventListener('beforeunload', () => { const bytes = currentQueue && currentQueue.pendingBytes() if (!bytes) return const blob = new Blob([bytes], { type: 'application/octet-stream' }) if (!navigator.sendBeacon(base, blob)) { fetch(base, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/octet-stream' }, body: bytes }).catch(() => {}) } }) } const mount = document.querySelector('[slides-editor]') if (mount) { mountEditor(mount).catch(err => { // The server-rendered page stays readable; only editing is lost. mount.setAttribute('data-editor-failed', '1') console.error(err) }) }