@xeplr/ui-canvas 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ import { useCallback, useLayoutEffect, useRef } from 'react'
2
+ import { moveRect, resizeRect, pixelRect, GRID } from './geometry.js'
3
+ import { createFrameStore } from './frameStore.js'
4
+
5
+ // Dragging and resizing on a canvas. The geometry is in geometry.js; this owns
6
+ // only the pointer lifecycle.
7
+ //
8
+ // Three things it deliberately does:
9
+ //
10
+ // Listeners go on the DOCUMENT, not the item. A drag that ends when the
11
+ // pointer leaves the element is a drag that breaks the moment you move
12
+ // quickly — which is exactly when you are moving something a long way.
13
+ //
14
+ // NOTHING RE-RENDERS DURING A DRAG. A frame writes `transform` (or, for
15
+ // resize, the box) straight onto the dragged item's own DOM element and
16
+ // publishes guides and live positions through a frame store, which only the
17
+ // overlays (guides, edges) subscribe to. React learns the new position exactly
18
+ // once, on drop. Everything else on the canvas is untouched, whatever the item
19
+ // count.
20
+ //
21
+ // Moves are COALESCED INTO ONE FRAME. The OS delivers mousemove faster than
22
+ // the display refreshes, so the work is done in a rAF and the extra events
23
+ // collapse into the frame that is already pending.
24
+ //
25
+ // Items are found by the `data-canvas-item-id` attribute on their element,
26
+ // inside `rootRef` when given (so two canvases on a page never find each
27
+ // other's items).
28
+
29
+ export const ITEM_ID_ATTR = 'data-canvas-item-id'
30
+
31
+ const EMPTY_FRAME = { guides: [], rect: null, mode: null, patches: null }
32
+
33
+ /**
34
+ * @param items the items on the canvas: { id, x, y, w, h, ... }
35
+ * @param units 'px' (default) or 'fraction' — see geometry.js
36
+ * @param canvas measured { width, height }; needed for 'fraction'
37
+ * @param rootRef the canvas element, to scope the item lookup
38
+ * @param onUpdate (id, patch) — called once per moved item, on drop, in
39
+ * PIXELS; a fractional caller converts
40
+ * @param onRaise (id) — optional, on grab
41
+ * @param onClick (id, event) — optional, when the pointer never moved
42
+ * past clickThreshold
43
+ * @param minSizeFor (item) → { minWidth, minHeight } — optional, per item
44
+ * @param snap false turns alignment snapping and the grid off
45
+ * @param grid grid fallback spacing in px; 0 for none
46
+ * @param clickThreshold px the pointer must move before it counts as a drag.
47
+ * 0 (default) commits any movement at all.
48
+ */
49
+ export function useCanvasDrag({
50
+ items, units, canvas, rootRef, onUpdate, onRaise, onClick, minSizeFor,
51
+ snap = true, grid = GRID, clickThreshold = 0
52
+ }) {
53
+ const stateRef = useRef(null)
54
+ // The listeners are added and removed by identity, so they have to be the
55
+ // same function objects across a drag — refs rather than callbacks, whose
56
+ // identity changes whenever `items` does.
57
+ const moveRef = useRef(null)
58
+ const endRef = useRef(null)
59
+
60
+ // One store per hook instance. It carries the live RECT and per-item
61
+ // PATCHES as well as the guides: a ruler has to show where the thing
62
+ // currently is, and an edge has to follow the item it is attached to.
63
+ const storeRef = useRef(null)
64
+ if (!storeRef.current) storeRef.current = createFrameStore(EMPTY_FRAME)
65
+ const store = storeRef.current
66
+
67
+ // Nodes whose inline drag styles are waiting to be handed back to React.
68
+ const pendingClearRef = useRef(null)
69
+
70
+ // ONLY `transform`, and that is not a detail.
71
+ //
72
+ // left/top/width/height are REACT'S — the item writes them from its own
73
+ // rect. Clearing them here wiped the item's position, and React does not
74
+ // put it back: its style diff only writes properties whose value CHANGED
75
+ // between renders, so a committed position that round-trips to the pixels
76
+ // it already rendered writes nothing at all.
77
+ //
78
+ // `transform` is the one property this file owns and React never sets, so
79
+ // it is the only one safe to reset. A resize writes the box directly, and
80
+ // React overwrites it on commit — those values genuinely changed.
81
+ function clearNodes(nodes) {
82
+ nodes.forEach((n) => { if (n) n.style.transform = '' })
83
+ }
84
+
85
+ // Cleared AFTER the commit has rendered, in a layout effect, so the element
86
+ // never paints a frame at its pre-drag position: React has already written
87
+ // the new left/top by the time the transform comes off. The live patches
88
+ // are dropped at the same moment, for the same reason — an edge must not
89
+ // flick back to the old position for the frame between drop and commit.
90
+ useLayoutEffect(() => {
91
+ if (!pendingClearRef.current) return
92
+ clearNodes(pendingClearRef.current)
93
+ pendingClearRef.current = null
94
+ store.publish(EMPTY_FRAME)
95
+ }, [items, store])
96
+
97
+ const begin = useCallback((e, ids, mode, handle) => {
98
+ // Only the primary button, and never through a button inside the item —
99
+ // a Remove control can sit in the drag handle.
100
+ if (e.button !== 0) return
101
+ if (e.target.closest && e.target.closest('button')) return
102
+ e.preventDefault()
103
+ e.stopPropagation()
104
+
105
+ // MOVE can carry a whole selection; RESIZE is always exactly one item —
106
+ // this just normalizes both callers to the same shape.
107
+ const idList = Array.isArray(ids) ? ids : [ids]
108
+ const members = idList.map((id) => items.find((w) => w.id === id)).filter(Boolean)
109
+ if (!members.length) return
110
+
111
+ // Pixels throughout the drag, because a mouse moves in pixels. Fractions
112
+ // are converted back by the caller, once, on drop.
113
+ const rectOf = (w) => pixelRect(w, units, canvas)
114
+ const memberIdSet = new Set(idList)
115
+ const others = items.filter((w) => !memberIdSet.has(w.id)).map(rectOf)
116
+ // Looked up ONCE. A per-frame querySelector would put a DOM search in the
117
+ // hot path.
118
+ const root = (rootRef && rootRef.current) || document
119
+ const memberRects = members.map((w) => ({
120
+ id: w.id,
121
+ item: w,
122
+ rect: rectOf(w),
123
+ node: root.querySelector(`[${ITEM_ID_ATTR}="${CSS.escape(String(w.id))}"]`)
124
+ }))
125
+ stateRef.current = {
126
+ ids: idList, mode, handle, others, memberRects,
127
+ startX: e.clientX, startY: e.clientY,
128
+ rect: memberRects[0].rect,
129
+ minSize: minSizeFor ? minSizeFor(members[0]) : null,
130
+ moved: false,
131
+ last: null,
132
+ pendingEvent: null,
133
+ raf: 0
134
+ }
135
+
136
+ function paint() {
137
+ const s = stateRef.current
138
+ if (!s) return
139
+ s.raf = 0
140
+ const ev = s.pendingEvent
141
+ if (!ev) return
142
+ s.pendingEvent = null
143
+
144
+ const dx = ev.clientX - s.startX
145
+ const dy = ev.clientY - s.startY
146
+
147
+ // Below the threshold it is still a click. Checked only until the
148
+ // pointer first leaves it — a drag that comes back near its start is
149
+ // still a drag.
150
+ if (!s.moved) {
151
+ if (clickThreshold > 0 && Math.abs(dx) <= clickThreshold && Math.abs(dy) <= clickThreshold) return
152
+ s.moved = true
153
+ }
154
+ // Alt overrules the snapping — the point of a free canvas is that it
155
+ // can always be overruled.
156
+ const disableSnap = !snap || ev.altKey
157
+
158
+ if (s.mode === 'move') {
159
+ if (s.memberRects.length > 1) {
160
+ // Group move: everyone shifts by the same raw delta. No alignment
161
+ // snapping here — moving several things as one unit is the point,
162
+ // not lining any one of them up against something else.
163
+ const patches = s.memberRects.map((m) => ({
164
+ id: m.id,
165
+ x: Math.max(0, Math.round(m.rect.x + dx)),
166
+ y: Math.max(0, Math.round(m.rect.y + dy))
167
+ }))
168
+ s.last = patches
169
+ s.memberRects.forEach((m, i) => {
170
+ if (!m.node) return
171
+ m.node.style.transform =
172
+ `translate3d(${patches[i].x - m.rect.x}px, ${patches[i].y - m.rect.y}px, 0)`
173
+ })
174
+ store.publish({ guides: [], rect: boundsOf(s.memberRects, patches), mode: 'move', patches })
175
+ return
176
+ }
177
+
178
+ const m = s.memberRects[0]
179
+ const { x, y, guides } = moveRect(s.rect, s.rect.x + dx, s.rect.y + dy, s.others, { disableSnap, grid })
180
+ s.last = [{ id: s.ids[0], x, y }]
181
+ if (m.node) m.node.style.transform = `translate3d(${x - m.rect.x}px, ${y - m.rect.y}px, 0)`
182
+ store.publish({ guides, rect: { x, y, w: m.rect.w, h: m.rect.h }, mode: 'move', patches: s.last })
183
+ return
184
+ }
185
+
186
+ // The minimum is per ITEM, not global: a chart has a real floor, a
187
+ // one-pixel rule does not.
188
+ const next = resizeRect(s.rect, s.handle, dx, dy,
189
+ Object.assign({}, s.minSize, { others: s.others, disableSnap }))
190
+ const { guides: resizeGuides, ...box } = next
191
+ s.last = [{ id: s.ids[0], ...box }]
192
+ // A resize changes the box, which no transform can express — so this
193
+ // one writes the box itself. Still the dragged node only.
194
+ const node = s.memberRects[0].node
195
+ if (node) {
196
+ node.style.left = `${box.x}px`
197
+ node.style.top = `${box.y}px`
198
+ node.style.width = `${box.w}px`
199
+ node.style.height = `${box.h}px`
200
+ }
201
+ store.publish({ guides: resizeGuides, rect: box, mode: 'resize', patches: s.last })
202
+ }
203
+
204
+ function onMove(ev) {
205
+ const s = stateRef.current
206
+ if (!s) return
207
+ s.pendingEvent = ev
208
+ if (!s.raf) s.raf = requestAnimationFrame(paint)
209
+ }
210
+
211
+ function onEnd(ev) {
212
+ const s = stateRef.current
213
+ stateRef.current = null
214
+ document.removeEventListener('mousemove', moveRef.current)
215
+ document.removeEventListener('mouseup', endRef.current)
216
+ if (!s) return
217
+ if (s.raf) cancelAnimationFrame(s.raf)
218
+ // The frame the pointer stopped on may still be queued; apply it, or
219
+ // the drop would commit the position from one frame earlier.
220
+ if (s.pendingEvent) { stateRef.current = s; s.raf = 0; paint(); stateRef.current = null }
221
+
222
+ const nodes = s.memberRects.map((m) => m.node)
223
+ if (!s.last) {
224
+ // A click, not a drag: nothing was written, so nothing has to wait
225
+ // for a commit that is not coming.
226
+ store.publish(EMPTY_FRAME)
227
+ clearNodes(nodes)
228
+ if (onClick) onClick(s.ids[0], ev)
229
+ return
230
+ }
231
+ // Guides off now; the live patches stay until the commit renders (see
232
+ // the layout effect above).
233
+ store.publish({ guides: [], rect: null, mode: null, patches: s.last })
234
+ // The committing write(s). Everything before it went to the DOM only.
235
+ pendingClearRef.current = nodes
236
+ s.last.forEach((p) => { const { id, ...patch } = p; onUpdate(id, patch) })
237
+ }
238
+
239
+ moveRef.current = onMove
240
+ endRef.current = onEnd
241
+ idList.forEach((id) => onRaise?.(id))
242
+ document.addEventListener('mousemove', onMove)
243
+ document.addEventListener('mouseup', onEnd)
244
+ }, [items, units, canvas, rootRef, onUpdate, onRaise, onClick, minSizeFor, snap, grid, clickThreshold, store])
245
+
246
+ return {
247
+ // Read by the overlays alone — DragGuides, EdgeLayer.
248
+ guideStore: store,
249
+ startDrag: (e, ids) => begin(e, ids, 'move'),
250
+ startResize: (e, id, handle) => begin(e, id, 'resize', handle)
251
+ }
252
+ }
253
+
254
+ // The rectangle enclosing every member at its patched position.
255
+ function boundsOf(memberRects, patches) {
256
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
257
+ memberRects.forEach((m, i) => {
258
+ minX = Math.min(minX, patches[i].x)
259
+ minY = Math.min(minY, patches[i].y)
260
+ maxX = Math.max(maxX, patches[i].x + m.rect.w)
261
+ maxY = Math.max(maxY, patches[i].y + m.rect.h)
262
+ })
263
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
264
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ // The measured size of an element — what a PAGE is on a fractional canvas.
4
+ //
5
+ // Returns [size, ref]. Put the ref on the element to measure (the scrolling
6
+ // wrapper, not the canvas inside it: measuring the canvas would feed its own
7
+ // growth back into the page height and never settle).
8
+ //
9
+ // A CALLBACK ref, not an effect with a dependency list. The observer has to
10
+ // attach when the NODE appears, and there is no dependency list that reliably
11
+ // names that moment: a page that renders "Loading…" first has no node on
12
+ // mount, the effect runs once against a null ref and never again, and every
13
+ // fractional item is converted against a null canvas — present in the DOM,
14
+ // sized to nothing. A callback ref is called BY React when the node mounts
15
+ // and unmounts, so it cannot be out of step with the DOM.
16
+ export function useCanvasSize() {
17
+ const [size, setSize] = useState(null)
18
+ const observerRef = useRef(null)
19
+ const ref = useCallback((el) => {
20
+ observerRef.current?.disconnect()
21
+ observerRef.current = null
22
+ if (!el || typeof ResizeObserver === 'undefined') return
23
+ const observer = new ResizeObserver(([entry]) => {
24
+ const { width, height } = entry.contentRect
25
+ // Ignored while zero — a hidden tab reports 0x0, and taking that as the
26
+ // canvas would collapse every item to nothing and then scale them back
27
+ // up on return.
28
+ if (width <= 0 || height <= 0) return
29
+ // Compared before storing: an unchanged size must not be a new object,
30
+ // or every scroll and reflow re-renders the whole board.
31
+ setSize((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height }))
32
+ })
33
+ observer.observe(el)
34
+ observerRef.current = observer
35
+ }, [])
36
+ return [size, ref]
37
+ }
@@ -0,0 +1,133 @@
1
+ import { useCallback, useRef } from 'react'
2
+ import { hits, pixelRect } from './geometry.js'
3
+ import { createFrameStore } from './frameStore.js'
4
+
5
+ // MARQUEE SELECT — drag a box on bare canvas, take everything it touches.
6
+ //
7
+ // ── WHY THIS IS ALSO THE ANSWER TO Z-INDEX ───────────────────────────────
8
+ //
9
+ // Clicking selects whatever is on top: an item under another cannot be
10
+ // reached at all, because the pointer never gets to it. A marquee does not
11
+ // hit-test — it compares RECTANGLES, so an occluded item is selected by
12
+ // exactly the same arithmetic as one in the open.
13
+ //
14
+ // ── AND WHY THE CANVAS DOES NOT CLEAR ON MOUSEDOWN ───────────────────────
15
+ //
16
+ // That is the gesture a marquee starts with, so the natural sweep would
17
+ // destroy the selection before it drew anything. Clearing happens on mouseUP,
18
+ // and only if the pointer never really moved — a click on bare canvas still
19
+ // means "deselect", a drag means "select these".
20
+ //
21
+ // Nothing re-renders while the band is drawn: the rect goes to a store that
22
+ // only MarqueeBox subscribes to, and moves coalesce into one rAF — the same
23
+ // bargain useCanvasDrag makes, for the same reason.
24
+
25
+ /** Below this, it was a click and not a drag. */
26
+ const THRESHOLD = 4
27
+
28
+ /** On <body> while a sweep is live — see canvas.css. */
29
+ export const SWEEPING_CLASS = 'xeplr-canvas-sweeping'
30
+
31
+ /**
32
+ * @param items the items on the canvas
33
+ * @param units 'px' (default) or 'fraction'
34
+ * @param canvas measured { width, height }; needed for 'fraction'
35
+ * @param canvasElRef the element the band is drawn in
36
+ * @param onSelect (ids, additive) — on mouseup after a sweep
37
+ * @param onClear () — on a click on bare canvas
38
+ */
39
+ export function useMarquee({ items, units, canvas, canvasElRef, onSelect, onClear }) {
40
+ const stateRef = useRef(null)
41
+ const moveRef = useRef(null)
42
+ const endRef = useRef(null)
43
+ const storeRef = useRef(null)
44
+ if (!storeRef.current) storeRef.current = createFrameStore({ rect: null })
45
+ const store = storeRef.current
46
+
47
+ const begin = useCallback((e) => {
48
+ // Only the primary button, and only on the canvas ITSELF — a mousedown
49
+ // that started on an item is that item's drag, not a marquee.
50
+ if (e.button !== 0 || e.target !== e.currentTarget) return
51
+ const el = canvasElRef.current
52
+ if (!el) return
53
+
54
+ // THE BROWSER'S OWN SELECTION, SUPPRESSED.
55
+ //
56
+ // A bare mousedown followed by a drag is also how you highlight text, so
57
+ // sweeping a band across a table item selected its CELLS. The only thing
58
+ // a canvas selects is items. preventDefault stops the selection starting;
59
+ // the body class stops it being started by anything else while the sweep
60
+ // is live.
61
+ e.preventDefault()
62
+ document.body.classList.add(SWEEPING_CLASS)
63
+ const sel = window.getSelection && window.getSelection()
64
+ if (sel && sel.rangeCount) sel.removeAllRanges()
65
+
66
+ const box = el.getBoundingClientRect()
67
+ const origin = { x: e.clientX - box.left, y: e.clientY - box.top }
68
+ stateRef.current = {
69
+ origin,
70
+ box,
71
+ // Held at the START. A modifier picked up later would turn a replace
72
+ // into an add halfway through, which is not something anyone means.
73
+ additive: Boolean(e.ctrlKey || e.metaKey || e.shiftKey),
74
+ moved: false,
75
+ pendingEvent: null,
76
+ raf: 0,
77
+ rect: null
78
+ }
79
+
80
+ function paint() {
81
+ const s = stateRef.current
82
+ if (!s) return
83
+ s.raf = 0
84
+ const ev = s.pendingEvent
85
+ if (!ev) return
86
+ s.pendingEvent = null
87
+
88
+ const x = ev.clientX - s.box.left
89
+ const y = ev.clientY - s.box.top
90
+ // Normalised, so dragging up and left works exactly like down and right.
91
+ s.rect = {
92
+ x: Math.min(s.origin.x, x),
93
+ y: Math.min(s.origin.y, y),
94
+ w: Math.abs(x - s.origin.x),
95
+ h: Math.abs(y - s.origin.y)
96
+ }
97
+ if (s.rect.w > THRESHOLD || s.rect.h > THRESHOLD) s.moved = true
98
+ if (s.moved) store.publish({ rect: s.rect })
99
+ }
100
+
101
+ function onMove(ev) {
102
+ const s = stateRef.current
103
+ if (!s) return
104
+ s.pendingEvent = ev
105
+ if (!s.raf) s.raf = requestAnimationFrame(paint)
106
+ }
107
+
108
+ function onEnd() {
109
+ const s = stateRef.current
110
+ stateRef.current = null
111
+ document.body.classList.remove(SWEEPING_CLASS)
112
+ document.removeEventListener('mousemove', moveRef.current)
113
+ document.removeEventListener('mouseup', endRef.current)
114
+ if (!s) return
115
+ if (s.raf) cancelAnimationFrame(s.raf)
116
+ store.publish({ rect: null })
117
+
118
+ // A click on bare canvas still deselects.
119
+ if (!s.moved || !s.rect) { onClear(); return }
120
+ // A fractional canvas not measured yet has nothing on it to hit.
121
+ if (units === 'fraction' && !canvas) { onSelect([], s.additive); return }
122
+ const rects = items.map((w) => ({ id: w.id, ...pixelRect(w, units, canvas) }))
123
+ onSelect(hits(rects, s.rect), s.additive)
124
+ }
125
+
126
+ moveRef.current = onMove
127
+ endRef.current = onEnd
128
+ document.addEventListener('mousemove', onMove)
129
+ document.addEventListener('mouseup', onEnd)
130
+ }, [items, units, canvas, canvasElRef, onSelect, onClear, store])
131
+
132
+ return { onMouseDown: begin, marqueeStore: store }
133
+ }
@@ -0,0 +1,26 @@
1
+ // Fails LOUDLY on a canvas wired up wrong, rather than rendering an empty or
2
+ // half-working board that looks like a data problem.
3
+ //
4
+ // The two mistakes that matter: no renderItem (nothing can be drawn) and an
5
+ // item with no id (drag, selection and edges all address items by id — two
6
+ // items sharing undefined move together, and an edge to one attaches to
7
+ // whichever was found first).
8
+ export function validateCanvasProps(props) {
9
+ if (!Array.isArray(props.items)) {
10
+ throw new Error('XeplrCanvas: `items` must be an array of { id, x, y, w, h }')
11
+ }
12
+ if (typeof props.renderItem !== 'function') {
13
+ throw new Error('XeplrCanvas: `renderItem(item, { selected })` is required')
14
+ }
15
+ const seen = new Set()
16
+ for (const item of props.items) {
17
+ if (item == null || item.id == null || item.id === '') {
18
+ throw new Error('XeplrCanvas: every item needs an `id`')
19
+ }
20
+ if (seen.has(item.id)) throw new Error(`XeplrCanvas: duplicate item id "${item.id}"`)
21
+ seen.add(item.id)
22
+ }
23
+ if (props.units && props.units !== 'px' && props.units !== 'fraction') {
24
+ throw new Error(`XeplrCanvas: units must be 'px' or 'fraction', got "${props.units}"`)
25
+ }
26
+ }