@preset.nz/ux-kit 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +90 -2
- package/package.json +51 -3
- package/src/components/avatar.tsx +35 -0
- package/src/components/badge.tsx +42 -0
- package/src/components/breadcrumb.tsx +102 -0
- package/src/components/button.tsx +56 -0
- package/src/components/card.tsx +92 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/color-field.tsx +248 -0
- package/src/components/color-swatch.tsx +52 -0
- package/src/components/context-menu.tsx +249 -0
- package/src/components/dialog.tsx +137 -0
- package/src/components/dropdown-menu.tsx +249 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/input.tsx +20 -0
- package/src/components/label.tsx +18 -0
- package/src/components/navigation-menu.tsx +144 -0
- package/src/components/number-field.tsx +428 -0
- package/src/components/popover.tsx +82 -0
- package/src/components/select.tsx +201 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/sheet.tsx +124 -0
- package/src/components/side-panel.tsx +166 -0
- package/src/components/sidebar.tsx +735 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +75 -0
- package/src/components/snackbar.tsx +141 -0
- package/src/components/status-bar.tsx +38 -0
- package/src/components/tabs.tsx +78 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/toggle-group.tsx +86 -0
- package/src/components/toggle.tsx +43 -0
- package/src/components/toolbar.tsx +307 -0
- package/src/components/tooltip.tsx +54 -0
- package/src/graphics/adsr.ts +70 -0
- package/src/graphics/curve-editor.tsx +525 -0
- package/src/graphics/curve-edits.ts +129 -0
- package/src/graphics/index.ts +16 -0
- package/src/graphics/scale.ts +22 -0
- package/src/graphics/use-canvas.ts +56 -0
- package/src/graphics/use-tokens.ts +32 -0
- package/src/icons.ts +3 -0
- package/src/index.css +199 -0
- package/src/index.ts +37 -0
- package/src/lib/use-mobile.ts +18 -0
- package/src/lib/utils.ts +6 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { evaluate, type Basis, type Curve } from "@preset.nz/math"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ContextMenu,
|
|
6
|
+
ContextMenuCheckboxItem,
|
|
7
|
+
ContextMenuContent,
|
|
8
|
+
ContextMenuGroup,
|
|
9
|
+
ContextMenuItem,
|
|
10
|
+
ContextMenuLabel,
|
|
11
|
+
ContextMenuRadioGroup,
|
|
12
|
+
ContextMenuRadioItem,
|
|
13
|
+
ContextMenuSeparator,
|
|
14
|
+
ContextMenuShortcut,
|
|
15
|
+
ContextMenuTrigger,
|
|
16
|
+
} from "../components/context-menu"
|
|
17
|
+
import { cn } from "../lib/utils"
|
|
18
|
+
import {
|
|
19
|
+
addPoint,
|
|
20
|
+
movePoint,
|
|
21
|
+
removePoint,
|
|
22
|
+
setBasis,
|
|
23
|
+
setTension,
|
|
24
|
+
tensionForMidpoint,
|
|
25
|
+
toggleSustain,
|
|
26
|
+
type CurveConstraint,
|
|
27
|
+
type Domain,
|
|
28
|
+
} from "./curve-edits"
|
|
29
|
+
import { linearScale } from "./scale"
|
|
30
|
+
import { useCanvasDraw, useSize } from "./use-canvas"
|
|
31
|
+
import { useTokenColours } from "./use-tokens"
|
|
32
|
+
|
|
33
|
+
/** How an edit arrived: inside a drag, as one keyboard nudge, or as a single discrete edit. */
|
|
34
|
+
export type CurveEditKind = "gesture" | "nudge" | "edit"
|
|
35
|
+
|
|
36
|
+
export interface CurveEditorProps {
|
|
37
|
+
value: Curve
|
|
38
|
+
/**
|
|
39
|
+
* Every change. Inside a drag (`"gesture"`) the calls fall between `onGestureStart` and
|
|
40
|
+
* `onGestureEnd`, so the host makes the drag one undo step. A nudge is streamed input the
|
|
41
|
+
* host may coalesce; an edit is one step on its own.
|
|
42
|
+
*/
|
|
43
|
+
onChange: (next: Curve, kind: CurveEditKind) => void
|
|
44
|
+
onGestureStart?: () => void
|
|
45
|
+
/**
|
|
46
|
+
* The drag is over. `false` when Escape cancelled it, after `onChange` has already put the
|
|
47
|
+
* curve back as it was when the drag began.
|
|
48
|
+
*/
|
|
49
|
+
onGestureEnd?: (committed: boolean) => void
|
|
50
|
+
/**
|
|
51
|
+
* `transfer`: input to output, 0..1 on both axes, over a histogram. `envelope`: x is time,
|
|
52
|
+
* over a waveform, and a point can carry the sustain mark.
|
|
53
|
+
*/
|
|
54
|
+
mode?: "transfer" | "envelope"
|
|
55
|
+
/** The x range. Defaults to 0..1. An envelope's is seconds, beats, or 0..1 when fitted. */
|
|
56
|
+
xDomain?: readonly [number, number]
|
|
57
|
+
/** The y range. Defaults to 0..1. */
|
|
58
|
+
yDomain?: readonly [number, number]
|
|
59
|
+
/** Limits applied to every edit. See `CurveConstraint`. */
|
|
60
|
+
constrain?: CurveConstraint
|
|
61
|
+
/** How an x or y value reads in the point readout. */
|
|
62
|
+
formatX?: (x: number) => string
|
|
63
|
+
formatY?: (y: number) => string
|
|
64
|
+
/** Drawn behind the curve, inside the plot area: a `Histogram` or a `Waveform`. */
|
|
65
|
+
background?: React.ReactNode
|
|
66
|
+
className?: string
|
|
67
|
+
"aria-label"?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PAD = 10
|
|
71
|
+
const POINT_R = 4.5
|
|
72
|
+
const HIT_R = 10
|
|
73
|
+
const NUDGE = 0.01
|
|
74
|
+
const DRAG_THRESHOLD = 3
|
|
75
|
+
const BASES: { value: Basis; label: string }[] = [
|
|
76
|
+
{ value: "constant", label: "Constant" },
|
|
77
|
+
{ value: "linear", label: "Linear" },
|
|
78
|
+
{ value: "monotone", label: "Monotone" },
|
|
79
|
+
{ value: "catmull-rom", label: "Catmull-Rom" },
|
|
80
|
+
]
|
|
81
|
+
const TOKENS = ["foreground", "muted-foreground", "border", "primary", "warning"] as const
|
|
82
|
+
|
|
83
|
+
/** A press on a handle. It becomes a gesture only once it has moved `DRAG_THRESHOLD` pixels. */
|
|
84
|
+
type Drag = {
|
|
85
|
+
kind: "point" | "tension"
|
|
86
|
+
index: number
|
|
87
|
+
start: Curve
|
|
88
|
+
pointer: number
|
|
89
|
+
from: { x: number; y: number }
|
|
90
|
+
started: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type MenuTarget =
|
|
94
|
+
| { kind: "point"; index: number }
|
|
95
|
+
| { kind: "segment"; index: number }
|
|
96
|
+
| { kind: "empty"; x: number; y: number }
|
|
97
|
+
|
|
98
|
+
const defaultFormat = (v: number) => v.toFixed(3)
|
|
99
|
+
|
|
100
|
+
/** Whether two curves draw the same, whatever order the host's copy keeps its keys in. */
|
|
101
|
+
function sameCurve(a: Curve, b: Curve): boolean {
|
|
102
|
+
if (a.points.length !== b.points.length || (a.sustain ?? -1) !== (b.sustain ?? -1)) return false
|
|
103
|
+
return a.points.every((p, i) => {
|
|
104
|
+
const q = b.points[i]!
|
|
105
|
+
return p.x === q.x && p.y === q.y && p.basis === q.basis && (p.tension ?? 0) === (q.tension ?? 0)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Edits a `@preset.nz/math` curve. The line is drawn on a canvas by sampling math's own
|
|
111
|
+
* `evaluate`, so it is exactly what Rust will compute. Points and tension handles are SVG on
|
|
112
|
+
* top, focusable and keyboard driven. Double-click to add a point; Delete removes the selected
|
|
113
|
+
* one; arrows nudge it (Shift ×10, Option ×0.1). Basis and sustain are in the context menu.
|
|
114
|
+
*/
|
|
115
|
+
export function CurveEditor({
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
onGestureStart,
|
|
119
|
+
onGestureEnd,
|
|
120
|
+
mode = "transfer",
|
|
121
|
+
xDomain = [0, 1],
|
|
122
|
+
yDomain = [0, 1],
|
|
123
|
+
constrain,
|
|
124
|
+
formatX = defaultFormat,
|
|
125
|
+
formatY = defaultFormat,
|
|
126
|
+
background,
|
|
127
|
+
className,
|
|
128
|
+
"aria-label": ariaLabel = "Curve",
|
|
129
|
+
}: CurveEditorProps) {
|
|
130
|
+
const box = React.useRef<HTMLDivElement>(null)
|
|
131
|
+
const canvas = React.useRef<HTMLCanvasElement>(null)
|
|
132
|
+
const svg = React.useRef<SVGSVGElement>(null)
|
|
133
|
+
const size = useSize(box)
|
|
134
|
+
const colours = useTokenColours(TOKENS)
|
|
135
|
+
|
|
136
|
+
// The scrub rule: during a drag the view shows its own value, not the host's echo. After
|
|
137
|
+
// release it keeps showing it until the host's value catches up, so the line can't flick back
|
|
138
|
+
// to an echo from mid-drag.
|
|
139
|
+
const [live, setLive] = React.useState<{ curve: Curve; held: boolean } | null>(null)
|
|
140
|
+
const drag = React.useRef<Drag | null>(null)
|
|
141
|
+
if (live?.held && sameCurve(live.curve, value)) setLive(null)
|
|
142
|
+
const curve = live?.curve ?? value
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
if (!live?.held) return
|
|
145
|
+
// A host that normalises the curve never echoes it exactly; let go regardless.
|
|
146
|
+
const timer = setTimeout(() => setLive(null), 1000)
|
|
147
|
+
return () => clearTimeout(timer)
|
|
148
|
+
}, [live])
|
|
149
|
+
const [selected, setSelected] = React.useState<number | null>(null)
|
|
150
|
+
const [menu, setMenu] = React.useState<MenuTarget | null>(null)
|
|
151
|
+
|
|
152
|
+
const [x0, x1] = xDomain
|
|
153
|
+
const [y0, y1] = yDomain
|
|
154
|
+
const domain: Domain = React.useMemo(() => ({ x: [x0, x1], y: [y0, y1] }), [x0, x1, y0, y1])
|
|
155
|
+
const sx = React.useMemo(() => linearScale([x0, x1], [PAD, Math.max(PAD, size.width - PAD)]), [x0, x1, size.width])
|
|
156
|
+
const sy = React.useMemo(() => linearScale([y0, y1], [Math.max(PAD, size.height - PAD), PAD]), [y0, y1, size.height])
|
|
157
|
+
|
|
158
|
+
const sel = selected !== null && selected < curve.points.length ? selected : null
|
|
159
|
+
|
|
160
|
+
useCanvasDraw(
|
|
161
|
+
canvas,
|
|
162
|
+
size,
|
|
163
|
+
(ctx, { width, height }) => {
|
|
164
|
+
// Quarters of each axis, faint.
|
|
165
|
+
ctx.strokeStyle = colours.border
|
|
166
|
+
ctx.lineWidth = 1
|
|
167
|
+
ctx.beginPath()
|
|
168
|
+
for (let k = 0; k <= 4; k++) {
|
|
169
|
+
const px = Math.round(sx(x0 + ((x1 - x0) * k) / 4)) + 0.5
|
|
170
|
+
const py = Math.round(sy(y0 + ((y1 - y0) * k) / 4)) + 0.5
|
|
171
|
+
ctx.moveTo(px, PAD)
|
|
172
|
+
ctx.lineTo(px, height - PAD)
|
|
173
|
+
ctx.moveTo(PAD, py)
|
|
174
|
+
ctx.lineTo(width - PAD, py)
|
|
175
|
+
}
|
|
176
|
+
ctx.stroke()
|
|
177
|
+
|
|
178
|
+
if (mode === "envelope" && curve.sustain !== undefined) {
|
|
179
|
+
const s = curve.points[curve.sustain]
|
|
180
|
+
if (s) {
|
|
181
|
+
ctx.strokeStyle = colours["muted-foreground"]
|
|
182
|
+
ctx.setLineDash([3, 3])
|
|
183
|
+
ctx.beginPath()
|
|
184
|
+
const px = Math.round(sx(s.x)) + 0.5
|
|
185
|
+
ctx.moveTo(px, PAD)
|
|
186
|
+
ctx.lineTo(px, height - PAD)
|
|
187
|
+
ctx.stroke()
|
|
188
|
+
ctx.setLineDash([])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (curve.points.length === 0) return
|
|
193
|
+
// One sample per device pixel, through math's evaluate.
|
|
194
|
+
const dpr = window.devicePixelRatio || 1
|
|
195
|
+
const steps = Math.max(2, Math.ceil((width - 2 * PAD) * dpr))
|
|
196
|
+
const line = new Path2D()
|
|
197
|
+
for (let k = 0; k <= steps; k++) {
|
|
198
|
+
const px = PAD + ((width - 2 * PAD) * k) / steps
|
|
199
|
+
const py = sy(evaluate(curve, sx.invert(px)))
|
|
200
|
+
if (k === 0) line.moveTo(px, py)
|
|
201
|
+
else line.lineTo(px, py)
|
|
202
|
+
}
|
|
203
|
+
const area = new Path2D(line)
|
|
204
|
+
area.lineTo(width - PAD, sy(y0))
|
|
205
|
+
area.lineTo(PAD, sy(y0))
|
|
206
|
+
area.closePath()
|
|
207
|
+
ctx.fillStyle = colours.primary
|
|
208
|
+
ctx.globalAlpha = 0.12
|
|
209
|
+
ctx.fill(area)
|
|
210
|
+
ctx.globalAlpha = 1
|
|
211
|
+
ctx.strokeStyle = colours.primary
|
|
212
|
+
ctx.lineWidth = 1.5
|
|
213
|
+
ctx.lineJoin = "round"
|
|
214
|
+
ctx.stroke(line)
|
|
215
|
+
},
|
|
216
|
+
[curve, colours, sx, sy, mode],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const toData = (e: { clientX: number; clientY: number }) => {
|
|
220
|
+
const r = svg.current!.getBoundingClientRect()
|
|
221
|
+
return { x: sx.invert(e.clientX - r.left), y: sy.invert(e.clientY - r.top) }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const commit = (next: Curve | null, kind: CurveEditKind) => {
|
|
225
|
+
if (next) onChange(next, kind)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const beginDrag = (e: React.PointerEvent, d: Drag) => {
|
|
229
|
+
if (e.button !== 0) return
|
|
230
|
+
e.preventDefault()
|
|
231
|
+
e.stopPropagation()
|
|
232
|
+
svg.current?.setPointerCapture(e.pointerId)
|
|
233
|
+
drag.current = d
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
237
|
+
const d = drag.current
|
|
238
|
+
if (!d || e.pointerId !== d.pointer) return
|
|
239
|
+
// A click is not a drag: nothing reaches the host, or its undo, until the pointer moves.
|
|
240
|
+
if (!d.started) {
|
|
241
|
+
if (Math.hypot(e.clientX - d.from.x, e.clientY - d.from.y) < DRAG_THRESHOLD) return
|
|
242
|
+
d.started = true
|
|
243
|
+
setLive({ curve: d.start, held: false })
|
|
244
|
+
onGestureStart?.()
|
|
245
|
+
}
|
|
246
|
+
const { x, y } = toData(e)
|
|
247
|
+
let next: Curve | null = null
|
|
248
|
+
if (d.kind === "point") {
|
|
249
|
+
next = movePoint(d.start, d.index, x, y, domain, constrain)
|
|
250
|
+
} else {
|
|
251
|
+
const a = d.start.points[d.index]
|
|
252
|
+
const b = d.start.points[d.index + 1]
|
|
253
|
+
const t = a && b ? tensionForMidpoint(a.y, b.y, y) : null
|
|
254
|
+
if (t !== null) next = setTension(d.start, d.index, t, constrain)
|
|
255
|
+
}
|
|
256
|
+
if (next) {
|
|
257
|
+
setLive({ curve: next, held: false })
|
|
258
|
+
onChange(next, "gesture")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const finishDrag = (committed: boolean) => {
|
|
263
|
+
const d = drag.current
|
|
264
|
+
if (!d) return
|
|
265
|
+
drag.current = null
|
|
266
|
+
if (!d.started) return
|
|
267
|
+
if (!committed) onChange(d.start, "gesture")
|
|
268
|
+
setLive((l) => ({ curve: committed && l ? l.curve : d.start, held: true }))
|
|
269
|
+
onGestureEnd?.(committed)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Escape cancels a drag wherever focus is.
|
|
273
|
+
React.useEffect(() => {
|
|
274
|
+
if (!drag.current?.started) return
|
|
275
|
+
const onKey = (e: KeyboardEvent) => {
|
|
276
|
+
if (e.key === "Escape" && drag.current) {
|
|
277
|
+
e.preventDefault()
|
|
278
|
+
finishDrag(false)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
window.addEventListener("keydown", onKey)
|
|
282
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const onPointKey = (e: React.KeyboardEvent, i: number) => {
|
|
286
|
+
const p = curve.points[i]
|
|
287
|
+
if (!p) return
|
|
288
|
+
if (e.key === "Delete" || e.key === "Backspace") {
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
const next = removePoint(curve, i, constrain)
|
|
291
|
+
if (next) {
|
|
292
|
+
commit(next, "edit")
|
|
293
|
+
setSelected(Math.min(i, next.points.length - 1))
|
|
294
|
+
}
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const arrows: Record<string, [number, number]> = {
|
|
298
|
+
ArrowLeft: [-1, 0],
|
|
299
|
+
ArrowRight: [1, 0],
|
|
300
|
+
ArrowUp: [0, 1],
|
|
301
|
+
ArrowDown: [0, -1],
|
|
302
|
+
}
|
|
303
|
+
const dir = arrows[e.key]
|
|
304
|
+
if (!dir) return
|
|
305
|
+
e.preventDefault()
|
|
306
|
+
const mult = e.shiftKey ? 10 : e.altKey ? 0.1 : 1
|
|
307
|
+
const dx = dir[0] * (x1 - x0) * NUDGE * mult
|
|
308
|
+
const dy = dir[1] * (y1 - y0) * NUDGE * mult
|
|
309
|
+
commit(movePoint(curve, i, p.x + dx, p.y + dy, domain, constrain), "nudge")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const onDoubleClick = (e: React.MouseEvent) => {
|
|
313
|
+
const { x, y } = toData(e)
|
|
314
|
+
const added = addPoint(curve, x, y, domain, constrain)
|
|
315
|
+
if (added) {
|
|
316
|
+
commit(added.curve, "edit")
|
|
317
|
+
setSelected(added.index)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// What the menu would do, tried against the constraint so a refused item shows disabled.
|
|
322
|
+
const target = menu
|
|
323
|
+
const menuPoint = target?.kind === "point" ? curve.points[target.index] : undefined
|
|
324
|
+
|
|
325
|
+
const readout = sel !== null ? curve.points[sel] : undefined
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<ContextMenu onOpenChange={(open) => !open && setMenu(null)}>
|
|
329
|
+
<ContextMenuTrigger
|
|
330
|
+
ref={box}
|
|
331
|
+
className={cn("relative h-48 w-full overflow-hidden rounded-md border bg-card", className)}
|
|
332
|
+
>
|
|
333
|
+
{background && (
|
|
334
|
+
<div className="pointer-events-none absolute" style={{ inset: PAD }}>
|
|
335
|
+
{background}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
<canvas ref={canvas} className="absolute inset-0 size-full" />
|
|
339
|
+
<svg
|
|
340
|
+
ref={svg}
|
|
341
|
+
role="group"
|
|
342
|
+
aria-label={ariaLabel}
|
|
343
|
+
className="absolute inset-0 size-full touch-none"
|
|
344
|
+
onPointerMove={onPointerMove}
|
|
345
|
+
onPointerUp={() => finishDrag(true)}
|
|
346
|
+
onLostPointerCapture={() => finishDrag(true)}
|
|
347
|
+
>
|
|
348
|
+
<rect
|
|
349
|
+
width="100%"
|
|
350
|
+
height="100%"
|
|
351
|
+
fill="transparent"
|
|
352
|
+
onPointerDown={() => setSelected(null)}
|
|
353
|
+
onDoubleClick={onDoubleClick}
|
|
354
|
+
onContextMenu={(e) => setMenu({ kind: "empty", ...toData(e) })}
|
|
355
|
+
/>
|
|
356
|
+
{curve.points.map((p, i) => {
|
|
357
|
+
const next = curve.points[i + 1]
|
|
358
|
+
if (!next || p.basis !== "linear" || next.x === p.x) return null
|
|
359
|
+
const mx = (p.x + next.x) / 2
|
|
360
|
+
return (
|
|
361
|
+
<g
|
|
362
|
+
key={`t${i}`}
|
|
363
|
+
className="cursor-ns-resize"
|
|
364
|
+
onPointerDown={(e) => beginDrag(e, { kind: "tension", index: i, start: curve, pointer: e.pointerId, from: { x: e.clientX, y: e.clientY }, started: false })}
|
|
365
|
+
onDoubleClick={(e) => {
|
|
366
|
+
e.stopPropagation()
|
|
367
|
+
commit(setTension(curve, i, 0, constrain), "edit")
|
|
368
|
+
}}
|
|
369
|
+
onContextMenu={() => setMenu({ kind: "segment", index: i })}
|
|
370
|
+
>
|
|
371
|
+
<title>Drag to bend this segment; double-click to straighten</title>
|
|
372
|
+
<circle cx={sx(mx)} cy={sy(evaluate(curve, mx))} r={HIT_R} fill="transparent" />
|
|
373
|
+
<rect
|
|
374
|
+
x={sx(mx) - 3}
|
|
375
|
+
y={sy(evaluate(curve, mx)) - 3}
|
|
376
|
+
width={6}
|
|
377
|
+
height={6}
|
|
378
|
+
transform={`rotate(45 ${sx(mx)} ${sy(evaluate(curve, mx))})`}
|
|
379
|
+
className="fill-background stroke-muted-foreground"
|
|
380
|
+
strokeWidth={1}
|
|
381
|
+
/>
|
|
382
|
+
</g>
|
|
383
|
+
)
|
|
384
|
+
})}
|
|
385
|
+
{curve.points.map((p, i) => {
|
|
386
|
+
const isSel = sel === i
|
|
387
|
+
const isSustain = mode === "envelope" && curve.sustain === i
|
|
388
|
+
return (
|
|
389
|
+
<g
|
|
390
|
+
key={`p${i}`}
|
|
391
|
+
tabIndex={0}
|
|
392
|
+
role="button"
|
|
393
|
+
aria-label={`Point ${i + 1}: ${formatX(p.x)}, ${formatY(p.y)}${isSustain ? ", sustain" : ""}`}
|
|
394
|
+
aria-pressed={isSel}
|
|
395
|
+
className="cursor-grab outline-none focus-visible:[&>[data-ring]]:stroke-ring"
|
|
396
|
+
onFocus={() => setSelected(i)}
|
|
397
|
+
onKeyDown={(e) => onPointKey(e, i)}
|
|
398
|
+
onPointerDown={(e) => {
|
|
399
|
+
setSelected(i)
|
|
400
|
+
;(e.currentTarget as SVGGElement).focus()
|
|
401
|
+
beginDrag(e, { kind: "point", index: i, start: curve, pointer: e.pointerId, from: { x: e.clientX, y: e.clientY }, started: false })
|
|
402
|
+
}}
|
|
403
|
+
onContextMenu={() => {
|
|
404
|
+
setSelected(i)
|
|
405
|
+
setMenu({ kind: "point", index: i })
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
<circle cx={sx(p.x)} cy={sy(p.y)} r={HIT_R} fill="transparent" />
|
|
409
|
+
<circle
|
|
410
|
+
data-ring
|
|
411
|
+
className="fill-none stroke-transparent"
|
|
412
|
+
cx={sx(p.x)}
|
|
413
|
+
cy={sy(p.y)}
|
|
414
|
+
r={POINT_R + 3}
|
|
415
|
+
strokeWidth={2}
|
|
416
|
+
/>
|
|
417
|
+
<circle
|
|
418
|
+
cx={sx(p.x)}
|
|
419
|
+
cy={sy(p.y)}
|
|
420
|
+
r={POINT_R}
|
|
421
|
+
strokeWidth={1.5}
|
|
422
|
+
className={cn(isSel ? "fill-primary stroke-primary" : "fill-background stroke-primary")}
|
|
423
|
+
/>
|
|
424
|
+
{isSustain && (
|
|
425
|
+
<text
|
|
426
|
+
x={sx(p.x) + 8}
|
|
427
|
+
y={sy(p.y) - 8}
|
|
428
|
+
className="fill-muted-foreground font-mono text-[10px] select-none"
|
|
429
|
+
>
|
|
430
|
+
S
|
|
431
|
+
</text>
|
|
432
|
+
)}
|
|
433
|
+
</g>
|
|
434
|
+
)
|
|
435
|
+
})}
|
|
436
|
+
{readout && (
|
|
437
|
+
<text
|
|
438
|
+
x={size.width - PAD - 2}
|
|
439
|
+
y={PAD + 10}
|
|
440
|
+
textAnchor="end"
|
|
441
|
+
className="pointer-events-none fill-muted-foreground font-mono text-[10px] select-none"
|
|
442
|
+
>
|
|
443
|
+
{formatX(readout.x)} · {formatY(readout.y)}
|
|
444
|
+
</text>
|
|
445
|
+
)}
|
|
446
|
+
</svg>
|
|
447
|
+
</ContextMenuTrigger>
|
|
448
|
+
<ContextMenuContent>
|
|
449
|
+
{target?.kind === "point" && menuPoint && (
|
|
450
|
+
<>
|
|
451
|
+
{/* Base UI's group label throws outside a group. */}
|
|
452
|
+
<ContextMenuGroup>
|
|
453
|
+
<ContextMenuLabel>Interpolation to the next point</ContextMenuLabel>
|
|
454
|
+
<ContextMenuRadioGroup
|
|
455
|
+
value={menuPoint.basis}
|
|
456
|
+
onValueChange={(b) => commit(setBasis(curve, target.index, b as Basis, constrain), "edit")}
|
|
457
|
+
>
|
|
458
|
+
{BASES.map((b) => (
|
|
459
|
+
<ContextMenuRadioItem
|
|
460
|
+
key={b.value}
|
|
461
|
+
value={b.value}
|
|
462
|
+
disabled={
|
|
463
|
+
target.index === curve.points.length - 1 ||
|
|
464
|
+
(b.value !== menuPoint.basis && !setBasis(curve, target.index, b.value, constrain))
|
|
465
|
+
}
|
|
466
|
+
>
|
|
467
|
+
{b.label}
|
|
468
|
+
</ContextMenuRadioItem>
|
|
469
|
+
))}
|
|
470
|
+
</ContextMenuRadioGroup>
|
|
471
|
+
</ContextMenuGroup>
|
|
472
|
+
{mode === "envelope" && (
|
|
473
|
+
<>
|
|
474
|
+
<ContextMenuSeparator />
|
|
475
|
+
<ContextMenuCheckboxItem
|
|
476
|
+
checked={curve.sustain === target.index}
|
|
477
|
+
disabled={!toggleSustain(curve, target.index, constrain)}
|
|
478
|
+
onCheckedChange={() => commit(toggleSustain(curve, target.index, constrain), "edit")}
|
|
479
|
+
>
|
|
480
|
+
Sustain here
|
|
481
|
+
</ContextMenuCheckboxItem>
|
|
482
|
+
</>
|
|
483
|
+
)}
|
|
484
|
+
<ContextMenuSeparator />
|
|
485
|
+
<ContextMenuItem
|
|
486
|
+
disabled={!removePoint(curve, target.index, constrain)}
|
|
487
|
+
onClick={() => {
|
|
488
|
+
const next = removePoint(curve, target.index, constrain)
|
|
489
|
+
if (next) {
|
|
490
|
+
commit(next, "edit")
|
|
491
|
+
setSelected(null)
|
|
492
|
+
}
|
|
493
|
+
}}
|
|
494
|
+
>
|
|
495
|
+
Delete Point
|
|
496
|
+
<ContextMenuShortcut>⌫</ContextMenuShortcut>
|
|
497
|
+
</ContextMenuItem>
|
|
498
|
+
</>
|
|
499
|
+
)}
|
|
500
|
+
{target?.kind === "segment" && (
|
|
501
|
+
<ContextMenuItem
|
|
502
|
+
disabled={!curve.points[target.index]?.tension}
|
|
503
|
+
onClick={() => commit(setTension(curve, target.index, 0, constrain), "edit")}
|
|
504
|
+
>
|
|
505
|
+
Straighten Segment
|
|
506
|
+
</ContextMenuItem>
|
|
507
|
+
)}
|
|
508
|
+
{target?.kind === "empty" && (
|
|
509
|
+
<ContextMenuItem
|
|
510
|
+
disabled={!addPoint(curve, target.x, target.y, domain, constrain)}
|
|
511
|
+
onClick={() => {
|
|
512
|
+
const added = addPoint(curve, target.x, target.y, domain, constrain)
|
|
513
|
+
if (added) {
|
|
514
|
+
commit(added.curve, "edit")
|
|
515
|
+
setSelected(added.index)
|
|
516
|
+
}
|
|
517
|
+
}}
|
|
518
|
+
>
|
|
519
|
+
Add Point Here
|
|
520
|
+
</ContextMenuItem>
|
|
521
|
+
)}
|
|
522
|
+
</ContextMenuContent>
|
|
523
|
+
</ContextMenu>
|
|
524
|
+
)
|
|
525
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { clamp, invlerp, TENSION_STRENGTH, type Basis, type Curve, type CurvePoint } from "@preset.nz/math"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Limits on a curve, applied to every proposed edit: a drag, a nudge, an added or removed
|
|
5
|
+
* point, a basis or sustain change. Return the curve to show (adjusted if need be), or `null`
|
|
6
|
+
* to refuse the edit. `prev` is the curve before the edit, or the curve at the start of a
|
|
7
|
+
* drag. An ADSR envelope is a curve with limits (`constrainAdsr`), not a different editor.
|
|
8
|
+
*/
|
|
9
|
+
export type CurveConstraint = (next: Curve, prev: Curve) => Curve | null
|
|
10
|
+
|
|
11
|
+
export interface Domain {
|
|
12
|
+
x: readonly [number, number]
|
|
13
|
+
y: readonly [number, number]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `next` through the constraint, or `next` itself without one. */
|
|
17
|
+
export function propose(next: Curve, prev: Curve, constrain?: CurveConstraint): Curve | null {
|
|
18
|
+
return constrain ? constrain(next, prev) : next
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Point `i` of `start` moved to `(x, y)`, kept inside the domain. After the constraint, the
|
|
23
|
+
* moved point is held between its neighbours, so the points stay sorted while indices stay put.
|
|
24
|
+
*/
|
|
25
|
+
export function movePoint(
|
|
26
|
+
start: Curve,
|
|
27
|
+
i: number,
|
|
28
|
+
x: number,
|
|
29
|
+
y: number,
|
|
30
|
+
domain: Domain,
|
|
31
|
+
constrain?: CurveConstraint,
|
|
32
|
+
): Curve | null {
|
|
33
|
+
const points = start.points.map((p) => ({ ...p }))
|
|
34
|
+
const p = points[i]
|
|
35
|
+
if (!p) return null
|
|
36
|
+
p.x = clamp(x, domain.x[0], domain.x[1])
|
|
37
|
+
p.y = clamp(y, domain.y[0], domain.y[1])
|
|
38
|
+
const next = propose({ ...start, points }, start, constrain)
|
|
39
|
+
return next && keepOrder(next, i)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Point `i` held between its neighbours. Equal x is allowed: it makes a step. */
|
|
43
|
+
function keepOrder(curve: Curve, i: number): Curve {
|
|
44
|
+
const pts = curve.points
|
|
45
|
+
const p = pts[i]
|
|
46
|
+
if (!p) return curve
|
|
47
|
+
const lo = pts[i - 1]?.x ?? -Infinity
|
|
48
|
+
const hi = pts[i + 1]?.x ?? Infinity
|
|
49
|
+
if (p.x >= lo && p.x <= hi) return curve
|
|
50
|
+
const points = pts.map((q, j) => (j === i ? { ...q, x: clamp(q.x, lo, hi) } : q))
|
|
51
|
+
return { ...curve, points }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A point added at `(x, y)`, in order. It takes the basis and tension of the segment it
|
|
56
|
+
* splits, so the curve around it keeps its character. Returns the new curve and the new
|
|
57
|
+
* point's index.
|
|
58
|
+
*/
|
|
59
|
+
export function addPoint(
|
|
60
|
+
curve: Curve,
|
|
61
|
+
x: number,
|
|
62
|
+
y: number,
|
|
63
|
+
domain: Domain,
|
|
64
|
+
constrain?: CurveConstraint,
|
|
65
|
+
): { curve: Curve; index: number } | null {
|
|
66
|
+
const px = clamp(x, domain.x[0], domain.x[1])
|
|
67
|
+
const py = clamp(y, domain.y[0], domain.y[1])
|
|
68
|
+
let index = curve.points.findIndex((p) => p.x > px)
|
|
69
|
+
if (index < 0) index = curve.points.length
|
|
70
|
+
const before = curve.points[index - 1] ?? curve.points[index]
|
|
71
|
+
const point: CurvePoint = { x: px, y: py, basis: before?.basis ?? "linear" }
|
|
72
|
+
if (before?.tension !== undefined) point.tension = before.tension
|
|
73
|
+
const points = [...curve.points.slice(0, index), point, ...curve.points.slice(index)]
|
|
74
|
+
const next: Curve = { ...curve, points }
|
|
75
|
+
if (curve.sustain !== undefined && index <= curve.sustain) next.sustain = curve.sustain + 1
|
|
76
|
+
const out = propose(next, curve, constrain)
|
|
77
|
+
return out && { curve: out, index }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Point `i` removed. The last point can't be. Removing the sustain point clears sustain. */
|
|
81
|
+
export function removePoint(curve: Curve, i: number, constrain?: CurveConstraint): Curve | null {
|
|
82
|
+
if (curve.points.length <= 1 || !curve.points[i]) return null
|
|
83
|
+
const next: Curve = { points: curve.points.filter((_, j) => j !== i) }
|
|
84
|
+
const s = curve.sustain
|
|
85
|
+
if (s !== undefined && s !== i) next.sustain = s > i ? s - 1 : s
|
|
86
|
+
return propose(next, curve, constrain)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Point `i`'s basis. Tension only means something on a linear segment, so others drop it. */
|
|
90
|
+
export function setBasis(curve: Curve, i: number, basis: Basis, constrain?: CurveConstraint): Curve | null {
|
|
91
|
+
const points = curve.points.map((p, j) => {
|
|
92
|
+
if (j !== i) return p
|
|
93
|
+
const q: CurvePoint = { ...p, basis }
|
|
94
|
+
if (basis !== "linear") delete q.tension
|
|
95
|
+
return q
|
|
96
|
+
})
|
|
97
|
+
return propose({ ...curve, points }, curve, constrain)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sustain on point `i`, or off when it is already there. */
|
|
101
|
+
export function toggleSustain(curve: Curve, i: number, constrain?: CurveConstraint): Curve | null {
|
|
102
|
+
const next: Curve = { points: curve.points }
|
|
103
|
+
if (curve.sustain !== i) next.sustain = i
|
|
104
|
+
return propose(next, curve, constrain)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The tension of the segment from point `i`, -1..1. Zero removes it. */
|
|
108
|
+
export function setTension(curve: Curve, i: number, tension: number, constrain?: CurveConstraint): Curve | null {
|
|
109
|
+
const t = clamp(tension, -1, 1)
|
|
110
|
+
const points = curve.points.map((p, j) => {
|
|
111
|
+
if (j !== i) return p
|
|
112
|
+
const q: CurvePoint = { ...p }
|
|
113
|
+
if (Math.abs(t) < 1e-3) delete q.tension
|
|
114
|
+
else q.tension = t
|
|
115
|
+
return q
|
|
116
|
+
})
|
|
117
|
+
return propose({ ...curve, points }, curve, constrain)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The tension that puts a linear segment's midpoint at `y`. The warp at t = 0.5 is
|
|
122
|
+
* `1 / (e^(k/2) + 1)`, so `k = 2 ln(1/w − 1)` where `w` is how far up the segment `y` sits.
|
|
123
|
+
* `null` on a flat segment, where the midpoint can't move.
|
|
124
|
+
*/
|
|
125
|
+
export function tensionForMidpoint(y0: number, y1: number, y: number): number | null {
|
|
126
|
+
if (Math.abs(y1 - y0) < 1e-9) return null
|
|
127
|
+
const w = clamp(invlerp(y, y0, y1), 0.005, 0.995)
|
|
128
|
+
return clamp((2 * Math.log(1 / w - 1)) / TENSION_STRENGTH, -1, 1)
|
|
129
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { CurveEditor, type CurveEditKind, type CurveEditorProps } from "./curve-editor"
|
|
2
|
+
export {
|
|
3
|
+
addPoint,
|
|
4
|
+
movePoint,
|
|
5
|
+
removePoint,
|
|
6
|
+
setBasis,
|
|
7
|
+
setTension,
|
|
8
|
+
tensionForMidpoint,
|
|
9
|
+
toggleSustain,
|
|
10
|
+
type CurveConstraint,
|
|
11
|
+
type Domain,
|
|
12
|
+
} from "./curve-edits"
|
|
13
|
+
export { adsrCurve, adsrOf, constrainAdsr, type Adsr } from "./adsr"
|
|
14
|
+
export { linearScale, type LinearScale } from "./scale"
|
|
15
|
+
export { useCanvasDraw, useSize, type Size } from "./use-canvas"
|
|
16
|
+
export { useTokenColours } from "./use-tokens"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fitUnclamped } from "@preset.nz/math"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One axis's mapping between data and pixels. Both layers of a view draw through it, and
|
|
5
|
+
* pointer events come back through `invert`, so a handle can't drift off its line.
|
|
6
|
+
*/
|
|
7
|
+
export interface LinearScale {
|
|
8
|
+
(v: number): number
|
|
9
|
+
invert: (px: number) => number
|
|
10
|
+
domain: readonly [number, number]
|
|
11
|
+
range: readonly [number, number]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function linearScale(domain: readonly [number, number], range: readonly [number, number]): LinearScale {
|
|
15
|
+
const [d0, d1] = domain
|
|
16
|
+
const [r0, r1] = range
|
|
17
|
+
const scale = ((v: number) => fitUnclamped(v, d0, d1, r0, r1)) as LinearScale
|
|
18
|
+
scale.invert = (px: number) => fitUnclamped(px, r0, r1, d0, d1)
|
|
19
|
+
scale.domain = domain
|
|
20
|
+
scale.range = range
|
|
21
|
+
return scale
|
|
22
|
+
}
|