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