From 44ca01761a77ca63e187b3c01fbbf0ff349ec864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= Date: Sun, 22 Feb 2026 10:49:33 +0100 Subject: [PATCH 1/3] ffix: using Canvas instead of morbillions of divs --- .../app/timeline/PianoRoll/PianoGrid.vue | 1 + .../timeline/PianoRoll/PianoGridCanvas.vue | 274 +++++++++++++++++ .../app/timeline/PianoRoll/PianoRoll.vue | 6 +- .../components/app/timeline/TimelineView.vue | 4 +- .../pianoGrid/usePianoGridCanvas.ts | 241 +++++++++++++++ .../pianoGrid/usePianoGridSelection.ts | 5 +- webapp/src/lib/canvas/pianoGridRenderer.ts | 288 ++++++++++++++++++ 7 files changed, 815 insertions(+), 4 deletions(-) create mode 100644 webapp/src/components/app/timeline/PianoRoll/PianoGridCanvas.vue create mode 100644 webapp/src/composables/pianoGrid/usePianoGridCanvas.ts create mode 100644 webapp/src/lib/canvas/pianoGridRenderer.ts 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/PianoRoll.vue b/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue index 6d915b1..28f83c5 100644 --- a/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue +++ b/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue @@ -11,6 +11,9 @@ import { } from "../../../../lib/audio/pianoRollConstants"; import PianoKeys from "./PianoKeys.vue"; import PianoGrid from "./PianoGrid.vue"; +import PianoGridCanvas from "./PianoGridCanvas.vue"; + +const USE_CANVAS_GRID = true; const props = defineProps<{ track: Track; @@ -150,7 +153,8 @@ onBeforeUnmount(() => { @note-stop="handleNoteStop" @all-notes-stop="handleAllNotesStop" /> - 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/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; + } +} From fbda4ec2a06e2dbc6dd3019344872ecfb84cb96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= Date: Sun, 22 Feb 2026 10:53:32 +0100 Subject: [PATCH 2/3] fix: using canvas as piano roll too --- .../timeline/PianoRoll/PianoKeysCanvas.vue | 169 +++++++++++++ .../app/timeline/PianoRoll/PianoRoll.vue | 8 +- webapp/src/lib/canvas/pianoKeysRenderer.ts | 229 ++++++++++++++++++ 3 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 webapp/src/components/app/timeline/PianoRoll/PianoKeysCanvas.vue create mode 100644 webapp/src/lib/canvas/pianoKeysRenderer.ts 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 28f83c5..45590ff 100644 --- a/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue +++ b/webapp/src/components/app/timeline/PianoRoll/PianoRoll.vue @@ -10,10 +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_GRID = true; +const USE_CANVAS = true; const props = defineProps<{ track: Track; @@ -146,7 +147,8 @@ onBeforeUnmount(() => {