This commit is contained in:
Daniil
2026-04-07 13:42:23 +03:00
parent d648678c68
commit 46f34bdcac
59 changed files with 2708 additions and 1312 deletions
@@ -10,6 +10,7 @@ import { FunctionComponent, useCallback, useEffect, useRef, useState } from "rea
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,
@@ -23,12 +24,18 @@ 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",
@@ -36,22 +43,41 @@ export const TranscriptionEditor: FunctionComponent<
{ params: { path: { artifact_id: artifactId } } },
)
const [segments, setSegments] = useState<EditorSegment[]>([])
const [segmentRows, setSegmentRows] = useState<SegmentRow[]>([])
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [splittingIdx, setSplittingIdx] = useState<number | null>(null)
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) {
setSegments(documentToSegments(transcription.document))
rowIdRef.current = 0
setSegmentRows(
documentToSegments(transcription.document).map((segment) =>
createSegmentRow(segment),
),
)
setDirty(false)
setSplittingRowId(null)
}
}, [transcription])
}, [transcription, createSegmentRow])
// Scroll to segment when navigated from SubtitlesTrack
useEffect(() => {
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
if (segments.length === 0) return
if (segmentRows.length === 0) return
const targetIdx = selectedFile.scrollToSegmentIndex
const container = segmentsListRef.current
@@ -76,16 +102,16 @@ export const TranscriptionEditor: FunctionComponent<
source: selectedFile.source,
artifactType: selectedFile.artifactType,
})
}, [selectedFile?.scrollToSegmentIndex, segments.length])
}, [selectedFile?.scrollToSegmentIndex, segmentRows.length, setSelectedFile])
const updateSegment = useCallback(
(idx: number, field: keyof EditorSegment, value: string) => {
setSegments((prev) =>
prev.map((seg, i) => {
if (i !== idx) return seg
const updated = { ...seg, [field]: value }
(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 updated
return { ...row, segment: updated }
}),
)
setDirty(true)
@@ -94,30 +120,38 @@ export const TranscriptionEditor: FunctionComponent<
)
const handleSplit = useCallback(
(idx: number, newSegments: EditorSegment[]) => {
setSegments((prev) => [
...prev.slice(0, idx),
...newSegments,
...prev.slice(idx + 1),
])
setSplittingIdx(null)
(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(() => {
const lastEnd =
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000"
setSegments((prev) => [
...prev,
{ startTime: lastEnd, endTime: lastEnd, text: "" },
])
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)
}, [segments])
}, [createSegmentRow])
const removeSegment = useCallback((idx: number) => {
setSegments((prev) => prev.filter((_, i) => i !== idx))
const removeSegment = useCallback((rowId: string) => {
setSegmentRows((prev) => prev.filter((row) => row.id !== rowId))
setSplittingRowId((prev) => (prev === rowId ? null : prev))
setDirty(true)
}, [])
@@ -129,7 +163,11 @@ export const TranscriptionEditor: FunctionComponent<
"/api/transcribe/transcriptions/{transcription_id}/",
{
params: { path: { transcription_id: transcription.id } },
body: { document: segmentsToDocument(segments) },
body: {
document: segmentsToDocument(
segmentRows.map((row) => row.segment),
),
},
},
)
setDirty(false)
@@ -143,7 +181,7 @@ export const TranscriptionEditor: FunctionComponent<
} finally {
setSaving(false)
}
}, [transcription, segments, artifactId, queryClient])
}, [transcription, segmentRows, artifactId, queryClient])
// Auto-save when dirty (debounced)
useEffect(() => {
@@ -157,9 +195,14 @@ export const TranscriptionEditor: FunctionComponent<
/* Loading */
if (isLoading) {
return (
<div className={styles.root} data-testid="TranscriptionEditor">
<div className={styles.loader}>
<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>
)
@@ -175,89 +218,138 @@ export const TranscriptionEditor: FunctionComponent<
}
return (
<div className={styles.root} data-testid="TranscriptionEditor">
{/* Header */}
<div className={styles.header}>
<h3 className={styles.title}>Редактор транскрипции</h3>
<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>
{/* Segments list */}
<div className={styles.segmentsList} ref={segmentsListRef}>
{segments.map((seg, idx) => (
<div key={idx} className={styles.segment} data-segment-index={idx}>
<div className={styles.segmentTimes}>
<div className={styles.timesGroup}>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={seg.startTime}
onChange={(e) =>
updateSegment(idx, "startTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={seg.endTime}
onChange={(e) =>
updateSegment(idx, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
{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.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingIdx(idx)}
title={
!seg.words || seg.words.length < 2
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={!seg.words || seg.words.length < 2}
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(idx)}
title="Удалить сегмент"
>
<Trash2 size={14} />
</button>
<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>
{splittingIdx === idx ? (
<SegmentSplitter
segment={seg}
onSplit={(newSegs) => handleSplit(idx, newSegs)}
onCancel={() => setSplittingIdx(null)}
/>
) : (
<textarea
className={styles.textArea}
value={seg.text}
onChange={(e) => updateSegment(idx, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div>
))}
)
})}
</div>
{/* Add segment */}
<button className={styles.addButton} onClick={addSegment}>
<Plus size={16} />
<span>Добавить сегмент</span>
</button>
</div>
)
}