oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Each inspector control produces the one op it stands for
2// (inspector-ops.mjs), and every op it produces is one the ops module
3// accepts: each is applied to a real deck built through ops.mjs, so a
4// field that named a prop the core refuses would fail here.
5import test from 'node:test'
6import assert from 'node:assert/strict'
7import * as Y from 'yjs'
8import { applyOps, view } from './ops.mjs'
9import {
10 deckOp, slideOp, moveSlideOp, propValue, propOp, regionOp, labelOp, boxFieldOp,
11 moveBlockOp, zOps, addBlockOp, blockLabel, DEFAULT_BOX
12} from './inspector-ops.mjs'
13import { fillBlocks } from './inspector.mjs'
14import { el as stub, withDocument } from './dom-stub.mjs'
15import { blockChange } from './inspector-block.mjs'
16
17function deck () {
18 const doc = new Y.Doc()
19 applyOps(doc, [
20 { op: 'set_meta', title: 'T', theme: 'paper' },
21 { op: 'add_slide', id: 'slideaaaaaaa', layout: 'flow', template: 'title-body' },
22 { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockheading', type: 'heading', region: 'heading', props: { level: 1 }, markdown: 'Hello world of slides' },
23 { op: 'add_block', slide: 'slideaaaaaaa', id: 'blocktexttxt', type: 'text', region: 'body', markdown: 'Body' },
24 { op: 'add_block', slide: 'slideaaaaaaa', id: 'blockshapeee', type: 'shape', region: 'body', props: { kind: 'rect' } },
25 { op: 'add_slide', id: 'slidebbbbbbb', layout: 'canvas' },
26 { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock1', type: 'text', box: { x: 0.1, y: 0.1, w: 0.3, h: 0.2 }, z: 0, markdown: 'One' },
27 { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock2', type: 'shape', box: { x: 0.5, y: 0.5, w: 0.3, h: 0.2 }, z: 1 },
28 { op: 'add_block', slide: 'slidebbbbbbb', id: 'canvasblock3', type: 'image', box: { x: 0.2, y: 0.6, w: 0.3, h: 0.2 }, z: 2 },
29 { op: 'add_slide', id: 'slideccccccc', layout: 'flow', template: 'blank' }
30 ], 'test')
31 return doc
32}
33const accepts = (doc, ops) => assert.doesNotThrow(() => applyOps(doc, Array.isArray(ops) ? ops : [ops], 'test'))
34
35test('deck controls are set_meta', () => {
36 const doc = deck()
37 const meta = view(doc).meta
38 assert.deepEqual(deckOp('title', 'New', meta), { op: 'set_meta', title: 'New' })
39 assert.deepEqual(deckOp('theme', 'ink', meta), { op: 'set_meta', theme: 'ink' })
40 assert.deepEqual(deckOp('accent', '#ff0000', meta), { op: 'set_meta', accent: '#ff0000' })
41 assert.deepEqual(deckOp('accent-reset', undefined, meta), { op: 'set_meta', accent: null })
42 assert.deepEqual(deckOp('font-heading', 'Georgia, serif', meta), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: '' } })
43 for (const f of ['title', 'theme', 'accent', 'accent-reset']) accepts(doc, deckOp(f, f === 'theme' ? 'ink' : '#123456', meta))
44 accepts(doc, deckOp('font-heading', 'Georgia, serif', meta))
45 const after = view(doc).meta
46 assert.deepEqual(deckOp('font-body', 'monospace', after), { op: 'set_meta', fonts: { heading: 'Georgia, serif', body: 'monospace' } })
47 // Both back to the theme's clears the map rather than storing two empties.
48 assert.deepEqual(deckOp('font-heading', '', { fonts: { heading: 'x', body: '' } }), { op: 'set_meta', fonts: null })
49 accepts(doc, deckOp('font-heading', '', after))
50 assert.equal(view(doc).meta.fonts, null)
51})
52
53test('slide controls are set_slide, and background keeps its colour', () => {
54 const doc = deck()
55 const s = view(doc).slides[0]
56 assert.deepEqual(slideOp(s, 'layout', 'canvas'), { op: 'set_slide', id: s.id, layout: 'canvas' })
57 assert.deepEqual(slideOp(s, 'template', 'quote'), { op: 'set_slide', id: s.id, template: 'quote' })
58 assert.deepEqual(slideOp(s, 'transition', 'fade'), { op: 'set_slide', id: s.id, transition: 'fade' })
59 assert.deepEqual(slideOp(s, 'phone', 'reflow'), { op: 'set_slide', id: s.id, phone: 'reflow' })
60 assert.deepEqual(slideOp(s, 'background-color', '#112233'), { op: 'set_slide', id: s.id, background: { kind: 'color', value: '#112233' } })
61 assert.deepEqual(slideOp(s, 'background', 'theme'), { op: 'set_slide', id: s.id, background: { kind: 'theme', value: '' } })
62 const coloured = { ...s, background: { kind: 'color', value: '#abcdef' } }
63 assert.deepEqual(slideOp(coloured, 'background', 'color').background, { kind: 'color', value: '#abcdef' })
64 assert.deepEqual(slideOp(s, 'background', 'color').background, { kind: 'color', value: '#ffffff' })
65 for (const [f, v] of [['template', 'quote'], ['transition', 'fade'], ['phone', 'reflow'], ['background-color', '#112233'], ['background', 'theme'], ['layout', 'canvas']]) accepts(doc, slideOp(s, f, v))
66 assert.equal(view(doc).slides[0].layout, 'canvas')
67 assert.throws(() => slideOp(s, 'nope', 1))
68})
69
70test('move up and down are move_slide with the right after', () => {
71 const ids = ['a', 'b', 'c']
72 assert.equal(moveSlideOp(ids, 'a', 'up'), null)
73 assert.deepEqual(moveSlideOp(ids, 'b', 'up'), { op: 'move_slide', id: 'b' })
74 assert.deepEqual(moveSlideOp(ids, 'c', 'up'), { op: 'move_slide', id: 'c', after: 'a' })
75 assert.deepEqual(moveSlideOp(ids, 'a', 'down'), { op: 'move_slide', id: 'a', after: 'b' })
76 assert.equal(moveSlideOp(ids, 'c', 'down'), null)
77 const doc = deck()
78 accepts(doc, moveSlideOp(view(doc).slides.map(s => s.id), 'slideccccccc', 'up'))
79 assert.deepEqual(view(doc).slides.map(s => s.id), ['slideaaaaaaa', 'slideccccccc', 'slidebbbbbbb'])
80})
81
82test('block props: value kinds, region, a shape label', () => {
83 const doc = deck()
84 const s = view(doc).slides[0]
85 const [heading, text, shape] = s.blocks
86 assert.equal(propValue('2', 'int'), 2)
87 assert.equal(propValue('', 'int'), null)
88 assert.equal(propValue('x', 'int'), null)
89 assert.equal(propValue(true, 'bool'), true)
90 assert.equal(propValue('on', 'bool'), true)
91 assert.equal(propValue('', ''), null)
92 assert.deepEqual(propOp(s.id, heading, 'level', 2), { op: 'set_block', slide: s.id, id: heading.id, props: { level: 2 } })
93 assert.deepEqual(regionOp(s.id, text, 'aside'), { op: 'set_block', slide: s.id, id: text.id, region: 'aside' })
94 assert.deepEqual(labelOp(s.id, shape, 'Go'), { op: 'set_text', slide: s.id, id: shape.id, markdown: 'Go' })
95 accepts(doc, [propOp(s.id, heading, 'level', 2), regionOp(s.id, text, 'aside'), labelOp(s.id, shape, 'Go'), propOp(s.id, text, 'align', null)])
96 const after = view(doc).slides[0]
97 assert.equal(after.blocks[0].props.level, 2)
98 assert.equal(after.blocks[1].region, 'aside')
99 assert.deepEqual(after.blocks[2].text, [[{ t: 'Go' }]])
100})
101
102test('box fields are set_block on the desktop and set_phone in the phone view', () => {
103 const doc = deck()
104 const s = view(doc).slides[1]
105 const b = s.blocks[0]
106 assert.deepEqual(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '25' }),
107 { op: 'set_block', slide: s.id, id: b.id, box: { x: 0.25, y: 0.1, w: 0.3, h: 0.2 } })
108 assert.deepEqual(boxFieldOp({ view: 'phone', slideId: s.id, block: b, key: 'w', percent: '50' }),
109 { op: 'set_phone', slide: s.id, id: b.id, phone: { box: { x: 0.1, y: 0.1, w: 0.5, h: 0.2 } } })
110 assert.equal(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: 'abc' }), null)
111 accepts(doc, [boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '25' }), boxFieldOp({ view: 'phone', slideId: s.id, block: b, key: 'w', percent: '50' })])
112 const after = view(doc).slides[1].blocks[0]
113 assert.equal(after.box.x, 0.25)
114 assert.deepEqual(after.phone.box, { x: 0.1, y: 0.1, w: 0.5, h: 0.2 })
115 // Typed values are clamped to the frame.
116 assert.deepEqual(boxFieldOp({ view: 'desktop', slideId: s.id, block: b, key: 'x', percent: '95' }).box, { x: 0.7, y: 0.1, w: 0.3, h: 0.2 })
117})
118
119test('move block and the layer buttons', () => {
120 const doc = deck()
121 const s = view(doc).slides[1]
122 const ids = s.blocks.map(b => b.id)
123 assert.equal(moveBlockOp(s.id, ids, ids[0], 'up'), null)
124 assert.deepEqual(moveBlockOp(s.id, ids, ids[1], 'up'), { op: 'move_block', slide: s.id, id: ids[1] })
125 assert.deepEqual(moveBlockOp(s.id, ids, ids[2], 'up'), { op: 'move_block', slide: s.id, id: ids[2], after: ids[0] })
126 assert.deepEqual(moveBlockOp(s.id, ids, ids[0], 'down'), { op: 'move_block', slide: s.id, id: ids[0], after: ids[1] })
127 // z: 0,1,2 — bringing the first forward swaps it with the second.
128 assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'forward'), [
129 { op: 'set_block', slide: s.id, id: ids[1], z: 0 },
130 { op: 'set_block', slide: s.id, id: ids[0], z: 1 }
131 ])
132 assert.deepEqual(zOps(s.id, s.blocks, ids[2], 'forward'), [])
133 assert.deepEqual(zOps(s.id, s.blocks, ids[0], 'back'), [])
134 // Duplicate zs are renumbered on the way through.
135 const dup = s.blocks.map(b => ({ ...b, z: 0 }))
136 assert.deepEqual(zOps(s.id, dup, ids[2], 'back').map(o => [o.id, o.z]).sort(), [[ids[1], 2], [ids[2], 1]].sort())
137 accepts(doc, zOps(s.id, s.blocks, ids[0], 'forward'))
138 assert.deepEqual(view(doc).slides[1].blocks.map(b => b.z), [1, 0, 2])
139 accepts(doc, moveBlockOp(s.id, ids, ids[2], 'up'))
140 assert.deepEqual(view(doc).slides[1].blocks.map(b => b.id), [ids[0], ids[2], ids[1]])
141})
142
143test('add block: region after the selection on flow, a default box on canvas, a heading level that fits', () => {
144 const doc = deck()
145 const [flow, canvas, blank] = view(doc).slides
146 const a = addBlockOp(flow, 'text', 'newblock0001', flow.blocks[0].id)
147 assert.deepEqual(a, { op: 'add_block', slide: flow.id, id: 'newblock0001', type: 'text', props: {}, after: flow.blocks[0].id, markdown: '', region: 'body' })
148 assert.equal(addBlockOp(flow, 'heading', 'newblock0002', '').props.level, 2)
149 assert.equal(addBlockOp(blank, 'heading', 'newblock0003', '').props.level, 1)
150 assert.equal(addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1').region, 'media')
151 assert.equal('after' in addBlockOp(flow, 'image', 'newblock0004', 'nosuchblock1'), false)
152 const c = addBlockOp(canvas, 'shape', 'newblock0005', '')
153 assert.deepEqual(c.box, DEFAULT_BOX)
154 assert.equal(c.z, 3)
155 assert.equal('markdown' in addBlockOp(canvas, 'divider', 'newblock0006', ''), false)
156 assert.deepEqual(addBlockOp(flow, 'table', 'newblock0007', '').props, { rows: 2, cols: 2 })
157 for (const kind of ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer']) {
158 accepts(doc, addBlockOp(view(doc).slides[0], kind, 'added' + kind.padEnd(7, 'x'), ''))
159 accepts(doc, addBlockOp(view(doc).slides[1], kind, 'canvs' + kind.padEnd(7, 'x'), ''))
160 }
161 assert.equal(view(doc).slides[0].blocks.length, 3 + 11)
162})
163
164test('a block is named by its type and its first words', () => {
165 const doc = deck()
166 const s = view(doc).slides[0]
167 assert.equal(blockLabel(s.blocks[0], 'Heading'), 'Heading: Hello world of slides')
168 assert.equal(blockLabel(s.blocks[2], 'Shape'), 'Shape')
169 assert.equal(blockLabel({ text: [[{ t: 'a'.repeat(60) }]] }, 'Text', 10), 'Text: ' + 'a'.repeat(9) + '…')
170})
171
172test('blockChange: a control element to its op', () => {
173 const doc = deck()
174 const flow = view(doc).slides[0]
175 const canvas = view(doc).slides[1]
176 const el = (dataset, value, extra = {}) => ({ dataset, value, type: 'text', ...extra })
177 assert.deepEqual(blockChange({ el: el({ prop: 'level', kind: 'int' }, '3'), slide: flow, block: flow.blocks[0], view: 'desktop' }),
178 [{ op: 'set_block', slide: flow.id, id: flow.blocks[0].id, props: { level: 3 } }])
179 assert.deepEqual(blockChange({ el: el({ prop: 'header', kind: 'bool' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }),
180 [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { header: true } }])
181 assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'desktop' }),
182 [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, props: { size: 'l' } }])
183 assert.deepEqual(blockChange({ el: el({ prop: 'size' }, 'l'), slide: flow, block: flow.blocks[1], view: 'phone' }),
184 [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { size: 'l' } }])
185 assert.deepEqual(blockChange({ el: el({ text: 'label' }, ' Go '), slide: flow, block: flow.blocks[2], view: 'desktop' }),
186 [{ op: 'set_text', slide: flow.id, id: flow.blocks[2].id, markdown: 'Go' }])
187 assert.deepEqual(blockChange({ el: el({ place: 'region' }, 'aside'), slide: flow, block: flow.blocks[1], view: 'desktop' }),
188 [{ op: 'set_block', slide: flow.id, id: flow.blocks[1].id, region: 'aside' }])
189 assert.deepEqual(blockChange({ el: el({ box: 'y' }, '40'), slide: canvas, block: canvas.blocks[0], view: 'desktop' }),
190 [{ op: 'set_block', slide: canvas.id, id: canvas.blocks[0].id, box: { x: 0.1, y: 0.4, w: 0.3, h: 0.2 } }])
191 assert.deepEqual(blockChange({ el: el({ phone: 'hidden' }, 'on', { type: 'checkbox', checked: true }), slide: flow, block: flow.blocks[1], view: 'desktop' }),
192 [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }])
193 assert.deepEqual(blockChange({ el: el({ phone: 'order' }, '-1'), slide: flow, block: flow.blocks[1], view: 'desktop' }),
194 [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { order: -1 } }])
195 assert.deepEqual(blockChange({ el: el({ phone: 'size' }, ''), slide: flow, block: { ...flow.blocks[1], phone: { size: 's', hidden: true } }, view: 'desktop' }),
196 [{ op: 'set_phone', slide: flow.id, id: flow.blocks[1].id, phone: { hidden: true } }])
197 assert.deepEqual(blockChange({ el: el({}, 'x'), slide: flow, block: flow.blocks[1], view: 'desktop' }), [])
198})
199
200// The Blocks list is refilled on EVERY document transaction — a
201// colleague's keystroke included — and it is the keyboard's way into a
202// block on a canvas slide. So it is reconciled rather than rebuilt: an
203// unchanged list has to be the SAME button elements afterwards, or the
204// focus went out of the document with the old ones.
205test('the Blocks list reconciles by id and keeps its buttons', () => {
206 const ctx = withDocument()
207 try {
208 const list = stub('div', { 'slides-blocks-buttons': '' })
209 const none = stub('p', { 'slides-blocks-none': '' })
210 const blocks = [{ id: 'b1', type: 'heading' }, { id: 'b2', type: 'text' }]
211 const label = b => b.type
212 const ids = () => list.children.map(b => b.dataset.selectBlock)
213
214 fillBlocks({ list, none, blocks, selected: 'b1', label })
215 assert.deepEqual(ids(), ['b1', 'b2'])
216 assert.equal(none.hidden, true)
217 const [first, second] = list.children
218 assert.equal(first.getAttribute('aria-pressed'), 'true')
219
220 fillBlocks({ list, none, blocks, selected: 'b1', label })
221 assert.equal(list.children[0], first, 'the same element, not a replacement')
222 assert.equal(list.children[1], second)
223
224 // A selection change writes the attribute and moves nothing.
225 fillBlocks({ list, none, blocks, selected: 'b2', label })
226 assert.equal(list.children[0], first)
227 assert.equal(first.getAttribute('aria-pressed'), 'false')
228 assert.equal(second.getAttribute('aria-pressed'), 'true')
229
230 // Reordering moves the elements that are already there.
231 fillBlocks({ list, none, blocks: [blocks[1], blocks[0]], selected: '', label })
232 assert.deepEqual(ids(), ['b2', 'b1'])
233 assert.equal(list.children[0], second)
234 assert.equal(list.children[1], first)
235
236 // A removed block takes its button with it; an empty slide says so.
237 fillBlocks({ list, none, blocks: [blocks[0]], selected: '', label })
238 assert.deepEqual(ids(), ['b1'])
239 assert.equal(list.children[0], first)
240 fillBlocks({ list, none, blocks: [], selected: '', label })
241 assert.deepEqual(ids(), [])
242 assert.equal(none.hidden, false)
243 } finally {
244 ctx.restore()
245 }
246})
247