@kolkrabbi/kol-component 0.199.0 → 0.201.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.
|
|
3
|
+
"version": "0.201.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
|
@@ -76,6 +76,8 @@ 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
78
|
export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
|
|
79
|
+
export { default as CurveEditor, CURVE_KINDS, defaultCurveFor } from './organisms/CurveEditor.jsx'
|
|
80
|
+
export { default as KeyframeEditor, KEYFRAME_EASES, DEFAULT_KEYFRAMES } from './organisms/KeyframeEditor.jsx'
|
|
79
81
|
export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
|
|
80
82
|
|
|
81
83
|
// molecules
|
|
@@ -156,6 +158,9 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
|
|
|
156
158
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
157
159
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
158
160
|
export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
161
|
+
/* the settings BODY as data — one call for the page, the drawer and a sheet
|
|
162
|
+
* (user 2026-09-03: "lets normalise a component they can grab") */
|
|
163
|
+
export { default as SettingsSections } from './organisms/SettingsSections.jsx'
|
|
159
164
|
export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
160
165
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
161
166
|
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import Button from '../atoms/Button.jsx'
|
|
2
|
+
import Input from '../atoms/Input.jsx'
|
|
3
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
4
|
+
import LabeledControl from '../molecules/LabeledControl.jsx'
|
|
5
|
+
|
|
6
|
+
/* The six curve kinds the picker offers. A consumer with more passes `kinds`. */
|
|
7
|
+
export const CURVE_KINDS = [
|
|
8
|
+
{ value: 'epicycle', label: 'Epicycle' },
|
|
9
|
+
{ value: 'polar', label: 'Polar' },
|
|
10
|
+
{ value: 'param2d', label: 'Parametric 2D' },
|
|
11
|
+
{ value: 'param3d', label: 'Parametric 3D' },
|
|
12
|
+
{ value: 'points', label: 'Points' },
|
|
13
|
+
{ value: 'maurer', label: 'Maurer rose' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const TAU = Math.PI * 2
|
|
17
|
+
|
|
18
|
+
/* Fresh per-kind defaults — the engine's `defaultCustomFor`, kept as the
|
|
19
|
+
* default so a kind switch has somewhere to land. Injectable via `defaultFor`. */
|
|
20
|
+
export function defaultCurveFor(kind) {
|
|
21
|
+
switch (kind) {
|
|
22
|
+
case 'epicycle': return { kind, turns: 2, terms: [{ amp: 1, freq: 1, phase: 0 }] }
|
|
23
|
+
case 'param2d': return { kind, range: [0, TAU], x: 'sin(3*t)', y: 'sin(2*t + 0.6)' }
|
|
24
|
+
case 'param3d': return { kind, range: [0, 6 * TAU], x: 'cos(t)', y: 'sin(t)', z: '0.3*t' }
|
|
25
|
+
case 'points': return { kind, count: 1400, a: 'k*TAU/(PHI*PHI)', r: 'sqrt(k)' }
|
|
26
|
+
case 'maurer': return { kind, n: 6, d: 71 }
|
|
27
|
+
default: return { kind: 'polar', range: [0, 6 * TAU], r: '3*sin(6*th)' }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const num = (v, fb) => { const n = Number(v); return Number.isFinite(n) ? n : fb }
|
|
32
|
+
|
|
33
|
+
/* Draft/commit number — `Input onCommit` IS the idiom since 0.196.0. */
|
|
34
|
+
function NumberField({ value, onCommit, ...rest }) {
|
|
35
|
+
return <Input type="number" variant="filled" size="sm" {...rest} value={String(value)} onCommit={onCommit} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Draft/commit text input for an expression, mono. A string that does not
|
|
39
|
+
* validate is STILL committed — the renderer keeps its last good function —
|
|
40
|
+
* and the field shows the hint until it is fixed. That is what makes live
|
|
41
|
+
* expression editing bearable; keep it. */
|
|
42
|
+
function ExprField({ label, value, args, validate, onCommit }) {
|
|
43
|
+
const bad = validate && String(value ?? '').trim() !== '' && !validate(value, args)
|
|
44
|
+
return (
|
|
45
|
+
<LabeledControl label={label} hint={bad ? 'doesn’t compile — last good kept' : undefined}>
|
|
46
|
+
<Input
|
|
47
|
+
variant="filled" size="sm" className="w-full"
|
|
48
|
+
style={{ fontFamily: 'var(--kol-font-family-mono, monospace)', fontVariantLigatures: 'none' }}
|
|
49
|
+
value={value ?? ''}
|
|
50
|
+
onCommit={(v) => { if (v !== value) onCommit(v) }}
|
|
51
|
+
/>
|
|
52
|
+
</LabeledControl>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Start/end of the curve's parameter range. */
|
|
57
|
+
function RangeFields({ def, commit }) {
|
|
58
|
+
const [a, b] = def.range || [0, 1]
|
|
59
|
+
return (
|
|
60
|
+
<div className="grid grid-cols-2 gap-2">
|
|
61
|
+
<LabeledControl label="Start">
|
|
62
|
+
<NumberField value={a} onCommit={(v) => commit({ range: [num(v, a), b] })} />
|
|
63
|
+
</LabeledControl>
|
|
64
|
+
<LabeledControl label="End">
|
|
65
|
+
<NumberField value={b} onCommit={(v) => commit({ range: [a, num(v, b)] })} />
|
|
66
|
+
</LabeledControl>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Epicycle = a sum of rotating vectors; each term {amp, freq, phase} is one
|
|
72
|
+
* vector (add/remove terms = the vector array). */
|
|
73
|
+
function TermsEditor({ def, commit }) {
|
|
74
|
+
const terms = def.terms || []
|
|
75
|
+
const setTerm = (i, key, v) => commit({ terms: terms.map((tm, j) => (j === i ? { ...tm, [key]: v } : tm)) })
|
|
76
|
+
const addTerm = () => commit({ terms: [...terms, { amp: 0.5, freq: 2, phase: 0 }] })
|
|
77
|
+
const removeTerm = (i) => commit({ terms: terms.filter((_, j) => j !== i) })
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-2">
|
|
80
|
+
<div className="flex items-center justify-between">
|
|
81
|
+
<span className="kol-helper-10 text-meta">Vectors</span>
|
|
82
|
+
<Button variant="primary" size="sm" onClick={addTerm}>Add</Button>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-1.5 items-center min-w-0 [&>*]:min-w-0">
|
|
85
|
+
<span className="kol-helper-10 text-fg-48 text-center">amp</span>
|
|
86
|
+
<span className="kol-helper-10 text-fg-48 text-center">freq</span>
|
|
87
|
+
<span className="kol-helper-10 text-fg-48 text-center">phase</span>
|
|
88
|
+
<span />
|
|
89
|
+
{terms.map((tm, i) => (
|
|
90
|
+
<TermRow key={i} term={tm} onSet={(k, v) => setTerm(i, k, v)} onRemove={() => removeTerm(i)} canRemove={terms.length > 1} />
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function TermRow({ term, onSet, onRemove, canRemove }) {
|
|
98
|
+
const cell = (key, fallback) => (
|
|
99
|
+
<NumberField value={term[key] ?? fallback} onCommit={(v) => onSet(key, num(v, term[key] ?? fallback))} />
|
|
100
|
+
)
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{cell('amp', 1)}
|
|
104
|
+
{cell('freq', 1)}
|
|
105
|
+
{cell('phase', 0)}
|
|
106
|
+
<Button variant="ghost" size="sm" onClick={onRemove} disabled={!canRemove} aria-label="Remove vector">×</Button>
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* CurveEditor — curve authoring: a kind picker, per-kind ranges and
|
|
113
|
+
* EXPRESSION fields, and the epicycle term list.
|
|
114
|
+
*
|
|
115
|
+
* Lifted from kol-fxr's editor (`compose/inspectors/CurveEditor.jsx`,
|
|
116
|
+
* `editor-panels-the-held-specs` A4, 2026-09-03) with its two couplings
|
|
117
|
+
* dropped: the compiler (`loops/math/mathfn`) is the `validate` seam, and the
|
|
118
|
+
* stock-clip table (`loops/math/curves`) is the `stock` prop.
|
|
119
|
+
*
|
|
120
|
+
* FORK-ON-EDIT, kept exactly: while the layer shows a stock clip the editor
|
|
121
|
+
* displays that clip's definition (`value`), and the FIRST commit forks it —
|
|
122
|
+
* `onChange` fires with `fork: true` and the extras the fork should seed
|
|
123
|
+
* (`copies` / `spiral` from the stock clip, unless the caller says they were
|
|
124
|
+
* already moved). The shared clip table is never mutated; the caller writes
|
|
125
|
+
* `{ clip: 'custom', custom, ...extras }`. A kind switch replaces the whole
|
|
126
|
+
* def with that kind's defaults.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} value - The curve def being edited: `{ kind, range?, r?, x?, y?, z?, turns?, terms?, count?, a?, n?, d? }`
|
|
129
|
+
* @param {Function} onChange - `(nextDef, { fork, extras }) => void` — `fork` is true when this commit leaves a stock clip; `extras` carries what the fork seeds
|
|
130
|
+
* @param {{label: string, copies?: number, spiral?: number, layerCopies?: number, layerSpiral?: number}} [stock] - Present while the layer still shows a STOCK clip: its label, its authored `copies` / `spiral`, and the layer's current ones so the fork only seeds what the user has not moved. Omit once the curve is custom
|
|
131
|
+
* @param {Function} [validate] - `(expr, args) => boolean` — the consumer's compiler; omitted, every expression is accepted without a hint
|
|
132
|
+
* @param {Array<{value: string, label: string}>} [kinds] - The kind picker (default `CURVE_KINDS`)
|
|
133
|
+
* @param {Function} [defaultFor] - `(kind) => def` for a kind switch (default `defaultCurveFor`)
|
|
134
|
+
* @param {string} [className] - Extra classes on the editor
|
|
135
|
+
*/
|
|
136
|
+
export default function CurveEditor({ value, onChange, stock = null, validate, kinds = CURVE_KINDS, defaultFor = defaultCurveFor, className = '' }) {
|
|
137
|
+
const def = value ?? defaultFor('polar')
|
|
138
|
+
const kind = def.kind || 'polar'
|
|
139
|
+
const isCustom = stock == null
|
|
140
|
+
|
|
141
|
+
/* Any commit forks a stock clip — and seeds the copies/spiral the stock
|
|
142
|
+
* clip authored, unless the layer already moved them. */
|
|
143
|
+
const commit = (defPatch) => {
|
|
144
|
+
const extras = {}
|
|
145
|
+
if (!isCustom) {
|
|
146
|
+
if ((stock.layerCopies ?? 1) <= 1 && (stock.copies ?? 0) > 1) extras.copies = stock.copies
|
|
147
|
+
if (!((stock.layerSpiral ?? 0) > 0) && (stock.spiral ?? 0) > 0) extras.spiral = stock.spiral
|
|
148
|
+
}
|
|
149
|
+
onChange?.({ ...def, ...defPatch }, { fork: !isCustom, extras })
|
|
150
|
+
}
|
|
151
|
+
const setKind = (k) => {
|
|
152
|
+
if (k === kind) return
|
|
153
|
+
onChange?.(defaultFor(k), { fork: !isCustom, extras: {} })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className={`kol-curve-editor flex flex-col gap-3 ${className}`.trim()}>
|
|
158
|
+
<span className="kol-helper-10 text-meta">Curve</span>
|
|
159
|
+
<Dropdown size="sm" variant="subtle" className="w-full" options={kinds} value={kind} onChange={setKind} />
|
|
160
|
+
{!isCustom && (
|
|
161
|
+
<p className="kol-helper-10 text-meta">Editing “{stock.label}” forks it to a custom curve.</p>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{kind === 'epicycle' && (
|
|
165
|
+
<>
|
|
166
|
+
<LabeledControl label="Turns">
|
|
167
|
+
<NumberField value={def.turns ?? 1} onCommit={(v) => commit({ turns: num(v, def.turns ?? 1) })} />
|
|
168
|
+
</LabeledControl>
|
|
169
|
+
<TermsEditor def={def} commit={commit} />
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{kind === 'polar' && (
|
|
174
|
+
<>
|
|
175
|
+
<RangeFields def={def} commit={commit} />
|
|
176
|
+
<ExprField label="r(th)" value={def.r} args={['th']} validate={validate} onCommit={(v) => commit({ r: v })} />
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{(kind === 'param2d' || kind === 'param3d') && (
|
|
181
|
+
<>
|
|
182
|
+
<RangeFields def={def} commit={commit} />
|
|
183
|
+
<ExprField label="x(t)" value={def.x} args={['t']} validate={validate} onCommit={(v) => commit({ x: v })} />
|
|
184
|
+
<ExprField label="y(t)" value={def.y} args={['t']} validate={validate} onCommit={(v) => commit({ y: v })} />
|
|
185
|
+
{kind === 'param3d' && <ExprField label="z(t)" value={def.z} args={['t']} validate={validate} onCommit={(v) => commit({ z: v })} />}
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{kind === 'points' && (
|
|
190
|
+
<>
|
|
191
|
+
<LabeledControl label="Count">
|
|
192
|
+
<NumberField value={def.count ?? 1400} onCommit={(v) => commit({ count: Math.max(1, Math.round(num(v, def.count ?? 1400))) })} />
|
|
193
|
+
</LabeledControl>
|
|
194
|
+
<ExprField label="a(k)" value={def.a} args={['k']} validate={validate} onCommit={(v) => commit({ a: v })} />
|
|
195
|
+
<ExprField label="r(k)" value={def.r} args={['k']} validate={validate} onCommit={(v) => commit({ r: v })} />
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{kind === 'maurer' && (
|
|
200
|
+
<div className="grid grid-cols-2 gap-2">
|
|
201
|
+
<LabeledControl label="n"><NumberField value={def.n ?? 6} onCommit={(v) => commit({ n: num(v, def.n ?? 6) })} /></LabeledControl>
|
|
202
|
+
<LabeledControl label="d°"><NumberField value={def.d ?? 71} onCommit={(v) => commit({ d: num(v, def.d ?? 71) })} /></LabeledControl>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
4
|
+
import LabeledControl from '../molecules/LabeledControl.jsx'
|
|
5
|
+
import Slider from '../molecules/Slider.jsx'
|
|
6
|
+
|
|
7
|
+
const deg = (r) => Math.round(((r || 0) * 180) / Math.PI)
|
|
8
|
+
const rad = (d) => (d * Math.PI) / 180
|
|
9
|
+
|
|
10
|
+
/* The engine's easings by name; the curves are the consumer's interpolator's. */
|
|
11
|
+
export const KEYFRAME_EASES = [
|
|
12
|
+
{ value: 'linear', label: 'Linear' },
|
|
13
|
+
{ value: 'in', label: 'Ease in' },
|
|
14
|
+
{ value: 'out', label: 'Ease out' },
|
|
15
|
+
{ value: 'inout', label: 'Ease in-out' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_KEYFRAMES = [
|
|
19
|
+
{ t: 0, rot: [0, 0, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' },
|
|
20
|
+
{ t: 1, rot: [0, Math.PI * 2, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* KeyframeEditor — a keyframe list over a pose track, kept sorted by `t`.
|
|
25
|
+
*
|
|
26
|
+
* { t: 0..1, rot: [x, y, z] RADIANS, pos: [x, y, z], scale, ease }
|
|
27
|
+
*
|
|
28
|
+
* Rotations are stored in radians (engine-native) and EDITED IN DEGREES here.
|
|
29
|
+
* Selecting a key pauses the clock and seeks to its `t`, so the live render is
|
|
30
|
+
* the pose being edited; "Add @ playhead" reads the clock's current `t`.
|
|
31
|
+
*
|
|
32
|
+
* Lifted from kol-fxr's editor (`compose/inspectors/KeyframeEditor.jsx`,
|
|
33
|
+
* `editor-panels-the-held-specs` A5, 2026-09-03) with the transport singleton
|
|
34
|
+
* and the layer patch path dropped, as the row asked: the track is `keyframes`
|
|
35
|
+
* + `onChange`, the clock is `t` + `onSeek` + `onPause`. The engine's
|
|
36
|
+
* cycles arithmetic (a layer's phase runs N loops per transport loop) is the
|
|
37
|
+
* CONSUMER's — it hands in the layer-local `t` and maps `onSeek`'s local `t`
|
|
38
|
+
* back to its global playhead. The pose shape stays rot / pos / scale; the
|
|
39
|
+
* row notes it should be schema-described, and that is a design change
|
|
40
|
+
* rather than a port, so it is not done here.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array<Object>} keyframes - The track; empty falls back to `DEFAULT_KEYFRAMES`
|
|
43
|
+
* @param {Function} onChange - `(keyframes) => void` — the whole track, sorted
|
|
44
|
+
* @param {number} t - The clock's current LAYER-LOCAL phase, 0..1 — what "Add @ playhead" stamps
|
|
45
|
+
* @param {Function} onSeek - `(t) => void` — a key was selected; seek the clock to its local `t`
|
|
46
|
+
* @param {Function} [onPause] - Called before the seek, so the render holds on the pose
|
|
47
|
+
* @param {Array<{value: string, label: string}>} [easeOptions] - The Ease menu (default `KEYFRAME_EASES`)
|
|
48
|
+
* @param {Array<Object>} [defaultKeyframes] - What an empty track shows (default `DEFAULT_KEYFRAMES`)
|
|
49
|
+
*/
|
|
50
|
+
export default function KeyframeEditor({
|
|
51
|
+
keyframes,
|
|
52
|
+
onChange,
|
|
53
|
+
t = 0,
|
|
54
|
+
onSeek,
|
|
55
|
+
onPause,
|
|
56
|
+
easeOptions = KEYFRAME_EASES,
|
|
57
|
+
defaultKeyframes = DEFAULT_KEYFRAMES,
|
|
58
|
+
}) {
|
|
59
|
+
const kfs = Array.isArray(keyframes) && keyframes.length ? keyframes : defaultKeyframes
|
|
60
|
+
const [selected, setSelected] = useState(0)
|
|
61
|
+
const sel = Math.min(selected, kfs.length - 1)
|
|
62
|
+
const k = kfs[sel] || { rot: [0, 0, 0], pos: [0, 0, 0], scale: 1 }
|
|
63
|
+
|
|
64
|
+
const write = (next) => onChange?.(next)
|
|
65
|
+
|
|
66
|
+
const onSelect = (i) => {
|
|
67
|
+
setSelected(i)
|
|
68
|
+
onPause?.()
|
|
69
|
+
onSeek?.(kfs[i].t ?? 0)
|
|
70
|
+
}
|
|
71
|
+
const onAdd = () => {
|
|
72
|
+
const base = kfs[sel] || { rot: [0, 0, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' }
|
|
73
|
+
const nk = {
|
|
74
|
+
t: Math.max(0, Math.min(1, t)),
|
|
75
|
+
rot: [...(base.rot || [0, 0, 0])],
|
|
76
|
+
pos: [...(base.pos || [0, 0, 0])],
|
|
77
|
+
scale: base.scale ?? 1,
|
|
78
|
+
ease: base.ease || 'inout',
|
|
79
|
+
}
|
|
80
|
+
const next = [...kfs, nk].sort((a, b) => a.t - b.t)
|
|
81
|
+
write(next)
|
|
82
|
+
setSelected(next.indexOf(nk))
|
|
83
|
+
}
|
|
84
|
+
const onDelete = () => {
|
|
85
|
+
if (kfs.length <= 1) return
|
|
86
|
+
write(kfs.filter((_, i) => i !== sel))
|
|
87
|
+
setSelected((s) => Math.max(0, s - 1))
|
|
88
|
+
}
|
|
89
|
+
const onPatch = (p) => write(kfs.map((kf, i) => (i === sel ? { ...kf, ...p } : kf)))
|
|
90
|
+
|
|
91
|
+
const setRot = (axis, d) => { const r = [...(k.rot || [0, 0, 0])]; r[axis] = rad(d); onPatch({ rot: r }) }
|
|
92
|
+
const setPos = (axis, v) => { const p = [...(k.pos || [0, 0, 0])]; p[axis] = v; onPatch({ pos: p }) }
|
|
93
|
+
|
|
94
|
+
const pose = (label, min, max, step, value, onValue) => (
|
|
95
|
+
<LabeledControl label={label}>
|
|
96
|
+
<Slider min={min} max={max} step={step} value={value} onChange={onValue} />
|
|
97
|
+
</LabeledControl>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="kol-keyframe-editor flex flex-col gap-3">
|
|
102
|
+
<span className="kol-helper-10 text-meta">Keyframes</span>
|
|
103
|
+
<div className="flex flex-col gap-1">
|
|
104
|
+
{kfs.map((kf, i) => (
|
|
105
|
+
<Button
|
|
106
|
+
key={i}
|
|
107
|
+
variant={i === sel ? 'primary' : 'secondary'}
|
|
108
|
+
size="sm"
|
|
109
|
+
className="w-full"
|
|
110
|
+
style={{ justifyContent: 'space-between' }}
|
|
111
|
+
onClick={() => onSelect(i)}
|
|
112
|
+
>
|
|
113
|
+
<span>Key {i + 1}</span>
|
|
114
|
+
<span className="kol-helper-10 tabular-nums">{Math.round((kf.t ?? 0) * 100)}%</span>
|
|
115
|
+
</Button>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex gap-2">
|
|
119
|
+
<Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
|
|
120
|
+
<Button variant="ghost" size="sm" title="Delete keyframe" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<span className="kol-helper-10 text-meta">Pose</span>
|
|
124
|
+
{pose('Rotate X', -360, 360, 1, deg(k.rot?.[0]), (v) => setRot(0, v))}
|
|
125
|
+
{pose('Rotate Y', -360, 360, 1, deg(k.rot?.[1]), (v) => setRot(1, v))}
|
|
126
|
+
{pose('Rotate Z', -360, 360, 1, deg(k.rot?.[2]), (v) => setRot(2, v))}
|
|
127
|
+
{pose('Move X', -2, 2, 0.05, k.pos?.[0] || 0, (v) => setPos(0, v))}
|
|
128
|
+
{pose('Move Y', -2, 2, 0.05, k.pos?.[1] || 0, (v) => setPos(1, v))}
|
|
129
|
+
{pose('Move Z', -2, 2, 0.05, k.pos?.[2] || 0, (v) => setPos(2, v))}
|
|
130
|
+
{pose('Scale', 0.2, 2, 0.05, k.scale ?? 1, (v) => onPatch({ scale: v }))}
|
|
131
|
+
<LabeledControl label="Ease">
|
|
132
|
+
<Dropdown variant="subtle" size="sm" className="w-full" options={easeOptions} value={k.ease || 'inout'} onChange={(v) => onPatch({ ease: v })} />
|
|
133
|
+
</LabeledControl>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { LabeledControlSection, SettingsRow } from './SettingsPanel.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SettingsSections — settings rows AS DATA, rendered once.
|
|
5
|
+
*
|
|
6
|
+
* The one component a consumer grabs for a settings body (user, 2026-09-03:
|
|
7
|
+
* *"lets normalise a component they can grab"*). The estate had the PIECES —
|
|
8
|
+
* `LabeledControlSection` for the eyebrow-headed section, `SettingsRow` for the
|
|
9
|
+
* 160px label column — and every surface looped over them itself: kol-fxr's
|
|
10
|
+
* `AppSettingsSections`, the settings page, the drawer, the shortcuts sheet.
|
|
11
|
+
* Four hand-written loops over one anatomy is how the sheet ended up with a
|
|
12
|
+
* plain `text-fg-32` heading and `fg-96` combos while the page two clicks away
|
|
13
|
+
* had eyebrows and `fg-32`.
|
|
14
|
+
*
|
|
15
|
+
* THE SECTIONS ARE A VALUE, so one declaration feeds every frame that shows
|
|
16
|
+
* them — the page, the drawer over what you are looking at, and a sheet:
|
|
17
|
+
*
|
|
18
|
+
* const sections = [
|
|
19
|
+
* { label: 'Canvas', rows: [
|
|
20
|
+
* { label: 'Default aspect', render: () => <SettingsChoice … /> },
|
|
21
|
+
* { label: 'Autoplay', render: () => <SettingsSwitch … /> },
|
|
22
|
+
* ] },
|
|
23
|
+
* ]
|
|
24
|
+
*
|
|
25
|
+
* <SettingsPanel open={open} onClose={close}><SettingsSections sections={sections} /></SettingsPanel>
|
|
26
|
+
* <SettingsScaffold renderContent={() => <SettingsSections sections={visible} />} />
|
|
27
|
+
*
|
|
28
|
+
* A row is `{ label, render, align?, id? }` — `render` returns the control, so
|
|
29
|
+
* the CONTROL stays the consumer's and this owns only the anatomy. A row may
|
|
30
|
+
* also carry a plain `value` instead of `render` when the right cell is just
|
|
31
|
+
* text (a keyboard combo, a version string), which is what lets the shortcuts
|
|
32
|
+
* sheet and the settings page's shortcut block be the same call.
|
|
33
|
+
*
|
|
34
|
+
* `divided` puts the hairline above a section — pass it on EVERY section, the
|
|
35
|
+
* first included: the rule is `.kol-section--divided + .kol-section--divided`,
|
|
36
|
+
* so the first draws nothing and skipping it just costs the next its line.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order
|
|
39
|
+
* @param {boolean} [divided=false] - Hairline above each section
|
|
40
|
+
* @param {number|'auto'} [labelWidth] - Default label column for every row — `'auto'` makes the label yield and the control hug, which is what a narrow column or a nowrap value needs
|
|
41
|
+
* @param {string} [align] - Default row alignment passed to `SettingsRow`
|
|
42
|
+
* @param {string} [className] - Extra classes on the wrapper
|
|
43
|
+
*/
|
|
44
|
+
export default function SettingsSections({ sections = [], divided = false, labelWidth, align, className = '' }) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={`kol-settings-sections flex flex-col gap-6 ${className}`.trim()}>
|
|
47
|
+
{sections.map((sec, i) => (
|
|
48
|
+
<LabeledControlSection key={sec.label ?? i} label={sec.label} divided={divided} rowGap={sec.rowGap}>
|
|
49
|
+
{sec.rows.map((row, j) => (
|
|
50
|
+
<SettingsRow
|
|
51
|
+
key={row.id ?? row.label ?? j}
|
|
52
|
+
label={row.label}
|
|
53
|
+
align={row.align ?? align}
|
|
54
|
+
labelWidth={row.labelWidth ?? labelWidth}
|
|
55
|
+
>
|
|
56
|
+
{row.render ? row.render() : row.value}
|
|
57
|
+
</SettingsRow>
|
|
58
|
+
))}
|
|
59
|
+
</LabeledControlSection>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|