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
@@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: transparent;
}
.loader {
@@ -10,12 +12,27 @@
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
color: variables.$text-tertiary;
@include typography.font-caption-m;
}
.spinner {
animation: spin 1s linear infinite;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -28,126 +45,168 @@
font-size: 14px;
}
.header {
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
gap: 16px;
padding: 16px 18px 14px;
border-bottom: 1px solid variables.$border-default;
background: variables.$bg-default;
flex-shrink: 0;
@media (max-width: 720px) {
flex-direction: column;
align-items: stretch;
}
}
.title {
font-size: 14px;
font-weight: 600;
color: variables.$text-primary;
.toolbarMeta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.toolbarTitle {
margin: 0;
color: variables.$text-primary;
@include typography.font-body-16(600);
}
.saveButton {
display: inline-flex;
.toolbarSummary {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: variables.$radius-sm;
border: none;
background: variables.$color-primary;
color: variables.$color-white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
flex-wrap: wrap;
gap: 10px;
}
&:hover {
opacity: 0.9;
}
.segmentCount {
color: variables.$text-secondary;
@include typography.font-body-14(500);
}
&.disabled {
background: variables.$border-default;
color: variables.$text-tertiary;
cursor: default;
pointer-events: none;
}
.headerStatus {
margin: 0;
color: variables.$text-tertiary;
@include typography.font-caption-m;
}
.segmentsList {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
padding: 14px 18px 18px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.segment {
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
padding: 12px 16px;
background: variables.$bg-surface;
transition: all 0.3s ease;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
border: 1px solid variables.$border-default;
border-radius: 10px;
padding: 12px;
background: variables.$bg-default;
transition: border-color 0.15s ease, background 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: variables.$border-default;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
background: variables.$bg-surface;
}
&.highlight {
border-color: variables.$color-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
box-shadow: var(--focus-ring);
}
@media (max-width: 720px) {
grid-template-columns: 1fr;
}
}
.segmentTimes {
.segmentNumber {
display: inline-flex;
align-items: flex-start;
justify-content: center;
padding-top: 7px;
color: variables.$text-tertiary;
@include typography.font-caption-m;
@include typography.font-numeric;
}
.segmentMain {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.segmentMetaRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
flex-wrap: wrap;
}
.timesGroup {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
gap: 8px;
flex: 1;
min-width: 0;
}
.actionsGroup {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.timeLabel {
.timeField {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
background: variables.$bg-surface;
border: 1px solid variables.$border-default;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:focus-within {
background: variables.$bg-default;
border-color: variables.$color-primary;
box-shadow: var(--focus-ring);
}
}
.timeLabelText {
font-size: 11px;
color: variables.$text-tertiary;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: variables.$text-secondary;
white-space: nowrap;
@include typography.font-caption-m;
}
.timeInput {
width: 84px;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 12px;
width: 96px;
padding: 0;
border: none;
border-radius: 0;
font-family: monospace;
color: variables.$text-secondary;
background: variables.$bg-hover;
transition: all 0.2s ease;
text-align: center;
color: variables.$text-primary;
background: transparent;
transition: color 0.2s ease;
text-align: left;
@include typography.font-caption-m;
@include typography.font-numeric;
&:focus {
outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary;
color: variables.$text-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1);
}
}
@@ -155,19 +214,23 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
background: transparent;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid variables.$border-default;
background: variables.$bg-default;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
transition: all 0.2s ease;
border-radius: 8px;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
}
.splitButton {
&:hover:not(:disabled) {
color: variables.$color-primary;
background: variables.$bg-hover;
background: variables.$bg-surface;
border-color: variables.$border-default;
}
&:disabled {
@@ -179,22 +242,23 @@
.removeButton {
&:hover {
color: variables.$color-danger;
background: rgba(239, 68, 68, 0.1);
background: variables.$bg-surface;
border-color: variables.$border-default;
}
}
.textArea {
width: 100%;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 14px;
line-height: 1.5;
min-height: 96px;
padding: 12px 14px;
border: 1px solid variables.$border-default;
border-radius: 8px;
color: variables.$text-primary;
background: variables.$bg-hover;
background: variables.$bg-surface;
resize: vertical;
font-family: inherit;
transition: all 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
@include typography.font-body-14(400);
&:hover {
background: variables.$bg-hover;
@@ -202,29 +266,41 @@
&:focus {
outline: none;
background: variables.$bg-surface;
background: variables.$bg-default;
border-color: variables.$color-primary;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
box-shadow: var(--focus-ring);
}
&::placeholder {
color: variables.$text-tertiary;
}
}
.addButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
margin: 0 16px 12px;
border: 1px dashed variables.$border-default;
border-radius: variables.$radius-md;
background: none;
gap: 8px;
justify-content: center;
padding: 10px 12px;
border: 1px solid variables.$border-default;
border-radius: 8px;
background: variables.$bg-default;
color: variables.$text-secondary;
font-size: 13px;
@include typography.font-body-14(500);
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
background: variables.$bg-hover;
border-color: variables.$color-primary;
background: variables.$bg-surface;
border-color: variables.$border-default;
color: variables.$color-primary;
box-shadow: var(--shadow-sm);
}
@media (max-width: 720px) {
width: 100%;
}
}
@@ -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>
)
}