rescored / docs /frontend /editor.md
calebhan's picture
initial docs
c27ae8d

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

  1. Implement Playback System to hear edited notation
  2. Add advanced features (copy/paste, multi-select)
  3. Test editing with complex scores

See Data Flow for how state propagates through the app.