Interactive Notation Editor
Overview
The editor allows users to modify transcribed notation directly in the browser: add/delete notes, change durations, transpose passages, and adjust musical parameters.
MVP Editor Features
Phase 1 (Minimum Viable Product)
- Add Note: Click on staff to add new note
- Delete Note: Click note + Delete key or right-click → Delete
- Move Note: Drag note vertically to change pitch
- Change Duration: Select note, press number key (1=whole, 2=half, 4=quarter, 8=eighth)
Phase 2 (Future)
- Copy/paste, multi-select
- Transpose selection
- Add articulations (staccato, accents)
- Lyrics, dynamics
- Undo/redo stack
State Management
Notation State Structure
interface NotationState {
score: {
id: string;
title: string;
composer: string;
key: string; // e.g., "C", "Gm"
timeSignature: string; // e.g., "4/4"
tempo: number; // BPM
measures: Measure[];
};
selectedNoteIds: string[];
clipboard: Note[] | null;
history: NotationState[]; // For undo/redo
historyIndex: number;
}
interface Measure {
id: string;
number: number;
notes: Note[];
}
interface Note {
id: string;
pitch: string; // e.g., "C4", "F#5"
duration: string; // "whole", "half", "quarter", "eighth", "16th"
octave: number;
dotted: boolean;
accidental?: 'sharp' | 'flat' | 'natural';
}
Zustand Store
import create from 'zustand';
interface NotationStore extends NotationState {
// Actions
addNote: (measureId: string, note: Note) => void;
deleteNote: (noteId: string) => void;
updateNote: (noteId: string, changes: Partial<Note>) => void;
selectNote: (noteId: string) => void;
deselectAll: () => void;
undo: () => void;
redo: () => void;
}
export const useNotationStore = create<NotationStore>((set, get) => ({
score: { /* initial state */ },
selectedNoteIds: [],
clipboard: null,
history: [],
historyIndex: -1,
addNote: (measureId, note) => set(state => {
const measure = state.score.measures.find(m => m.id === measureId);
if (!measure) return state;
return {
score: {
...state.score,
measures: state.score.measures.map(m =>
m.id === measureId
? { ...m, notes: [...m.notes, note].sort(byTimestamp) }
: m
),
},
};
}),
deleteNote: (noteId) => set(state => ({
score: {
...state.score,
measures: state.score.measures.map(m => ({
...m,
notes: m.notes.filter(n => n.id !== noteId),
})),
},
})),
updateNote: (noteId, changes) => set(state => ({
score: {
...state.score,
measures: state.score.measures.map(m => ({
...m,
notes: m.notes.map(n =>
n.id === noteId ? { ...n, ...changes } : n
),
})),
},
})),
selectNote: (noteId) => set({ selectedNoteIds: [noteId] }),
deselectAll: () => set({ selectedNoteIds: [] }),
undo: () => {
const { history, historyIndex } = get();
if (historyIndex > 0) {
set(history[historyIndex - 1]);
set({ historyIndex: historyIndex - 1 });
}
},
redo: () => {
const { history, historyIndex } = get();
if (historyIndex < history.length - 1) {
set(history[historyIndex + 1]);
set({ historyIndex: historyIndex + 1 });
}
},
}));
Edit Operations
1. Add Note
User Action: Click on staff at desired pitch/time
function handleStaffClick(event: MouseEvent, staveElement: SVGElement) {
// Get click position relative to stave
const rect = staveElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Convert Y position to pitch
const pitch = yPositionToPitch(y, stave.clef);
// Convert X position to time (measure + beat)
const { measureId, beat } = xPositionToTime(x, stave.width);
// Create new note
const newNote: Note = {
id: generateId(),
pitch: pitch,
duration: currentDuration, // From toolbar
octave: parseInt(pitch.slice(-1)),
dotted: false,
};
// Add to state
useNotationStore.getState().addNote(measureId, newNote);
}
function yPositionToPitch(y: number, clef: 'treble' | 'bass'): string {
// Map Y pixel to line/space on staff
// Treble clef: E5 (top line) to F4 (bottom line)
// Each line/space is ~10px
const lineHeight = 10;
const pitches = clef === 'treble'
? ['F5', 'E5', 'D5', 'C5', 'B4', 'A4', 'G4', 'F4', 'E4']
: ['A3', 'G3', 'F3', 'E3', 'D3', 'C3', 'B2', 'A2', 'G2'];
const index = Math.floor(y / lineHeight);
return pitches[index] || 'C4';
}
2. Delete Note
User Action: Select note, press Delete key
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const { selectedNoteIds, deleteNote } = useNotationStore.getState();
if (event.key === 'Delete' || event.key === 'Backspace') {
selectedNoteIds.forEach(id => deleteNote(id));
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
3. Move Note (Change Pitch)
User Action: Drag note vertically
function handleNoteDrag(noteId: string, event: MouseEvent) {
const startY = event.clientY;
let currentY = startY;
function onMouseMove(e: MouseEvent) {
currentY = e.clientY;
const deltaY = currentY - startY;
// Convert delta to semitones (10px per semitone)
const semitoneShift = Math.round(deltaY / 10);
// Update note pitch
const originalNote = findNoteById(noteId);
const newPitch = transposePitch(originalNote.pitch, semitoneShift);
useNotationStore.getState().updateNote(noteId, { pitch: newPitch });
// Re-render VexFlow
renderNotation();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function transposePitch(pitch: string, semitones: number): string {
const pitchMap = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const [note, octaveStr] = [pitch.slice(0, -1), pitch.slice(-1)];
let octave = parseInt(octaveStr);
let index = pitchMap.indexOf(note);
index += semitones;
// Handle octave wrap
while (index < 0) {
index += 12;
octave--;
}
while (index >= 12) {
index -= 12;
octave++;
}
return `${pitchMap[index]}${octave}`;
}
4. Change Duration
User Action: Select note, press number key
const durationKeyMap: { [key: string]: string } = {
'1': 'whole',
'2': 'half',
'4': 'quarter',
'8': 'eighth',
'6': '16th', // 6 for 16th note
};
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const { selectedNoteIds, updateNote } = useNotationStore.getState();
const newDuration = durationKeyMap[event.key];
if (newDuration && selectedNoteIds.length > 0) {
selectedNoteIds.forEach(id => {
updateNote(id, { duration: newDuration });
});
renderNotation(); // Re-render
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
UI Components
Toolbar
export const EditorToolbar: React.FC = () => {
const [selectedTool, setSelectedTool] = useState<'select' | 'add' | 'delete'>('select');
const [selectedDuration, setSelectedDuration] = useState<string>('quarter');
return (
<div className="editor-toolbar">
<ToolButton
icon="cursor"
active={selectedTool === 'select'}
onClick={() => setSelectedTool('select')}
tooltip="Select (V)"
/>
<ToolButton
icon="plus"
active={selectedTool === 'add'}
onClick={() => setSelectedTool('add')}
tooltip="Add Note (A)"
/>
<ToolButton
icon="trash"
active={selectedTool === 'delete'}
onClick={() => setSelectedTool('delete')}
tooltip="Delete (D)"
/>
<Divider />
<DurationSelector value={selectedDuration} onChange={setSelectedDuration} />
<Divider />
<ToolButton icon="undo" onClick={() => useNotationStore.getState().undo()} tooltip="Undo (Cmd+Z)" />
<ToolButton icon="redo" onClick={() => useNotationStore.getState().redo()} tooltip="Redo (Cmd+Shift+Z)" />
</div>
);
};
Context Menu (Right-Click)
export const NoteContextMenu: React.FC<{ noteId: string, position: { x: number, y: number } }> = ({ noteId, position }) => {
const { deleteNote, updateNote } = useNotationStore();
return (
<Menu style={{ top: position.y, left: position.x }}>
<MenuItem onClick={() => deleteNote(noteId)}>Delete</MenuItem>
<MenuItem onClick={() => updateNote(noteId, { dotted: true })}>Add Dot</MenuItem>
<MenuItem onClick={() => { /* transpose logic */ }}>Transpose...</MenuItem>
</Menu>
);
};
Keyboard Shortcuts
const shortcuts = {
'v': 'select tool',
'a': 'add note tool',
'd': 'delete tool',
'1-8': 'change duration',
'Delete': 'delete selected',
'Cmd+Z': 'undo',
'Cmd+Shift+Z': 'redo',
'Cmd+C': 'copy',
'Cmd+V': 'paste',
'ArrowUp': 'transpose up',
'ArrowDown': 'transpose down',
};
Undo/Redo Implementation
// Save state before every mutation
function saveHistory() {
const state = useNotationStore.getState();
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(state);
set({
history: newHistory,
historyIndex: newHistory.length - 1,
});
}
// Call before mutations
addNote: (measureId, note) => {
saveHistory();
// ... perform mutation
}
Validation
Prevent Invalid Edits
function validateNote(note: Note, measure: Measure): string | null {
// Check measure capacity (e.g., 4/4 = 4 beats max)
const totalBeats = measure.notes.reduce((sum, n) => sum + durationToBeats(n.duration), 0);
if (totalBeats + durationToBeats(note.duration) > measure.maxBeats) {
return 'Measure is full';
}
// Check pitch range for instrument
if (!isPitchInRange(note.pitch, 'piano')) {
return 'Pitch out of range for piano';
}
return null; // Valid
}
Next Steps
- Implement Playback System to hear edited notation
- Add advanced features (copy/paste, multi-select)
- Test editing with complex scores
See Data Flow for how state propagates through the app.