| 1 | // fixtures/inspector-ops.mjs — every inspector control as the one |
| 2 | // drive operation it is (spec §3.1: "every inspector control is one |
| 3 | // drive operation, and the editor calls the same function the API |
| 4 | // handler calls"). Pure: a field name and a value in, an op out, over |
| 5 | // the deck's JSON view (view.mjs), so inspector.test.mjs can hold each |
| 6 | // control to its op with no DOM. |
| 7 | // |
| 8 | // The view-dependent block fields — box, size, hidden, order — go |
| 9 | // through switcher.mjs's routeEdit, which is where the desktop/phone |
| 10 | // rule lives; everything here is the rest. |
| 11 | import { routeEdit } from './switcher.mjs' |
| 12 | import { defaultRegion } from './layout.mjs' |
| 13 | |
| 14 | export const FONT_KEYS = ['heading', 'body'] |
| 15 | export const DEFAULT_BOX = { x: 0.1, y: 0.1, w: 0.5, h: 0.3 } |
| 16 | export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape'] |
| 17 | |
| 18 | // --- deck --- |
| 19 | |
| 20 | /** field: title | theme | accent | accent-reset | font-heading | font-body. */ |
| 21 | export function deckOp (field, value, meta) { |
| 22 | switch (field) { |
| 23 | case 'title': return { op: 'set_meta', title: value } |
| 24 | case 'theme': return { op: 'set_meta', theme: value } |
| 25 | case 'accent': return { op: 'set_meta', accent: value } |
| 26 | case 'accent-reset': return { op: 'set_meta', accent: null } |
| 27 | case 'font-heading': |
| 28 | case 'font-body': { |
| 29 | const fonts = { heading: '', body: '', ...(meta && meta.fonts ? meta.fonts : {}) } |
| 30 | fonts[field.slice(5)] = value |
| 31 | if (!fonts.heading && !fonts.body) return { op: 'set_meta', fonts: null } |
| 32 | return { op: 'set_meta', fonts: { heading: fonts.heading || '', body: fonts.body || '' } } |
| 33 | } |
| 34 | } |
| 35 | throw new Error(`deckOp: unknown field ${field}`) |
| 36 | } |
| 37 | |
| 38 | // --- slide --- |
| 39 | |
| 40 | const DEFAULT_COLOR = '#ffffff' |
| 41 | |
| 42 | /** field: layout | template | background | background-color | transition | phone. */ |
| 43 | export function slideOp (slide, field, value) { |
| 44 | const id = slide.id |
| 45 | switch (field) { |
| 46 | case 'layout': return { op: 'set_slide', id, layout: value } |
| 47 | case 'template': return { op: 'set_slide', id, template: value } |
| 48 | case 'transition': return { op: 'set_slide', id, transition: value } |
| 49 | case 'phone': return { op: 'set_slide', id, phone: value } |
| 50 | case 'background': { |
| 51 | if (value === 'color') { |
| 52 | const was = slide.background && slide.background.kind === 'color' ? slide.background.value : '' |
| 53 | return { op: 'set_slide', id, background: { kind: 'color', value: was || DEFAULT_COLOR } } |
| 54 | } |
| 55 | return { op: 'set_slide', id, background: { kind: 'theme', value: '' } } |
| 56 | } |
| 57 | case 'background-color': return { op: 'set_slide', id, background: { kind: 'color', value } } |
| 58 | } |
| 59 | throw new Error(`slideOp: unknown field ${field}`) |
| 60 | } |
| 61 | |
| 62 | /** move_slide one step: after the previous-but-one going up, after the next going down; null when already at the end. */ |
| 63 | export function moveSlideOp (ids, id, dir) { |
| 64 | const i = ids.indexOf(id) |
| 65 | if (i < 0) return null |
| 66 | if (dir === 'up') { |
| 67 | if (i === 0) return null |
| 68 | return i === 1 ? { op: 'move_slide', id } : { op: 'move_slide', id, after: ids[i - 2] } |
| 69 | } |
| 70 | if (i >= ids.length - 1) return null |
| 71 | return { op: 'move_slide', id, after: ids[i + 1] } |
| 72 | } |
| 73 | |
| 74 | // --- block --- |
| 75 | |
| 76 | /** A prop from a control: kind 'int' parses a number, 'bool' is a checkbox, '' clears the prop. */ |
| 77 | export function propValue (raw, kind) { |
| 78 | if (kind === 'bool') return raw === true || raw === 'true' || raw === 'on' |
| 79 | if (raw === '' || raw === null || raw === undefined) return null |
| 80 | if (kind === 'int') { |
| 81 | const n = Number(raw) |
| 82 | return Number.isFinite(n) ? Math.trunc(n) : null |
| 83 | } |
| 84 | return String(raw) |
| 85 | } |
| 86 | |
| 87 | /** set_block on one prop. */ |
| 88 | export function propOp (slideId, block, prop, value) { |
| 89 | return { op: 'set_block', slide: slideId, id: block.id, props: { [prop]: value } } |
| 90 | } |
| 91 | |
| 92 | /** set_block region on a flow slide. */ |
| 93 | export function regionOp (slideId, block, region) { |
| 94 | return { op: 'set_block', slide: slideId, id: block.id, region } |
| 95 | } |
| 96 | |
| 97 | /** A shape's label: set_text over the block's one paragraph. */ |
| 98 | export function labelOp (slideId, block, text) { |
| 99 | return { op: 'set_text', slide: slideId, id: block.id, markdown: text } |
| 100 | } |
| 101 | |
| 102 | /** One box field (x, y, w, h) typed as a percentage, routed by view. */ |
| 103 | export function boxFieldOp ({ view, slideId, block, key, percent }) { |
| 104 | const base = view === 'phone' && block.phone && block.phone.box ? block.phone.box : block.box |
| 105 | if (!base) return null |
| 106 | const n = Number(percent) |
| 107 | if (!Number.isFinite(n)) return null |
| 108 | const box = { ...base, [key]: n / 100 } |
| 109 | return routeEdit({ view, slide: slideId, block, field: 'box', value: box }) |
| 110 | } |
| 111 | |
| 112 | /** move_block one step within the slide's array order; null at the end. */ |
| 113 | export function moveBlockOp (slideId, ids, id, dir) { |
| 114 | const i = ids.indexOf(id) |
| 115 | if (i < 0) return null |
| 116 | if (dir === 'up') { |
| 117 | if (i === 0) return null |
| 118 | return i === 1 ? { op: 'move_block', slide: slideId, id } : { op: 'move_block', slide: slideId, id, after: ids[i - 2] } |
| 119 | } |
| 120 | if (i >= ids.length - 1) return null |
| 121 | return { op: 'move_block', slide: slideId, id, after: ids[i + 1] } |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Bring forward / send back: the blocks sorted by z (array order for |
| 126 | * ties), the block swapped with its neighbour, and every block whose |
| 127 | * z then differs from its position written — so z is a permutation |
| 128 | * again however the slide arrived. [] when nothing changes. |
| 129 | */ |
| 130 | export function zOps (slideId, blocks, id, dir) { |
| 131 | const order = blocks.map((b, i) => ({ id: b.id, z: Number.isFinite(b.z) ? b.z : 0, i })) |
| 132 | .sort((a, b) => (a.z - b.z) || (a.i - b.i)) |
| 133 | const at = order.findIndex(b => b.id === id) |
| 134 | if (at < 0) return [] |
| 135 | const to = dir === 'forward' ? at + 1 : at - 1 |
| 136 | if (to < 0 || to >= order.length) return [] |
| 137 | const [moved] = order.splice(at, 1) |
| 138 | order.splice(to, 0, moved) |
| 139 | return order.map((b, z) => ({ ...b, z })).filter(b => b.z !== blocks[b.i].z) |
| 140 | .map(b => ({ op: 'set_block', slide: slideId, id: b.id, z: b.z })) |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * The op adding a block of kind to slide (the view's slide JSON) after |
| 145 | * the selected block, or last: on a flow slide in the body region (a |
| 146 | * heading in the heading region, an image in media), on a canvas |
| 147 | * slide in the default box at the top z. A heading is level 2 unless |
| 148 | * the slide has none; text-bearing types get an empty paragraph to |
| 149 | * type into. |
| 150 | */ |
| 151 | export function addBlockOp (slide, kind, id, after) { |
| 152 | const op = { op: 'add_block', slide: slide.id, id, type: kind, props: {} } |
| 153 | if (after && slide.blocks.some(b => b.id === after)) op.after = after |
| 154 | if (kind === 'heading') op.props.level = slide.blocks.some(b => b.type === 'heading') ? 2 : 1 |
| 155 | if (kind === 'table') { op.props.rows = 2; op.props.cols = 2 } |
| 156 | if (kind === 'columns') op.props.count = 2 |
| 157 | if (TEXT_TYPES.includes(kind)) op.markdown = '' |
| 158 | if (slide.layout === 'canvas') { |
| 159 | op.box = { ...DEFAULT_BOX } |
| 160 | op.z = slide.blocks.reduce((m, b) => Math.max(m, Number.isFinite(b.z) ? b.z + 1 : 0), 0) |
| 161 | } else { |
| 162 | op.region = defaultRegion(kind) |
| 163 | } |
| 164 | return op |
| 165 | } |
| 166 | |
| 167 | /** A block's name in the Blocks list: its type's label, then its first words. */ |
| 168 | export function blockLabel (block, typeLabel, max = 40) { |
| 169 | const paras = Array.isArray(block.text) ? block.text : [] |
| 170 | const words = paras.map(p => p.map(r => r.t || '').join('')).join(' ').replace(/\s+/g, ' ').trim() |
| 171 | if (!words) return typeLabel |
| 172 | const cut = words.length > max ? words.slice(0, max - 1).trimEnd() + '…' : words |
| 173 | return `${typeLabel}: ${cut}` |
| 174 | } |
| 175 | |
| 176 | // --- assets (§2.7) --- |
| 177 | |
| 178 | /** |
| 179 | * The two ops one finished upload is: the registration itself, and the |
| 180 | * selected image block pointing at it. |
| 181 | * |
| 182 | * `record` is what POST /d/{sqid}/assets answered — the id the server |
| 183 | * minted, the ref it verified, and the name, size and alt it validated. |
| 184 | * The client writes them rather than the server, so an upload arrives |
| 185 | * in the document through the same path, the same update log and the |
| 186 | * same undo step as every other change (uploads.mjs's header). |
| 187 | * |
| 188 | * One batch, so undo takes both back: an asset registered with no block |
| 189 | * using it would be an entry in the Unused images list nobody asked |
| 190 | * for. |
| 191 | */ |
| 192 | export function registerAssetOps (record, slideId, blockId) { |
| 193 | const ops = [{ |
| 194 | op: 'register_asset', |
| 195 | id: record.id, |
| 196 | ref: record.ref, |
| 197 | name: record.name || '', |
| 198 | width: Number.isInteger(record.width) ? record.width : 0, |
| 199 | height: Number.isInteger(record.height) ? record.height : 0, |
| 200 | alt: record.alt || '' |
| 201 | }] |
| 202 | if (slideId && blockId) { |
| 203 | ops.push({ op: 'set_block', slide: slideId, id: blockId, props: { asset: record.id } }) |
| 204 | } |
| 205 | return ops |
| 206 | } |
| 207 | |
| 208 | /** Dropping a registration. The bytes stay in the store, which is shared and content-addressed. */ |
| 209 | export function removeAssetOp (id) { |
| 210 | return { op: 'remove_asset', id } |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * The assets no slide in the deck refers to any more, as [{id, ...asset}] |
| 215 | * in the document's own order. |
| 216 | * |
| 217 | * Two things refer to one: an image block's props.asset, and a slide |
| 218 | * background of kind "image". Deleting the last block that used an |
| 219 | * asset deliberately leaves the registration behind — an undo has to be |
| 220 | * able to put the block back — so this list is how a person clears them |
| 221 | * out, and it is derived from the document every time rather than |
| 222 | * tracked. |
| 223 | */ |
| 224 | export function unusedAssets (deckView) { |
| 225 | const assets = (deckView && deckView.assets) || {} |
| 226 | const used = new Set() |
| 227 | for (const slide of (deckView && deckView.slides) || []) { |
| 228 | const bg = slide.background |
| 229 | if (bg && bg.kind === 'image' && bg.value) used.add(bg.value) |
| 230 | for (const block of slide.blocks || []) { |
| 231 | const id = block.props && block.props.asset |
| 232 | if (typeof id === 'string' && id) used.add(id) |
| 233 | } |
| 234 | } |
| 235 | return Object.keys(assets) |
| 236 | .filter(id => !used.has(id)) |
| 237 | .map(id => ({ id, ...assets[id] })) |
| 238 | } |
| 239 | |