// stage.mjs's pure parts: which rendered elements get an editor, where
// in the element it mounts, and how a block on the stage is found in
// the Y.Doc. The DOM is a stub with the two methods discovery uses;
// the document is a real Y.Doc built through ops.mjs, because the
// lookup walks the same structure the core writes.
import test from 'node:test'
import assert from 'node:assert/strict'
import * as Y from 'yjs'
import { applyOps } from './ops.mjs'
import { readText } from './schema.mjs'
import {
textBlocks, mountTarget, paragraphTag, blockFragment, notesFragment,
blockIDs, slideIDs, slideAfter, cellFragments, mountStage
} from './stage.mjs'
/** A DOM element stub: attributes, children found by tag. */
function el (tag, attrs = {}, children = []) {
return {
tagName: tag.toUpperCase(),
getAttribute: k => (k in attrs ? attrs[k] : null),
hasAttribute: k => k in attrs,
querySelector: sel => children.find(c => c.tagName === sel.toUpperCase()) ?? null,
querySelectorAll: () => children.flatMap(c => [c, ...c.querySelectorAll()])
}
}
function block (type, id, children = []) {
return el(type === 'heading' ? 'h1' : 'div', { 'slides-block': '', 'data-block': type, 'data-id': id }, children)
}
test('textBlocks finds the text-bearing blocks and skips the rest', () => {
const quote = block('quote', 'q1', [el('blockquote'), el('figcaption')])
const root = el('section', {}, [
block('heading', 'h1'),
block('image', 'i1'),
block('text', 't1'),
block('list', 'l1'),
quote,
block('code', 'c1', [el('code')]),
block('shape', 's1'),
block('table', 'tb1'),
block('divider', 'd1'),
el('div', { 'slides-block': '', 'data-block': 'text' }) // no id: unaddressable
])
const got = textBlocks(root).map(b => [b.id, b.type])
assert.deepEqual(got, [['h1', 'heading'], ['t1', 'text'], ['l1', 'list'], ['q1', 'quote'], ['c1', 'code']])
})
test('mountTarget is the element whose children are the paragraphs', () => {
const bq = el('blockquote')
const quote = block('quote', 'q1', [bq, el('figcaption')])
assert.equal(mountTarget(quote, 'quote'), bq)
const code = el('code')
const pre = block('code', 'c1', [code])
assert.equal(mountTarget(pre, 'code'), code)
const h = block('heading', 'h1')
assert.equal(mountTarget(h, 'heading'), h)
// A rendering without the inner element mounts on the block itself
// rather than nowhere.
const bare = block('quote', 'q2')
assert.equal(mountTarget(bare, 'quote'), bare)
})
test('paragraphTag keeps the renderer\'s own markup per type', () => {
assert.equal(paragraphTag('text'), 'p')
assert.equal(paragraphTag('quote'), 'p')
assert.equal(paragraphTag('list'), 'li')
assert.equal(paragraphTag('heading'), 'div')
assert.equal(paragraphTag('code'), 'div')
})
function deck () {
const doc = new Y.Doc({ guid: 'stage-test' })
doc.clientID = 7
applyOps(doc, [
{ op: 'add_slide', id: 's1', layout: 'flow', template: 'title' },
{ op: 'add_block', slide: 's1', id: 'b1', type: 'heading', region: 'heading', markdown: 'Hello' },
{ op: 'add_block', slide: 's1', id: 'b2', type: 'image', region: 'media', props: {} },
{ op: 'add_block', slide: 's1', id: 'b3', type: 'text', region: 'body', markdown: 'Body' },
{ op: 'set_notes', id: 's1', markdown: 'Say hello' },
{ op: 'add_slide', id: 's2', layout: 'flow' }
], 'test')
return doc
}
test('blockFragment finds a block\'s text by slide and block id', () => {
const doc = deck()
const f = blockFragment(doc, 's1', 'b1')
assert.ok(f instanceof Y.XmlFragment)
assert.deepEqual(readText(f), [[{ t: 'Hello' }]])
assert.equal(blockFragment(doc, 's1', 'b2'), null, 'an image has no text')
assert.equal(blockFragment(doc, 's1', 'nope'), null)
assert.equal(blockFragment(doc, 'nope', 'b1'), null)
})
test('notesFragment finds the slide\'s notes', () => {
const doc = deck()
assert.deepEqual(readText(notesFragment(doc, 's1')), [[{ t: 'Say hello' }]])
assert.deepEqual(readText(notesFragment(doc, 's2')), [])
assert.equal(notesFragment(doc, 'nope'), null)
})
test('blockIDs and slideIDs are the orders on the stage and the strip', () => {
const doc = deck()
assert.deepEqual(blockIDs(doc, 's1'), ['b1', 'b2', 'b3'])
assert.deepEqual(blockIDs(doc, 's2'), [])
assert.deepEqual(blockIDs(doc, 'nope'), [])
assert.deepEqual(slideIDs(doc), ['s1', 's2'])
})
test('cellFragments are a table\'s cells, row-major', () => {
const doc = deck()
applyOps(doc, [
{ op: 'add_block', slide: 's2', id: 'tb', type: 'table', props: { rows: 2, cols: 3 } },
{ op: 'add_block', slide: 's2', id: 'tx', type: 'text', markdown: 'x' }
], 'test')
assert.equal(cellFragments(doc, 's2', 'tb').length, 6)
assert.ok(cellFragments(doc, 's2', 'tb').every(c => c instanceof Y.XmlFragment))
assert.deepEqual(cellFragments(doc, 's2', 'tx'), [])
assert.deepEqual(cellFragments(doc, 'nope', 'tb'), [])
})
test('a load that fails leaves the stage on the slide it was showing and says so', async () => {
const doc = deck()
const stageEl = { querySelectorAll: () => [], textContent: '', innerHTML: '' }
const root = { querySelector: sel => (sel === '[slides-stage]' ? stageEl : null) }
const errors = []
const loaded = []
let fail = true
const stage = mountStage({
root, ydoc: doc, slideId: 's1', keys: {},
fetchSlide: async id => { if (fail) throw new Error('offline'); return `` },
onLoaded: id => loaded.push(id),
onError: (err, id) => errors.push([err.message, id])
})
await assert.rejects(stage.load('s2'), /offline/)
assert.equal(stage.slideId, 's1')
assert.deepEqual(errors, [['offline', 's2']])
assert.deepEqual(loaded, [])
fail = false
await stage.load('s2')
assert.equal(stage.slideId, 's2')
assert.equal(stageEl.innerHTML, '')
assert.deepEqual(loaded, ['s2'])
// setViewport re-fetches the same slide in the other rendering.
const asked = []
const phone = mountStage({ root, ydoc: doc, slideId: 's1', keys: {}, readOnly: true, fetchSlide: async (id, vp) => { asked.push(vp); return '' } })
await phone.setViewport('phone')
await phone.setViewport('phone')
assert.deepEqual(asked, ['phone'])
assert.equal(phone.viewport, 'phone')
})
test('slideAfter is the slide to show once one is gone', () => {
assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'c'], 'b'), 'c')
assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'b'], 'c'), 'b')
assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'b', 'c'], 'b'), 'b')
assert.equal(slideAfter(['a'], [], 'a'), '')
assert.equal(slideAfter([], ['a'], ''), 'a')
})