@kolkrabbi/kol-component 0.198.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 +1 -1
- package/src/index.js +1 -0
- package/src/organisms/TimelineDock.jsx +258 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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",
|
package/src/index.js
CHANGED
|
@@ -75,6 +75,7 @@ export { default as PathNodeOverlay } from './atoms/PathNodeOverlay.jsx'
|
|
|
75
75
|
export { default as CropOverlay } from './atoms/CropOverlay.jsx'
|
|
76
76
|
export { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
|
|
77
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'
|
|
78
79
|
export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
|
|
79
80
|
|
|
80
81
|
// molecules
|
|
@@ -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
|
+
}
|