@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
package/src/geometry.js
ADDED
|
@@ -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
|
+
}
|