@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -2
  3. package/package.json +51 -3
  4. package/src/components/avatar.tsx +35 -0
  5. package/src/components/badge.tsx +42 -0
  6. package/src/components/breadcrumb.tsx +102 -0
  7. package/src/components/button.tsx +56 -0
  8. package/src/components/card.tsx +92 -0
  9. package/src/components/checkbox.tsx +27 -0
  10. package/src/components/color-field.tsx +248 -0
  11. package/src/components/color-swatch.tsx +52 -0
  12. package/src/components/context-menu.tsx +249 -0
  13. package/src/components/dialog.tsx +137 -0
  14. package/src/components/dropdown-menu.tsx +249 -0
  15. package/src/components/empty-state.tsx +32 -0
  16. package/src/components/input.tsx +20 -0
  17. package/src/components/label.tsx +18 -0
  18. package/src/components/navigation-menu.tsx +144 -0
  19. package/src/components/number-field.tsx +428 -0
  20. package/src/components/popover.tsx +82 -0
  21. package/src/components/select.tsx +201 -0
  22. package/src/components/separator.tsx +25 -0
  23. package/src/components/sheet.tsx +124 -0
  24. package/src/components/side-panel.tsx +166 -0
  25. package/src/components/sidebar.tsx +735 -0
  26. package/src/components/skeleton.tsx +14 -0
  27. package/src/components/slider.tsx +75 -0
  28. package/src/components/snackbar.tsx +141 -0
  29. package/src/components/status-bar.tsx +38 -0
  30. package/src/components/tabs.tsx +78 -0
  31. package/src/components/textarea.tsx +18 -0
  32. package/src/components/toggle-group.tsx +86 -0
  33. package/src/components/toggle.tsx +43 -0
  34. package/src/components/toolbar.tsx +307 -0
  35. package/src/components/tooltip.tsx +54 -0
  36. package/src/graphics/adsr.ts +70 -0
  37. package/src/graphics/curve-editor.tsx +525 -0
  38. package/src/graphics/curve-edits.ts +129 -0
  39. package/src/graphics/index.ts +16 -0
  40. package/src/graphics/scale.ts +22 -0
  41. package/src/graphics/use-canvas.ts +56 -0
  42. package/src/graphics/use-tokens.ts +32 -0
  43. package/src/icons.ts +3 -0
  44. package/src/index.css +199 -0
  45. package/src/index.ts +37 -0
  46. package/src/lib/use-mobile.ts +18 -0
  47. 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
+ }