// fixtures/strip.mjs — the slide strip: one item per slide, each a
// server-drawn picture of it (`/d/{sqid}/slides/{id}.svg`, thumbs.go),
// the slide's label, and the word "current" on the one on stage.
//
// An
of an SVG, not a scaled copy of the slide's HTML, which is
// what this used to be: nine sections with every block and every run
// live in the page, re-laid-out on every resize, and a second opinion
// about what a slide looks like. One image each is smaller to send,
// cheaper to paint, and is the same picture the PDF export is made of,
// because both come off one paint list.
//
// The server renders the whole strip on the page; this keeps it in
// line with the Y.Doc afterwards. sync() reconciles the items with the
// deck's slide order (adding, dropping, reordering — never re-fetching
// on its own), and marks anything it added as dirty; mark(id) marks a
// slide whose content changed. flush() re-points every dirty
// thumbnail's src at a URL carrying the deck's current seq — and the
// caller decides when: after a remote change, straight away; after a
// local one, once the save has landed, because until then the server
// would draw the slide as it was (or, for a new slide, not at all).
// The seq is what makes the browser ask again; the route's ETag is
// what makes asking cheap for the slides that did not change.
//
// Navigation is the URL hash: every item's link is #, and
// editor-entry.js loads the stage on hashchange. No click handler for
// it — the link is the control, for a pointer and a keyboard alike.
// Each item also carries its menu (add after, duplicate, move, delete),
// cloned with the item from the page's ; reordering by drag
// and by Alt+arrows is strip-reorder.mjs.
import { slideIDs, slideHeading } from './stage.mjs'
/**
* A thumbnail's label: the slide's first heading's words, or the
* numbered fallback ("Slide {n}").
*
* From the document, not from the picture: an
has no text to
* read, and the heading in the Y.Doc is current the moment it is typed
* rather than one fetch later.
*/
export function labelFor (ydoc, id, n, fallback) {
const words = ydoc ? slideHeading(ydoc, id) : ''
return words || fallback.replace('{n}', String(n))
}
/** What sync has to do to turn `have` (item ids in DOM order) into `want`. */
export function plan (have, want) {
const has = new Set(have)
const wants = new Set(want)
return {
add: want.filter(id => !has.has(id)),
drop: have.filter(id => !wants.has(id)),
order: want.slice()
}
}
/**
* Mounts the strip over root (the ). thumbURL(id) is the address
* of a slide's picture at the deck's current seq; template is the
* page's of one item; slideLabel is the catalog's
* "Slide {n}"; onCommand(name, id) hears the item menu. Returns
* {sync, mark, flush, setCurrent, destroy}.
*/
export function mountStrip ({ root, ydoc, thumbURL, template, slideLabel, onCommand = () => {} }) {
const dirty = new Set()
// The outlives any one Y.Doc: a resync mounts a second strip
// over the same element, and destroy() is how the first lets go.
const abort = new AbortController()
// The item menu: five commands, each handed to the entry with the
// slide's id, the menu closed first (rastrillo.js closes it on an
// outside click, not on a press inside).
root.addEventListener('click', e => {
const btn = e.target.closest('[data-slide-command]')
const li = btn && btn.closest('[slides-strip-item]')
if (!li) return
const menu = btn.closest('details')
if (menu) menu.open = false
onCommand(btn.dataset.slideCommand, li.dataset.slideId)
}, { signal: abort.signal })
function items () {
return Array.from(root.querySelectorAll('[slides-strip-item]'))
}
function itemFor (id) {
return items().find(li => li.dataset.slideId === id) || null
}
// A new item is the page's own cloned, so its markup and
// its words (the menu's items, "Current slide") are the server's.
function makeItem (id) {
const li = template.content.firstElementChild.cloneNode(true)
li.dataset.slideId = id
li.querySelector('[slides-strip-link]').href = '#' + id
const img = li.querySelector('[slides-thumb-img]')
// The src is set by the flush sync() schedules, not here: a slide
// this tab has only just added does not exist on the server yet,
// and asking for its picture now would be one guaranteed 404 per
// new slide.
if (img) img.alt = ''
return li
}
// The label goes in two places, and they are not the same place: the
// words under the thumbnail, and the picture's alt, because a screen
// reader announces an image by its alt and never opens the file to
// read the inside it.
function relabel () {
items().forEach((li, i) => {
const words = labelFor(ydoc, li.dataset.slideId, i + 1, slideLabel)
const label = li.querySelector('[slides-strip-label]')
if (label && label.textContent !== words) label.textContent = words
const img = li.querySelector('[slides-thumb-img]')
if (img && img.alt !== words) img.alt = words
})
}
function sync () {
const want = slideIDs(ydoc)
const { add, drop, order } = plan(items().map(li => li.dataset.slideId), want)
for (const id of drop) {
const li = itemFor(id)
if (li) li.remove()
dirty.delete(id)
}
for (const id of add) {
root.append(makeItem(id))
dirty.add(id)
}
// Only the items out of place move, and focus is put back after.
// A browser blurs a node it moves, so re-appending every item to
// walk the order — as this once did — took focus off the strip
// whenever a colleague reordered slides, mid-keystroke.
const active = typeof document !== 'undefined' ? document.activeElement : null
order.forEach((id, i) => {
const li = itemFor(id)
if (!li) return
const at = items()[i] || null
if (at !== li) root.insertBefore(li, at)
})
if (active && root.contains(active) && document.activeElement !== active && typeof active.focus === 'function') {
active.focus()
}
relabel()
}
function mark (id) {
dirty.add(id)
}
// Re-point every dirty thumbnail. No fetch of our own and nothing to
// await: setting src is the request, the browser makes it, and a
// slide whose bytes did not change comes back 304 off the route's
// ETag. A picture that fails to load keeps the last one it had,
// which is a thumbnail a little behind rather than a hole in the
// strip — and not worth an error on screen either way.
function flush () {
const ids = Array.from(dirty)
dirty.clear()
for (const id of ids) {
const li = itemFor(id)
const img = li && li.querySelector('[slides-thumb-img]')
if (img) img.src = thumbURL(id)
}
relabel()
}
function setCurrent (id) {
for (const li of items()) {
const on = li.dataset.slideId === id
if (on) li.dataset.current = 'true'
else delete li.dataset.current
const a = li.querySelector('[slides-strip-link]')
if (a) {
if (on) a.setAttribute('aria-current', 'true')
else a.removeAttribute('aria-current')
}
}
}
return { sync, mark, flush, setCurrent, destroy: () => abort.abort() }
}