menghao
Optimize interface layout, improve sentence-based filling, and add transposition function.
82c8054
import { useEffect, useMemo, useRef, useState } from 'react'
import type { NoteEvent } from '../types'
import type { Lang } from '../i18n'
import { getTranslations, tokenizeLyrics } from '../i18n'
export type LyricTableProps = {
notes: NoteEvent[]
selectedId: string | null
tempo: number
focusLyricId: string | null
lang: Lang
onSelect: (id: string | null) => void
onUpdate: (id: string, patch: Partial<NoteEvent>) => 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,
confirmTitle,
type = 'number',
min,
step
}: {
value: number
noteId: string
field: 'midi' | 'start' | 'end'
tempo: number
onConfirm: (noteId: string, field: string, value: number) => void
confirmTitle?: string
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<HTMLInputElement>(null)
// Sync with external value when it changes (and not dirty)
useEffect(() => {
if (!isDirty) {
setLocalValue(String(displayValue))
}
}, [displayValue, isDirty])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="editable-cell">
<input
ref={inputRef}
className={`lyric-meta-input ${isDirty ? 'lyric-meta-dirty' : ''}`}
type={type}
min={min}
step={step}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onClick={(e) => e.stopPropagation()}
/>
{isDirty && (
<button
className="confirm-btn"
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleConfirm()
}}
title={confirmTitle}
>
</button>
)}
</div>
)
}
export function LyricTable({ notes, selectedId, tempo, focusLyricId, lang, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) {
const t = getTranslations(lang)
const listRef = useRef<HTMLDivElement | null>(null)
const inputRefs = useRef<Map<string, HTMLInputElement>>(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<HTMLDivElement>(`[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
// Uses smart tokenizer: CJK chars -> one per note, English words -> one per note
const handleBulkFill = (bulkText: string) => {
if (!sorted.length) return
const tokens = tokenizeLyrics(bulkText)
if (!tokens.length) return
let startIndex = 0
if (selectedId) {
const selectedIndex = sorted.findIndex(n => n.id === selectedId)
if (selectedIndex >= 0) {
startIndex = selectedIndex
}
}
let tokenIndex = 0
for (let i = startIndex; i < sorted.length && tokenIndex < tokens.length; i++) {
onUpdate(sorted[i].id, { lyric: tokens[tokenIndex] })
tokenIndex++
}
}
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 (
<div className="lyric-card">
<div className="lyric-bulk">
<textarea
className="lyric-bulk-input"
rows={2}
placeholder={selectedId ? t.fillPlaceholderSelected : t.fillPlaceholderDefault}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleBulkFill(e.currentTarget.value)
}
}}
/>
<button
className="soft"
type="button"
onClick={(e) => {
const textarea = e.currentTarget.previousElementSibling as HTMLTextAreaElement
handleBulkFill(textarea.value)
}}
>
{t.fillButton.split('\n').map((line, i) => (
<span key={i}>{line}{i === 0 && <br/>}</span>
))}
</button>
</div>
<div className="lyric-header" style={{ flexShrink: 0 }}>
<div>LYRIC</div>
<div>PITCH</div>
<div>START</div>
<div>END</div>
</div>
<div className="lyric-list" ref={listRef}>
{sorted.map((note) => (
<div
key={note.id}
className={`lyric-row ${selectedId === note.id ? 'lyric-row-active' : ''}`}
data-note-id={note.id}
onClick={() => handleRowClick(note.id)}
>
<input
ref={(el) => {
if (el) {
inputRefs.current.set(note.id, el)
} else {
inputRefs.current.delete(note.id)
}
}}
className="lyric-input"
value={note.lyric}
placeholder={t.lyricPlaceholder}
onChange={(event) => onUpdate(note.id, { lyric: event.target.value })}
onClick={(e) => e.stopPropagation()}
/>
<EditableCell
value={note.midi}
noteId={note.id}
field="midi"
tempo={tempo}
onConfirm={handleFieldConfirm}
confirmTitle={t.confirmEdit}
min={0}
/>
<EditableCell
value={note.start}
noteId={note.id}
field="start"
tempo={tempo}
onConfirm={handleFieldConfirm}
confirmTitle={t.confirmEdit}
min={0}
step={0.01}
/>
<EditableCell
value={note.start + note.duration}
noteId={note.id}
field="end"
tempo={tempo}
onConfirm={handleFieldConfirm}
confirmTitle={t.confirmEdit}
min={0}
step={0.01}
/>
</div>
))}
{sorted.length === 0 && <div className="lyric-empty">{t.emptyHint}</div>}
</div>
</div>
)
}