@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,326 @@
1
+ // Free-canvas geometry — where an item is, and what moving or resizing one
2
+ // does. Pure, so the arithmetic that actually goes wrong is testable without
3
+ // a mouse. No React, no DOM.
4
+ //
5
+ // Moved here from the BI dashboard builder so every canvas — dashboard,
6
+ // workflow, dataset joins, the UI creator — lands on the same rules.
7
+ //
8
+ // Free placement rather than a snap grid WAS a deliberate choice: a board is
9
+ // composed, not filled. The cost is that "roughly aligned" is the natural
10
+ // result of dragging, and a board of nearly-aligned edges looks broken in a
11
+ // way a grid never does.
12
+ //
13
+ // BOTH NOW, and in that order. The grid came back because composing a unit out
14
+ // of five items needs consistent widths and gaps, which free placement makes
15
+ // you achieve by eye every time. It is the FALLBACK though: alignment snapping
16
+ // is tried first and wins, because lining up with the box beside you is a
17
+ // stronger intent than landing on a round number. See GRID below.
18
+ //
19
+ // So the canvas offers ALIGNMENT SNAPPING first — while you drag, an edge or
20
+ // centre that comes within a few pixels of another item's edge or centre
21
+ // jumps to it exactly, and the guide is drawn so you can see why it moved.
22
+
23
+ // ── FRACTIONAL geometry ───────────────────────────────────────────────
24
+ //
25
+ // An item may store where it sits as a FRACTION of the canvas (0..1) rather
26
+ // than as pixels (units: 'fraction'). A board laid out on a 27-inch screen
27
+ // then still reads on a laptop.
28
+ //
29
+ // The two axes are NOT the same fraction, and the difference is the point.
30
+ //
31
+ // x and w are a fraction of the canvas WIDTH — so a board laid out wide
32
+ // still reads narrow, and nothing hangs off the right edge.
33
+ // y and h are a fraction of a PAGE, where one page is the visible height.
34
+ // y may exceed 1: 1.5 is half a page below the fold.
35
+ //
36
+ // Horizontal space is fixed by the window and vertical space is not — you
37
+ // scroll down, you do not scroll sideways.
38
+ //
39
+ // Dragging still works in PIXELS, because a mouse does. The conversion
40
+ // happens once, when the position is committed.
41
+
42
+ // An item's floor.
43
+ //
44
+ // What the floor is for is keeping an item SELECTABLE — a box you cannot get
45
+ // hold of cannot be resized back, or deleted. So it is set to the smallest
46
+ // thing you can still reliably grab, and no larger. A caller with smaller
47
+ // things (a one-pixel rule) passes its own via minSizeFor.
48
+ export const MIN_WIDTH = 48
49
+ export const MIN_HEIGHT = 28
50
+
51
+ /** How close an edge has to come before it snaps. */
52
+ export const SNAP_TOLERANCE = 6
53
+
54
+ /**
55
+ * The background grid, in px. A canvas that draws a grid should draw it at
56
+ * this spacing, so what you see and what you land on are the same.
57
+ *
58
+ * ALIGNMENT SNAPPING STILL WINS. The grid is the fallback for an axis that hit
59
+ * nothing: a grid that overrode alignment would pull an item off the edge it
60
+ * was just aligned to.
61
+ */
62
+ export const GRID = 10
63
+ export const DEFAULT_SIZE = { w: 420, h: 300 }
64
+
65
+ /** The eight handles, as [x, y] multipliers of the box's size. */
66
+ export const RESIZE_HANDLES = [
67
+ { key: 'nw', x: 0, y: 0 }, { key: 'n', x: 0.5, y: 0 }, { key: 'ne', x: 1, y: 0 },
68
+ { key: 'w', x: 0, y: 0.5 }, { key: 'e', x: 1, y: 0.5 },
69
+ { key: 'sw', x: 0, y: 1 }, { key: 's', x: 0.5, y: 1 }, { key: 'se', x: 1, y: 1 }
70
+ ]
71
+
72
+ /** A pixel item's rect, with defaults for anything unset. */
73
+ export function rectOf(item) {
74
+ return { x: item.x || 0, y: item.y || 0, w: item.w || DEFAULT_SIZE.w, h: item.h || DEFAULT_SIZE.h }
75
+ }
76
+
77
+ /**
78
+ * An item's rect in PIXELS whatever it is stored in — the one conversion the
79
+ * drag, the marquee and the edges all need.
80
+ *
81
+ * A fraction item with no measured canvas yet falls back to its raw values:
82
+ * the same thing the dashboard always did before its canvas was measured.
83
+ */
84
+ export function pixelRect(item, units, canvas) {
85
+ return units === 'fraction' && canvas ? toPixels(item, canvas) : rectOf(item)
86
+ }
87
+
88
+ /** The lines a rect can snap to: both edges and the centre, per axis. */
89
+ function linesOf(rect) {
90
+ return {
91
+ x: [rect.x, rect.x + rect.w / 2, rect.x + rect.w],
92
+ y: [rect.y, rect.y + rect.h / 2, rect.y + rect.h]
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The nearest snap for one axis.
98
+ *
99
+ * `candidates` are the moving rect's own lines — so a right edge snapping to
100
+ * another item's left edge moves the whole box, not just that edge.
101
+ *
102
+ * @returns {{ delta, line }|null} delta — add to the origin; line — where to
103
+ * draw the guide
104
+ */
105
+ function nearestSnap(candidates, targets, tolerance) {
106
+ let best = null
107
+ for (const c of candidates) {
108
+ for (const t of targets) {
109
+ const distance = Math.abs(c.at - t)
110
+ if (distance > tolerance) continue
111
+ if (!best || distance < best.distance) best = { distance, delta: t - c.at, line: t }
112
+ }
113
+ }
114
+ return best ? { delta: best.delta, line: best.line } : null
115
+ }
116
+
117
+ /**
118
+ * Moves a rect to (x, y), snapping to the others.
119
+ *
120
+ * @param others the rects to snap against — every item except this one
121
+ * @returns {{ x, y, guides }} guides — [{ axis, at }] for the ones that hit
122
+ */
123
+ export function moveRect(rect, x, y, others, opts) {
124
+ const tolerance = opts?.tolerance ?? SNAP_TOLERANCE
125
+ // Held down, snapping is off: the point of a free canvas is that you can
126
+ // always overrule it.
127
+ const free = Boolean(opts?.disableSnap)
128
+ const moved = { x, y, w: rect.w, h: rect.h }
129
+ const guides = []
130
+
131
+ if (!free && others.length) {
132
+ const targets = others.map(linesOf)
133
+ const targetX = targets.flatMap((t) => t.x)
134
+ const targetY = targets.flatMap((t) => t.y)
135
+
136
+ const snapX = nearestSnap(
137
+ [{ at: moved.x }, { at: moved.x + moved.w / 2 }, { at: moved.x + moved.w }], targetX, tolerance)
138
+ const snapY = nearestSnap(
139
+ [{ at: moved.y }, { at: moved.y + moved.h / 2 }, { at: moved.y + moved.h }], targetY, tolerance)
140
+
141
+ if (snapX) { moved.x += snapX.delta; guides.push({ axis: 'x', at: snapX.line }) }
142
+ if (snapY) { moved.y += snapY.delta; guides.push({ axis: 'y', at: snapY.line }) }
143
+
144
+ // Only where nothing better was found — see GRID.
145
+ const grid = opts?.grid ?? GRID
146
+ if (grid > 0) {
147
+ if (!snapX) moved.x = Math.round(moved.x / grid) * grid
148
+ if (!snapY) moved.y = Math.round(moved.y / grid) * grid
149
+ }
150
+ }
151
+
152
+ // Negative coordinates put an item where no scrollbar can reach it.
153
+ return { x: Math.max(0, Math.round(moved.x)), y: Math.max(0, Math.round(moved.y)), guides }
154
+ }
155
+
156
+ /**
157
+ * Resizes from one handle, keeping the opposite edge fixed.
158
+ *
159
+ * Below the minimum the box stops growing INWARD rather than flipping: a box
160
+ * dragged past its own opposite edge would otherwise invert, and a negative
161
+ * width is not a shape anything can render.
162
+ */
163
+ export function resizeRect(rect, handle, dx, dy, opts) {
164
+ const min = { w: opts?.minWidth ?? MIN_WIDTH, h: opts?.minHeight ?? MIN_HEIGHT }
165
+ const h = RESIZE_HANDLES.find((k) => k.key === handle)
166
+ if (!h) return { ...rect }
167
+
168
+ let { x, y, w, hgt } = { x: rect.x, y: rect.y, w: rect.w, hgt: rect.h }
169
+
170
+ if (h.x === 0) { // dragging the left edge: the right stays put
171
+ const right = x + w
172
+ x = Math.min(x + dx, right - min.w)
173
+ w = right - x
174
+ } else if (h.x === 1) {
175
+ w = Math.max(min.w, w + dx)
176
+ }
177
+
178
+ if (h.y === 0) { // dragging the top edge: the bottom stays put
179
+ const bottom = y + hgt
180
+ y = Math.min(y + dy, bottom - min.h)
181
+ hgt = bottom - y
182
+ } else if (h.y === 1) {
183
+ hgt = Math.max(min.h, hgt + dy)
184
+ }
185
+
186
+ // SNAPPING WHILE RESIZING, and only on the edges actually being dragged.
187
+ //
188
+ // A resize holds one edge still by definition — grabbing the right handle
189
+ // must never shift the left — so only the moving edge is offered as a
190
+ // candidate. Snapping the fixed one would drag the box sideways while you
191
+ // were trying to make it wider.
192
+ const guides = []
193
+ const others = opts?.others
194
+ if (others && others.length && !opts?.disableSnap) {
195
+ const tolerance = opts?.tolerance ?? SNAP_TOLERANCE
196
+ const targets = others.map(linesOf)
197
+ const targetX = targets.flatMap((t) => t.x)
198
+ const targetY = targets.flatMap((t) => t.y)
199
+
200
+ if (h.x === 0) {
201
+ const snap = nearestSnap([{ at: x }], targetX, tolerance)
202
+ if (snap) { const right = x + w; x += snap.delta; w = right - x; guides.push({ axis: 'x', at: snap.line }) }
203
+ } else if (h.x === 1) {
204
+ const snap = nearestSnap([{ at: x + w }], targetX, tolerance)
205
+ if (snap) { w += snap.delta; guides.push({ axis: 'x', at: snap.line }) }
206
+ }
207
+
208
+ if (h.y === 0) {
209
+ const snap = nearestSnap([{ at: y }], targetY, tolerance)
210
+ if (snap) { const bottom = y + hgt; y += snap.delta; hgt = bottom - y; guides.push({ axis: 'y', at: snap.line }) }
211
+ } else if (h.y === 1) {
212
+ const snap = nearestSnap([{ at: y + hgt }], targetY, tolerance)
213
+ if (snap) { hgt += snap.delta; guides.push({ axis: 'y', at: snap.line }) }
214
+ }
215
+ }
216
+
217
+ return {
218
+ x: Math.max(0, Math.round(x)),
219
+ y: Math.max(0, Math.round(y)),
220
+ w: Math.round(Math.max(min.w, w)),
221
+ h: Math.round(Math.max(min.h, hgt)),
222
+ guides
223
+ }
224
+ }
225
+
226
+ /**
227
+ * The canvas has to be at least big enough to hold everything on it.
228
+ *
229
+ * @param padding a number, or { x, y } when the room wanted to the right
230
+ * differs from the room wanted below
231
+ */
232
+ export function canvasBounds(rects, padding) {
233
+ const pad = padding ?? 80
234
+ const padX = typeof pad === 'object' ? pad.x || 0 : pad
235
+ const padY = typeof pad === 'object' ? pad.y || 0 : pad
236
+ let width = 0
237
+ let height = 0
238
+ for (const item of rects || []) {
239
+ const r = rectOf(item)
240
+ width = Math.max(width, r.x + r.w)
241
+ height = Math.max(height, r.y + r.h)
242
+ }
243
+ return { width: width + padX, height: height + padY }
244
+ }
245
+
246
+ /**
247
+ * Raises `id` above the rest.
248
+ *
249
+ * Renumbered from zero rather than "max + 1" so z never climbs forever — a
250
+ * board that is rearranged for a year would otherwise carry meaningless
251
+ * five-digit z values, and their ORDER is the only thing that matters.
252
+ */
253
+ export function bringToFront(items, id) {
254
+ const others = (items || []).filter((item) => item.id !== id)
255
+ const target = (items || []).find((item) => item.id === id)
256
+ if (!target) return items || []
257
+ const ordered = [...others].sort((a, b) => (a.z || 0) - (b.z || 0))
258
+ const renumbered = ordered.map((item, i) => ({ ...item, z: i }))
259
+ return [...renumbered, { ...target, z: renumbered.length }]
260
+ }
261
+
262
+ /** Items in paint order — the one on top renders last. */
263
+ export function inStackOrder(items) {
264
+ return [...(items || [])].sort((a, b) => (a.z || 0) - (b.z || 0))
265
+ }
266
+
267
+ /** A new item: a bit under half the width, a third of the height. */
268
+ export const DEFAULT_FRACTION = { w: 0.45, h: 0.35 }
269
+
270
+ /** A pixel rect → fractions of the canvas, clamped to stay on it. */
271
+ export function toFraction(rect, canvas) {
272
+ const cw = canvas?.width || 1
273
+ const ch = canvas?.height || 1
274
+ // Size first: the position clamp below depends on it.
275
+ const w = clamp01((rect.w || 0) / cw)
276
+ // An item may be TALLER than one page — a long table is a reasonable thing
277
+ // to place — so height is not capped at 1 either.
278
+ const h = Math.max(0, (rect.h || 0) / ch)
279
+ return {
280
+ // x + w never exceeds 1, so an item cannot be placed where the canvas
281
+ // does not reach sideways — which with fractions would be nowhere at all.
282
+ x: clamp(0, 1 - w, (rect.x || 0) / cw),
283
+ // NOT clamped to one page. The canvas grows downward to hold whatever is
284
+ // placed on it.
285
+ y: Math.max(0, (rect.y || 0) / ch),
286
+ w, h
287
+ }
288
+ }
289
+
290
+ /** Fractions → a pixel rect on the canvas. */
291
+ export function toPixels(fraction, canvas) {
292
+ const cw = canvas?.width || 0
293
+ const ch = canvas?.height || 0
294
+ return {
295
+ x: Math.round((fraction.x || 0) * cw),
296
+ y: Math.round((fraction.y || 0) * ch),
297
+ // An item with no stored size gets a readable default rather than
298
+ // collapsing to nothing.
299
+ w: Math.round((fraction.w || DEFAULT_FRACTION.w) * cw),
300
+ h: Math.round((fraction.h || DEFAULT_FRACTION.h) * ch)
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Every rect the band touches, by id.
306
+ *
307
+ * TOUCHES, not encloses. Requiring full containment means a band drawn across
308
+ * a row of wide cards selects nothing, and the user has to start outside the
309
+ * board and sweep the lot — which is the behaviour people complain about in
310
+ * every tool that chooses it.
311
+ *
312
+ * @param rects [{ id, x, y, w, h }] in pixels
313
+ */
314
+ export function hits(rects, band) {
315
+ return (rects || []).filter((r) => overlaps(r, band)).map((r) => r.id)
316
+ }
317
+
318
+ export function overlaps(a, b) {
319
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
320
+ }
321
+
322
+ function clamp01(v) { return clamp(0, 1, v) }
323
+ function clamp(min, max, v) {
324
+ if (!Number.isFinite(v)) return min
325
+ return Math.min(max, Math.max(min, v))
326
+ }
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ // @xeplr/ui-canvas — one canvas for every xeplr builder.
2
+ //
3
+ // Three ways in, per the xeplr-ui-* convention:
4
+ // 1. <XeplrCanvas> as-is
5
+ // 2. the controller hooks with your own design (the BI dashboard does this)
6
+ // 3. the model alone — '@xeplr/ui-canvas/geometry' and '/edges' have no
7
+ // React, so they run in node tests too
8
+
9
+ // Model
10
+ export {
11
+ MIN_WIDTH, MIN_HEIGHT, SNAP_TOLERANCE, GRID, DEFAULT_SIZE, DEFAULT_FRACTION, RESIZE_HANDLES,
12
+ rectOf, pixelRect, moveRect, resizeRect, canvasBounds, bringToFront, inStackOrder,
13
+ toFraction, toPixels, hits, overlaps
14
+ } from './geometry.js'
15
+ export { anchorOf, bezierPath, midpoint, edgeEnds } from './edges.js'
16
+ export { createFrameStore } from './frameStore.js'
17
+ export { validateCanvasProps } from './validateCanvas.js'
18
+
19
+ // Controllers
20
+ export { useCanvasDrag, ITEM_ID_ATTR } from './useCanvasDrag.js'
21
+ export { useMarquee, SWEEPING_CLASS } from './useMarquee.js'
22
+ export { useCanvasSize } from './useCanvasSize.js'
23
+ export { useCanvasController, DEFAULT_FEATURES } from './useCanvasController.js'
24
+
25
+ // Designs
26
+ export { CanvasSample, DragGuides, MarqueeBox, ResizeHandles, EdgeLayer } from './designs/index.js'
27
+
28
+ // Ready-made
29
+ export { XeplrCanvas } from './pages.jsx'
package/src/pages.jsx ADDED
@@ -0,0 +1,48 @@
1
+ import { useCanvasController } from './useCanvasController.js'
2
+ import { validateCanvasProps } from './validateCanvas.js'
3
+ import { CanvasSample } from './designs/index.js'
4
+
5
+ /**
6
+ * The ready-made canvas: controller + design.
7
+ *
8
+ * One component, every capability; a builder switches on what it needs.
9
+ *
10
+ * dashboard / UI creator features={{ resize: true, marquee: true }}
11
+ * workflow / dataset edges={[...]} (drag and snap are on by default)
12
+ *
13
+ * @prop items [{ id, x, y, w, h, z?, groupId? }]
14
+ * @prop units 'px' (default) | 'fraction'
15
+ * @prop features { drag, resize, snap, grid, marquee } — see DEFAULT_FEATURES
16
+ * @prop selection ids (array or Set) — controlled; omit to let the canvas own it
17
+ * @prop onSelectionChange(Set)
18
+ * @prop onItemChange (id, patch) — on drop, in the item's own units
19
+ * @prop onItemClick (id, event) — a press that never became a drag
20
+ * @prop onRaise (id) — on grab
21
+ * @prop renderItem (item, { selected }) → node — REQUIRED
22
+ * @prop minSizeFor (item) → { minWidth, minHeight }
23
+ * @prop edges [{ id, from, to | toOffset, fromPort?, toPort?, variant?, className? }]
24
+ * @prop getAnchor (item, port, 'from'|'to', rect) → { x, y } — for port edges
25
+ * @prop renderEdgeLabel (edge, { from, to, mid }) → node — absolutely positioned by you
26
+ * @prop edgeMinOffset minimum sideways reach of an edge's curve, px (default 60)
27
+ * @prop padding room beyond the furthest item: number or { x, y }
28
+ * @prop minWidth, minHeight the smallest the inner canvas gets
29
+ * @prop underlay, overlay extra layers under the edges / over everything
30
+ * @prop className, style on the scrolling wrapper
31
+ */
32
+ export function XeplrCanvas(props) {
33
+ validateCanvasProps(props)
34
+ const ctrl = useCanvasController(props)
35
+ return (
36
+ <CanvasSample
37
+ ctrl={ctrl}
38
+ renderItem={props.renderItem}
39
+ getAnchor={props.getAnchor}
40
+ renderEdgeLabel={props.renderEdgeLabel}
41
+ edgeMinOffset={props.edgeMinOffset}
42
+ underlay={props.underlay}
43
+ overlay={props.overlay}
44
+ className={props.className}
45
+ style={props.style}
46
+ />
47
+ )
48
+ }
@@ -0,0 +1,181 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react'
2
+ import { GRID, canvasBounds, inStackOrder, pixelRect, toFraction } from './geometry.js'
3
+ import { useCanvasDrag } from './useCanvasDrag.js'
4
+ import { useMarquee } from './useMarquee.js'
5
+ import { useCanvasSize } from './useCanvasSize.js'
6
+
7
+ // The ready-made canvas's controller: features, selection and the gestures,
8
+ // wired together. No JSX — XeplrCanvas (pages.jsx) hands this to a design.
9
+ //
10
+ // A builder with a selection model of its own (the BI dashboard: groups you
11
+ // click into, a property drawer) uses useCanvasDrag / useMarquee directly
12
+ // instead, and keeps its own. This is the version for a builder that just
13
+ // wants a canvas.
14
+
15
+ export const DEFAULT_FEATURES = {
16
+ drag: true,
17
+ resize: false,
18
+ snap: true,
19
+ grid: GRID,
20
+ marquee: false
21
+ }
22
+
23
+ /** Below this, a press on an item is a click and not a drag. */
24
+ const CLICK_THRESHOLD = 4
25
+
26
+ export function useCanvasController(props) {
27
+ const {
28
+ items, units = 'px', features, edges, selection,
29
+ onItemClick, onRaise, minSizeFor,
30
+ padding, minWidth = 0, minHeight = 0
31
+ } = props
32
+ // onItemChange and onSelectionChange are read through propsRef below, so a
33
+ // new inline handler from the caller never rebuilds the gestures.
34
+
35
+ const f = useMemo(() => ({ ...DEFAULT_FEATURES, ...features }), [features])
36
+ const fraction = units === 'fraction'
37
+
38
+ // Measured only for a fractional canvas, where a page IS the visible size.
39
+ const [measured, measureRef] = useCanvasSize()
40
+ const canvas = fraction ? measured : null
41
+ const canvasRef = useRef(canvas); canvasRef.current = canvas
42
+ const rootRef = useRef(null)
43
+
44
+ // SELECTION — controlled when `selection` is passed, owned here otherwise.
45
+ const [ownSelection, setOwnSelection] = useState(() => new Set())
46
+ const selected = useMemo(
47
+ () => (selection ? new Set(selection) : ownSelection),
48
+ [selection, ownSelection]
49
+ )
50
+ // Read through refs by the handlers, so they stay stable.
51
+ const selectedRef = useRef(selected); selectedRef.current = selected
52
+ const itemsRef = useRef(items); itemsRef.current = items
53
+ const propsRef = useRef(props); propsRef.current = props
54
+
55
+ const setSelected = useCallback((next) => {
56
+ if (!propsRef.current.selection) setOwnSelection(next)
57
+ propsRef.current.onSelectionChange?.(next)
58
+ }, [])
59
+
60
+ // A GROUP IS ONE THING: touching any member takes the whole group.
61
+ const withGroups = useCallback((ids) => {
62
+ const all = itemsRef.current
63
+ const picked = new Set()
64
+ ids.forEach((id) => {
65
+ const item = all.find((x) => x.id === id)
66
+ if (item?.groupId) all.filter((x) => x.groupId === item.groupId).forEach((x) => picked.add(x.id))
67
+ else picked.add(id)
68
+ })
69
+ return picked
70
+ }, [])
71
+
72
+ // Pixels in, the item's own units out.
73
+ const commit = useCallback((id, patch) => {
74
+ const { onItemChange: write, units: u } = propsRef.current
75
+ if (!write) return
76
+ if (u !== 'fraction') return write(id, patch)
77
+ const current = itemsRef.current.find((w) => w.id === id)
78
+ if (!current || !canvasRef.current) return write(id, patch)
79
+ write(id, toFraction({ ...pixelRect(current, 'fraction', canvasRef.current), ...patch }, canvasRef.current))
80
+ }, [])
81
+
82
+ const drag = useCanvasDrag({
83
+ items,
84
+ units,
85
+ canvas,
86
+ rootRef,
87
+ onUpdate: commit,
88
+ onRaise,
89
+ onClick: onItemClick,
90
+ minSizeFor,
91
+ snap: f.snap,
92
+ grid: f.grid,
93
+ clickThreshold: CLICK_THRESHOLD
94
+ })
95
+ const dragRef = useRef(drag); dragRef.current = drag
96
+
97
+ const marquee = useMarquee({
98
+ items,
99
+ units,
100
+ canvas,
101
+ canvasElRef: rootRef,
102
+ onSelect: useCallback((ids, additive) => {
103
+ const picked = withGroups(ids)
104
+ if (!additive) return setSelected(picked)
105
+ const next = new Set(selectedRef.current)
106
+ picked.forEach((id) => next.add(id))
107
+ setSelected(next)
108
+ }, [withGroups, setSelected]),
109
+ onClear: useCallback(() => {
110
+ if (selectedRef.current.size) setSelected(new Set())
111
+ }, [setSelected])
112
+ })
113
+
114
+ // PRESS ON AN ITEM. Modifier toggles it in the selection; otherwise it
115
+ // drags — the whole selection when the item is part of one, else the item
116
+ // (and its group) alone.
117
+ const onItemMouseDown = useCallback((e, id) => {
118
+ if (e.button !== 0) return
119
+ const modifier = e.ctrlKey || e.metaKey || e.shiftKey
120
+ const members = [...withGroups([id])]
121
+ const current = selectedRef.current
122
+
123
+ if (modifier) {
124
+ const next = new Set(current)
125
+ const allIn = members.every((m) => next.has(m))
126
+ members.forEach((m) => (allIn ? next.delete(m) : next.add(m)))
127
+ setSelected(next)
128
+ return
129
+ }
130
+
131
+ const ids = current.has(id) && current.size > 1 ? [...current] : members
132
+ const same = ids.length === current.size && ids.every((x) => current.has(x))
133
+ if (!same) setSelected(new Set(ids))
134
+ if (propsRef.current.features?.drag === false) return
135
+ dragRef.current.startDrag(e, ids)
136
+ }, [withGroups, setSelected])
137
+
138
+ const onResizeStart = useCallback((e, id, handle) => {
139
+ dragRef.current.startResize(e, id, handle)
140
+ }, [])
141
+
142
+ // Pixel rects, by id — what the items and edges are drawn from.
143
+ const rectById = useMemo(() => {
144
+ const map = {}
145
+ items.forEach((item) => { map[item.id] = pixelRect(item, units, canvas) })
146
+ return map
147
+ }, [items, units, canvas])
148
+
149
+ const itemById = useMemo(() => {
150
+ const map = {}
151
+ items.forEach((item) => { map[item.id] = item })
152
+ return map
153
+ }, [items])
154
+
155
+ // How big the inner canvas is: enough for everything on it, never less
156
+ // than the minimum. A fractional canvas is at least one page.
157
+ const size = useMemo(() => {
158
+ const bounds = canvasBounds(Object.values(rectById), padding)
159
+ if (fraction) {
160
+ return { width: '100%', height: Math.max(canvas ? canvas.height : 0, bounds.height, minHeight) }
161
+ }
162
+ return { width: Math.max(minWidth, bounds.width), height: Math.max(minHeight, bounds.height) }
163
+ }, [rectById, padding, fraction, canvas, minWidth, minHeight])
164
+
165
+ return {
166
+ rootRef,
167
+ measureRef,
168
+ features: f,
169
+ stacked: useMemo(() => inStackOrder(items), [items]),
170
+ rectById,
171
+ itemById,
172
+ edges: edges || [],
173
+ selected,
174
+ size,
175
+ guideStore: drag.guideStore,
176
+ marqueeStore: marquee.marqueeStore,
177
+ onCanvasMouseDown: f.marquee ? marquee.onMouseDown : undefined,
178
+ onItemMouseDown,
179
+ onResizeStart
180
+ }
181
+ }