refactor: decompose PianoRollCanvas.tsx into focused modules#1050
refactor: decompose PianoRollCanvas.tsx into focused modules#1050
Conversation
Extract pure canvas rendering into PianoRollRenderer.ts (388 lines) and drag state machine into usePianoRollDrag.ts (565 lines), reducing the main component from 1391 to 784 lines. Closes #1028 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors the piano roll implementation by extracting canvas rendering and the drag/mouse interaction state machine out of PianoRollCanvas.tsx into dedicated modules, while keeping behavior and the public API consistent.
Changes:
- Extracted canvas drawing logic into
PianoRollRenderer.ts. - Extracted the piano roll drag/state machine into
usePianoRollDrag.ts. - Updated the background color unit test to assert against the new renderer source.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/components/pianoroll/PianoRollCanvas.tsx |
Removes inlined rendering + drag logic and delegates to the new renderer/hook modules. |
src/components/pianoroll/PianoRollRenderer.ts |
New pure rendering module responsible for drawing grid, notes, playhead, selection box, tool badge, and velocity lane. |
src/components/pianoroll/usePianoRollDrag.ts |
New hook encapsulating mouse/keyboard drag behavior across tools and edit modes. |
tests/unit/pianoRollBackground.test.tsx |
Updates raw-source import target from canvas component to renderer module for background color assertion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // --- Drag state machine (extracted hook) --- | ||
|
|
||
| const { | ||
| dragRef, | ||
| dividerDragRef, |
There was a problem hiding this comment.
This PR closes #1028, but the issue acceptance criteria calls out reducing PianoRollCanvas.tsx to < 400 lines (and potentially extracting additional modules like a velocity lane renderer). After this refactor, the file is still ~785 lines. If the issue should remain open for the remaining extraction work, consider adjusting the “Closes #1028” language or opening a follow-up issue/PR for the remaining scope.
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import type { Clip, MidiNote, PianoRollGrid } from '../../types/project'; | ||
| import type { PianoRollTool } from './PianoRollConstants'; | ||
| import { | ||
| generateNoteId, | ||
| gridSizeToBeats, | ||
| MIDI_MAX_NOTE, | ||
| PIANO_KEYBOARD_WIDTH, | ||
| VELOCITY_LANE_HEIGHT, | ||
| } from './PianoRollConstants'; | ||
| import { useProjectStore } from '../../store/projectStore'; | ||
| import { useUIStore } from '../../store/uiStore'; |
There was a problem hiding this comment.
usePianoRollDrag.ts has unused imports/selectors (e.g. useState, generateNoteId, VELOCITY_LANE_HEIGHT, useUIStore, and addMidiNote). This adds dead code and may break lint rules. Please remove the unused imports/selectors, or wire them up if they’re intended to be used.
| import { useCallback, useEffect, useRef, useState } from 'react'; | |
| import type { Clip, MidiNote, PianoRollGrid } from '../../types/project'; | |
| import type { PianoRollTool } from './PianoRollConstants'; | |
| import { | |
| generateNoteId, | |
| gridSizeToBeats, | |
| MIDI_MAX_NOTE, | |
| PIANO_KEYBOARD_WIDTH, | |
| VELOCITY_LANE_HEIGHT, | |
| } from './PianoRollConstants'; | |
| import { useProjectStore } from '../../store/projectStore'; | |
| import { useUIStore } from '../../store/uiStore'; | |
| import { useCallback, useEffect, useRef } from 'react'; | |
| import type { Clip, MidiNote, PianoRollGrid } from '../../types/project'; | |
| import type { PianoRollTool } from './PianoRollConstants'; | |
| import { | |
| gridSizeToBeats, | |
| MIDI_MAX_NOTE, | |
| PIANO_KEYBOARD_WIDTH, | |
| } from './PianoRollConstants'; | |
| import { useProjectStore } from '../../store/projectStore'; |
| bpm: number; | ||
| clipStartTime: number; | ||
| clipDuration: number; |
There was a problem hiding this comment.
In PianoRollDrawParams, currentBeat is actually treated as a transport time in seconds (it’s multiplied by bpm/60 and offset by clipStartTime). The name is misleading and makes it easy to pass an already-converted beat value by mistake. Consider renaming it to something like currentTime / transportTimeSeconds (and clarifying that clipStartTime/clipDuration are seconds) to prevent future misuse.
| bpm: number; | |
| clipStartTime: number; | |
| clipDuration: number; | |
| bpm: number; | |
| // Start time of this clip in transport seconds (not beats). | |
| clipStartTime: number; | |
| // Duration of this clip in transport seconds (not beats). | |
| clipDuration: number; | |
| /** | |
| * Current transport position in seconds. | |
| * NOTE: Despite the name, this is a time value (seconds), not a beat index. | |
| */ |
| import type { MidiNote } from '../../types/project'; | ||
| import type { GhostNote } from './PianoRollCanvas'; | ||
| import { drawPianoRollKeyboard } from './PianoRollKeyboard'; |
There was a problem hiding this comment.
PianoRollRenderer.ts imports the GhostNote type from PianoRollCanvas.tsx. Even though it’s a type-only import, it couples the pure renderer module back to the React component module (and makes the type harder to reuse elsewhere). Consider moving GhostNote into a small shared types file (e.g. pianoRollTypes.ts) that both modules can import from.
Summary
PianoRollRenderer.ts(388 lines) — grid, notes, ghost notes, playhead, box selection, tool badgeusePianoRollDrag.ts(565 lines) — mouse down/move/up handlers for all 5 edit modes (select, pencil, paint, erase, velocity paint, slide)PianoRollCanvas.tsxfrom 1391 to 784 lines while preserving identical behavior and exported APIpianoRollBackground.test.tsxto reference the new renderer module for source-level color assertionsCloses #1028
Test plan
npx tsc --noEmit— 0 type errorsnpm test -- --run— all 3008 tests pass (0 failures)npm run build— succeeds🤖 Generated with Claude Code