rev 4
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user