Spaces:
Sleeping
Sleeping
| "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<number, ValidationIssue[]>; | |
| totalErrors: number; | |
| totalWarnings: number; | |
| // Save state | |
| hasUnsavedChanges: boolean; | |
| isSaving: boolean; | |
| lastSavedAt: Date | null; | |
| saveNow: () => Promise<void>; | |
| // Rationale tracking (for diff capture) | |
| pendingRationales: Map<number, Map<string, string>>; | |
| setRationale: (index: number, field: string, rationale: string) => void; | |
| // Completion workflow | |
| editedIndices: Set<number>; | |
| editedIndicesWithoutRationale: Set<number>; | |
| 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<EditorEventBus>( | |
| new EditorEventBus(initialSubtitles) | |
| ); | |
| const eventBus = eventBusRef.current; | |
| const [subtitles, setSubtitles] = useState<SubtitleData[]>( | |
| () => 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<Date | null>(null); | |
| const [pendingRationales, setPendingRationales] = useState< | |
| Map<number, Map<string, string>> | |
| >(new Map()); | |
| const initialSubtitlesRef = useRef<SubtitleData[]>(structuredClone(initialSubtitles)); | |
| const [validationState, setValidationState] = useState<ValidationState>("none"); | |
| const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(); | |
| // 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<number>(); | |
| 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<number, ValidationIssue[]>(); | |
| 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<number>(); | |
| 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<string, string> = { | |
| setText: "text", | |
| setTimecodeIn: "tc_in", | |
| setTimecodeOut: "tc_out", | |
| setSpeaker: "speaker", | |
| setComment: "comment", | |
| setPosition: "position", | |
| setStyle: "style", | |
| }; | |
| const categoryMap: Record<string, string> = { | |
| 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<string, string>(); | |
| 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, | |
| }; | |
| } | |