@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.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/THIRD_PARTY_NOTICES.md +253 -0
- package/package.json +50 -3
- package/src/chat/index.ts +9 -0
- package/src/chat/message.tsx +80 -0
- package/src/chat/prompt-input.tsx +121 -0
- package/src/chat/reasoning.tsx +182 -0
- package/src/chat/scroller.tsx +147 -0
- package/src/chat/suggestion.tsx +65 -0
- package/src/chat/tool.tsx +143 -0
- package/src/chat/types.ts +8 -0
- package/src/components/avatar.tsx +35 -0
- package/src/components/badge.tsx +40 -0
- package/src/components/breadcrumb.tsx +100 -0
- package/src/components/button.tsx +56 -0
- package/src/components/card.tsx +75 -0
- package/src/components/checkbox.tsx +25 -0
- package/src/components/color-field.tsx +261 -0
- package/src/components/color-swatch.tsx +52 -0
- package/src/components/context-menu.tsx +249 -0
- package/src/components/dialog.tsx +133 -0
- package/src/components/dropdown-menu.tsx +249 -0
- package/src/components/empty-state.tsx +35 -0
- package/src/components/input.tsx +20 -0
- package/src/components/label.tsx +19 -0
- package/src/components/navigation-menu.tsx +150 -0
- package/src/components/number-field.tsx +432 -0
- package/src/components/popover.tsx +75 -0
- package/src/components/select.tsx +189 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +132 -0
- package/src/components/side-panel.tsx +189 -0
- package/src/components/sidebar.tsx +698 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +71 -0
- package/src/components/snackbar.tsx +143 -0
- package/src/components/status-bar.tsx +45 -0
- package/src/components/tabs.tsx +75 -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 +304 -0
- package/src/components/tooltip.tsx +54 -0
- package/src/graphics/adsr.ts +78 -0
- package/src/graphics/curve-editor.tsx +567 -0
- package/src/graphics/curve-edits.ts +146 -0
- package/src/graphics/index.ts +16 -0
- package/src/graphics/scale.ts +25 -0
- package/src/graphics/use-canvas.ts +56 -0
- package/src/graphics/use-tokens.ts +35 -0
- package/src/icons.ts +3 -0
- package/src/index.css +199 -0
- package/src/index.ts +35 -0
- package/src/lib/use-mobile.ts +18 -0
- package/src/lib/utils.ts +6 -0
- 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
|
+
}
|