oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
25import { round4 } from './layout.mjs'
26
27export const MIN_SIZE = 0.02
28export const SNAP = 0.01
29export const NUDGE = 0.005
30export const NUDGE_BIG = 0.02
31export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
32const THIRDS = [0, 1 / 3, 2 / 3, 1]
33const DRAG_THRESHOLD_PX = 3
34const NUDGE_COALESCE_MS = 300
35const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'table']
36
37/** The box kept inside the frame, never smaller than MIN_SIZE. */
38export 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. */
47export 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
52export 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. */
57export 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. */
68function 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. */
79export 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. */
93export 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
114export 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. */
119export 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 */
140export 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. */
150export 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
160function pct (f) { return (f * 100).toFixed(2) + '%' }
161
162/** Writes a box onto a block element's inline style — the drag preview. */
163export 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 */
178export 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