@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xeplr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # @xeplr/ui-canvas
2
+
3
+ **One free-placement canvas for every builder.** Drag, resize, alignment guides, snapping, marquee select and edges — in a single React package, so a dashboard designer, a workflow editor, a join diagram and a form builder all feel like the same product.
4
+
5
+ (The package name on npm is `@xeplr/ui-canvas` — the GitHub repo and folder are named `xeplr-ui-canvas`.)
6
+
7
+ ## Why
8
+
9
+ Every builder ends up writing its own canvas: mousedown, window listeners, apply the delta, clean up. Then one of them gets snapping, another gets a marquee, a third draws bezier edges, and none of them agree on what a drag feels like.
10
+
11
+ This package is the superset. It has every capability once, and each builder switches on the ones it needs:
12
+
13
+ | Builder | drag | resize | guides & snap | marquee & groups | edges |
14
+ |---|:-:|:-:|:-:|:-:|:-:|
15
+ | Dashboard / page designer | ✓ | ✓ | ✓ | ✓ | |
16
+ | Workflow / flow editor | ✓ | | ✓ | | ✓ |
17
+ | Join diagram (column ports) | ✓ | | ✓ | | ✓ |
18
+ | Form / UI builder | ✓ | ✓ | ✓ | ✓ | |
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm i @xeplr/ui-canvas
24
+ ```
25
+
26
+ Peer dependency: `react ^18 || ^19`. No other runtime dependencies.
27
+
28
+ ## Quick start — boxes you can move and resize
29
+
30
+ ```jsx
31
+ import { useState } from 'react'
32
+ import { XeplrCanvas } from '@xeplr/ui-canvas'
33
+
34
+ export function Board() {
35
+ const [items, setItems] = useState([
36
+ { id: 'sales', x: 40, y: 40, w: 320, h: 200 },
37
+ { id: 'orders', x: 400, y: 40, w: 320, h: 200 }
38
+ ])
39
+
40
+ return (
41
+ <XeplrCanvas
42
+ style={{ height: 600 }}
43
+ items={items}
44
+ features={{ resize: true, marquee: true }}
45
+ onItemChange={(id, patch) =>
46
+ setItems((all) => all.map((it) => (it.id === id ? { ...it, ...patch } : it)))
47
+ }
48
+ renderItem={(item, { selected }) => <div className="card">{item.id}</div>}
49
+ />
50
+ )
51
+ }
52
+ ```
53
+
54
+ ## Quick start — nodes joined by edges
55
+
56
+ ```jsx
57
+ <XeplrCanvas
58
+ items={steps} // [{ id, x, y, w, h }]
59
+ edges={[
60
+ { id: 'a-b', from: 'fetch', to: 'transform' },
61
+ { id: 'b-c', from: 'transform', to: 'load', variant: 'dashed' },
62
+ { id: 'c-end', from: 'load', toOffset: { x: 130, y: 0 }, done: true }
63
+ ]}
64
+ onItemChange={(id, { x, y }) => moveStep(id, x, y)}
65
+ onItemClick={(id) => openStep(id)} // a press that never became a drag
66
+ renderItem={(step, { selected }) => <StepCard step={step} selected={selected} />}
67
+ renderEdgeLabel={(edge, { to }) =>
68
+ edge.done ? <span className="pill" style={{ position: 'absolute', left: to.x, top: to.y }}>Done</span> : null
69
+ }
70
+ />
71
+ ```
72
+
73
+ Edges follow their node **while** it is being dragged, not only after the drop.
74
+
75
+ ## What you get
76
+
77
+ - **Drag without re-renders.** A drag writes a CSS transform straight onto the moving element, one frame at a time. React hears about the new position exactly once, on drop — so a board of 300 items stays smooth.
78
+ - **Alignment guides.** An edge or centre that comes within 6px of another item's edge or centre snaps to it, and a guide line shows why it jumped. When nothing is close, it falls back to a 10px grid. Hold **Alt** to place freely.
79
+ - **Resize from eight handles**, with snapping on the edge being dragged and a per-item minimum size (`minSizeFor`).
80
+ - **Marquee select** that takes everything it *touches*, not only what it encloses — so it also reaches items hidden under others. Ctrl/Cmd/Shift adds to the selection.
81
+ - **Groups.** Items sharing a `groupId` select and move as one.
82
+ - **Edges.** Bezier curves with arrowheads, dashed variant, labels, stubs to non-item targets (`toOffset`), and port anchors inside an item (`getAnchor`).
83
+ - **Pixel or fractional units.** `units="fraction"` stores `x`/`w` as fractions of the width and `y`/`h` as fractions of the visible height, so a layout made on a large screen still reads on a laptop.
84
+ - **Fails loudly.** A missing `renderItem`, or an item with no id or a duplicate id, throws a clear error instead of rendering a board that half works.
85
+
86
+ ## `<XeplrCanvas>` props
87
+
88
+ | prop | type | default | notes |
89
+ |---|---|---|---|
90
+ | `items` | `Array<{ id, x, y, w, h, z?, groupId? }>` | required | `z` sets paint order |
91
+ | `renderItem` | `(item, { selected }) => ReactNode` | required | the item's content; the canvas positions it |
92
+ | `units` | `'px' \| 'fraction'` | `'px'` | see above |
93
+ | `features` | `{ drag, resize, snap, grid, marquee }` | `{ drag: true, resize: false, snap: true, grid: 10, marquee: false }` | merged over the defaults |
94
+ | `onItemChange` | `(id, patch) => void` | — | once per item, on drop, in the item's own units |
95
+ | `onItemClick` | `(id, event) => void` | — | a press that moved less than 4px |
96
+ | `onRaise` | `(id) => void` | — | when an item is grabbed — e.g. `bringToFront` |
97
+ | `selection` | `Array<id> \| Set<id>` | — | controlled selection; omit to let the canvas own it |
98
+ | `onSelectionChange` | `(Set<id>) => void` | — | |
99
+ | `minSizeFor` | `(item) => { minWidth, minHeight }` | 48 × 28 | per-item resize floor |
100
+ | `edges` | `Array<Edge>` | `[]` | see below |
101
+ | `getAnchor` | `(item, port, 'from' \| 'to', rect) => { x, y }` | right / left middle | for edges that attach to ports |
102
+ | `renderEdgeLabel` | `(edge, { from, to, mid }) => ReactNode` | — | absolutely position it yourself |
103
+ | `edgeMinOffset` | number | `60` | minimum sideways reach of a curve |
104
+ | `padding` | `number \| { x, y }` | `80` | room beyond the furthest item (px units) |
105
+ | `minWidth`, `minHeight` | number | `0` | smallest the canvas gets |
106
+ | `underlay`, `overlay` | ReactNode | — | extra layers under the edges / over everything |
107
+ | `className`, `style` | | — | on the scrolling wrapper |
108
+
109
+ ### Edge
110
+
111
+ | field | notes |
112
+ |---|---|
113
+ | `id` | required |
114
+ | `from` | item id |
115
+ | `to` *or* `toOffset` | item id, or `{ x, y }` relative to where the edge leaves |
116
+ | `fromPort`, `toPort` | passed to `getAnchor` |
117
+ | `fromSide`, `toSide` | `'left' \| 'right' \| 'top' \| 'bottom'` when not using `getAnchor` |
118
+ | `variant` | `'dashed'` |
119
+ | `className` | extra class on the path |
120
+ | `arrow` | `false` to hide the arrowhead |
121
+
122
+ Anything else you put on an edge comes back to you in `renderEdgeLabel`.
123
+
124
+ ## Three ways to use it
125
+
126
+ Like every `@xeplr/ui-*` package, it is split into model, controller and design, so you take as much as you want:
127
+
128
+ 1. **Ready-made** — `<XeplrCanvas>` as above.
129
+ 2. **Your own design** — the hooks, with your own markup. Use this when your builder already has a selection model of its own.
130
+
131
+ ```jsx
132
+ import { useCanvasDrag, useMarquee, useCanvasSize, DragGuides, MarqueeBox, ResizeHandles } from '@xeplr/ui-canvas'
133
+
134
+ const drag = useCanvasDrag({ items, units: 'fraction', canvas, rootRef, onUpdate })
135
+ // on an item: <div data-canvas-item-id={item.id} onMouseDown={(e) => drag.startDrag(e, [item.id])}>
136
+ // on a handle: <ResizeHandles onStart={(e, handle) => drag.startResize(e, item.id, handle)} />
137
+ // in the canvas: <DragGuides store={drag.guideStore} />
138
+ ```
139
+
140
+ 3. **Geometry only** — no React at all, runs in Node:
141
+
142
+ ```js
143
+ import { moveRect, resizeRect, hits, toFraction } from '@xeplr/ui-canvas/geometry'
144
+ import { bezierPath, anchorOf } from '@xeplr/ui-canvas/edges'
145
+ ```
146
+
147
+ ## Styling
148
+
149
+ Everything is namespaced `.xeplr-canvas-*`, and colours come from the xeplr theme variables with fallbacks: `--xeplr-accent` for guides, handles and the marquee, and `--xeplr-border-strong` for edges. Set those variables to restyle, or override the classes:
150
+
151
+ | class | what |
152
+ |---|---|
153
+ | `.xeplr-canvas` / `.xeplr-canvas-inner` | scrolling wrapper / sized canvas |
154
+ | `.xeplr-canvas-item` (`.is-selected`) | the positioned box around each item |
155
+ | `.xeplr-canvas-edge` (`--dashed`), `.xeplr-canvas-edge-arrow` | edges |
156
+ | `.xeplr-canvas-guide-x` / `-y` | alignment guides |
157
+ | `.xeplr-canvas-handle-{nw,n,ne,w,e,sw,s,se}` | resize handles |
158
+ | `.xeplr-canvas-marquee` | the selection band |
159
+
160
+ ## Exports
161
+
162
+ - **Component:** `XeplrCanvas`
163
+ - **Hooks:** `useCanvasController`, `useCanvasDrag`, `useMarquee`, `useCanvasSize`
164
+ - **Designs:** `CanvasSample`, `DragGuides`, `MarqueeBox`, `ResizeHandles`, `EdgeLayer`
165
+ - **Geometry:** `moveRect`, `resizeRect`, `rectOf`, `pixelRect`, `canvasBounds`, `bringToFront`, `inStackOrder`, `toFraction`, `toPixels`, `hits`, `overlaps`, `RESIZE_HANDLES`, `GRID`, `SNAP_TOLERANCE`, `MIN_WIDTH`, `MIN_HEIGHT`, `DEFAULT_SIZE`, `DEFAULT_FRACTION`
166
+ - **Edges:** `bezierPath`, `anchorOf`, `midpoint`, `edgeEnds`
167
+ - **Other:** `createFrameStore`, `validateCanvasProps`, `DEFAULT_FEATURES`, `ITEM_ID_ATTR`, `SWEEPING_CLASS`
168
+
169
+ ## Files
170
+
171
+ ```
172
+ src/
173
+ index.js ─ public exports
174
+ pages.jsx ─ XeplrCanvas (controller + design)
175
+ geometry.js ─ move, resize, snap, stack, fractions, hit-testing
176
+ edges.js ─ anchors and bezier paths
177
+ frameStore.js ─ per-frame store the overlays subscribe to
178
+ validateCanvas.js ─ loud failure on a mis-wired canvas
179
+ useCanvasController.js ─ features, selection, groups
180
+ useCanvasDrag.js ─ drag and resize pointer lifecycle
181
+ useMarquee.js ─ marquee select
182
+ useCanvasSize.js ─ measured canvas size
183
+ designs/
184
+ CanvasSample.jsx · EdgeLayer.jsx · DragGuides.jsx · MarqueeBox.jsx · ResizeHandles.jsx
185
+ canvas.css
186
+ ```
187
+
188
+ ## Tests
189
+
190
+ ```sh
191
+ npm test
192
+ ```
193
+
194
+ Geometry, snapping, resize limits, fractions, marquee hits and edge paths are covered by plain Node scripts, with no test framework.
195
+
196
+ ## License
197
+
198
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@xeplr/ui-canvas",
3
+ "version": "1.0.1",
4
+ "description": "One free-placement canvas for every xeplr builder: drag, resize, alignment guides, snapping, marquee select and edges — React controller hooks, pure geometry and a ready-made <XeplrCanvas>",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./geometry": "./src/geometry.js",
10
+ "./edges": "./src/edges.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src/"
15
+ ],
16
+ "scripts": {
17
+ "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
18
+ },
19
+ "keywords": [
20
+ "canvas",
21
+ "drag",
22
+ "resize",
23
+ "snapping",
24
+ "alignment-guides",
25
+ "marquee",
26
+ "flow-editor",
27
+ "react",
28
+ "builder",
29
+ "xeplr"
30
+ ],
31
+ "author": "xeplr",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/Xeplr/xeplr-ui-canvas"
36
+ },
37
+ "homepage": "https://github.com/Xeplr/xeplr-ui-canvas#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/Xeplr/xeplr-ui-canvas/issues"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^18.0.0 || ^19.0.0"
46
+ }
47
+ }
@@ -0,0 +1,71 @@
1
+ import DragGuides from './DragGuides.jsx'
2
+ import MarqueeBox from './MarqueeBox.jsx'
3
+ import ResizeHandles from './ResizeHandles.jsx'
4
+ import EdgeLayer from './EdgeLayer.jsx'
5
+ import { ITEM_ID_ATTR } from '../useCanvasDrag.js'
6
+
7
+ // The ready-made canvas design. Presentation only — everything arrives from
8
+ // useCanvasController plus the caller's render props.
9
+ //
10
+ // Layers, bottom to top: underlay, edges, items, guides and marquee, overlay.
11
+ // Edges sit UNDER items so a line never crosses the box it enters.
12
+ export default function CanvasSample({
13
+ ctrl, renderItem, getAnchor, renderEdgeLabel, edgeMinOffset,
14
+ underlay, overlay, className, style
15
+ }) {
16
+ const { features, selected, rectById } = ctrl
17
+ const soleSelection = selected.size === 1
18
+
19
+ return (
20
+ <div
21
+ ref={ctrl.measureRef}
22
+ className={'xeplr-canvas' + (className ? ' ' + className : '')}
23
+ style={style}
24
+ >
25
+ <div
26
+ ref={ctrl.rootRef}
27
+ className="xeplr-canvas-inner"
28
+ style={{ width: ctrl.size.width, height: ctrl.size.height }}
29
+ onMouseDown={ctrl.onCanvasMouseDown}
30
+ >
31
+ {underlay}
32
+
33
+ {ctrl.edges.length > 0 && (
34
+ <EdgeLayer
35
+ edges={ctrl.edges}
36
+ rectById={rectById}
37
+ itemById={ctrl.itemById}
38
+ store={ctrl.guideStore}
39
+ getAnchor={getAnchor}
40
+ renderEdgeLabel={renderEdgeLabel}
41
+ minOffset={edgeMinOffset}
42
+ />
43
+ )}
44
+
45
+ {ctrl.stacked.map((item) => {
46
+ const rect = rectById[item.id]
47
+ const isSelected = selected.has(item.id)
48
+ return (
49
+ <div
50
+ key={item.id}
51
+ {...{ [ITEM_ID_ATTR]: item.id }}
52
+ className={'xeplr-canvas-item' + (isSelected ? ' is-selected' : '')}
53
+ style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h, zIndex: (item.z || 0) + 1 }}
54
+ onMouseDown={(e) => ctrl.onItemMouseDown(e, item.id)}
55
+ >
56
+ {renderItem(item, { selected: isSelected })}
57
+ {features.resize && isSelected && soleSelection && (
58
+ <ResizeHandles onStart={(e, handle) => ctrl.onResizeStart(e, item.id, handle)} />
59
+ )}
60
+ </div>
61
+ )
62
+ })}
63
+
64
+ {features.snap && <DragGuides store={ctrl.guideStore} />}
65
+ {features.marquee && <MarqueeBox store={ctrl.marqueeStore} />}
66
+
67
+ {overlay}
68
+ </div>
69
+ </div>
70
+ )
71
+ }
@@ -0,0 +1,18 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ // The alignment guides drawn while a drag is snapping.
4
+ //
5
+ // ITS OWN COMPONENT, subscribed to the drag store directly, because this is
6
+ // the one thing on the canvas that has to change on every frame of a drag.
7
+ // Held in a page's state instead, each frame re-rendered the page and every
8
+ // item under it. Here, a frame re-renders exactly this.
9
+ export default function DragGuides({ store }) {
10
+ const frame = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
11
+ return frame.guides.map((g, i) => (
12
+ <div
13
+ key={i}
14
+ className={`xeplr-canvas-guide xeplr-canvas-guide-${g.axis}`}
15
+ style={g.axis === 'x' ? { left: g.at } : { top: g.at }}
16
+ />
17
+ ))
18
+ }
@@ -0,0 +1,62 @@
1
+ import { Fragment, useId, useMemo, useSyncExternalStore } from 'react'
2
+ import { bezierPath, edgeEnds, midpoint } from '../edges.js'
3
+
4
+ // The lines between items.
5
+ //
6
+ // Subscribed to the drag store, like the guides, so an edge FOLLOWS its item
7
+ // while it is dragged — the item itself is moved by a transform React never
8
+ // hears about until the drop, so an edge drawn only from React state would be
9
+ // left behind pointing at where the item used to be.
10
+ //
11
+ // Edge shape: { id, from, to | toOffset, fromPort?, toPort?, fromSide?,
12
+ // toSide?, variant?: 'dashed', className?, arrow?: false, ... } — anything
13
+ // else on it is the caller's, handed back to renderEdgeLabel.
14
+ export default function EdgeLayer({ edges, rectById, itemById, store, getAnchor, renderEdgeLabel, minOffset }) {
15
+ const frame = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
16
+ const markerId = 'xeplr-canvas-arrow-' + useId().replace(/:/g, '')
17
+
18
+ // Committed rects, overlaid with whatever is moving this frame.
19
+ const live = useMemo(() => {
20
+ if (!frame.patches) return rectById
21
+ const next = { ...rectById }
22
+ frame.patches.forEach((p) => {
23
+ if (next[p.id]) next[p.id] = { ...next[p.id], ...p }
24
+ })
25
+ return next
26
+ }, [rectById, frame.patches])
27
+
28
+ const drawn = edges
29
+ .map((edge) => {
30
+ const ends = edgeEnds(edge, live, itemById, getAnchor)
31
+ return ends ? { edge, ...ends } : null
32
+ })
33
+ .filter(Boolean)
34
+
35
+ return (
36
+ <>
37
+ <svg className="xeplr-canvas-edges" width="100%" height="100%">
38
+ <defs>
39
+ <marker id={markerId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
40
+ <path className="xeplr-canvas-edge-arrow" d="M0,0 L8,4 L0,8 z" />
41
+ </marker>
42
+ </defs>
43
+ {drawn.map(({ edge, from, to }) => (
44
+ <path
45
+ key={edge.id}
46
+ className={
47
+ 'xeplr-canvas-edge' +
48
+ (edge.variant ? ' xeplr-canvas-edge--' + edge.variant : '') +
49
+ (edge.className ? ' ' + edge.className : '')
50
+ }
51
+ d={bezierPath(from, to, { minOffset, fromDir: edge.fromDir, toDir: edge.toDir })}
52
+ markerEnd={edge.arrow === false ? undefined : `url(#${markerId})`}
53
+ />
54
+ ))}
55
+ </svg>
56
+ {renderEdgeLabel && drawn.map(({ edge, from, to }) => {
57
+ const label = renderEdgeLabel(edge, { from, to, mid: midpoint(from, to) })
58
+ return label ? <Fragment key={edge.id}>{label}</Fragment> : null
59
+ })}
60
+ </>
61
+ )
62
+ }
@@ -0,0 +1,10 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ // The band itself. Its own leaf on the same bargain as DragGuides: one frame
4
+ // re-renders this and nothing else on the canvas.
5
+ export default function MarqueeBox({ store }) {
6
+ const frame = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
7
+ if (!frame.rect) return null
8
+ const { x, y, w, h } = frame.rect
9
+ return <div className="xeplr-canvas-marquee" style={{ left: x, top: y, width: w, height: h }} />
10
+ }
@@ -0,0 +1,15 @@
1
+ import { RESIZE_HANDLES } from '../geometry.js'
2
+
3
+ // The eight grab points on a selected item. Rendered INSIDE the item's own
4
+ // positioned element, so they move with it during a drag for free.
5
+ //
6
+ // `onStart(event, handleKey)` — hand it to useCanvasDrag's startResize.
7
+ export default function ResizeHandles({ onStart }) {
8
+ return RESIZE_HANDLES.map((h) => (
9
+ <span
10
+ key={h.key}
11
+ className={`xeplr-canvas-handle xeplr-canvas-handle-${h.key}`}
12
+ onMouseDown={(e) => onStart(e, h.key)}
13
+ />
14
+ ))
15
+ }
@@ -0,0 +1,50 @@
1
+ /* @xeplr/ui-canvas — the shared canvas.
2
+ Colours come from the xeplr theme variables, with fallbacks, so a builder
3
+ restyles by theme rather than by overriding these. */
4
+
5
+ .xeplr-canvas { position: relative; overflow: auto; }
6
+ .xeplr-canvas-inner { position: relative; min-width: 100%; min-height: 100%; }
7
+ .xeplr-canvas-item { position: absolute; box-sizing: border-box; }
8
+
9
+ /* ── Edges ────────────────────────────────────────────────────────────── */
10
+ .xeplr-canvas-edges { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
11
+ .xeplr-canvas-edge { fill: none; stroke: var(--xeplr-border-strong, #cfcec4); stroke-width: 2; }
12
+ .xeplr-canvas-edge--dashed { stroke-dasharray: 6 5; }
13
+ .xeplr-canvas-edge-arrow { fill: var(--xeplr-border-strong, #cfcec4); }
14
+
15
+ /* ── Alignment guides ─────────────────────────────────────────────────
16
+ Drawn only while a drag is snapping to them. Without these an item
17
+ jumping four pixels looks like a bug rather than an alignment. */
18
+ .xeplr-canvas-guide { position: absolute; z-index: 9000; pointer-events: none; background: var(--xeplr-accent, #4f5fd6); }
19
+ .xeplr-canvas-guide-x { top: 0; bottom: 0; width: 1px; }
20
+ .xeplr-canvas-guide-y { left: 0; right: 0; height: 1px; }
21
+
22
+ /* ── Resize handles ───────────────────────────────────────────────────── */
23
+ .xeplr-canvas-handle {
24
+ position: absolute; width: 9px; height: 9px; z-index: 10;
25
+ background: var(--xeplr-surface, #fff);
26
+ border: 1px solid var(--xeplr-accent, #4f5fd6);
27
+ border-radius: 2px;
28
+ }
29
+ .xeplr-canvas-handle-nw { left: -5px; top: -5px; cursor: nwse-resize; }
30
+ .xeplr-canvas-handle-n { left: calc(50% - 4px); top: -5px; cursor: ns-resize; }
31
+ .xeplr-canvas-handle-ne { right: -5px; top: -5px; cursor: nesw-resize; }
32
+ .xeplr-canvas-handle-w { left: -5px; top: calc(50% - 4px); cursor: ew-resize; }
33
+ .xeplr-canvas-handle-e { right: -5px; top: calc(50% - 4px); cursor: ew-resize; }
34
+ .xeplr-canvas-handle-sw { left: -5px; bottom: -5px; cursor: nesw-resize; }
35
+ .xeplr-canvas-handle-s { left: calc(50% - 4px); bottom: -5px; cursor: ns-resize; }
36
+ .xeplr-canvas-handle-se { right: -5px; bottom: -5px; cursor: nwse-resize; }
37
+
38
+ /* THE MARQUEE BAND. pointer-events:none so the sweep it is drawing cannot be
39
+ interrupted by the thing drawing it. Above the items, below the alignment
40
+ guides — a guide that appears mid-sweep is still the more important mark. */
41
+ .xeplr-canvas-marquee {
42
+ position: absolute; z-index: 8800; pointer-events: none;
43
+ border: 1px solid var(--xeplr-accent, #4f5fd6);
44
+ background: color-mix(in srgb, var(--xeplr-accent, #4f5fd6) 12%, transparent);
45
+ }
46
+
47
+ /* While a marquee is being swept. Text inside an item — a table's cells, a
48
+ label — is part of its rendering, not something the canvas selects, and a
49
+ drag across it must not leave a trail of highlighted rows behind. */
50
+ body.xeplr-canvas-sweeping, body.xeplr-canvas-sweeping * { user-select: none; -webkit-user-select: none; }
@@ -0,0 +1,7 @@
1
+ import './canvas.css'
2
+
3
+ export { default as CanvasSample } from './CanvasSample.jsx'
4
+ export { default as DragGuides } from './DragGuides.jsx'
5
+ export { default as MarqueeBox } from './MarqueeBox.jsx'
6
+ export { default as ResizeHandles } from './ResizeHandles.jsx'
7
+ export { default as EdgeLayer } from './EdgeLayer.jsx'
package/src/edges.js ADDED
@@ -0,0 +1,73 @@
1
+ // Edges — the lines between items on a canvas that links things (workflow
2
+ // transitions, dataset joins). Pure: points in, SVG path data out.
3
+ //
4
+ // The workflow and dataset canvases each drew their own near-identical
5
+ // bezier; this is the one both use now.
6
+
7
+ /** The middle of one side of a rect — where an edge leaves or arrives. */
8
+ export function anchorOf(rect, side) {
9
+ switch (side) {
10
+ case 'left': return { x: rect.x, y: rect.y + rect.h / 2 }
11
+ case 'top': return { x: rect.x + rect.w / 2, y: rect.y }
12
+ case 'bottom': return { x: rect.x + rect.w / 2, y: rect.y + rect.h }
13
+ case 'right':
14
+ default: return { x: rect.x + rect.w, y: rect.y + rect.h / 2 }
15
+ }
16
+ }
17
+
18
+ /**
19
+ * A horizontal S-curve from `from` to `to`.
20
+ *
21
+ * The control points reach out sideways by half the horizontal gap, but never
22
+ * less than `minOffset` — two boxes stacked almost vertically would otherwise
23
+ * be joined by a straight line that runs through both of them.
24
+ *
25
+ * `fromDir` / `toDir` (+1 right, -1 left) say which way the line leaves and
26
+ * arrives. The defaults are the usual left-to-right flow; a join between two
27
+ * right-hand ports passes fromDir 1, toDir 1 to loop back round.
28
+ */
29
+ export function bezierPath(from, to, opts) {
30
+ const minOffset = opts?.minOffset ?? 60
31
+ const fromDir = opts?.fromDir ?? 1
32
+ const toDir = opts?.toDir ?? -1
33
+ const dx = Math.max(minOffset, Math.abs(to.x - from.x) / 2)
34
+ return 'M ' + from.x + ' ' + from.y +
35
+ ' C ' + (from.x + fromDir * dx) + ' ' + from.y +
36
+ ', ' + (to.x + toDir * dx) + ' ' + to.y +
37
+ ', ' + to.x + ' ' + to.y
38
+ }
39
+
40
+ /** Where a label on an edge sits. */
41
+ export function midpoint(from, to) {
42
+ return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }
43
+ }
44
+
45
+ /**
46
+ * The two ends of one edge, in pixels.
47
+ *
48
+ * `edge.to` is an item id; `edge.toOffset` instead ends the edge at a point
49
+ * relative to where it leaves — for a target that is not an item, like the
50
+ * workflow's Success/Failed pills. Relative, so it follows its item while it
51
+ * is dragged.
52
+ *
53
+ * @param rectById id → pixel rect (live positions during a drag)
54
+ * @param getAnchor optional (item, port, end, rect) → { x, y }, for canvases
55
+ * whose edges attach to ports inside an item (column dots)
56
+ * @returns {{ from, to }|null} null when either end is not on the canvas
57
+ */
58
+ export function edgeEnds(edge, rectById, itemById, getAnchor) {
59
+ const fromRect = rectById[edge.from]
60
+ if (!fromRect) return null
61
+ const from = getAnchor
62
+ ? getAnchor(itemById[edge.from], edge.fromPort, 'from', fromRect)
63
+ : anchorOf(fromRect, edge.fromSide || 'right')
64
+ if (edge.toOffset) {
65
+ return { from, to: { x: from.x + (edge.toOffset.x || 0), y: from.y + (edge.toOffset.y || 0) } }
66
+ }
67
+ const toRect = rectById[edge.to]
68
+ if (!toRect) return null
69
+ const to = getAnchor
70
+ ? getAnchor(itemById[edge.to], edge.toPort, 'to', toRect)
71
+ : anchorOf(toRect, edge.toSide || 'left')
72
+ return { from, to }
73
+ }
@@ -0,0 +1,26 @@
1
+ // A tiny external store for per-frame gesture state — guides, the marquee
2
+ // band, live drag positions.
3
+ //
4
+ // The whole point is that NOTHING ELSE RE-RENDERS during a gesture. Held in a
5
+ // page's state, every frame of a drag re-rendered the page and every card on
6
+ // it, sixty times a second, to move two thin lines. Published here, only the
7
+ // overlay leaves that subscribe (useSyncExternalStore) re-render.
8
+ //
9
+ // A new snapshot object per publish, because useSyncExternalStore compares by
10
+ // identity.
11
+
12
+ export function createFrameStore(initial) {
13
+ let snapshot = initial
14
+ const subs = new Set()
15
+ return {
16
+ subscribe(cb) {
17
+ subs.add(cb)
18
+ return () => { subs.delete(cb) }
19
+ },
20
+ getSnapshot() { return snapshot },
21
+ publish(next) {
22
+ snapshot = next
23
+ subs.forEach((cb) => cb())
24
+ }
25
+ }
26
+ }