"use client" import type { ITranscriptionEditorProps } from "./TranscriptionEditor.d" import type { JSX } from "react" import { useQueryClient } from "@tanstack/react-query" import { LoaderCircle, Plus, Scissors, Trash2 } from "lucide-react" import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react" import api from "@shared/api" import { fetchClient } from "@shared/api" import { useWorkspaceFiles } from "@shared/context/WorkspaceContext" import { declOfNum } from "@shared/lib/russianNoun" import { type EditorSegment, documentToSegments, segmentsToDocument, } from "@shared/lib/transcriptionDocument" import { SegmentSplitter } from "@features/project/SegmentSplitter" import styles from "./TranscriptionEditor.module.scss" /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ type SegmentRow = { id: string segment: EditorSegment } export const TranscriptionEditor: FunctionComponent< ITranscriptionEditorProps > = ({ artifactId }): JSX.Element => { const queryClient = useQueryClient() const { selectedFile, setSelectedFile } = useWorkspaceFiles() const segmentsListRef = useRef(null) const rowIdRef = useRef(0) const { data: transcription, isLoading } = api.useQuery( "get", "/api/transcribe/transcriptions/by-artifact/{artifact_id}/", { params: { path: { artifact_id: artifactId } } }, ) const [segmentRows, setSegmentRows] = useState([]) const [saving, setSaving] = useState(false) const [dirty, setDirty] = useState(false) const [splittingRowId, setSplittingRowId] = useState(null) const visibleStatus = saving ? "Сохраняем изменения" : dirty ? "Изменения будут сохранены автоматически" : null const createSegmentRow = useCallback( (segment: EditorSegment): SegmentRow => ({ id: `segment-row-${rowIdRef.current++}`, segment, }), [], ) useEffect(() => { if (transcription?.document) { rowIdRef.current = 0 setSegmentRows( documentToSegments(transcription.document).map((segment) => createSegmentRow(segment), ), ) setDirty(false) setSplittingRowId(null) } }, [transcription, createSegmentRow]) // Scroll to segment when navigated from SubtitlesTrack useEffect(() => { if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return if (segmentRows.length === 0) return const targetIdx = selectedFile.scrollToSegmentIndex const container = segmentsListRef.current if (!container) return const segmentEl = container.querySelector( `[data-segment-index="${targetIdx}"]`, ) if (!segmentEl) return // Brief delay to let the DOM settle requestAnimationFrame(() => { segmentEl.scrollIntoView({ behavior: "smooth", block: "center" }) segmentEl.classList.add(styles.highlight) setTimeout(() => segmentEl.classList.remove(styles.highlight), 1500) }) // Clear scrollToSegmentIndex so it doesn't re-trigger setSelectedFile({ id: selectedFile.id, path: selectedFile.path, source: selectedFile.source, artifactType: selectedFile.artifactType, }) }, [selectedFile?.scrollToSegmentIndex, segmentRows.length, setSelectedFile]) const updateSegment = useCallback( (rowId: string, field: keyof EditorSegment, value: string) => { setSegmentRows((prev) => prev.map((row) => { if (row.id !== rowId) return row const updated = { ...row.segment, [field]: value } if (field === "text") updated.words = undefined return { ...row, segment: updated } }), ) setDirty(true) }, [], ) const handleSplit = useCallback( (rowId: string, newSegments: EditorSegment[]) => { setSegmentRows((prev) => { const targetIndex = prev.findIndex((row) => row.id === rowId) if (targetIndex === -1) return prev const nextRows = newSegments.map((segment) => createSegmentRow(segment)) return [ ...prev.slice(0, targetIndex), ...nextRows, ...prev.slice(targetIndex + 1), ] }) setSplittingRowId(null) setDirty(true) }, [createSegmentRow], ) const addSegment = useCallback(() => { setSegmentRows((prev) => { const lastEnd = prev.length > 0 ? prev[prev.length - 1].segment.endTime : "00:00.000" return [ ...prev, createSegmentRow({ startTime: lastEnd, endTime: lastEnd, text: "" }), ] }) setDirty(true) }, [createSegmentRow]) const removeSegment = useCallback((rowId: string) => { setSegmentRows((prev) => prev.filter((row) => row.id !== rowId)) setSplittingRowId((prev) => (prev === rowId ? null : prev)) setDirty(true) }, []) const handleSave = useCallback(async () => { if (!transcription?.id) return setSaving(true) try { await fetchClient.PATCH( "/api/transcribe/transcriptions/{transcription_id}/", { params: { path: { transcription_id: transcription.id } }, body: { document: segmentsToDocument( segmentRows.map((row) => row.segment), ), }, }, ) setDirty(false) queryClient.invalidateQueries({ queryKey: api.queryOptions( "get", "/api/transcribe/transcriptions/by-artifact/{artifact_id}/", { params: { path: { artifact_id: artifactId } } }, ).queryKey, }) } finally { setSaving(false) } }, [transcription, segmentRows, artifactId, queryClient]) // Auto-save when dirty (debounced) useEffect(() => { if (!dirty) return const timer = setTimeout(() => { handleSave() }, 1500) return () => clearTimeout(timer) }, [dirty, handleSave]) /* Loading */ if (isLoading) { return (
Загружаем транскрипцию
) } /* No transcription found */ if (!transcription) { return (

Транскрипция не найдена

) } return (

Сегменты

{segmentRows.length}{" "} {declOfNum(segmentRows.length, [ "сегмент", "сегмента", "сегментов", ])} {visibleStatus ? (

{visibleStatus}

) : null}
{visibleStatus ?? ""}
{segmentRows.map((row, idx) => { const textareaId = `${row.id}-text` const splitDisabled = !row.segment.words || row.segment.words.length < 2 return (
{splittingRowId === row.id ? ( handleSplit(row.id, newSegs)} onCancel={() => setSplittingRowId(null)} /> ) : (