// The second pass of applyOps — core/src/ops.rs's apply_one, and the // constructors it uses from schema.rs. Runs inside one transaction after // the model has passed every op, so by construction every lookup here // succeeds; the throws that remain surface a bug as a message rather // than a half-applied batch going unnoticed. // // What a slide and a block look like in the document is decided here // and in the core together (schema.rs's module comment): a block map // holds id, type, props (Y.Map of plain values), phone (Y.Map), then // region (flow) or box + z (canvas); text-bearing types carry text (an // XmlFragment), a table carries cells (a Y.Array of XmlFragments). import * as Y from 'yjs' import { getString, getNumber, getMap, getArray, getFragment, findById, mapsOf, mapToJSON, cloneMap, orderedMaps, hasExplicitOrder, renumber } from './ydoc.mjs' import { writeText, textFragment } from './schema.mjs' import { parse, parseLines } from './markdown.mjs' import { defaultRegion, regionRect, boxJSON, blocksOf, toCanvas, toFlow } from './layout.mjs' import { isTextType, tableSide } from './ops-model.mjs' export const DEFAULT_TEMPLATE = 'blank' export const DEFAULT_TABLE_ROWS = 2 export const DEFAULT_TABLE_COLS = 2 const fail = msg => { throw new Error(msg) } function slideMap (r, id) { return findById(r.slides, id) ?? fail(`no slide ${JSON.stringify(id)}`) } function blockMap (blocks, id) { return findById(blocks, id) ?? fail(`no block ${JSON.stringify(id)}`) } /** Where an after puts a new entry: absent appends, null goes first, an id goes right after it. */ function insertIndex (array, after) { if (after === undefined) return array.length if (after === null) return 0 return (findById(array, after) ?? fail(`no entry ${JSON.stringify(after)}`))[0] + 1 } /** * Puts the entry id of an ordered array — a slide of the deck, a block * of a slide — at the reading position after asks for: undefined means * first, an id means right after it. It does that by renumbering order * on every entry (ydoc.mjs, "reading order"). The array is not * touched, so a peer's edit in flight against any entry still lands * (codex 4). */ function placeEntry (array, what, id, after) { const ordered = orderedMaps(array).map(([, m]) => [getString(m, 'id') ?? '', m]) const i = ordered.findIndex(([bid]) => bid === id) if (i === -1) fail(`no ${what} ${JSON.stringify(id)}`) const [moved] = ordered.splice(i, 1) let at = 0 if (after !== undefined) { const j = ordered.findIndex(([bid]) => bid === after) if (j === -1) fail(`no entry ${JSON.stringify(after)}`) at = j + 1 } ordered.splice(at, 0, moved) renumber(ordered.map(([, m]) => m)) } /** * placeEntry for an entry just inserted, whose after is the * three-valued kind (undefined appends, null first, an id after it). * Only called when the array already carries explicit order keys. */ function placeNew (array, what, id, after) { let afterId if (after === undefined) { const others = orderedMaps(array).map(([, m]) => getString(m, 'id') ?? '').filter(other => other !== id) afterId = others.length ? others[others.length - 1] : undefined } else if (after === null) { afterId = undefined } else { afterId = after } placeEntry(array, what, id, afterId) } function fragmentOf (map, key) { let f = getFragment(map, key) if (f === null) { f = new Y.XmlFragment(); map.set(key, f) } return f } function mapOf (map, key) { let m = getMap(map, key) if (m === null) { m = new Y.Map(); map.set(key, m) } return m } /** Text for a block of kind from its Markdown: code is plain lines, everything else the subset. */ const textFor = (kind, markdown) => (kind === 'code' ? parseLines(markdown) : parse(markdown)) function plainMap (entries) { const m = new Y.Map() for (const k of Object.keys(entries)) m.set(k, entries[k]) return m } function backgroundMap (kind, value) { return plainMap({ kind, value }) } /** A block map with a fresh text fragment / cells array where the type wants one (schema.rs block_prelim). */ function blockPrelim ({ id, kind, props, region, box, z, text, cells }) { const m = new Y.Map() m.set('id', id) m.set('type', kind) m.set('props', plainMap(props)) m.set('phone', new Y.Map()) if (region !== undefined) m.set('region', region) if (box !== undefined) m.set('box', box) if (z !== undefined) m.set('z', z) if (text !== undefined) m.set('text', textFragment(text)) if (cells !== undefined) { const a = new Y.Array() m.set('cells', a) a.insert(0, Array.from({ length: cells }, () => new Y.XmlFragment())) } return m } function slidePrelim (id, layout, template) { const m = new Y.Map() m.set('id', id) m.set('layout', layout) m.set('template', template) m.set('phone', 'inherit') m.set('transition', 'none') m.set('background', backgroundMap('theme', '')) m.set('notes', new Y.XmlFragment()) m.set('blocks', new Y.Array()) return m } /** Drops removedId from every columns block's props.children in blocks. */ function removeFromColumnsChildren (blocks, removedId) { for (const [, m] of mapsOf(blocks)) { if (getString(m, 'type') !== 'columns') continue const pm = getMap(m, 'props') if (pm === null) continue const children = mapToJSON(pm).children if (!Array.isArray(children) || !children.includes(removedId)) continue pm.set('children', children.filter(c => c !== removedId)) } } /** * Gives a copied slide its new ids: the slide's own, each block's, and * any columns.children naming the old ones. The k-th new id goes to the * k-th block in READING order — the order the model paired them in — * whatever the array position; the copied order keys come along, so * the copy reads as the original did. */ function rewriteIds (copy, newId, newBlockIds) { copy.set('id', newId) const blocks = getArray(copy, 'blocks') if (blocks === null) return const mapping = new Map() const maps = orderedMaps(blocks) maps.forEach(([, bm], k) => { if (k >= newBlockIds.length) return const old = getString(bm, 'id') if (old !== null) mapping.set(old, newBlockIds[k]) bm.set('id', newBlockIds[k]) }) for (const [, bm] of maps) { const props = getMap(bm, 'props') if (props === null) continue const children = props.get('children') if (!Array.isArray(children)) continue props.set('children', children.map(c => (typeof c === 'string' && mapping.has(c) ? mapping.get(c) : c))) } } function setMeta (r, op) { if (op.title !== undefined) r.meta.set('title', op.title) if (op.theme !== undefined) r.meta.set('theme', op.theme) if (op.accent === null) r.meta.delete('accent') else if (op.accent !== undefined) r.meta.set('accent', op.accent) if (op.fonts === null) r.meta.delete('fonts') else if (op.fonts !== undefined) r.meta.set('fonts', { heading: op.fonts.heading, body: op.fonts.body }) } function addBlock (r, op) { const [, s] = slideMap(r, op.slide) const blocks = blocksOf(s) const at = insertIndex(blocks, op.after) const canvas = getString(s, 'layout') === 'canvas' const props = { ...op.props } let cells if (op.type === 'table') { const rows = tableSide(props, 'rows') ?? DEFAULT_TABLE_ROWS const cols = tableSide(props, 'cols') ?? DEFAULT_TABLE_COLS props.rows = rows props.cols = cols cells = rows * cols } const text = isTextType(op.type) ? (op.markdown === undefined ? [] : textFor(op.type, op.markdown)) : undefined const region = op.region ?? defaultRegion(op.type) const spec = { id: op.id, kind: op.type, props, text, cells } if (canvas) { spec.box = op.box !== undefined ? boxJSON(op.box.x, op.box.y, op.box.w, op.box.h) : boxJSON(...regionRect(region, false)) spec.z = op.z ?? at } else { spec.region = region } const explicit = hasExplicitOrder(blocks) blocks.insert(at, [blockPrelim(spec)]) // On a slide nobody has reordered the array IS the reading order and // the insert put the block where after asked. Once the slide carries // order keys the array position means nothing, so the new block is // placed by renumbering instead (ydoc.mjs, "reading order"). if (explicit) placeNew(blocks, 'block', op.id, op.after) } function setBlock (r, op) { const [, s] = slideMap(r, op.slide) const blocks = blocksOf(s) const [, m] = blockMap(blocks, op.id) if (op.props !== undefined) { const pm = mapOf(m, 'props') for (const k of Object.keys(op.props)) { if (op.props[k] === null) pm.delete(k) else pm.set(k, op.props[k]) } const isTable = getString(m, 'type') === 'table' // cells is row-major and flat, so a size change only appends to or // truncates the end of it (spec §2.4's v1 limitation). if (isTable && ('rows' in op.props || 'cols' in op.props)) { const rows = Math.trunc(getNumber(pm, 'rows') ?? DEFAULT_TABLE_ROWS) const cols = Math.trunc(getNumber(pm, 'cols') ?? DEFAULT_TABLE_COLS) const want = Math.max(rows, 1) * Math.max(cols, 1) let cells = getArray(m, 'cells') if (cells === null) { cells = new Y.Array(); m.set('cells', cells) } const have = cells.length if (have > want) cells.delete(want, have - want) if (have < want) cells.insert(have, Array.from({ length: want - have }, () => new Y.XmlFragment())) } } if (op.region === null) m.delete('region') else if (op.region !== undefined) m.set('region', op.region) if (op.box === null) m.delete('box') else if (op.box !== undefined) m.set('box', boxJSON(op.box.x, op.box.y, op.box.w, op.box.h)) if (op.z !== undefined) m.set('z', op.z) } function setPhone (r, op) { const [, s] = slideMap(r, op.slide) const [, m] = blockMap(blocksOf(s), op.id) const pm = mapOf(m, 'phone') pm.clear() const p = op.phone if (p.hidden !== undefined) pm.set('hidden', p.hidden) if (p.order !== undefined) pm.set('order', p.order) if (p.size !== undefined) pm.set('size', p.size) if (p.box !== undefined) pm.set('box', boxJSON(p.box.x, p.box.y, p.box.w, p.box.h)) } export function applyOne (r, op) { switch (op.op) { case 'set_meta': return setMeta(r, op) case 'add_slide': { const at = insertIndex(r.slides, op.after) const explicit = hasExplicitOrder(r.slides) r.slides.insert(at, [slidePrelim(op.id, op.layout, op.template ?? DEFAULT_TEMPLATE)]) // On a deck nobody has reordered the array IS the reading order; // once it carries order keys the array position means nothing // and the slide is placed by renumbering (ydoc.mjs, "reading // order"). if (explicit) placeNew(r.slides, 'slide', op.id, op.after) return } case 'remove_slide': return r.slides.delete(slideMap(r, op.id)[0], 1) case 'move_slide': slideMap(r, op.id) return placeEntry(r.slides, 'slide', op.id, op.after) case 'duplicate_slide': { const [i, m] = slideMap(r, op.id) const copy = cloneMap(m) const explicit = hasExplicitOrder(r.slides) r.slides.insert(i + 1, [copy]) rewriteIds(copy, op.new_id, op.new_block_ids) // The copy carries the original's order too, so on a reordered // deck it would tie with it; renumber so the duplicate is // unambiguously the next slide. if (explicit) placeEntry(r.slides, 'slide', op.new_id, op.id) return } case 'set_slide': { const [, m] = slideMap(r, op.id) if (op.layout !== undefined) { const current = getString(m, 'layout') ?? 'flow' if (op.layout !== current) { if (op.layout === 'canvas') toCanvas(m) else toFlow(m) m.set('layout', op.layout) } } if (op.template !== undefined) m.set('template', op.template) if (op.background !== undefined) m.set('background', backgroundMap(op.background.kind, op.background.value)) if (op.phone !== undefined) m.set('phone', op.phone) if (op.transition !== undefined) m.set('transition', op.transition) return } case 'set_notes': { const [, m] = slideMap(r, op.id) return writeText(fragmentOf(m, 'notes'), parse(op.markdown)) } case 'add_block': return addBlock(r, op) case 'remove_block': { const [, s] = slideMap(r, op.slide) const blocks = blocksOf(s) blocks.delete(blockMap(blocks, op.id)[0], 1) return removeFromColumnsChildren(blocks, op.id) } case 'move_block': { const [, s] = slideMap(r, op.slide) const blocks = blocksOf(s) blockMap(blocks, op.id) return placeEntry(blocks, 'block', op.id, op.after) } case 'set_block': return setBlock(r, op) case 'set_text': { const [, s] = slideMap(r, op.slide) const [, m] = blockMap(blocksOf(s), op.id) const kind = getString(m, 'type') ?? '' return writeText(fragmentOf(m, 'text'), textFor(kind, op.markdown)) } case 'set_phone': return setPhone(r, op) case 'register_asset': return r.assets.set(op.id, { ref: op.ref, name: op.name, width: op.width, height: op.height, alt: op.alt }) case 'remove_asset': return r.assets.delete(op.id) } }