| 1 | // fixtures/stage.mjs — the stage: the server's HTML for the current |
| 2 | // slide, with a ProseMirror view bound to every text block in it |
| 3 | // (spec §3.1; plan: "the browser never becomes a second renderer"). |
| 4 | // |
| 5 | // The stage fetches a slide's section from the server, puts it in the |
| 6 | // DOM as-is, and then mounts an EditorView INTO each text-bearing |
| 7 | // block element — the block's own element (or the blockquote / code |
| 8 | // inside it) becomes the editable node, so what a person sees is the |
| 9 | // renderer's block chrome with a live text inside it. The view's |
| 10 | // paragraphs are drawn with the tag the renderer would have used (p, |
| 11 | // li, or a plain div for a heading's lines) so the block's stylesheet |
| 12 | // rules keep applying. A table's cells are bound the same way, one |
| 13 | // view per cell over the row-major cells array. Everything else on the |
| 14 | // slide — images, shapes, the layout, the theme — is the server's and |
| 15 | // stays inert. A shape's label is an SVG <title>, which cannot host a |
| 16 | // caret; the inspector edits it. |
| 17 | // |
| 18 | // A structural change (a block added, moved, its props changed) is |
| 19 | // not something this file redraws: refresh() fetches the section |
| 20 | // again and re-binds. Text changes, local or remote, flow through |
| 21 | // y-prosemirror and never touch the section. |
| 22 | // |
| 23 | // The stage renders one viewport at a time (desktop, or the phone |
| 24 | // rendering at ?vp=phone for the switcher, spec §3.2), and a |
| 25 | // read-only stage — the "both" view's phone preview — fetches and |
| 26 | // shows the section with nothing bound. |
| 27 | // |
| 28 | // The pure parts — which elements bind, where in them the view mounts, |
| 29 | // and how a block on the stage is found in the Y.Doc — take a DOM |
| 30 | // element only for its attributes, so stage.test.mjs drives them with |
| 31 | // a stub. |
| 32 | import * as Y from 'yjs' |
| 33 | import { EditorState } from 'prosemirror-state' |
| 34 | import { EditorView } from 'prosemirror-view' |
| 35 | import { keymap } from 'prosemirror-keymap' |
| 36 | import { baseKeymap } from 'prosemirror-commands' |
| 37 | import { ySyncPlugin } from 'y-prosemirror' |
| 38 | import { schema, readText } from './schema.mjs' |
| 39 | import { roots, findById, getArray, getFragment, getString, orderedMaps } from './ydoc.mjs' |
| 40 | import { sanitiseFragment } from './check.mjs' |
| 41 | |
| 42 | /** The block types this stage binds a text editor to. */ |
| 43 | export const BOUND_TYPES = ['heading', 'text', 'list', 'quote', 'code'] |
| 44 | |
| 45 | /** The text-bearing blocks under root, in document order: [{el, id, type}]. */ |
| 46 | export function textBlocks (root) { |
| 47 | const out = [] |
| 48 | for (const el of root.querySelectorAll('[slides-block][data-id]')) { |
| 49 | const id = el.getAttribute('data-id') |
| 50 | const type = el.getAttribute('data-block') |
| 51 | if (id && BOUND_TYPES.includes(type)) out.push({ el, id, type }) |
| 52 | } |
| 53 | return out |
| 54 | } |
| 55 | |
| 56 | /** The table blocks under root: [{el, id}], each bound cell by cell. */ |
| 57 | export function tableBlocks (root) { |
| 58 | const out = [] |
| 59 | for (const el of root.querySelectorAll('[slides-block][data-block="table"][data-id]')) { |
| 60 | const id = el.getAttribute('data-id') |
| 61 | if (id) out.push({ el, id }) |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * The element the view mounts into: the block itself, except where the |
| 68 | * renderer wraps the paragraphs in an inner element that must stay — |
| 69 | * a quote's blockquote (its figcaption is the citation, not text) and |
| 70 | * a code block's <code>. |
| 71 | */ |
| 72 | export function mountTarget (el, type) { |
| 73 | if (type === 'quote') return el.querySelector('blockquote') ?? el |
| 74 | if (type === 'code') return el.querySelector('code') ?? el |
| 75 | return el |
| 76 | } |
| 77 | |
| 78 | /** The tag a paragraph is drawn with, per block type — the renderer's own. */ |
| 79 | export function paragraphTag (type) { |
| 80 | if (type === 'list') return 'li' |
| 81 | if (type === 'heading' || type === 'code' || type === 'cell') return 'div' |
| 82 | return 'p' |
| 83 | } |
| 84 | |
| 85 | function slideMap (ydoc, slideId) { |
| 86 | const found = findById(roots(ydoc).slides, slideId) |
| 87 | return found ? found[1] : null |
| 88 | } |
| 89 | |
| 90 | function blockMap (ydoc, slideId, blockId) { |
| 91 | const s = slideMap(ydoc, slideId) |
| 92 | if (!s) return null |
| 93 | const blocks = getArray(s, 'blocks') |
| 94 | if (!blocks) return null |
| 95 | const found = findById(blocks, blockId) |
| 96 | return found ? found[1] : null |
| 97 | } |
| 98 | |
| 99 | /** The block's text fragment, or null when there is no such block or it carries no text. */ |
| 100 | export function blockFragment (ydoc, slideId, blockId) { |
| 101 | const m = blockMap(ydoc, slideId, blockId) |
| 102 | return m ? getFragment(m, 'text') : null |
| 103 | } |
| 104 | |
| 105 | /** A table block's cell fragments, row-major, or [] when it has none. */ |
| 106 | export function cellFragments (ydoc, slideId, blockId) { |
| 107 | const m = blockMap(ydoc, slideId, blockId) |
| 108 | const cells = m && getArray(m, 'cells') |
| 109 | if (!cells) return [] |
| 110 | return cells.toArray().filter(c => c instanceof Y.XmlFragment && !(c instanceof Y.XmlElement)) |
| 111 | } |
| 112 | |
| 113 | /** The slide's notes fragment, or null when there is no such slide. */ |
| 114 | export function notesFragment (ydoc, slideId) { |
| 115 | const s = slideMap(ydoc, slideId) |
| 116 | return s ? getFragment(s, 'notes') : null |
| 117 | } |
| 118 | |
| 119 | /** The slide's block ids in reading order — what a refresh compares against and what move_block's `after` is computed from. */ |
| 120 | export function blockIDs (ydoc, slideId) { |
| 121 | const s = slideMap(ydoc, slideId) |
| 122 | const blocks = s && getArray(s, 'blocks') |
| 123 | if (!blocks) return [] |
| 124 | return orderedMaps(blocks).map(([, m]) => getString(m, 'id')).filter(id => id) |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * The words of a slide's first heading, or '' — what the strip labels |
| 129 | * an item with (slideLabel's "Slide {n}" is the fallback). |
| 130 | * |
| 131 | * Read from the Y.Doc rather than from the thumbnail, which is what |
| 132 | * this used to do: a thumbnail is an <img> of an SVG now, and an image |
| 133 | * has no text a script can reach. The document is the better source |
| 134 | * anyway — the label changes as the heading is typed, not a fetch |
| 135 | * later. |
| 136 | */ |
| 137 | export function slideHeading (ydoc, slideId) { |
| 138 | const s = slideMap(ydoc, slideId) |
| 139 | const blocks = s && getArray(s, 'blocks') |
| 140 | if (!blocks) return '' |
| 141 | for (const [, m] of orderedMaps(blocks)) { |
| 142 | if (getString(m, 'type') !== 'heading') continue |
| 143 | const f = getFragment(m, 'text') |
| 144 | if (!f) return '' |
| 145 | return readText(f).map(p => p.map(r => r.t).join('')).join(' ').trim() |
| 146 | } |
| 147 | return '' |
| 148 | } |
| 149 | |
| 150 | /** The deck's slide ids in order — the strip's order. */ |
| 151 | export function slideIDs (ydoc) { |
| 152 | return orderedMaps(roots(ydoc).slides).map(([, m]) => getString(m, 'id')).filter(id => id) |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * The slide to show after the order changed from before to after, |
| 157 | * given the one on stage: the same one while it exists; otherwise the |
| 158 | * one that took its place (the next, or the last when it was last); |
| 159 | * empty when the deck is empty. |
| 160 | */ |
| 161 | export function slideAfter (before, after, current) { |
| 162 | if (after.includes(current)) return current |
| 163 | if (after.length === 0) return '' |
| 164 | const was = before.indexOf(current) |
| 165 | if (was < 0) return after[0] |
| 166 | return after[Math.min(was, after.length - 1)] |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Binds a ProseMirror view to fragment inside target. The target's |
| 171 | * rendered content is cleared first: ProseMirror draws the same |
| 172 | * paragraphs back from the fragment, live. keys is the app's keymap |
| 173 | * (text-keys.mjs), ahead of baseKeymap so its bindings win. |
| 174 | * |
| 175 | * The fragment is sanitised first (check.mjs): a formatting attribute |
| 176 | * the schema does not know is stripped from the shared text before |
| 177 | * y-prosemirror sees it, because y-prosemirror 1.3.7 answers an unknown |
| 178 | * mark by deleting the whole XmlText — the words, not the mark (codex |
| 179 | * 3). The server refuses such an update now; this is for a document |
| 180 | * that already carries one. |
| 181 | */ |
| 182 | export function bindView (target, fragment, type, keys) { |
| 183 | sanitiseFragment(fragment) |
| 184 | target.textContent = '' |
| 185 | const tag = paragraphTag(type) |
| 186 | return new EditorView({ mount: target }, { |
| 187 | state: EditorState.create({ |
| 188 | schema, |
| 189 | plugins: [ySyncPlugin(fragment), keymap(keys), keymap(baseKeymap)] |
| 190 | }), |
| 191 | nodeViews: { |
| 192 | paragraph: () => { |
| 193 | const dom = document.createElement(tag) |
| 194 | return { dom, contentDOM: dom } |
| 195 | } |
| 196 | } |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Mounts the stage. root holds [slides-stage] and [slides-notes], or |
| 202 | * el names the stage element itself (the read-only phone preview has |
| 203 | * no notes); fetchSlide(id, vp) resolves |
| 204 | * to the section's HTML; keys is the keymap every view gets; vp is the |
| 205 | * viewport fetched ('desktop' or 'phone'). Returns the stage: load(id) |
| 206 | * shows a slide, refresh() re-fetches the current one, setViewport(vp) |
| 207 | * re-fetches in the other rendering, views() is what is bound. |
| 208 | * |
| 209 | * load() resolves when the slide is on the DOM, and the stage's |
| 210 | * slideId moves only then: a fetch that fails leaves the stage, and |
| 211 | * whatever follows it (the hash, the strip's "current"), on the slide |
| 212 | * it was showing, and onError hears about it. |
| 213 | */ |
| 214 | export function mountStage ({ root, el, ydoc, slideId, fetchSlide, keys, vp = 'desktop', readOnly = false, onLoaded = () => {}, onError = () => {} }) { |
| 215 | const stageEl = el ?? root.querySelector('[slides-stage]') |
| 216 | const notesEl = readOnly ? null : root.querySelector('[slides-notes]') |
| 217 | let current = slideId || '' |
| 218 | let viewport = vp |
| 219 | let views = [] |
| 220 | let seq = 0 |
| 221 | |
| 222 | function unbind () { |
| 223 | for (const v of views) v.destroy() |
| 224 | views = [] |
| 225 | } |
| 226 | |
| 227 | function bind () { |
| 228 | unbind() |
| 229 | if (!current || readOnly) return |
| 230 | for (const { el, id, type } of textBlocks(stageEl)) { |
| 231 | const fragment = blockFragment(ydoc, current, id) |
| 232 | if (fragment) views.push(bindView(mountTarget(el, type), fragment, type, keys)) |
| 233 | } |
| 234 | for (const { el, id } of tableBlocks(stageEl)) { |
| 235 | const cells = cellFragments(ydoc, current, id) |
| 236 | el.querySelectorAll('th, td').forEach((cell, i) => { |
| 237 | if (cells[i]) views.push(bindView(cell, cells[i], 'cell', keys)) |
| 238 | }) |
| 239 | } |
| 240 | const notes = notesEl && notesFragment(ydoc, current) |
| 241 | if (notes) views.push(bindView(notesEl, notes, 'text', keys)) |
| 242 | } |
| 243 | |
| 244 | async function load (id) { |
| 245 | const wanted = id || '' |
| 246 | const mine = ++seq |
| 247 | if (!wanted) { |
| 248 | current = '' |
| 249 | unbind() |
| 250 | if (stageEl) stageEl.textContent = '' |
| 251 | if (notesEl) notesEl.textContent = '' |
| 252 | onLoaded('') |
| 253 | return |
| 254 | } |
| 255 | let html |
| 256 | try { |
| 257 | html = await fetchSlide(wanted, viewport) |
| 258 | } catch (err) { |
| 259 | if (mine === seq) onError(err, wanted) |
| 260 | throw err |
| 261 | } |
| 262 | // A later load overtook this one: its section is the one to show. |
| 263 | if (mine !== seq) return |
| 264 | current = wanted |
| 265 | unbind() |
| 266 | stageEl.innerHTML = html |
| 267 | if (notesEl) notesEl.textContent = '' |
| 268 | bind() |
| 269 | onLoaded(current) |
| 270 | } |
| 271 | |
| 272 | // The first paint is the server's: bind to what is already there |
| 273 | // rather than fetching it again. |
| 274 | if (stageEl && current) bind() |
| 275 | |
| 276 | return { |
| 277 | load, |
| 278 | refresh: () => load(current), |
| 279 | setViewport (next) { |
| 280 | if (next === viewport) return Promise.resolve() |
| 281 | viewport = next |
| 282 | // Nothing to re-fetch on an empty deck; the empty state stays. |
| 283 | return current ? load(current) : Promise.resolve() |
| 284 | }, |
| 285 | get viewport () { return viewport }, |
| 286 | bind, |
| 287 | views: () => views, |
| 288 | get slideId () { return current }, |
| 289 | get element () { return stageEl }, |
| 290 | destroy () { |
| 291 | seq++ |
| 292 | unbind() |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | /** Whether a Y event's target is text (flows through y-prosemirror) or structure (needs a refresh). */ |
| 298 | export function isTextTarget (target) { |
| 299 | return target instanceof Y.XmlFragment || target instanceof Y.XmlText |
| 300 | } |
| 301 | |