Files
main_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx
T
Daniil 46f34bdcac rev 4
2026-04-07 13:42:23 +03:00

356 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLDivElement>(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<SegmentRow[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [splittingRowId, setSplittingRowId] = useState<string | null>(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<HTMLElement>(
`[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 (
<div
className={styles.root}
data-testid="TranscriptionEditor"
aria-busy="true"
>
<div className={styles.loader} role="status" aria-live="polite">
<LoaderCircle size={24} className={styles.spinner} />
<span className={styles.srOnly}>Загружаем транскрипцию</span>
</div>
</div>
)
}
/* No transcription found */
if (!transcription) {
return (
<div className={styles.root} data-testid="TranscriptionEditor">
<p className={styles.empty}>Транскрипция не найдена</p>
</div>
)
}
return (
<div
className={styles.root}
data-testid="TranscriptionEditor"
aria-busy={saving ? "true" : "false"}
>
<div className={styles.toolbar}>
<div className={styles.toolbarMeta}>
<p className={styles.toolbarTitle}>Сегменты</p>
<div className={styles.toolbarSummary}>
<span className={styles.segmentCount}>
{segmentRows.length}{" "}
{declOfNum(segmentRows.length, [
"сегмент",
"сегмента",
"сегментов",
])}
</span>
{visibleStatus ? (
<p className={styles.headerStatus}>{visibleStatus}</p>
) : null}
</div>
</div>
<button className={styles.addButton} onClick={addSegment} type="button">
<Plus size={16} />
<span>Добавить сегмент</span>
</button>
<span
className={styles.srOnly}
role="status"
aria-live="polite"
aria-atomic="true"
>
{visibleStatus ?? ""}
</span>
</div>
<div className={styles.segmentsList} ref={segmentsListRef}>
{segmentRows.map((row, idx) => {
const textareaId = `${row.id}-text`
const splitDisabled = !row.segment.words || row.segment.words.length < 2
return (
<div
key={row.id}
className={styles.segment}
data-segment-index={idx}
>
<div className={styles.segmentNumber} aria-hidden="true">
{String(idx + 1).padStart(2, "0")}
</div>
<div className={styles.segmentMain}>
<div className={styles.segmentMetaRow}>
<div className={styles.timesGroup}>
<label className={styles.timeField}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={row.segment.startTime}
onChange={(e) =>
updateSegment(row.id, "startTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeField}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={row.segment.endTime}
onChange={(e) =>
updateSegment(row.id, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
</div>
<div className={styles.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingRowId(row.id)}
aria-label={
splitDisabled
? `Разделить сегмент ${idx + 1}: недоступно без разбивки по словам`
: `Разделить сегмент ${idx + 1}`
}
title={
splitDisabled
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={splitDisabled}
type="button"
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(row.id)}
aria-label={`Удалить сегмент ${idx + 1}`}
title="Удалить сегмент"
type="button"
>
<Trash2 size={14} />
</button>
</div>
</div>
<label className={styles.srOnly} htmlFor={textareaId}>
Текст сегмента {idx + 1}
</label>
{splittingRowId === row.id ? (
<SegmentSplitter
segment={row.segment}
onSplit={(newSegs) => handleSplit(row.id, newSegs)}
onCancel={() => setSplittingRowId(null)}
/>
) : (
<textarea
id={textareaId}
className={styles.textArea}
value={row.segment.text}
onChange={(e) => updateSegment(row.id, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div>
</div>
)
})}
</div>
</div>
)
}