@brett_lamy/docstream 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/strudel/RackGrid.d.ts +23 -0
- package/dist/strudel/RackGrid.d.ts.map +1 -0
- package/dist/strudel/RackGrid.js +182 -0
- package/dist/strudel/RackGrid.js.map +1 -0
- package/dist/strudel/StrudelRack.d.ts +5 -1
- package/dist/strudel/StrudelRack.d.ts.map +1 -1
- package/dist/strudel/StrudelRack.js +35 -4
- package/dist/strudel/StrudelRack.js.map +1 -1
- package/dist/strudel/controls.d.ts +2 -0
- package/dist/strudel/controls.d.ts.map +1 -1
- package/dist/strudel/controls.js +27 -21
- package/dist/strudel/controls.js.map +1 -1
- package/dist/strudel/index.d.ts +2 -0
- package/dist/strudel/index.d.ts.map +1 -1
- package/dist/strudel/index.js +1 -0
- package/dist/strudel/index.js.map +1 -1
- package/dist/styles.css +456 -118
- package/package.json +1 -1
- package/src/strudel/RackGrid.tsx +231 -0
- package/src/strudel/StrudelRack.tsx +80 -16
- package/src/strudel/controls.tsx +65 -28
- package/src/strudel/index.ts +2 -0
- package/src/styles.css +456 -118
package/package.json
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* A masonry grid of modules you can rearrange and resize. Columns follow the container's width; every module spans
|
|
2
|
+
some of them and as many 8px rows as its content (or the height you dragged it to) needs, and `dense` flow packs
|
|
3
|
+
the gaps. Drag a module's grip to move it (others reflow under the pointer); drag its corner to resize. Both
|
|
4
|
+
handles are keyboard-operable: arrows move or resize, Home/End move to the ends. */
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
export interface GridItem {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
node: ReactNode
|
|
11
|
+
/** Default width in twelfths of the grid (12 = full width). */
|
|
12
|
+
span?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GridLayout {
|
|
16
|
+
order: string[]
|
|
17
|
+
/** Width in twelfths, and an optional minimum height in px, per item key. */
|
|
18
|
+
sizes: Record<string, { w: number; h?: number }>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RackGridProps {
|
|
22
|
+
items: GridItem[]
|
|
23
|
+
layout: GridLayout
|
|
24
|
+
onLayoutChange: (layout: GridLayout) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ROW = 8
|
|
28
|
+
const GAP = 10
|
|
29
|
+
|
|
30
|
+
/** Items in layout order: known keys first, new ones appended. */
|
|
31
|
+
function ordered(items: GridItem[], order: string[]): GridItem[] {
|
|
32
|
+
const byKey = new Map(items.map((i) => [i.key, i]))
|
|
33
|
+
const out = order.map((k) => byKey.get(k)).filter((i): i is GridItem => !!i)
|
|
34
|
+
for (const i of items) if (!order.includes(i.key)) out.push(i)
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function RackGrid({ items, layout, onLayoutChange }: RackGridProps) {
|
|
39
|
+
const gridRef = useRef<HTMLDivElement>(null)
|
|
40
|
+
const [cols, setCols] = useState(12)
|
|
41
|
+
const [heights, setHeights] = useState<Record<string, number>>({})
|
|
42
|
+
const [dragging, setDragging] = useState<string | null>(null)
|
|
43
|
+
const drag = useRef<{ key: string; dx: number; dy: number; x: number; y: number } | null>(null)
|
|
44
|
+
const elements = useRef(new Map<string, HTMLDivElement>())
|
|
45
|
+
const list = ordered(items, layout.order)
|
|
46
|
+
const keys = list.map((i) => i.key)
|
|
47
|
+
const layoutRef = useRef(layout)
|
|
48
|
+
layoutRef.current = layout
|
|
49
|
+
|
|
50
|
+
// Columns: one per ~84px of width, at most 12 — spans are stored in twelfths and scaled.
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
const grid = gridRef.current
|
|
53
|
+
if (!grid) return
|
|
54
|
+
const measure = () => setCols(Math.max(2, Math.min(12, Math.floor((grid.clientWidth + GAP) / 84))))
|
|
55
|
+
measure()
|
|
56
|
+
const ro = new ResizeObserver(measure)
|
|
57
|
+
ro.observe(grid)
|
|
58
|
+
return () => ro.disconnect()
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
// Masonry: each module spans as many rows as its content is tall.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const ro = new ResizeObserver((entries) => {
|
|
64
|
+
setHeights((prev) => {
|
|
65
|
+
let next = prev
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const key = (e.target as HTMLElement).dataset.content!
|
|
68
|
+
const h = Math.ceil((e.target as HTMLElement).offsetHeight)
|
|
69
|
+
if (prev[key] !== h) next = { ...next, [key]: h }
|
|
70
|
+
}
|
|
71
|
+
return next
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
elements.current.forEach((el) => {
|
|
75
|
+
const content = el.querySelector<HTMLElement>("[data-content]")
|
|
76
|
+
if (content) ro.observe(content)
|
|
77
|
+
})
|
|
78
|
+
return () => ro.disconnect()
|
|
79
|
+
}, [keys.join("|")])
|
|
80
|
+
|
|
81
|
+
const span = (item: GridItem) => {
|
|
82
|
+
const w = layout.sizes[item.key]?.w ?? item.span ?? 6
|
|
83
|
+
return Math.max(1, Math.min(cols, Math.round((w / 12) * cols)))
|
|
84
|
+
}
|
|
85
|
+
const rows = (item: GridItem) => {
|
|
86
|
+
const h = Math.max(heights[item.key] ?? 120, layout.sizes[item.key]?.h ?? 0)
|
|
87
|
+
return Math.ceil((h + GAP) / (ROW + GAP))
|
|
88
|
+
}
|
|
89
|
+
const setSize = (key: string, size: { w?: number; h?: number }) => {
|
|
90
|
+
const cur = layoutRef.current
|
|
91
|
+
onLayoutChange({ order: keys, sizes: { ...cur.sizes, [key]: { ...cur.sizes[key], w: cur.sizes[key]?.w ?? 6, ...size } } })
|
|
92
|
+
}
|
|
93
|
+
const move = (key: string, to: number) => {
|
|
94
|
+
const from = keys.indexOf(key)
|
|
95
|
+
const next = keys.filter((k) => k !== key)
|
|
96
|
+
next.splice(Math.max(0, Math.min(next.length, to)), 0, key)
|
|
97
|
+
if (from !== next.indexOf(key)) onLayoutChange({ ...layoutRef.current, order: next })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Moving: the module follows the pointer; hovering another module takes its place.
|
|
101
|
+
const place = (key: string, x: number, y: number) => {
|
|
102
|
+
const el = elements.current.get(key)
|
|
103
|
+
const grid = gridRef.current
|
|
104
|
+
const d = drag.current
|
|
105
|
+
if (!el || !grid || !d) return
|
|
106
|
+
const box = grid.getBoundingClientRect()
|
|
107
|
+
el.style.transform = `translate(${x - d.dx - box.left - el.offsetLeft}px, ${y - d.dy - box.top - el.offsetTop}px)`
|
|
108
|
+
}
|
|
109
|
+
const onGripDown = (key: string) => (e: PointerEvent<HTMLElement>) => {
|
|
110
|
+
if (e.button !== 0) return
|
|
111
|
+
const el = elements.current.get(key)!
|
|
112
|
+
const r = el.getBoundingClientRect()
|
|
113
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
114
|
+
drag.current = { key, dx: e.clientX - r.left, dy: e.clientY - r.top, x: e.clientX, y: e.clientY }
|
|
115
|
+
setDragging(key)
|
|
116
|
+
}
|
|
117
|
+
const onGripMove = (e: PointerEvent<HTMLElement>) => {
|
|
118
|
+
const d = drag.current
|
|
119
|
+
if (!d) return
|
|
120
|
+
d.x = e.clientX
|
|
121
|
+
d.y = e.clientY
|
|
122
|
+
place(d.key, e.clientX, e.clientY)
|
|
123
|
+
const dragged = elements.current.get(d.key)!
|
|
124
|
+
dragged.style.pointerEvents = "none"
|
|
125
|
+
const under = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>("[data-grid-key]")
|
|
126
|
+
dragged.style.pointerEvents = ""
|
|
127
|
+
const target = under?.dataset.gridKey
|
|
128
|
+
if (target && target !== d.key) move(d.key, keys.indexOf(target))
|
|
129
|
+
}
|
|
130
|
+
const onGripUp = () => {
|
|
131
|
+
const d = drag.current
|
|
132
|
+
if (!d) return
|
|
133
|
+
const el = elements.current.get(d.key)
|
|
134
|
+
if (el) el.style.transform = ""
|
|
135
|
+
drag.current = null
|
|
136
|
+
setDragging(null)
|
|
137
|
+
}
|
|
138
|
+
// After a reorder the module's slot moved: keep it under the pointer.
|
|
139
|
+
useLayoutEffect(() => {
|
|
140
|
+
const d = drag.current
|
|
141
|
+
if (d) place(d.key, d.x, d.y)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const onGripKey = (key: string) => (e: KeyboardEvent<HTMLElement>) => {
|
|
145
|
+
const i = keys.indexOf(key)
|
|
146
|
+
const to = { ArrowLeft: i - 1, ArrowUp: i - 1, ArrowRight: i + 1, ArrowDown: i + 1, Home: 0, End: keys.length - 1 }[e.key]
|
|
147
|
+
if (to === undefined) return
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
move(key, to)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resizing: the corner sets the width in columns and a minimum height.
|
|
153
|
+
const resize = useRef<{ key: string; left: number; top: number } | null>(null)
|
|
154
|
+
const onCornerDown = (key: string) => (e: PointerEvent<HTMLElement>) => {
|
|
155
|
+
const r = elements.current.get(key)!.getBoundingClientRect()
|
|
156
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
157
|
+
resize.current = { key, left: r.left, top: r.top }
|
|
158
|
+
}
|
|
159
|
+
const onCornerMove = (e: PointerEvent<HTMLElement>) => {
|
|
160
|
+
const r = resize.current
|
|
161
|
+
const grid = gridRef.current
|
|
162
|
+
if (!r || !grid) return
|
|
163
|
+
const colWidth = (grid.clientWidth - GAP * (cols - 1)) / cols
|
|
164
|
+
const c = Math.max(1, Math.min(cols, Math.round((e.clientX - r.left + GAP) / (colWidth + GAP))))
|
|
165
|
+
setSize(r.key, { w: Math.round((c / cols) * 12 * 100) / 100, h: Math.max(60, Math.round(e.clientY - r.top)) })
|
|
166
|
+
}
|
|
167
|
+
const onCornerUp = () => {
|
|
168
|
+
resize.current = null
|
|
169
|
+
}
|
|
170
|
+
const onCornerKey = (item: GridItem) => (e: KeyboardEvent<HTMLElement>) => {
|
|
171
|
+
const w = layout.sizes[item.key]?.w ?? item.span ?? 6
|
|
172
|
+
const h = layout.sizes[item.key]?.h ?? heights[item.key] ?? 120
|
|
173
|
+
const step = 12 / cols
|
|
174
|
+
const next = {
|
|
175
|
+
ArrowRight: { w: Math.min(12, w + step) },
|
|
176
|
+
ArrowLeft: { w: Math.max(step, w - step) },
|
|
177
|
+
ArrowDown: { h: h + 24 },
|
|
178
|
+
ArrowUp: { h: Math.max(0, h - 24) },
|
|
179
|
+
}[e.key]
|
|
180
|
+
if (!next) return
|
|
181
|
+
e.preventDefault()
|
|
182
|
+
setSize(item.key, next)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div ref={gridRef} className="strudel-grid" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridAutoRows: ROW, rowGap: GAP, columnGap: GAP }}>
|
|
187
|
+
{list.map((item) => {
|
|
188
|
+
const h = layout.sizes[item.key]?.h
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
key={item.key}
|
|
192
|
+
ref={(el) => {
|
|
193
|
+
if (el) elements.current.set(item.key, el)
|
|
194
|
+
else elements.current.delete(item.key)
|
|
195
|
+
}}
|
|
196
|
+
className="strudel-grid__item"
|
|
197
|
+
data-grid-key={item.key}
|
|
198
|
+
data-dragging={dragging === item.key || undefined}
|
|
199
|
+
style={{ gridColumn: `span ${span(item)}`, gridRow: `span ${rows(item)}` }}
|
|
200
|
+
>
|
|
201
|
+
<div data-content={item.key} className="strudel-grid__content" style={h ? { minHeight: h } : undefined}>
|
|
202
|
+
{item.node}
|
|
203
|
+
</div>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className="strudel-grid__grip"
|
|
207
|
+
aria-label={`Move ${item.label}`}
|
|
208
|
+
title="Drag to move (arrow keys too)"
|
|
209
|
+
onPointerDown={onGripDown(item.key)}
|
|
210
|
+
onPointerMove={onGripMove}
|
|
211
|
+
onPointerUp={onGripUp}
|
|
212
|
+
onPointerCancel={onGripUp}
|
|
213
|
+
onKeyDown={onGripKey(item.key)}
|
|
214
|
+
/>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
className="strudel-grid__corner"
|
|
218
|
+
aria-label={`Resize ${item.label}`}
|
|
219
|
+
title="Drag to resize (arrow keys too)"
|
|
220
|
+
onPointerDown={onCornerDown(item.key)}
|
|
221
|
+
onPointerMove={onCornerMove}
|
|
222
|
+
onPointerUp={onCornerUp}
|
|
223
|
+
onPointerCancel={onCornerUp}
|
|
224
|
+
onKeyDown={onCornerKey(item)}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
@@ -9,6 +9,7 @@ import { findCall, parseStrudel, StrudelSyntaxError, type StrudelArg, type Strud
|
|
|
9
9
|
import { formatNumber, isAudible, mixCode, removeMethod, setMethod, setMiniAtom, setMiniSource, setTempo, type StrudelMix } from "./ast/edit"
|
|
10
10
|
import { Fader, Knob, defaultFormat } from "./controls"
|
|
11
11
|
import { StepGrid } from "./StepGrid"
|
|
12
|
+
import { RackGrid, type GridLayout } from "./RackGrid"
|
|
12
13
|
|
|
13
14
|
export interface StrudelRackProps {
|
|
14
15
|
code: string
|
|
@@ -16,6 +17,24 @@ export interface StrudelRackProps {
|
|
|
16
17
|
/** The code to play for the current mute/solo mix (the written code with silenced tracks). */
|
|
17
18
|
onPlayableChange?: (code: string) => void
|
|
18
19
|
className?: string
|
|
20
|
+
/** "rack": modules stacked full width. "grid": a masonry grid you drag and resize. Defaults to "rack". */
|
|
21
|
+
layout?: "rack" | "grid"
|
|
22
|
+
/** Remember the layout (mode, order and sizes) in localStorage under this key. */
|
|
23
|
+
persistKey?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Saved {
|
|
27
|
+
mode: "rack" | "grid"
|
|
28
|
+
grid: GridLayout
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load(key: string | undefined): Partial<Saved> {
|
|
32
|
+
if (!key) return {}
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(localStorage.getItem(`strudel-rack:${key}`) ?? "{}") as Partial<Saved>
|
|
35
|
+
} catch {
|
|
36
|
+
return {}
|
|
37
|
+
}
|
|
19
38
|
}
|
|
20
39
|
|
|
21
40
|
interface Param {
|
|
@@ -120,7 +139,8 @@ function Device({
|
|
|
120
139
|
return (
|
|
121
140
|
<section className="strudel-device" data-kind={track.kind} data-audible={audible} aria-label={name}>
|
|
122
141
|
<header className="strudel-device__head">
|
|
123
|
-
<
|
|
142
|
+
<span className="strudel-device__led" aria-hidden="true" />
|
|
143
|
+
<Icon size={14} aria-hidden="true" />
|
|
124
144
|
<span className="strudel-device__name" title={code.slice(track.start, track.end)}>
|
|
125
145
|
{name}
|
|
126
146
|
</span>
|
|
@@ -145,7 +165,7 @@ function Device({
|
|
|
145
165
|
/>
|
|
146
166
|
<div className="strudel-device__main">
|
|
147
167
|
{(synth || scaleMatch) && (
|
|
148
|
-
<div className="strudel-device__selects">
|
|
168
|
+
<div className="strudel-device__selects strudel-lcd">
|
|
149
169
|
{synth && (
|
|
150
170
|
<label>
|
|
151
171
|
<span>Sound</span>
|
|
@@ -229,7 +249,18 @@ function Device({
|
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
/** Equipment generated from Strudel code. Controlled: `code` in, edited code out through `onChange`. */
|
|
232
|
-
export function StrudelRack({ code, onChange, onPlayableChange, className }: StrudelRackProps) {
|
|
252
|
+
export function StrudelRack({ code, onChange, onPlayableChange, className, layout = "rack", persistKey }: StrudelRackProps) {
|
|
253
|
+
const [saved] = useState(() => load(persistKey))
|
|
254
|
+
const [mode, setMode] = useState<"rack" | "grid">(saved.mode ?? layout)
|
|
255
|
+
const [grid, setGrid] = useState<GridLayout>(saved.grid ?? { order: [], sizes: {} })
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!persistKey) return
|
|
258
|
+
try {
|
|
259
|
+
localStorage.setItem(`strudel-rack:${persistKey}`, JSON.stringify({ mode, grid } satisfies Saved))
|
|
260
|
+
} catch {
|
|
261
|
+
// Storage unavailable (private mode): the layout just isn't remembered.
|
|
262
|
+
}
|
|
263
|
+
}, [persistKey, mode, grid])
|
|
233
264
|
const parsed = useMemo((): { song: StrudelSong } | { error: string } => {
|
|
234
265
|
try {
|
|
235
266
|
return { song: parseStrudel(code) }
|
|
@@ -265,11 +296,18 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
|
|
|
265
296
|
const master = song.masterChain
|
|
266
297
|
const postgain = master ? firstNumber(findCall(master, "postgain")) : null
|
|
267
298
|
|
|
268
|
-
|
|
269
|
-
<div className={`strudel-rack ${className ?? ""}`.trim()}>
|
|
299
|
+
const masterStrip = (
|
|
270
300
|
<div className="strudel-rack__master" role="group" aria-label="Master">
|
|
271
|
-
<span className="strudel-rack__title">
|
|
301
|
+
<span className="strudel-rack__title">
|
|
302
|
+
Master
|
|
303
|
+
<small>Strudel</small>
|
|
304
|
+
</span>
|
|
305
|
+
<output className="strudel-lcd strudel-lcd--bpm" aria-label="Tempo">
|
|
306
|
+
{Math.round((cps && Number.isFinite(cps) ? cps : 0.5) * 2400) / 10}
|
|
307
|
+
<small>BPM</small>
|
|
308
|
+
</output>
|
|
272
309
|
<Knob
|
|
310
|
+
size="lg"
|
|
273
311
|
label="Tempo"
|
|
274
312
|
value={cps && Number.isFinite(cps) ? cps : 0.5}
|
|
275
313
|
min={0.1}
|
|
@@ -280,6 +318,7 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
|
|
|
280
318
|
/>
|
|
281
319
|
{master && (
|
|
282
320
|
<Knob
|
|
321
|
+
size="lg"
|
|
283
322
|
label="Output"
|
|
284
323
|
value={postgain ?? 1}
|
|
285
324
|
min={0}
|
|
@@ -293,18 +332,43 @@ export function StrudelRack({ code, onChange, onPlayableChange, className }: Str
|
|
|
293
332
|
{song.tracks.length} track{song.tracks.length === 1 ? "" : "s"}
|
|
294
333
|
{song.layout === "stack" ? " · stack" : song.layout === "labels" ? " · $:" : ""}
|
|
295
334
|
</span>
|
|
335
|
+
<div className="strudel-rack__mode" role="radiogroup" aria-label="Layout">
|
|
336
|
+
{(["rack", "grid"] as const).map((m) => (
|
|
337
|
+
<button key={m} type="button" role="radio" aria-checked={mode === m} onClick={() => setMode(m)}>
|
|
338
|
+
{m}
|
|
339
|
+
</button>
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
296
342
|
</div>
|
|
343
|
+
)
|
|
344
|
+
const devices = song.tracks.map((t) => ({
|
|
345
|
+
key: `track-${t.index}`,
|
|
346
|
+
label: trackName(t),
|
|
347
|
+
span: 6,
|
|
348
|
+
node: (
|
|
349
|
+
<Device
|
|
350
|
+
song={song}
|
|
351
|
+
track={t}
|
|
352
|
+
mix={mix}
|
|
353
|
+
onChange={onChange}
|
|
354
|
+
onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
|
|
355
|
+
/>
|
|
356
|
+
),
|
|
357
|
+
}))
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<div className={`strudel-rack ${className ?? ""}`.trim()} data-mode={mode}>
|
|
361
|
+
{mode === "grid" ? (
|
|
362
|
+
<RackGrid items={[{ key: "master", label: "Master", span: 12, node: masterStrip }, ...devices]} layout={grid} onLayoutChange={setGrid} />
|
|
363
|
+
) : (
|
|
364
|
+
<>
|
|
365
|
+
{masterStrip}
|
|
366
|
+
{devices.map((d) => (
|
|
367
|
+
<div key={d.key}>{d.node}</div>
|
|
368
|
+
))}
|
|
369
|
+
</>
|
|
370
|
+
)}
|
|
297
371
|
{song.tracks.length === 0 && <p className="strudel-rack__error">No pattern found.</p>}
|
|
298
|
-
{song.tracks.map((t) => (
|
|
299
|
-
<Device
|
|
300
|
-
key={t.index}
|
|
301
|
-
song={song}
|
|
302
|
-
track={t}
|
|
303
|
-
mix={mix}
|
|
304
|
-
onChange={onChange}
|
|
305
|
-
onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
|
|
306
|
-
/>
|
|
307
|
-
))}
|
|
308
372
|
</div>
|
|
309
373
|
)
|
|
310
374
|
}
|
package/src/strudel/controls.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/* The rack's hardware: a rotary knob
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* The rack's hardware: a rotary knob (rubber skirt, machined cap, LED ring) and a channel fader (slotted
|
|
2
|
+
track, tick scale, chunky cap). Both are ARIA sliders — drag (Shift for fine), arrow keys, Page Up/Down,
|
|
3
|
+
Home/End — and double-click resets to the default. Values map logarithmically when `log`. */
|
|
4
|
+
import { useId, useRef, type KeyboardEvent, type PointerEvent } from "react"
|
|
4
5
|
|
|
5
6
|
export interface ControlProps {
|
|
6
7
|
label: string
|
|
@@ -16,6 +17,8 @@ export interface ControlProps {
|
|
|
16
17
|
/** Shown instead of the value, e.g. "∿" for a modulated parameter. */
|
|
17
18
|
display?: string
|
|
18
19
|
title?: string
|
|
20
|
+
/** Knob only: "lg" for master controls. */
|
|
21
|
+
size?: "md" | "lg"
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
|
@@ -38,20 +41,19 @@ export function defaultFormat(v: number): string {
|
|
|
38
41
|
return v.toFixed(2)
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
function useDrag(props: ControlProps,
|
|
44
|
+
function useDrag(props: ControlProps, travel: number) {
|
|
42
45
|
const start = useRef<{ pos: number; unit: number } | null>(null)
|
|
43
46
|
const set = (unit: number) => props.onChange(fromUnit(unit, props))
|
|
44
47
|
const onPointerDown = (e: PointerEvent<HTMLElement>) => {
|
|
45
48
|
if (props.disabled) return
|
|
46
49
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
47
50
|
e.currentTarget.focus()
|
|
48
|
-
start.current = { pos:
|
|
51
|
+
start.current = { pos: e.clientY, unit: toUnit(props.value, props) }
|
|
49
52
|
}
|
|
50
53
|
const onPointerMove = (e: PointerEvent<HTMLElement>) => {
|
|
51
54
|
const s = start.current
|
|
52
55
|
if (!s) return
|
|
53
|
-
|
|
54
|
-
set(s.unit + (delta / travel) * (e.shiftKey ? 0.2 : 1))
|
|
56
|
+
set(s.unit + ((s.pos - e.clientY) / travel) * (e.shiftKey ? 0.2 : 1))
|
|
55
57
|
}
|
|
56
58
|
const onPointerUp = () => {
|
|
57
59
|
start.current = null
|
|
@@ -95,31 +97,57 @@ function aria(props: ControlProps, format: (v: number) => string) {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
const ARC = 270
|
|
98
|
-
const
|
|
100
|
+
const LEDS = 15
|
|
101
|
+
const polar = (deg: number, r: number, c = 30) => {
|
|
99
102
|
const a = ((deg - 90) * Math.PI) / 180
|
|
100
|
-
return [
|
|
101
|
-
}
|
|
102
|
-
const arc = (from: number, to: number, r: number) => {
|
|
103
|
-
const [x1, y1] = polar(from, r)
|
|
104
|
-
const [x2, y2] = polar(to, r)
|
|
105
|
-
return `M ${x1} ${y1} A ${r} ${r} 0 ${to - from > 180 ? 1 : 0} 1 ${x2} ${y2}`
|
|
103
|
+
return [c + r * Math.cos(a), c + r * Math.sin(a)] as const
|
|
106
104
|
}
|
|
107
105
|
|
|
108
106
|
export function Knob(props: ControlProps) {
|
|
107
|
+
const id = useId().replace(/:/g, "")
|
|
109
108
|
const format = props.format ?? defaultFormat
|
|
110
|
-
const handlers = useDrag(props,
|
|
109
|
+
const handlers = useDrag(props, 160)
|
|
111
110
|
const u = toUnit(props.value, props)
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
const
|
|
111
|
+
const angle = -ARC / 2 + ARC * u
|
|
112
|
+
// A bipolar parameter (pan, speed) lights from the centre; the rest light from the left.
|
|
113
|
+
const bipolar = props.min < 0 || props.label === "Pan"
|
|
114
|
+
const zero = bipolar ? toUnit(props.min < 0 ? 0 : (props.min + props.max) / 2, props) : 0
|
|
115
|
+
const lit = (i: number) => {
|
|
116
|
+
const t = i / (LEDS - 1)
|
|
117
|
+
return !props.disabled && (bipolar ? (t >= Math.min(zero, u) - 0.001 && t <= Math.max(zero, u) + 0.001) : t <= u + 0.001)
|
|
118
|
+
}
|
|
115
119
|
return (
|
|
116
|
-
<div className="strudel-knob" data-disabled={props.disabled || undefined}>
|
|
120
|
+
<div className="strudel-knob" data-size={props.size ?? "md"} data-disabled={props.disabled || undefined}>
|
|
117
121
|
<div className="strudel-knob__dial" {...aria(props, format)} {...handlers}>
|
|
118
|
-
<svg viewBox="0 0
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
<svg viewBox="0 0 60 60" aria-hidden="true">
|
|
123
|
+
<defs>
|
|
124
|
+
<radialGradient id={`${id}-skirt`} cx="50%" cy="35%" r="70%">
|
|
125
|
+
<stop offset="0" stopColor="#4a4d55" />
|
|
126
|
+
<stop offset=".6" stopColor="#1d1f24" />
|
|
127
|
+
<stop offset="1" stopColor="#0b0c0e" />
|
|
128
|
+
</radialGradient>
|
|
129
|
+
<linearGradient id={`${id}-cap`} x1="0" y1="0" x2="0" y2="1">
|
|
130
|
+
<stop offset="0" stopColor="#f2f3f5" />
|
|
131
|
+
<stop offset=".45" stopColor="#a9adb5" />
|
|
132
|
+
<stop offset=".55" stopColor="#8a8f98" />
|
|
133
|
+
<stop offset="1" stopColor="#d7dae0" />
|
|
134
|
+
</linearGradient>
|
|
135
|
+
</defs>
|
|
136
|
+
{Array.from({ length: LEDS }, (_, i) => {
|
|
137
|
+
const [x, y] = polar(-ARC / 2 + (ARC * i) / (LEDS - 1), 27)
|
|
138
|
+
return <circle key={i} cx={x} cy={y} r="1.7" className="strudel-knob__led" data-on={lit(i) || undefined} />
|
|
139
|
+
})}
|
|
140
|
+
<circle cx="30" cy="31.5" r="19.5" className="strudel-knob__shadow" />
|
|
141
|
+
<circle cx="30" cy="30" r="19" fill={`url(#${id}-skirt)`} className="strudel-knob__skirt" />
|
|
142
|
+
{/* Grip ridges on the skirt, then the machined cap and its indicator. */}
|
|
143
|
+
<g transform={`rotate(${angle} 30 30)`}>
|
|
144
|
+
{Array.from({ length: 24 }, (_, i) => (
|
|
145
|
+
<line key={i} x1="30" y1="11.6" x2="30" y2="14" transform={`rotate(${i * 15} 30 30)`} className="strudel-knob__ridge" />
|
|
146
|
+
))}
|
|
147
|
+
<circle cx="30" cy="30" r="12.5" fill={`url(#${id}-cap)`} className="strudel-knob__cap" />
|
|
148
|
+
<circle cx="30" cy="30" r="12.5" className="strudel-knob__brush" />
|
|
149
|
+
<line x1="30" y1="19.5" x2="30" y2="26" className="strudel-knob__pointer" />
|
|
150
|
+
</g>
|
|
123
151
|
</svg>
|
|
124
152
|
</div>
|
|
125
153
|
<span className="strudel-knob__value">{props.display ?? format(props.value)}</span>
|
|
@@ -128,16 +156,25 @@ export function Knob(props: ControlProps) {
|
|
|
128
156
|
)
|
|
129
157
|
}
|
|
130
158
|
|
|
159
|
+
const TICKS = [10, 8, 6, 4, 2, 0]
|
|
160
|
+
|
|
131
161
|
export function Fader(props: ControlProps) {
|
|
132
162
|
const format = props.format ?? defaultFormat
|
|
133
|
-
const handlers = useDrag(props,
|
|
163
|
+
const handlers = useDrag(props, 104)
|
|
134
164
|
const u = toUnit(props.value, props)
|
|
135
165
|
return (
|
|
136
166
|
<div className="strudel-fader" data-disabled={props.disabled || undefined}>
|
|
137
167
|
<span className="strudel-fader__value">{props.display ?? format(props.value)}</span>
|
|
138
|
-
<div className="strudel-
|
|
139
|
-
<div className="strudel-
|
|
140
|
-
|
|
168
|
+
<div className="strudel-fader__body">
|
|
169
|
+
<div className="strudel-fader__scale" aria-hidden="true">
|
|
170
|
+
{TICKS.map((t) => (
|
|
171
|
+
<span key={t}>{t}</span>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
<div className="strudel-fader__track" {...aria(props, format)} {...handlers} aria-orientation="vertical">
|
|
175
|
+
<div className="strudel-fader__slot" />
|
|
176
|
+
<div className="strudel-fader__cap" style={{ bottom: `calc(${u} * (100% - 22px))` }} />
|
|
177
|
+
</div>
|
|
141
178
|
</div>
|
|
142
179
|
<span className="strudel-fader__label">{props.label}</span>
|
|
143
180
|
</div>
|
package/src/strudel/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type { StrudelPlayerProps } from "./StrudelPlayer"
|
|
|
3
3
|
export { StrudelRack, STRUDEL_PARAMS, trackName } from "./StrudelRack"
|
|
4
4
|
export type { StrudelRackProps } from "./StrudelRack"
|
|
5
5
|
export { StepGrid } from "./StepGrid"
|
|
6
|
+
export { RackGrid } from "./RackGrid"
|
|
7
|
+
export type { GridItem, GridLayout, RackGridProps } from "./RackGrid"
|
|
6
8
|
export type { StepGridProps } from "./StepGrid"
|
|
7
9
|
export { Fader, Knob } from "./controls"
|
|
8
10
|
export type { ControlProps } from "./controls"
|