| 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. |
| 12 | import { getString, getArray, getObject, orderedMaps } from './ydoc.mjs' |
| 13 | |
| 14 | export { parseOp } from './ops-parse.mjs' |
| 15 | |
| 16 | export const LAYOUTS = ['flow', 'canvas'] |
| 17 | export const TEMPLATES = ['title', 'title-body', 'two-column', 'section', 'image-full', 'quote', 'blank'] |
| 18 | export const REGIONS = ['heading', 'body', 'aside', 'media'] |
| 19 | export const BACKGROUND_KINDS = ['theme', 'color', 'image'] |
| 20 | export const SLIDE_PHONES = ['inherit', 'reflow'] |
| 21 | export const TRANSITIONS = ['none', 'fade'] |
| 22 | export const BLOCK_TYPES = ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer'] |
| 23 | export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape'] |
| 24 | export const SIZES = ['s', 'm', 'l'] |
| 25 | export const MAX_TABLE_SIDE = 100 |
| 26 | |
| 27 | export const isTextType = kind => TEXT_TYPES.includes(kind) |
| 28 | |
| 29 | export class OpsError extends Error { |
| 30 | constructor (message, index) { |
| 31 | super(message) |
| 32 | this.name = 'OpsError' |
| 33 | this.index = index |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | const fail = msg => { throw new Error(msg) } |
| 38 | const isInt = v => Number.isInteger(v) |
| 39 | |
| 40 | function inVocab (what, value, vocab) { |
| 41 | if (!vocab.includes(value)) fail(`${what} ${JSON.stringify(value)} is not one of ${vocab.join(', ')}`) |
| 42 | } |
| 43 | function 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. */ |
| 48 | export 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 | |
| 55 | function 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 | */ |
| 69 | function 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. */ |
| 87 | function 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 | |
| 101 | export 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 | |