@kolkrabbi/kol-component 0.197.0 → 0.199.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.197.0",
3
+ "version": "0.199.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,9 @@ 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'
78
+ export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
76
79
  export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
77
80
 
78
81
  // 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
+ }
@@ -0,0 +1,258 @@
1
+ import { useRef, useState } from 'react'
2
+ import Input from '../atoms/Input.jsx'
3
+ import Dropdown from '../molecules/Dropdown.jsx'
4
+
5
+ /* The six easings the key editor offers. The CURVES stay the consumer's
6
+ * (its interpolator resolves the name); this is the menu, not the math. */
7
+ export const TIMELINE_EASINGS = [
8
+ { value: 'linear', label: 'Linear' },
9
+ { value: 'ease', label: 'Ease' },
10
+ { value: 'in', label: 'Ease in' },
11
+ { value: 'out', label: 'Ease out' },
12
+ { value: 'in-out', label: 'Ease in-out' },
13
+ { value: 'hold', label: 'Hold' },
14
+ ]
15
+
16
+ /* Sample a track's value at t (linear across the segment — good enough for
17
+ * the "add key without a jump" affordance). Exported: a consumer's renderer
18
+ * wants the same answer the dock used when it placed the key. */
19
+ export function sampleTrack(keys, t) {
20
+ if (keys.length === 0) return 0
21
+ if (t <= keys[0].t) return keys[0].v
22
+ const last = keys[keys.length - 1]
23
+ if (t >= last.t) return last.v
24
+ let i = 0
25
+ while (i < keys.length - 1 && keys[i + 1].t <= t) i++
26
+ const a = keys[i], b = keys[i + 1]
27
+ if (typeof a.v !== 'number' || typeof b.v !== 'number') return a.v
28
+ const span = b.t - a.t || 1
29
+ return a.v + (b.v - a.v) * ((t - a.t) / span)
30
+ }
31
+
32
+ /* Click/drag to seek. */
33
+ function ScrubRuler({ t, onSeek }) {
34
+ const ref = useRef(null)
35
+ const fracFromEvent = (e) => {
36
+ const r = ref.current.getBoundingClientRect()
37
+ return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
38
+ }
39
+ const onPointerDown = (e) => {
40
+ e.currentTarget.setPointerCapture(e.pointerId)
41
+ onSeek?.(fracFromEvent(e))
42
+ }
43
+ const onPointerMove = (e) => {
44
+ if (e.buttons & 1) onSeek?.(fracFromEvent(e))
45
+ }
46
+ return (
47
+ <div className="flex items-center gap-3">
48
+ <span className="kol-mono-12 text-meta tabular-nums shrink-0 text-right" style={{ width: 120 }}>{t.toFixed(2)}</span>
49
+ <div
50
+ ref={ref}
51
+ className="relative flex-1 h-4 cursor-ew-resize rounded"
52
+ style={{ background: 'var(--kol-fg-04)' }}
53
+ onPointerDown={onPointerDown}
54
+ onPointerMove={onPointerMove}
55
+ >
56
+ <Playhead t={t} />
57
+ </div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ function Playhead({ t }) {
63
+ return (
64
+ <span
65
+ aria-hidden="true"
66
+ className="absolute top-0 bottom-0"
67
+ style={{ left: `${t * 100}%`, width: 1.5, background: 'var(--kol-accent-primary)' }}
68
+ />
69
+ )
70
+ }
71
+
72
+ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
73
+ const laneRef = useRef(null)
74
+ /* Local drag state — committed once on pointer-up. */
75
+ const drag = useRef(null)
76
+ const [, force] = useState(0)
77
+
78
+ const fracFromEvent = (e) => {
79
+ const r = laneRef.current.getBoundingClientRect()
80
+ return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
81
+ }
82
+
83
+ const isSel = (i) => selected && selected.trackId === track.id && selected.index === i
84
+
85
+ const onLanePointerDown = (e) => {
86
+ if (e.target.dataset.diamond !== undefined) return
87
+ /* Add a key at the click position, valued at the track's current value
88
+ * there (no visual jump), then select it. */
89
+ const clickT = fracFromEvent(e)
90
+ const v = sampleTrack(track.keys, clickT)
91
+ const next = [...track.keys, { t: clickT, v, easing: 'linear' }].sort((a, b) => a.t - b.t)
92
+ writeKeys(track, next)
93
+ setSelected({ trackId: track.id, index: next.findIndex((k) => k.t === clickT) })
94
+ }
95
+
96
+ const onDiamondPointerDown = (i) => (e) => {
97
+ e.stopPropagation()
98
+ if (e.altKey) {
99
+ /* alt-click deletes (min 1 key stays — an empty track is a broken binding) */
100
+ if (track.keys.length > 1) {
101
+ writeKeys(track, track.keys.filter((_, j) => j !== i))
102
+ setSelected(null)
103
+ }
104
+ return
105
+ }
106
+ e.currentTarget.setPointerCapture(e.pointerId)
107
+ drag.current = { index: i, t: track.keys[i].t }
108
+ setSelected({ trackId: track.id, index: i })
109
+ }
110
+ const onDiamondPointerMove = (i) => (e) => {
111
+ if (!drag.current || drag.current.index !== i) return
112
+ drag.current.t = fracFromEvent(e)
113
+ force((n) => n + 1)
114
+ }
115
+ const onDiamondPointerUp = (i) => () => {
116
+ if (!drag.current || drag.current.index !== i) return
117
+ const moved = { ...track.keys[i], t: drag.current.t }
118
+ const next = track.keys.map((k, j) => (j === i ? moved : k))
119
+ drag.current = null
120
+ writeKeys(track, next)
121
+ setSelected(null)
122
+ }
123
+
124
+ return (
125
+ <div className="flex items-center gap-3">
126
+ <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }} title={track.label}>
127
+ {track.label}
128
+ </span>
129
+ <div
130
+ ref={laneRef}
131
+ className="relative flex-1 h-5 rounded cursor-copy"
132
+ style={{ background: 'var(--kol-fg-04)' }}
133
+ onPointerDown={onLanePointerDown}
134
+ >
135
+ <Playhead t={t} />
136
+ {track.keys.map((k, i) => {
137
+ const kt = drag.current?.index === i ? drag.current.t : k.t
138
+ return (
139
+ <span
140
+ key={i}
141
+ data-diamond=""
142
+ onPointerDown={onDiamondPointerDown(i)}
143
+ onPointerMove={onDiamondPointerMove(i)}
144
+ onPointerUp={onDiamondPointerUp(i)}
145
+ title={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`}
146
+ className="absolute top-1/2 cursor-grab"
147
+ style={{
148
+ left: `${kt * 100}%`,
149
+ width: 9, height: 9,
150
+ transform: 'translate(-50%, -50%) rotate(45deg)',
151
+ background: isSel(i) ? 'var(--kol-accent-primary)' : 'var(--kol-fg-emphasis)',
152
+ borderRadius: 1.5,
153
+ }}
154
+ />
155
+ )
156
+ })}
157
+ </div>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ function SelectedKeyEditor({ tracks, selected, setSelected, writeKeys, easingOptions }) {
163
+ if (!selected) return null
164
+ const track = tracks.find((tr) => tr.id === selected.trackId)
165
+ const key = track?.keys[selected.index]
166
+ if (!key) return null
167
+
168
+ const patchKey = (patch) => {
169
+ writeKeys(track, track.keys.map((k, i) => (i === selected.index ? { ...k, ...patch } : k)))
170
+ }
171
+ const isNum = typeof key.v === 'number'
172
+
173
+ return (
174
+ <div className="flex items-center gap-2 pt-1">
175
+ <span className="kol-helper-10 text-meta shrink-0">key @ {key.t.toFixed(2)}</span>
176
+ <Input
177
+ variant="ghost" size="sm" chars={7}
178
+ type={isNum ? 'number' : 'text'}
179
+ value={String(key.v)}
180
+ onChange={(e) => patchKey({ v: isNum ? Number(e.target.value) || 0 : e.target.value })}
181
+ />
182
+ <Dropdown
183
+ variant="subtle" size="sm"
184
+ options={easingOptions}
185
+ value={Array.isArray(key.easing) ? 'linear' : (key.easing ?? 'linear')}
186
+ onChange={(v) => patchKey({ easing: v })}
187
+ />
188
+ <button
189
+ type="button"
190
+ className="kol-helper-10 text-meta hover:text-emphasis px-2"
191
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
192
+ onClick={() => {
193
+ if (track.keys.length > 1) writeKeys(track, track.keys.filter((_, i) => i !== selected.index))
194
+ setSelected(null)
195
+ }}
196
+ >
197
+ Delete key
198
+ </button>
199
+ <button
200
+ type="button"
201
+ className="kol-helper-10 text-meta hover:text-emphasis px-2 ml-auto"
202
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
203
+ onClick={() => setSelected(null)}
204
+ >
205
+ Close
206
+ </button>
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /**
212
+ * TimelineDock — the keyframe timeline, docked below a canvas.
213
+ *
214
+ * [t readout] [scrub ruler ................................ playhead]
215
+ * [track label] [lane: ◆ diamonds at t · click adds · drag moves · alt-click deletes]
216
+ * [selected key: value · easing · delete]
217
+ *
218
+ * Collapses to NOTHING while there are no tracks, so a static editor pays
219
+ * zero chrome. Drags commit on pointer-up — one `onChange` per gesture, so a
220
+ * consumer's undo gets one entry instead of a flood.
221
+ *
222
+ * Lifted from kol-fxr's editor (`params/TimelineDock.jsx`,
223
+ * `editor-panels-the-held-specs` B3, 2026-09-03) with its couplings dropped
224
+ * exactly as the row asked: `collectTracks`, which walked fxr's layer tree
225
+ * for `{ bind: 'track' }` bindings, is the CONSUMER's — it hands in a flat
226
+ * `tracks` array; `updateLayer` is `onChange(trackId, keys)`; and the clock
227
+ * is two props, `t` and `onSeek`, so any clock drives it. fxr's `transport` is
228
+ * an external store precisely so 60fps ticks re-render only bound renderers;
229
+ * a consumer keeps that property by wrapping this in the one component that
230
+ * subscribes to its clock. The clock itself does not ship.
231
+ *
232
+ * @param {Array<{id: string, label: string, keys: Array<{t: number, v: any, easing?: string}>}>} tracks - One lane each, `t` in 0..1; empty renders nothing
233
+ * @param {number} t - The clock, 0..1
234
+ * @param {Function} onSeek - `(t) => void` — the ruler scrubbed
235
+ * @param {Function} onChange - `(trackId, keys) => void` — a track's keys after an add, move, edit or delete; already sorted by `t`
236
+ * @param {Array<{value: string, label: string}>} [easingOptions] - The key editor's easing menu (default: `TIMELINE_EASINGS`)
237
+ * @param {string} [className] - Extra classes on the dock
238
+ */
239
+ export default function TimelineDock({ tracks = [], t = 0, onSeek, onChange, easingOptions = TIMELINE_EASINGS, className = '' }) {
240
+ const [selected, setSelected] = useState(null) /* { trackId, index } */
241
+
242
+ if (tracks.length === 0) return null
243
+
244
+ const writeKeys = (track, nextKeys) => {
245
+ const sorted = [...nextKeys].sort((a, b) => a.t - b.t)
246
+ onChange?.(track.id, sorted)
247
+ }
248
+
249
+ return (
250
+ <div className={`kol-timeline-dock border-t border-fg-08 px-4 py-2 flex flex-col gap-1 select-none ${className}`.trim()} style={{ background: 'var(--kol-surface-primary)' }}>
251
+ <ScrubRuler t={t} onSeek={onSeek} />
252
+ {tracks.map((track) => (
253
+ <TrackRow key={track.id} track={track} t={t} selected={selected} setSelected={setSelected} writeKeys={writeKeys} />
254
+ ))}
255
+ <SelectedKeyEditor tracks={tracks} selected={selected} setSelected={setSelected} writeKeys={writeKeys} easingOptions={easingOptions} />
256
+ </div>
257
+ )
258
+ }