| 1 | // stage.mjs's pure parts: which rendered elements get an editor, where |
| 2 | // in the element it mounts, and how a block on the stage is found in |
| 3 | // the Y.Doc. The DOM is a stub with the two methods discovery uses; |
| 4 | // the document is a real Y.Doc built through ops.mjs, because the |
| 5 | // lookup walks the same structure the core writes. |
| 6 | import test from 'node:test' |
| 7 | import assert from 'node:assert/strict' |
| 8 | import * as Y from 'yjs' |
| 9 | import { applyOps } from './ops.mjs' |
| 10 | import { readText } from './schema.mjs' |
| 11 | import { |
| 12 | textBlocks, mountTarget, paragraphTag, blockFragment, notesFragment, |
| 13 | blockIDs, slideIDs, slideAfter, cellFragments, mountStage |
| 14 | } from './stage.mjs' |
| 15 | |
| 16 | /** A DOM element stub: attributes, children found by tag. */ |
| 17 | function el (tag, attrs = {}, children = []) { |
| 18 | return { |
| 19 | tagName: tag.toUpperCase(), |
| 20 | getAttribute: k => (k in attrs ? attrs[k] : null), |
| 21 | hasAttribute: k => k in attrs, |
| 22 | querySelector: sel => children.find(c => c.tagName === sel.toUpperCase()) ?? null, |
| 23 | querySelectorAll: () => children.flatMap(c => [c, ...c.querySelectorAll()]) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function block (type, id, children = []) { |
| 28 | return el(type === 'heading' ? 'h1' : 'div', { 'slides-block': '', 'data-block': type, 'data-id': id }, children) |
| 29 | } |
| 30 | |
| 31 | test('textBlocks finds the text-bearing blocks and skips the rest', () => { |
| 32 | const quote = block('quote', 'q1', [el('blockquote'), el('figcaption')]) |
| 33 | const root = el('section', {}, [ |
| 34 | block('heading', 'h1'), |
| 35 | block('image', 'i1'), |
| 36 | block('text', 't1'), |
| 37 | block('list', 'l1'), |
| 38 | quote, |
| 39 | block('code', 'c1', [el('code')]), |
| 40 | block('shape', 's1'), |
| 41 | block('table', 'tb1'), |
| 42 | block('divider', 'd1'), |
| 43 | el('div', { 'slides-block': '', 'data-block': 'text' }) // no id: unaddressable |
| 44 | ]) |
| 45 | const got = textBlocks(root).map(b => [b.id, b.type]) |
| 46 | assert.deepEqual(got, [['h1', 'heading'], ['t1', 'text'], ['l1', 'list'], ['q1', 'quote'], ['c1', 'code']]) |
| 47 | }) |
| 48 | |
| 49 | test('mountTarget is the element whose children are the paragraphs', () => { |
| 50 | const bq = el('blockquote') |
| 51 | const quote = block('quote', 'q1', [bq, el('figcaption')]) |
| 52 | assert.equal(mountTarget(quote, 'quote'), bq) |
| 53 | const code = el('code') |
| 54 | const pre = block('code', 'c1', [code]) |
| 55 | assert.equal(mountTarget(pre, 'code'), code) |
| 56 | const h = block('heading', 'h1') |
| 57 | assert.equal(mountTarget(h, 'heading'), h) |
| 58 | // A rendering without the inner element mounts on the block itself |
| 59 | // rather than nowhere. |
| 60 | const bare = block('quote', 'q2') |
| 61 | assert.equal(mountTarget(bare, 'quote'), bare) |
| 62 | }) |
| 63 | |
| 64 | test('paragraphTag keeps the renderer\'s own markup per type', () => { |
| 65 | assert.equal(paragraphTag('text'), 'p') |
| 66 | assert.equal(paragraphTag('quote'), 'p') |
| 67 | assert.equal(paragraphTag('list'), 'li') |
| 68 | assert.equal(paragraphTag('heading'), 'div') |
| 69 | assert.equal(paragraphTag('code'), 'div') |
| 70 | }) |
| 71 | |
| 72 | function deck () { |
| 73 | const doc = new Y.Doc({ guid: 'stage-test' }) |
| 74 | doc.clientID = 7 |
| 75 | applyOps(doc, [ |
| 76 | { op: 'add_slide', id: 's1', layout: 'flow', template: 'title' }, |
| 77 | { op: 'add_block', slide: 's1', id: 'b1', type: 'heading', region: 'heading', markdown: 'Hello' }, |
| 78 | { op: 'add_block', slide: 's1', id: 'b2', type: 'image', region: 'media', props: {} }, |
| 79 | { op: 'add_block', slide: 's1', id: 'b3', type: 'text', region: 'body', markdown: 'Body' }, |
| 80 | { op: 'set_notes', id: 's1', markdown: 'Say hello' }, |
| 81 | { op: 'add_slide', id: 's2', layout: 'flow' } |
| 82 | ], 'test') |
| 83 | return doc |
| 84 | } |
| 85 | |
| 86 | test('blockFragment finds a block\'s text by slide and block id', () => { |
| 87 | const doc = deck() |
| 88 | const f = blockFragment(doc, 's1', 'b1') |
| 89 | assert.ok(f instanceof Y.XmlFragment) |
| 90 | assert.deepEqual(readText(f), [[{ t: 'Hello' }]]) |
| 91 | assert.equal(blockFragment(doc, 's1', 'b2'), null, 'an image has no text') |
| 92 | assert.equal(blockFragment(doc, 's1', 'nope'), null) |
| 93 | assert.equal(blockFragment(doc, 'nope', 'b1'), null) |
| 94 | }) |
| 95 | |
| 96 | test('notesFragment finds the slide\'s notes', () => { |
| 97 | const doc = deck() |
| 98 | assert.deepEqual(readText(notesFragment(doc, 's1')), [[{ t: 'Say hello' }]]) |
| 99 | assert.deepEqual(readText(notesFragment(doc, 's2')), []) |
| 100 | assert.equal(notesFragment(doc, 'nope'), null) |
| 101 | }) |
| 102 | |
| 103 | test('blockIDs and slideIDs are the orders on the stage and the strip', () => { |
| 104 | const doc = deck() |
| 105 | assert.deepEqual(blockIDs(doc, 's1'), ['b1', 'b2', 'b3']) |
| 106 | assert.deepEqual(blockIDs(doc, 's2'), []) |
| 107 | assert.deepEqual(blockIDs(doc, 'nope'), []) |
| 108 | assert.deepEqual(slideIDs(doc), ['s1', 's2']) |
| 109 | }) |
| 110 | |
| 111 | test('cellFragments are a table\'s cells, row-major', () => { |
| 112 | const doc = deck() |
| 113 | applyOps(doc, [ |
| 114 | { op: 'add_block', slide: 's2', id: 'tb', type: 'table', props: { rows: 2, cols: 3 } }, |
| 115 | { op: 'add_block', slide: 's2', id: 'tx', type: 'text', markdown: 'x' } |
| 116 | ], 'test') |
| 117 | assert.equal(cellFragments(doc, 's2', 'tb').length, 6) |
| 118 | assert.ok(cellFragments(doc, 's2', 'tb').every(c => c instanceof Y.XmlFragment)) |
| 119 | assert.deepEqual(cellFragments(doc, 's2', 'tx'), []) |
| 120 | assert.deepEqual(cellFragments(doc, 'nope', 'tb'), []) |
| 121 | }) |
| 122 | |
| 123 | test('a load that fails leaves the stage on the slide it was showing and says so', async () => { |
| 124 | const doc = deck() |
| 125 | const stageEl = { querySelectorAll: () => [], textContent: '', innerHTML: '' } |
| 126 | const root = { querySelector: sel => (sel === '[slides-stage]' ? stageEl : null) } |
| 127 | const errors = [] |
| 128 | const loaded = [] |
| 129 | let fail = true |
| 130 | const stage = mountStage({ |
| 131 | root, ydoc: doc, slideId: 's1', keys: {}, |
| 132 | fetchSlide: async id => { if (fail) throw new Error('offline'); return `<section data-id="${id}"></section>` }, |
| 133 | onLoaded: id => loaded.push(id), |
| 134 | onError: (err, id) => errors.push([err.message, id]) |
| 135 | }) |
| 136 | await assert.rejects(stage.load('s2'), /offline/) |
| 137 | assert.equal(stage.slideId, 's1') |
| 138 | assert.deepEqual(errors, [['offline', 's2']]) |
| 139 | assert.deepEqual(loaded, []) |
| 140 | fail = false |
| 141 | await stage.load('s2') |
| 142 | assert.equal(stage.slideId, 's2') |
| 143 | assert.equal(stageEl.innerHTML, '<section data-id="s2"></section>') |
| 144 | assert.deepEqual(loaded, ['s2']) |
| 145 | // setViewport re-fetches the same slide in the other rendering. |
| 146 | const asked = [] |
| 147 | const phone = mountStage({ root, ydoc: doc, slideId: 's1', keys: {}, readOnly: true, fetchSlide: async (id, vp) => { asked.push(vp); return '' } }) |
| 148 | await phone.setViewport('phone') |
| 149 | await phone.setViewport('phone') |
| 150 | assert.deepEqual(asked, ['phone']) |
| 151 | assert.equal(phone.viewport, 'phone') |
| 152 | }) |
| 153 | |
| 154 | test('slideAfter is the slide to show once one is gone', () => { |
| 155 | assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'c'], 'b'), 'c') |
| 156 | assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'b'], 'c'), 'b') |
| 157 | assert.equal(slideAfter(['a', 'b', 'c'], ['a', 'b', 'c'], 'b'), 'b') |
| 158 | assert.equal(slideAfter(['a'], [], 'a'), '') |
| 159 | assert.equal(slideAfter([], ['a'], ''), 'a') |
| 160 | }) |
| 161 | |