| 1 | // fixtures/canvas.mjs — editing a canvas slide on the stage (spec |
| 2 | // §3.1): the selected block drags by its body and resizes by eight |
| 3 | // handles, snapping to the frame's thirds and to the other blocks' |
| 4 | // edges, and the box is written once, on pointer up, as four-decimal |
| 5 | // fractions of the frame — so undo is per gesture. |
| 6 | // |
| 7 | // The maths is pure over boxes {x, y, w, h} in frame fractions |
| 8 | // (canvas.test.mjs): clamping to the frame, the guides a slide |
| 9 | // offers, snapping a moving edge to the nearest guide within one per |
| 10 | // cent, moving, resizing by handle, nudging by key. The overlay is the |
| 11 | // DOM part: one element placed over the selected block, with a drag |
| 12 | // surface and the handles, positioned from the block's own rectangle |
| 13 | // so it is right in the desktop rendering and the phone one alike. |
| 14 | // |
| 15 | // The keyboard's equivalent is the overlay itself: it is focusable and |
| 16 | // named, arrow keys nudge by half a per cent, Shift by two, Alt |
| 17 | // resizes; the inspector's Left/Top/Width/Height fields are the same |
| 18 | // numbers typed. The handles are pointer targets only (aria-hidden), |
| 19 | // 24px square so a finger finds them (touch is pointer events too). |
| 20 | // |
| 21 | // A text block is dragged by its edges — a ring inside its border — |
| 22 | // because its interior is the caret's; anything else drags by its |
| 23 | // whole body. During a drag only the block's inline style moves; the |
| 24 | // op is written on pointer up and the stage re-fetches the section. |
| 25 | import { round4 } from './layout.mjs' |
| 26 | |
| 27 | export const MIN_SIZE = 0.02 |
| 28 | export const SNAP = 0.01 |
| 29 | export const NUDGE = 0.005 |
| 30 | export const NUDGE_BIG = 0.02 |
| 31 | export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] |
| 32 | const THIRDS = [0, 1 / 3, 2 / 3, 1] |
| 33 | const DRAG_THRESHOLD_PX = 3 |
| 34 | const NUDGE_COALESCE_MS = 300 |
| 35 | const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'table'] |
| 36 | |
| 37 | /** The box kept inside the frame, never smaller than MIN_SIZE. */ |
| 38 | export function clampBox (box) { |
| 39 | const w = Math.min(1, Math.max(MIN_SIZE, box.w)) |
| 40 | const h = Math.min(1, Math.max(MIN_SIZE, box.h)) |
| 41 | const x = Math.min(1 - w, Math.max(0, box.x)) |
| 42 | const y = Math.min(1 - h, Math.max(0, box.y)) |
| 43 | return { x, y, w, h } |
| 44 | } |
| 45 | |
| 46 | /** Four decimals each, then clamped: what an op carries. */ |
| 47 | export function fixBox (box) { |
| 48 | const c = clampBox(box) |
| 49 | return { x: round4(c.x), y: round4(c.y), w: round4(c.w), h: round4(c.h) } |
| 50 | } |
| 51 | |
| 52 | export function sameBox (a, b) { |
| 53 | return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h |
| 54 | } |
| 55 | |
| 56 | /** The guides a slide offers: the thirds of the frame and every other block's edges. */ |
| 57 | export function guides (others) { |
| 58 | const x = new Set(THIRDS) |
| 59 | const y = new Set(THIRDS) |
| 60 | for (const b of others) { |
| 61 | x.add(b.x); x.add(b.x + b.w) |
| 62 | y.add(b.y); y.add(b.y + b.h) |
| 63 | } |
| 64 | return { x: [...x], y: [...y] } |
| 65 | } |
| 66 | |
| 67 | /** The guide nearest v within tol, or null. */ |
| 68 | function nearest (v, lines, tol) { |
| 69 | let best = null |
| 70 | let dist = tol |
| 71 | for (const g of lines) { |
| 72 | const d = Math.abs(v - g) |
| 73 | if (d <= dist) { dist = d; best = g } |
| 74 | } |
| 75 | return best |
| 76 | } |
| 77 | |
| 78 | /** The whole box shifted so its nearest edge (per axis) sits on a guide within tol. */ |
| 79 | export function snapMove (box, g, tol = SNAP) { |
| 80 | const out = { ...box } |
| 81 | const left = nearest(box.x, g.x, tol) |
| 82 | const right = nearest(box.x + box.w, g.x, tol) |
| 83 | if (left !== null && (right === null || Math.abs(left - box.x) <= Math.abs(right - box.x - box.w))) out.x = left |
| 84 | else if (right !== null) out.x = right - box.w |
| 85 | const top = nearest(box.y, g.y, tol) |
| 86 | const bottom = nearest(box.y + box.h, g.y, tol) |
| 87 | if (top !== null && (bottom === null || Math.abs(top - box.y) <= Math.abs(bottom - box.y - box.h))) out.y = top |
| 88 | else if (bottom !== null) out.y = bottom - box.h |
| 89 | return out |
| 90 | } |
| 91 | |
| 92 | /** The box with only the edges handle moves snapped to a guide within tol. */ |
| 93 | export function snapResize (box, handle, g, tol = SNAP) { |
| 94 | let { x, y, w, h } = box |
| 95 | if (handle.includes('w')) { |
| 96 | const s = nearest(x, g.x, tol) |
| 97 | if (s !== null) { w += x - s; x = s } |
| 98 | } |
| 99 | if (handle.includes('e')) { |
| 100 | const s = nearest(x + w, g.x, tol) |
| 101 | if (s !== null) w = s - x |
| 102 | } |
| 103 | if (handle.includes('n')) { |
| 104 | const s = nearest(y, g.y, tol) |
| 105 | if (s !== null) { h += y - s; y = s } |
| 106 | } |
| 107 | if (handle.includes('s')) { |
| 108 | const s = nearest(y + h, g.y, tol) |
| 109 | if (s !== null) h = s - y |
| 110 | } |
| 111 | return { x, y, w, h } |
| 112 | } |
| 113 | |
| 114 | export function moveBox (box, dx, dy) { |
| 115 | return { ...box, x: box.x + dx, y: box.y + dy } |
| 116 | } |
| 117 | |
| 118 | /** The box after handle is dragged by (dx, dy): the named edges move, the opposite ones stay. */ |
| 119 | export function resizeBox (box, handle, dx, dy) { |
| 120 | let { x, y, w, h } = box |
| 121 | if (handle.includes('e')) w = Math.max(MIN_SIZE, w + dx) |
| 122 | if (handle.includes('s')) h = Math.max(MIN_SIZE, h + dy) |
| 123 | if (handle.includes('w')) { |
| 124 | const nw = Math.max(MIN_SIZE, w - dx) |
| 125 | x += w - nw |
| 126 | w = nw |
| 127 | } |
| 128 | if (handle.includes('n')) { |
| 129 | const nh = Math.max(MIN_SIZE, h - dy) |
| 130 | y += h - nh |
| 131 | h = nh |
| 132 | } |
| 133 | return { x, y, w, h } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * The box after an arrow key: a nudge (Shift for the bigger step), or |
| 138 | * with Alt a resize of the right/bottom edge. null for any other key. |
| 139 | */ |
| 140 | export function nudge (box, key, { shift = false, alt = false } = {}) { |
| 141 | const step = shift ? NUDGE_BIG : NUDGE |
| 142 | const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[key] |
| 143 | if (!d) return null |
| 144 | const [dx, dy] = d |
| 145 | if (alt) return resizeBox(box, 'se', dx, dy) |
| 146 | return moveBox(box, dx, dy) |
| 147 | } |
| 148 | |
| 149 | /** A block element's box as fractions of its frame, from the two rectangles. */ |
| 150 | export function rectBox (frame, rect) { |
| 151 | if (!frame.width || !frame.height) return { x: 0, y: 0, w: 0, h: 0 } |
| 152 | return { |
| 153 | x: (rect.left - frame.left) / frame.width, |
| 154 | y: (rect.top - frame.top) / frame.height, |
| 155 | w: rect.width / frame.width, |
| 156 | h: rect.height / frame.height |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | function pct (f) { return (f * 100).toFixed(2) + '%' } |
| 161 | |
| 162 | /** Writes a box onto a block element's inline style — the drag preview. */ |
| 163 | export function applyBox (el, box) { |
| 164 | el.style.left = pct(box.x) |
| 165 | el.style.top = pct(box.y) |
| 166 | el.style.width = pct(box.w) |
| 167 | el.style.height = pct(box.h) |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Mounts the canvas editing overlay in stageEl. getSelected() is the |
| 172 | * selected block's id; onCommit(id, box) is called once per gesture |
| 173 | * with the fixed box; label names the overlay for a screen reader. |
| 174 | * Returns {place, destroy}: place() re-positions the overlay (after a |
| 175 | * selection change, a refresh, a resize) and hides it when the stage |
| 176 | * is not a canvas slide or nothing is selected. |
| 177 | */ |
| 178 | export function mountCanvas ({ stageEl, getSelected, onCommit, label }) { |
| 179 | const overlay = document.createElement('div') |
| 180 | overlay.setAttribute('slides-canvas-overlay', '') |
| 181 | overlay.setAttribute('tabindex', '0') |
| 182 | overlay.setAttribute('role', 'group') |
| 183 | overlay.setAttribute('aria-label', label) |
| 184 | overlay.hidden = true |
| 185 | const body = document.createElement('div') |
| 186 | body.setAttribute('slides-drag', '') |
| 187 | body.dataset.surface = 'body' |
| 188 | overlay.append(body) |
| 189 | for (const side of ['n', 'e', 's', 'w']) { |
| 190 | const edge = document.createElement('div') |
| 191 | edge.setAttribute('slides-drag', '') |
| 192 | edge.dataset.surface = 'edge' |
| 193 | edge.dataset.side = side |
| 194 | overlay.append(edge) |
| 195 | } |
| 196 | for (const h of HANDLES) { |
| 197 | const el = document.createElement('div') |
| 198 | el.setAttribute('slides-handle', '') |
| 199 | el.dataset.handle = h |
| 200 | el.setAttribute('aria-hidden', 'true') |
| 201 | overlay.append(el) |
| 202 | } |
| 203 | stageEl.append(overlay) |
| 204 | |
| 205 | const section = () => stageEl.querySelector('[slides-slide][data-layout="canvas"]') |
| 206 | const selectedEl = () => { |
| 207 | const id = getSelected() |
| 208 | const s = section() |
| 209 | return id && s ? s.querySelector(`:scope > [slides-block][data-id="${CSS.escape(id)}"]`) : null |
| 210 | } |
| 211 | const frameRect = () => section().getBoundingClientRect() |
| 212 | const boxOf = el => rectBox(frameRect(), el.getBoundingClientRect()) |
| 213 | const othersBoxes = el => Array.from(section().querySelectorAll(':scope > [slides-block]')).filter(b => b !== el).map(boxOf) |
| 214 | |
| 215 | function place () { |
| 216 | // A load replaces the stage's whole DOM, the overlay with it: it |
| 217 | // goes back after the section, every time. |
| 218 | if (!overlay.isConnected) stageEl.append(overlay) |
| 219 | const el = selectedEl() |
| 220 | if (!el) { |
| 221 | overlay.hidden = true |
| 222 | return |
| 223 | } |
| 224 | const stage = stageEl.getBoundingClientRect() |
| 225 | const r = el.getBoundingClientRect() |
| 226 | overlay.hidden = false |
| 227 | overlay.style.left = (r.left - stage.left + stageEl.scrollLeft) + 'px' |
| 228 | overlay.style.top = (r.top - stage.top + stageEl.scrollTop) + 'px' |
| 229 | overlay.style.width = r.width + 'px' |
| 230 | overlay.style.height = r.height + 'px' |
| 231 | overlay.dataset.text = TEXT_TYPES.includes(el.getAttribute('data-block')) ? 'true' : 'false' |
| 232 | } |
| 233 | |
| 234 | // One gesture: pointerdown on a surface or a handle, moves, up. |
| 235 | let gesture = null |
| 236 | function start (e, handle) { |
| 237 | const el = selectedEl() |
| 238 | if (!el || e.button !== 0) return |
| 239 | const frame = frameRect() |
| 240 | gesture = { el, handle, frame, startX: e.clientX, startY: e.clientY, from: boxOf(el), box: boxOf(el), moved: false, guides: guides(othersBoxes(el)), pointerId: e.pointerId } |
| 241 | overlay.setPointerCapture(e.pointerId) |
| 242 | e.preventDefault() |
| 243 | } |
| 244 | overlay.addEventListener('pointerdown', e => { |
| 245 | const handle = e.target.closest('[slides-handle]') |
| 246 | if (handle) return start(e, handle.dataset.handle) |
| 247 | if (e.target.closest('[slides-drag]')) start(e, null) |
| 248 | }) |
| 249 | overlay.addEventListener('pointermove', e => { |
| 250 | if (!gesture || e.pointerId !== gesture.pointerId) return |
| 251 | const px = e.clientX - gesture.startX |
| 252 | const py = e.clientY - gesture.startY |
| 253 | if (!gesture.moved && Math.hypot(px, py) < DRAG_THRESHOLD_PX) return |
| 254 | gesture.moved = true |
| 255 | const dx = px / gesture.frame.width |
| 256 | const dy = py / gesture.frame.height |
| 257 | let box = gesture.handle ? resizeBox(gesture.from, gesture.handle, dx, dy) : moveBox(gesture.from, dx, dy) |
| 258 | box = gesture.handle ? snapResize(box, gesture.handle, gesture.guides) : snapMove(box, gesture.guides) |
| 259 | box = clampBox(box) |
| 260 | gesture.box = box |
| 261 | applyBox(gesture.el, box) |
| 262 | place() |
| 263 | }) |
| 264 | function end (e) { |
| 265 | if (!gesture || e.pointerId !== gesture.pointerId) return |
| 266 | const g = gesture |
| 267 | gesture = null |
| 268 | if (!g.moved) return |
| 269 | const box = fixBox(g.box) |
| 270 | if (!sameBox(box, fixBox(g.from))) onCommit(g.el.getAttribute('data-id'), box) |
| 271 | } |
| 272 | overlay.addEventListener('pointerup', end) |
| 273 | overlay.addEventListener('pointercancel', end) |
| 274 | |
| 275 | // A held arrow key repeats several times a second; a mouse drag |
| 276 | // commits once, at pointerup. The keyboard gets the same shape: the |
| 277 | // box is redrawn on every keydown for instant feedback, but onCommit |
| 278 | // — a Y transaction, and past it a save-queue post — fires once, |
| 279 | // after the burst settles, not once per repeat. |
| 280 | let nudgeTimer = null |
| 281 | let pending = null |
| 282 | function flushNudge () { |
| 283 | if (!pending) return |
| 284 | clearTimeout(nudgeTimer) |
| 285 | nudgeTimer = null |
| 286 | const { id, box } = pending |
| 287 | pending = null |
| 288 | onCommit(id, box) |
| 289 | } |
| 290 | overlay.addEventListener('keydown', e => { |
| 291 | const el = selectedEl() |
| 292 | if (!el) return |
| 293 | const next = nudge(boxOf(el), e.key, { shift: e.shiftKey, alt: e.altKey }) |
| 294 | if (!next) return |
| 295 | e.preventDefault() |
| 296 | const box = fixBox(next) |
| 297 | applyBox(el, box) |
| 298 | place() |
| 299 | pending = { id: el.getAttribute('data-id'), box } |
| 300 | clearTimeout(nudgeTimer) |
| 301 | nudgeTimer = setTimeout(flushNudge, NUDGE_COALESCE_MS) |
| 302 | }) |
| 303 | |
| 304 | const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null |
| 305 | if (ro) ro.observe(stageEl) |
| 306 | |
| 307 | return { |
| 308 | place, |
| 309 | focus: () => { if (!overlay.hidden) overlay.focus() }, |
| 310 | destroy () { |
| 311 | // A resync destroys this mount before another replaces it: the |
| 312 | // last nudge in a burst is real intent and lands rather than |
| 313 | // being dropped on the floor with the timer that was carrying it. |
| 314 | flushNudge() |
| 315 | if (ro) ro.disconnect() |
| 316 | overlay.remove() |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | |