"use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { EditorEventBus, type SubtitleData, type EditorEventType, } from "./event-bus"; import { validateSubtitle, type ValidationIssue, type ValidationConfig, DEFAULT_VALIDATION, } from "./validation"; export interface UseEditorOptions { initialSubtitles: SubtitleData[]; jobId: string; resourceId?: string; editSessionId?: string; validationConfig?: ValidationConfig; fps?: number; autoSaveDelayMs?: number; } export type ValidationState = "none" | "validating" | "validated" | "reviewing"; export interface UseEditorReturn { subtitles: SubtitleData[]; selectedIndex: number; setSelectedIndex: (index: number) => void; // Edit operations setText: (index: number, value: string) => void; setTimecodeIn: (index: number, value: string) => void; setTimecodeOut: (index: number, value: string) => void; setSpeaker: (index: number, value: string) => void; setComment: (index: number, value: string) => void; insertSubtitle: (atIndex: number) => void; deleteSubtitle: (index: number) => void; splitSubtitle: (index: number, splitPoint: number) => void; mergeWithNext: (index: number) => void; // Undo/redo undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; // Validation validationIssues: Map; totalErrors: number; totalWarnings: number; // Save state hasUnsavedChanges: boolean; isSaving: boolean; lastSavedAt: Date | null; saveNow: () => Promise; // Rationale tracking (for diff capture) pendingRationales: Map>; setRationale: (index: number, field: string, rationale: string) => void; // Completion workflow editedIndices: Set; editedIndicesWithoutRationale: Set; canValidate: boolean; validationState: ValidationState; setValidationState: (state: ValidationState) => void; // Event bus (for NLI integration) eventBus: EditorEventBus; } export function useEditor(options: UseEditorOptions): UseEditorReturn { const { initialSubtitles, jobId, resourceId, editSessionId, validationConfig = DEFAULT_VALIDATION, fps = 23.976, autoSaveDelayMs = 5000, } = options; const eventBusRef = useRef( new EditorEventBus(initialSubtitles) ); const eventBus = eventBusRef.current; const [subtitles, setSubtitles] = useState( () => eventBus.getSubtitles() ); const [selectedIndex, setSelectedIndex] = useState(0); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isSaving, setIsSaving] = useState(false); const [lastSavedAt, setLastSavedAt] = useState(null); const [pendingRationales, setPendingRationales] = useState< Map> >(new Map()); const initialSubtitlesRef = useRef(structuredClone(initialSubtitles)); const [validationState, setValidationState] = useState("none"); const saveTimerRef = useRef>(); // Subscribe to event bus changes useEffect(() => { const unsubscribe = eventBus.subscribe(() => { setSubtitles([...eventBus.getSubtitles()]); setCanUndo(eventBus.canUndo()); setCanRedo(eventBus.canRedo()); setHasUnsavedChanges(eventBus.hasUnsavedChanges()); // If ANY edit happens after validation, reset the cycle if (validationState !== "none") { setValidationState("none"); } }); return unsubscribe; }, [eventBus, validationState]); // Compute editedIndices by diffing current subtitles against initial state // If undo reverts a subtitle to its original, it drops out of this set const editedIndices = useMemo(() => { const edited = new Set(); const initial = initialSubtitlesRef.current; const current = subtitles; const len = Math.max(initial.length, current.length); for (let i = 0; i < len; i++) { const orig = initial[i]; const curr = current[i]; if (!orig || !curr) { edited.add(i); continue; } if (orig.text !== curr.text || orig.tcIn !== curr.tcIn || orig.tcOut !== curr.tcOut || orig.speaker !== curr.speaker || orig.comment !== curr.comment) { edited.add(i); } } return edited; }, [subtitles]); // Validation const validationIssues = useMemo(() => { const issues = new Map(); for (let i = 0; i < subtitles.length; i++) { const subIssues = validateSubtitle( subtitles[i], i, subtitles, validationConfig, fps ); if (subIssues.length > 0) { issues.set(i, subIssues); } } return issues; }, [subtitles, validationConfig, fps]); const totalErrors = useMemo(() => { let count = 0; validationIssues.forEach((issues) => { count += issues.filter((i) => i.severity === "error").length; }); return count; }, [validationIssues]); const totalWarnings = useMemo(() => { let count = 0; validationIssues.forEach((issues) => { count += issues.filter((i) => i.severity === "warning").length; }); return count; }, [validationIssues]); // Completion workflow: which edited subtitles are missing rationale const editedIndicesWithoutRationale = useMemo(() => { const missing = new Set(); for (const idx of editedIndices) { const ratMap = pendingRationales.get(idx); if (!ratMap || ratMap.size === 0) { missing.add(idx); } else { // Check if at least one rationale entry is non-empty const hasValue = [...ratMap.values()].some((v) => v.trim().length > 0); if (!hasValue) missing.add(idx); } } return missing; }, [editedIndices, pendingRationales]); // Validate is always available — missing rationales are surfaced as errors during validation, not as a blocker const canValidate = true; // Save function const saveNow = useCallback(async () => { if (!eventBus.hasUnsavedChanges()) return; setIsSaving(true); try { const changes = eventBus.flushChanges(); // Build diffs from changes + rationales const fieldMap: Record = { setText: "text", setTimecodeIn: "tc_in", setTimecodeOut: "tc_out", setSpeaker: "speaker", setComment: "comment", setPosition: "position", setStyle: "style", }; const categoryMap: Record = { setText: "translation_fix", setTimecodeIn: "timing_fix", setTimecodeOut: "timing_fix", setSpeaker: "other", setComment: "other", setPosition: "style_correction", setStyle: "style_correction", splitSubtitle: "split_merge", mergeSubtitles: "split_merge", }; // Deduplicate: per (subtitleIndex + field), keep first oldValue and last newValue const diffMap = new Map< string, { subtitleIndex: number; field: string; oldValue: string | null; newValue: string | null; changeCategory: string; } >(); for (const c of changes) { const field = fieldMap[c.type]; if (!field) continue; const key = `${c.target}:${field}`; const existing = diffMap.get(key); const newValue = c.payload.value != null ? String(c.payload.value) : null; if (existing) { // Keep the original oldValue, update newValue to latest existing.newValue = newValue; } else { diffMap.set(key, { subtitleIndex: c.target, field, oldValue: c.previousValue != null ? String(c.previousValue) : null, newValue, changeCategory: categoryMap[c.type] || "other", }); } } // Attach rationales and filter out no-ops const diffs = Array.from(diffMap.values()) .filter((d) => d.oldValue !== d.newValue) // Skip no-ops .map((d) => ({ ...d, rationale: pendingRationales.get(d.subtitleIndex)?.get(d.field) || pendingRationales.get(d.subtitleIndex)?.values().next().value || "Edit applied", })); // Build GTSDocument from current subtitles const document = { tracks: [ { subtitles: subtitles.map((s) => ({ tcIn: s.tcIn, tcOut: s.tcOut, text: s.text, speaker: s.speaker, comment: s.comment, position: s.position, style: s.style, })), }, ], }; await fetch(`/api/jobs/${jobId}/document`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ document, diffs: diffs.length > 0 ? diffs : undefined, resourceId, editSessionId, }), }); setLastSavedAt(new Date()); setHasUnsavedChanges(false); // Keep rationales visible — they persist until job is submitted/locked // The diffs already captured them server-side } catch (err) { console.error("Save failed:", err); } finally { setIsSaving(false); } }, [ eventBus, subtitles, jobId, resourceId, editSessionId, pendingRationales, ]); // Auto-save useEffect(() => { if (hasUnsavedChanges) { clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(saveNow, autoSaveDelayMs); } return () => clearTimeout(saveTimerRef.current); }, [hasUnsavedChanges, saveNow, autoSaveDelayMs]); // Edit operations const setText = useCallback( (index: number, value: string) => eventBus.dispatch({ type: "setText", target: index, payload: { value } }), [eventBus] ); const setTimecodeIn = useCallback( (index: number, value: string) => eventBus.dispatch({ type: "setTimecodeIn", target: index, payload: { value }, }), [eventBus] ); const setTimecodeOut = useCallback( (index: number, value: string) => eventBus.dispatch({ type: "setTimecodeOut", target: index, payload: { value }, }), [eventBus] ); const setSpeaker = useCallback( (index: number, value: string) => eventBus.dispatch({ type: "setSpeaker", target: index, payload: { value }, }), [eventBus] ); const setComment = useCallback( (index: number, value: string) => eventBus.dispatch({ type: "setComment", target: index, payload: { value }, }), [eventBus] ); const insertSubtitle = useCallback( (atIndex: number) => eventBus.dispatch({ type: "insertSubtitle", target: atIndex, payload: {}, }), [eventBus] ); const deleteSubtitle = useCallback( (index: number) => eventBus.dispatch({ type: "deleteSubtitle", target: index, payload: {}, }), [eventBus] ); const splitSubtitle = useCallback( (index: number, splitPoint: number) => eventBus.dispatch({ type: "splitSubtitle", target: index, payload: { splitPoint }, }), [eventBus] ); const mergeWithNext = useCallback( (index: number) => { const next = eventBus.getSubtitle(index + 1); eventBus.dispatch({ type: "mergeSubtitles", target: index, payload: { mergedSubtitle: next }, }); }, [eventBus] ); const undo = useCallback(() => eventBus.undo(), [eventBus]); const redo = useCallback(() => eventBus.redo(), [eventBus]); const setRationale = useCallback( (index: number, field: string, rationale: string) => { setPendingRationales((prev) => { const next = new Map(prev); const fieldMap = next.get(index) || new Map(); fieldMap.set(field, rationale); next.set(index, fieldMap); return next; }); }, [] ); return { subtitles, selectedIndex, setSelectedIndex, setText, setTimecodeIn, setTimecodeOut, setSpeaker, setComment, insertSubtitle, deleteSubtitle, splitSubtitle, mergeWithNext, undo, redo, canUndo, canRedo, validationIssues, totalErrors, totalWarnings, hasUnsavedChanges, isSaving, lastSavedAt, saveNow, pendingRationales, setRationale, editedIndices, editedIndicesWithoutRationale, canValidate, validationState, setValidationState, eventBus, }; }