// 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
.
*/
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
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
}