oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
6import test from 'node:test'
7import assert from 'node:assert/strict'
8import * as Y from 'yjs'
9import { applyOps } from './ops.mjs'
10import { readText } from './schema.mjs'
11import {
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. */
17function 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
27function block (type, id, children = []) {
28 return el(type === 'heading' ? 'h1' : 'div', { 'slides-block': '', 'data-block': type, 'data-id': id }, children)
29}
30
31test('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
49test('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
64test('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
72function 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
86test('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
96test('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
103test('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
111test('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
123test('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
154test('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