| 1 | // fixtures/strip.mjs — the slide strip: one item per slide, each a |
| 2 | // server-drawn picture of it (`/d/{sqid}/slides/{id}.svg`, thumbs.go), |
| 3 | // the slide's label, and the word "current" on the one on stage. |
| 4 | // |
| 5 | // An <img> of an SVG, not a scaled copy of the slide's HTML, which is |
| 6 | // what this used to be: nine sections with every block and every run |
| 7 | // live in the page, re-laid-out on every resize, and a second opinion |
| 8 | // about what a slide looks like. One image each is smaller to send, |
| 9 | // cheaper to paint, and is the same picture the PDF export is made of, |
| 10 | // because both come off one paint list. |
| 11 | // |
| 12 | // The server renders the whole strip on the page; this keeps it in |
| 13 | // line with the Y.Doc afterwards. sync() reconciles the items with the |
| 14 | // deck's slide order (adding, dropping, reordering — never re-fetching |
| 15 | // on its own), and marks anything it added as dirty; mark(id) marks a |
| 16 | // slide whose content changed. flush() re-points every dirty |
| 17 | // thumbnail's src at a URL carrying the deck's current seq — and the |
| 18 | // caller decides when: after a remote change, straight away; after a |
| 19 | // local one, once the save has landed, because until then the server |
| 20 | // would draw the slide as it was (or, for a new slide, not at all). |
| 21 | // The seq is what makes the browser ask again; the route's ETag is |
| 22 | // what makes asking cheap for the slides that did not change. |
| 23 | // |
| 24 | // Navigation is the URL hash: every item's link is #<slide id>, and |
| 25 | // editor-entry.js loads the stage on hashchange. No click handler for |
| 26 | // it — the link is the control, for a pointer and a keyboard alike. |
| 27 | // Each item also carries its menu (add after, duplicate, move, delete), |
| 28 | // cloned with the item from the page's <template>; reordering by drag |
| 29 | // and by Alt+arrows is strip-reorder.mjs. |
| 30 | import { slideIDs, slideHeading } from './stage.mjs' |
| 31 | |
| 32 | /** |
| 33 | * A thumbnail's label: the slide's first heading's words, or the |
| 34 | * numbered fallback ("Slide {n}"). |
| 35 | * |
| 36 | * From the document, not from the picture: an <img> has no text to |
| 37 | * read, and the heading in the Y.Doc is current the moment it is typed |
| 38 | * rather than one fetch later. |
| 39 | */ |
| 40 | export function labelFor (ydoc, id, n, fallback) { |
| 41 | const words = ydoc ? slideHeading(ydoc, id) : '' |
| 42 | return words || fallback.replace('{n}', String(n)) |
| 43 | } |
| 44 | |
| 45 | /** What sync has to do to turn `have` (item ids in DOM order) into `want`. */ |
| 46 | export function plan (have, want) { |
| 47 | const has = new Set(have) |
| 48 | const wants = new Set(want) |
| 49 | return { |
| 50 | add: want.filter(id => !has.has(id)), |
| 51 | drop: have.filter(id => !wants.has(id)), |
| 52 | order: want.slice() |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * Mounts the strip over root (the <ol>). thumbURL(id) is the address |
| 58 | * of a slide's picture at the deck's current seq; template is the |
| 59 | * page's <template> of one item; slideLabel is the catalog's |
| 60 | * "Slide {n}"; onCommand(name, id) hears the item menu. Returns |
| 61 | * {sync, mark, flush, setCurrent, destroy}. |
| 62 | */ |
| 63 | export function mountStrip ({ root, ydoc, thumbURL, template, slideLabel, onCommand = () => {} }) { |
| 64 | const dirty = new Set() |
| 65 | // The <ol> outlives any one Y.Doc: a resync mounts a second strip |
| 66 | // over the same element, and destroy() is how the first lets go. |
| 67 | const abort = new AbortController() |
| 68 | |
| 69 | // The item menu: five commands, each handed to the entry with the |
| 70 | // slide's id, the menu closed first (rastrillo.js closes it on an |
| 71 | // outside click, not on a press inside). |
| 72 | root.addEventListener('click', e => { |
| 73 | const btn = e.target.closest('[data-slide-command]') |
| 74 | const li = btn && btn.closest('[slides-strip-item]') |
| 75 | if (!li) return |
| 76 | const menu = btn.closest('details') |
| 77 | if (menu) menu.open = false |
| 78 | onCommand(btn.dataset.slideCommand, li.dataset.slideId) |
| 79 | }, { signal: abort.signal }) |
| 80 | |
| 81 | function items () { |
| 82 | return Array.from(root.querySelectorAll('[slides-strip-item]')) |
| 83 | } |
| 84 | |
| 85 | function itemFor (id) { |
| 86 | return items().find(li => li.dataset.slideId === id) || null |
| 87 | } |
| 88 | |
| 89 | // A new item is the page's own <template> cloned, so its markup and |
| 90 | // its words (the menu's items, "Current slide") are the server's. |
| 91 | function makeItem (id) { |
| 92 | const li = template.content.firstElementChild.cloneNode(true) |
| 93 | li.dataset.slideId = id |
| 94 | li.querySelector('[slides-strip-link]').href = '#' + id |
| 95 | const img = li.querySelector('[slides-thumb-img]') |
| 96 | // The src is set by the flush sync() schedules, not here: a slide |
| 97 | // this tab has only just added does not exist on the server yet, |
| 98 | // and asking for its picture now would be one guaranteed 404 per |
| 99 | // new slide. |
| 100 | if (img) img.alt = '' |
| 101 | return li |
| 102 | } |
| 103 | |
| 104 | // The label goes in two places, and they are not the same place: the |
| 105 | // words under the thumbnail, and the picture's alt, because a screen |
| 106 | // reader announces an image by its alt and never opens the file to |
| 107 | // read the <title> inside it. |
| 108 | function relabel () { |
| 109 | items().forEach((li, i) => { |
| 110 | const words = labelFor(ydoc, li.dataset.slideId, i + 1, slideLabel) |
| 111 | const label = li.querySelector('[slides-strip-label]') |
| 112 | if (label && label.textContent !== words) label.textContent = words |
| 113 | const img = li.querySelector('[slides-thumb-img]') |
| 114 | if (img && img.alt !== words) img.alt = words |
| 115 | }) |
| 116 | } |
| 117 | |
| 118 | function sync () { |
| 119 | const want = slideIDs(ydoc) |
| 120 | const { add, drop, order } = plan(items().map(li => li.dataset.slideId), want) |
| 121 | for (const id of drop) { |
| 122 | const li = itemFor(id) |
| 123 | if (li) li.remove() |
| 124 | dirty.delete(id) |
| 125 | } |
| 126 | for (const id of add) { |
| 127 | root.append(makeItem(id)) |
| 128 | dirty.add(id) |
| 129 | } |
| 130 | // Only the items out of place move, and focus is put back after. |
| 131 | // A browser blurs a node it moves, so re-appending every item to |
| 132 | // walk the order — as this once did — took focus off the strip |
| 133 | // whenever a colleague reordered slides, mid-keystroke. |
| 134 | const active = typeof document !== 'undefined' ? document.activeElement : null |
| 135 | order.forEach((id, i) => { |
| 136 | const li = itemFor(id) |
| 137 | if (!li) return |
| 138 | const at = items()[i] || null |
| 139 | if (at !== li) root.insertBefore(li, at) |
| 140 | }) |
| 141 | if (active && root.contains(active) && document.activeElement !== active && typeof active.focus === 'function') { |
| 142 | active.focus() |
| 143 | } |
| 144 | relabel() |
| 145 | } |
| 146 | |
| 147 | function mark (id) { |
| 148 | dirty.add(id) |
| 149 | } |
| 150 | |
| 151 | // Re-point every dirty thumbnail. No fetch of our own and nothing to |
| 152 | // await: setting src is the request, the browser makes it, and a |
| 153 | // slide whose bytes did not change comes back 304 off the route's |
| 154 | // ETag. A picture that fails to load keeps the last one it had, |
| 155 | // which is a thumbnail a little behind rather than a hole in the |
| 156 | // strip — and not worth an error on screen either way. |
| 157 | function flush () { |
| 158 | const ids = Array.from(dirty) |
| 159 | dirty.clear() |
| 160 | for (const id of ids) { |
| 161 | const li = itemFor(id) |
| 162 | const img = li && li.querySelector('[slides-thumb-img]') |
| 163 | if (img) img.src = thumbURL(id) |
| 164 | } |
| 165 | relabel() |
| 166 | } |
| 167 | |
| 168 | function setCurrent (id) { |
| 169 | for (const li of items()) { |
| 170 | const on = li.dataset.slideId === id |
| 171 | if (on) li.dataset.current = 'true' |
| 172 | else delete li.dataset.current |
| 173 | const a = li.querySelector('[slides-strip-link]') |
| 174 | if (a) { |
| 175 | if (on) a.setAttribute('aria-current', 'true') |
| 176 | else a.removeAttribute('aria-current') |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | return { sync, mark, flush, setCurrent, destroy: () => abort.abort() } |
| 182 | } |
| 183 | |