// fixtures/stage.mjs — the stage: the server's HTML for the current // slide, with a ProseMirror view bound to every text block in it // (spec §3.1; plan: "the browser never becomes a second renderer"). // // The stage fetches a slide's section from the server, puts it in the // DOM as-is, and then mounts an EditorView INTO each text-bearing // block element — the block's own element (or the blockquote / code // inside it) becomes the editable node, so what a person sees is the // renderer's block chrome with a live text inside it. The view's // paragraphs are drawn with the tag the renderer would have used (p, // li, or a plain div for a heading's lines) so the block's stylesheet // rules keep applying. A table's cells are bound the same way, one // view per cell over the row-major cells array. Everything else on the // slide — images, shapes, the layout, the theme — is the server's and // stays inert. A shape's label is an SVG , which cannot host a // caret; the inspector edits it. // // A structural change (a block added, moved, its props changed) is // not something this file redraws: refresh() fetches the section // again and re-binds. Text changes, local or remote, flow through // y-prosemirror and never touch the section. // // The stage renders one viewport at a time (desktop, or the phone // rendering at ?vp=phone for the switcher, spec §3.2), and a // read-only stage — the "both" view's phone preview — fetches and // shows the section with nothing bound. // // The pure parts — which elements bind, where in them the view mounts, // and how a block on the stage is found in the Y.Doc — take a DOM // element only for its attributes, so stage.test.mjs drives them with // a stub. import * as Y from 'yjs' import { EditorState } from 'prosemirror-state' import { EditorView } from 'prosemirror-view' import { keymap } from 'prosemirror-keymap' import { baseKeymap } from 'prosemirror-commands' import { ySyncPlugin } from 'y-prosemirror' import { schema, readText } from './schema.mjs' import { roots, findById, getArray, getFragment, getString, orderedMaps } from './ydoc.mjs' import { sanitiseFragment } from './check.mjs' /** The block types this stage binds a text editor to. */ export const BOUND_TYPES = ['heading', 'text', 'list', 'quote', 'code'] /** The text-bearing blocks under root, in document order: [{el, id, type}]. */ export function textBlocks (root) { const out = [] for (const el of root.querySelectorAll('[slides-block][data-id]')) { const id = el.getAttribute('data-id') const type = el.getAttribute('data-block') if (id && BOUND_TYPES.includes(type)) out.push({ el, id, type }) } return out } /** The table blocks under root: [{el, id}], each bound cell by cell. */ export function tableBlocks (root) { const out = [] for (const el of root.querySelectorAll('[slides-block][data-block="table"][data-id]')) { const id = el.getAttribute('data-id') if (id) out.push({ el, id }) } return out } /** * The element the view mounts into: the block itself, except where the * renderer wraps the paragraphs in an inner element that must stay — * a quote's blockquote (its figcaption is the citation, not text) and * a code block's <code>. */ export function mountTarget (el, type) { if (type === 'quote') return el.querySelector('blockquote') ?? el if (type === 'code') return el.querySelector('code') ?? el return el } /** The tag a paragraph is drawn with, per block type — the renderer's own. */ export function paragraphTag (type) { if (type === 'list') return 'li' if (type === 'heading' || type === 'code' || type === 'cell') return 'div' return 'p' } function slideMap (ydoc, slideId) { const found = findById(roots(ydoc).slides, slideId) return found ? found[1] : null } function blockMap (ydoc, slideId, blockId) { const s = slideMap(ydoc, slideId) if (!s) return null const blocks = getArray(s, 'blocks') if (!blocks) return null const found = findById(blocks, blockId) return found ? found[1] : null } /** The block's text fragment, or null when there is no such block or it carries no text. */ export function blockFragment (ydoc, slideId, blockId) { const m = blockMap(ydoc, slideId, blockId) return m ? getFragment(m, 'text') : null } /** A table block's cell fragments, row-major, or [] when it has none. */ export function cellFragments (ydoc, slideId, blockId) { const m = blockMap(ydoc, slideId, blockId) const cells = m && getArray(m, 'cells') if (!cells) return [] return cells.toArray().filter(c => c instanceof Y.XmlFragment && !(c instanceof Y.XmlElement)) } /** The slide's notes fragment, or null when there is no such slide. */ export function notesFragment (ydoc, slideId) { const s = slideMap(ydoc, slideId) return s ? getFragment(s, 'notes') : null } /** The slide's block ids in reading order — what a refresh compares against and what move_block's `after` is computed from. */ export function blockIDs (ydoc, slideId) { const s = slideMap(ydoc, slideId) const blocks = s && getArray(s, 'blocks') if (!blocks) return [] return orderedMaps(blocks).map(([, m]) => getString(m, 'id')).filter(id => id) } /** * The words of a slide's first heading, or '' — what the strip labels * an item with (slideLabel's "Slide {n}" is the fallback). * * Read from the Y.Doc rather than from the thumbnail, which is what * this used to do: a thumbnail is an <img> of an SVG now, and an image * has no text a script can reach. The document is the better source * anyway — the label changes as the heading is typed, not a fetch * later. */ export function slideHeading (ydoc, slideId) { const s = slideMap(ydoc, slideId) const blocks = s && getArray(s, 'blocks') if (!blocks) return '' for (const [, m] of orderedMaps(blocks)) { if (getString(m, 'type') !== 'heading') continue const f = getFragment(m, 'text') if (!f) return '' return readText(f).map(p => p.map(r => r.t).join('')).join(' ').trim() } return '' } /** The deck's slide ids in order — the strip's order. */ export function slideIDs (ydoc) { return orderedMaps(roots(ydoc).slides).map(([, m]) => getString(m, 'id')).filter(id => id) } /** * The slide to show after the order changed from before to after, * given the one on stage: the same one while it exists; otherwise the * one that took its place (the next, or the last when it was last); * empty when the deck is empty. */ export function slideAfter (before, after, current) { if (after.includes(current)) return current if (after.length === 0) return '' const was = before.indexOf(current) if (was < 0) return after[0] return after[Math.min(was, after.length - 1)] } /** * Binds a ProseMirror view to fragment inside target. The target's * rendered content is cleared first: ProseMirror draws the same * paragraphs back from the fragment, live. keys is the app's keymap * (text-keys.mjs), ahead of baseKeymap so its bindings win. * * The fragment is sanitised first (check.mjs): a formatting attribute * the schema does not know is stripped from the shared text before * y-prosemirror sees it, because y-prosemirror 1.3.7 answers an unknown * mark by deleting the whole XmlText — the words, not the mark (codex * 3). The server refuses such an update now; this is for a document * that already carries one. */ export function bindView (target, fragment, type, keys) { sanitiseFragment(fragment) target.textContent = '' const tag = paragraphTag(type) return new EditorView({ mount: target }, { state: EditorState.create({ schema, plugins: [ySyncPlugin(fragment), keymap(keys), keymap(baseKeymap)] }), nodeViews: { paragraph: () => { const dom = document.createElement(tag) return { dom, contentDOM: dom } } } }) } /** * Mounts the stage. root holds [slides-stage] and [slides-notes], or * el names the stage element itself (the read-only phone preview has * no notes); fetchSlide(id, vp) resolves * to the section's HTML; keys is the keymap every view gets; vp is the * viewport fetched ('desktop' or 'phone'). Returns the stage: load(id) * shows a slide, refresh() re-fetches the current one, setViewport(vp) * re-fetches in the other rendering, views() is what is bound. * * load() resolves when the slide is on the DOM, and the stage's * slideId moves only then: a fetch that fails leaves the stage, and * whatever follows it (the hash, the strip's "current"), on the slide * it was showing, and onError hears about it. */ export function mountStage ({ root, el, ydoc, slideId, fetchSlide, keys, vp = 'desktop', readOnly = false, onLoaded = () => {}, onError = () => {} }) { const stageEl = el ?? root.querySelector('[slides-stage]') const notesEl = readOnly ? null : root.querySelector('[slides-notes]') let current = slideId || '' let viewport = vp let views = [] let seq = 0 function unbind () { for (const v of views) v.destroy() views = [] } function bind () { unbind() if (!current || readOnly) return for (const { el, id, type } of textBlocks(stageEl)) { const fragment = blockFragment(ydoc, current, id) if (fragment) views.push(bindView(mountTarget(el, type), fragment, type, keys)) } for (const { el, id } of tableBlocks(stageEl)) { const cells = cellFragments(ydoc, current, id) el.querySelectorAll('th, td').forEach((cell, i) => { if (cells[i]) views.push(bindView(cell, cells[i], 'cell', keys)) }) } const notes = notesEl && notesFragment(ydoc, current) if (notes) views.push(bindView(notesEl, notes, 'text', keys)) } async function load (id) { const wanted = id || '' const mine = ++seq if (!wanted) { current = '' unbind() if (stageEl) stageEl.textContent = '' if (notesEl) notesEl.textContent = '' onLoaded('') return } let html try { html = await fetchSlide(wanted, viewport) } catch (err) { if (mine === seq) onError(err, wanted) throw err } // A later load overtook this one: its section is the one to show. if (mine !== seq) return current = wanted unbind() stageEl.innerHTML = html if (notesEl) notesEl.textContent = '' bind() onLoaded(current) } // The first paint is the server's: bind to what is already there // rather than fetching it again. if (stageEl && current) bind() return { load, refresh: () => load(current), setViewport (next) { if (next === viewport) return Promise.resolve() viewport = next // Nothing to re-fetch on an empty deck; the empty state stays. return current ? load(current) : Promise.resolve() }, get viewport () { return viewport }, bind, views: () => views, get slideId () { return current }, get element () { return stageEl }, destroy () { seq++ unbind() } } } /** Whether a Y event's target is text (flows through y-prosemirror) or structure (needs a refresh). */ export function isTextTarget (target) { return target instanceof Y.XmlFragment || target instanceof Y.XmlText }