| 1 | // Layout conversion between a slide's two modes (spec §2.3) — |
| 2 | // core/src/layout.rs, table for table. A flow slide places blocks by |
| 3 | // region; a canvas slide by an absolute box and z. Switching carries the |
| 4 | // blocks across, lossily in both directions. |
| 5 | import * as Y from 'yjs' |
| 6 | import { getString, getObject, getArray, orderedMaps, renumber } from './ydoc.mjs' |
| 7 | |
| 8 | export const REGIONS = ['heading', 'body', 'aside', 'media'] |
| 9 | |
| 10 | /** Rounds a box coordinate to the four decimals §2.3 specifies (Rust's round(): half away from zero). */ |
| 11 | export function round4 (f) { |
| 12 | const scaled = f * 10000 |
| 13 | return (scaled < 0 ? -Math.round(-scaled) : Math.round(scaled)) / 10000 |
| 14 | } |
| 15 | |
| 16 | export function boxJSON (x, y, w, h) { |
| 17 | return { x: round4(x), y: round4(y), w: round4(w), h: round4(h) } |
| 18 | } |
| 19 | |
| 20 | /** The region a block lands in when nobody said. */ |
| 21 | export function defaultRegion (kind) { |
| 22 | if (kind === 'heading') return 'heading' |
| 23 | if (kind === 'image') return 'media' |
| 24 | return 'body' |
| 25 | } |
| 26 | |
| 27 | /** The frame fraction each region occupies: [x, y, w, h]. body narrows beside an aside. */ |
| 28 | export function regionRect (region, hasAside) { |
| 29 | switch (region) { |
| 30 | case 'heading': return [0.06, 0.08, 0.88, 0.18] |
| 31 | case 'aside': return [0.54, 0.30, 0.40, 0.62] |
| 32 | case 'media': return [0.50, 0.08, 0.44, 0.84] |
| 33 | default: return hasAside ? [0.06, 0.30, 0.44, 0.62] : [0.06, 0.30, 0.88, 0.62] |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | function regionOf (block) { |
| 38 | const r = getString(block, 'region') |
| 39 | if (r !== null && REGIONS.includes(r)) return r |
| 40 | return defaultRegion(getString(block, 'type') ?? '') |
| 41 | } |
| 42 | |
| 43 | /** The slide's block array, created if a peer wrote a slide without one. */ |
| 44 | export function blocksOf (slide) { |
| 45 | let a = getArray(slide, 'blocks') |
| 46 | if (a === null) { |
| 47 | a = new Y.Array() |
| 48 | slide.set('blocks', a) |
| 49 | } |
| 50 | return a |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Flow to canvas: every block gets the box its region gave it, blocks |
| 55 | * sharing a region split its height equally in reading order, z is the |
| 56 | * reading-order index, and region goes. |
| 57 | */ |
| 58 | export function toCanvas (slide) { |
| 59 | const maps = orderedMaps(blocksOf(slide)) |
| 60 | const regions = maps.map(([, m]) => regionOf(m)) |
| 61 | const hasAside = regions.includes('aside') |
| 62 | const counts = new Map() |
| 63 | for (const r of regions) counts.set(r, (counts.get(r) ?? 0) + 1) |
| 64 | const seen = new Map() |
| 65 | maps.forEach(([, m], at) => { |
| 66 | const region = regions[at] |
| 67 | const n = counts.get(region) |
| 68 | const k = seen.get(region) ?? 0 |
| 69 | const [x, y, w, h] = regionRect(region, hasAside) |
| 70 | const slot = h / n |
| 71 | seen.set(region, k + 1) |
| 72 | m.set('box', boxJSON(x, y + slot * k, w, slot)) |
| 73 | m.set('z', at) |
| 74 | m.delete('region') |
| 75 | }) |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Canvas to flow: boxes go, regions come from position (a heading in |
| 80 | * the top quarter is heading, an image is media, the rest body), and |
| 81 | * the reading order becomes top to bottom, left to right. |
| 82 | * |
| 83 | * The reorder is written as order on each block map (ydoc.mjs, "block |
| 84 | * order") and never by rebuilding the array. Rebuilding it — clone, |
| 85 | * clear, insert the copies — is what this did once, and a peer typing |
| 86 | * into a block at that moment lost the sentence: their edit addressed |
| 87 | * the deleted original and the merge kept the copy (codex 4). |
| 88 | */ |
| 89 | export function toFlow (slide) { |
| 90 | const blocks = blocksOf(slide) |
| 91 | const maps = orderedMaps(blocks) |
| 92 | const order = [] |
| 93 | maps.forEach(([, m], pos) => { |
| 94 | const kind = getString(m, 'type') ?? '' |
| 95 | const bx = getObject(m, 'box') |
| 96 | const coord = (k, dflt) => (bx !== null && typeof bx[k] === 'number' ? bx[k] : dflt) |
| 97 | const x = coord('x', 0.06) |
| 98 | const y = coord('y', 0.30) |
| 99 | let region = 'body' |
| 100 | if (kind === 'heading' && y < 0.25) region = 'heading' |
| 101 | else if (kind === 'image') region = 'media' |
| 102 | m.set('region', region) |
| 103 | m.delete('box') |
| 104 | m.delete('z') |
| 105 | order.push([y, x, pos]) |
| 106 | }) |
| 107 | order.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1])) |
| 108 | if (order.every(([, , pos], i) => i === pos)) return |
| 109 | renumber(order.map(([, , pos]) => maps[pos][1])) |
| 110 | } |
| 111 | |
| 112 | |