@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.
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/package.json +47 -0
- package/src/designs/CanvasSample.jsx +71 -0
- package/src/designs/DragGuides.jsx +18 -0
- package/src/designs/EdgeLayer.jsx +62 -0
- package/src/designs/MarqueeBox.jsx +10 -0
- package/src/designs/ResizeHandles.jsx +15 -0
- package/src/designs/canvas.css +50 -0
- package/src/designs/index.js +7 -0
- package/src/edges.js +73 -0
- package/src/frameStore.js +26 -0
- package/src/geometry.js +326 -0
- package/src/index.js +29 -0
- package/src/pages.jsx +48 -0
- package/src/useCanvasController.js +181 -0
- package/src/useCanvasDrag.js +264 -0
- package/src/useCanvasSize.js +37 -0
- package/src/useMarquee.js +133 -0
- package/src/validateCanvas.js +26 -0
|
@@ -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
|
+
}
|