// 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 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()
}
})