1import { useEffect, useRef } from 'react';
2import { store } from '../state/store.ts';
3import { player } from '../audio/player.ts';
4import { TempoMap } from '../midi/time.ts';
5import { newId, songEndTick, type Note, type Song } from '../types.ts';
6import { noteName } from '../midi/gm.ts';
7import { trackColor } from './colors.ts';
8import { isBlackKey, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts';
10// The piano roll: a single canvas holding the ruler, key column, note
11// grid, and velocity lane. It redraws every animation frame from current
12// state, which keeps the playhead and edits in sync without React
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; double-click a note deletes it
17// drag a note move it (alt+drag moves a copy)
18// drag a note's edges resize from either end
19// right-click a note delete it
20// click in the ruler move the playhead
21// drag in the ruler make a time selection (loop, range edits)
22// drag in the velocity lane paint velocities
23// wheel / shift+wheel scroll; ctrl+wheel and alt+wheel zoom
24// shift while dragging bypass snap (free positioning)
25// Ctrl+A/C/X/V/D/E and the arrow keys act on the selection (see App.tsx).
27interface OrigNote { tick: number; duration: number; pitch: number }
29type Drag =
30 | { mode: 'ruler-pending'; x0: number }
31 | { mode: 'range'; anchorTick: number }
32 | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null }
33 | { mode: 'velocity' }
34 | { mode: 'pending'; x0: number; y0: number }
35 | { mode: 'marquee'; additive: boolean; x0: number; y0: number; x1: number; y1: number }
36 | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map<string, OrigNote>; lastBlipPitch: number }
37 | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> }
38 | { mode: 'resize-left'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
40const MIN_DUR_TICKS = 24;
42export function PianoRoll() {
43 const canvasRef = useRef<HTMLCanvasElement | null>(null);
44 const containerRef = useRef<HTMLDivElement | null>(null);
45 const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * 12, keyH: 12 });
46 const drag = useRef<Drag | null>(null);
47 const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null);
49 useEffect(() => {
50 const canvas = canvasRef.current!;
51 const container = containerRef.current!;
52 let raf = 0;
53 let width = 0;
54 let height = 0;
56 const getTm = (song: Song): TempoMap => {
57 if (!tmCache.current || tmCache.current.song !== song) {
58 tmCache.current = { song, tm: new TempoMap(song.ppq, song.tempo, song.timeSig) };
59 }
60 return tmCache.current.tm;
61 };
63 const resize = () => {
64 const rect = container.getBoundingClientRect();
65 width = Math.max(50, rect.width);
66 height = Math.max(50, rect.height);
67 const dpr = window.devicePixelRatio || 1;
68 canvas.width = Math.round(width * dpr);
69 canvas.height = Math.round(height * dpr);
70 canvas.style.width = `${width}px`;
71 canvas.style.height = `${height}px`;
72 };
73 const ro = new ResizeObserver(resize);
74 ro.observe(container);
75 resize();
77 const pxPerTick = (song: Song) => view.current.pxPerBeat / song.ppq;
78 const tickToX = (song: Song, tick: number) => KEYS_W + tick * pxPerTick(song) - view.current.scrollX;
79 const xToTick = (song: Song, x: number) => (x - KEYS_W + view.current.scrollX) / pxPerTick(song);
80 const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * view.current.keyH - view.current.scrollY;
81 const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / view.current.keyH);
83 let lastDrawnPos = -1;
84 const draw = () => {
85 raf = requestAnimationFrame(draw);
86 const ctx = canvas.getContext('2d');
87 if (!ctx) return;
88 const dpr = window.devicePixelRatio || 1;
89 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
90 ctx.fillStyle = '#16181d';
91 ctx.fillRect(0, 0, width, height);
92 const st = store.state;
93 const song = st.rec?.song;
94 if (!song) return;
95 const tm = getTm(song);
96 const rollBottom = height - VEL_H;
97 const selection = new Set(st.selection);
98 const activeIdx = Math.max(0, song.tracks.findIndex((t) => t.id === st.activeTrackId));
99 const activeTrack = song.tracks[activeIdx] as typeof song.tracks[number] | undefined;
101 // playhead position and auto-scroll
102 const posSec = Math.max(0, player.getPositionSec());
103 const playTick = tm.secToTick(posSec);
104 const posChanged = posSec !== lastDrawnPos;
105 lastDrawnPos = posSec;
106 if (player.state !== 'stopped' || posChanged) {
107 const px = tickToX(song, playTick);
108 if (px > width - 40 || px < KEYS_W) {
109 view.current.scrollX = Math.max(0, playTick * pxPerTick(song) - 80);
110 }
111 }
113 // pitch rows
114 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
115 const y = pitchToY(p);
116 if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
117 ctx.fillStyle = isBlackKey(p) ? '#1a1d23' : '#20242c';
118 ctx.fillRect(KEYS_W, y, width - KEYS_W, view.current.keyH - 0.5);
119 }
121 // vertical grid
122 const tick0 = Math.max(0, Math.floor(xToTick(song, KEYS_W)));
123 const tick1 = Math.ceil(xToTick(song, width));
124 const snapTicks = store.snapTicks();
125 if (snapTicks > 0 && snapTicks * pxPerTick(song) > 7) {
126 ctx.strokeStyle = 'rgba(255,255,255,0.05)';
127 ctx.beginPath();
128 for (let t = Math.floor(tick0 / snapTicks) * snapTicks; t <= tick1; t += snapTicks) {
129 const x = tickToX(song, t);
130 ctx.moveTo(x, RULER_H);
131 ctx.lineTo(x, rollBottom);
132 }
133 ctx.stroke();
134 }
135 const lines = tm.gridLines(tick0, tick1 + 1);
136 for (const line of lines) {
137 const x = tickToX(song, line.tick);
138 ctx.strokeStyle = line.isBar ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.09)';
139 ctx.beginPath();
140 ctx.moveTo(x, RULER_H);
141 ctx.lineTo(x, height);
142 ctx.stroke();
143 }
145 // time selection band
146 const range = st.rangeTicks;
147 if (range) {
148 const rx0 = tickToX(song, range[0]);
149 const rx1 = tickToX(song, range[1]);
150 ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.07)' : 'rgba(255, 210, 74, 0.07)';
151 ctx.fillRect(rx0, RULER_H, rx1 - rx0, rollBottom - RULER_H);
152 }
154 // notes: ghost tracks first, active track on top
155 const drawTrackNotes = (ti: number, ghost: boolean) => {
156 const t = song.tracks[ti];
157 const color = trackColor(ti);
158 for (const n of t.notes) {
159 const x = tickToX(song, n.tick);
160 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
161 if (x + w < KEYS_W || x > width) continue;
162 const y = pitchToY(n.pitch);
163 if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
164 ctx.globalAlpha = ghost ? 0.22 : 0.45 + 0.55 * (n.velocity / 127);
165 ctx.fillStyle = color;
166 ctx.fillRect(x, y + 1, w, view.current.keyH - 2);
167 if (!ghost && selection.has(n.id)) {
168 ctx.globalAlpha = 1;
169 ctx.strokeStyle = '#fff';
170 ctx.lineWidth = 1.5;
171 ctx.strokeRect(x + 0.5, y + 1.5, w - 1, view.current.keyH - 3);
172 ctx.lineWidth = 1;
173 }
174 }
175 ctx.globalAlpha = 1;
176 };
177 song.tracks.forEach((_, i) => { if (i !== activeIdx) drawTrackNotes(i, true); });
178 if (activeTrack) drawTrackNotes(activeIdx, false);
180 // marquee
181 const d = drag.current;
182 if (d && d.mode === 'marquee') {
183 ctx.strokeStyle = 'rgba(120,180,255,0.9)';
184 ctx.fillStyle = 'rgba(120,180,255,0.12)';
185 const rx = Math.min(d.x0, d.x1);
186 const ry = Math.min(d.y0, d.y1);
187 ctx.fillRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
188 ctx.strokeRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
189 }
191 // velocity lane
192 ctx.fillStyle = '#101216';
193 ctx.fillRect(0, rollBottom, width, VEL_H);
194 ctx.strokeStyle = 'rgba(255,255,255,0.15)';
195 ctx.beginPath();
196 ctx.moveTo(0, rollBottom + 0.5);
197 ctx.lineTo(width, rollBottom + 0.5);
198 ctx.stroke();
199 if (activeTrack) {
200 const color = trackColor(activeIdx);
201 for (const n of activeTrack.notes) {
202 const x = tickToX(song, n.tick);
203 if (x < KEYS_W - 4 || x > width) continue;
204 const bh = (n.velocity / 127) * (VEL_H - 8);
205 ctx.fillStyle = color;
206 ctx.globalAlpha = selection.has(n.id) ? 1 : 0.6;
207 ctx.fillRect(x - 2, height - 2 - bh, 5, bh);
208 }
209 ctx.globalAlpha = 1;
210 }
211 ctx.fillStyle = 'rgba(255,255,255,0.4)';
212 ctx.font = '10px system-ui';
213 ctx.fillText('velocity', 6, rollBottom + 13);
215 // ruler
216 ctx.fillStyle = '#22262e';
217 ctx.fillRect(0, 0, width, RULER_H);
218 if (range) {
219 ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.35)' : 'rgba(255, 210, 74, 0.3)';
220 ctx.fillRect(tickToX(song, range[0]), 0, (range[1] - range[0]) * pxPerTick(song), RULER_H - 1);
221 }
222 for (const line of lines) {
223 if (!line.isBar) continue;
224 const x = tickToX(song, line.tick);
225 ctx.strokeStyle = 'rgba(255,255,255,0.3)';
226 ctx.beginPath();
227 ctx.moveTo(x, RULER_H - 8);
228 ctx.lineTo(x, RULER_H);
229 ctx.stroke();
230 ctx.fillStyle = 'rgba(255,255,255,0.6)';
231 ctx.font = '10px system-ui';
232 ctx.fillText(String(tm.barBeatAt(line.tick).bar), x + 3, RULER_H - 10);
233 }
235 // key column
236 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
237 const y = pitchToY(p);
238 if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
239 const black = isBlackKey(p);
240 ctx.fillStyle = black ? '#2a2d34' : '#e8e8ea';
241 ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, view.current.keyH - 0.5);
242 if (p % 12 === 0) {
243 ctx.fillStyle = '#333';
244 ctx.font = '9px system-ui';
245 ctx.fillText(noteName(p), 4, y + view.current.keyH - 3);
246 }
247 }
248 ctx.fillStyle = '#16181d';
249 ctx.fillRect(KEYS_W - 2, RULER_H, 2, rollBottom - RULER_H);
251 // playhead
252 const px = tickToX(song, playTick);
253 if (px >= KEYS_W) {
254 ctx.strokeStyle = player.state === 'recording' ? '#ff5252' : '#ffd24a';
255 ctx.beginPath();
256 ctx.moveTo(px, 0);
257 ctx.lineTo(px, height);
258 ctx.stroke();
259 }
260 };
261 raf = requestAnimationFrame(draw);
263 // --- interaction ---
265 const hitNote = (song: Song, x: number, y: number): Note | null => {
266 const st = store.state;
267 const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
268 if (!track) return null;
269 let hit: Note | null = null;
270 for (const n of track.notes) {
271 const nx = tickToX(song, n.tick);
272 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
273 const ny = pitchToY(n.pitch);
274 if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + view.current.keyH) hit = n;
275 }
276 return hit;
277 };
279 const blip = (instrumentId: string, pitch: number) => {
280 try {
281 const voice = player.previewNoteOn(instrumentId, pitch, 100);
282 setTimeout(() => voice.noteOff(), 180);
283 } catch { /* audio not available */ }
284 };
286 const activeInstrument = (song: Song): string => {
287 const st = store.state;
288 const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
289 return track?.instrumentId ?? 'pluck';
290 };
292 const effSnap = (ev: PointerEvent | MouseEvent): number => (ev.altKey ? 0 : store.snapTicks());
293 const effSnapDrag = (ev: PointerEvent): number => (ev.shiftKey ? 0 : store.snapTicks());
295 const onPointerDown = (ev: PointerEvent) => {
296 const song = store.song();
297 if (!song) return;
298 const rect = canvas.getBoundingClientRect();
299 const x = ev.clientX - rect.left;
300 const y = ev.clientY - rect.top;
301 const rollBottom = height - VEL_H;
302 canvas.setPointerCapture(ev.pointerId);
304 if (y < RULER_H) {
305 drag.current = { mode: 'ruler-pending', x0: x };
306 return;
307 }
308 if (x < KEYS_W && y < rollBottom) {
309 const pitch = yToPitch(y);
310 if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
311 const voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
312 drag.current = { mode: 'keys', pitch, voice };
313 return;
314 }
315 if (y >= rollBottom) {
316 store.beginEdit();
317 drag.current = { mode: 'velocity' };
318 applyVelocity(x, y);
319 return;
320 }
322 const hit = hitNote(song, x, y);
323 if (ev.button === 2) {
324 if (hit) {
325 store.mutateSong((s) => {
326 for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hit.id);
327 });
328 store.setSelection(store.state.selection.filter((id) => id !== hit.id));
329 }
330 drag.current = null;
331 return;
332 }
333 if (hit) {
334 let sel = store.state.selection;
335 if (!sel.includes(hit.id)) {
336 sel = ev.shiftKey ? [...sel, hit.id] : [hit.id];
337 store.setSelection(sel);
338 }
339 const nx = tickToX(song, hit.tick);
340 const w = Math.max(3, hit.duration * pxPerTick(song) - 1);
341 const nearRight = x > nx + w - 6 && w > 10;
342 const nearLeft = x < nx + 5 && w > 10 && !nearRight;
343 store.beginEdit();
344 let ids = sel;
345 let anchorId = hit.id;
346 if (ev.altKey && !nearRight && !nearLeft) {
347 // alt+drag: duplicate the selection and move the copies
348 const map = new Map<string, string>();
349 store.editSong((s) => {
350 for (const t of s.tracks) {
351 const copies = t.notes
352 .filter((n) => ids.includes(n.id))
353 .map((n) => {
354 const id = newId('n');
355 map.set(n.id, id);
356 return { ...n, id };
357 });
358 t.notes.push(...copies);
359 }
360 });
361 ids = [...map.values()];
362 anchorId = map.get(hit.id)!;
363 store.setSelection(ids);
364 }
365 const cur = store.song()!;
366 const origs = new Map<string, OrigNote>();
367 for (const t of cur.tracks) for (const n of t.notes) if (ids.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch });
368 if (nearRight) drag.current = { mode: 'resize', startX: x, anchorId, origs };
369 else if (nearLeft) drag.current = { mode: 'resize-left', startX: x, anchorId, origs };
370 else drag.current = { mode: 'move', startX: x, startY: y, anchorId, origs, lastBlipPitch: hit.pitch };
371 return;
372 }
373 if (ev.shiftKey) {
374 drag.current = { mode: 'marquee', additive: true, x0: x, y0: y, x1: x, y1: y };
375 return;
376 }
377 // A click seeks; a drag becomes a marquee. Decided by movement.
378 drag.current = { mode: 'pending', x0: x, y0: y };
379 };
381 const onDblClick = (ev: MouseEvent) => {
382 const song = store.song();
383 if (!song) return;
384 const rect = canvas.getBoundingClientRect();
385 const x = ev.clientX - rect.left;
386 const y = ev.clientY - rect.top;
387 if (y < RULER_H || x < KEYS_W || y >= height - VEL_H) return;
388 const hitn = hitNote(song, x, y);
389 if (hitn) {
390 store.mutateSong((s) => {
391 for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hitn.id);
392 });
393 store.setSelection(store.state.selection.filter((id) => id !== hitn.id));
394 return;
395 }
396 const snap = effSnap(ev);
397 const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor'));
398 const pitch = yToPitch(y);
399 if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
400 const dur = snap > 0 ? snap : Math.round(song.ppq / 4);
401 const id = newId('n');
402 store.mutateSong((s) => {
403 const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
404 track?.notes.push({ id, tick, duration: dur, pitch, velocity: 100 });
405 });
406 store.setSelection([id]);
407 blip(activeInstrument(song), pitch);
408 };
410 const applyVelocity = (x: number, y: number) => {
411 const vel = Math.max(1, Math.min(127, Math.round(((height - 2 - y) / (VEL_H - 8)) * 127)));
412 store.editSong((s) => {
413 const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
414 if (!track) return;
415 for (const n of track.notes) {
416 const nx = tickToX(s, n.tick);
417 if (Math.abs(nx - x) <= 4) n.velocity = vel;
418 }
419 });
420 };
422 const onPointerMove = (ev: PointerEvent) => {
423 const d = drag.current;
424 const song = store.song();
425 if (!d || !song) return;
426 const rect = canvas.getBoundingClientRect();
427 const x = ev.clientX - rect.left;
428 const y = ev.clientY - rect.top;
429 if (d.mode === 'ruler-pending') {
430 if (Math.abs(x - d.x0) > 4) {
431 const grid = effSnapDrag(ev);
432 const anchor = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, d.x0)), grid, 'round'));
433 drag.current = { mode: 'range', anchorTick: anchor };
434 }
435 } else if (d.mode === 'range') {
436 const grid = effSnapDrag(ev);
437 const t = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, x)), grid, 'round'));
438 const a = d.anchorTick;
439 store.setRange(t === a ? null : [Math.min(a, t), Math.max(a, t)]);
440 } else if (d.mode === 'keys') {
441 const pitch = yToPitch(y);
442 if (pitch !== d.pitch && pitch >= PITCH_MIN && pitch <= PITCH_MAX) {
443 d.voice?.noteOff();
444 d.voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
445 d.pitch = pitch;
446 }
447 } else if (d.mode === 'velocity') {
448 applyVelocity(x, y);
449 } else if (d.mode === 'pending') {
450 if (Math.hypot(x - d.x0, y - d.y0) > 4) {
451 drag.current = { mode: 'marquee', additive: false, x0: d.x0, y0: d.y0, x1: x, y1: y };
452 }
453 } else if (d.mode === 'marquee') {
454 d.x1 = x;
455 d.y1 = y;
456 } else if (d.mode === 'move') {
457 const snap = effSnapDrag(ev);
458 const anchor = d.origs.get(d.anchorId)!;
459 const rawDelta = (x - d.startX) / pxPerTick(song);
460 const delta = snapValue(anchor.tick + rawDelta, snap, 'round') - anchor.tick;
461 const dPitch = -Math.round((y - d.startY) / view.current.keyH);
462 store.editSong((s) => {
463 for (const t of s.tracks) {
464 for (const n of t.notes) {
465 const o = d.origs.get(n.id);
466 if (!o) continue;
467 n.tick = Math.max(0, o.tick + delta);
468 n.pitch = Math.max(0, Math.min(127, o.pitch + dPitch));
469 }
470 }
471 });
472 const newAnchorPitch = Math.max(0, Math.min(127, anchor.pitch + dPitch));
473 if (newAnchorPitch !== d.lastBlipPitch) {
474 d.lastBlipPitch = newAnchorPitch;
475 blip(activeInstrument(song), newAnchorPitch);
476 }
477 } else if (d.mode === 'resize') {
478 const snap = effSnapDrag(ev);
479 const anchor = d.origs.get(d.anchorId)!;
480 const rawDelta = (x - d.startX) / pxPerTick(song);
481 const newEnd = snapValue(anchor.tick + anchor.duration + rawDelta, snap, 'round');
482 const delta = newEnd - (anchor.tick + anchor.duration);
483 store.editSong((s) => {
484 for (const t of s.tracks) {
485 for (const n of t.notes) {
486 const o = d.origs.get(n.id);
487 if (!o) continue;
488 n.duration = Math.max(MIN_DUR_TICKS, o.duration + delta);
489 }
490 }
491 });
492 } else if (d.mode === 'resize-left') {
493 const snap = effSnapDrag(ev);
494 const anchor = d.origs.get(d.anchorId)!;
495 const rawDelta = (x - d.startX) / pxPerTick(song);
496 const newStart = snapValue(anchor.tick + rawDelta, snap, 'round');
497 const delta = newStart - anchor.tick;
498 store.editSong((s) => {
499 for (const t of s.tracks) {
500 for (const n of t.notes) {
501 const o = d.origs.get(n.id);
502 if (!o) continue;
503 const tick = Math.max(0, Math.min(o.tick + o.duration - MIN_DUR_TICKS, o.tick + delta));
504 n.tick = tick;
505 n.duration = o.duration - (tick - o.tick);
506 }
507 }
508 });
509 }
510 };
512 const onPointerUp = () => {
513 const d = drag.current;
514 const song = store.song();
515 drag.current = null;
516 if (!d || !song) return;
517 if (d.mode === 'keys') {
518 d.voice?.noteOff();
519 } else if (d.mode === 'pending') {
520 const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
521 store.seekTo(getTm(song).tickToSec(tick));
522 store.setSelection([]);
523 } else if (d.mode === 'ruler-pending') {
524 const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
525 store.seekTo(getTm(song).tickToSec(tick));
526 store.setRange(null);
527 } else if (d.mode === 'marquee') {
528 const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
529 if (!track) return;
530 const x0 = Math.min(d.x0, d.x1);
531 const x1 = Math.max(d.x0, d.x1);
532 const y0 = Math.min(d.y0, d.y1);
533 const y1 = Math.max(d.y0, d.y1);
534 const ids: string[] = [];
535 for (const n of track.notes) {
536 const nx = tickToX(song, n.tick);
537 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
538 const ny = pitchToY(n.pitch);
539 if (nx + w >= x0 && nx <= x1 && ny + view.current.keyH >= y0 && ny <= y1) ids.push(n.id);
540 }
541 store.setSelection(d.additive ? [...new Set([...store.state.selection, ...ids])] : ids);
542 } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'resize-left' || d.mode === 'velocity') {
543 store.endEdit();
544 }
545 };
547 const onWheel = (ev: WheelEvent) => {
548 ev.preventDefault();
549 const song = store.song();
550 if (!song) return;
551 const rect = canvas.getBoundingClientRect();
552 const x = ev.clientX - rect.left;
553 if (ev.ctrlKey || ev.metaKey) {
554 const tickAtMouse = xToTick(song, x);
555 const factor = Math.exp(-ev.deltaY * 0.0018);
556 view.current.pxPerBeat = Math.max(12, Math.min(700, view.current.pxPerBeat * factor));
557 view.current.scrollX = Math.max(0, tickAtMouse * pxPerTick(song) - (x - KEYS_W));
558 } else if (ev.altKey) {
559 const y = ev.clientY - rect.top;
560 const pitchF = (y + view.current.scrollY - RULER_H) / view.current.keyH;
561 const factor = Math.exp(-ev.deltaY * 0.0018);
562 view.current.keyH = Math.max(6, Math.min(26, view.current.keyH * factor));
563 view.current.scrollY = Math.max(0, pitchF * view.current.keyH - (y - RULER_H));
564 } else if (ev.shiftKey) {
565 view.current.scrollX = Math.max(0, view.current.scrollX + ev.deltaY);
566 } else {
567 const rollH = (PITCH_MAX - PITCH_MIN + 1) * view.current.keyH;
568 const viewH = Math.max(50, height - VEL_H - RULER_H);
569 view.current.scrollY = Math.max(0, Math.min(rollH - viewH, view.current.scrollY + ev.deltaY));
570 const maxX = Math.max(0, (songEndTick(song) + song.ppq * 64) * pxPerTick(song) - (width - KEYS_W));
571 view.current.scrollX = Math.max(0, Math.min(maxX, view.current.scrollX + ev.deltaX));
572 }
573 };
575 canvas.addEventListener('pointerdown', onPointerDown);
576 canvas.addEventListener('dblclick', onDblClick);
577 canvas.addEventListener('pointermove', onPointerMove);
578 canvas.addEventListener('pointerup', onPointerUp);
579 canvas.addEventListener('pointercancel', onPointerUp);
580 canvas.addEventListener('wheel', onWheel, { passive: false });
581 canvas.addEventListener('contextmenu', (e) => e.preventDefault());
583 return () => {
584 cancelAnimationFrame(raf);
585 ro.disconnect();
586 canvas.removeEventListener('pointerdown', onPointerDown);
587 canvas.removeEventListener('dblclick', onDblClick);
588 canvas.removeEventListener('pointermove', onPointerMove);
589 canvas.removeEventListener('pointerup', onPointerUp);
590 canvas.removeEventListener('pointercancel', onPointerUp);
591 canvas.removeEventListener('wheel', onWheel);
592 };
593 }, []);
595 return (
596 <div className="pianoroll" ref={containerRef}>
597 <canvas ref={canvasRef} />
598 </div>
599 );
600}