oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The checking pass of applyOps — core/src/ops.rs's Model and the
2// vocabularies it closes.
3//
4// A Yjs transaction cannot be rolled back, so "a failure applies
5// nothing" has to be true before the first write: every op, once parsed
6// (ops-parse.mjs), is checked against a model of the deck — the slide
7// ids, their block ids and types, the asset ids — that each op updates
8// as it is checked, so a reference that only becomes wrong halfway
9// through a batch (remove_block on a block an earlier op removed) is
10// caught here too. The messages are the core's, word for word, so the
11// inspector and the drive API say the same thing about the same mistake.
12import { getString, getArray, getObject, orderedMaps } from './ydoc.mjs'
13
14export { parseOp } from './ops-parse.mjs'
15
16export const LAYOUTS = ['flow', 'canvas']
17export const TEMPLATES = ['title', 'title-body', 'two-column', 'section', 'image-full', 'quote', 'blank']
18export const REGIONS = ['heading', 'body', 'aside', 'media']
19export const BACKGROUND_KINDS = ['theme', 'color', 'image']
20export const SLIDE_PHONES = ['inherit', 'reflow']
21export const TRANSITIONS = ['none', 'fade']
22export const BLOCK_TYPES = ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer']
23export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape']
24export const SIZES = ['s', 'm', 'l']
25export const MAX_TABLE_SIDE = 100
26
27export const isTextType = kind => TEXT_TYPES.includes(kind)
28
29export class OpsError extends Error {
30 constructor (message, index) {
31 super(message)
32 this.name = 'OpsError'
33 this.index = index
34 }
35}
36
37const fail = msg => { throw new Error(msg) }
38const isInt = v => Number.isInteger(v)
39
40function inVocab (what, value, vocab) {
41 if (!vocab.includes(value)) fail(`${what} ${JSON.stringify(value)} is not one of ${vocab.join(', ')}`)
42}
43function nonEmpty (what, value) {
44 if (value === '') fail(`${what} is empty`)
45}
46
47/** rows/cols from props when present: whole numbers within 1..=MAX_TABLE_SIDE. */
48export function tableSide (props, key) {
49 const v = props[key]
50 if (v === undefined) return null
51 if (!isInt(v) || v < 1 || v > MAX_TABLE_SIDE) fail(`table ${key} must be a whole number from 1 to ${MAX_TABLE_SIDE}`)
52 return v
53}
54
55function blockIndex (s, id) {
56 const i = s.blocks.findIndex(b => b.id === id)
57 if (i === -1) fail(`no block ${JSON.stringify(id)} in slide ${JSON.stringify(s.id)}`)
58 return i
59}
60
61/**
62 * A columns block's props.children (spec §2.4): block ids, each naming
63 * a block on s other than selfId, each named once, none already a child
64 * of another columns block, and none with selfId underneath it — the
65 * children of a slide are a forest, one parent per block and no way
66 * back up (codex 7). null clears. The messages are core/src/ops.rs's,
67 * word for word.
68 */
69function validateChildren (s, selfId, value) {
70 if (value === null) return []
71 if (!Array.isArray(value)) fail('columns children must be an array of block ids')
72 const out = []
73 for (const cid of value) {
74 if (typeof cid !== 'string') fail('columns children must be an array of block ids')
75 if (cid === selfId) fail(`columns ${JSON.stringify(selfId)} cannot name itself in children`)
76 if (!s.blocks.some(b => b.id === cid)) fail(`columns children names ${JSON.stringify(cid)}, not a block on slide ${JSON.stringify(s.id)}`)
77 if (out.includes(cid)) fail(`columns children names ${JSON.stringify(cid)} twice`)
78 const other = s.blocks.find(b => b.id !== selfId && b.children !== null && b.children.includes(cid))
79 if (other) fail(`columns children names ${JSON.stringify(cid)}, already a child of columns ${JSON.stringify(other.id)}`)
80 if (contains(s, cid, selfId)) fail(`columns children names ${JSON.stringify(cid)}, which would put columns ${JSON.stringify(selfId)} inside itself`)
81 out.push(cid)
82 }
83 return out
84}
85
86/** Whether target is root or sits anywhere under it, following the slide's columns children as the model holds them. */
87function contains (s, root, target) {
88 const stack = [root]
89 const seen = new Set()
90 while (stack.length) {
91 const id = stack.pop()
92 if (id === target) return true
93 if (seen.has(id)) continue
94 seen.add(id)
95 const b = s.blocks.find(x => x.id === id)
96 if (b && b.children !== null) stack.push(...b.children)
97 }
98 return false
99}
100
101export class Model {
102 static read (r) {
103 const m = new Model()
104 m.slides = orderedMaps(r.slides).map(([, sm]) => {
105 const blocks = getArray(sm, 'blocks')
106 // Reading order: after and duplicate_slide's new_block_ids are
107 // positional, and the position that means anything is the one a
108 // reader of the view sees.
109 return {
110 id: getString(sm, 'id') ?? '',
111 blocks: blocks === null ? [] : orderedMaps(blocks).map(([, b]) => {
112 const kind = getString(b, 'type') ?? ''
113 let children = null
114 if (kind === 'columns') {
115 const c = (getObject(b, 'props') ?? {}).children
116 children = Array.isArray(c) ? c.filter(x => typeof x === 'string') : []
117 }
118 return { id: getString(b, 'id') ?? '', kind, children }
119 })
120 }
121 })
122 m.assets = new Set()
123 r.assets.forEach((_, k) => m.assets.add(k))
124 return m
125 }
126
127 slideIndex (id) {
128 const i = this.slides.findIndex(s => s.id === id)
129 if (i === -1) fail(`no slide ${JSON.stringify(id)}`)
130 return i
131 }
132
133 hasSlide (id) { return this.slides.some(s => s.id === id) }
134
135 slide (id) { return this.slides[this.slideIndex(id)] }
136
137 check (op) {
138 switch (op.op) {
139 case 'set_meta': return
140 case 'add_slide': {
141 nonEmpty('id', op.id)
142 if (this.hasSlide(op.id)) fail(`slide ${JSON.stringify(op.id)} already exists`)
143 inVocab('layout', op.layout, LAYOUTS)
144 if (op.template !== undefined) inVocab('template', op.template, TEMPLATES)
145 const at = op.after === undefined ? this.slides.length : op.after === null ? 0 : this.slideIndex(op.after) + 1
146 this.slides.splice(at, 0, { id: op.id, blocks: [] })
147 return
148 }
149 case 'remove_slide':
150 this.slides.splice(this.slideIndex(op.id), 1)
151 return
152 case 'move_slide': {
153 this.slideIndex(op.id)
154 if (op.after !== undefined) {
155 if (op.after === op.id) fail('move_slide: after is the slide itself')
156 this.slideIndex(op.after)
157 }
158 const [s] = this.slides.splice(this.slideIndex(op.id), 1)
159 const at = op.after === undefined ? 0 : this.slideIndex(op.after) + 1
160 this.slides.splice(at, 0, s)
161 return
162 }
163 case 'duplicate_slide': {
164 nonEmpty('new_id', op.new_id)
165 const i = this.slideIndex(op.id)
166 if (this.hasSlide(op.new_id)) fail(`slide ${JSON.stringify(op.new_id)} already exists`)
167 const n = this.slides[i].blocks.length
168 if (op.new_block_ids.length !== n) fail(`duplicate_slide: ${n} blocks but ${op.new_block_ids.length} new_block_ids`)
169 if (new Set(op.new_block_ids).size !== n || op.new_block_ids.some(x => x === '')) fail('duplicate_slide: new_block_ids must be distinct and non-empty')
170 const idMap = new Map(this.slides[i].blocks.map((b, k) => [b.id, op.new_block_ids[k]]))
171 const blocks = this.slides[i].blocks.map((b, k) => ({
172 id: op.new_block_ids[k],
173 kind: b.kind,
174 children: b.children === null ? null : b.children.map(c => idMap.get(c) ?? c)
175 }))
176 this.slides.splice(i + 1, 0, { id: op.new_id, blocks })
177 return
178 }
179 case 'set_slide':
180 this.slideIndex(op.id)
181 if (op.layout !== undefined) inVocab('layout', op.layout, LAYOUTS)
182 if (op.template !== undefined) inVocab('template', op.template, TEMPLATES)
183 if (op.background !== undefined) inVocab('background.kind', op.background.kind, BACKGROUND_KINDS)
184 if (op.phone !== undefined) inVocab('phone', op.phone, SLIDE_PHONES)
185 if (op.transition !== undefined) inVocab('transition', op.transition, TRANSITIONS)
186 return
187 case 'set_notes':
188 this.slideIndex(op.id)
189 return
190 case 'add_block': {
191 nonEmpty('id', op.id)
192 inVocab('type', op.type, BLOCK_TYPES)
193 if (op.region !== undefined) inVocab('region', op.region, REGIONS)
194 if (op.type === 'table') { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') }
195 const s = this.slide(op.slide)
196 if (s.blocks.some(b => b.id === op.id)) fail(`block ${JSON.stringify(op.id)} already exists in slide ${JSON.stringify(op.slide)}`)
197 let children = null
198 if (op.type === 'columns') children = op.props.children === undefined ? [] : validateChildren(s, op.id, op.props.children)
199 const at = op.after === undefined ? s.blocks.length : op.after === null ? 0 : blockIndex(s, op.after) + 1
200 s.blocks.splice(at, 0, { id: op.id, kind: op.type, children })
201 return
202 }
203 case 'remove_block': {
204 const s = this.slide(op.slide)
205 s.blocks.splice(blockIndex(s, op.id), 1)
206 for (const b of s.blocks) if (b.children !== null) b.children = b.children.filter(c => c !== op.id)
207 return
208 }
209 case 'move_block': {
210 const s = this.slide(op.slide)
211 const i = blockIndex(s, op.id)
212 if (op.after !== undefined) {
213 if (op.after === op.id) fail('move_block: after is the block itself')
214 blockIndex(s, op.after)
215 }
216 const [b] = s.blocks.splice(i, 1)
217 const at = op.after === undefined ? 0 : blockIndex(s, op.after) + 1
218 s.blocks.splice(at, 0, b)
219 return
220 }
221 case 'set_block': {
222 const s = this.slide(op.slide)
223 const i = blockIndex(s, op.id)
224 if (typeof op.region === 'string') inVocab('region', op.region, REGIONS)
225 if (s.blocks[i].kind === 'table' && op.props !== undefined) { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') }
226 if (s.blocks[i].kind === 'columns' && op.props !== undefined && op.props.children !== undefined) {
227 s.blocks[i].children = validateChildren(s, op.id, op.props.children)
228 }
229 return
230 }
231 case 'set_text': {
232 const s = this.slide(op.slide)
233 const b = s.blocks[blockIndex(s, op.id)]
234 if (!isTextType(b.kind)) fail(`block ${JSON.stringify(op.id)} is a ${b.kind} and carries no text`)
235 return
236 }
237 case 'set_phone': {
238 const s = this.slide(op.slide)
239 blockIndex(s, op.id)
240 if (op.phone.size !== undefined) inVocab('phone.size', op.phone.size, SIZES)
241 return
242 }
243 case 'register_asset':
244 nonEmpty('id', op.id)
245 nonEmpty('ref', op.ref)
246 if (this.assets.has(op.id)) fail(`asset ${JSON.stringify(op.id)} is already registered`)
247 this.assets.add(op.id)
248 return
249 case 'remove_asset':
250 if (!this.assets.delete(op.id)) fail(`no asset ${JSON.stringify(op.id)}`)
251 }
252 }
253}
254