@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,304 @@
|
|
|
1
|
+
import { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils"
|
|
5
|
+
import { buttonVariants } from "./button"
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuShortcut,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "./dropdown-menu"
|
|
13
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
14
|
+
|
|
15
|
+
// Toolbar: props and callbacks only, no app state. The rules (native-apps.md,
|
|
16
|
+
// ux-patterns.md): icon only, tooltip with name and shortcut, groups split by
|
|
17
|
+
// separators, toggles show a pressed state, and every item is a command that
|
|
18
|
+
// also lives in the menu. The toolbar is a subset of the menu, not a second
|
|
19
|
+
// home for commands.
|
|
20
|
+
//
|
|
21
|
+
// Two ways in. Data-driven: `<Toolbar groups={[[item, item], [item]]} onCommand={run} />`
|
|
22
|
+
// where `id` is the menu's command id. Composed: `ToolbarGroup`, `ToolbarSeparator`,
|
|
23
|
+
// `ToolbarSpacer` and `ToolbarButton` as children, for anything the table cannot say.
|
|
24
|
+
//
|
|
25
|
+
// Roving focus and arrow-key navigation come from Base UI's Toolbar. Disabled
|
|
26
|
+
// buttons stay focusable and keep their tooltip, which is how a gated item
|
|
27
|
+
// says why it is gated (Oblique's `disabledHint`, M&T's `disabledReason`).
|
|
28
|
+
|
|
29
|
+
/** An entry in a dropdown item's menu. */
|
|
30
|
+
interface ToolbarMenuEntry {
|
|
31
|
+
id: string
|
|
32
|
+
label: string
|
|
33
|
+
shortcut?: string
|
|
34
|
+
/** Default true. */
|
|
35
|
+
enabled?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One toolbar item. `id` is the command id shared with the menu. */
|
|
39
|
+
interface ToolbarItemSpec {
|
|
40
|
+
id: string
|
|
41
|
+
label: string
|
|
42
|
+
icon: React.ReactNode
|
|
43
|
+
/** Shown in the tooltip, in the platform's own notation ("⌘S"). */
|
|
44
|
+
shortcut?: string
|
|
45
|
+
/** Default true. */
|
|
46
|
+
enabled?: boolean
|
|
47
|
+
/** Replaces the shortcut in the tooltip while the item is disabled. */
|
|
48
|
+
disabledReason?: string
|
|
49
|
+
/** Present (true or false) makes the item a toggle. Absent means a plain button. */
|
|
50
|
+
pressed?: boolean
|
|
51
|
+
/** Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. */
|
|
52
|
+
menu?: ToolbarMenuEntry[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Toolbar({ className, ...props }: React.ComponentProps<typeof ToolbarPrimitive.Root>) {
|
|
56
|
+
return (
|
|
57
|
+
<ToolbarPrimitive.Root
|
|
58
|
+
data-slot="toolbar"
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex items-center gap-1 border-b border-border bg-background px-2 py-1 data-[orientation=vertical]:w-fit data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-r data-[orientation=vertical]:border-b-0",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ToolbarGroup({
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: React.ComponentProps<typeof ToolbarPrimitive.Group>) {
|
|
72
|
+
return (
|
|
73
|
+
<ToolbarPrimitive.Group
|
|
74
|
+
data-slot="toolbar-group"
|
|
75
|
+
className={cn("flex items-center gap-0.5 data-[orientation=vertical]:flex-col", className)}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ToolbarSeparator({
|
|
82
|
+
className,
|
|
83
|
+
...props
|
|
84
|
+
}: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {
|
|
85
|
+
return (
|
|
86
|
+
<ToolbarPrimitive.Separator
|
|
87
|
+
data-slot="toolbar-separator"
|
|
88
|
+
className={cn(
|
|
89
|
+
"mx-1 h-5 w-px shrink-0 bg-border data-[orientation=horizontal]:mx-0 data-[orientation=horizontal]:my-1 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-5",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Pushes what follows to the far end (the right-panel toggle sits there). */
|
|
98
|
+
function ToolbarSpacer({ className, ...props }: React.ComponentProps<"div">) {
|
|
99
|
+
return <div data-slot="toolbar-spacer" className={cn("flex-1", className)} {...props} />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function TooltipBody({
|
|
103
|
+
label,
|
|
104
|
+
shortcut,
|
|
105
|
+
disabledReason,
|
|
106
|
+
}: Pick<ToolbarItemSpec, "label" | "shortcut" | "disabledReason">) {
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<span>{label}</span>
|
|
110
|
+
{disabledReason ? (
|
|
111
|
+
<span className="opacity-70">{disabledReason}</span>
|
|
112
|
+
) : shortcut ? (
|
|
113
|
+
<kbd data-slot="kbd" className="rounded-sm bg-background/20 px-1 font-sans text-[11px]">
|
|
114
|
+
{shortcut}
|
|
115
|
+
</kbd>
|
|
116
|
+
) : null}
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const buttonClass = (pressed?: boolean) =>
|
|
122
|
+
cn(
|
|
123
|
+
buttonVariants({ variant: "ghost", size: "icon" }),
|
|
124
|
+
"data-disabled:cursor-default data-disabled:opacity-40 data-disabled:hover:bg-transparent",
|
|
125
|
+
pressed && "bg-muted text-foreground",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
interface ToolbarButtonProps
|
|
129
|
+
extends Omit<ToolbarItemSpec, "id" | "menu">,
|
|
130
|
+
Omit<React.ComponentProps<typeof ToolbarPrimitive.Button>, "children" | "aria-label"> {}
|
|
131
|
+
|
|
132
|
+
/** One icon button: plain, or a toggle when `pressed` is given. Tooltip shows label and shortcut. */
|
|
133
|
+
function ToolbarButton({
|
|
134
|
+
label,
|
|
135
|
+
icon,
|
|
136
|
+
shortcut,
|
|
137
|
+
enabled = true,
|
|
138
|
+
disabledReason,
|
|
139
|
+
pressed,
|
|
140
|
+
className,
|
|
141
|
+
...props
|
|
142
|
+
}: ToolbarButtonProps) {
|
|
143
|
+
return (
|
|
144
|
+
<Tooltip>
|
|
145
|
+
<TooltipTrigger
|
|
146
|
+
render={
|
|
147
|
+
<ToolbarPrimitive.Button
|
|
148
|
+
data-slot="toolbar-button"
|
|
149
|
+
aria-label={label}
|
|
150
|
+
aria-pressed={pressed}
|
|
151
|
+
disabled={!enabled}
|
|
152
|
+
className={cn(buttonClass(pressed), className)}
|
|
153
|
+
{...props}
|
|
154
|
+
/>
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
{icon}
|
|
158
|
+
</TooltipTrigger>
|
|
159
|
+
<TooltipContent side="bottom">
|
|
160
|
+
<TooltipBody
|
|
161
|
+
label={label}
|
|
162
|
+
shortcut={shortcut}
|
|
163
|
+
disabledReason={enabled ? undefined : disabledReason}
|
|
164
|
+
/>
|
|
165
|
+
</TooltipContent>
|
|
166
|
+
</Tooltip>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu" | "pressed"> {
|
|
171
|
+
entries: ToolbarMenuEntry[]
|
|
172
|
+
onCommand?: (id: string) => void
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** An icon button that opens a menu of commands. Entries are menu items, so they show shortcuts too. */
|
|
176
|
+
function ToolbarDropdown({
|
|
177
|
+
label,
|
|
178
|
+
icon,
|
|
179
|
+
shortcut,
|
|
180
|
+
enabled = true,
|
|
181
|
+
disabledReason,
|
|
182
|
+
entries,
|
|
183
|
+
onCommand,
|
|
184
|
+
}: ToolbarDropdownProps) {
|
|
185
|
+
return (
|
|
186
|
+
<DropdownMenu>
|
|
187
|
+
<Tooltip>
|
|
188
|
+
<TooltipTrigger
|
|
189
|
+
render={
|
|
190
|
+
<DropdownMenuTrigger
|
|
191
|
+
render={
|
|
192
|
+
<ToolbarPrimitive.Button
|
|
193
|
+
data-slot="toolbar-dropdown"
|
|
194
|
+
aria-label={label}
|
|
195
|
+
disabled={!enabled}
|
|
196
|
+
className={buttonClass()}
|
|
197
|
+
/>
|
|
198
|
+
}
|
|
199
|
+
/>
|
|
200
|
+
}
|
|
201
|
+
>
|
|
202
|
+
{icon}
|
|
203
|
+
</TooltipTrigger>
|
|
204
|
+
<TooltipContent side="bottom">
|
|
205
|
+
<TooltipBody
|
|
206
|
+
label={label}
|
|
207
|
+
shortcut={shortcut}
|
|
208
|
+
disabledReason={enabled ? undefined : disabledReason}
|
|
209
|
+
/>
|
|
210
|
+
</TooltipContent>
|
|
211
|
+
</Tooltip>
|
|
212
|
+
<DropdownMenuContent align="start">
|
|
213
|
+
{entries.map((e) => (
|
|
214
|
+
<DropdownMenuItem
|
|
215
|
+
key={e.id}
|
|
216
|
+
disabled={e.enabled === false}
|
|
217
|
+
onClick={() => onCommand?.(e.id)}
|
|
218
|
+
>
|
|
219
|
+
{e.label}
|
|
220
|
+
{e.shortcut ? <DropdownMenuShortcut>{e.shortcut}</DropdownMenuShortcut> : null}
|
|
221
|
+
</DropdownMenuItem>
|
|
222
|
+
))}
|
|
223
|
+
</DropdownMenuContent>
|
|
224
|
+
</DropdownMenu>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Renders one spec as a button, toggle or dropdown. */
|
|
229
|
+
function ToolbarItem({
|
|
230
|
+
item,
|
|
231
|
+
onCommand,
|
|
232
|
+
}: {
|
|
233
|
+
item: ToolbarItemSpec
|
|
234
|
+
onCommand?: (id: string) => void
|
|
235
|
+
}) {
|
|
236
|
+
if (item.menu) {
|
|
237
|
+
return <ToolbarDropdown {...item} entries={item.menu} onCommand={onCommand} />
|
|
238
|
+
}
|
|
239
|
+
return <ToolbarButton {...item} onClick={() => onCommand?.(item.id)} />
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface ToolbarItemsProps extends Omit<React.ComponentProps<typeof Toolbar>, "children"> {
|
|
243
|
+
/** Groups of items; a separator is drawn between groups. */
|
|
244
|
+
groups: ToolbarItemSpec[][]
|
|
245
|
+
/** Called with the item's (or menu entry's) id. The app runs the same command the menu does. */
|
|
246
|
+
onCommand?: (id: string) => void
|
|
247
|
+
/** Pinned to the far left, before the groups (the left-panel toggle). */
|
|
248
|
+
leading?: ToolbarItemSpec[]
|
|
249
|
+
/** Pinned to the far right (the right-panel toggle). */
|
|
250
|
+
trailing?: ToolbarItemSpec[]
|
|
251
|
+
/** Extra content between the groups and the trailing items: a slot for anything custom. */
|
|
252
|
+
children?: React.ReactNode
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The data-driven form: a toolbar from an item table. */
|
|
256
|
+
function ToolbarItems({
|
|
257
|
+
groups,
|
|
258
|
+
onCommand,
|
|
259
|
+
leading,
|
|
260
|
+
trailing,
|
|
261
|
+
children,
|
|
262
|
+
...props
|
|
263
|
+
}: ToolbarItemsProps) {
|
|
264
|
+
const lead = leading?.length ? [leading] : []
|
|
265
|
+
const all = [...lead, ...groups.filter((g) => g.length > 0)]
|
|
266
|
+
return (
|
|
267
|
+
<Toolbar {...props}>
|
|
268
|
+
{all.map((group, i) => (
|
|
269
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: groups are anonymous lists rebuilt each render and never reordered
|
|
270
|
+
<React.Fragment key={i}>
|
|
271
|
+
{i > 0 && <ToolbarSeparator />}
|
|
272
|
+
<ToolbarGroup>
|
|
273
|
+
{group.map((item) => (
|
|
274
|
+
<ToolbarItem key={item.id} item={item} onCommand={onCommand} />
|
|
275
|
+
))}
|
|
276
|
+
</ToolbarGroup>
|
|
277
|
+
</React.Fragment>
|
|
278
|
+
))}
|
|
279
|
+
{children}
|
|
280
|
+
{trailing?.length ? (
|
|
281
|
+
<>
|
|
282
|
+
<ToolbarSpacer />
|
|
283
|
+
<ToolbarGroup>
|
|
284
|
+
{trailing.map((item) => (
|
|
285
|
+
<ToolbarItem key={item.id} item={item} onCommand={onCommand} />
|
|
286
|
+
))}
|
|
287
|
+
</ToolbarGroup>
|
|
288
|
+
</>
|
|
289
|
+
) : null}
|
|
290
|
+
</Toolbar>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type { ToolbarItemSpec, ToolbarMenuEntry }
|
|
295
|
+
export {
|
|
296
|
+
Toolbar,
|
|
297
|
+
ToolbarButton,
|
|
298
|
+
ToolbarDropdown,
|
|
299
|
+
ToolbarGroup,
|
|
300
|
+
ToolbarItem,
|
|
301
|
+
ToolbarItems,
|
|
302
|
+
ToolbarSeparator,
|
|
303
|
+
ToolbarSpacer,
|
|
304
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils"
|
|
4
|
+
|
|
5
|
+
// Base UI: `delay` (ms) replaces Radix's `delayDuration`. Triggers take
|
|
6
|
+
// `render={<Button />}` where Radix used `asChild`.
|
|
7
|
+
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
|
|
8
|
+
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
|
12
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
16
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function TooltipContent({
|
|
20
|
+
className,
|
|
21
|
+
side = "top",
|
|
22
|
+
sideOffset = 4,
|
|
23
|
+
align = "center",
|
|
24
|
+
alignOffset = 0,
|
|
25
|
+
children,
|
|
26
|
+
...props
|
|
27
|
+
}: TooltipPrimitive.Popup.Props &
|
|
28
|
+
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
|
29
|
+
return (
|
|
30
|
+
<TooltipPrimitive.Portal>
|
|
31
|
+
<TooltipPrimitive.Positioner
|
|
32
|
+
align={align}
|
|
33
|
+
alignOffset={alignOffset}
|
|
34
|
+
side={side}
|
|
35
|
+
sideOffset={sideOffset}
|
|
36
|
+
className="isolate z-50"
|
|
37
|
+
>
|
|
38
|
+
<TooltipPrimitive.Popup
|
|
39
|
+
data-slot="tooltip-content"
|
|
40
|
+
className={cn(
|
|
41
|
+
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
|
48
|
+
</TooltipPrimitive.Popup>
|
|
49
|
+
</TooltipPrimitive.Positioner>
|
|
50
|
+
</TooltipPrimitive.Portal>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type Curve, clamp } from "@preset.nz/math"
|
|
2
|
+
|
|
3
|
+
import type { CurveConstraint } from "./curve-edits"
|
|
4
|
+
|
|
5
|
+
/** An ADSR envelope's four controls. Times are in the curve's x units; sustain is a level, 0..1. */
|
|
6
|
+
export interface Adsr {
|
|
7
|
+
attack: number
|
|
8
|
+
decay: number
|
|
9
|
+
sustain: number
|
|
10
|
+
release: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The curve an ADSR draws: start at rest, peak, sustain level, end at rest, with the sustain
|
|
15
|
+
* mark on the third point. Shard's sliders and the editor both write these four points.
|
|
16
|
+
*/
|
|
17
|
+
export function adsrCurve({ attack, decay, sustain, release }: Adsr): Curve {
|
|
18
|
+
const a = Math.max(0, attack)
|
|
19
|
+
const d = Math.max(0, decay)
|
|
20
|
+
const r = Math.max(0, release)
|
|
21
|
+
const s = clamp(sustain, 0, 1)
|
|
22
|
+
return {
|
|
23
|
+
points: [
|
|
24
|
+
{ x: 0, y: 0, basis: "linear" },
|
|
25
|
+
{ x: a, y: 1, basis: "linear" },
|
|
26
|
+
{ x: a + d, y: s, basis: "linear" },
|
|
27
|
+
{ x: a + d + r, y: 0, basis: "linear" },
|
|
28
|
+
],
|
|
29
|
+
sustain: 2,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The ADSR a curve draws, or `null` when it no longer has that shape. */
|
|
34
|
+
export function adsrOf(curve: Curve): Adsr | null {
|
|
35
|
+
const [p0, p1, p2, p3] = curve.points
|
|
36
|
+
if (curve.points.length !== 4 || !p0 || !p1 || !p2 || !p3 || curve.sustain !== 2) return null
|
|
37
|
+
if (p0.x !== 0 || p0.y !== 0 || p1.y !== 1 || p3.y !== 0) return null
|
|
38
|
+
return { attack: p1.x - p0.x, decay: p2.x - p1.x, sustain: p2.y, release: p3.x - p2.x }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The limits that keep a curve an ADSR. No points are added or removed and sustain stays on the
|
|
43
|
+
* third point. The start is pinned at rest, the peak's level at 1 and the end's at rest. Moving
|
|
44
|
+
* a point changes only the stage that ends there, and the later points ride along, so a longer
|
|
45
|
+
* attack doesn't eat the decay. The whole shape stays within `maxX`.
|
|
46
|
+
*/
|
|
47
|
+
export function constrainAdsr(maxX = Infinity): CurveConstraint {
|
|
48
|
+
return (next, prev) => {
|
|
49
|
+
if (next.points.length !== 4 || prev.points.length !== 4 || next.sustain !== 2) return null
|
|
50
|
+
const [n0, n1, n2, n3] = next.points
|
|
51
|
+
const [p0, p1, p2, p3] = prev.points
|
|
52
|
+
if (!(n0 && n1 && n2 && n3 && p0 && p1 && p2 && p3)) return null
|
|
53
|
+
const [wa, wd, wr] = durations(p0, p1, p2, p3)
|
|
54
|
+
const [na, nd, nr] = durations(n0, n1, n2, n3)
|
|
55
|
+
// The stage whose end moved takes the new length; the others keep theirs.
|
|
56
|
+
const moved = [n1.x !== p1.x, n2.x !== p2.x, n3.x !== p3.x].indexOf(true) + 1
|
|
57
|
+
const len = [
|
|
58
|
+
moved === 1 ? Math.max(0, na) : wa,
|
|
59
|
+
moved === 2 ? Math.max(0, nd) : wd,
|
|
60
|
+
moved === 3 ? Math.max(0, nr) : wr,
|
|
61
|
+
] as [number, number, number]
|
|
62
|
+
const over = len[0] + len[1] + len[2] - maxX
|
|
63
|
+
if (over > 0 && moved > 0) len[moved - 1] = Math.max(0, (len[moved - 1] ?? 0) - over)
|
|
64
|
+
const ys = [0, 1, clamp(n2.y, 0, 1), 0]
|
|
65
|
+
let x = 0
|
|
66
|
+
const points = next.points.map((p, i) => {
|
|
67
|
+
if (i > 0) x += len[i - 1] ?? 0
|
|
68
|
+
return { ...p, x, y: ys[i] ?? p.y }
|
|
69
|
+
})
|
|
70
|
+
return { points, sustain: 2 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Point = Curve["points"][number]
|
|
75
|
+
|
|
76
|
+
function durations(p0: Point, p1: Point, p2: Point, p3: Point): [number, number, number] {
|
|
77
|
+
return [p1.x - p0.x, p2.x - p1.x, p3.x - p2.x]
|
|
78
|
+
}
|