diff --git a/src/components/video-editor/CaptionListPanel.tsx b/src/components/video-editor/CaptionListPanel.tsx new file mode 100644 index 00000000..7ae2503c --- /dev/null +++ b/src/components/video-editor/CaptionListPanel.tsx @@ -0,0 +1,226 @@ +import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react"; +import { useCallback, useEffect, useState } from "react"; +import type { CaptionRetimeSpan } from "./captionOps"; +import type { CaptionCue } from "./types"; + +interface CaptionListPanelProps { + cues: CaptionCue[]; + selectedCaptionId: string | null; + currentTimeMs: number; + onBeginCaptionEdit: (id: string) => void; + onCaptionTextEdit: (id: string, text: string) => void; + onCaptionRetime: (id: string, span: CaptionRetimeSpan) => void; + onCaptionSplit: (id: string, atMs: number) => void; + onCaptionMerge: (idA: string, idB: string) => void; + onCaptionDelete: (id: string) => void; +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function formatTimecode(ms: number): string { + const safeMs = Math.max(0, Math.round(ms)); + const minutes = Math.floor(safeMs / 60_000); + const seconds = Math.floor((safeMs % 60_000) / 1_000); + const millis = safeMs % 1_000; + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function parseTimecode(value: string): number | null { + const match = value.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/); + if (!match) { + return null; + } + const minutes = match[1] ? Number.parseInt(match[1], 10) : 0; + const seconds = Number.parseInt(match[2], 10); + const millis = match[3] ? Number.parseInt(match[3].padEnd(3, "0"), 10) : 0; + return (minutes * 60 + seconds) * 1_000 + millis; +} + +interface CaptionEditorProps { + cue: CaptionCue; + canMerge: boolean; + currentTimeMs: number; + onBeginEdit: (id: string) => void; + onTextEdit: (id: string, text: string) => void; + onRetime: (id: string, span: CaptionRetimeSpan) => void; + onSplit: (id: string, atMs: number) => void; + onMerge: (id: string) => void; + onDelete: (id: string) => void; +} + +function CaptionEditor({ + cue, + canMerge, + currentTimeMs, + onBeginEdit, + onTextEdit, + onRetime, + onSplit, + onMerge, + onDelete, +}: CaptionEditorProps) { + const [draftText, setDraftText] = useState(cue.text); + const [startValue, setStartValue] = useState(formatTimecode(cue.startMs)); + const [endValue, setEndValue] = useState(formatTimecode(cue.endMs)); + + useEffect(() => { + setDraftText(cue.text); + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + }, [cue.text, cue.startMs, cue.endMs]); + + const commitText = useCallback(() => { + const normalized = draftText.trim(); + if (normalized && normalized !== cue.text) { + onTextEdit(cue.id, normalized); + } else { + setDraftText(cue.text); + } + }, [cue.id, cue.text, draftText, onTextEdit]); + + const commitTiming = useCallback(() => { + const parsedStart = parseTimecode(startValue); + const parsedEnd = parseTimecode(endValue); + if (parsedStart === null || parsedEnd === null || parsedEnd <= parsedStart) { + setStartValue(formatTimecode(cue.startMs)); + setEndValue(formatTimecode(cue.endMs)); + return; + } + if (parsedStart !== cue.startMs || parsedEnd !== cue.endMs) { + onRetime(cue.id, { startMs: parsedStart, endMs: parsedEnd }); + } + }, [cue.endMs, cue.id, cue.startMs, endValue, onRetime, startValue]); + + return ( +
+