Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions frontend/src/components/PasteTranscriptDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { useEditorStore } from '../store/editorStore';
import { diffTranscript, groupContiguousIndices } from '../utils/diffTranscript';
import { buildDeletedSet } from '../utils/buildDeletedSet';

interface Props {
onClose: () => void;
}

interface Preview {
deletedCount: number;
rangeCount: number;
totalActive: number;
}

export default function PasteTranscriptDialog({ onClose }: Props) {
const words = useEditorStore((s) => s.words);
const deletedRanges = useEditorStore((s) => s.deletedRanges);
const applyPastedTranscript = useEditorStore((s) => s.applyPastedTranscript);

const [pastedText, setPastedText] = useState('');
const [preview, setPreview] = useState<Preview | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);

if (pastedText.trim() === '') {
setPreview(null);
return;
}

debounceRef.current = setTimeout(() => {
const alreadyDeletedSet = buildDeletedSet(deletedRanges);
const totalActive = words.length - alreadyDeletedSet.size;
const deletedIndices = diffTranscript(words, pastedText, alreadyDeletedSet);
const rangeCount = groupContiguousIndices(deletedIndices).length;
setPreview({ deletedCount: deletedIndices.length, rangeCount, totalActive });
}, 300);

return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [pastedText, words, deletedRanges]);

const handleApply = () => {
applyPastedTranscript(pastedText);
onClose();
};

const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};

const allDeleted = preview !== null && preview.deletedCount === preview.totalActive;
const noChanges = preview !== null && preview.deletedCount === 0;
const percent = preview !== null && preview.totalActive > 0
? Math.round((preview.deletedCount / preview.totalActive) * 100)
: 0;

return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={handleBackdropClick}
>
<div className="bg-editor-surface border border-editor-border rounded-lg w-full max-w-lg shadow-xl flex flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-editor-text">Paste Edited Transcript</h2>
<button
onClick={onClose}
className="text-editor-text-muted hover:text-editor-text transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>

<p className="text-xs text-editor-text-muted">
Paste your edited transcript below. Deleted words will be cut automatically.
Modified words are ignored — only deletions are detected.
</p>

<textarea
className="w-full h-48 bg-editor-bg border border-editor-border rounded p-3 text-sm text-editor-text placeholder:text-editor-text-muted resize-none focus:outline-none focus:border-editor-accent font-mono"
placeholder="Paste your edited transcript here..."
value={pastedText}
onChange={(e) => setPastedText(e.target.value)}
autoFocus
/>

{preview && (
<div
className={`text-xs rounded p-3 border ${
allDeleted
? 'bg-editor-danger/10 border-editor-danger/30 text-editor-danger'
: 'bg-editor-bg border-editor-border text-editor-text-muted'
}`}
>
{allDeleted ? (
<span>Warning: this would delete all remaining words in the transcript.</span>
) : noChanges ? (
<span>No changes detected — the pasted text matches the current transcript.</span>
) : (
<span>
<span className="text-editor-text font-medium">{preview.deletedCount}</span> words
will be cut ({percent}% of transcript) &middot;{' '}
<span className="text-editor-text font-medium">{preview.rangeCount}</span> cut
{preview.rangeCount !== 1 ? 's' : ''} will be created
</span>
)}
</div>
)}

<div className="flex items-center justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-xs text-editor-text-muted hover:text-editor-text border border-editor-border rounded hover:bg-editor-bg transition-colors"
>
Cancel
</button>
<button
onClick={handleApply}
disabled={!preview || noChanges || allDeleted}
className="px-3 py-1.5 text-xs bg-editor-accent text-white rounded hover:bg-editor-accent/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Apply Cuts
</button>
</div>
</div>
</div>
);
}
49 changes: 41 additions & 8 deletions frontend/src/components/TranscriptEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Virtuoso } from 'react-virtuoso';
import { Trash2, RotateCcw } from 'lucide-react';
import { Trash2, RotateCcw, Copy, ClipboardPaste } from 'lucide-react';
import PasteTranscriptDialog from './PasteTranscriptDialog';
import { buildDeletedSet } from '../utils/buildDeletedSet';

export default function TranscriptEditor() {
const words = useEditorStore((s) => s.words);
Expand All @@ -19,17 +21,19 @@ export default function TranscriptEditor() {
const wasDragging = useRef(false);
const virtuosoRef = useRef<any>(null);

const deletedSet = useMemo(() => {
const s = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) s.add(idx);
}
return s;
}, [deletedRanges]);
const deletedSet = useMemo(() => buildDeletedSet(deletedRanges), [deletedRanges]);

const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);

const [activeWordIndex, setActiveWordIndex] = useState(-1);
const [copied, setCopied] = useState(false);
const [showPasteDialog, setShowPasteDialog] = useState(false);

useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);

useEffect(() => {
if (words.length === 0) return;
Expand Down Expand Up @@ -168,12 +172,38 @@ export default function TranscriptEditor() {
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, restoreRange],
);

const handleCopy = useCallback(async () => {
const text = useEditorStore.getState().getTranscriptText();
await navigator.clipboard.writeText(text);
setCopied(true);
}, []);

return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
<span className="text-xs text-editor-text-muted flex-1">
{words.length} words &middot; {deletedRanges.length} cuts
</span>
{words.length > 0 && (
<>
<button
onClick={handleCopy}
title="Copy transcript"
className="flex items-center gap-1 px-2 py-1 text-xs text-editor-text-muted hover:text-editor-text border border-editor-border rounded hover:bg-editor-bg transition-colors"
>
<Copy className="w-3 h-3" />
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={() => setShowPasteDialog(true)}
title="Paste edited transcript"
className="flex items-center gap-1 px-2 py-1 text-xs text-editor-text-muted hover:text-editor-text border border-editor-border rounded hover:bg-editor-bg transition-colors"
>
<ClipboardPaste className="w-3 h-3" />
Paste edits
</button>
</>
)}
{selectedWordIndices.length > 0 && (
<button
onClick={deleteSelectedWords}
Expand All @@ -199,6 +229,9 @@ export default function TranscriptEditor() {
style={{ height: '100%' }}
/>
</div>
{showPasteDialog && (
<PasteTranscriptDialog onClose={() => setShowPasteDialog(false)} />
)}
</div>
);
}
53 changes: 49 additions & 4 deletions frontend/src/store/editorStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { temporal } from 'zundo';
import type { Word, Segment, DeletedRange, TranscriptionResult } from '../types/project';
import { diffTranscript, groupContiguousIndices } from '../utils/diffTranscript';
import { buildDeletedSet } from '../utils/buildDeletedSet';

interface EditorState {
videoPath: string | null;
Expand Down Expand Up @@ -41,6 +43,8 @@ interface EditorActions {
setExporting: (active: boolean, progress?: number) => void;
getKeepSegments: () => Array<{ start: number; end: number }>;
getWordAtTime: (time: number) => number;
getTranscriptText: () => string;
applyPastedTranscript: (pastedText: string) => { deletedCount: number; rangeCount: number };
loadProject: (projectData: any) => void;
reset: () => void;
}
Expand Down Expand Up @@ -163,10 +167,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
const { words, deletedRanges, duration } = get();
if (words.length === 0) return [{ start: 0, end: duration }];

const deletedSet = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) deletedSet.add(idx);
}
const deletedSet = buildDeletedSet(deletedRanges);

const segments: Array<{ start: number; end: number }> = [];
let segStart: number | null = null;
Expand Down Expand Up @@ -202,6 +203,50 @@ export const useEditorStore = create<EditorState & EditorActions>()(
return lo < words.length ? lo : words.length - 1;
},

getTranscriptText: () => {
const { segments, deletedRanges } = get();
const deletedSet = buildDeletedSet(deletedRanges);

const lines: string[] = [];
for (const segment of segments) {
const wordParts: string[] = [];
segment.words.forEach((word, localIdx) => {
const globalIdx = (segment.globalStartIndex ?? 0) + localIdx;
if (!deletedSet.has(globalIdx)) {
wordParts.push(word.word.trim());
}
});
if (wordParts.length === 0) continue;
const line = segment.speaker
? `${segment.speaker}: ${wordParts.join(' ')}`
: wordParts.join(' ');
lines.push(line);
}
return lines.join('\n');
},

applyPastedTranscript: (pastedText) => {
const { words, deletedRanges } = get();

const deletedIndices = diffTranscript(words, pastedText, buildDeletedSet(deletedRanges));

if (deletedIndices.length === 0) {
return { deletedCount: 0, rangeCount: 0 };
}

const groups = groupContiguousIndices(deletedIndices);
const newRanges: DeletedRange[] = groups.map((group) => ({
id: `dr_${nextRangeId++}`,
start: words[group[0]].start,
end: words[group[group.length - 1]].end,
wordIndices: group,
}));

set({ deletedRanges: [...deletedRanges, ...newRanges] });

return { deletedCount: deletedIndices.length, rangeCount: newRanges.length };
},

loadProject: (data) => {
const backend = get().backendUrl;
const url = `${backend}/file?path=${encodeURIComponent(data.videoPath)}`;
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/utils/buildDeletedSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { DeletedRange } from '../types/project';

export function buildDeletedSet(deletedRanges: DeletedRange[]): Set<number> {
const set = new Set<number>();
for (const range of deletedRanges) {
for (const idx of range.wordIndices) set.add(idx);
}
return set;
}
Loading