@kolkrabbi/kol-component 0.196.0 → 0.198.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 +1 -1
- package/src/atoms/CropOverlay.jsx +211 -0
- package/src/atoms/PathNodeOverlay.jsx +294 -0
- package/src/hooks/layerTree.js +92 -0
- package/src/hooks/pathMath.js +242 -0
- package/src/index.js +8 -0
- package/src/organisms/LayerStack.jsx +504 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.198.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
|
+
}
|
|
@@ -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
|
+
/**
|
|
2
|
+
* layerTree — the labels and the tree walk a layer stack reads.
|
|
3
|
+
*
|
|
4
|
+
* Lifted verbatim from kol-fxr's engine (`compose/labels.js` +
|
|
5
|
+
* `compose/helpers.js`, `editor-panels-the-held-specs` A1, 2026-09-03) as
|
|
6
|
+
* `LayerStack`'s DEFAULTS. A consumer's layer taxonomy is its own — the stack
|
|
7
|
+
* takes `labelFor` and `iconFor` seams — and these are what those seams fall
|
|
8
|
+
* back to when a consumer's layers happen to speak the same `type` vocabulary.
|
|
9
|
+
* In `src/hooks` for the reason `glyphLadders.js` and `pathMath.js` are: the
|
|
10
|
+
* taxonomy's one non-component folder.
|
|
11
|
+
*
|
|
12
|
+
* Convention: Title Case everywhere. A user-set `layer.name` always wins,
|
|
13
|
+
* verbatim — no casing applied.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const TYPE_LABELS = {
|
|
17
|
+
background: 'Background',
|
|
18
|
+
pattern: 'Pattern',
|
|
19
|
+
photo: 'Photo',
|
|
20
|
+
shape: 'Shape',
|
|
21
|
+
text: 'Text',
|
|
22
|
+
group: 'Group',
|
|
23
|
+
bool: 'Boolean',
|
|
24
|
+
loop: 'Loop',
|
|
25
|
+
kinetic: 'Kinetic type',
|
|
26
|
+
misc: 'Misc',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const BOOL_OP_LABELS = {
|
|
30
|
+
unite: 'Unite',
|
|
31
|
+
subtract: 'Subtract',
|
|
32
|
+
intersect: 'Intersect',
|
|
33
|
+
exclude: 'Exclude',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SHAPE_KIND_LABELS = {
|
|
37
|
+
logo: 'Logo',
|
|
38
|
+
rect: 'Rectangle',
|
|
39
|
+
ellipse: 'Ellipse',
|
|
40
|
+
triangle: 'Triangle',
|
|
41
|
+
line: 'Line',
|
|
42
|
+
polygon: 'Polygon',
|
|
43
|
+
star: 'Star',
|
|
44
|
+
flatten: 'Flatten',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Inspector title — verbose form, e.g. "Shape · Rectangle". */
|
|
48
|
+
export function labelForLayer(layer) {
|
|
49
|
+
if (layer.type === 'shape') {
|
|
50
|
+
const kind = SHAPE_KIND_LABELS[layer.kind ?? 'logo'] ?? 'Shape'
|
|
51
|
+
return `Shape · ${kind}`
|
|
52
|
+
}
|
|
53
|
+
if (layer.type === 'bool') {
|
|
54
|
+
const op = BOOL_OP_LABELS[layer.op]
|
|
55
|
+
return op ? `Boolean · ${op}` : TYPE_LABELS.bool
|
|
56
|
+
}
|
|
57
|
+
if (layer.type === 'loop' && layer.presetLabel) return `Loop · ${layer.presetLabel}`
|
|
58
|
+
if (layer.type === 'misc' && layer.presetLabel) return `Misc · ${layer.presetLabel}`
|
|
59
|
+
if (layer.type === 'kinetic' && layer.presetLabel) return `Kinetic · ${layer.presetLabel}`
|
|
60
|
+
return TYPE_LABELS[layer.type] ?? layer.type
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Compact label for a layer-stack row. A user-set `layer.name` (inline
|
|
64
|
+
* rename in the layer stack) always wins, verbatim — no casing applied.
|
|
65
|
+
* Otherwise shapes show their kind directly (Figma idiom — "Rectangle"
|
|
66
|
+
* not "Shape · Rectangle"); text rows show the actual content (truncated
|
|
67
|
+
* by the row's CSS). */
|
|
68
|
+
export function rowLabelForLayer(layer) {
|
|
69
|
+
if (layer.name) return layer.name
|
|
70
|
+
if (layer.type === 'text') return layer.text || TYPE_LABELS.text
|
|
71
|
+
if (layer.type === 'shape') {
|
|
72
|
+
return SHAPE_KIND_LABELS[layer.kind ?? 'logo'] ?? TYPE_LABELS.shape
|
|
73
|
+
}
|
|
74
|
+
if (layer.type === 'bool') return BOOL_OP_LABELS[layer.op] ?? TYPE_LABELS.bool
|
|
75
|
+
if (layer.type === 'loop' || layer.type === 'misc') return layer.presetLabel || TYPE_LABELS[layer.type]
|
|
76
|
+
if (layer.type === 'kinetic') return layer.presetLabel || TYPE_LABELS.kinetic
|
|
77
|
+
return TYPE_LABELS[layer.type] ?? layer.type
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* Walk the layer tree (including group/bool children) and return the layer
|
|
81
|
+
* with `id`, or null if none. Single source of truth — the engine had four
|
|
82
|
+
* inline copies drifting independently before this. */
|
|
83
|
+
export function findLayerDeep(layers, id) {
|
|
84
|
+
for (const l of layers) {
|
|
85
|
+
if (l.id === id) return l
|
|
86
|
+
if (Array.isArray(l.children)) {
|
|
87
|
+
const found = findLayerDeep(l.children, id)
|
|
88
|
+
if (found) return found
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
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
|
@@ -68,6 +68,14 @@ export { default as ViewToggle } from './atoms/ViewToggle.jsx'
|
|
|
68
68
|
/* the design-editor parts, taken per row from editor-panels-the-held-specs (2026-09-03) */
|
|
69
69
|
export { default as XYPad } from './atoms/XYPad.jsx'
|
|
70
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 { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
|
|
77
|
+
export { TYPE_LABELS, BOOL_OP_LABELS, SHAPE_KIND_LABELS, labelForLayer, rowLabelForLayer, findLayerDeep } from './hooks/layerTree.js'
|
|
78
|
+
export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
|
|
71
79
|
|
|
72
80
|
// molecules
|
|
73
81
|
export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import Input from '../atoms/Input.jsx'
|
|
5
|
+
import { MenuDropdownItem, MenuDropdownNest } from '../molecules/MenuItem.jsx'
|
|
6
|
+
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
|
|
7
|
+
import { rowLabelForLayer, findLayerDeep } from '../hooks/layerTree.js'
|
|
8
|
+
|
|
9
|
+
/* DS icon names for the engine's types — every one a shipped v1 glyph. A
|
|
10
|
+
* consumer's own map goes in through `iconFor`. */
|
|
11
|
+
const DEFAULT_TYPE_ICONS = {
|
|
12
|
+
background: 'square',
|
|
13
|
+
pattern: 'ptrn-dot',
|
|
14
|
+
photo: 'image',
|
|
15
|
+
shape: 'rectangle',
|
|
16
|
+
text: 'type',
|
|
17
|
+
group: 'layers',
|
|
18
|
+
bool: 'layers',
|
|
19
|
+
loop: 'refresh',
|
|
20
|
+
misc: 'refresh',
|
|
21
|
+
kinetic: 'type',
|
|
22
|
+
}
|
|
23
|
+
const defaultIconFor = (type) => DEFAULT_TYPE_ICONS[type] ?? 'rectangle'
|
|
24
|
+
|
|
25
|
+
/* Exported — an inspector's Blend dropdown shares this list. */
|
|
26
|
+
export const BLEND_MODES = [
|
|
27
|
+
{ value: 'normal', label: 'Normal' },
|
|
28
|
+
{ value: 'multiply', label: 'Multiply' },
|
|
29
|
+
{ value: 'screen', label: 'Screen' },
|
|
30
|
+
{ value: 'overlay', label: 'Overlay' },
|
|
31
|
+
{ value: 'soft-light', label: 'Soft light' },
|
|
32
|
+
{ value: 'difference', label: 'Difference' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/* Shift state captured in mousedown via ref; click reads the ref to decide
|
|
36
|
+
* single-select vs toggle-select. Avoids relying on the synthetic event's
|
|
37
|
+
* shiftKey passing through (proved unreliable in the source codebase). */
|
|
38
|
+
function useShiftClickHandlers(onSelect, onShiftSelect) {
|
|
39
|
+
const shiftRef = useRef(false)
|
|
40
|
+
const onMouseDown = (e) => { shiftRef.current = !!e.shiftKey }
|
|
41
|
+
const onClick = () => {
|
|
42
|
+
if (shiftRef.current) {
|
|
43
|
+
shiftRef.current = false
|
|
44
|
+
onShiftSelect?.()
|
|
45
|
+
} else {
|
|
46
|
+
onSelect?.()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { onMouseDown, onClick }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Chevron({ IconC, collapsed, onToggle, title }) {
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={onToggle}
|
|
57
|
+
aria-expanded={!collapsed}
|
|
58
|
+
title={title}
|
|
59
|
+
className="kol-layer-stack-collapse"
|
|
60
|
+
>
|
|
61
|
+
<IconC
|
|
62
|
+
name="chevron-down"
|
|
63
|
+
size={10}
|
|
64
|
+
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 150ms' }}
|
|
65
|
+
/>
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function LayerRow({
|
|
71
|
+
layer, active, tinted, isContainer, IconC, labelFor, iconFor,
|
|
72
|
+
groupCollapsed, onToggleGroup,
|
|
73
|
+
onSelect, onShiftSelect, onToggleVisibility, onToggleLock, onRename,
|
|
74
|
+
draggedId, dropTargetId, dropPosition,
|
|
75
|
+
onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
|
|
76
|
+
parentId = null,
|
|
77
|
+
}) {
|
|
78
|
+
const isDragging = draggedId === layer.id
|
|
79
|
+
const isDropAbove = dropTargetId === layer.id && dropPosition === 'above'
|
|
80
|
+
const isDropBelow = dropTargetId === layer.id && dropPosition === 'below'
|
|
81
|
+
|
|
82
|
+
const selectHandlers = useShiftClickHandlers(onSelect, onShiftSelect)
|
|
83
|
+
|
|
84
|
+
/* Inline rename — double-click the name to edit. Enter/blur commits (the
|
|
85
|
+
* consumer's write, so undo-safety is theirs); Escape cancels. An emptied
|
|
86
|
+
* input clears the name so the row falls back to its type label. */
|
|
87
|
+
const [renaming, setRenaming] = useState(false)
|
|
88
|
+
const [draft, setDraft] = useState('')
|
|
89
|
+
const cancelRef = useRef(false)
|
|
90
|
+
|
|
91
|
+
const startRename = () => {
|
|
92
|
+
setDraft(layer.name ?? '')
|
|
93
|
+
setRenaming(true)
|
|
94
|
+
}
|
|
95
|
+
const commitRename = () => {
|
|
96
|
+
if (!cancelRef.current) onRename(draft.trim() || null)
|
|
97
|
+
cancelRef.current = false
|
|
98
|
+
setRenaming(false)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="kol-layer-stack-line group">
|
|
103
|
+
{isContainer ? (
|
|
104
|
+
<Chevron IconC={IconC} collapsed={groupCollapsed} onToggle={onToggleGroup} title={groupCollapsed ? 'Expand group' : 'Collapse group'} />
|
|
105
|
+
) : (
|
|
106
|
+
<span aria-hidden="true" className="kol-layer-stack-collapse" />
|
|
107
|
+
)}
|
|
108
|
+
<div
|
|
109
|
+
draggable={!renaming}
|
|
110
|
+
onDragStart={(e) => onDragStart(e, layer.id)}
|
|
111
|
+
onDragOver={(e) => onDragOver(e, layer.id, parentId)}
|
|
112
|
+
onDragLeave={(e) => onDragLeave(e, layer.id)}
|
|
113
|
+
onDrop={(e) => onDrop(e, layer.id, parentId)}
|
|
114
|
+
onDragEnd={onDragEnd}
|
|
115
|
+
className={
|
|
116
|
+
`kol-layer-stack-row${active ? ' is-active' : ''}` +
|
|
117
|
+
`${tinted && !active ? ' is-tinted' : ''}` +
|
|
118
|
+
`${!layer.visible ? ' is-hidden' : ''}` +
|
|
119
|
+
`${isDragging ? ' is-dragging' : ''}` +
|
|
120
|
+
`${isDropAbove ? ' is-drop-above' : ''}` +
|
|
121
|
+
`${isDropBelow ? ' is-drop-below' : ''}`
|
|
122
|
+
}
|
|
123
|
+
data-layer-id={layer.id}
|
|
124
|
+
>
|
|
125
|
+
{renaming ? (
|
|
126
|
+
<span className="kol-layer-stack-main">
|
|
127
|
+
<span className="kol-layer-stack-icon" aria-hidden="true">
|
|
128
|
+
<IconC name={iconFor(layer.type)} size={14} />
|
|
129
|
+
</span>
|
|
130
|
+
<Input
|
|
131
|
+
variant="ghost"
|
|
132
|
+
size="sm"
|
|
133
|
+
width="100%"
|
|
134
|
+
value={draft}
|
|
135
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
136
|
+
onFocus={(e) => e.target.select()}
|
|
137
|
+
onBlur={commitRename}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === 'Enter') e.currentTarget.blur()
|
|
140
|
+
else if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur() }
|
|
141
|
+
}}
|
|
142
|
+
autoFocus
|
|
143
|
+
placeholder={labelFor({ ...layer, name: null })}
|
|
144
|
+
inputClassName="kol-helper-12 text-emphasis"
|
|
145
|
+
/>
|
|
146
|
+
</span>
|
|
147
|
+
) : (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onMouseDown={selectHandlers.onMouseDown}
|
|
151
|
+
onClick={selectHandlers.onClick}
|
|
152
|
+
onDoubleClick={startRename}
|
|
153
|
+
className="kol-layer-stack-main"
|
|
154
|
+
>
|
|
155
|
+
<span className="kol-layer-stack-icon" aria-hidden="true">
|
|
156
|
+
<IconC name={iconFor(layer.type)} size={14} />
|
|
157
|
+
</span>
|
|
158
|
+
<span className="kol-helper-12 truncate flex-1 text-left">
|
|
159
|
+
{labelFor(layer)}
|
|
160
|
+
</span>
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={onToggleVisibility}
|
|
166
|
+
title={layer.visible ? 'Hide' : 'Show'}
|
|
167
|
+
aria-pressed={!layer.visible}
|
|
168
|
+
className={`kol-layer-stack-toggle kol-layer-stack-toggle--eye${active || !layer.visible ? ' is-pinned' : ''}`}
|
|
169
|
+
>
|
|
170
|
+
<IconC name={layer.visible ? 'eye-on' : 'eye-off'} size={12} />
|
|
171
|
+
</button>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={onToggleLock}
|
|
175
|
+
title={layer.locked ? 'Unlock' : 'Lock'}
|
|
176
|
+
aria-pressed={!!layer.locked}
|
|
177
|
+
className={`kol-layer-stack-toggle kol-layer-stack-toggle--lock${active || layer.locked ? ' is-pinned' : ''}${layer.locked ? ' is-on' : ''}`}
|
|
178
|
+
>
|
|
179
|
+
<IconC name={layer.locked ? 'lock' : 'unlock'} size={12} />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* CanvasRow — the container row at the top of the stack (Figma frame model:
|
|
187
|
+
* everything nests one step inside it). Always present, can't be deleted;
|
|
188
|
+
* its chevron collapses the contents. */
|
|
189
|
+
function CanvasRow({ IconC, active, collapsed, onToggleCollapse, onSelect }) {
|
|
190
|
+
return (
|
|
191
|
+
<div className="kol-layer-stack-line group">
|
|
192
|
+
<Chevron IconC={IconC} collapsed={collapsed} onToggle={onToggleCollapse} title={collapsed ? 'Expand layers' : 'Collapse layers'} />
|
|
193
|
+
<div className={`kol-layer-stack-row${active ? ' is-active' : ''}`} data-layer-id="canvas">
|
|
194
|
+
<button type="button" onClick={onSelect} className="kol-layer-stack-main">
|
|
195
|
+
<span className="kol-layer-stack-icon" aria-hidden="true">
|
|
196
|
+
<IconC name="maximize" size={14} />
|
|
197
|
+
</span>
|
|
198
|
+
{/* helper-12 like every layer row — mono-12 read heavier than the stack */}
|
|
199
|
+
<span className="kol-helper-12 truncate flex-1 text-left">Canvas</span>
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* LayerStack — the layers panel: a z-stacked tree of rows with HTML5 drag to
|
|
208
|
+
* reorder AND reparent in one gesture, hover-revealed eye + lock toggles,
|
|
209
|
+
* inline rename, collapsible containers, and a Canvas root row above it all.
|
|
210
|
+
*
|
|
211
|
+
* Lifted from kol-fxr's editor (`compose/LayerStack.jsx`, 583 lines,
|
|
212
|
+
* `editor-panels-the-held-specs` A1, 2026-09-03) with its ONE coupling
|
|
213
|
+
* dropped: it read and wrote `useComposeState()` directly. Every store call
|
|
214
|
+
* is now a prop, and the drag model — the non-trivial part, the drop target
|
|
215
|
+
* computed against the flattened tree with the cycle guard — is verbatim.
|
|
216
|
+
*
|
|
217
|
+
* ANATOMY, per row: `[chevron] [type icon] [name] … [eye] [lock]`. Chevrons
|
|
218
|
+
* are focus chrome, not resting chrome (Figma model): hidden until the pointer
|
|
219
|
+
* is inside the stack. Eye and lock reveal on row hover and pin visible when
|
|
220
|
+
* the layer is hidden or locked. Double-click the name to rename inline —
|
|
221
|
+
* Enter / blur commits, Escape cancels, an emptied field clears the name so
|
|
222
|
+
* the row falls back to its type label. The panel renders REVERSED, so the
|
|
223
|
+
* top row is the top of the z-order.
|
|
224
|
+
*
|
|
225
|
+
* THE DRAG. One drop path for every row: `onReorder(id, parentId, index)`
|
|
226
|
+
* handles same-container reorder, child → top level, and top level → container
|
|
227
|
+
* alike. `index` is in the target container's order WITHOUT the dragged item.
|
|
228
|
+
* Dropping a container into its own subtree is refused at every depth (the UI
|
|
229
|
+
* shows no indicator, so it never promises a drop the consumer would reject).
|
|
230
|
+
*
|
|
231
|
+
* SEAMS. `labelFor(layer)` and `iconFor(type)` because a consumer's layer
|
|
232
|
+
* taxonomy is not ours — the defaults are the engine's own labels
|
|
233
|
+
* (`hooks/layerTree.js`) and a DS-icon map, and a consumer with its own icon
|
|
234
|
+
* registry passes `iconComponent` (Button's seam, same shape: `{ name, size,
|
|
235
|
+
* className, style }`). `containerTypes` says which types have children.
|
|
236
|
+
*
|
|
237
|
+
* Chrome is `.kol-layer-stack-*` in kol-theme (organisms) — the states,
|
|
238
|
+
* the drop indicators and the hover-reveal are pseudo-elements and descendant
|
|
239
|
+
* rules a utility cannot express.
|
|
240
|
+
*
|
|
241
|
+
* @param {Array<Object>} layers - The tree, bottom-of-z-order first: `{ id, type, name?, visible, locked?, children? }` plus whatever the consumer's `labelFor` reads
|
|
242
|
+
* @param {string[]} selectedIds - Current selection; may include `canvasId`
|
|
243
|
+
* @param {string} [canvasId='canvas'] - The id that means the canvas root row
|
|
244
|
+
* @param {Function} onSelect - `(id) => void` — plain click
|
|
245
|
+
* @param {Function} onToggleSelect - `(id) => void` — shift-click adds / removes
|
|
246
|
+
* @param {Function} onSelectCanvas - `() => void` — the root row
|
|
247
|
+
* @param {Function} onToggleVisible - `(id) => void`
|
|
248
|
+
* @param {Function} onToggleLocked - `(id) => void`
|
|
249
|
+
* @param {Function} onRename - `(id, name|null) => void` — null clears the name
|
|
250
|
+
* @param {Function} onReorder - `(id, parentId|null, index) => void` — see THE DRAG
|
|
251
|
+
* @param {Function} onGroup - `(ids) => void` — the footer's Group action over a multi-selection; omit to hide it
|
|
252
|
+
* @param {Function} [labelFor] - `(layer) => string` (default: the engine's `rowLabelForLayer`)
|
|
253
|
+
* @param {Function} [iconFor] - `(type) => iconName` (default: a DS-icon map over the engine's types)
|
|
254
|
+
* @param {ElementType} [iconComponent] - Icon renderer receiving `{ name, size, className, style }` (default: DS `Icon`)
|
|
255
|
+
* @param {string[]} [containerTypes=['group','bool']] - Types whose rows collapse and whose `children` nest
|
|
256
|
+
* @param {string} [className] - Extra classes on the panel
|
|
257
|
+
*/
|
|
258
|
+
export default function LayerStack({
|
|
259
|
+
layers = [],
|
|
260
|
+
selectedIds = [],
|
|
261
|
+
canvasId = 'canvas',
|
|
262
|
+
onSelect,
|
|
263
|
+
onToggleSelect,
|
|
264
|
+
onSelectCanvas,
|
|
265
|
+
onToggleVisible,
|
|
266
|
+
onToggleLocked,
|
|
267
|
+
onRename,
|
|
268
|
+
onReorder,
|
|
269
|
+
onGroup,
|
|
270
|
+
labelFor = rowLabelForLayer,
|
|
271
|
+
iconFor = defaultIconFor,
|
|
272
|
+
iconComponent: IconC = Icon,
|
|
273
|
+
containerTypes = ['group', 'bool'],
|
|
274
|
+
className = '',
|
|
275
|
+
}) {
|
|
276
|
+
const isContainer = (l) => containerTypes.includes(l.type)
|
|
277
|
+
|
|
278
|
+
/* The canvas is selectable but isn't a layer — exclude it from the group
|
|
279
|
+
* action's count and payload. */
|
|
280
|
+
const layerSelectedIds = selectedIds.filter((id) => id !== canvasId)
|
|
281
|
+
const layerSelectionCount = layerSelectedIds.length
|
|
282
|
+
|
|
283
|
+
const [draggedId, setDraggedId] = useState(null)
|
|
284
|
+
const [dropTargetId, setDropTargetId] = useState(null)
|
|
285
|
+
const [dropPosition, setDropPosition] = useState(null)
|
|
286
|
+
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
|
|
287
|
+
const [canvasCollapsed, setCanvasCollapsed] = useState(false)
|
|
288
|
+
|
|
289
|
+
const toggleGroupCollapse = (id) => setCollapsedGroups((prev) => {
|
|
290
|
+
const next = new Set(prev)
|
|
291
|
+
if (next.has(id)) next.delete(id)
|
|
292
|
+
else next.add(id)
|
|
293
|
+
return next
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const onDragStart = (e, id) => {
|
|
297
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
298
|
+
e.dataTransfer.setData('text/plain', id)
|
|
299
|
+
setDraggedId(id)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* True when the drop container sits anywhere inside the dragged layer's own
|
|
303
|
+
* subtree (group into its own descendant) — a cycle the consumer will
|
|
304
|
+
* reject, so the UI must not promise the drop. Every depth, not just direct
|
|
305
|
+
* children. */
|
|
306
|
+
const isIntoOwnSubtree = (targetParentId) => {
|
|
307
|
+
if (!draggedId || targetParentId == null) return false
|
|
308
|
+
const dragged = findLayerDeep(layers, draggedId)
|
|
309
|
+
return dragged != null && findLayerDeep([dragged], targetParentId) != null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const onDragOver = (e, targetId, targetParentId = null) => {
|
|
313
|
+
e.preventDefault()
|
|
314
|
+
e.dataTransfer.dropEffect = 'move'
|
|
315
|
+
if (!draggedId || draggedId === targetId || isIntoOwnSubtree(targetParentId)) {
|
|
316
|
+
setDropTargetId(null)
|
|
317
|
+
setDropPosition(null)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
321
|
+
const isUpper = (e.clientY - rect.top) < rect.height / 2
|
|
322
|
+
setDropTargetId(targetId)
|
|
323
|
+
setDropPosition(isUpper ? 'above' : 'below')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const onDragLeave = (_e, targetId) => {
|
|
327
|
+
setDropTargetId((cur) => (cur === targetId ? null : cur))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const clearDrag = () => {
|
|
331
|
+
setDraggedId(null)
|
|
332
|
+
setDropTargetId(null)
|
|
333
|
+
setDropPosition(null)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* One drop path for every row. Index is in the target container's order
|
|
337
|
+
* WITHOUT the dragged item. The panel renders reversed, so visual 'above'
|
|
338
|
+
* = one past the target. */
|
|
339
|
+
const onDrop = (e, targetId, targetParentId = null) => {
|
|
340
|
+
e.preventDefault()
|
|
341
|
+
if (!draggedId || draggedId === targetId || isIntoOwnSubtree(targetParentId)) {
|
|
342
|
+
clearDrag()
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const container = targetParentId
|
|
346
|
+
? (findLayerDeep(layers, targetParentId)?.children ?? [])
|
|
347
|
+
: layers
|
|
348
|
+
const list = container.filter((l) => l.id !== draggedId)
|
|
349
|
+
const targetIndex = list.findIndex((l) => l.id === targetId)
|
|
350
|
+
if (targetIndex < 0) {
|
|
351
|
+
clearDrag()
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const finalIndex = dropPosition === 'above' ? targetIndex + 1 : targetIndex
|
|
355
|
+
onReorder?.(draggedId, targetParentId, finalIndex)
|
|
356
|
+
clearDrag()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const rowProps = (layer, parentId) => ({
|
|
360
|
+
layer,
|
|
361
|
+
parentId,
|
|
362
|
+
active: selectedIds.includes(layer.id),
|
|
363
|
+
tinted: selectedIds.includes(parentId ?? canvasId),
|
|
364
|
+
isContainer: isContainer(layer),
|
|
365
|
+
IconC, labelFor, iconFor,
|
|
366
|
+
groupCollapsed: collapsedGroups.has(layer.id),
|
|
367
|
+
onToggleGroup: () => toggleGroupCollapse(layer.id),
|
|
368
|
+
onSelect: () => onSelect?.(layer.id),
|
|
369
|
+
onShiftSelect: () => onToggleSelect?.(layer.id),
|
|
370
|
+
onToggleVisibility: () => onToggleVisible?.(layer.id),
|
|
371
|
+
onToggleLock: () => onToggleLocked?.(layer.id),
|
|
372
|
+
onRename: (name) => onRename?.(layer.id, name),
|
|
373
|
+
draggedId, dropTargetId, dropPosition,
|
|
374
|
+
onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd: clearDrag,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
/* Recursive container contents. Each level wraps in a `-nest` ul, so the
|
|
378
|
+
* indent compounds one chevron slot per depth; collapse, selection and drag
|
|
379
|
+
* are id-keyed, so they work identically at every depth. */
|
|
380
|
+
const renderChildren = (parent) => (
|
|
381
|
+
<ul className="flex flex-col kol-layer-stack-nest">
|
|
382
|
+
{[...parent.children].reverse().map((child) => (
|
|
383
|
+
<li key={child.id}>
|
|
384
|
+
<LayerRow {...rowProps(child, parent.id)} />
|
|
385
|
+
{isContainer(child) && !collapsedGroups.has(child.id)
|
|
386
|
+
&& Array.isArray(child.children) && child.children.length > 0
|
|
387
|
+
&& renderChildren(child)}
|
|
388
|
+
</li>
|
|
389
|
+
))}
|
|
390
|
+
</ul>
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<div className={`kol-layer-stack flex flex-col min-h-[240px] ${className}`.trim()} data-layer-stack="true">
|
|
395
|
+
{/* Figma frame model: Canvas is the container, every layer nests one
|
|
396
|
+
* step inside it; container children one more. */}
|
|
397
|
+
<ul className="flex flex-col pb-3 px-2 pt-3">
|
|
398
|
+
<li>
|
|
399
|
+
<CanvasRow
|
|
400
|
+
IconC={IconC}
|
|
401
|
+
active={selectedIds.includes(canvasId)}
|
|
402
|
+
collapsed={canvasCollapsed}
|
|
403
|
+
onToggleCollapse={() => setCanvasCollapsed((v) => !v)}
|
|
404
|
+
onSelect={() => onSelectCanvas?.()}
|
|
405
|
+
/>
|
|
406
|
+
</li>
|
|
407
|
+
{!canvasCollapsed && [...layers].reverse().map((layer) => (
|
|
408
|
+
<li key={layer.id} className="kol-layer-stack-nest">
|
|
409
|
+
<LayerRow {...rowProps(layer, null)} />
|
|
410
|
+
{isContainer(layer) && !collapsedGroups.has(layer.id)
|
|
411
|
+
&& Array.isArray(layer.children) && layer.children.length > 0
|
|
412
|
+
&& renderChildren(layer)}
|
|
413
|
+
</li>
|
|
414
|
+
))}
|
|
415
|
+
</ul>
|
|
416
|
+
|
|
417
|
+
{/* Footer only exists while a multi-selection can be grouped — add
|
|
418
|
+
* lives in the panel's tab row (AddLayerButton), delete is the
|
|
419
|
+
* consumer's keymap. */}
|
|
420
|
+
{onGroup && layerSelectionCount >= 2 && (
|
|
421
|
+
<div className="mt-auto flex items-center gap-2 px-3 h-10 border-t border-fg-08">
|
|
422
|
+
<Button
|
|
423
|
+
iconComponent={IconC}
|
|
424
|
+
variant="primary"
|
|
425
|
+
size="sm"
|
|
426
|
+
iconLeft="layers"
|
|
427
|
+
onClick={() => onGroup(layerSelectedIds)}
|
|
428
|
+
title={`Group ${layerSelectionCount} selected layers`}
|
|
429
|
+
>
|
|
430
|
+
Group {layerSelectionCount}
|
|
431
|
+
</Button>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* AddLayerButton — the `+` that opens a menu of layer types. Lives in the
|
|
440
|
+
* panel's tab row, not the stack footer (the source's placement). One entry
|
|
441
|
+
* may expand inline to a kind picker so adding "a shape" doesn't silently
|
|
442
|
+
* default to the first kind.
|
|
443
|
+
*
|
|
444
|
+
* @param {Array<{id: string, label: string, icon?: string}>} types - The menu rows
|
|
445
|
+
* @param {{typeId: string, kinds: Array<{id: string, label: string, icon?: string, extras?: Object}>}} [nested] - One type that opens a sub-menu of kinds; picking one fires `onAdd(typeId, kind.extras)`
|
|
446
|
+
* @param {Function} onAdd - `(typeId, extras?) => void`
|
|
447
|
+
* @param {Function} [iconFor] - `(typeId) => iconName` for rows without their own `icon`
|
|
448
|
+
* @param {ElementType} [iconComponent] - Icon renderer (default: DS `Icon`)
|
|
449
|
+
* @param {number} [menuWidth=180] - Panel width in px
|
|
450
|
+
*/
|
|
451
|
+
export function AddLayerButton({
|
|
452
|
+
types = [],
|
|
453
|
+
nested,
|
|
454
|
+
onAdd,
|
|
455
|
+
iconFor = defaultIconFor,
|
|
456
|
+
iconComponent: IconC = Icon,
|
|
457
|
+
menuWidth = 180,
|
|
458
|
+
}) {
|
|
459
|
+
const [open, setOpen] = useState(false)
|
|
460
|
+
const popover = usePopover({
|
|
461
|
+
open,
|
|
462
|
+
onOpenChange: setOpen,
|
|
463
|
+
placement: 'bottom-start',
|
|
464
|
+
offset: 4,
|
|
465
|
+
role: 'menu',
|
|
466
|
+
})
|
|
467
|
+
const pick = (id, extras) => { onAdd?.(id, extras); setOpen(false) }
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<>
|
|
471
|
+
<span ref={popover.refs.setReference} {...popover.getReferenceProps()} className="inline-flex">
|
|
472
|
+
<Button
|
|
473
|
+
iconComponent={IconC}
|
|
474
|
+
variant="primary"
|
|
475
|
+
size="sm"
|
|
476
|
+
quiet
|
|
477
|
+
iconOnly="plus"
|
|
478
|
+
aria-label="Add layer"
|
|
479
|
+
title="Add layer"
|
|
480
|
+
/>
|
|
481
|
+
</span>
|
|
482
|
+
<PopoverPanel popover={popover} className="py-1" style={{ width: menuWidth }}>
|
|
483
|
+
{types.map((t) => {
|
|
484
|
+
if (nested && t.id === nested.typeId) {
|
|
485
|
+
return (
|
|
486
|
+
<MenuDropdownNest key={t.id} iconLeft={<IconC name={t.icon ?? iconFor(t.id)} size={12} />} label={t.label}>
|
|
487
|
+
{nested.kinds.map((k) => (
|
|
488
|
+
<MenuDropdownItem key={k.id} iconLeft={<IconC name={k.icon ?? iconFor(t.id)} size={12} />} onClick={() => pick(t.id, k.extras)}>
|
|
489
|
+
{k.label}
|
|
490
|
+
</MenuDropdownItem>
|
|
491
|
+
))}
|
|
492
|
+
</MenuDropdownNest>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
return (
|
|
496
|
+
<MenuDropdownItem key={t.id} iconLeft={<IconC name={t.icon ?? iconFor(t.id)} size={12} />} onClick={() => pick(t.id)}>
|
|
497
|
+
{t.label}
|
|
498
|
+
</MenuDropdownItem>
|
|
499
|
+
)
|
|
500
|
+
})}
|
|
501
|
+
</PopoverPanel>
|
|
502
|
+
</>
|
|
503
|
+
)
|
|
504
|
+
}
|