oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// strip.mjs's pure parts: the label a thumbnail gets, and the plan for
2// bringing the strip's items into line with the deck's slide order.
3import test from 'node:test'
4import assert from 'node:assert/strict'
5import * as Y from 'yjs'
6import { labelFor, plan, mountStrip } from './strip.mjs'
7import { el, withDocument } from './dom-stub.mjs'
8
9// deckWith builds a Y.Doc of slides, each optionally with a heading —
10// the label's source now that a thumbnail is an <img> with no text in
11// it for a script to read.
12function deckWith (slides) {
13 const ydoc = new Y.Doc()
14 ydoc.transact(() => {
15 const arr = ydoc.getArray('slides')
16 arr.push(slides.map(({ id, heading }) => {
17 const m = new Y.Map()
18 m.set('id', id)
19 const blocks = new Y.Array()
20 if (heading !== undefined) {
21 const b = new Y.Map()
22 b.set('id', id + '-h')
23 b.set('type', 'heading')
24 const f = new Y.XmlFragment()
25 const p = new Y.XmlElement('paragraph')
26 p.insert(0, [new Y.XmlText(heading)])
27 f.insert(0, [p])
28 b.set('text', f)
29 blocks.push([b])
30 }
31 m.set('blocks', blocks)
32 return m
33 }))
34 })
35 return ydoc
36}
37
38test('labelFor is the slide\'s first heading, else the numbered fallback', () => {
39 const ydoc = deckWith([
40 { id: 'a', heading: ' Regions, not coordinates ' },
41 { id: 'b', heading: ' ' },
42 { id: 'c' }
43 ])
44 assert.equal(labelFor(ydoc, 'a', 3, 'Slide {n}'), 'Regions, not coordinates')
45 assert.equal(labelFor(ydoc, 'b', 3, 'Slide {n}'), 'Slide 3')
46 assert.equal(labelFor(ydoc, 'c', 8, 'Slide {n}'), 'Slide 8')
47 assert.equal(labelFor(ydoc, 'gone', 2, 'Slide {n}'), 'Slide 2')
48 assert.equal(labelFor(null, 'a', 1, 'Slide {n}'), 'Slide 1')
49})
50
51// A thumbnail is re-pointed, not re-fetched: setting src IS the
52// request, and the route's ETag makes the ones that did not change a
53// 304. The label and the picture's alt are the same words, because a
54// screen reader announces an image by its alt and never opens the file
55// to read the <title> inside it.
56test('flush re-points every dirty thumbnail and relabels it', () => {
57 const ctx = withDocument()
58 try {
59 const item = id => el('li', { 'slides-strip-item': '', 'data-slide-id': id }, [
60 el('div', { 'slides-thumb': '' }, [el('img', { 'slides-thumb-img': '' })]),
61 el('a', { 'slides-strip-link': '' }, [el('span', { 'slides-strip-label': '' })])
62 ])
63 const root = el('ol', { 'slides-strip': '' }, [item('a'), item('b')])
64 const ydoc = deckWith([{ id: 'a', heading: 'First' }, { id: 'b' }])
65 let seq = 4
66 const strip = mountStrip({
67 root, ydoc, template: null, slideLabel: 'Slide {n}',
68 thumbURL: id => `/d/xyz/slides/${id}.svg?seq=${seq}`
69 })
70
71 strip.mark('a')
72 strip.flush()
73 const img = id => root.children.find(li => li.dataset.slideId === id).querySelector('[slides-thumb-img]')
74 assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=4')
75 assert.equal(img('b').src, undefined, 'a thumbnail nothing marked is left alone')
76 assert.equal(img('a').alt, 'First', 'the picture is announced by the slide\'s own label')
77 assert.equal(root.children[1].querySelector('[slides-strip-label]').textContent, 'Slide 2')
78
79 // A second flush with nothing marked changes nothing; a later seq
80 // is what makes the browser ask again.
81 seq = 7
82 strip.flush()
83 assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=4')
84 strip.mark('a')
85 strip.flush()
86 assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=7')
87 strip.destroy()
88 } finally {
89 ctx.restore()
90 }
91})
92
93// The <ol> is the page's and outlives every Y.Doc mounted over it: a
94// strip that did not let go would run each item menu's command once
95// per resync this session has had.
96test('destroy takes the item-menu listener off; a second mount leaves one', () => {
97 const root = el('ol', { 'slides-strip': '' })
98 const mount = () => mountStrip({ root, ydoc: null, thumbURL: () => '', template: null, slideLabel: 'Slide {n}' })
99 const first = mount()
100 assert.equal(root.countListeners('click'), 1)
101 first.destroy()
102 assert.equal(root.countListeners('click'), 0)
103 const second = mount()
104 assert.equal(root.countListeners('click'), 1)
105 second.destroy()
106})
107
108test('plan says what to add, drop and where each item goes', () => {
109 assert.deepEqual(plan(['a', 'b', 'c'], ['a', 'b', 'c']), { add: [], drop: [], order: ['a', 'b', 'c'] })
110 assert.deepEqual(plan(['a', 'b'], ['b', 'a', 'c']), { add: ['c'], drop: [], order: ['b', 'a', 'c'] })
111 assert.deepEqual(plan(['a', 'b', 'c'], ['c']), { add: [], drop: ['a', 'b'], order: ['c'] })
112 assert.deepEqual(plan([], []), { add: [], drop: [], order: [] })
113})
114
115// A colleague reordering slides must not take focus off the strip: a
116// browser blurs a node it moves, so sync moves only the items that are
117// out of place and puts focus back where it was.
118test('sync moves only the items out of place and keeps focus', () => {
119 const ctx = withDocument()
120 try {
121 const item = id => el('li', { 'slides-strip-item': '', 'data-slide-id': id }, [el('a', { 'slides-strip-link': '' })])
122 const [a, b, c] = ['a', 'b', 'c'].map(item)
123 const root = el('ol', { 'slides-strip': '' }, [a, b, c])
124 const ydoc = new Y.Doc()
125 const order = ids => ydoc.transact(() => {
126 const slides = ydoc.getArray('slides')
127 slides.delete(0, slides.length)
128 slides.push(ids.map(id => { const m = new Y.Map(); m.set('id', id); return m }))
129 })
130 const moved = []
131 const insertBefore = root.insertBefore.bind(root)
132 root.insertBefore = (node, ref) => { moved.push(node.dataset.slideId); return insertBefore(node, ref) }
133 const append = root.append.bind(root)
134 root.append = (...nodes) => { moved.push(...nodes.map(n => n.dataset.slideId)); return append(...nodes) }
135 const strip = mountStrip({ root, ydoc, thumbURL: id => '/x/' + id, template: null, slideLabel: 'Slide {n}' })
136
137 const link = c.querySelector('[slides-strip-link]')
138 link.focus()
139 order(['c', 'a', 'b'])
140 strip.sync()
141 assert.deepEqual(root.children.map(li => li.dataset.slideId), ['c', 'a', 'b'])
142 assert.deepEqual(moved, ['c'], 'only the item that changed position moves')
143 assert.equal(ctx.document.activeElement, link, 'focus is back on the moved item')
144
145 moved.length = 0
146 strip.sync()
147 assert.deepEqual(moved, [], 'nothing moves when nothing changed')
148 strip.destroy()
149 } finally {
150 ctx.restore()
151 }
152})
153