@kolkrabbi/kol-component 0.195.0 → 0.197.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.195.0",
3
+ "version": "0.197.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,211 @@
1
+ import { useContext, useEffect, useState } from 'react'
2
+ import { CanvasZoomContext } from '../hooks/canvasZoom.js'
3
+
4
+ /**
5
+ * CropOverlay — crop-mode chrome for a photo layer with an explicit crop
6
+ * window ({imgX,imgY,imgW,imgH} — the image's draw rect in frame-local px).
7
+ *
8
+ * • drag inside the frame → pan the image within the frame (clamped)
9
+ * • drag a frame handle → crop: the frame moves/resizes while the
10
+ * image stays FIXED in world space
11
+ * • Escape / Enter → exit crop mode
12
+ *
13
+ * The ghost <img> shows the image's full extent at low opacity so the user
14
+ * can see what they're cropping away. Chrome divides by zoom to stay
15
+ * screen-constant.
16
+ *
17
+ * `SelectionOverlay`'s other sibling, same contract, same zoom division.
18
+ * Lifted verbatim from kol-fxr (`compose/CropOverlay.jsx`,
19
+ * `editor-panels-the-held-specs` B2, 2026-09-03), already prop-driven, and it
20
+ * keeps its transaction-bracketed write path for the reason given on
21
+ * `PathNodeOverlay`.
22
+ *
23
+ * @param {Object} layer - The photo layer: `{ id, x, y, w, h, rotation?, src, imgX, imgY, imgW, imgH }` — the crop window is the image's draw rect in frame-local px
24
+ * @param {Function} toVirtual - `(clientX, clientY) => { vx, vy }`
25
+ * @param {Function} updateLayer - `(id, patch) => void` — the live write
26
+ * @param {Function} beginTransaction - Pointer down: open one undo entry
27
+ * @param {Function} commitTransaction - Pointer up: close it
28
+ * @param {Function} onExit - Escape / Enter — leave crop mode
29
+ */
30
+
31
+ const HANDLE = 10 /* virtual px */
32
+
33
+ const DIRS = [
34
+ { dir: 'NW', cursor: 'nwse-resize', hx: 0, hy: 0 },
35
+ { dir: 'N', cursor: 'ns-resize', hx: 0.5, hy: 0 },
36
+ { dir: 'NE', cursor: 'nesw-resize', hx: 1, hy: 0 },
37
+ { dir: 'E', cursor: 'ew-resize', hx: 1, hy: 0.5 },
38
+ { dir: 'SE', cursor: 'nwse-resize', hx: 1, hy: 1 },
39
+ { dir: 'S', cursor: 'ns-resize', hx: 0.5, hy: 1 },
40
+ { dir: 'SW', cursor: 'nesw-resize', hx: 0, hy: 1 },
41
+ { dir: 'W', cursor: 'ew-resize', hx: 0, hy: 0.5 },
42
+ ]
43
+
44
+ /* Clamp an image offset so the image covers the frame span where it can
45
+ * (imgSpan ≥ span) or stays inside it where it can't (contain-fit init). */
46
+ function clampOffset(v, span, imgSpan) {
47
+ const lo = Math.min(0, span - imgSpan)
48
+ const hi = Math.max(0, span - imgSpan)
49
+ return Math.min(hi, Math.max(lo, v))
50
+ }
51
+
52
+ export default function CropOverlay({
53
+ layer, toVirtual, updateLayer, beginTransaction, commitTransaction, onExit,
54
+ }) {
55
+ const zoom = useContext(CanvasZoomContext)
56
+ const hs = HANDLE / zoom
57
+ const [drag, setDrag] = useState(null) /* { kind: 'pan'|dir, startVX, startVY, start } */
58
+
59
+ useEffect(() => {
60
+ if (!drag) return
61
+ const onMove = (e) => {
62
+ const { vx, vy } = toVirtual(e.clientX, e.clientY)
63
+ let dvx = vx - drag.startVX
64
+ let dvy = vy - drag.startVY
65
+ const st = drag.start
66
+
67
+ if (drag.kind === 'pan') {
68
+ /* Pointer delta into the layer's local frame when rotated. */
69
+ const rot = (st.rotation * Math.PI) / 180
70
+ if (rot) {
71
+ const c = Math.cos(rot)
72
+ const s = Math.sin(rot)
73
+ const lx = dvx * c + dvy * s
74
+ const ly = -dvx * s + dvy * c
75
+ dvx = lx; dvy = ly
76
+ }
77
+ updateLayer(layer.id, {
78
+ imgX: clampOffset(st.imgX + dvx, st.w, st.imgW),
79
+ imgY: clampOffset(st.imgY + dvy, st.h, st.imgH),
80
+ })
81
+ return
82
+ }
83
+
84
+ /* Crop handle — frame changes, image world-position fixed. Only
85
+ * offered on unrotated photos (handles hidden otherwise). */
86
+ const IL = st.x + st.imgX /* image world left/top */
87
+ const IT = st.y + st.imgY
88
+ let x = st.x, y = st.y, w = st.w, h = st.h
89
+ if (drag.kind.includes('W')) {
90
+ x = Math.min(Math.max(st.x + dvx, IL), st.x + st.w - 8)
91
+ w = st.x + st.w - x
92
+ }
93
+ if (drag.kind.includes('E')) {
94
+ const r = Math.max(Math.min(st.x + st.w + dvx, IL + st.imgW), st.x + 8)
95
+ w = r - st.x
96
+ }
97
+ if (drag.kind.includes('N')) {
98
+ y = Math.min(Math.max(st.y + dvy, IT), st.y + st.h - 8)
99
+ h = st.y + st.h - y
100
+ }
101
+ if (drag.kind.includes('S')) {
102
+ const b = Math.max(Math.min(st.y + st.h + dvy, IT + st.imgH), st.y + 8)
103
+ h = b - st.y
104
+ }
105
+ updateLayer(layer.id, { x, y, w, h, imgX: IL - x, imgY: IT - y })
106
+ }
107
+ const onUp = () => {
108
+ /* No unconditional write here — commit's reference-diff makes a
109
+ * click-without-move a history no-op. */
110
+ commitTransaction()
111
+ setDrag(null)
112
+ }
113
+ window.addEventListener('mousemove', onMove)
114
+ window.addEventListener('mouseup', onUp)
115
+ return () => {
116
+ window.removeEventListener('mousemove', onMove)
117
+ window.removeEventListener('mouseup', onUp)
118
+ }
119
+ }, [drag, layer.id, toVirtual, updateLayer, commitTransaction])
120
+
121
+ /* Escape / Enter exit crop mode. Capture phase beats the compose-level
122
+ * deselect / delete handlers. */
123
+ useEffect(() => {
124
+ const onKey = (e) => {
125
+ if (e.key === 'Escape' || e.key === 'Enter') {
126
+ e.preventDefault(); e.stopPropagation()
127
+ onExit()
128
+ }
129
+ }
130
+ window.addEventListener('keydown', onKey, true)
131
+ return () => window.removeEventListener('keydown', onKey, true)
132
+ }, [onExit])
133
+
134
+ const startDrag = (kind) => (e) => {
135
+ if (e.button !== 0) return
136
+ /* Stop the stage router from reading this as an empty-stage marquee
137
+ * (which would deselect and pop crop mode). */
138
+ e.preventDefault(); e.stopPropagation()
139
+ const { vx, vy } = toVirtual(e.clientX, e.clientY)
140
+ beginTransaction()
141
+ setDrag({
142
+ kind, startVX: vx, startVY: vy,
143
+ start: {
144
+ x: layer.x, y: layer.y, w: layer.w, h: layer.h,
145
+ imgX: layer.imgX, imgY: layer.imgY, imgW: layer.imgW, imgH: layer.imgH,
146
+ rotation: layer.rotation ?? 0,
147
+ },
148
+ })
149
+ }
150
+
151
+ const rot = layer.rotation ?? 0
152
+ const accent = 'var(--kol-accent-primary)'
153
+
154
+ return (
155
+ <div
156
+ style={{
157
+ position: 'absolute',
158
+ left: layer.x, top: layer.y,
159
+ width: layer.w, height: layer.h,
160
+ transform: rot ? `rotate(${rot}deg)` : undefined,
161
+ pointerEvents: 'none',
162
+ zIndex: 110,
163
+ }}
164
+ >
165
+ {/* full-extent ghost — what lies outside the crop. Video sources use a
166
+ muted <video> (an <img src=blob:video> renders blank). */}
167
+ {(() => {
168
+ const ghostStyle = {
169
+ position: 'absolute',
170
+ left: layer.imgX, top: layer.imgY,
171
+ width: layer.imgW, height: layer.imgH,
172
+ maxWidth: 'none',
173
+ opacity: 0.35,
174
+ pointerEvents: 'none',
175
+ }
176
+ return layer.srcType === 'video'
177
+ ? <video src={layer.src} muted playsInline draggable={false} style={ghostStyle} />
178
+ : <img src={layer.src} alt="" draggable={false} style={ghostStyle} />
179
+ })()}
180
+ {/* frame outline + pan surface */}
181
+ <div
182
+ onMouseDown={startDrag('pan')}
183
+ style={{
184
+ position: 'absolute', inset: 0,
185
+ outline: `${1 / zoom}px solid ${accent}`,
186
+ cursor: drag?.kind === 'pan' ? 'grabbing' : 'grab',
187
+ pointerEvents: 'auto',
188
+ }}
189
+ />
190
+ {/* crop handles — hidden on rotated photos.
191
+ * ponytail: pan-only when rotated; add rotation-aware crop resize
192
+ * (world-anchor math like CanvasArea's rotated resize) if needed. */}
193
+ {rot === 0 && DIRS.map(({ dir, cursor, hx, hy }) => (
194
+ <div
195
+ key={dir}
196
+ onMouseDown={startDrag(dir)}
197
+ style={{
198
+ position: 'absolute',
199
+ left: `calc(${hx * 100}% - ${hs / 2}px)`,
200
+ top: `calc(${hy * 100}% - ${hs / 2}px)`,
201
+ width: hs, height: hs,
202
+ background: 'white',
203
+ border: `${1 / zoom}px solid ${accent}`,
204
+ cursor,
205
+ pointerEvents: 'auto',
206
+ }}
207
+ />
208
+ ))}
209
+ </div>
210
+ )
211
+ }
@@ -20,6 +20,12 @@ import { glyphSize } from '../hooks/glyphLadders.js'
20
20
  * scope expression, not every keystroke. With it
21
21
  * the field keeps a local draft seeded from
22
22
  * `value`; `onChange` still fires live if given.
23
+ * After the commit the draft RE-SNAPS to `value`,
24
+ * so a rejected commit falls back to the last
25
+ * good value rather than lingering — which makes
26
+ * `type="number"` + `onCommit` the draft/commit
27
+ * number idiom outright (parse and clamp at the
28
+ * call site; fxr's NumberField, retired 2026-09-03).
23
29
  * variant="ghost" — legacy alias, resolves to outline
24
30
  * variant="property" — the Figma property field (PropertyField,
25
31
  * 2026-08-12): filled chrome, dim `affordance`
@@ -123,7 +129,17 @@ export default function Input({
123
129
  ? {
124
130
  value: draft,
125
131
  onChange: (e) => { draftRef.current = e.target.value; setDraft(e.target.value); onChange?.(e) },
126
- onBlur: () => onCommit(String(draftRef.current).trim()),
132
+ /* RE-SNAP AFTER EVERY COMMIT, not only when `value` changes. The
133
+ * effect above re-syncs the draft on a value change — so a commit the
134
+ * caller REJECTED (invalid input, value kept) left the bad draft on
135
+ * screen, and `1` → `19` → `19x` showed `19x` after blur. kol-fxr's
136
+ * `NumberField` existed for exactly this line (34 lines wrapping this
137
+ * atom: commit, then `setDraft(String(value))`); with the re-snap here
138
+ * it is `<Input type="number" onCommit>` and no component
139
+ * (editor-panels-the-held-specs A8, 2026-09-03). The order matters —
140
+ * commit first, so a caller that DOES accept the value re-renders
141
+ * with the new prop and the effect wins over this fallback. */
142
+ onBlur: () => { onCommit(String(draftRef.current).trim()); draftRef.current = value ?? ''; setDraft(value ?? '') },
127
143
  onKeyDown: (e) => {
128
144
  if (e.key === 'Enter') e.currentTarget.blur()
129
145
  if (e.key === 'Escape') { draftRef.current = value ?? ''; setDraft(value ?? ''); e.currentTarget.blur() }
@@ -0,0 +1,294 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react'
2
+ import { CanvasZoomContext } from '../hooks/canvasZoom.js'
3
+ import { normalizePathRings, pathD, nearestSegmentT, splitSegment, smoothNode } from '../hooks/pathMath.js'
4
+
5
+ /**
6
+ * PathNodeOverlay — node/bezier editing chrome for a selected `path` layer.
7
+ *
8
+ * Draws each anchor (square) and its in/out bezier handles (round knobs on a
9
+ * thin leash) in the 1080-virtual coord space, over the stage. Interaction:
10
+ *
11
+ * • drag anchor → moves the node + both handles together
12
+ * • drag handle → adjusts that handle; the opposite handle mirrors it
13
+ * (smooth) unless Alt is held (independent / break tangent)
14
+ * • click anchor → selects it; Delete/Backspace removes it (min 2 kept)
15
+ * • click FIRST anchor of an open path → closes it (the pen-tool gesture)
16
+ * • dbl-click anchor → corner ↔ smooth toggle
17
+ * • dbl-click segment → insert an anchor at that curve point (split)
18
+ * • Escape → exit node-edit mode
19
+ *
20
+ * Nodes are layer-local; on drag-commit we renormalize so the anchor bbox
21
+ * re-origins to (0,0) and the layer's {x,y,w,h} stay in sync. Live drag
22
+ * skips renormalization to avoid origin jitter under the cursor.
23
+ *
24
+ * `SelectionOverlay`'s sibling on the same 1080-virtual contract and the same
25
+ * zoom division. Lifted verbatim from kol-fxr (`compose/PathNodeOverlay.jsx`,
26
+ * `editor-panels-the-held-specs` B2, 2026-09-03). It was ALREADY prop-driven —
27
+ * no store hook — and the contract it had is the one it keeps: live writes go
28
+ * through `updateLayer`, bracketed by `beginTransaction` / `commitTransaction`
29
+ * so a drag is ONE undo entry. The ticket suggested `value` + `onChange`; that
30
+ * would have lost the transaction bracket, and the bracket is the part a
31
+ * consumer cannot reconstruct from a stream of values.
32
+ *
33
+ * @param {Object} layer - The path layer: `{ id, x, y, w, h, nodes, holes?, closed? }` in virtual px, nodes layer-local
34
+ * @param {number} viewW - The stage's virtual width (the SVG viewBox)
35
+ * @param {number} viewH - The stage's virtual height
36
+ * @param {Function} toVirtual - `(clientX, clientY) => { vx, vy }` — the consumer's screen→virtual mapping
37
+ * @param {Function} updateLayer - `(id, patch) => void` — the live write, called on every move
38
+ * @param {Function} beginTransaction - Called on pointer down; the consumer opens one undo entry
39
+ * @param {Function} commitTransaction - Called on pointer up; the consumer closes it (a no-move drag still brackets)
40
+ * @param {Function} onExit - Escape — leave node-edit mode
41
+ */
42
+
43
+ const ANCHOR = 10 /* virtual px */
44
+ const KNOB = 5 /* virtual px radius */
45
+
46
+ export default function PathNodeOverlay({
47
+ layer, viewW, viewH, toVirtual,
48
+ updateLayer, beginTransaction, commitTransaction, onExit,
49
+ }) {
50
+ const [selNode, setSelNode] = useState(null)
51
+ const [drag, setDrag] = useState(null) /* { type:'anchor'|'in'|'out', index } */
52
+ const movedRef = useRef(false) /* did this drag actually move? */
53
+
54
+ /* Chrome renders in virtual px inside the zoomed transform — divide by
55
+ * zoom so anchors/knobs stay screen-constant instead of vanishing at 25%
56
+ * and ballooning at 400%. */
57
+ const zoom = useContext(CanvasZoomContext)
58
+ const anchor = ANCHOR / zoom
59
+ const knob = KNOB / zoom
60
+
61
+ /* Latest layer geometry for the window drag listeners (which close over a
62
+ * render snapshot otherwise). */
63
+ const nodesRef = useRef(layer.nodes)
64
+ const holesRef = useRef(layer.holes)
65
+ const posRef = useRef({ x: layer.x, y: layer.y })
66
+ nodesRef.current = layer.nodes
67
+ holesRef.current = layer.holes
68
+ posRef.current = { x: layer.x, y: layer.y }
69
+
70
+ const nodes = layer.nodes ?? []
71
+
72
+ useEffect(() => {
73
+ if (!drag) return
74
+ const onMove = (e) => {
75
+ movedRef.current = true
76
+ const { vx, vy } = toVirtual(e.clientX, e.clientY)
77
+ const lx = vx - posRef.current.x
78
+ const ly = vy - posRef.current.y
79
+ const cur = nodesRef.current
80
+ const next = cur.map((n, i) => {
81
+ if (i !== drag.index) return n
82
+ if (drag.type === 'anchor') {
83
+ const dx = lx - n.x
84
+ const dy = ly - n.y
85
+ return {
86
+ x: lx, y: ly,
87
+ in: n.in ? { x: n.in.x + dx, y: n.in.y + dy } : null,
88
+ out: n.out ? { x: n.out.x + dx, y: n.out.y + dy } : null,
89
+ }
90
+ }
91
+ /* extract — pull symmetric handles out of a corner anchor (the anchor
92
+ * itself stays put). Gives endpoints / corners bezier handles. */
93
+ if (drag.type === 'extract') {
94
+ return { ...n, out: { x: lx, y: ly }, in: { x: 2 * n.x - lx, y: 2 * n.y - ly } }
95
+ }
96
+ /* handle drag — set the dragged side; mirror the other around the
97
+ * anchor unless Alt breaks the tangent. */
98
+ const mirror = { x: 2 * n.x - lx, y: 2 * n.y - ly }
99
+ if (drag.type === 'out') {
100
+ return { ...n, out: { x: lx, y: ly }, in: e.altKey ? n.in : mirror }
101
+ }
102
+ return { ...n, in: { x: lx, y: ly }, out: e.altKey ? n.out : mirror }
103
+ })
104
+ updateLayer(layer.id, { nodes: next })
105
+ }
106
+ const onUp = () => {
107
+ /* Only renormalize if the drag actually moved — a bare click (select)
108
+ * must not write, or the commit's reference-diff sees a "change" and
109
+ * pushes a junk undo entry. */
110
+ if (movedRef.current) {
111
+ const norm = normalizePathRings(nodesRef.current, holesRef.current)
112
+ updateLayer(layer.id, {
113
+ nodes: norm.nodes, holes: norm.holes,
114
+ x: posRef.current.x + norm.dx,
115
+ y: posRef.current.y + norm.dy,
116
+ w: norm.w, h: norm.h,
117
+ })
118
+ } else if (drag.type === 'anchor' && drag.index === 0 && !layer.closed && nodesRef.current.length >= 2) {
119
+ /* Plain click on the FIRST anchor of an open path closes it — the
120
+ * same gesture that closes a draft under the pen tool (≥2 nodes,
121
+ * matching the pen's close threshold). A real write, so the open
122
+ * transaction commits it as one history entry. */
123
+ updateLayer(layer.id, { closed: true })
124
+ }
125
+ commitTransaction()
126
+ setDrag(null)
127
+ }
128
+ window.addEventListener('mousemove', onMove)
129
+ window.addEventListener('mouseup', onUp)
130
+ return () => {
131
+ window.removeEventListener('mousemove', onMove)
132
+ window.removeEventListener('mouseup', onUp)
133
+ }
134
+ }, [drag, layer.id, layer.closed, toVirtual, updateLayer, commitTransaction])
135
+
136
+ /* Delete removes the selected node; Escape exits. Capture phase so we beat
137
+ * the compose-level delete-layer / deselect handlers. */
138
+ useEffect(() => {
139
+ const onKey = (e) => {
140
+ if (e.key === 'Escape') {
141
+ e.preventDefault(); e.stopPropagation()
142
+ onExit()
143
+ return
144
+ }
145
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selNode != null) {
146
+ e.preventDefault(); e.stopPropagation()
147
+ const cur = nodesRef.current
148
+ if (cur.length <= 2) return /* keep a drawable minimum */
149
+ const kept = cur.filter((_, i) => i !== selNode)
150
+ const norm = normalizePathRings(kept, holesRef.current)
151
+ beginTransaction()
152
+ updateLayer(layer.id, {
153
+ nodes: norm.nodes, holes: norm.holes,
154
+ x: posRef.current.x + norm.dx,
155
+ y: posRef.current.y + norm.dy,
156
+ w: norm.w, h: norm.h,
157
+ })
158
+ commitTransaction()
159
+ setSelNode(null)
160
+ }
161
+ }
162
+ window.addEventListener('keydown', onKey, true)
163
+ return () => window.removeEventListener('keydown', onKey, true)
164
+ }, [selNode, layer.id, updateLayer, beginTransaction, commitTransaction, onExit])
165
+
166
+ const startDrag = (type, index) => (e) => {
167
+ e.preventDefault(); e.stopPropagation()
168
+ setSelNode(index)
169
+ movedRef.current = false
170
+ beginTransaction()
171
+ /* Alt-drag on an anchor extracts fresh handles instead of moving it. */
172
+ const t = type === 'anchor' && e.altKey ? 'extract' : type
173
+ setDrag({ type: t, index })
174
+ }
175
+
176
+ /* Renormalize + write a node-list change as ONE history entry. */
177
+ const commitNodes = (nextNodes) => {
178
+ const norm = normalizePathRings(nextNodes, holesRef.current)
179
+ beginTransaction()
180
+ updateLayer(layer.id, {
181
+ nodes: norm.nodes, holes: norm.holes,
182
+ x: posRef.current.x + norm.dx,
183
+ y: posRef.current.y + norm.dy,
184
+ w: norm.w, h: norm.h,
185
+ })
186
+ commitTransaction()
187
+ }
188
+
189
+ /* Double-click a segment → insert an anchor at the clicked curve point
190
+ * via de Casteljau split (shape-preserving: the new node takes the split
191
+ * handles, neighbors' handles trim to the half-curves). Straight segments
192
+ * insert a handle-less corner node. */
193
+ const onSegmentDoubleClick = (e) => {
194
+ e.preventDefault(); e.stopPropagation()
195
+ const { vx, vy } = toVirtual(e.clientX, e.clientY)
196
+ const lx = vx - posRef.current.x
197
+ const ly = vy - posRef.current.y
198
+ const cur = nodesRef.current
199
+ const segCount = cur.length - 1 + (layer.closed ? 1 : 0)
200
+ let best = null
201
+ for (let i = 0; i < segCount; i++) {
202
+ const hit = nearestSegmentT(cur[i], cur[(i + 1) % cur.length], lx, ly)
203
+ if (!best || hit.dist < best.dist) best = { index: i, ...hit }
204
+ }
205
+ if (!best) return
206
+ const { a, mid, b } = splitSegment(cur[best.index], cur[(best.index + 1) % cur.length], best.t)
207
+ const next = [...cur]
208
+ next[best.index] = a
209
+ next[(best.index + 1) % cur.length] = b
210
+ next.splice(best.index + 1, 0, mid)
211
+ commitNodes(next)
212
+ setSelNode(best.index + 1)
213
+ }
214
+
215
+ /* Double-click an anchor → corner ↔ smooth. Corner drops both handles;
216
+ * smooth derives mirrored handles from the neighbor chord (~1/3 of each
217
+ * adjacent segment — same tangent model the mirror-by-default handle drag
218
+ * maintains). Anchors never move, so bounds are untouched (anchor-only). */
219
+ const onAnchorDoubleClick = (i) => (e) => {
220
+ e.preventDefault(); e.stopPropagation()
221
+ const cur = nodesRef.current
222
+ const n = cur[i]
223
+ const toggled = (n.in || n.out)
224
+ ? { ...n, in: null, out: null }
225
+ : smoothNode(cur, i, !!layer.closed)
226
+ if (toggled === n) return
227
+ beginTransaction()
228
+ updateLayer(layer.id, { nodes: cur.map((m, j) => (j === i ? toggled : m)) })
229
+ commitTransaction()
230
+ }
231
+
232
+ const accent = 'var(--kol-accent-primary)'
233
+
234
+ return (
235
+ <svg
236
+ width="100%" height="100%"
237
+ viewBox={`0 0 ${viewW} ${viewH}`}
238
+ preserveAspectRatio="none"
239
+ style={{ position: 'absolute', inset: 0, zIndex: 120, pointerEvents: 'none' }}
240
+ >
241
+ <g transform={`translate(${layer.x} ${layer.y})`}>
242
+ {/* invisible fat stroke = segment hit area (~12 screen px, on par
243
+ * with the anchor hit size). Rendered first so anchors/knobs win
244
+ * overlapping hits; mousedown swallowed so the stage router doesn't
245
+ * treat the click as empty-stage (deselect). */}
246
+ <path
247
+ d={pathD(nodes, !!layer.closed)}
248
+ fill="none"
249
+ stroke="transparent"
250
+ strokeWidth={12 / zoom}
251
+ style={{ pointerEvents: 'stroke' }}
252
+ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation() }}
253
+ onDoubleClick={onSegmentDoubleClick}
254
+ />
255
+ {/* handle leashes + knobs */}
256
+ {nodes.map((n, i) => (
257
+ <g key={`h${i}`}>
258
+ {n.in && (
259
+ <>
260
+ <line x1={n.x} y1={n.y} x2={n.in.x} y2={n.in.y}
261
+ stroke={accent} strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.7} />
262
+ <circle cx={n.in.x} cy={n.in.y} r={knob} fill="white" stroke={accent}
263
+ strokeWidth={1} vectorEffect="non-scaling-stroke"
264
+ style={{ pointerEvents: 'auto', cursor: 'move' }}
265
+ onMouseDown={startDrag('in', i)} />
266
+ </>
267
+ )}
268
+ {n.out && (
269
+ <>
270
+ <line x1={n.x} y1={n.y} x2={n.out.x} y2={n.out.y}
271
+ stroke={accent} strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.7} />
272
+ <circle cx={n.out.x} cy={n.out.y} r={knob} fill="white" stroke={accent}
273
+ strokeWidth={1} vectorEffect="non-scaling-stroke"
274
+ style={{ pointerEvents: 'auto', cursor: 'move' }}
275
+ onMouseDown={startDrag('out', i)} />
276
+ </>
277
+ )}
278
+ </g>
279
+ ))}
280
+ {/* anchors */}
281
+ {nodes.map((n, i) => (
282
+ <rect key={`a${i}`}
283
+ x={n.x - anchor / 2} y={n.y - anchor / 2}
284
+ width={anchor} height={anchor}
285
+ fill={i === selNode ? accent : 'white'}
286
+ stroke={accent} strokeWidth={1} vectorEffect="non-scaling-stroke"
287
+ style={{ pointerEvents: 'auto', cursor: 'move' }}
288
+ onMouseDown={startDrag('anchor', i)}
289
+ onDoubleClick={onAnchorDoubleClick(i)} />
290
+ ))}
291
+ </g>
292
+ </svg>
293
+ )
294
+ }
@@ -0,0 +1,92 @@
1
+ import { useRef } from 'react'
2
+
3
+ /**
4
+ * XYPad — a two-axis control pad: drag one puck to vary two values at once.
5
+ *
6
+ * Lifted verbatim from kol-fxr's editor (`compose/inspectors/XYPad.jsx`,
7
+ * `editor-panels-the-held-specs` A6, 2026-09-03 — the row the filer marked
8
+ * "portable as-is", and it was: presentation-only, no store coupling). Its own
9
+ * lineage runs back through kol-labs-single's para-type lab to Font Playground.
10
+ *
11
+ * Axis meaning, ranges and the write path belong to the caller. It fills its
12
+ * container's width and is square via `aspect-ratio`; the puck is positioned
13
+ * in %, so there is no `size` prop and a rail of any width takes it. `y` is
14
+ * inverted — top is high — because that is how every axis pad reads.
15
+ *
16
+ * No `useCallback` on the handlers, on purpose: the labs original memoized
17
+ * them with `[]` deps and froze the first render's axis ranges into the drag
18
+ * math, so a pad whose range changed kept mapping to the old one.
19
+ *
20
+ * <XYPad xValue={wdth} yValue={wght} xMin={50} xMax={200} yMin={100} yMax={900}
21
+ * xLabel="Width" yLabel="Weight" onChange={(x, y) => set({ wdth: x, wght: y })} />
22
+ *
23
+ * @param {number} xValue - Current x, in the x range
24
+ * @param {number} yValue - Current y, in the y range
25
+ * @param {number} [xMin=0] - x at the left edge
26
+ * @param {number} [xMax=1] - x at the right edge
27
+ * @param {number} [yMin=0] - y at the BOTTOM edge
28
+ * @param {number} [yMax=1] - y at the top edge
29
+ * @param {Function} onChange - `(x, y) => void` on pointer down and on every move while a button is held — the caller coalesces if it wants one patch per gesture
30
+ * @param {ReactNode} xLabel - Left label above the pad
31
+ * @param {ReactNode} yLabel - Right label above the pad
32
+ * @param {string} [className] - Extra classes on the wrapper
33
+ */
34
+ export default function XYPad({
35
+ xValue, yValue,
36
+ xMin = 0, xMax = 1,
37
+ yMin = 0, yMax = 1,
38
+ onChange,
39
+ xLabel,
40
+ yLabel,
41
+ className = '',
42
+ }) {
43
+ const ref = useRef(null)
44
+
45
+ const handlePos = (e) => {
46
+ const el = ref.current
47
+ if (!el) return
48
+ const rect = el.getBoundingClientRect()
49
+ const px = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
50
+ const py = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
51
+ const x = xMin + px * (xMax - xMin)
52
+ const y = yMax - py * (yMax - yMin) /* invert: top = high */
53
+ onChange?.(x, y)
54
+ }
55
+
56
+ const onPointerDown = (e) => {
57
+ e.target.setPointerCapture?.(e.pointerId)
58
+ handlePos(e)
59
+ }
60
+ const onPointerMove = (e) => {
61
+ if (e.buttons === 0) return
62
+ handlePos(e)
63
+ }
64
+
65
+ const span = (max, min) => (max - min) || 1
66
+ const puckX = ((xValue - xMin) / span(xMax, xMin)) * 100
67
+ const puckY = (1 - (yValue - yMin) / span(yMax, yMin)) * 100
68
+
69
+ return (
70
+ <div className={`flex flex-col gap-1 ${className}`.trim()}>
71
+ <div className="flex justify-between kol-helper-10 tracking-widest text-meta">
72
+ <span>{xLabel}</span>
73
+ <span>{yLabel}</span>
74
+ </div>
75
+ <div
76
+ ref={ref}
77
+ onPointerDown={onPointerDown}
78
+ onPointerMove={onPointerMove}
79
+ className="relative w-full aspect-square border border-fg-16 bg-fg-04 rounded cursor-crosshair touch-none"
80
+ >
81
+ {/* crosshair guides */}
82
+ <div className="absolute inset-x-0 top-1/2 border-t border-fg-08" />
83
+ <div className="absolute inset-y-0 left-1/2 border-l border-fg-08" />
84
+ {/* puck */}
85
+ <div
86
+ className="absolute w-3 h-3 -ml-1.5 -mt-1.5 rounded-full bg-fg-96 border border-fg-04 pointer-events-none"
87
+ style={{ left: `${puckX}%`, top: `${puckY}%` }}
88
+ />
89
+ </div>
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * pathMath — bezier path geometry: the `d` string, bounds, normalisation,
3
+ * scale/rotate, nearest-point-on-segment, split and smooth.
4
+ *
5
+ * Lifted verbatim from kol-fxr's engine (`compose/path-math.js`,
6
+ * `editor-panels-the-held-specs` B2, 2026-09-03) because `PathNodeOverlay`
7
+ * needs five of these and a second copy of pure geometry is a drift waiting
8
+ * to happen. It lives in `src/hooks` for the reason `glyphLadders.js` and
9
+ * `colorMath.js` do — the taxonomy's one non-component folder — and
10
+ * `@kolkrabbi/design-editor`'s own `path-math.js` is now a re-export of this
11
+ * file, so the engine and the overlay compute the same curve.
12
+ */
13
+ /**
14
+ * path-math — pure geometry for the vector `path` layer type.
15
+ *
16
+ * A path node: `{ x, y, in, out }` where `x,y` is the anchor and `in`/`out`
17
+ * are the incoming/outgoing cubic-bezier control points (absolute, same
18
+ * coord space as the anchor) or `null` for a corner (control collapses to
19
+ * the anchor → that side renders as a straight segment).
20
+ *
21
+ * Node coords are LOCAL to the layer's `{x, y}` translation. Keeping them
22
+ * layer-local means a whole-path move is just an `{x, y}` update — the same
23
+ * gesture every other positioned layer already uses — with no per-node math.
24
+ */
25
+
26
+ /* Build an SVG path `d`. Every segment is emitted as a cubic `C`; a corner
27
+ * node (null handle) uses its anchor as the control, degenerating the cubic
28
+ * into the straight line we want. Uniform command stream = one code path. */
29
+ export function pathD(nodes, closed = false) {
30
+ if (!nodes || nodes.length === 0) return ''
31
+ if (nodes.length === 1) return `M ${nodes[0].x} ${nodes[0].y}`
32
+ const seg = (p, c) => {
33
+ const c1 = p.out ?? p
34
+ const c2 = c.in ?? c
35
+ return `C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${c.x} ${c.y}`
36
+ }
37
+ let d = `M ${nodes[0].x} ${nodes[0].y}`
38
+ for (let i = 1; i < nodes.length; i++) d += ` ${seg(nodes[i - 1], nodes[i])}`
39
+ if (closed) d += ` ${seg(nodes[nodes.length - 1], nodes[0])} Z`
40
+ return d
41
+ }
42
+
43
+ /* Anchor-only bounding box. Handles are intentionally excluded so the
44
+ * selection wireframe doesn't jump when a handle is pulled far out.
45
+ * ponytail: anchor bbox, not the true curve extent — a selection hint, not
46
+ * a clip. Swap to a de Casteljau extent solve if tight bounds are needed. */
47
+ export function pathBounds(nodes) {
48
+ if (!nodes || nodes.length === 0) return { minX: 0, minY: 0, w: 1, h: 1 }
49
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
50
+ for (const n of nodes) {
51
+ if (n.x < minX) minX = n.x
52
+ if (n.y < minY) minY = n.y
53
+ if (n.x > maxX) maxX = n.x
54
+ if (n.y > maxY) maxY = n.y
55
+ }
56
+ return { minX, minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) }
57
+ }
58
+
59
+ /* Translate a node (anchor + both handles) by (dx, dy). */
60
+ export function shiftNode(n, dx, dy) {
61
+ return {
62
+ x: n.x + dx,
63
+ y: n.y + dy,
64
+ in: n.in ? { x: n.in.x + dx, y: n.in.y + dy } : null,
65
+ out: n.out ? { x: n.out.x + dx, y: n.out.y + dy } : null,
66
+ }
67
+ }
68
+
69
+ /* Re-origin a node list so its anchor bbox starts at (0,0). Returns the
70
+ * shifted nodes plus the (dx, dy) that must be ADDED to the layer's {x,y}
71
+ * to keep the path visually fixed. Called after create + after any node
72
+ * edit so nodes stay layer-local and {x,y,w,h} stay in sync. */
73
+ export function normalizePath(nodes) {
74
+ const { minX, minY, w, h } = pathBounds(nodes)
75
+ return {
76
+ nodes: nodes.map((n) => shiftNode(n, -minX, -minY)),
77
+ dx: minX,
78
+ dy: minY,
79
+ w,
80
+ h,
81
+ }
82
+ }
83
+
84
+ /* Scale a node list (anchors + handles) around the local origin. Used when
85
+ * the layer's {w,h} bbox is resized so the geometry tracks the box. Nodes
86
+ * are normalized (bbox origin at 0,0), so plain multiplication preserves
87
+ * normalization: min stays 0, max becomes the new w/h. */
88
+ export function scalePathNodes(nodes, sx, sy) {
89
+ const s = (p) => (p ? { x: p.x * sx, y: p.y * sy } : null)
90
+ return nodes.map((n) => ({ x: n.x * sx, y: n.y * sy, in: s(n.in), out: s(n.out) }))
91
+ }
92
+
93
+ /* Re-origin a MULTI-RING path (outer nodes + optional hole rings, both
94
+ * introduced by boolean ops) so the combined anchor bbox starts at (0,0).
95
+ * Same contract as normalizePath, but bounds span every ring and holes
96
+ * shift in lockstep with the outer ring. `holes` may be null/empty. */
97
+ export function normalizePathRings(nodes, holes) {
98
+ const rings = [nodes, ...(holes ?? [])]
99
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
100
+ for (const ring of rings) for (const n of ring) {
101
+ if (n.x < minX) minX = n.x
102
+ if (n.y < minY) minY = n.y
103
+ if (n.x > maxX) maxX = n.x
104
+ if (n.y > maxY) maxY = n.y
105
+ }
106
+ if (!Number.isFinite(minX)) return { nodes, holes: holes ?? null, dx: 0, dy: 0, w: 1, h: 1 }
107
+ return {
108
+ nodes: nodes.map((n) => shiftNode(n, -minX, -minY)),
109
+ holes: holes?.length ? holes.map((ring) => ring.map((n) => shiftNode(n, -minX, -minY))) : null,
110
+ dx: minX,
111
+ dy: minY,
112
+ w: Math.max(1, maxX - minX),
113
+ h: Math.max(1, maxY - minY),
114
+ }
115
+ }
116
+
117
+ /* Rotate a node list by `deg` (clockwise, y-down) about (cx, cy). Used to
118
+ * BAKE a layer's live `rotation` into path geometry when entering node-edit
119
+ * mode — node editing always operates on rotation-free geometry, mirroring
120
+ * how flips are baked. */
121
+ export function rotatePathNodes(nodes, deg, cx, cy) {
122
+ const rad = (deg * Math.PI) / 180
123
+ const cos = Math.cos(rad)
124
+ const sin = Math.sin(rad)
125
+ const r = (p) => (p ? {
126
+ x: cx + (p.x - cx) * cos - (p.y - cy) * sin,
127
+ y: cy + (p.x - cx) * sin + (p.y - cy) * cos,
128
+ } : null)
129
+ return nodes.map((n) => ({ ...r(n), in: r(n.in), out: r(n.out) }))
130
+ }
131
+
132
+ export function dist(ax, ay, bx, by) {
133
+ return Math.hypot(ax - bx, ay - by)
134
+ }
135
+
136
+ /* Point on the segment a→b at parameter t. Corner nodes collapse their
137
+ * control to the anchor — same degenerate-cubic convention as pathD, so
138
+ * this evaluates straight segments correctly too. */
139
+ function segPoint(a, b, t) {
140
+ const p1 = a.out ?? a
141
+ const p2 = b.in ?? b
142
+ const u = 1 - t
143
+ return {
144
+ x: u * u * u * a.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * b.x,
145
+ y: u * u * u * a.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * b.y,
146
+ }
147
+ }
148
+
149
+ /* Nearest parameter on the segment a→b to (px, py): coarse 32-step scan +
150
+ * a fine pass around the winner. Click-time hit-testing only, not a hot
151
+ * path — and the split lands wherever the user perceived the click, so
152
+ * sub-pixel exactness buys nothing. */
153
+ export function nearestSegmentT(a, b, px, py) {
154
+ let bestT = 0
155
+ let bestD = Infinity
156
+ const scan = (from, to, steps) => {
157
+ for (let i = 0; i <= steps; i++) {
158
+ const t = from + ((to - from) * i) / steps
159
+ const p = segPoint(a, b, t)
160
+ const d = (p.x - px) ** 2 + (p.y - py) ** 2
161
+ if (d < bestD) { bestD = d; bestT = t }
162
+ }
163
+ }
164
+ scan(0, 1, 32)
165
+ scan(Math.max(0, bestT - 1 / 32), Math.min(1, bestT + 1 / 32), 16)
166
+ return { t: bestT, dist: Math.sqrt(bestD) }
167
+ }
168
+
169
+ /* de Casteljau split of the segment a→b at t. Returns replacement nodes
170
+ * { a, mid, b } — shape-preserving: the two half-cubics retrace the original
171
+ * curve exactly (the new node takes the interior split handles; a/b keep
172
+ * their far handles and get their near handles trimmed). A straight segment
173
+ * (both controls null) yields a handle-less mid so corners stay corners. */
174
+ export function splitSegment(a, b, t) {
175
+ if (!a.out && !b.in) {
176
+ return {
177
+ a, b,
178
+ mid: { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, in: null, out: null },
179
+ }
180
+ }
181
+ const p1 = a.out ?? a
182
+ const p2 = b.in ?? b
183
+ const lerp = (p, q) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t })
184
+ const q0 = lerp(a, p1)
185
+ const q1 = lerp(p1, p2)
186
+ const q2 = lerp(p2, b)
187
+ const r0 = lerp(q0, q1)
188
+ const r1 = lerp(q1, q2)
189
+ const s = lerp(r0, r1)
190
+ /* A null (corner) side stays null: its split control degenerates to the
191
+ * anchor anyway, and a zero-length handle would just put a dead knob on
192
+ * top of the anchor square. */
193
+ return {
194
+ a: { ...a, out: a.out ? q0 : null },
195
+ mid: { x: s.x, y: s.y, in: r0, out: r1 },
196
+ b: { ...b, in: b.in ? q2 : null },
197
+ }
198
+ }
199
+
200
+ /* Smooth a corner anchor: mirrored handles along the neighbor chord
201
+ * (Illustrator convention), each sized to ~1/3 of its adjacent segment
202
+ * length. Open-path endpoints get a single handle toward their only
203
+ * neighbor. The anchor itself never moves. Returns a new node (or the
204
+ * original when there are no neighbors to derive a tangent from). */
205
+ export function smoothNode(nodes, i, closed) {
206
+ const n = nodes[i]
207
+ const len = nodes.length
208
+ const prev = (closed || i > 0) ? nodes[(i - 1 + len) % len] : null
209
+ const next = (closed || i < len - 1) ? nodes[(i + 1) % len] : null
210
+ if (!prev && !next) return n
211
+ const dPrev = prev ? dist(n.x, n.y, prev.x, prev.y) : 0
212
+ const dNext = next ? dist(n.x, n.y, next.x, next.y) : 0
213
+ let tx = (next ?? n).x - (prev ?? n).x
214
+ let ty = (next ?? n).y - (prev ?? n).y
215
+ const tl = Math.hypot(tx, ty) || 1
216
+ tx /= tl
217
+ ty /= tl
218
+ return {
219
+ ...n,
220
+ in: prev ? { x: n.x - tx * (dPrev / 3), y: n.y - ty * (dPrev / 3) } : null,
221
+ out: next ? { x: n.x + tx * (dNext / 3), y: n.y + ty * (dNext / 3) } : null,
222
+ }
223
+ }
224
+
225
+ /* ── dev self-check ─────────────────────────────────────────────────── */
226
+ if (import.meta.env?.DEV) {
227
+ const sq = [
228
+ { x: 10, y: 10, in: null, out: null },
229
+ { x: 30, y: 10, in: null, out: null },
230
+ { x: 30, y: 40, in: null, out: null },
231
+ ]
232
+ const b = pathBounds(sq)
233
+ console.assert(b.minX === 10 && b.minY === 10 && b.w === 20 && b.h === 30, 'pathBounds')
234
+ const n = normalizePath(sq)
235
+ console.assert(n.dx === 10 && n.dy === 10 && n.nodes[0].x === 0 && n.nodes[0].y === 0, 'normalizePath origin')
236
+ console.assert(pathD(sq).startsWith('M 10 10 C'), 'pathD corner→cubic')
237
+ console.assert(pathD(sq, true).endsWith('Z'), 'pathD closed')
238
+ const sp = splitSegment(sq[0], sq[1], 0.5)
239
+ console.assert(sp.mid.x === 20 && sp.mid.y === 10 && sp.mid.in === null, 'splitSegment straight → corner mid')
240
+ const sm = smoothNode(sq, 1, false)
241
+ console.assert(sm.in && sm.out && sm.x === 30 && sm.y === 10, 'smoothNode handles, anchor fixed')
242
+ }
package/src/index.js CHANGED
@@ -65,6 +65,15 @@ export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
65
65
  export { default as TiltCard } from './utilities/TiltCard.jsx'
66
66
  export { default as TransparentX } from './utilities/TransparentX.jsx'
67
67
  export { default as ViewToggle } from './atoms/ViewToggle.jsx'
68
+ /* the design-editor parts, taken per row from editor-panels-the-held-specs (2026-09-03) */
69
+ export { default as XYPad } from './atoms/XYPad.jsx'
70
+ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
71
+ /* SelectionOverlay's two siblings — same 1080-virtual contract, same zoom
72
+ * division (editor-panels-the-held-specs B2). `pathMath` is their geometry,
73
+ * exported because the editor engine re-exports it rather than keep a copy. */
74
+ export { default as PathNodeOverlay } from './atoms/PathNodeOverlay.jsx'
75
+ export { default as CropOverlay } from './atoms/CropOverlay.jsx'
76
+ export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
68
77
 
69
78
  // molecules
70
79
  export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
@@ -0,0 +1,59 @@
1
+ /**
2
+ * InspectorRail — the selection-routing shell of an inspector panel.
3
+ *
4
+ * Lifted from kol-fxr's editor (`compose/InspectorRail.jsx`,
5
+ * `editor-panels-the-held-specs` A7, 2026-09-03) with ALL of its coupling
6
+ * dropped: it read the compose store and imported three concrete panels. What
7
+ * is left is the one piece of logic the filer said everyone gets wrong — the
8
+ * precedence — and it is the thing that makes an inspector rail a component
9
+ * rather than a `<div>`:
10
+ *
11
+ * nothing selected → renders NOTHING (user ruling 2026-08-12: no dummy
12
+ * empty-state copy; selection must visibly spawn
13
+ * its controls)
14
+ * the canvas is selected → `renderers.canvas`, and it WINS over multi-select.
15
+ * Selecting the canvas selects every layer with it,
16
+ * so without this precedence "inspect the canvas"
17
+ * with two layers in frame fell into the multi
18
+ * branch and hid the fill / opacity controls
19
+ * exactly one id → `renderers.single(id)`
20
+ * two or more ids → `renderers.multi(ids)` — the canvas id excluded
21
+ * from the count; it is selectable but not a layer
22
+ *
23
+ * The panels themselves are the consumer's — a layer inspector delegating by
24
+ * type, a canvas inspector, a multi-select summary with a Group action — and
25
+ * they arrive as render functions so this file imports none of them.
26
+ *
27
+ * <InspectorRail
28
+ * selectedIds={selectedIds}
29
+ * canvasId="canvas"
30
+ * renderers={{
31
+ * canvas: () => <CanvasInspector />,
32
+ * single: (id) => <LayerInspector layer={find(id)} />,
33
+ * multi: (ids) => <MultiSummary ids={ids} onGroup={group} />,
34
+ * }}
35
+ * />
36
+ *
37
+ * @param {string[]} selectedIds - The current selection, in selection order; may include `canvasId`
38
+ * @param {string} [canvasId='canvas'] - The id that means "the canvas itself" — precedence, and excluded from the multi count
39
+ * @param {{canvas?: Function, single?: Function, multi?: Function}} renderers - `canvas()`, `single(id)`, `multi(ids)` — each returns the node for that state; a missing renderer renders nothing for it
40
+ * @param {string} [className] - Extra classes on the rail
41
+ */
42
+ export default function InspectorRail({ selectedIds = [], canvasId = 'canvas', renderers = {}, className = '' }) {
43
+ const isCanvas = selectedIds.includes(canvasId)
44
+ const layerIds = selectedIds.filter((id) => id !== canvasId)
45
+
46
+ const body = isCanvas
47
+ ? renderers.canvas?.()
48
+ : layerIds.length >= 2
49
+ ? renderers.multi?.(layerIds)
50
+ : layerIds.length === 1
51
+ ? renderers.single?.(layerIds[0])
52
+ : null
53
+
54
+ return (
55
+ <div className={`kol-inspector-rail ${className}`.trim()}>
56
+ {body && <div className="kol-inspector-rail-body">{body}</div>}
57
+ </div>
58
+ )
59
+ }