| 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). |
| 12 | import * as Y from 'yjs' |
| 13 | import { getString, getNumber, getMap, getArray, getFragment, findById, mapsOf, mapToJSON, cloneMap, orderedMaps, hasExplicitOrder, renumber } from './ydoc.mjs' |
| 14 | import { writeText, textFragment } from './schema.mjs' |
| 15 | import { parse, parseLines } from './markdown.mjs' |
| 16 | import { defaultRegion, regionRect, boxJSON, blocksOf, toCanvas, toFlow } from './layout.mjs' |
| 17 | import { isTextType, tableSide } from './ops-model.mjs' |
| 18 | |
| 19 | export const DEFAULT_TEMPLATE = 'blank' |
| 20 | export const DEFAULT_TABLE_ROWS = 2 |
| 21 | export const DEFAULT_TABLE_COLS = 2 |
| 22 | |
| 23 | const fail = msg => { throw new Error(msg) } |
| 24 | |
| 25 | function slideMap (r, id) { |
| 26 | return findById(r.slides, id) ?? fail(`no slide ${JSON.stringify(id)}`) |
| 27 | } |
| 28 | function 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. */ |
| 33 | function 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 | */ |
| 47 | function 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 | */ |
| 67 | function 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 | |
| 80 | function 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 | } |
| 85 | function 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. */ |
| 92 | const textFor = (kind, markdown) => (kind === 'code' ? parseLines(markdown) : parse(markdown)) |
| 93 | |
| 94 | function 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 | |
| 100 | function 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). */ |
| 105 | function 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 | |
| 123 | function 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. */ |
| 137 | function 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 | */ |
| 155 | function 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 | |
| 176 | function 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 | |
| 185 | function 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 | |
| 217 | function 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 | |
| 248 | function 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 | |
| 260 | export 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 | |