From 32a165f5f997a468077d31f0e1c6df73253fff52 Mon Sep 17 00:00:00 2001 From: Randy Lutcavich Date: Sun, 12 Apr 2026 20:30:27 -0500 Subject: [PATCH] feat: add copy/paste transcript editing with LCS diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bulk transcript editing: copy the transcript, edit it externally, and paste it back to apply all deletions at once. Workflow: 1. Click "Copy" in the transcript toolbar to copy all non-deleted words 2. Edit the text in any external editor (delete unwanted words) 3. Click "Paste edits" to open a diff preview dialog 4. Review the preview (word count, cut count, warnings) and click "Apply" The diff algorithm uses Longest Common Subsequence (LCS) to detect only deletions — modified words are ignored. Uses Uint32Array for the DP table to support transcripts beyond 65k words. The diff computation is debounced to 300ms to avoid running on every keystroke. Also extracts buildDeletedSet() utility to eliminate duplicate set- building loops across components. --- .../src/components/PasteTranscriptDialog.tsx | 132 ++++++++++++++++++ frontend/src/components/TranscriptEditor.tsx | 49 +++++-- frontend/src/store/editorStore.ts | 53 ++++++- frontend/src/utils/buildDeletedSet.ts | 9 ++ frontend/src/utils/diffTranscript.ts | 99 +++++++++++++ 5 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/PasteTranscriptDialog.tsx create mode 100644 frontend/src/utils/buildDeletedSet.ts create mode 100644 frontend/src/utils/diffTranscript.ts diff --git a/frontend/src/components/PasteTranscriptDialog.tsx b/frontend/src/components/PasteTranscriptDialog.tsx new file mode 100644 index 0000000..6619836 --- /dev/null +++ b/frontend/src/components/PasteTranscriptDialog.tsx @@ -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(null); + const debounceRef = useRef | 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 ( +
+
+
+

Paste Edited Transcript

+ +
+ +

+ Paste your edited transcript below. Deleted words will be cut automatically. + Modified words are ignored — only deletions are detected. +

+ +