new features
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export interface ITranscriptionEditorProps {
|
||||
artifactId: string
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: variables.$text-tertiary;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid variables.$border-default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: variables.$text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
display: inline-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;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: variables.$border-default;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.segmentsList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
padding: 10px 12px;
|
||||
background: variables.$bg-surface;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
&.highlight {
|
||||
border-color: variables.$color-primary;
|
||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.segmentTimes {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.timeLabelText {
|
||||
font-size: 11px;
|
||||
color: variables.$text-tertiary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeInput {
|
||||
width: 100px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
color: variables.$text-primary;
|
||||
background: variables.$bg-default;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: variables.$color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.splitButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: pointer;
|
||||
border-radius: variables.$radius-sm;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: variables.$color-primary;
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: pointer;
|
||||
border-radius: variables.$radius-sm;
|
||||
|
||||
&:hover {
|
||||
color: variables.$color-danger;
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: variables.$text-primary;
|
||||
background: variables.$bg-default;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: variables.$color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
color: variables.$text-secondary;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
border-color: variables.$color-primary;
|
||||
color: variables.$color-primary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import type { ITranscriptionEditorProps } from "./TranscriptionEditor.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import cs from "classnames"
|
||||
import { LoaderCircle, Plus, Save, 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 {
|
||||
type EditorSegment,
|
||||
documentToSegments,
|
||||
segmentsToDocument,
|
||||
} from "@shared/lib/transcriptionDocument"
|
||||
import { SegmentSplitter } from "@features/project/SegmentSplitter"
|
||||
|
||||
import styles from "./TranscriptionEditor.module.scss"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const TranscriptionEditor: FunctionComponent<
|
||||
ITranscriptionEditorProps
|
||||
> = ({ artifactId }): JSX.Element => {
|
||||
const queryClient = useQueryClient()
|
||||
const { selectedFile, setSelectedFile } = useWorkspaceFiles()
|
||||
const segmentsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: transcription, isLoading } = api.useQuery(
|
||||
"get",
|
||||
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
|
||||
{ params: { path: { artifact_id: artifactId } } },
|
||||
)
|
||||
|
||||
const [segments, setSegments] = useState<EditorSegment[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [splittingIdx, setSplittingIdx] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (transcription?.document) {
|
||||
setSegments(documentToSegments(transcription.document))
|
||||
setDirty(false)
|
||||
}
|
||||
}, [transcription])
|
||||
|
||||
// Scroll to segment when navigated from SubtitlesTrack
|
||||
useEffect(() => {
|
||||
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
|
||||
if (segments.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, segments.length])
|
||||
|
||||
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 }
|
||||
if (field === "text") updated.words = undefined
|
||||
return updated
|
||||
}),
|
||||
)
|
||||
setDirty(true)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleSplit = useCallback(
|
||||
(idx: number, newSegments: EditorSegment[]) => {
|
||||
setSegments((prev) => [
|
||||
...prev.slice(0, idx),
|
||||
...newSegments,
|
||||
...prev.slice(idx + 1),
|
||||
])
|
||||
setSplittingIdx(null)
|
||||
setDirty(true)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const addSegment = useCallback(() => {
|
||||
const lastEnd =
|
||||
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000"
|
||||
setSegments((prev) => [
|
||||
...prev,
|
||||
{ startTime: lastEnd, endTime: lastEnd, text: "" },
|
||||
])
|
||||
setDirty(true)
|
||||
}, [segments])
|
||||
|
||||
const removeSegment = useCallback((idx: number) => {
|
||||
setSegments((prev) => prev.filter((_, i) => i !== idx))
|
||||
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(segments) },
|
||||
},
|
||||
)
|
||||
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, segments, artifactId, queryClient])
|
||||
|
||||
/* Loading */
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.root} data-testid="TranscriptionEditor">
|
||||
<div className={styles.loader}>
|
||||
<LoaderCircle size={24} className={styles.spinner} />
|
||||
</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">
|
||||
{/* Header */}
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>Редактор транскрипции</h3>
|
||||
<button
|
||||
className={cs(styles.saveButton, { [styles.disabled]: !dirty })}
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle size={16} className={styles.spinner} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
<span>Сохранить</span>
|
||||
</button>
|
||||
</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}>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TranscriptionEditor"
|
||||
Reference in New Issue
Block a user