oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
30import { 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 */
40export 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`. */
46export 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 */
63export 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