import { useEffect, useMemo, useRef, useState } from 'react' import type { NoteEvent } from '../types' export type LyricTableProps = { notes: NoteEvent[] selectedId: string | null tempo: number focusLyricId: string | null onSelect: (id: string | null) => void onUpdate: (id: string, patch: Partial) => void onScrollToNote?: (noteId: string) => void onFocusHandled?: () => void } const formatSeconds = (beats: number, tempo: number) => { const seconds = beats * (60 / tempo) return Number.parseFloat(seconds.toFixed(2)) } const secondsToBeats = (seconds: number, tempo: number) => { return seconds * (tempo / 60) } // Editable cell with confirmation function EditableCell({ value, noteId, field, tempo, onConfirm, type = 'number', min, step }: { value: number noteId: string field: 'midi' | 'start' | 'end' tempo: number onConfirm: (noteId: string, field: string, value: number) => void type?: string min?: number step?: number }) { const displayValue = field === 'midi' ? value : formatSeconds(value, tempo) const [localValue, setLocalValue] = useState(String(displayValue)) const [isDirty, setIsDirty] = useState(false) const inputRef = useRef(null) // Sync with external value when it changes (and not dirty) useEffect(() => { if (!isDirty) { setLocalValue(String(displayValue)) } }, [displayValue, isDirty]) const handleChange = (e: React.ChangeEvent) => { setLocalValue(e.target.value) setIsDirty(true) } const handleConfirm = () => { const parsed = parseFloat(localValue) if (!isNaN(parsed)) { if (field === 'midi') { if (parsed >= 0 && parsed <= 127) { onConfirm(noteId, field, Math.round(parsed)) } } else { if (parsed >= 0) { onConfirm(noteId, field, secondsToBeats(parsed, tempo)) } } } setIsDirty(false) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() handleConfirm() inputRef.current?.blur() } else if (e.key === 'Escape') { setLocalValue(String(displayValue)) setIsDirty(false) inputRef.current?.blur() } } const handleBlur = () => { if (isDirty) { // Reset to original on blur without confirm setLocalValue(String(displayValue)) setIsDirty(false) } } return (
e.stopPropagation()} /> {isDirty && ( )}
) } export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) { const listRef = useRef(null) const inputRefs = useRef>(new Map()) const sorted = useMemo(() => [...notes].sort((a, b) => a.start - b.start), [notes]) // Scroll to selected note (no auto-focus on single click) useEffect(() => { if (!selectedId || !listRef.current) return const target = listRef.current.querySelector(`[data-note-id="${selectedId}"]`) if (target) { target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } }, [selectedId]) // Focus lyric input when requested (double-click on note or click on list row) useEffect(() => { if (!focusLyricId) return const input = inputRefs.current.get(focusLyricId) if (input) { setTimeout(() => { input.focus() input.select() }, 50) } onFocusHandled?.() }, [focusLyricId, onFocusHandled]) // Fill lyrics from selected note onwards const handleBulkFill = (bulkText: string) => { if (!sorted.length) return const chars = Array.from(bulkText.replace(/\s+/g, '')) let startIndex = 0 if (selectedId) { const selectedIndex = sorted.findIndex(n => n.id === selectedId) if (selectedIndex >= 0) { startIndex = selectedIndex } } let charIndex = 0 for (let i = startIndex; i < sorted.length && charIndex < chars.length; i++) { onUpdate(sorted[i].id, { lyric: chars[charIndex] }) charIndex++ } } const handleRowClick = (noteId: string) => { onSelect(noteId) onScrollToNote?.(noteId) } const handleFieldConfirm = (noteId: string, field: string, value: number) => { const note = notes.find(n => n.id === noteId) if (!note) return if (field === 'midi') { onUpdate(noteId, { midi: value }) } else if (field === 'start') { // Keep END the same, adjust duration accordingly const currentEnd = note.start + note.duration const newDuration = Math.max(0.01, currentEnd - value) onUpdate(noteId, { start: value, duration: newDuration }) } else if (field === 'end') { // End changed, update duration const newDuration = Math.max(0.01, value - note.start) onUpdate(noteId, { duration: newDuration }) } } return (