356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
"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>
|
||
)
|
||
}
|