diff --git a/webapp/src/components/app/timeline/PianoRoll/PianoGrid.vue b/webapp/src/components/app/timeline/PianoRoll/PianoGrid.vue
index 2ca7fef..54ee2c2 100644
--- a/webapp/src/components/app/timeline/PianoRoll/PianoGrid.vue
+++ b/webapp/src/components/app/timeline/PianoRoll/PianoGrid.vue
@@ -59,6 +59,7 @@ const {
removeFromSelection,
cleanup: cleanupSelection,
} = usePianoGridSelection(
+ null,
() => props.notes,
() => props.colWidth,
() => gridWidth.value,
diff --git a/webapp/src/components/app/timeline/PianoRoll/PianoGridCanvas.vue b/webapp/src/components/app/timeline/PianoRoll/PianoGridCanvas.vue
new file mode 100644
index 0000000..dac4d7b
--- /dev/null
+++ b/webapp/src/components/app/timeline/PianoRoll/PianoGridCanvas.vue
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/app/timeline/PianoRoll/PianoKeysCanvas.vue b/webapp/src/components/app/timeline/PianoRoll/PianoKeysCanvas.vue
new file mode 100644
index 0000000..0542053
--- /dev/null
+++ b/webapp/src/components/app/timeline/PianoRoll/PianoKeysCanvas.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue b/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue
index 6d915b1..45590ff 100644
--- a/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue
+++ b/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue
@@ -10,7 +10,11 @@ import {
noteIndexToName,
} from "../../../../lib/audio/pianoRollConstants";
import PianoKeys from "./PianoKeys.vue";
+import PianoKeysCanvas from "./PianoKeysCanvas.vue";
import PianoGrid from "./PianoGrid.vue";
+import PianoGridCanvas from "./PianoGridCanvas.vue";
+
+const USE_CANVAS = true;
const props = defineProps<{
track: Track;
@@ -143,14 +147,16 @@ onBeforeUnmount(() => {
-
-
timelineStore.sortedTracks);
const TRACK_HEADER_WIDTH = 180;
const cursorStyle = computed(() => ({
- left: `${currentPosition.value * COL_WIDTH + TRACK_HEADER_WIDTH}px`,
+ transform: `translateX(${currentPosition.value * COL_WIDTH + TRACK_HEADER_WIDTH}px)`,
}));
const noteNamesDescending = [
@@ -730,10 +730,12 @@ defineExpose({
position: absolute;
top: 0;
bottom: 0;
+ left: 0;
width: 2px;
background: #ef4444;
pointer-events: none;
z-index: 50;
+ will-change: transform;
&::before {
content: "";
diff --git a/webapp/src/components/app/timeline/TrackRow.vue b/webapp/src/components/app/timeline/TrackRow.vue
index a604731..4362d2a 100644
--- a/webapp/src/components/app/timeline/TrackRow.vue
+++ b/webapp/src/components/app/timeline/TrackRow.vue
@@ -2,8 +2,11 @@
import type { Track } from "../../../lib/utils/types";
import TrackHeader from "./TrackHeader.vue";
import TrackTimelinePreview from "./TrackTimelinePreview.vue";
+import TrackTimelinePreviewCanvas from "./TrackTimelinePreviewCanvas.vue";
import PianoRoll from "./PianoRoll/PianoRoll.vue";
+const USE_CANVAS = true;
+
defineProps<{
track: Track;
cols: number;
@@ -43,7 +46,8 @@ const emit = defineEmits<{
@toggle-expand="emit('toggle-expand', track)"
/>
-
+import { ref, watch, onMounted } from "vue";
+import type { MidiNote } from "../../../lib/utils/types";
+import { TOTAL_NOTES } from "../../../lib/audio/pianoRollConstants";
+
+const props = defineProps<{
+ notes: MidiNote[];
+ cols: number;
+ colWidth: number;
+ rowHeight: number;
+ color: string;
+}>();
+
+const emit = defineEmits<{
+ (e: "dblclick"): void;
+}>();
+
+const canvasRef = ref(null);
+const isHovered = ref(false);
+let dpr = 1;
+
+const initCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ dpr = window.devicePixelRatio || 1;
+ const width = props.cols * props.colWidth;
+ const height = props.rowHeight;
+
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+
+ render();
+};
+
+const updateCanvasSize = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ const width = props.cols * props.colWidth;
+ const height = props.rowHeight;
+
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+
+ render();
+};
+
+const render = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d")!;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const width = props.cols * props.colWidth;
+ const height = props.rowHeight;
+
+ ctx.fillStyle = isHovered.value ? "#1f1119" : "#1a0e15";
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.strokeStyle = "rgba(122, 15, 62, 0.5)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ for (let i = 0; i <= Math.ceil(props.cols / 4); i++) {
+ const x = i * 4 * props.colWidth - 0.5;
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ }
+ ctx.stroke();
+
+ if (props.notes.length === 0) return;
+
+ let minY = TOTAL_NOTES;
+ let maxY = 0;
+ for (const note of props.notes) {
+ if (note.y < minY) minY = note.y;
+ if (note.y > maxY) maxY = note.y;
+ }
+
+ const padding = 2;
+ minY = Math.max(0, minY - padding);
+ maxY = Math.min(TOTAL_NOTES - 1, maxY + padding);
+
+ const range = Math.max(maxY - minY + 1, 5);
+ const noteHeight = Math.max(2, Math.min(height / range - 1, 8));
+
+ ctx.globalAlpha = 0.9;
+ ctx.fillStyle = props.color;
+
+ for (const note of props.notes) {
+ const x = note.x * props.colWidth;
+ const y = ((note.y - minY) / range) * (height - noteHeight);
+ const w = Math.max(note.w * props.colWidth - 1, 2);
+
+ ctx.beginPath();
+ ctx.roundRect(x, y, w, noteHeight, 1);
+ ctx.fill();
+ }
+
+ ctx.globalAlpha = 1;
+};
+
+watch([() => props.notes, () => props.color, isHovered], render, { deep: true });
+watch([() => props.cols, () => props.colWidth, () => props.rowHeight], updateCanvasSize);
+
+onMounted(() => {
+ initCanvas();
+});
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/composables/pianoGrid/usePianoGridCanvas.ts b/webapp/src/composables/pianoGrid/usePianoGridCanvas.ts
new file mode 100644
index 0000000..f6ba62d
--- /dev/null
+++ b/webapp/src/composables/pianoGrid/usePianoGridCanvas.ts
@@ -0,0 +1,241 @@
+import { ref, watch, type Ref } from "vue";
+import type { MidiNote, NoteName } from "../../lib/utils/types";
+import {
+ TOTAL_NOTES,
+ NOTE_ROW_HEIGHT,
+ noteIndexToName,
+} from "../../lib/audio/pianoRollConstants";
+import {
+ PianoGridRenderer,
+ type NoteRenderData,
+ type SelectionRectData,
+} from "../../lib/canvas/pianoGridRenderer";
+
+interface DragState {
+ notesInitialPos: Map;
+}
+
+interface ResizingState {
+ notesInitialWidth: Map;
+}
+
+interface SelectionRect {
+ startX: number;
+ startY: number;
+ currentX: number;
+ currentY: number;
+}
+
+interface UsePianoGridCanvasConfig {
+ cols: () => number;
+ colWidth: () => number;
+ notes: () => MidiNote[];
+ trackColor: () => string;
+ activeNotes: () => Set;
+ selectedNotes: Ref>;
+ dragState: Ref;
+ dragPreviewDeltas: Ref<{ dx: number; dy: number } | null>;
+ resizingState: Ref;
+ resizePreviewDelta: Ref;
+ selectionRect: Ref;
+}
+
+export function usePianoGridCanvas(
+ canvasRef: Ref,
+ config: UsePianoGridCanvasConfig,
+) {
+ const renderer = ref(null);
+ let renderScheduled = false;
+ let dpr = 1;
+
+ const initCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ dpr = window.devicePixelRatio || 1;
+
+ const width = config.cols() * config.colWidth();
+ const height = TOTAL_NOTES * NOTE_ROW_HEIGHT;
+
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+
+ const ctx = canvas.getContext("2d")!;
+ ctx.scale(dpr, dpr);
+
+ renderer.value = new PianoGridRenderer(
+ ctx,
+ {
+ cols: config.cols(),
+ colWidth: config.colWidth(),
+ trackColor: config.trackColor(),
+ },
+ width,
+ height,
+ );
+
+ render();
+ };
+
+ const updateCanvasSize = () => {
+ const canvas = canvasRef.value;
+ if (!canvas || !renderer.value) return;
+
+ const width = config.cols() * config.colWidth();
+ const height = TOTAL_NOTES * NOTE_ROW_HEIGHT;
+
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+
+ const ctx = canvas.getContext("2d")!;
+ ctx.scale(dpr, dpr);
+
+ renderer.value.updateConfig(
+ {
+ cols: config.cols(),
+ colWidth: config.colWidth(),
+ trackColor: config.trackColor(),
+ },
+ width,
+ height,
+ );
+
+ render();
+ };
+
+ const scheduleRender = () => {
+ if (renderScheduled) return;
+ renderScheduled = true;
+ requestAnimationFrame(() => {
+ render();
+ renderScheduled = false;
+ });
+ };
+
+ const render = () => {
+ if (!renderer.value) return;
+
+ const noteData: NoteRenderData[] = config.notes().map((note) => {
+ const isDragging =
+ config.dragState.value?.notesInitialPos.has(note.i) ?? false;
+ const isResizing =
+ config.resizingState.value?.notesInitialWidth.has(note.i) ?? false;
+
+ let previewX: number | undefined;
+ let previewY: number | undefined;
+ let previewW: number | undefined;
+
+ if (isDragging && config.dragPreviewDeltas.value) {
+ const initial = config.dragState.value!.notesInitialPos.get(note.i)!;
+ previewX = initial.x + config.dragPreviewDeltas.value.dx;
+ previewY = initial.y + config.dragPreviewDeltas.value.dy;
+ }
+
+ if (isResizing && config.resizePreviewDelta.value !== null) {
+ const initial = config.resizingState.value!.notesInitialWidth.get(
+ note.i,
+ )!;
+ previewW = initial.width + config.resizePreviewDelta.value;
+ }
+
+ return {
+ id: note.i,
+ x: note.x,
+ y: note.y,
+ w: note.w,
+ isSelected: config.selectedNotes.value.has(note.i),
+ isDragging,
+ isResizing,
+ previewX,
+ previewY,
+ previewW,
+ };
+ });
+
+ const activeRows = new Set();
+ for (let i = 0; i < TOTAL_NOTES; i++) {
+ if (config.activeNotes().has(noteIndexToName(i))) {
+ activeRows.add(i);
+ }
+ }
+
+ let selRect: SelectionRectData | null = null;
+ if (config.selectionRect.value) {
+ const sr = config.selectionRect.value;
+ selRect = {
+ x: Math.min(sr.startX, sr.currentX),
+ y: Math.min(sr.startY, sr.currentY),
+ w: Math.abs(sr.currentX - sr.startX),
+ h: Math.abs(sr.currentY - sr.startY),
+ };
+ }
+
+ renderer.value.render(noteData, activeRows, selRect);
+ };
+
+ watch(
+ [
+ () => config.notes(),
+ () => config.activeNotes(),
+ config.selectedNotes,
+ config.dragState,
+ config.dragPreviewDeltas,
+ config.resizingState,
+ config.resizePreviewDelta,
+ config.selectionRect,
+ ],
+ scheduleRender,
+ { deep: true },
+ );
+
+ watch([() => config.cols(), () => config.colWidth()], updateCanvasSize);
+
+ const getNoteAtPosition = (x: number, y: number): MidiNote | null => {
+ if (!renderer.value) return null;
+
+ const noteData: NoteRenderData[] = config.notes().map((note) => ({
+ id: note.i,
+ x: note.x,
+ y: note.y,
+ w: note.w,
+ isSelected: false,
+ isDragging: false,
+ isResizing: false,
+ }));
+
+ const found = renderer.value.getNoteAtPosition(x, y, noteData);
+ if (!found) return null;
+
+ return config.notes().find((n) => n.i === found.id) ?? null;
+ };
+
+ const isOnResizeHandle = (x: number, note: MidiNote): boolean => {
+ if (!renderer.value) return false;
+
+ const noteData: NoteRenderData = {
+ id: note.i,
+ x: note.x,
+ y: note.y,
+ w: note.w,
+ isSelected: false,
+ isDragging: false,
+ isResizing: false,
+ };
+
+ return renderer.value.isOnResizeHandle(x, noteData);
+ };
+
+ return {
+ initCanvas,
+ render,
+ scheduleRender,
+ getNoteAtPosition,
+ isOnResizeHandle,
+ };
+}
diff --git a/webapp/src/composables/pianoGrid/usePianoGridSelection.ts b/webapp/src/composables/pianoGrid/usePianoGridSelection.ts
index d8b95df..0a7d760 100644
--- a/webapp/src/composables/pianoGrid/usePianoGridSelection.ts
+++ b/webapp/src/composables/pianoGrid/usePianoGridSelection.ts
@@ -1,4 +1,4 @@
-import { ref, computed } from "vue";
+import { ref, computed, type Ref } from "vue";
import type { MidiNote } from "../../lib/utils/types";
import { NOTE_ROW_HEIGHT } from "../../lib/audio/pianoRollConstants";
@@ -10,6 +10,7 @@ export interface SelectionRect {
}
export function usePianoGridSelection(
+ containerRef: Ref | null,
notes: () => MidiNote[],
colWidth: () => number,
gridWidth: () => number,
@@ -47,7 +48,7 @@ export function usePianoGridSelection(
const handleSelectionMove = (event: MouseEvent) => {
if (!selectionRect.value) return;
- const grid = document.querySelector(".piano-grid");
+ const grid = containerRef?.value ?? document.querySelector(".piano-grid");
if (!grid) return;
const rect = grid.getBoundingClientRect();
const x = Math.max(0, Math.min(event.clientX - rect.left, gridWidth()));
diff --git a/webapp/src/lib/canvas/pianoGridRenderer.ts b/webapp/src/lib/canvas/pianoGridRenderer.ts
new file mode 100644
index 0000000..d238330
--- /dev/null
+++ b/webapp/src/lib/canvas/pianoGridRenderer.ts
@@ -0,0 +1,288 @@
+import {
+ TOTAL_NOTES,
+ NOTE_ROW_HEIGHT,
+ isBlackKey,
+ isOctaveStart,
+ noteIndexToName,
+} from "../audio/pianoRollConstants";
+
+export interface GridRenderConfig {
+ cols: number;
+ colWidth: number;
+ trackColor: string;
+}
+
+export interface NoteRenderData {
+ id: string;
+ x: number;
+ y: number;
+ w: number;
+ isSelected: boolean;
+ isDragging: boolean;
+ isResizing: boolean;
+ previewX?: number;
+ previewY?: number;
+ previewW?: number;
+}
+
+export interface SelectionRectData {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+const COLORS = {
+ blackKeyRow: "rgba(0, 0, 0, 0.15)",
+ octaveLine: "rgba(0, 0, 0, 0.3)",
+ activeRowHighlight: "rgba(215, 38, 109, 0.15)",
+ cellBorderVertical: "rgba(122, 15, 62, 0.2)",
+ cellBorderHorizontal: "rgba(122, 15, 62, 0.15)",
+ measureLine: "rgba(122, 15, 62, 0.5)",
+ noteSelectedOutline: "#fff7ab",
+ noteSelectedShadow: "rgba(255, 247, 171, 0.4)",
+ noteDraggingOutline: "#fff7ab",
+ selectionRectBorder: "#fff7ab",
+ selectionRectFill: "rgba(255, 247, 171, 0.1)",
+ noteLabel: "rgba(0, 0, 0, 0.8)",
+};
+
+export class PianoGridRenderer {
+ private ctx: CanvasRenderingContext2D;
+ private config: GridRenderConfig;
+ private width: number;
+ private height: number;
+
+ constructor(
+ ctx: CanvasRenderingContext2D,
+ config: GridRenderConfig,
+ width: number,
+ height: number,
+ ) {
+ this.ctx = ctx;
+ this.config = config;
+ this.width = width;
+ this.height = height;
+ }
+
+ updateConfig(config: GridRenderConfig, width: number, height: number) {
+ this.config = config;
+ this.width = width;
+ this.height = height;
+ }
+
+ render(
+ notes: NoteRenderData[],
+ activeRows: Set,
+ selectionRect: SelectionRectData | null,
+ ) {
+ const { ctx } = this;
+
+ ctx.clearRect(0, 0, this.width, this.height);
+
+ this.drawBlackKeyRows();
+ this.drawActiveRowHighlights(activeRows);
+ this.drawGridLines();
+ this.drawOctaveLines();
+ this.drawMeasureLines();
+ this.drawNotes(notes);
+
+ if (selectionRect) {
+ this.drawSelectionRect(selectionRect);
+ }
+ }
+
+ private drawBlackKeyRows() {
+ const { ctx } = this;
+
+ ctx.fillStyle = COLORS.blackKeyRow;
+
+ for (let row = 0; row < TOTAL_NOTES; row++) {
+ const noteName = noteIndexToName(row);
+ if (isBlackKey(noteName)) {
+ const y = row * NOTE_ROW_HEIGHT;
+ ctx.fillRect(0, y, this.width, NOTE_ROW_HEIGHT);
+ }
+ }
+ }
+
+ private drawActiveRowHighlights(activeRows: Set) {
+ if (activeRows.size === 0) return;
+
+ const { ctx } = this;
+ ctx.fillStyle = COLORS.activeRowHighlight;
+
+ for (const row of activeRows) {
+ const y = row * NOTE_ROW_HEIGHT;
+ ctx.fillRect(0, y, this.width, NOTE_ROW_HEIGHT);
+ }
+ }
+
+ private drawGridLines() {
+ const { ctx, config } = this;
+
+ ctx.strokeStyle = COLORS.cellBorderHorizontal;
+ ctx.lineWidth = 1;
+
+ ctx.beginPath();
+ for (let row = 1; row <= TOTAL_NOTES; row++) {
+ const y = row * NOTE_ROW_HEIGHT - 0.5;
+ ctx.moveTo(0, y);
+ ctx.lineTo(this.width, y);
+ }
+ ctx.stroke();
+
+ ctx.strokeStyle = COLORS.cellBorderVertical;
+ ctx.beginPath();
+ for (let col = 1; col <= config.cols; col++) {
+ const x = col * config.colWidth - 0.5;
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, this.height);
+ }
+ ctx.stroke();
+ }
+
+ private drawOctaveLines() {
+ const { ctx } = this;
+
+ ctx.strokeStyle = COLORS.octaveLine;
+ ctx.lineWidth = 2;
+
+ ctx.beginPath();
+ for (let row = 0; row < TOTAL_NOTES; row++) {
+ const noteName = noteIndexToName(row);
+ if (isOctaveStart(noteName)) {
+ const y = (row + 1) * NOTE_ROW_HEIGHT - 1;
+ ctx.moveTo(0, y);
+ ctx.lineTo(this.width, y);
+ }
+ }
+ ctx.stroke();
+ }
+
+ private drawMeasureLines() {
+ const { ctx, config } = this;
+
+ ctx.strokeStyle = COLORS.measureLine;
+ ctx.lineWidth = 1;
+
+ ctx.beginPath();
+ for (let measure = 0; measure <= Math.ceil(config.cols / 4); measure++) {
+ const x = measure * 4 * config.colWidth - 0.5;
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, this.height);
+ }
+ ctx.stroke();
+ }
+
+ private drawNotes(notes: NoteRenderData[]) {
+ for (const note of notes) {
+ this.drawNote(note);
+ }
+ }
+
+ private drawNote(note: NoteRenderData) {
+ const { ctx, config } = this;
+
+ const x =
+ note.previewX !== undefined
+ ? note.previewX * config.colWidth
+ : note.x * config.colWidth;
+ const y =
+ note.previewY !== undefined
+ ? note.previewY * NOTE_ROW_HEIGHT
+ : note.y * NOTE_ROW_HEIGHT;
+ const w =
+ note.previewW !== undefined
+ ? note.previewW * config.colWidth - 2
+ : note.w * config.colWidth - 2;
+ const h = NOTE_ROW_HEIGHT - 2;
+
+ const noteColor = config.trackColor;
+ const noteName = noteIndexToName(note.y);
+ const isBlack = isBlackKey(noteName);
+
+ ctx.globalAlpha = note.isDragging || note.isResizing ? 0.7 : 0.9;
+
+ ctx.fillStyle = noteColor;
+ if (isBlack) {
+ ctx.filter = "brightness(0.85)";
+ }
+
+ ctx.beginPath();
+ ctx.roundRect(x + 1, y + 1, w, h, 2);
+ ctx.fill();
+
+ ctx.filter = "none";
+
+ if (note.isSelected) {
+ ctx.strokeStyle = COLORS.noteSelectedOutline;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ ctx.shadowColor = COLORS.noteSelectedShadow;
+ ctx.shadowBlur = 12;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ } else if (note.isDragging || note.isResizing) {
+ ctx.setLineDash([4, 2]);
+ ctx.strokeStyle = COLORS.noteDraggingOutline;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ ctx.globalAlpha = 1;
+
+ if (w > 12) {
+ ctx.fillStyle = COLORS.noteLabel;
+ const fontSize = w > 20 ? 8 : 6;
+ ctx.font = `500 ${fontSize}px system-ui, sans-serif`;
+ ctx.fillText(noteName, x + 3, y + h / 2 + (fontSize === 6 ? 2 : 3));
+ }
+ }
+
+ private drawSelectionRect(rect: SelectionRectData) {
+ const { ctx } = this;
+
+ ctx.fillStyle = COLORS.selectionRectFill;
+ ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
+
+ ctx.setLineDash([4, 2]);
+ ctx.strokeStyle = COLORS.selectionRectBorder;
+ ctx.lineWidth = 2;
+ ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
+ ctx.setLineDash([]);
+ }
+
+ getNoteAtPosition(
+ x: number,
+ y: number,
+ notes: NoteRenderData[],
+ ): NoteRenderData | null {
+ const { config } = this;
+ const col = x / config.colWidth;
+ const row = Math.floor(y / NOTE_ROW_HEIGHT);
+
+ for (let i = notes.length - 1; i >= 0; i--) {
+ const note = notes[i];
+ const noteX = note.previewX ?? note.x;
+ const noteY = note.previewY ?? note.y;
+ const noteW = note.previewW ?? note.w;
+
+ if (col >= noteX && col < noteX + noteW && row === noteY) {
+ return note;
+ }
+ }
+ return null;
+ }
+
+ isOnResizeHandle(x: number, note: NoteRenderData): boolean {
+ const { config } = this;
+ const noteW = note.previewW ?? note.w;
+ const noteX = note.previewX ?? note.x;
+ const noteRightEdge = (noteX + noteW) * config.colWidth;
+ const handleWidth = 6;
+ return x >= noteRightEdge - handleWidth && x <= noteRightEdge;
+ }
+}
diff --git a/webapp/src/lib/canvas/pianoKeysRenderer.ts b/webapp/src/lib/canvas/pianoKeysRenderer.ts
new file mode 100644
index 0000000..342a510
--- /dev/null
+++ b/webapp/src/lib/canvas/pianoKeysRenderer.ts
@@ -0,0 +1,229 @@
+import type { NoteName } from "../utils/types";
+import {
+ NOTE_ROW_HEIGHT,
+ WHITE_KEY_MULTIPLIERS,
+ isOctaveStart,
+ getOctaveNumber,
+ getWhiteKeys,
+ getBlackKeys,
+ ALL_NOTES,
+} from "../audio/pianoRollConstants";
+
+export interface PianoKeysRenderConfig {
+ width: number;
+ height: number;
+ activeNotes: Set;
+}
+
+interface KeyRect {
+ note: NoteName;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ isBlack: boolean;
+}
+
+const COLORS = {
+ whiteKey: {
+ fill: "#f0f0f0",
+ fillHover: "#fff",
+ fillActive: "#d7266d",
+ border: "#bbb",
+ octaveBorder: "#222",
+ text: "rgba(0, 0, 0, 0.25)",
+ textActive: "#fff",
+ octaveText: "rgba(0, 0, 0, 0.3)",
+ },
+ blackKey: {
+ fill: "#1a1a1a",
+ fillHover: "#2a2a2a",
+ fillActive: "#9b2458",
+ text: "rgba(255, 255, 255, 0.25)",
+ textActive: "#fff",
+ },
+};
+
+export class PianoKeysRenderer {
+ private ctx: CanvasRenderingContext2D;
+ private width: number;
+ private height: number;
+ private keyRects: KeyRect[] = [];
+ private whiteKeys: NoteName[];
+ private blackKeys: NoteName[];
+ private allNotes: NoteName[];
+ private hoveredKey: NoteName | null = null;
+
+ constructor(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ ) {
+ this.ctx = ctx;
+ this.width = width;
+ this.height = height;
+ this.whiteKeys = getWhiteKeys();
+ this.blackKeys = getBlackKeys();
+ this.allNotes = ALL_NOTES;
+ this.buildKeyRects();
+ }
+
+ updateSize(width: number, height: number) {
+ this.width = width;
+ this.height = height;
+ this.buildKeyRects();
+ }
+
+ setHoveredKey(note: NoteName | null) {
+ this.hoveredKey = note;
+ }
+
+ private buildKeyRects() {
+ this.keyRects = [];
+
+ for (let i = 0; i < this.whiteKeys.length; i++) {
+ const note = this.whiteKeys[i];
+ const noteName = note.replace(/\d+$/, "");
+ let top = 0;
+ for (let j = 0; j < i; j++) {
+ const prevNote = this.whiteKeys[j];
+ const prevName = prevNote.replace(/\d+$/, "");
+ top += WHITE_KEY_MULTIPLIERS[prevName] * NOTE_ROW_HEIGHT;
+ }
+ const h = WHITE_KEY_MULTIPLIERS[noteName] * NOTE_ROW_HEIGHT;
+
+ this.keyRects.push({
+ note,
+ x: 0,
+ y: top,
+ w: this.width,
+ h,
+ isBlack: false,
+ });
+ }
+
+ for (const note of this.blackKeys) {
+ const noteIndex = this.allNotes.indexOf(note);
+ this.keyRects.push({
+ note,
+ x: 0,
+ y: noteIndex * NOTE_ROW_HEIGHT,
+ w: this.width * 0.55,
+ h: NOTE_ROW_HEIGHT,
+ isBlack: true,
+ });
+ }
+ }
+
+ render(activeNotes: Set) {
+ const { ctx } = this;
+ ctx.clearRect(0, 0, this.width, this.height);
+
+ const whiteKeyRects = this.keyRects.filter((k) => !k.isBlack);
+ const blackKeyRects = this.keyRects.filter((k) => k.isBlack);
+
+ for (const key of whiteKeyRects) {
+ this.drawWhiteKey(key, activeNotes.has(key.note));
+ }
+
+ for (const key of blackKeyRects) {
+ this.drawBlackKey(key, activeNotes.has(key.note));
+ }
+ }
+
+ private drawWhiteKey(key: KeyRect, isActive: boolean) {
+ const { ctx } = this;
+ const isHovered = this.hoveredKey === key.note;
+ const isOctave = isOctaveStart(key.note);
+
+ const gradient = ctx.createLinearGradient(key.w, key.y, 0, key.y);
+ if (isActive) {
+ gradient.addColorStop(0, COLORS.whiteKey.fillActive);
+ gradient.addColorStop(1, COLORS.whiteKey.fillActive);
+ } else if (isHovered) {
+ gradient.addColorStop(0, "#f0f0f0");
+ gradient.addColorStop(0.5, "#fff");
+ gradient.addColorStop(1, "#e8e8e8");
+ } else {
+ gradient.addColorStop(0, "#e8e8e8");
+ gradient.addColorStop(0.5, "#f5f5f5");
+ gradient.addColorStop(1, "#e0e0e0");
+ }
+
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.roundRect(key.x, key.y, key.w - 1, key.h - 1, [0, 0, 3, 3]);
+ ctx.fill();
+
+ ctx.strokeStyle = isOctave ? COLORS.whiteKey.octaveBorder : COLORS.whiteKey.border;
+ ctx.lineWidth = isOctave ? 2 : 1;
+ ctx.beginPath();
+ ctx.moveTo(key.x, key.y + key.h - 1);
+ ctx.lineTo(key.x + key.w, key.y + key.h - 1);
+ ctx.stroke();
+
+ ctx.font = "bold 10px system-ui, sans-serif";
+ ctx.textAlign = "right";
+ ctx.textBaseline = "middle";
+ ctx.fillStyle = isActive ? COLORS.whiteKey.textActive : COLORS.whiteKey.text;
+ ctx.fillText(key.note, key.w - 6, key.y + key.h / 2);
+ }
+
+ private drawBlackKey(key: KeyRect, isActive: boolean) {
+ const { ctx } = this;
+ const isHovered = this.hoveredKey === key.note;
+
+ const gradient = ctx.createLinearGradient(key.x, key.y, key.x, key.y + key.h);
+ if (isActive) {
+ gradient.addColorStop(0, COLORS.blackKey.fillActive);
+ gradient.addColorStop(1, COLORS.blackKey.fillActive);
+ } else if (isHovered) {
+ gradient.addColorStop(0, "#3a3a3a");
+ gradient.addColorStop(0.6, "#2a2a2a");
+ gradient.addColorStop(1, "#1a1a1a");
+ } else {
+ gradient.addColorStop(0, "#2a2a2a");
+ gradient.addColorStop(0.6, "#1a1a1a");
+ gradient.addColorStop(1, "#0a0a0a");
+ }
+
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.roundRect(key.x, key.y, key.w, key.h - 1, [0, 0, 2, 2]);
+ ctx.fill();
+
+ ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
+ ctx.shadowBlur = 4;
+ ctx.shadowOffsetX = 2;
+ ctx.shadowOffsetY = 2;
+ ctx.fill();
+ ctx.shadowColor = "transparent";
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 0;
+
+ ctx.fillStyle = isActive ? COLORS.blackKey.textActive : COLORS.blackKey.text;
+ ctx.font = "bold 8px system-ui, sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(key.note, key.w / 2, key.y + key.h / 2);
+ }
+
+ getKeyAtPosition(x: number, y: number): NoteName | null {
+ const blackKeyRects = this.keyRects.filter((k) => k.isBlack);
+ for (const key of blackKeyRects) {
+ if (x >= key.x && x < key.x + key.w && y >= key.y && y < key.y + key.h) {
+ return key.note;
+ }
+ }
+
+ const whiteKeyRects = this.keyRects.filter((k) => !k.isBlack);
+ for (const key of whiteKeyRects) {
+ if (x >= key.x && x < key.x + key.w && y >= key.y && y < key.y + key.h) {
+ return key.note;
+ }
+ }
+
+ return null;
+ }
+}