// strip.mjs's pure parts: the label a thumbnail gets, and the plan for // bringing the strip's items into line with the deck's slide order. import test from 'node:test' import assert from 'node:assert/strict' import * as Y from 'yjs' import { labelFor, plan, mountStrip } from './strip.mjs' import { el, withDocument } from './dom-stub.mjs' // deckWith builds a Y.Doc of slides, each optionally with a heading — // the label's source now that a thumbnail is an with no text in // it for a script to read. function deckWith (slides) { const ydoc = new Y.Doc() ydoc.transact(() => { const arr = ydoc.getArray('slides') arr.push(slides.map(({ id, heading }) => { const m = new Y.Map() m.set('id', id) const blocks = new Y.Array() if (heading !== undefined) { const b = new Y.Map() b.set('id', id + '-h') b.set('type', 'heading') const f = new Y.XmlFragment() const p = new Y.XmlElement('paragraph') p.insert(0, [new Y.XmlText(heading)]) f.insert(0, [p]) b.set('text', f) blocks.push([b]) } m.set('blocks', blocks) return m })) }) return ydoc } test('labelFor is the slide\'s first heading, else the numbered fallback', () => { const ydoc = deckWith([ { id: 'a', heading: ' Regions, not coordinates ' }, { id: 'b', heading: ' ' }, { id: 'c' } ]) assert.equal(labelFor(ydoc, 'a', 3, 'Slide {n}'), 'Regions, not coordinates') assert.equal(labelFor(ydoc, 'b', 3, 'Slide {n}'), 'Slide 3') assert.equal(labelFor(ydoc, 'c', 8, 'Slide {n}'), 'Slide 8') assert.equal(labelFor(ydoc, 'gone', 2, 'Slide {n}'), 'Slide 2') assert.equal(labelFor(null, 'a', 1, 'Slide {n}'), 'Slide 1') }) // A thumbnail is re-pointed, not re-fetched: setting src IS the // request, and the route's ETag makes the ones that did not change a // 304. The label and the picture's alt are the same words, because a // screen reader announces an image by its alt and never opens the file // to read the inside it. test('flush re-points every dirty thumbnail and relabels it', () => { const ctx = withDocument() try { const item = id => el('li', { 'slides-strip-item': '', 'data-slide-id': id }, [ el('div', { 'slides-thumb': '' }, [el('img', { 'slides-thumb-img': '' })]), el('a', { 'slides-strip-link': '' }, [el('span', { 'slides-strip-label': '' })]) ]) const root = el('ol', { 'slides-strip': '' }, [item('a'), item('b')]) const ydoc = deckWith([{ id: 'a', heading: 'First' }, { id: 'b' }]) let seq = 4 const strip = mountStrip({ root, ydoc, template: null, slideLabel: 'Slide {n}', thumbURL: id => `/d/xyz/slides/${id}.svg?seq=${seq}` }) strip.mark('a') strip.flush() const img = id => root.children.find(li => li.dataset.slideId === id).querySelector('[slides-thumb-img]') assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=4') assert.equal(img('b').src, undefined, 'a thumbnail nothing marked is left alone') assert.equal(img('a').alt, 'First', 'the picture is announced by the slide\'s own label') assert.equal(root.children[1].querySelector('[slides-strip-label]').textContent, 'Slide 2') // A second flush with nothing marked changes nothing; a later seq // is what makes the browser ask again. seq = 7 strip.flush() assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=4') strip.mark('a') strip.flush() assert.equal(img('a').src, '/d/xyz/slides/a.svg?seq=7') strip.destroy() } finally { ctx.restore() } }) // The <ol> is the page's and outlives every Y.Doc mounted over it: a // strip that did not let go would run each item menu's command once // per resync this session has had. test('destroy takes the item-menu listener off; a second mount leaves one', () => { const root = el('ol', { 'slides-strip': '' }) const mount = () => mountStrip({ root, ydoc: null, thumbURL: () => '', template: null, slideLabel: 'Slide {n}' }) const first = mount() assert.equal(root.countListeners('click'), 1) first.destroy() assert.equal(root.countListeners('click'), 0) const second = mount() assert.equal(root.countListeners('click'), 1) second.destroy() }) test('plan says what to add, drop and where each item goes', () => { assert.deepEqual(plan(['a', 'b', 'c'], ['a', 'b', 'c']), { add: [], drop: [], order: ['a', 'b', 'c'] }) assert.deepEqual(plan(['a', 'b'], ['b', 'a', 'c']), { add: ['c'], drop: [], order: ['b', 'a', 'c'] }) assert.deepEqual(plan(['a', 'b', 'c'], ['c']), { add: [], drop: ['a', 'b'], order: ['c'] }) assert.deepEqual(plan([], []), { add: [], drop: [], order: [] }) }) // A colleague reordering slides must not take focus off the strip: a // browser blurs a node it moves, so sync moves only the items that are // out of place and puts focus back where it was. test('sync moves only the items out of place and keeps focus', () => { const ctx = withDocument() try { const item = id => el('li', { 'slides-strip-item': '', 'data-slide-id': id }, [el('a', { 'slides-strip-link': '' })]) const [a, b, c] = ['a', 'b', 'c'].map(item) const root = el('ol', { 'slides-strip': '' }, [a, b, c]) const ydoc = new Y.Doc() const order = ids => ydoc.transact(() => { const slides = ydoc.getArray('slides') slides.delete(0, slides.length) slides.push(ids.map(id => { const m = new Y.Map(); m.set('id', id); return m })) }) const moved = [] const insertBefore = root.insertBefore.bind(root) root.insertBefore = (node, ref) => { moved.push(node.dataset.slideId); return insertBefore(node, ref) } const append = root.append.bind(root) root.append = (...nodes) => { moved.push(...nodes.map(n => n.dataset.slideId)); return append(...nodes) } const strip = mountStrip({ root, ydoc, thumbURL: id => '/x/' + id, template: null, slideLabel: 'Slide {n}' }) const link = c.querySelector('[slides-strip-link]') link.focus() order(['c', 'a', 'b']) strip.sync() assert.deepEqual(root.children.map(li => li.dataset.slideId), ['c', 'a', 'b']) assert.deepEqual(moved, ['c'], 'only the item that changed position moves') assert.equal(ctx.document.activeElement, link, 'focus is back on the moved item') moved.length = 0 strip.sync() assert.deepEqual(moved, [], 'nothing moves when nothing changed') strip.destroy() } finally { ctx.restore() } })