# 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 ```typescript 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 ```typescript import create from 'zustand'; interface NotationStore extends NotationState { // Actions addNote: (measureId: string, note: Note) => void; deleteNote: (noteId: string) => void; updateNote: (noteId: string, changes: Partial) => void; selectNote: (noteId: string) => void; deselectAll: () => void; undo: () => void; redo: () => void; } export const useNotationStore = create((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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript export const EditorToolbar: React.FC = () => { const [selectedTool, setSelectedTool] = useState<'select' | 'add' | 'delete'>('select'); const [selectedDuration, setSelectedDuration] = useState('quarter'); return (
setSelectedTool('select')} tooltip="Select (V)" /> setSelectedTool('add')} tooltip="Add Note (A)" /> setSelectedTool('delete')} tooltip="Delete (D)" /> useNotationStore.getState().undo()} tooltip="Undo (Cmd+Z)" /> useNotationStore.getState().redo()} tooltip="Redo (Cmd+Shift+Z)" />
); }; ``` ### Context Menu (Right-Click) ```typescript export const NoteContextMenu: React.FC<{ noteId: string, position: { x: number, y: number } }> = ({ noteId, position }) => { const { deleteNote, updateNote } = useNotationStore(); return ( deleteNote(noteId)}>Delete updateNote(noteId, { dotted: true })}>Add Dot { /* transpose logic */ }}>Transpose... ); }; ``` --- ## Keyboard Shortcuts ```typescript 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 ```typescript // 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 ```typescript 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 1. Implement [Playback System](playback.md) to hear edited notation 2. Add advanced features (copy/paste, multi-select) 3. Test editing with complex scores See [Data Flow](data-flow.md) for how state propagates through the app.