| 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. |
| 3 | import test from 'node:test' |
| 4 | import assert from 'node:assert/strict' |
| 5 | import * as Y from 'yjs' |
| 6 | import { labelFor, plan, mountStrip } from './strip.mjs' |
| 7 | import { 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. |
| 12 | function 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 | |
| 38 | test('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. |
| 56 | test('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. |
| 96 | test('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 | |
| 108 | test('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. |
| 118 | test('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 | |