// fixtures/canvas.mjs — editing a canvas slide on the stage (spec // §3.1): the selected block drags by its body and resizes by eight // handles, snapping to the frame's thirds and to the other blocks' // edges, and the box is written once, on pointer up, as four-decimal // fractions of the frame — so undo is per gesture. // // The maths is pure over boxes {x, y, w, h} in frame fractions // (canvas.test.mjs): clamping to the frame, the guides a slide // offers, snapping a moving edge to the nearest guide within one per // cent, moving, resizing by handle, nudging by key. The overlay is the // DOM part: one element placed over the selected block, with a drag // surface and the handles, positioned from the block's own rectangle // so it is right in the desktop rendering and the phone one alike. // // The keyboard's equivalent is the overlay itself: it is focusable and // named, arrow keys nudge by half a per cent, Shift by two, Alt // resizes; the inspector's Left/Top/Width/Height fields are the same // numbers typed. The handles are pointer targets only (aria-hidden), // 24px square so a finger finds them (touch is pointer events too). // // A text block is dragged by its edges — a ring inside its border — // because its interior is the caret's; anything else drags by its // whole body. During a drag only the block's inline style moves; the // op is written on pointer up and the stage re-fetches the section. import { round4 } from './layout.mjs' export const MIN_SIZE = 0.02 export const SNAP = 0.01 export const NUDGE = 0.005 export const NUDGE_BIG = 0.02 export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] const THIRDS = [0, 1 / 3, 2 / 3, 1] const DRAG_THRESHOLD_PX = 3 const NUDGE_COALESCE_MS = 300 const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'table'] /** The box kept inside the frame, never smaller than MIN_SIZE. */ export function clampBox (box) { const w = Math.min(1, Math.max(MIN_SIZE, box.w)) const h = Math.min(1, Math.max(MIN_SIZE, box.h)) const x = Math.min(1 - w, Math.max(0, box.x)) const y = Math.min(1 - h, Math.max(0, box.y)) return { x, y, w, h } } /** Four decimals each, then clamped: what an op carries. */ export function fixBox (box) { const c = clampBox(box) return { x: round4(c.x), y: round4(c.y), w: round4(c.w), h: round4(c.h) } } export function sameBox (a, b) { return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h } /** The guides a slide offers: the thirds of the frame and every other block's edges. */ export function guides (others) { const x = new Set(THIRDS) const y = new Set(THIRDS) for (const b of others) { x.add(b.x); x.add(b.x + b.w) y.add(b.y); y.add(b.y + b.h) } return { x: [...x], y: [...y] } } /** The guide nearest v within tol, or null. */ function nearest (v, lines, tol) { let best = null let dist = tol for (const g of lines) { const d = Math.abs(v - g) if (d <= dist) { dist = d; best = g } } return best } /** The whole box shifted so its nearest edge (per axis) sits on a guide within tol. */ export function snapMove (box, g, tol = SNAP) { const out = { ...box } const left = nearest(box.x, g.x, tol) const right = nearest(box.x + box.w, g.x, tol) if (left !== null && (right === null || Math.abs(left - box.x) <= Math.abs(right - box.x - box.w))) out.x = left else if (right !== null) out.x = right - box.w const top = nearest(box.y, g.y, tol) const bottom = nearest(box.y + box.h, g.y, tol) if (top !== null && (bottom === null || Math.abs(top - box.y) <= Math.abs(bottom - box.y - box.h))) out.y = top else if (bottom !== null) out.y = bottom - box.h return out } /** The box with only the edges handle moves snapped to a guide within tol. */ export function snapResize (box, handle, g, tol = SNAP) { let { x, y, w, h } = box if (handle.includes('w')) { const s = nearest(x, g.x, tol) if (s !== null) { w += x - s; x = s } } if (handle.includes('e')) { const s = nearest(x + w, g.x, tol) if (s !== null) w = s - x } if (handle.includes('n')) { const s = nearest(y, g.y, tol) if (s !== null) { h += y - s; y = s } } if (handle.includes('s')) { const s = nearest(y + h, g.y, tol) if (s !== null) h = s - y } return { x, y, w, h } } export function moveBox (box, dx, dy) { return { ...box, x: box.x + dx, y: box.y + dy } } /** The box after handle is dragged by (dx, dy): the named edges move, the opposite ones stay. */ export function resizeBox (box, handle, dx, dy) { let { x, y, w, h } = box if (handle.includes('e')) w = Math.max(MIN_SIZE, w + dx) if (handle.includes('s')) h = Math.max(MIN_SIZE, h + dy) if (handle.includes('w')) { const nw = Math.max(MIN_SIZE, w - dx) x += w - nw w = nw } if (handle.includes('n')) { const nh = Math.max(MIN_SIZE, h - dy) y += h - nh h = nh } return { x, y, w, h } } /** * The box after an arrow key: a nudge (Shift for the bigger step), or * with Alt a resize of the right/bottom edge. null for any other key. */ export function nudge (box, key, { shift = false, alt = false } = {}) { const step = shift ? NUDGE_BIG : NUDGE const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[key] if (!d) return null const [dx, dy] = d if (alt) return resizeBox(box, 'se', dx, dy) return moveBox(box, dx, dy) } /** A block element's box as fractions of its frame, from the two rectangles. */ export function rectBox (frame, rect) { if (!frame.width || !frame.height) return { x: 0, y: 0, w: 0, h: 0 } return { x: (rect.left - frame.left) / frame.width, y: (rect.top - frame.top) / frame.height, w: rect.width / frame.width, h: rect.height / frame.height } } function pct (f) { return (f * 100).toFixed(2) + '%' } /** Writes a box onto a block element's inline style — the drag preview. */ export function applyBox (el, box) { el.style.left = pct(box.x) el.style.top = pct(box.y) el.style.width = pct(box.w) el.style.height = pct(box.h) } /** * Mounts the canvas editing overlay in stageEl. getSelected() is the * selected block's id; onCommit(id, box) is called once per gesture * with the fixed box; label names the overlay for a screen reader. * Returns {place, destroy}: place() re-positions the overlay (after a * selection change, a refresh, a resize) and hides it when the stage * is not a canvas slide or nothing is selected. */ export function mountCanvas ({ stageEl, getSelected, onCommit, label }) { const overlay = document.createElement('div') overlay.setAttribute('slides-canvas-overlay', '') overlay.setAttribute('tabindex', '0') overlay.setAttribute('role', 'group') overlay.setAttribute('aria-label', label) overlay.hidden = true const body = document.createElement('div') body.setAttribute('slides-drag', '') body.dataset.surface = 'body' overlay.append(body) for (const side of ['n', 'e', 's', 'w']) { const edge = document.createElement('div') edge.setAttribute('slides-drag', '') edge.dataset.surface = 'edge' edge.dataset.side = side overlay.append(edge) } for (const h of HANDLES) { const el = document.createElement('div') el.setAttribute('slides-handle', '') el.dataset.handle = h el.setAttribute('aria-hidden', 'true') overlay.append(el) } stageEl.append(overlay) const section = () => stageEl.querySelector('[slides-slide][data-layout="canvas"]') const selectedEl = () => { const id = getSelected() const s = section() return id && s ? s.querySelector(`:scope > [slides-block][data-id="${CSS.escape(id)}"]`) : null } const frameRect = () => section().getBoundingClientRect() const boxOf = el => rectBox(frameRect(), el.getBoundingClientRect()) const othersBoxes = el => Array.from(section().querySelectorAll(':scope > [slides-block]')).filter(b => b !== el).map(boxOf) function place () { // A load replaces the stage's whole DOM, the overlay with it: it // goes back after the section, every time. if (!overlay.isConnected) stageEl.append(overlay) const el = selectedEl() if (!el) { overlay.hidden = true return } const stage = stageEl.getBoundingClientRect() const r = el.getBoundingClientRect() overlay.hidden = false overlay.style.left = (r.left - stage.left + stageEl.scrollLeft) + 'px' overlay.style.top = (r.top - stage.top + stageEl.scrollTop) + 'px' overlay.style.width = r.width + 'px' overlay.style.height = r.height + 'px' overlay.dataset.text = TEXT_TYPES.includes(el.getAttribute('data-block')) ? 'true' : 'false' } // One gesture: pointerdown on a surface or a handle, moves, up. let gesture = null function start (e, handle) { const el = selectedEl() if (!el || e.button !== 0) return const frame = frameRect() 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 } overlay.setPointerCapture(e.pointerId) e.preventDefault() } overlay.addEventListener('pointerdown', e => { const handle = e.target.closest('[slides-handle]') if (handle) return start(e, handle.dataset.handle) if (e.target.closest('[slides-drag]')) start(e, null) }) overlay.addEventListener('pointermove', e => { if (!gesture || e.pointerId !== gesture.pointerId) return const px = e.clientX - gesture.startX const py = e.clientY - gesture.startY if (!gesture.moved && Math.hypot(px, py) < DRAG_THRESHOLD_PX) return gesture.moved = true const dx = px / gesture.frame.width const dy = py / gesture.frame.height let box = gesture.handle ? resizeBox(gesture.from, gesture.handle, dx, dy) : moveBox(gesture.from, dx, dy) box = gesture.handle ? snapResize(box, gesture.handle, gesture.guides) : snapMove(box, gesture.guides) box = clampBox(box) gesture.box = box applyBox(gesture.el, box) place() }) function end (e) { if (!gesture || e.pointerId !== gesture.pointerId) return const g = gesture gesture = null if (!g.moved) return const box = fixBox(g.box) if (!sameBox(box, fixBox(g.from))) onCommit(g.el.getAttribute('data-id'), box) } overlay.addEventListener('pointerup', end) overlay.addEventListener('pointercancel', end) // A held arrow key repeats several times a second; a mouse drag // commits once, at pointerup. The keyboard gets the same shape: the // box is redrawn on every keydown for instant feedback, but onCommit // — a Y transaction, and past it a save-queue post — fires once, // after the burst settles, not once per repeat. let nudgeTimer = null let pending = null function flushNudge () { if (!pending) return clearTimeout(nudgeTimer) nudgeTimer = null const { id, box } = pending pending = null onCommit(id, box) } overlay.addEventListener('keydown', e => { const el = selectedEl() if (!el) return const next = nudge(boxOf(el), e.key, { shift: e.shiftKey, alt: e.altKey }) if (!next) return e.preventDefault() const box = fixBox(next) applyBox(el, box) place() pending = { id: el.getAttribute('data-id'), box } clearTimeout(nudgeTimer) nudgeTimer = setTimeout(flushNudge, NUDGE_COALESCE_MS) }) const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null if (ro) ro.observe(stageEl) return { place, focus: () => { if (!overlay.hidden) overlay.focus() }, destroy () { // A resync destroys this mount before another replaces it: the // last nudge in a burst is real intent and lands rather than // being dropped on the floor with the timer that was carrying it. flushNudge() if (ro) ro.disconnect() overlay.remove() } } }