// Layout conversion between a slide's two modes (spec §2.3) — // core/src/layout.rs, table for table. A flow slide places blocks by // region; a canvas slide by an absolute box and z. Switching carries the // blocks across, lossily in both directions. import * as Y from 'yjs' import { getString, getObject, getArray, orderedMaps, renumber } from './ydoc.mjs' export const REGIONS = ['heading', 'body', 'aside', 'media'] /** Rounds a box coordinate to the four decimals §2.3 specifies (Rust's round(): half away from zero). */ export function round4 (f) { const scaled = f * 10000 return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 10000 } export function boxJSON (x, y, w, h) { return { x: round4(x), y: round4(y), w: round4(w), h: round4(h) } } /** The region a block lands in when nobody said. */ export function defaultRegion (kind) { if (kind === 'heading') return 'heading' if (kind === 'image') return 'media' return 'body' } /** The frame fraction each region occupies: [x, y, w, h]. body narrows beside an aside. */ export function regionRect (region, hasAside) { switch (region) { case 'heading': return [0.06, 0.08, 0.88, 0.18] case 'aside': return [0.54, 0.30, 0.40, 0.62] case 'media': return [0.50, 0.08, 0.44, 0.84] default: return hasAside ? [0.06, 0.30, 0.44, 0.62] : [0.06, 0.30, 0.88, 0.62] } } function regionOf (block) { const r = getString(block, 'region') if (r !== null && REGIONS.includes(r)) return r return defaultRegion(getString(block, 'type') ?? '') } /** The slide's block array, created if a peer wrote a slide without one. */ export function blocksOf (slide) { let a = getArray(slide, 'blocks') if (a === null) { a = new Y.Array() slide.set('blocks', a) } return a } /** * Flow to canvas: every block gets the box its region gave it, blocks * sharing a region split its height equally in reading order, z is the * reading-order index, and region goes. */ export function toCanvas (slide) { const maps = orderedMaps(blocksOf(slide)) const regions = maps.map(([, m]) => regionOf(m)) const hasAside = regions.includes('aside') const counts = new Map() for (const r of regions) counts.set(r, (counts.get(r) ?? 0) + 1) const seen = new Map() maps.forEach(([, m], at) => { const region = regions[at] const n = counts.get(region) const k = seen.get(region) ?? 0 const [x, y, w, h] = regionRect(region, hasAside) const slot = h / n seen.set(region, k + 1) m.set('box', boxJSON(x, y + slot * k, w, slot)) m.set('z', at) m.delete('region') }) } /** * Canvas to flow: boxes go, regions come from position (a heading in * the top quarter is heading, an image is media, the rest body), and * the reading order becomes top to bottom, left to right. * * The reorder is written as order on each block map (ydoc.mjs, "block * order") and never by rebuilding the array. Rebuilding it — clone, * clear, insert the copies — is what this did once, and a peer typing * into a block at that moment lost the sentence: their edit addressed * the deleted original and the merge kept the copy (codex 4). */ export function toFlow (slide) { const blocks = blocksOf(slide) const maps = orderedMaps(blocks) const order = [] maps.forEach(([, m], pos) => { const kind = getString(m, 'type') ?? '' const bx = getObject(m, 'box') const coord = (k, dflt) => (bx !== null && typeof bx[k] === 'number' ? bx[k] : dflt) const x = coord('x', 0.06) const y = coord('y', 0.30) let region = 'body' if (kind === 'heading' && y < 0.25) region = 'heading' else if (kind === 'image') region = 'media' m.set('region', region) m.delete('box') m.delete('z') order.push([y, x, pos]) }) order.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1])) if (order.every(([, , pos], i) => i === pos)) return renumber(order.map(([, , pos]) => maps[pos][1])) }