@preset.nz/ux-kit 0.0.0-stage → 0.1.1

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/THIRD_PARTY_NOTICES.md +253 -0
  4. package/package.json +50 -3
  5. package/src/chat/index.ts +9 -0
  6. package/src/chat/message.tsx +80 -0
  7. package/src/chat/prompt-input.tsx +121 -0
  8. package/src/chat/reasoning.tsx +182 -0
  9. package/src/chat/scroller.tsx +147 -0
  10. package/src/chat/suggestion.tsx +65 -0
  11. package/src/chat/tool.tsx +143 -0
  12. package/src/chat/types.ts +8 -0
  13. package/src/components/avatar.tsx +35 -0
  14. package/src/components/badge.tsx +40 -0
  15. package/src/components/breadcrumb.tsx +100 -0
  16. package/src/components/button.tsx +56 -0
  17. package/src/components/card.tsx +75 -0
  18. package/src/components/checkbox.tsx +25 -0
  19. package/src/components/color-field.tsx +261 -0
  20. package/src/components/color-swatch.tsx +52 -0
  21. package/src/components/context-menu.tsx +249 -0
  22. package/src/components/dialog.tsx +133 -0
  23. package/src/components/dropdown-menu.tsx +249 -0
  24. package/src/components/empty-state.tsx +35 -0
  25. package/src/components/input.tsx +20 -0
  26. package/src/components/label.tsx +19 -0
  27. package/src/components/navigation-menu.tsx +150 -0
  28. package/src/components/number-field.tsx +432 -0
  29. package/src/components/popover.tsx +75 -0
  30. package/src/components/select.tsx +189 -0
  31. package/src/components/separator.tsx +21 -0
  32. package/src/components/sheet.tsx +132 -0
  33. package/src/components/side-panel.tsx +189 -0
  34. package/src/components/sidebar.tsx +698 -0
  35. package/src/components/skeleton.tsx +14 -0
  36. package/src/components/slider.tsx +71 -0
  37. package/src/components/snackbar.tsx +143 -0
  38. package/src/components/status-bar.tsx +45 -0
  39. package/src/components/tabs.tsx +75 -0
  40. package/src/components/textarea.tsx +18 -0
  41. package/src/components/toggle-group.tsx +86 -0
  42. package/src/components/toggle.tsx +43 -0
  43. package/src/components/toolbar.tsx +304 -0
  44. package/src/components/tooltip.tsx +54 -0
  45. package/src/graphics/adsr.ts +78 -0
  46. package/src/graphics/curve-editor.tsx +567 -0
  47. package/src/graphics/curve-edits.ts +146 -0
  48. package/src/graphics/index.ts +16 -0
  49. package/src/graphics/scale.ts +25 -0
  50. package/src/graphics/use-canvas.ts +56 -0
  51. package/src/graphics/use-tokens.ts +35 -0
  52. package/src/icons.ts +3 -0
  53. package/src/index.css +199 -0
  54. package/src/index.ts +35 -0
  55. package/src/lib/use-mobile.ts +18 -0
  56. package/src/lib/utils.ts +6 -0
  57. package/src/shadcn-tailwind.css +665 -0
@@ -0,0 +1,567 @@
1
+ import { type Basis, type Curve, evaluate } from "@preset.nz/math"
2
+ import * as React from "react"
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
+ type CurveConstraint,
21
+ type Domain,
22
+ movePoint,
23
+ removePoint,
24
+ setBasis,
25
+ setTension,
26
+ tensionForMidpoint,
27
+ toggleSustain,
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 (
106
+ q !== undefined &&
107
+ p.x === q.x &&
108
+ p.y === q.y &&
109
+ p.basis === q.basis &&
110
+ (p.tension ?? 0) === (q.tension ?? 0)
111
+ )
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Edits a `@preset.nz/math` curve. The line is drawn on a canvas by sampling math's own
117
+ * `evaluate`, so it is exactly what Rust will compute. Points and tension handles are SVG on
118
+ * top, focusable and keyboard driven. Double-click to add a point; Delete removes the selected
119
+ * one; arrows nudge it (Shift ×10, Option ×0.1). Basis and sustain are in the context menu.
120
+ */
121
+ export function CurveEditor({
122
+ value,
123
+ onChange,
124
+ onGestureStart,
125
+ onGestureEnd,
126
+ mode = "transfer",
127
+ xDomain = [0, 1],
128
+ yDomain = [0, 1],
129
+ constrain,
130
+ formatX = defaultFormat,
131
+ formatY = defaultFormat,
132
+ background,
133
+ className,
134
+ "aria-label": ariaLabel = "Curve",
135
+ }: CurveEditorProps) {
136
+ const box = React.useRef<HTMLDivElement>(null)
137
+ const canvas = React.useRef<HTMLCanvasElement>(null)
138
+ const svg = React.useRef<SVGSVGElement>(null)
139
+ const size = useSize(box)
140
+ const colours = useTokenColours(TOKENS)
141
+
142
+ // The scrub rule: during a drag the view shows its own value, not the host's echo. After
143
+ // release it keeps showing it until the host's value catches up, so the line can't flick back
144
+ // to an echo from mid-drag.
145
+ const [live, setLive] = React.useState<{ curve: Curve; held: boolean } | null>(null)
146
+ const drag = React.useRef<Drag | null>(null)
147
+ if (live?.held && sameCurve(live.curve, value)) setLive(null)
148
+ const curve = live?.curve ?? value
149
+ React.useEffect(() => {
150
+ if (!live?.held) return
151
+ // A host that normalises the curve never echoes it exactly; let go regardless.
152
+ const timer = setTimeout(() => setLive(null), 1000)
153
+ return () => clearTimeout(timer)
154
+ }, [live])
155
+ const [selected, setSelected] = React.useState<number | null>(null)
156
+ const [menu, setMenu] = React.useState<MenuTarget | null>(null)
157
+
158
+ const [x0, x1] = xDomain
159
+ const [y0, y1] = yDomain
160
+ const domain: Domain = React.useMemo(() => ({ x: [x0, x1], y: [y0, y1] }), [x0, x1, y0, y1])
161
+ const sx = React.useMemo(
162
+ () => linearScale([x0, x1], [PAD, Math.max(PAD, size.width - PAD)]),
163
+ [x0, x1, size.width],
164
+ )
165
+ const sy = React.useMemo(
166
+ () => linearScale([y0, y1], [Math.max(PAD, size.height - PAD), PAD]),
167
+ [y0, y1, size.height],
168
+ )
169
+
170
+ const sel = selected !== null && selected < curve.points.length ? selected : null
171
+
172
+ useCanvasDraw(
173
+ canvas,
174
+ size,
175
+ (ctx, { width, height }) => {
176
+ // Quarters of each axis, faint.
177
+ ctx.strokeStyle = colours.border
178
+ ctx.lineWidth = 1
179
+ ctx.beginPath()
180
+ for (let k = 0; k <= 4; k++) {
181
+ const px = Math.round(sx(x0 + ((x1 - x0) * k) / 4)) + 0.5
182
+ const py = Math.round(sy(y0 + ((y1 - y0) * k) / 4)) + 0.5
183
+ ctx.moveTo(px, PAD)
184
+ ctx.lineTo(px, height - PAD)
185
+ ctx.moveTo(PAD, py)
186
+ ctx.lineTo(width - PAD, py)
187
+ }
188
+ ctx.stroke()
189
+
190
+ if (mode === "envelope" && curve.sustain !== undefined) {
191
+ const s = curve.points[curve.sustain]
192
+ if (s) {
193
+ ctx.strokeStyle = colours["muted-foreground"]
194
+ ctx.setLineDash([3, 3])
195
+ ctx.beginPath()
196
+ const px = Math.round(sx(s.x)) + 0.5
197
+ ctx.moveTo(px, PAD)
198
+ ctx.lineTo(px, height - PAD)
199
+ ctx.stroke()
200
+ ctx.setLineDash([])
201
+ }
202
+ }
203
+
204
+ if (curve.points.length === 0) return
205
+ // One sample per device pixel, through math's evaluate.
206
+ const dpr = window.devicePixelRatio || 1
207
+ const steps = Math.max(2, Math.ceil((width - 2 * PAD) * dpr))
208
+ const line = new Path2D()
209
+ for (let k = 0; k <= steps; k++) {
210
+ const px = PAD + ((width - 2 * PAD) * k) / steps
211
+ const py = sy(evaluate(curve, sx.invert(px)))
212
+ if (k === 0) line.moveTo(px, py)
213
+ else line.lineTo(px, py)
214
+ }
215
+ const area = new Path2D(line)
216
+ area.lineTo(width - PAD, sy(y0))
217
+ area.lineTo(PAD, sy(y0))
218
+ area.closePath()
219
+ ctx.fillStyle = colours.primary
220
+ ctx.globalAlpha = 0.12
221
+ ctx.fill(area)
222
+ ctx.globalAlpha = 1
223
+ ctx.strokeStyle = colours.primary
224
+ ctx.lineWidth = 1.5
225
+ ctx.lineJoin = "round"
226
+ ctx.stroke(line)
227
+ },
228
+ [curve, colours, sx, sy, mode],
229
+ )
230
+
231
+ const toData = (e: { clientX: number; clientY: number }) => {
232
+ const r = svg.current?.getBoundingClientRect()
233
+ if (!r) return { x: 0, y: 0 }
234
+ return { x: sx.invert(e.clientX - r.left), y: sy.invert(e.clientY - r.top) }
235
+ }
236
+
237
+ const commit = (next: Curve | null, kind: CurveEditKind) => {
238
+ if (next) onChange(next, kind)
239
+ }
240
+
241
+ const beginDrag = (e: React.PointerEvent, d: Drag) => {
242
+ if (e.button !== 0) return
243
+ e.preventDefault()
244
+ e.stopPropagation()
245
+ svg.current?.setPointerCapture(e.pointerId)
246
+ drag.current = d
247
+ }
248
+
249
+ const onPointerMove = (e: React.PointerEvent) => {
250
+ const d = drag.current
251
+ if (!d || e.pointerId !== d.pointer) return
252
+ // A click is not a drag: nothing reaches the host, or its undo, until the pointer moves.
253
+ if (!d.started) {
254
+ if (Math.hypot(e.clientX - d.from.x, e.clientY - d.from.y) < DRAG_THRESHOLD) return
255
+ d.started = true
256
+ setLive({ curve: d.start, held: false })
257
+ onGestureStart?.()
258
+ }
259
+ const { x, y } = toData(e)
260
+ let next: Curve | null = null
261
+ if (d.kind === "point") {
262
+ next = movePoint(d.start, d.index, x, y, domain, constrain)
263
+ } else {
264
+ const a = d.start.points[d.index]
265
+ const b = d.start.points[d.index + 1]
266
+ const t = a && b ? tensionForMidpoint(a.y, b.y, y) : null
267
+ if (t !== null) next = setTension(d.start, d.index, t, constrain)
268
+ }
269
+ if (next) {
270
+ setLive({ curve: next, held: false })
271
+ onChange(next, "gesture")
272
+ }
273
+ }
274
+
275
+ const finishDrag = (committed: boolean) => {
276
+ const d = drag.current
277
+ if (!d) return
278
+ drag.current = null
279
+ if (!d.started) return
280
+ if (!committed) onChange(d.start, "gesture")
281
+ setLive((l) => ({ curve: committed && l ? l.curve : d.start, held: true }))
282
+ onGestureEnd?.(committed)
283
+ }
284
+
285
+ // Escape cancels a drag wherever focus is.
286
+ React.useEffect(() => {
287
+ if (!drag.current?.started) return
288
+ const onKey = (e: KeyboardEvent) => {
289
+ if (e.key === "Escape" && drag.current) {
290
+ e.preventDefault()
291
+ finishDrag(false)
292
+ }
293
+ }
294
+ window.addEventListener("keydown", onKey)
295
+ return () => window.removeEventListener("keydown", onKey)
296
+ })
297
+
298
+ const onPointKey = (e: React.KeyboardEvent, i: number) => {
299
+ const p = curve.points[i]
300
+ if (!p) return
301
+ if (e.key === "Delete" || e.key === "Backspace") {
302
+ e.preventDefault()
303
+ const next = removePoint(curve, i, constrain)
304
+ if (next) {
305
+ commit(next, "edit")
306
+ setSelected(Math.min(i, next.points.length - 1))
307
+ }
308
+ return
309
+ }
310
+ const arrows: Record<string, [number, number]> = {
311
+ ArrowLeft: [-1, 0],
312
+ ArrowRight: [1, 0],
313
+ ArrowUp: [0, 1],
314
+ ArrowDown: [0, -1],
315
+ }
316
+ const dir = arrows[e.key]
317
+ if (!dir) return
318
+ e.preventDefault()
319
+ const mult = e.shiftKey ? 10 : e.altKey ? 0.1 : 1
320
+ const dx = dir[0] * (x1 - x0) * NUDGE * mult
321
+ const dy = dir[1] * (y1 - y0) * NUDGE * mult
322
+ commit(movePoint(curve, i, p.x + dx, p.y + dy, domain, constrain), "nudge")
323
+ }
324
+
325
+ const onDoubleClick = (e: React.MouseEvent) => {
326
+ const { x, y } = toData(e)
327
+ const added = addPoint(curve, x, y, domain, constrain)
328
+ if (added) {
329
+ commit(added.curve, "edit")
330
+ setSelected(added.index)
331
+ }
332
+ }
333
+
334
+ // What the menu would do, tried against the constraint so a refused item shows disabled.
335
+ const target = menu
336
+ const menuPoint = target?.kind === "point" ? curve.points[target.index] : undefined
337
+
338
+ const readout = sel !== null ? curve.points[sel] : undefined
339
+
340
+ return (
341
+ <ContextMenu onOpenChange={(open) => !open && setMenu(null)}>
342
+ <ContextMenuTrigger
343
+ ref={box}
344
+ className={cn("relative h-48 w-full overflow-hidden rounded-md border bg-card", className)}
345
+ >
346
+ {background && (
347
+ <div className="pointer-events-none absolute" style={{ inset: PAD }}>
348
+ {background}
349
+ </div>
350
+ )}
351
+ <canvas ref={canvas} className="absolute inset-0 size-full" />
352
+ {/* biome-ignore lint/a11y/useSemanticElements: an SVG canvas has no semantic element; role="group" with a label names it */}
353
+ <svg
354
+ ref={svg}
355
+ role="group"
356
+ aria-label={ariaLabel}
357
+ className="absolute inset-0 size-full touch-none"
358
+ onPointerMove={onPointerMove}
359
+ onPointerUp={() => finishDrag(true)}
360
+ onLostPointerCapture={() => finishDrag(true)}
361
+ >
362
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: the backing rect only takes pointer input to deselect and add points; the points are the keyboard targets */}
363
+ <rect
364
+ width="100%"
365
+ height="100%"
366
+ fill="transparent"
367
+ onPointerDown={() => setSelected(null)}
368
+ onDoubleClick={onDoubleClick}
369
+ onContextMenu={(e) => setMenu({ kind: "empty", ...toData(e) })}
370
+ />
371
+ {curve.points.map((p, i) => {
372
+ const next = curve.points[i + 1]
373
+ if (!next || p.basis !== "linear" || next.x === p.x) return null
374
+ const mx = (p.x + next.x) / 2
375
+ return (
376
+ // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only tension handle; the keyboard route is the point menu and arrow keys on the points
377
+ <g
378
+ // biome-ignore lint/suspicious/noArrayIndexKey: curve points have no id; the index is their identity
379
+ key={`t${i}`}
380
+ className="cursor-ns-resize"
381
+ onPointerDown={(e) =>
382
+ beginDrag(e, {
383
+ kind: "tension",
384
+ index: i,
385
+ start: curve,
386
+ pointer: e.pointerId,
387
+ from: { x: e.clientX, y: e.clientY },
388
+ started: false,
389
+ })
390
+ }
391
+ onDoubleClick={(e) => {
392
+ e.stopPropagation()
393
+ commit(setTension(curve, i, 0, constrain), "edit")
394
+ }}
395
+ onContextMenu={() => setMenu({ kind: "segment", index: i })}
396
+ >
397
+ <title>Drag to bend this segment; double-click to straighten</title>
398
+ <circle cx={sx(mx)} cy={sy(evaluate(curve, mx))} r={HIT_R} fill="transparent" />
399
+ <rect
400
+ x={sx(mx) - 3}
401
+ y={sy(evaluate(curve, mx)) - 3}
402
+ width={6}
403
+ height={6}
404
+ transform={`rotate(45 ${sx(mx)} ${sy(evaluate(curve, mx))})`}
405
+ className="fill-background stroke-muted-foreground"
406
+ strokeWidth={1}
407
+ />
408
+ </g>
409
+ )
410
+ })}
411
+ {curve.points.map((p, i) => {
412
+ const isSel = sel === i
413
+ const isSustain = mode === "envelope" && curve.sustain === i
414
+ return (
415
+ // biome-ignore lint/a11y/useSemanticElements: an SVG <g> cannot be a <button>; role="button" with tabIndex and key handling
416
+ <g
417
+ // biome-ignore lint/suspicious/noArrayIndexKey: curve points have no id; the index is their identity
418
+ key={`p${i}`}
419
+ tabIndex={0}
420
+ role="button"
421
+ aria-label={`Point ${i + 1}: ${formatX(p.x)}, ${formatY(p.y)}${isSustain ? ", sustain" : ""}`}
422
+ aria-pressed={isSel}
423
+ className="cursor-grab outline-none focus-visible:[&>[data-ring]]:stroke-ring"
424
+ onFocus={() => setSelected(i)}
425
+ onKeyDown={(e) => onPointKey(e, i)}
426
+ onPointerDown={(e) => {
427
+ setSelected(i)
428
+ ;(e.currentTarget as SVGGElement).focus()
429
+ beginDrag(e, {
430
+ kind: "point",
431
+ index: i,
432
+ start: curve,
433
+ pointer: e.pointerId,
434
+ from: { x: e.clientX, y: e.clientY },
435
+ started: false,
436
+ })
437
+ }}
438
+ onContextMenu={() => {
439
+ setSelected(i)
440
+ setMenu({ kind: "point", index: i })
441
+ }}
442
+ >
443
+ <circle cx={sx(p.x)} cy={sy(p.y)} r={HIT_R} fill="transparent" />
444
+ <circle
445
+ data-ring
446
+ className="fill-none stroke-transparent"
447
+ cx={sx(p.x)}
448
+ cy={sy(p.y)}
449
+ r={POINT_R + 3}
450
+ strokeWidth={2}
451
+ />
452
+ <circle
453
+ cx={sx(p.x)}
454
+ cy={sy(p.y)}
455
+ r={POINT_R}
456
+ strokeWidth={1.5}
457
+ className={cn(
458
+ isSel ? "fill-primary stroke-primary" : "fill-background stroke-primary",
459
+ )}
460
+ />
461
+ {isSustain && (
462
+ <text
463
+ x={sx(p.x) + 8}
464
+ y={sy(p.y) - 8}
465
+ className="fill-muted-foreground font-mono text-[10px] select-none"
466
+ >
467
+ S
468
+ </text>
469
+ )}
470
+ </g>
471
+ )
472
+ })}
473
+ {readout && (
474
+ <text
475
+ x={size.width - PAD - 2}
476
+ y={PAD + 10}
477
+ textAnchor="end"
478
+ className="pointer-events-none fill-muted-foreground font-mono text-[10px] select-none"
479
+ >
480
+ {formatX(readout.x)} · {formatY(readout.y)}
481
+ </text>
482
+ )}
483
+ </svg>
484
+ </ContextMenuTrigger>
485
+ <ContextMenuContent>
486
+ {target?.kind === "point" && menuPoint && (
487
+ <>
488
+ {/* Base UI's group label throws outside a group. */}
489
+ <ContextMenuGroup>
490
+ <ContextMenuLabel>Interpolation to the next point</ContextMenuLabel>
491
+ <ContextMenuRadioGroup
492
+ value={menuPoint.basis}
493
+ onValueChange={(b) =>
494
+ commit(setBasis(curve, target.index, b as Basis, constrain), "edit")
495
+ }
496
+ >
497
+ {BASES.map((b) => (
498
+ <ContextMenuRadioItem
499
+ key={b.value}
500
+ value={b.value}
501
+ disabled={
502
+ target.index === curve.points.length - 1 ||
503
+ (b.value !== menuPoint.basis &&
504
+ !setBasis(curve, target.index, b.value, constrain))
505
+ }
506
+ >
507
+ {b.label}
508
+ </ContextMenuRadioItem>
509
+ ))}
510
+ </ContextMenuRadioGroup>
511
+ </ContextMenuGroup>
512
+ {mode === "envelope" && (
513
+ <>
514
+ <ContextMenuSeparator />
515
+ <ContextMenuCheckboxItem
516
+ checked={curve.sustain === target.index}
517
+ disabled={!toggleSustain(curve, target.index, constrain)}
518
+ onCheckedChange={() =>
519
+ commit(toggleSustain(curve, target.index, constrain), "edit")
520
+ }
521
+ >
522
+ Sustain here
523
+ </ContextMenuCheckboxItem>
524
+ </>
525
+ )}
526
+ <ContextMenuSeparator />
527
+ <ContextMenuItem
528
+ disabled={!removePoint(curve, target.index, constrain)}
529
+ onClick={() => {
530
+ const next = removePoint(curve, target.index, constrain)
531
+ if (next) {
532
+ commit(next, "edit")
533
+ setSelected(null)
534
+ }
535
+ }}
536
+ >
537
+ Delete Point
538
+ <ContextMenuShortcut>⌫</ContextMenuShortcut>
539
+ </ContextMenuItem>
540
+ </>
541
+ )}
542
+ {target?.kind === "segment" && (
543
+ <ContextMenuItem
544
+ disabled={!curve.points[target.index]?.tension}
545
+ onClick={() => commit(setTension(curve, target.index, 0, constrain), "edit")}
546
+ >
547
+ Straighten Segment
548
+ </ContextMenuItem>
549
+ )}
550
+ {target?.kind === "empty" && (
551
+ <ContextMenuItem
552
+ disabled={!addPoint(curve, target.x, target.y, domain, constrain)}
553
+ onClick={() => {
554
+ const added = addPoint(curve, target.x, target.y, domain, constrain)
555
+ if (added) {
556
+ commit(added.curve, "edit")
557
+ setSelected(added.index)
558
+ }
559
+ }}
560
+ >
561
+ Add Point Here
562
+ </ContextMenuItem>
563
+ )}
564
+ </ContextMenuContent>
565
+ </ContextMenu>
566
+ )
567
+ }