Piano roll: DAW-standard gestures and selection operations
Clicking empty grid space now moves the playhead (and deselects)
instead of adding a note; dragging on empty space is a marquee
selection, with shift extending it; double-click adds a note at the
snapped position. The selection responds to the expected keys:
ctrl+A selects the active track, ctrl+C/X/V copy, cut, and paste at
the playhead, ctrl+D duplicates after itself, and the arrow keys
nudge by the snap grid horizontally and semitones (shift: octaves)
vertically.
4 changed files+154−31
README.mdmodified+1−1View file
@@ -30,7 +30,7 @@ Recording takes input from any connected MIDI device through the Web MIDI API, f
3030
3131 ## Editing
3232
33-The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. Click on empty space to add a note (drag right to set its length), drag notes to move them, drag a note's right edge to resize, shift+drag for marquee selection, right-click to delete, and drag in the velocity lane to paint velocities. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
33+The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. The gestures follow the usual DAW conventions: clicking empty space moves the playhead there and clears the selection, dragging on empty space is a marquee selection (shift extends it), double-clicking adds a note at the snapped position, dragging notes moves them, dragging a note's right edge resizes, right-click deletes, and dragging in the velocity lane paints velocities. The selection responds to the expected keys: delete removes it, ctrl+A selects the active track, ctrl+C/X/V copy, cut, and paste at the playhead, ctrl+D duplicates in place after itself, and the arrow keys nudge by the snap grid horizontally and by semitones (shift for octaves) vertically. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
3434
3535 ## Playback and rendering
3636
src/components/App.tsxmodified+25−1View file
@@ -78,6 +78,30 @@ export function App() {
7878 } else if (ev.key === 'Delete' || ev.key === 'Backspace') {
7979 ev.preventDefault();
8080 store.deleteSelection();
81+ } else if (mod && ev.key.toLowerCase() === 'a') {
82+ ev.preventDefault();
83+ store.selectAll();
84+ } else if (mod && ev.key.toLowerCase() === 'c') {
85+ ev.preventDefault();
86+ store.copySelection();
87+ } else if (mod && ev.key.toLowerCase() === 'x') {
88+ ev.preventDefault();
89+ store.cutSelection();
90+ } else if (mod && ev.key.toLowerCase() === 'v') {
91+ ev.preventDefault();
92+ store.pasteAtPlayhead();
93+ } else if (mod && ev.key.toLowerCase() === 'd') {
94+ ev.preventDefault();
95+ store.duplicateSelection();
96+ } else if (ev.key.startsWith('Arrow') && store.state.selection.length > 0) {
97+ ev.preventDefault();
98+ const song = store.song();
99+ if (!song) return;
100+ const grid = store.snapTicks() || Math.round(song.ppq / 8);
101+ if (ev.key === 'ArrowLeft') store.nudgeSelection(-grid, 0);
102+ else if (ev.key === 'ArrowRight') store.nudgeSelection(grid, 0);
103+ else if (ev.key === 'ArrowUp') store.nudgeSelection(0, ev.shiftKey ? 12 : 1);
104+ else if (ev.key === 'ArrowDown') store.nudgeSelection(0, ev.shiftKey ? -12 : -1);
81105 } else if (ev.key === 'Escape') {
82106 store.setLibraryOpen(false);
83107 store.setSelection([]);
@@ -139,7 +163,7 @@ export function App() {
139163 <TrackList />
140164 <PianoRoll />
141165 </div>
142- <footer className="statusbar">{app.status || 'click in the grid to add notes; right-click deletes; shift+drag selects; space plays'}</footer>
166+ <footer className="statusbar">{app.status || 'click sets the position; drag selects; double-click adds a note; right-click deletes; ctrl+C/X/V/D and arrows act on the selection; space plays'}</footer>
143167 <Library />
144168 </div>
145169 );
src/components/PianoRoll.tsxmodified+33−29View file
@@ -10,16 +10,18 @@ import { isBlackKey, KEY_H, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VE
1010 // The piano roll: a single canvas holding the ruler, key column, note
1111 // grid, and velocity lane. It redraws every animation frame from current
1212 // state, which keeps the playhead and edits in sync without React
13-// re-renders. Gestures:
14-// click empty space add a note (drag right to set length)
13+// re-renders. Gestures follow the usual DAW conventions:
14+// click empty space set the playhead there (and deselect)
15+// drag on empty space marquee select (shift extends the selection)
16+// double-click empty space add a note at the snapped position
1517 // drag a note move it (and the rest of the selection)
1618 // drag a note's right edge resize
17-// shift+drag empty space marquee select
1819 // right-click a note delete it
1920 // drag in the ruler move the playhead
2021 // drag in the velocity lane paint velocities
2122 // wheel / shift+wheel scroll; ctrl+wheel zooms
2223 // alt while dragging bypass snap
24+// Ctrl+A/C/X/V/D and the arrow keys act on the selection (see App.tsx).
2325
2426 interface OrigNote { tick: number; duration: number; pitch: number }
2527
@@ -27,8 +29,8 @@ type Drag =
2729 | { mode: 'scrub' }
2830 | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null }
2931 | { mode: 'velocity' }
30- | { mode: 'marquee'; x0: number; y0: number; x1: number; y1: number }
31- | { mode: 'create'; id: string; startTick: number }
32+ | { mode: 'pending'; x0: number; y0: number }
33+ | { mode: 'marquee'; additive: boolean; x0: number; y0: number; x1: number; y1: number }
3234 | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map<string, OrigNote>; lastBlipPitch: number }
3335 | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
3436
@@ -39,7 +41,6 @@ export function PianoRoll() {
3941 const containerRef = useRef<HTMLDivElement | null>(null);
4042 const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * KEY_H });
4143 const drag = useRef<Drag | null>(null);
42- const lastCreateDur = useRef(0);
4344 const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null);
4445
4546 useEffect(() => {
@@ -339,24 +340,33 @@ export function PianoRoll() {
339340 return;
340341 }
341342 if (ev.shiftKey) {
342- drag.current = { mode: 'marquee', x0: x, y0: y, x1: x, y1: y };
343+ drag.current = { mode: 'marquee', additive: true, x0: x, y0: y, x1: x, y1: y };
343344 return;
344345 }
345- // create a note
346+ // A click seeks; a drag becomes a marquee. Decided by movement.
347+ drag.current = { mode: 'pending', x0: x, y0: y };
348+ };
349+
350+ const onDblClick = (ev: MouseEvent) => {
351+ const song = store.song();
352+ if (!song) return;
353+ const rect = canvas.getBoundingClientRect();
354+ const x = ev.clientX - rect.left;
355+ const y = ev.clientY - rect.top;
356+ if (y < RULER_H || x < KEYS_W || y >= height - VEL_H) return;
357+ if (hitNote(song, x, y)) return;
346358 const snap = effSnap(ev);
347359 const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor'));
348360 const pitch = yToPitch(y);
349361 if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
350- const dur = lastCreateDur.current > 0 ? lastCreateDur.current : snap > 0 ? snap : Math.round(song.ppq / 4);
362+ const dur = snap > 0 ? snap : Math.round(song.ppq / 4);
351363 const id = newId('n');
352- store.beginEdit();
353- store.editSong((s) => {
364+ store.mutateSong((s) => {
354365 const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
355366 track?.notes.push({ id, tick, duration: dur, pitch, velocity: 100 });
356367 });
357368 store.setSelection([id]);
358369 blip(activeInstrument(song), pitch);
359- drag.current = { mode: 'create', id, startTick: tick };
360370 };
361371
362372 const applyVelocity = (x: number, y: number) => {
@@ -390,19 +400,13 @@ export function PianoRoll() {
390400 }
391401 } else if (d.mode === 'velocity') {
392402 applyVelocity(x, y);
403+ } else if (d.mode === 'pending') {
404+ if (Math.hypot(x - d.x0, y - d.y0) > 4) {
405+ drag.current = { mode: 'marquee', additive: false, x0: d.x0, y0: d.y0, x1: x, y1: y };
406+ }
393407 } else if (d.mode === 'marquee') {
394408 d.x1 = x;
395409 d.y1 = y;
396- } else if (d.mode === 'create') {
397- const snap = effSnap(ev);
398- const raw = xToTick(song, x);
399- const end = Math.max(d.startTick + MIN_DUR_TICKS, snapValue(raw, snap, 'ceil'));
400- store.editSong((s) => {
401- for (const t of s.tracks) {
402- const n = t.notes.find((nn) => nn.id === d.id);
403- if (n) n.duration = end - d.startTick;
404- }
405- });
406410 } else if (d.mode === 'move') {
407411 const snap = effSnap(ev);
408412 const anchor = d.origs.get(d.anchorId)!;
@@ -449,6 +453,10 @@ export function PianoRoll() {
449453 if (!d || !song) return;
450454 if (d.mode === 'keys') {
451455 d.voice?.noteOff();
456+ } else if (d.mode === 'pending') {
457+ const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
458+ store.seekTo(getTm(song).tickToSec(tick));
459+ store.setSelection([]);
452460 } else if (d.mode === 'marquee') {
453461 const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
454462 if (!track) return;
@@ -463,13 +471,7 @@ export function PianoRoll() {
463471 const ny = pitchToY(n.pitch);
464472 if (nx + w >= x0 && nx <= x1 && ny + KEY_H >= y0 && ny <= y1) ids.push(n.id);
465473 }
466- store.setSelection(ids);
467- } else if (d.mode === 'create') {
468- for (const t of song.tracks) {
469- const n = t.notes.find((nn) => nn.id === d.id);
470- if (n) lastCreateDur.current = n.duration;
471- }
472- store.endEdit();
474+ store.setSelection(d.additive ? [...new Set([...store.state.selection, ...ids])] : ids);
473475 } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'velocity') {
474476 store.endEdit();
475477 }
@@ -498,6 +500,7 @@ export function PianoRoll() {
498500 };
499501
500502 canvas.addEventListener('pointerdown', onPointerDown);
503+ canvas.addEventListener('dblclick', onDblClick);
501504 canvas.addEventListener('pointermove', onPointerMove);
502505 canvas.addEventListener('pointerup', onPointerUp);
503506 canvas.addEventListener('pointercancel', onPointerUp);
@@ -508,6 +511,7 @@ export function PianoRoll() {
508511 cancelAnimationFrame(raf);
509512 ro.disconnect();
510513 canvas.removeEventListener('pointerdown', onPointerDown);
514+ canvas.removeEventListener('dblclick', onDblClick);
511515 canvas.removeEventListener('pointermove', onPointerMove);
512516 canvas.removeEventListener('pointerup', onPointerUp);
513517 canvas.removeEventListener('pointercancel', onPointerUp);
src/state/store.tsmodified+95−0View file
@@ -286,6 +286,101 @@ class Store {
286286
287287 setSelection(ids: string[]): void { this.set({ selection: ids }); }
288288
289+ selectAll(): void {
290+ const song = this.song();
291+ if (!song) return;
292+ const t = song.tracks.find((x) => x.id === this.state.activeTrackId) ?? song.tracks[0];
293+ if (t) this.set({ selection: t.notes.map((n) => n.id) });
294+ }
295+
296+ // --- clipboard (in-memory, note times relative to the earliest note) ---
297+
298+ private clipboard: { dTick: number; duration: number; pitch: number; velocity: number }[] = [];
299+
300+ copySelection(): number {
301+ const song = this.song();
302+ if (!song || this.state.selection.length === 0) return 0;
303+ const sel = new Set(this.state.selection);
304+ const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
305+ if (notes.length === 0) return 0;
306+ const t0 = Math.min(...notes.map((n) => n.tick));
307+ this.clipboard = notes.map((n) => ({ dTick: n.tick - t0, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
308+ this.set({ status: `Copied ${notes.length} note(s)` });
309+ return notes.length;
310+ }
311+
312+ cutSelection(): void {
313+ if (this.copySelection() > 0) this.deleteSelection();
314+ }
315+
316+ pasteAtPlayhead(): void {
317+ const song = this.song();
318+ if (!song || this.clipboard.length === 0) return;
319+ const tm = this.tempoMap()!;
320+ let tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
321+ const grid = this.snapTicks();
322+ if (grid > 0) tick = Math.round(tick / grid) * grid;
323+ const activeId = this.state.activeTrackId;
324+ const ids: string[] = [];
325+ this.mutateSong((s) => {
326+ const t = s.tracks.find((x) => x.id === activeId) ?? s.tracks[0];
327+ if (!t) return;
328+ for (const c of this.clipboard) {
329+ const id = newId('n');
330+ ids.push(id);
331+ t.notes.push({ id, tick: Math.max(0, tick + c.dTick), duration: c.duration, pitch: c.pitch, velocity: c.velocity });
332+ }
333+ t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
334+ });
335+ this.set({ selection: ids, status: `Pasted ${ids.length} note(s)` });
336+ }
337+
338+ /** Duplicate the selection immediately after itself (span rounded up to
339+ * the snap grid), the usual ctrl+D. */
340+ duplicateSelection(): void {
341+ const song = this.song();
342+ if (!song || this.state.selection.length === 0) return;
343+ const sel = new Set(this.state.selection);
344+ const notes = song.tracks.flatMap((t) => t.notes.filter((n) => sel.has(n.id)));
345+ if (notes.length === 0) return;
346+ const t0 = Math.min(...notes.map((n) => n.tick));
347+ const end = Math.max(...notes.map((n) => n.tick + n.duration));
348+ let span = Math.max(1, end - t0);
349+ const grid = this.snapTicks();
350+ if (grid > 0) span = Math.ceil(span / grid) * grid;
351+ const ids: string[] = [];
352+ this.mutateSong((s) => {
353+ for (const t of s.tracks) {
354+ const copies = t.notes
355+ .filter((n) => sel.has(n.id))
356+ .map((n) => {
357+ const id = newId('n');
358+ ids.push(id);
359+ return { ...n, id, tick: n.tick + span };
360+ });
361+ if (copies.length > 0) {
362+ t.notes.push(...copies);
363+ t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
364+ }
365+ }
366+ });
367+ this.set({ selection: ids });
368+ }
369+
370+ nudgeSelection(dTick: number, dPitch: number): void {
371+ if (this.state.selection.length === 0) return;
372+ const sel = new Set(this.state.selection);
373+ this.mutateSong((s) => {
374+ for (const t of s.tracks) {
375+ for (const n of t.notes) {
376+ if (!sel.has(n.id)) continue;
377+ n.tick = Math.max(0, n.tick + dTick);
378+ n.pitch = Math.max(0, Math.min(127, n.pitch + dPitch));
379+ }
380+ }
381+ });
382+ }
383+
289384 setSnapBeats(v: number): void { this.set({ snapBeats: v }); }
290385
291386 deleteSelection(): void {