@kolkrabbi/kol-component 0.197.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/hooks/layerTree.js +92 -0
- package/src/index.js +2 -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,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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -73,6 +73,8 @@ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
|
|
|
73
73
|
* exported because the editor engine re-exports it rather than keep a copy. */
|
|
74
74
|
export { default as PathNodeOverlay } from './atoms/PathNodeOverlay.jsx'
|
|
75
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'
|
|
76
78
|
export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
|
|
77
79
|
|
|
78
80
|
// molecules
|
|
@@ -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
|
+
}
|