// fixtures/inspector-ops.mjs — every inspector control as the one // drive operation it is (spec §3.1: "every inspector control is one // drive operation, and the editor calls the same function the API // handler calls"). Pure: a field name and a value in, an op out, over // the deck's JSON view (view.mjs), so inspector.test.mjs can hold each // control to its op with no DOM. // // The view-dependent block fields — box, size, hidden, order — go // through switcher.mjs's routeEdit, which is where the desktop/phone // rule lives; everything here is the rest. import { routeEdit } from './switcher.mjs' import { defaultRegion } from './layout.mjs' export const FONT_KEYS = ['heading', 'body'] export const DEFAULT_BOX = { x: 0.1, y: 0.1, w: 0.5, h: 0.3 } export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape'] // --- deck --- /** field: title | theme | accent | accent-reset | font-heading | font-body. */ export function deckOp (field, value, meta) { switch (field) { case 'title': return { op: 'set_meta', title: value } case 'theme': return { op: 'set_meta', theme: value } case 'accent': return { op: 'set_meta', accent: value } case 'accent-reset': return { op: 'set_meta', accent: null } case 'font-heading': case 'font-body': { const fonts = { heading: '', body: '', ...(meta && meta.fonts ? meta.fonts : {}) } fonts[field.slice(5)] = value if (!fonts.heading && !fonts.body) return { op: 'set_meta', fonts: null } return { op: 'set_meta', fonts: { heading: fonts.heading || '', body: fonts.body || '' } } } } throw new Error(`deckOp: unknown field ${field}`) } // --- slide --- const DEFAULT_COLOR = '#ffffff' /** field: layout | template | background | background-color | transition | phone. */ export function slideOp (slide, field, value) { const id = slide.id switch (field) { case 'layout': return { op: 'set_slide', id, layout: value } case 'template': return { op: 'set_slide', id, template: value } case 'transition': return { op: 'set_slide', id, transition: value } case 'phone': return { op: 'set_slide', id, phone: value } case 'background': { if (value === 'color') { const was = slide.background && slide.background.kind === 'color' ? slide.background.value : '' return { op: 'set_slide', id, background: { kind: 'color', value: was || DEFAULT_COLOR } } } return { op: 'set_slide', id, background: { kind: 'theme', value: '' } } } case 'background-color': return { op: 'set_slide', id, background: { kind: 'color', value } } } throw new Error(`slideOp: unknown field ${field}`) } /** move_slide one step: after the previous-but-one going up, after the next going down; null when already at the end. */ export function moveSlideOp (ids, id, dir) { const i = ids.indexOf(id) if (i < 0) return null if (dir === 'up') { if (i === 0) return null return i === 1 ? { op: 'move_slide', id } : { op: 'move_slide', id, after: ids[i - 2] } } if (i >= ids.length - 1) return null return { op: 'move_slide', id, after: ids[i + 1] } } // --- block --- /** A prop from a control: kind 'int' parses a number, 'bool' is a checkbox, '' clears the prop. */ export function propValue (raw, kind) { if (kind === 'bool') return raw === true || raw === 'true' || raw === 'on' if (raw === '' || raw === null || raw === undefined) return null if (kind === 'int') { const n = Number(raw) return Number.isFinite(n) ? Math.trunc(n) : null } return String(raw) } /** set_block on one prop. */ export function propOp (slideId, block, prop, value) { return { op: 'set_block', slide: slideId, id: block.id, props: { [prop]: value } } } /** set_block region on a flow slide. */ export function regionOp (slideId, block, region) { return { op: 'set_block', slide: slideId, id: block.id, region } } /** A shape's label: set_text over the block's one paragraph. */ export function labelOp (slideId, block, text) { return { op: 'set_text', slide: slideId, id: block.id, markdown: text } } /** One box field (x, y, w, h) typed as a percentage, routed by view. */ export function boxFieldOp ({ view, slideId, block, key, percent }) { const base = view === 'phone' && block.phone && block.phone.box ? block.phone.box : block.box if (!base) return null const n = Number(percent) if (!Number.isFinite(n)) return null const box = { ...base, [key]: n / 100 } return routeEdit({ view, slide: slideId, block, field: 'box', value: box }) } /** move_block one step within the slide's array order; null at the end. */ export function moveBlockOp (slideId, ids, id, dir) { const i = ids.indexOf(id) if (i < 0) return null if (dir === 'up') { if (i === 0) return null return i === 1 ? { op: 'move_block', slide: slideId, id } : { op: 'move_block', slide: slideId, id, after: ids[i - 2] } } if (i >= ids.length - 1) return null return { op: 'move_block', slide: slideId, id, after: ids[i + 1] } } /** * Bring forward / send back: the blocks sorted by z (array order for * ties), the block swapped with its neighbour, and every block whose * z then differs from its position written — so z is a permutation * again however the slide arrived. [] when nothing changes. */ export function zOps (slideId, blocks, id, dir) { const order = blocks.map((b, i) => ({ id: b.id, z: Number.isFinite(b.z) ? b.z : 0, i })) .sort((a, b) => (a.z - b.z) || (a.i - b.i)) const at = order.findIndex(b => b.id === id) if (at < 0) return [] const to = dir === 'forward' ? at + 1 : at - 1 if (to < 0 || to >= order.length) return [] const [moved] = order.splice(at, 1) order.splice(to, 0, moved) return order.map((b, z) => ({ ...b, z })).filter(b => b.z !== blocks[b.i].z) .map(b => ({ op: 'set_block', slide: slideId, id: b.id, z: b.z })) } /** * The op adding a block of kind to slide (the view's slide JSON) after * the selected block, or last: on a flow slide in the body region (a * heading in the heading region, an image in media), on a canvas * slide in the default box at the top z. A heading is level 2 unless * the slide has none; text-bearing types get an empty paragraph to * type into. */ export function addBlockOp (slide, kind, id, after) { const op = { op: 'add_block', slide: slide.id, id, type: kind, props: {} } if (after && slide.blocks.some(b => b.id === after)) op.after = after if (kind === 'heading') op.props.level = slide.blocks.some(b => b.type === 'heading') ? 2 : 1 if (kind === 'table') { op.props.rows = 2; op.props.cols = 2 } if (kind === 'columns') op.props.count = 2 if (TEXT_TYPES.includes(kind)) op.markdown = '' if (slide.layout === 'canvas') { op.box = { ...DEFAULT_BOX } op.z = slide.blocks.reduce((m, b) => Math.max(m, Number.isFinite(b.z) ? b.z + 1 : 0), 0) } else { op.region = defaultRegion(kind) } return op } /** A block's name in the Blocks list: its type's label, then its first words. */ export function blockLabel (block, typeLabel, max = 40) { const paras = Array.isArray(block.text) ? block.text : [] const words = paras.map(p => p.map(r => r.t || '').join('')).join(' ').replace(/\s+/g, ' ').trim() if (!words) return typeLabel const cut = words.length > max ? words.slice(0, max - 1).trimEnd() + '…' : words return `${typeLabel}: ${cut}` } // --- assets (§2.7) --- /** * The two ops one finished upload is: the registration itself, and the * selected image block pointing at it. * * `record` is what POST /d/{sqid}/assets answered — the id the server * minted, the ref it verified, and the name, size and alt it validated. * The client writes them rather than the server, so an upload arrives * in the document through the same path, the same update log and the * same undo step as every other change (uploads.mjs's header). * * One batch, so undo takes both back: an asset registered with no block * using it would be an entry in the Unused images list nobody asked * for. */ export function registerAssetOps (record, slideId, blockId) { const ops = [{ op: 'register_asset', id: record.id, ref: record.ref, name: record.name || '', width: Number.isInteger(record.width) ? record.width : 0, height: Number.isInteger(record.height) ? record.height : 0, alt: record.alt || '' }] if (slideId && blockId) { ops.push({ op: 'set_block', slide: slideId, id: blockId, props: { asset: record.id } }) } return ops } /** Dropping a registration. The bytes stay in the store, which is shared and content-addressed. */ export function removeAssetOp (id) { return { op: 'remove_asset', id } } /** * The assets no slide in the deck refers to any more, as [{id, ...asset}] * in the document's own order. * * Two things refer to one: an image block's props.asset, and a slide * background of kind "image". Deleting the last block that used an * asset deliberately leaves the registration behind — an undo has to be * able to put the block back — so this list is how a person clears them * out, and it is derived from the document every time rather than * tracked. */ export function unusedAssets (deckView) { const assets = (deckView && deckView.assets) || {} const used = new Set() for (const slide of (deckView && deckView.slides) || []) { const bg = slide.background if (bg && bg.kind === 'image' && bg.value) used.add(bg.value) for (const block of slide.blocks || []) { const id = block.props && block.props.asset if (typeof id === 'string' && id) used.add(id) } } return Object.keys(assets) .filter(id => !used.has(id)) .map(id => ({ id, ...assets[id] })) }