@zylem/ui 0.8.8 → 0.8.11
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/dist/chunk-V47CGFWX.js +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.js +1 -1
- package/package.json +22 -11
- package/src/components/Accordion/Accordion.tsx +10 -1
- package/src/components/AddTile/AddTile.css.ts +95 -0
- package/src/components/AddTile/AddTile.tsx +45 -0
- package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
- package/src/components/BuildLabel/BuildLabel.tsx +37 -0
- package/src/components/BuildLabel/mount-build-label.ts +77 -0
- package/src/components/BuildLabel/resolve-build-version.ts +47 -0
- package/src/components/Callout/Callout.css.ts +45 -0
- package/src/components/Callout/Callout.tsx +32 -0
- package/src/components/Card/Card.css.ts +18 -0
- package/src/components/Card/Card.tsx +25 -2
- package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
- package/src/components/ColorPalette/ColorPalette.tsx +78 -0
- package/src/components/Dialog/Dialog.css.ts +69 -2
- package/src/components/Dialog/Dialog.tsx +35 -7
- package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
- package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
- package/src/components/EmptyState/EmptyState.css.ts +9 -0
- package/src/components/EmptyState/EmptyState.tsx +3 -1
- package/src/components/FrameStrip/FrameStrip.css.ts +138 -0
- package/src/components/FrameStrip/FrameStrip.tsx +146 -0
- package/src/components/Menubar/Menubar.css.ts +52 -0
- package/src/components/Menubar/Menubar.tsx +77 -0
- package/src/components/Mixer/Knob.tsx +188 -0
- package/src/components/Mixer/Mixer.css.ts +246 -0
- package/src/components/Mixer/MixerStrip.tsx +150 -0
- package/src/components/NumberField/NumberField.tsx +5 -1
- package/src/components/Panel/Panel.tsx +6 -5
- package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
- package/src/components/PianoRoll/PianoRoll.tsx +353 -0
- package/src/components/PianoRoll/piano-roll-math.ts +89 -0
- package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
- package/src/components/PixelGrid/PixelGrid.tsx +48 -0
- package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
- package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
- package/src/components/Property/Property.css.ts +1 -1
- package/src/components/Property/Property.tsx +3 -0
- package/src/components/Sidebar/Sidebar.css.ts +8 -0
- package/src/components/Sidebar/Sidebar.tsx +10 -2
- package/src/components/Slider/Slider.tsx +6 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +172 -0
- package/src/components/SpriteSizePicker/snap-size.ts +28 -0
- package/src/components/Tabs/Tabs.css.ts +48 -0
- package/src/components/Tabs/Tabs.tsx +41 -3
- package/src/components/TextField/TextField.tsx +49 -5
- package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
- package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
- package/src/components/TimelineLanes/lanes-math.ts +182 -0
- package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
- package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
- package/src/components/TimelineTrack/timeline-math.ts +43 -0
- package/src/components/Toast/Toast.css.ts +48 -0
- package/src/components/Toast/Toast.tsx +37 -0
- package/src/components/ToolButton/ToolButton.tsx +92 -0
- package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
- package/src/components/ToolGroup/ToolGroup.tsx +29 -0
- package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
- package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
- package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
- package/src/components/TrackHeader/TrackHeader.tsx +145 -0
- package/src/components/TransportBar/TransportBar.css.ts +65 -0
- package/src/components/TransportBar/TransportBar.tsx +154 -0
- package/src/components/Waveform/Waveform.css.ts +77 -0
- package/src/components/Waveform/Waveform.tsx +195 -0
- package/src/components/Waveform/peaks.ts +49 -0
- package/src/components/index.ts +122 -0
- package/src/global/accordion.css.ts +23 -0
- package/src/global/hyperglass-base.css.ts +9 -6
- package/src/styles.ts +23 -0
- package/src/theme.css.ts +15 -0
- package/vite/collect-zylem-versions.ts +82 -0
- package/vite/index.ts +30 -0
- package/dist/chunk-3TP2SNNW.js +0 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createMemo, createSignal, For, Index, Show } from 'solid-js';
|
|
2
|
+
import { clamp } from '../TimelineLanes/lanes-math';
|
|
3
|
+
import {
|
|
4
|
+
cellStart,
|
|
5
|
+
isBlackKey,
|
|
6
|
+
type PianoRollNote,
|
|
7
|
+
pitchName,
|
|
8
|
+
pitchToRow,
|
|
9
|
+
resolveNoteDrag,
|
|
10
|
+
rowToPitch,
|
|
11
|
+
} from './piano-roll-math';
|
|
12
|
+
|
|
13
|
+
export type { PianoRollNote } from './piano-roll-math';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PPB = 36;
|
|
16
|
+
const DEFAULT_ROW = 14;
|
|
17
|
+
const KEYS_WIDTH = 56;
|
|
18
|
+
const END_PADDING = 24;
|
|
19
|
+
const VELOCITY_HEIGHT = 48;
|
|
20
|
+
|
|
21
|
+
export interface PianoRollProps {
|
|
22
|
+
notes: readonly PianoRollNote[];
|
|
23
|
+
/** Clip length in beats. */
|
|
24
|
+
length: number;
|
|
25
|
+
beatsPerBar?: number;
|
|
26
|
+
pixelsPerBeat?: number;
|
|
27
|
+
rowHeight?: number;
|
|
28
|
+
/** Lowest visible pitch (default C2 = 36). */
|
|
29
|
+
minPitch?: number;
|
|
30
|
+
/** Highest visible pitch (default C7 = 96). */
|
|
31
|
+
maxPitch?: number;
|
|
32
|
+
/** Grid snap in beats (default a sixteenth). */
|
|
33
|
+
snap?: number;
|
|
34
|
+
/** Duration of notes added by double-click, in beats (default one beat). */
|
|
35
|
+
newNoteDuration?: number;
|
|
36
|
+
playhead?: number;
|
|
37
|
+
selectedIds?: readonly string[];
|
|
38
|
+
onSelect?: (ids: string[]) => void;
|
|
39
|
+
/** Double-click on an empty cell. */
|
|
40
|
+
onAddNote?: (note: { pitch: number; start: number; duration: number }) => void;
|
|
41
|
+
/** Fires continuously while notes are dragged, with the changed notes. */
|
|
42
|
+
onChangeNotes?: (notes: PianoRollNote[]) => void;
|
|
43
|
+
/** Fires once when a drag or a keyboard edit ends, with the changed notes. */
|
|
44
|
+
onCommitNotes?: (notes: PianoRollNote[]) => void;
|
|
45
|
+
/** Delete / Backspace with a selection. */
|
|
46
|
+
onDeleteNotes?: (ids: string[]) => void;
|
|
47
|
+
/** Clicking a key on the left, for auditioning. */
|
|
48
|
+
onPreviewPitch?: (pitch: number) => void;
|
|
49
|
+
/** Show the velocity lane under the grid. */
|
|
50
|
+
showVelocity?: boolean;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
class?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Piano roll: pitch rows against a beat grid. Double-click adds a note, drag
|
|
57
|
+
* moves (pitch and time), the right edge trims, Delete removes, arrows nudge
|
|
58
|
+
* and transpose, and the optional velocity lane edits loudness per note.
|
|
59
|
+
*/
|
|
60
|
+
export function PianoRoll(props: PianoRollProps) {
|
|
61
|
+
const ppb = () => props.pixelsPerBeat ?? DEFAULT_PPB;
|
|
62
|
+
const rowHeight = () => props.rowHeight ?? DEFAULT_ROW;
|
|
63
|
+
const minPitch = () => props.minPitch ?? 36;
|
|
64
|
+
const maxPitch = () => props.maxPitch ?? 96;
|
|
65
|
+
const snap = () => props.snap ?? 0.25;
|
|
66
|
+
const bpb = () => props.beatsPerBar ?? 4;
|
|
67
|
+
const rows = () => Math.max(1, maxPitch() - minPitch() + 1);
|
|
68
|
+
const gridWidth = () => props.length * ppb() + END_PADDING;
|
|
69
|
+
const gridHeight = () => rows() * rowHeight();
|
|
70
|
+
const selected = createMemo(() => new Set(props.selectedIds ?? []));
|
|
71
|
+
const [dragging, setDragging] = createSignal(false);
|
|
72
|
+
let grid: HTMLDivElement | undefined;
|
|
73
|
+
let root: HTMLDivElement | undefined;
|
|
74
|
+
|
|
75
|
+
const pointerCell = (event: PointerEvent | MouseEvent) => {
|
|
76
|
+
if (!grid) return null;
|
|
77
|
+
const rect = grid.getBoundingClientRect();
|
|
78
|
+
const beats = (event.clientX - rect.left) / ppb();
|
|
79
|
+
const row = Math.floor((event.clientY - rect.top) / rowHeight());
|
|
80
|
+
return {
|
|
81
|
+
start: cellStart(beats, snap(), props.length),
|
|
82
|
+
pitch: clamp(rowToPitch(row, maxPitch()), minPitch(), maxPitch()),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const select = (ids: string[]) => props.onSelect?.(ids);
|
|
87
|
+
|
|
88
|
+
const beginNoteDrag = (note: PianoRollNote, mode: 'move' | 'resize') => (event: PointerEvent) => {
|
|
89
|
+
if (event.button !== 0) return;
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
root?.focus({ preventScroll: true });
|
|
92
|
+
let ids: string[];
|
|
93
|
+
if (event.shiftKey) {
|
|
94
|
+
ids = selected().has(note.id)
|
|
95
|
+
? [...selected()].filter((id) => id !== note.id)
|
|
96
|
+
: [...selected(), note.id];
|
|
97
|
+
} else {
|
|
98
|
+
ids = selected().has(note.id) ? [...selected()] : [note.id];
|
|
99
|
+
}
|
|
100
|
+
select(ids);
|
|
101
|
+
if (props.disabled) return;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
const target = event.currentTarget as HTMLElement;
|
|
104
|
+
target.setPointerCapture?.(event.pointerId);
|
|
105
|
+
const idSet = new Set(ids);
|
|
106
|
+
const origin = props.notes.filter((candidate) => idSet.has(candidate.id));
|
|
107
|
+
const originX = event.clientX;
|
|
108
|
+
const originY = event.clientY;
|
|
109
|
+
let last: PianoRollNote[] = origin;
|
|
110
|
+
let moved = false;
|
|
111
|
+
setDragging(true);
|
|
112
|
+
const move = (moveEvent: PointerEvent) => {
|
|
113
|
+
moved = true;
|
|
114
|
+
last = resolveNoteDrag({
|
|
115
|
+
mode,
|
|
116
|
+
deltaBeats: (moveEvent.clientX - originX) / ppb(),
|
|
117
|
+
deltaRows: (moveEvent.clientY - originY) / rowHeight(),
|
|
118
|
+
origin,
|
|
119
|
+
length: props.length,
|
|
120
|
+
minPitch: minPitch(),
|
|
121
|
+
maxPitch: maxPitch(),
|
|
122
|
+
snap: snap(),
|
|
123
|
+
});
|
|
124
|
+
props.onChangeNotes?.(last);
|
|
125
|
+
};
|
|
126
|
+
const stop = () => {
|
|
127
|
+
target.removeEventListener('pointermove', move);
|
|
128
|
+
target.removeEventListener('pointerup', stop);
|
|
129
|
+
target.removeEventListener('pointercancel', stop);
|
|
130
|
+
target.releasePointerCapture?.(event.pointerId);
|
|
131
|
+
setDragging(false);
|
|
132
|
+
if (moved) props.onCommitNotes?.(last);
|
|
133
|
+
};
|
|
134
|
+
target.addEventListener('pointermove', move);
|
|
135
|
+
target.addEventListener('pointerup', stop);
|
|
136
|
+
target.addEventListener('pointercancel', stop);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const beginVelocityDrag = (note: PianoRollNote) => (event: PointerEvent) => {
|
|
140
|
+
if (event.button !== 0 || props.disabled) return;
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
event.stopPropagation();
|
|
143
|
+
if (!selected().has(note.id)) select([note.id]);
|
|
144
|
+
const target = event.currentTarget as HTMLElement;
|
|
145
|
+
const lane = target.parentElement!;
|
|
146
|
+
target.setPointerCapture?.(event.pointerId);
|
|
147
|
+
let last = note;
|
|
148
|
+
const apply = (moveEvent: PointerEvent) => {
|
|
149
|
+
const rect = lane.getBoundingClientRect();
|
|
150
|
+
const velocity = clamp(1 - (moveEvent.clientY - rect.top) / rect.height, 0.05, 1);
|
|
151
|
+
last = { ...note, velocity: Math.round(velocity * 100) / 100 };
|
|
152
|
+
props.onChangeNotes?.([last]);
|
|
153
|
+
};
|
|
154
|
+
apply(event);
|
|
155
|
+
const stop = () => {
|
|
156
|
+
target.removeEventListener('pointermove', apply);
|
|
157
|
+
target.removeEventListener('pointerup', stop);
|
|
158
|
+
target.removeEventListener('pointercancel', stop);
|
|
159
|
+
target.releasePointerCapture?.(event.pointerId);
|
|
160
|
+
props.onCommitNotes?.([last]);
|
|
161
|
+
};
|
|
162
|
+
target.addEventListener('pointermove', apply);
|
|
163
|
+
target.addEventListener('pointerup', stop);
|
|
164
|
+
target.addEventListener('pointercancel', stop);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleGridDoubleClick = (event: MouseEvent) => {
|
|
168
|
+
if (props.disabled || event.target !== grid) return;
|
|
169
|
+
const cell = pointerCell(event);
|
|
170
|
+
if (!cell) return;
|
|
171
|
+
const duration = Math.min(props.newNoteDuration ?? 1, Math.max(snap(), props.length - cell.start));
|
|
172
|
+
props.onAddNote?.({ pitch: cell.pitch, start: cell.start, duration });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
176
|
+
if (props.disabled) return;
|
|
177
|
+
const ids = [...selected()];
|
|
178
|
+
if (event.key === 'Escape') {
|
|
179
|
+
select([]);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (ids.length === 0) return;
|
|
183
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
props.onDeleteNotes?.(ids);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const arrows: Record<string, { beats: number; rows: number }> = {
|
|
189
|
+
ArrowLeft: { beats: -snap(), rows: 0 },
|
|
190
|
+
ArrowRight: { beats: snap(), rows: 0 },
|
|
191
|
+
ArrowUp: { beats: 0, rows: event.shiftKey ? -12 : -1 },
|
|
192
|
+
ArrowDown: { beats: 0, rows: event.shiftKey ? 12 : 1 },
|
|
193
|
+
};
|
|
194
|
+
const arrow = arrows[event.key];
|
|
195
|
+
if (!arrow) return;
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
const idSet = new Set(ids);
|
|
198
|
+
const origin = props.notes.filter((note) => idSet.has(note.id));
|
|
199
|
+
props.onCommitNotes?.(
|
|
200
|
+
resolveNoteDrag({
|
|
201
|
+
mode: 'move',
|
|
202
|
+
deltaBeats: arrow.beats,
|
|
203
|
+
deltaRows: arrow.rows,
|
|
204
|
+
origin,
|
|
205
|
+
length: props.length,
|
|
206
|
+
minPitch: minPitch(),
|
|
207
|
+
maxPitch: maxPitch(),
|
|
208
|
+
snap: snap(),
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index));
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
ref={root}
|
|
218
|
+
class={props.class ? `zylem-piano-roll ${props.class}` : 'zylem-piano-roll'}
|
|
219
|
+
tabIndex={0}
|
|
220
|
+
role="application"
|
|
221
|
+
aria-label="Piano roll"
|
|
222
|
+
data-disabled={props.disabled ? '' : undefined}
|
|
223
|
+
data-dragging={dragging() ? '' : undefined}
|
|
224
|
+
onKeyDown={handleKeyDown}
|
|
225
|
+
>
|
|
226
|
+
<div
|
|
227
|
+
class="zylem-piano-roll-scroll"
|
|
228
|
+
style={{
|
|
229
|
+
'--zylem-pr-ppb': `${ppb()}px`,
|
|
230
|
+
'--zylem-pr-bar': `${ppb() * bpb()}px`,
|
|
231
|
+
'--zylem-pr-snap': `${ppb() * snap()}px`,
|
|
232
|
+
'--zylem-pr-row': `${rowHeight()}px`,
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<div
|
|
236
|
+
class="zylem-piano-roll-content"
|
|
237
|
+
style={{ width: `${KEYS_WIDTH + gridWidth()}px`, height: `${gridHeight()}px` }}
|
|
238
|
+
>
|
|
239
|
+
<div class="zylem-piano-roll-keys" style={{ width: `${KEYS_WIDTH}px` }}>
|
|
240
|
+
<Index each={rowIndexes()}>
|
|
241
|
+
{(row) => {
|
|
242
|
+
const pitch = () => rowToPitch(row(), maxPitch());
|
|
243
|
+
return (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
class="zylem-piano-roll-key"
|
|
247
|
+
style={{ height: `${rowHeight()}px` }}
|
|
248
|
+
data-black={isBlackKey(pitch()) ? '' : undefined}
|
|
249
|
+
data-c={pitch() % 12 === 0 ? '' : undefined}
|
|
250
|
+
aria-label={pitchName(pitch())}
|
|
251
|
+
tabIndex={-1}
|
|
252
|
+
disabled={props.disabled}
|
|
253
|
+
onPointerDown={(event) => {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
props.onPreviewPitch?.(pitch());
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<Show when={pitch() % 12 === 0}>
|
|
259
|
+
<span>{pitchName(pitch())}</span>
|
|
260
|
+
</Show>
|
|
261
|
+
</button>
|
|
262
|
+
);
|
|
263
|
+
}}
|
|
264
|
+
</Index>
|
|
265
|
+
</div>
|
|
266
|
+
<div
|
|
267
|
+
ref={grid}
|
|
268
|
+
class="zylem-piano-roll-grid"
|
|
269
|
+
style={{ width: `${gridWidth()}px`, height: `${gridHeight()}px` }}
|
|
270
|
+
onPointerDown={(event) => {
|
|
271
|
+
if (event.target === grid && !event.shiftKey) select([]);
|
|
272
|
+
root?.focus({ preventScroll: true });
|
|
273
|
+
}}
|
|
274
|
+
onDblClick={handleGridDoubleClick}
|
|
275
|
+
>
|
|
276
|
+
<Index each={rowIndexes()}>
|
|
277
|
+
{(row) => (
|
|
278
|
+
<div
|
|
279
|
+
class="zylem-piano-roll-row"
|
|
280
|
+
style={{ top: `${row() * rowHeight()}px`, height: `${rowHeight()}px` }}
|
|
281
|
+
data-black={isBlackKey(rowToPitch(row(), maxPitch())) ? '' : undefined}
|
|
282
|
+
data-c={rowToPitch(row(), maxPitch()) % 12 === 0 ? '' : undefined}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
</Index>
|
|
286
|
+
<div class="zylem-piano-roll-end" style={{ left: `${props.length * ppb()}px` }} />
|
|
287
|
+
<For each={props.notes}>
|
|
288
|
+
{(note) => (
|
|
289
|
+
<div
|
|
290
|
+
class="zylem-piano-roll-note"
|
|
291
|
+
role="button"
|
|
292
|
+
tabIndex={-1}
|
|
293
|
+
aria-label={`${pitchName(note.pitch)} at beat ${note.start + 1}`}
|
|
294
|
+
aria-pressed={selected().has(note.id) ? 'true' : 'false'}
|
|
295
|
+
data-selected={selected().has(note.id) ? '' : undefined}
|
|
296
|
+
style={{
|
|
297
|
+
left: `${note.start * ppb()}px`,
|
|
298
|
+
top: `${pitchToRow(note.pitch, maxPitch()) * rowHeight()}px`,
|
|
299
|
+
width: `${Math.max(3, note.duration * ppb() - 1)}px`,
|
|
300
|
+
height: `${rowHeight() - 1}px`,
|
|
301
|
+
'--zylem-pr-velocity': String(note.velocity ?? 0.8),
|
|
302
|
+
}}
|
|
303
|
+
onPointerDown={beginNoteDrag(note, 'move')}
|
|
304
|
+
>
|
|
305
|
+
<Show when={note.duration * ppb() > 28}>
|
|
306
|
+
<span class="zylem-piano-roll-note-label">{pitchName(note.pitch)}</span>
|
|
307
|
+
</Show>
|
|
308
|
+
<div
|
|
309
|
+
class="zylem-piano-roll-note-handle"
|
|
310
|
+
onPointerDown={beginNoteDrag(note, 'resize')}
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</For>
|
|
315
|
+
<Show when={props.playhead !== undefined}>
|
|
316
|
+
<div
|
|
317
|
+
class="zylem-piano-roll-playhead"
|
|
318
|
+
style={{ left: `${(props.playhead ?? 0) * ppb()}px` }}
|
|
319
|
+
aria-hidden="true"
|
|
320
|
+
/>
|
|
321
|
+
</Show>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<Show when={props.showVelocity}>
|
|
325
|
+
<div
|
|
326
|
+
class="zylem-piano-roll-velocity"
|
|
327
|
+
style={{ width: `${KEYS_WIDTH + gridWidth()}px`, height: `${VELOCITY_HEIGHT}px` }}
|
|
328
|
+
>
|
|
329
|
+
<div class="zylem-piano-roll-velocity-label" style={{ width: `${KEYS_WIDTH}px` }}>
|
|
330
|
+
Vel
|
|
331
|
+
</div>
|
|
332
|
+
<div class="zylem-piano-roll-velocity-lane" style={{ width: `${gridWidth()}px` }}>
|
|
333
|
+
<For each={props.notes}>
|
|
334
|
+
{(note) => (
|
|
335
|
+
<div
|
|
336
|
+
class="zylem-piano-roll-velocity-bar"
|
|
337
|
+
data-selected={selected().has(note.id) ? '' : undefined}
|
|
338
|
+
style={{
|
|
339
|
+
left: `${note.start * ppb()}px`,
|
|
340
|
+
height: `${Math.round((note.velocity ?? 0.8) * 100)}%`,
|
|
341
|
+
}}
|
|
342
|
+
title={`Velocity ${Math.round((note.velocity ?? 0.8) * 100)}`}
|
|
343
|
+
onPointerDown={beginVelocityDrag(note)}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
</For>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</Show>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { clamp, snapTime } from '../TimelineLanes/lanes-math';
|
|
2
|
+
|
|
3
|
+
export interface PianoRollNote {
|
|
4
|
+
id: string;
|
|
5
|
+
/** MIDI pitch, 0–127. */
|
|
6
|
+
pitch: number;
|
|
7
|
+
/** Beats from the clip start. */
|
|
8
|
+
start: number;
|
|
9
|
+
/** Beats. */
|
|
10
|
+
duration: number;
|
|
11
|
+
/** 0–1; defaults to 0.8 when absent. */
|
|
12
|
+
velocity?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;
|
|
16
|
+
|
|
17
|
+
/** `60` → `C4`. */
|
|
18
|
+
export function pitchName(pitch: number): string {
|
|
19
|
+
const index = ((Math.round(pitch) % 12) + 12) % 12;
|
|
20
|
+
return `${NOTE_NAMES[index]}${Math.floor(Math.round(pitch) / 12) - 1}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isBlackKey(pitch: number): boolean {
|
|
24
|
+
const index = ((Math.round(pitch) % 12) + 12) % 12;
|
|
25
|
+
return index === 1 || index === 3 || index === 6 || index === 8 || index === 10;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Highest pitch is the top row. */
|
|
29
|
+
export function pitchToRow(pitch: number, maxPitch: number): number {
|
|
30
|
+
return maxPitch - pitch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function rowToPitch(row: number, maxPitch: number): number {
|
|
34
|
+
return maxPitch - row;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NoteDragOptions {
|
|
38
|
+
mode: 'move' | 'resize';
|
|
39
|
+
/** Pointer travel in beats. */
|
|
40
|
+
deltaBeats: number;
|
|
41
|
+
/** Pointer travel in rows (positive = down = lower pitch). */
|
|
42
|
+
deltaRows: number;
|
|
43
|
+
/** The notes being dragged, as they were when the drag began. */
|
|
44
|
+
origin: readonly PianoRollNote[];
|
|
45
|
+
/** Clip length in beats. */
|
|
46
|
+
length: number;
|
|
47
|
+
minPitch: number;
|
|
48
|
+
maxPitch: number;
|
|
49
|
+
snap?: number;
|
|
50
|
+
minDuration?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where a set of notes lands after a drag. Moves shift every note by the same
|
|
55
|
+
* snapped amount, clamped so the group stays inside the clip and the pitch
|
|
56
|
+
* range; resizes change every note's duration by the same snapped amount.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveNoteDrag(options: NoteDragOptions): PianoRollNote[] {
|
|
59
|
+
const { origin, length, minPitch, maxPitch } = options;
|
|
60
|
+
if (origin.length === 0) return [];
|
|
61
|
+
const snap = options.snap;
|
|
62
|
+
if (options.mode === 'resize') {
|
|
63
|
+
const minDuration = Math.max(options.minDuration ?? snap ?? 0.0625, 0.0001);
|
|
64
|
+
const delta = snapTime(options.deltaBeats, snap);
|
|
65
|
+
return origin.map((note) => ({
|
|
66
|
+
...note,
|
|
67
|
+
duration: clamp(note.duration + delta, minDuration, Math.max(minDuration, length - note.start)),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
const first = origin.reduce((min, note) => Math.min(min, note.start), Number.POSITIVE_INFINITY);
|
|
71
|
+
const last = origin.reduce((max, note) => Math.max(max, note.start + note.duration), 0);
|
|
72
|
+
const lowest = origin.reduce((min, note) => Math.min(min, note.pitch), Number.POSITIVE_INFINITY);
|
|
73
|
+
const highest = origin.reduce((max, note) => Math.max(max, note.pitch), Number.NEGATIVE_INFINITY);
|
|
74
|
+
const rawShift = snapTime(first + options.deltaBeats, snap) - first;
|
|
75
|
+
const shift = clamp(rawShift, -first, Math.max(-first, length - last));
|
|
76
|
+
const rawTranspose = -Math.round(options.deltaRows);
|
|
77
|
+
const transpose = clamp(rawTranspose, minPitch - lowest, maxPitch - highest);
|
|
78
|
+
return origin.map((note) => ({
|
|
79
|
+
...note,
|
|
80
|
+
start: note.start + shift,
|
|
81
|
+
pitch: note.pitch + transpose,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Snaps a beat position down to the grid so a new note starts on the cell under the pointer. */
|
|
86
|
+
export function cellStart(beats: number, snap: number | undefined, length: number): number {
|
|
87
|
+
const grid = snap && snap > 0 ? snap : 0.25;
|
|
88
|
+
return clamp(Math.floor(beats / grid + 1e-9) * grid, 0, Math.max(0, length - grid));
|
|
89
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { globalStyle } from '@vanilla-extract/css';
|
|
2
|
+
import { vars } from '../../theme.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checker pattern shared by every "transparent" surface in the art tools:
|
|
6
|
+
* the pixel grid, the transparent palette swatch, and empty frame thumbs.
|
|
7
|
+
* Cell size comes from `--zylem-pixel-grid-cell` so hosts can scale it.
|
|
8
|
+
*/
|
|
9
|
+
export const checkerBackground = `repeating-conic-gradient(${vars.art.checkerLight} 0 25%, ${vars.art.checkerDark} 0 50%) 0 0 / calc(var(--zylem-pixel-grid-cell, 8px) * 2) calc(var(--zylem-pixel-grid-cell, 8px) * 2)`;
|
|
10
|
+
|
|
11
|
+
globalStyle('.zylem-pixel-grid', {
|
|
12
|
+
boxSizing: 'content-box',
|
|
13
|
+
position: 'relative',
|
|
14
|
+
flex: 'none',
|
|
15
|
+
background: checkerBackground,
|
|
16
|
+
border: `1px solid ${vars.art.canvasBorder}`,
|
|
17
|
+
imageRendering: 'pixelated',
|
|
18
|
+
overflow: 'hidden',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Overlays fill the surface; a consumer canvas keeps its own pixel size and
|
|
22
|
+
// stretches to the zoomed box without smoothing.
|
|
23
|
+
globalStyle('.zylem-pixel-grid > canvas, .zylem-pixel-grid > img, .zylem-pixel-grid > svg', {
|
|
24
|
+
position: 'absolute',
|
|
25
|
+
inset: 0,
|
|
26
|
+
width: '100%',
|
|
27
|
+
height: '100%',
|
|
28
|
+
display: 'block',
|
|
29
|
+
imageRendering: 'pixelated',
|
|
30
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JSX, splitProps } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export interface PixelGridProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
/** Sprite width in source pixels. */
|
|
5
|
+
width: number;
|
|
6
|
+
/** Sprite height in source pixels. */
|
|
7
|
+
height: number;
|
|
8
|
+
/** Screen pixels per source pixel. */
|
|
9
|
+
zoom?: number;
|
|
10
|
+
/** Checker cell size in screen pixels. */
|
|
11
|
+
checkerCell?: number;
|
|
12
|
+
/** Overlay content (a canvas, selection chrome); fills the surface. */
|
|
13
|
+
children?: JSX.Element;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transparent-pixel checkerboard for the sprite editor. Sized to the sprite
|
|
18
|
+
* at the given zoom with a thin blue edge, so a consumer's canvas can sit on
|
|
19
|
+
* top with a transparent background and read against the checker.
|
|
20
|
+
*/
|
|
21
|
+
export function PixelGrid(props: PixelGridProps) {
|
|
22
|
+
const [local, rest] = splitProps(props, [
|
|
23
|
+
'width',
|
|
24
|
+
'height',
|
|
25
|
+
'zoom',
|
|
26
|
+
'checkerCell',
|
|
27
|
+
'children',
|
|
28
|
+
'class',
|
|
29
|
+
'style',
|
|
30
|
+
]);
|
|
31
|
+
const zoom = () => local.zoom ?? 1;
|
|
32
|
+
const inlineStyle = (): JSX.CSSProperties => ({
|
|
33
|
+
width: `${local.width * zoom()}px`,
|
|
34
|
+
height: `${local.height * zoom()}px`,
|
|
35
|
+
'--zylem-pixel-grid-cell': `${local.checkerCell ?? 8}px`,
|
|
36
|
+
...(typeof local.style === 'object' ? local.style : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
class={local.class ? `zylem-pixel-grid ${local.class}` : 'zylem-pixel-grid'}
|
|
42
|
+
style={inlineStyle()}
|
|
43
|
+
{...rest}
|
|
44
|
+
>
|
|
45
|
+
{local.children}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { globalStyle } from '@vanilla-extract/css';
|
|
2
|
+
import { vars } from '../../theme.css';
|
|
3
|
+
|
|
4
|
+
globalStyle('.zylem-playback', {
|
|
5
|
+
display: 'flex',
|
|
6
|
+
alignItems: 'center',
|
|
7
|
+
gap: vars.spacing.md,
|
|
8
|
+
minWidth: 0,
|
|
9
|
+
fontFamily: vars.typography.fontFamily,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
globalStyle('.zylem-playback-transport', {
|
|
13
|
+
display: 'flex',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
gap: vars.spacing.xs,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
globalStyle('.zylem-playback-btn', {
|
|
19
|
+
width: '34px',
|
|
20
|
+
padding: 0,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Play is the primary control: a touch wider so the strip reads as a
|
|
24
|
+
// transport rather than three identical tool buttons.
|
|
25
|
+
globalStyle('.zylem-playback-btn--play', {
|
|
26
|
+
width: '44px',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
globalStyle('.zylem-playback-btn .zylem-icon', {
|
|
30
|
+
fill: 'currentColor',
|
|
31
|
+
fillOpacity: 0.85,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
globalStyle('.zylem-playback-loop .zylem-icon', {
|
|
35
|
+
width: '14px',
|
|
36
|
+
height: '14px',
|
|
37
|
+
stroke: 'currentColor',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
globalStyle('.zylem-playback-extra', {
|
|
41
|
+
display: 'flex',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
gap: vars.spacing.sm,
|
|
44
|
+
minWidth: 0,
|
|
45
|
+
flex: '1 1 auto',
|
|
46
|
+
color: vars.colors.textSecondary,
|
|
47
|
+
fontSize: `calc(${vars.typography.fontSize} - 1px)`,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Compact fields inside the transport (FPS, duration).
|
|
51
|
+
globalStyle('.zylem-playback-extra .zylem-field-root', {
|
|
52
|
+
width: '72px',
|
|
53
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import FastForward from 'lucide-solid/icons/fast-forward';
|
|
2
|
+
import Pause from 'lucide-solid/icons/pause';
|
|
3
|
+
import Play from 'lucide-solid/icons/play';
|
|
4
|
+
import Repeat from 'lucide-solid/icons/repeat';
|
|
5
|
+
import Rewind from 'lucide-solid/icons/rewind';
|
|
6
|
+
import { type JSX, Show } from 'solid-js';
|
|
7
|
+
import { Button } from '../Button/Button';
|
|
8
|
+
import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
|
|
9
|
+
|
|
10
|
+
export interface PlaybackControlsProps {
|
|
11
|
+
playing: boolean;
|
|
12
|
+
onTogglePlay: () => void;
|
|
13
|
+
onRewind?: () => void;
|
|
14
|
+
onFastForward?: () => void;
|
|
15
|
+
/** When defined, a Loop toggle follows the transport buttons. */
|
|
16
|
+
loop?: boolean;
|
|
17
|
+
onToggleLoop?: () => void;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
/** Trailing controls: an FPS field, a time readout, a duration field. */
|
|
20
|
+
children?: JSX.Element;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Transport strip: rewind, play/pause, fast-forward as glass toolbar
|
|
26
|
+
* buttons, an optional Loop toggle, then whatever the host wants alongside.
|
|
27
|
+
*/
|
|
28
|
+
export function PlaybackControls(props: PlaybackControlsProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
class={props.class ? `zylem-playback ${props.class}` : 'zylem-playback'}
|
|
32
|
+
role="group"
|
|
33
|
+
aria-label="Playback"
|
|
34
|
+
>
|
|
35
|
+
<div class="zylem-playback-transport">
|
|
36
|
+
<ToolbarButton
|
|
37
|
+
label="Rewind"
|
|
38
|
+
disabled={props.disabled || !props.onRewind}
|
|
39
|
+
onClick={() => props.onRewind?.()}
|
|
40
|
+
class="zylem-playback-btn"
|
|
41
|
+
>
|
|
42
|
+
<Rewind class="zylem-icon" />
|
|
43
|
+
</ToolbarButton>
|
|
44
|
+
<ToolbarButton
|
|
45
|
+
label={props.playing ? 'Pause' : 'Play'}
|
|
46
|
+
selected={props.playing}
|
|
47
|
+
disabled={props.disabled}
|
|
48
|
+
onClick={() => props.onTogglePlay()}
|
|
49
|
+
class="zylem-playback-btn zylem-playback-btn--play"
|
|
50
|
+
>
|
|
51
|
+
{props.playing ? <Pause class="zylem-icon" /> : <Play class="zylem-icon" />}
|
|
52
|
+
</ToolbarButton>
|
|
53
|
+
<ToolbarButton
|
|
54
|
+
label="Fast forward"
|
|
55
|
+
disabled={props.disabled || !props.onFastForward}
|
|
56
|
+
onClick={() => props.onFastForward?.()}
|
|
57
|
+
class="zylem-playback-btn"
|
|
58
|
+
>
|
|
59
|
+
<FastForward class="zylem-icon" />
|
|
60
|
+
</ToolbarButton>
|
|
61
|
+
</div>
|
|
62
|
+
<Show when={props.loop !== undefined}>
|
|
63
|
+
<Button
|
|
64
|
+
size="sm"
|
|
65
|
+
variant={props.loop ? 'active' : 'default'}
|
|
66
|
+
aria-pressed={props.loop ? 'true' : 'false'}
|
|
67
|
+
disabled={props.disabled}
|
|
68
|
+
onClick={() => props.onToggleLoop?.()}
|
|
69
|
+
class="zylem-playback-loop"
|
|
70
|
+
>
|
|
71
|
+
<Repeat class="zylem-icon" aria-hidden="true" />
|
|
72
|
+
Loop
|
|
73
|
+
</Button>
|
|
74
|
+
</Show>
|
|
75
|
+
<Show when={props.children}>
|
|
76
|
+
<div class="zylem-playback-extra">{props.children}</div>
|
|
77
|
+
</Show>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -17,7 +17,7 @@ globalStyle('.zylem-property-list', {
|
|
|
17
17
|
globalStyle('.zylem-property-row', {
|
|
18
18
|
minHeight: '30px',
|
|
19
19
|
display: 'grid',
|
|
20
|
-
gridTemplateColumns: '112px 1fr',
|
|
20
|
+
gridTemplateColumns: 'var(--zylem-property-label-width, 112px) minmax(0, 1fr)',
|
|
21
21
|
alignItems: 'center',
|
|
22
22
|
gap: vars.spacing.sm,
|
|
23
23
|
padding: `${vars.spacing.xs} ${vars.spacing.sm}`,
|
|
@@ -4,6 +4,8 @@ import type { JSX } from 'solid-js';
|
|
|
4
4
|
export interface PropertyListProps {
|
|
5
5
|
children: JSX.Element;
|
|
6
6
|
class?: string;
|
|
7
|
+
/** Width of the label column (default `112px`); narrow it for slim inspectors. */
|
|
8
|
+
labelWidth?: string;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -17,6 +19,7 @@ export function PropertyList(props: PropertyListProps) {
|
|
|
17
19
|
? `zylem-property-list ${props.class}`
|
|
18
20
|
: 'zylem-property-list'
|
|
19
21
|
}
|
|
22
|
+
style={props.labelWidth ? { '--zylem-property-label-width': props.labelWidth } : undefined}
|
|
20
23
|
>
|
|
21
24
|
{props.children}
|
|
22
25
|
</div>
|