oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The second pass of applyOps — core/src/ops.rs's apply_one, and the
2// constructors it uses from schema.rs. Runs inside one transaction after
3// the model has passed every op, so by construction every lookup here
4// succeeds; the throws that remain surface a bug as a message rather
5// than a half-applied batch going unnoticed.
6//
7// What a slide and a block look like in the document is decided here
8// and in the core together (schema.rs's module comment): a block map
9// holds id, type, props (Y.Map of plain values), phone (Y.Map), then
10// region (flow) or box + z (canvas); text-bearing types carry text (an
11// XmlFragment), a table carries cells (a Y.Array of XmlFragments).
12import * as Y from 'yjs'
13import { getString, getNumber, getMap, getArray, getFragment, findById, mapsOf, mapToJSON, cloneMap, orderedMaps, hasExplicitOrder, renumber } from './ydoc.mjs'
14import { writeText, textFragment } from './schema.mjs'
15import { parse, parseLines } from './markdown.mjs'
16import { defaultRegion, regionRect, boxJSON, blocksOf, toCanvas, toFlow } from './layout.mjs'
17import { isTextType, tableSide } from './ops-model.mjs'
18
19export const DEFAULT_TEMPLATE = 'blank'
20export const DEFAULT_TABLE_ROWS = 2
21export const DEFAULT_TABLE_COLS = 2
22
23const fail = msg => { throw new Error(msg) }
24
25function slideMap (r, id) {
26 return findById(r.slides, id) ?? fail(`no slide ${JSON.stringify(id)}`)
27}
28function blockMap (blocks, id) {
29 return findById(blocks, id) ?? fail(`no block ${JSON.stringify(id)}`)
30}
31
32/** Where an after puts a new entry: absent appends, null goes first, an id goes right after it. */
33function insertIndex (array, after) {
34 if (after === undefined) return array.length
35 if (after === null) return 0
36 return (findById(array, after) ?? fail(`no entry ${JSON.stringify(after)}`))[0] + 1
37}
38
39/**
40 * Puts the entry id of an ordered array — a slide of the deck, a block
41 * of a slide — at the reading position after asks for: undefined means
42 * first, an id means right after it. It does that by renumbering order
43 * on every entry (ydoc.mjs, "reading order"). The array is not
44 * touched, so a peer's edit in flight against any entry still lands
45 * (codex 4).
46 */
47function placeEntry (array, what, id, after) {
48 const ordered = orderedMaps(array).map(([, m]) => [getString(m, 'id') ?? '', m])
49 const i = ordered.findIndex(([bid]) => bid === id)
50 if (i === -1) fail(`no ${what} ${JSON.stringify(id)}`)
51 const [moved] = ordered.splice(i, 1)
52 let at = 0
53 if (after !== undefined) {
54 const j = ordered.findIndex(([bid]) => bid === after)
55 if (j === -1) fail(`no entry ${JSON.stringify(after)}`)
56 at = j + 1
57 }
58 ordered.splice(at, 0, moved)
59 renumber(ordered.map(([, m]) => m))
60}
61
62/**
63 * placeEntry for an entry just inserted, whose after is the
64 * three-valued kind (undefined appends, null first, an id after it).
65 * Only called when the array already carries explicit order keys.
66 */
67function placeNew (array, what, id, after) {
68 let afterId
69 if (after === undefined) {
70 const others = orderedMaps(array).map(([, m]) => getString(m, 'id') ?? '').filter(other => other !== id)
71 afterId = others.length ? others[others.length - 1] : undefined
72 } else if (after === null) {
73 afterId = undefined
74 } else {
75 afterId = after
76 }
77 placeEntry(array, what, id, afterId)
78}
79
80function fragmentOf (map, key) {
81 let f = getFragment(map, key)
82 if (f === null) { f = new Y.XmlFragment(); map.set(key, f) }
83 return f
84}
85function mapOf (map, key) {
86 let m = getMap(map, key)
87 if (m === null) { m = new Y.Map(); map.set(key, m) }
88 return m
89}
90
91/** Text for a block of kind from its Markdown: code is plain lines, everything else the subset. */
92const textFor = (kind, markdown) => (kind === 'code' ? parseLines(markdown) : parse(markdown))
93
94function plainMap (entries) {
95 const m = new Y.Map()
96 for (const k of Object.keys(entries)) m.set(k, entries[k])
97 return m
98}
99
100function backgroundMap (kind, value) {
101 return plainMap({ kind, value })
102}
103
104/** A block map with a fresh text fragment / cells array where the type wants one (schema.rs block_prelim). */
105function blockPrelim ({ id, kind, props, region, box, z, text, cells }) {
106 const m = new Y.Map()
107 m.set('id', id)
108 m.set('type', kind)
109 m.set('props', plainMap(props))
110 m.set('phone', new Y.Map())
111 if (region !== undefined) m.set('region', region)
112 if (box !== undefined) m.set('box', box)
113 if (z !== undefined) m.set('z', z)
114 if (text !== undefined) m.set('text', textFragment(text))
115 if (cells !== undefined) {
116 const a = new Y.Array()
117 m.set('cells', a)
118 a.insert(0, Array.from({ length: cells }, () => new Y.XmlFragment()))
119 }
120 return m
121}
122
123function slidePrelim (id, layout, template) {
124 const m = new Y.Map()
125 m.set('id', id)
126 m.set('layout', layout)
127 m.set('template', template)
128 m.set('phone', 'inherit')
129 m.set('transition', 'none')
130 m.set('background', backgroundMap('theme', ''))
131 m.set('notes', new Y.XmlFragment())
132 m.set('blocks', new Y.Array())
133 return m
134}
135
136/** Drops removedId from every columns block's props.children in blocks. */
137function removeFromColumnsChildren (blocks, removedId) {
138 for (const [, m] of mapsOf(blocks)) {
139 if (getString(m, 'type') !== 'columns') continue
140 const pm = getMap(m, 'props')
141 if (pm === null) continue
142 const children = mapToJSON(pm).children
143 if (!Array.isArray(children) || !children.includes(removedId)) continue
144 pm.set('children', children.filter(c => c !== removedId))
145 }
146}
147
148/**
149 * Gives a copied slide its new ids: the slide's own, each block's, and
150 * any columns.children naming the old ones. The k-th new id goes to the
151 * k-th block in READING order — the order the model paired them in —
152 * whatever the array position; the copied order keys come along, so
153 * the copy reads as the original did.
154 */
155function rewriteIds (copy, newId, newBlockIds) {
156 copy.set('id', newId)
157 const blocks = getArray(copy, 'blocks')
158 if (blocks === null) return
159 const mapping = new Map()
160 const maps = orderedMaps(blocks)
161 maps.forEach(([, bm], k) => {
162 if (k >= newBlockIds.length) return
163 const old = getString(bm, 'id')
164 if (old !== null) mapping.set(old, newBlockIds[k])
165 bm.set('id', newBlockIds[k])
166 })
167 for (const [, bm] of maps) {
168 const props = getMap(bm, 'props')
169 if (props === null) continue
170 const children = props.get('children')
171 if (!Array.isArray(children)) continue
172 props.set('children', children.map(c => (typeof c === 'string' && mapping.has(c) ? mapping.get(c) : c)))
173 }
174}
175
176function setMeta (r, op) {
177 if (op.title !== undefined) r.meta.set('title', op.title)
178 if (op.theme !== undefined) r.meta.set('theme', op.theme)
179 if (op.accent === null) r.meta.delete('accent')
180 else if (op.accent !== undefined) r.meta.set('accent', op.accent)
181 if (op.fonts === null) r.meta.delete('fonts')
182 else if (op.fonts !== undefined) r.meta.set('fonts', { heading: op.fonts.heading, body: op.fonts.body })
183}
184
185function addBlock (r, op) {
186 const [, s] = slideMap(r, op.slide)
187 const blocks = blocksOf(s)
188 const at = insertIndex(blocks, op.after)
189 const canvas = getString(s, 'layout') === 'canvas'
190 const props = { ...op.props }
191 let cells
192 if (op.type === 'table') {
193 const rows = tableSide(props, 'rows') ?? DEFAULT_TABLE_ROWS
194 const cols = tableSide(props, 'cols') ?? DEFAULT_TABLE_COLS
195 props.rows = rows
196 props.cols = cols
197 cells = rows * cols
198 }
199 const text = isTextType(op.type) ? (op.markdown === undefined ? [] : textFor(op.type, op.markdown)) : undefined
200 const region = op.region ?? defaultRegion(op.type)
201 const spec = { id: op.id, kind: op.type, props, text, cells }
202 if (canvas) {
203 spec.box = op.box !== undefined ? boxJSON(op.box.x, op.box.y, op.box.w, op.box.h) : boxJSON(...regionRect(region, false))
204 spec.z = op.z ?? at
205 } else {
206 spec.region = region
207 }
208 const explicit = hasExplicitOrder(blocks)
209 blocks.insert(at, [blockPrelim(spec)])
210 // On a slide nobody has reordered the array IS the reading order and
211 // the insert put the block where after asked. Once the slide carries
212 // order keys the array position means nothing, so the new block is
213 // placed by renumbering instead (ydoc.mjs, "reading order").
214 if (explicit) placeNew(blocks, 'block', op.id, op.after)
215}
216
217function setBlock (r, op) {
218 const [, s] = slideMap(r, op.slide)
219 const blocks = blocksOf(s)
220 const [, m] = blockMap(blocks, op.id)
221 if (op.props !== undefined) {
222 const pm = mapOf(m, 'props')
223 for (const k of Object.keys(op.props)) {
224 if (op.props[k] === null) pm.delete(k)
225 else pm.set(k, op.props[k])
226 }
227 const isTable = getString(m, 'type') === 'table'
228 // cells is row-major and flat, so a size change only appends to or
229 // truncates the end of it (spec §2.4's v1 limitation).
230 if (isTable && ('rows' in op.props || 'cols' in op.props)) {
231 const rows = Math.trunc(getNumber(pm, 'rows') ?? DEFAULT_TABLE_ROWS)
232 const cols = Math.trunc(getNumber(pm, 'cols') ?? DEFAULT_TABLE_COLS)
233 const want = Math.max(rows, 1) * Math.max(cols, 1)
234 let cells = getArray(m, 'cells')
235 if (cells === null) { cells = new Y.Array(); m.set('cells', cells) }
236 const have = cells.length
237 if (have > want) cells.delete(want, have - want)
238 if (have < want) cells.insert(have, Array.from({ length: want - have }, () => new Y.XmlFragment()))
239 }
240 }
241 if (op.region === null) m.delete('region')
242 else if (op.region !== undefined) m.set('region', op.region)
243 if (op.box === null) m.delete('box')
244 else if (op.box !== undefined) m.set('box', boxJSON(op.box.x, op.box.y, op.box.w, op.box.h))
245 if (op.z !== undefined) m.set('z', op.z)
246}
247
248function setPhone (r, op) {
249 const [, s] = slideMap(r, op.slide)
250 const [, m] = blockMap(blocksOf(s), op.id)
251 const pm = mapOf(m, 'phone')
252 pm.clear()
253 const p = op.phone
254 if (p.hidden !== undefined) pm.set('hidden', p.hidden)
255 if (p.order !== undefined) pm.set('order', p.order)
256 if (p.size !== undefined) pm.set('size', p.size)
257 if (p.box !== undefined) pm.set('box', boxJSON(p.box.x, p.box.y, p.box.w, p.box.h))
258}
259
260export function applyOne (r, op) {
261 switch (op.op) {
262 case 'set_meta': return setMeta(r, op)
263 case 'add_slide': {
264 const at = insertIndex(r.slides, op.after)
265 const explicit = hasExplicitOrder(r.slides)
266 r.slides.insert(at, [slidePrelim(op.id, op.layout, op.template ?? DEFAULT_TEMPLATE)])
267 // On a deck nobody has reordered the array IS the reading order;
268 // once it carries order keys the array position means nothing
269 // and the slide is placed by renumbering (ydoc.mjs, "reading
270 // order").
271 if (explicit) placeNew(r.slides, 'slide', op.id, op.after)
272 return
273 }
274 case 'remove_slide':
275 return r.slides.delete(slideMap(r, op.id)[0], 1)
276 case 'move_slide':
277 slideMap(r, op.id)
278 return placeEntry(r.slides, 'slide', op.id, op.after)
279 case 'duplicate_slide': {
280 const [i, m] = slideMap(r, op.id)
281 const copy = cloneMap(m)
282 const explicit = hasExplicitOrder(r.slides)
283 r.slides.insert(i + 1, [copy])
284 rewriteIds(copy, op.new_id, op.new_block_ids)
285 // The copy carries the original's order too, so on a reordered
286 // deck it would tie with it; renumber so the duplicate is
287 // unambiguously the next slide.
288 if (explicit) placeEntry(r.slides, 'slide', op.new_id, op.id)
289 return
290 }
291 case 'set_slide': {
292 const [, m] = slideMap(r, op.id)
293 if (op.layout !== undefined) {
294 const current = getString(m, 'layout') ?? 'flow'
295 if (op.layout !== current) {
296 if (op.layout === 'canvas') toCanvas(m)
297 else toFlow(m)
298 m.set('layout', op.layout)
299 }
300 }
301 if (op.template !== undefined) m.set('template', op.template)
302 if (op.background !== undefined) m.set('background', backgroundMap(op.background.kind, op.background.value))
303 if (op.phone !== undefined) m.set('phone', op.phone)
304 if (op.transition !== undefined) m.set('transition', op.transition)
305 return
306 }
307 case 'set_notes': {
308 const [, m] = slideMap(r, op.id)
309 return writeText(fragmentOf(m, 'notes'), parse(op.markdown))
310 }
311 case 'add_block': return addBlock(r, op)
312 case 'remove_block': {
313 const [, s] = slideMap(r, op.slide)
314 const blocks = blocksOf(s)
315 blocks.delete(blockMap(blocks, op.id)[0], 1)
316 return removeFromColumnsChildren(blocks, op.id)
317 }
318 case 'move_block': {
319 const [, s] = slideMap(r, op.slide)
320 const blocks = blocksOf(s)
321 blockMap(blocks, op.id)
322 return placeEntry(blocks, 'block', op.id, op.after)
323 }
324 case 'set_block': return setBlock(r, op)
325 case 'set_text': {
326 const [, s] = slideMap(r, op.slide)
327 const [, m] = blockMap(blocksOf(s), op.id)
328 const kind = getString(m, 'type') ?? ''
329 return writeText(fragmentOf(m, 'text'), textFor(kind, op.markdown))
330 }
331 case 'set_phone': return setPhone(r, op)
332 case 'register_asset':
333 return r.assets.set(op.id, { ref: op.ref, name: op.name, width: op.width, height: op.height, alt: op.alt })
334 case 'remove_asset':
335 return r.assets.delete(op.id)
336 }
337}
338