diff --git a/components/Editor.tsx b/components/Editor.tsx
index ea57f58..e119524 100644
--- a/components/Editor.tsx
+++ b/components/Editor.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
import { useEditorStore } from "@/lib/store";
+import { getCutRanges, isWordCutOut } from "@/lib/edits";
import { extractAudio, getFFmpeg } from "@/lib/ffmpeg";
import { VAD_SAMPLE_RATE } from "@/lib/vad";
import { isElectron } from "@/lib/platform";
@@ -176,7 +177,8 @@ export default function Editor() {
return () => window.clearTimeout(timer);
}, [status]);
- // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split.
+ // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split,
+ // Delete/Backspace = delete selected clip or restore selected cut / cut words.
// Capture phase so Space is ours before a focused
+
+
+
+
@@ -657,6 +827,22 @@ export default function Timeline() {
);
})}
+ {/* Selected cut/silence outline */}
+ {selectedCutIndex != null && cuts[selectedCutIndex] && (
+
+ )}
+
{/* Clip trim handles (selected or hovered) */}
{clips.map((clip) => {
const active =
@@ -707,12 +893,12 @@ export default function Timeline() {
{selected && (
)}
@@ -761,13 +947,31 @@ export default function Timeline() {
e.stopPropagation();
seekTo(w.start);
const store = useEditorStore.getState();
- // Select the word (the transcript mirrors this) and the clip
- // it sits in.
+ // Select the word (the transcript mirrors this). Kept words
+ // also select their clip; cut-out words select the cut so
+ // Delete / Restore can bring them back.
store.setSelectedWords([w.id]);
- const clip = clips.find(
- (c) => w.start >= c.start && w.start < c.end
- );
- store.setSelectedClipIndex(clip?.index ?? null);
+ if (cutOut) {
+ const cut = cutRangeAt(w.start + (w.end - w.start) / 2, cuts);
+ const cutIdx = cut
+ ? cuts.findIndex(
+ (c) =>
+ Math.abs(c.start - cut.start) < 1e-4 &&
+ Math.abs(c.end - cut.end) < 1e-4
+ )
+ : -1;
+ if (cutIdx >= 0) store.setSelectedCutIndex(cutIdx);
+ else {
+ store.setSelectedCutIndex(null);
+ store.setSelectedClipIndex(null);
+ }
+ } else {
+ const clip = clips.find(
+ (c) => w.start >= c.start && w.start < c.end
+ );
+ store.setSelectedClipIndex(clip?.index ?? null);
+ store.setSelectedCutIndex(null);
+ }
}}
>
diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx
index afa0c2e..2a8e05c 100644
--- a/components/TranscriptPanel.tsx
+++ b/components/TranscriptPanel.tsx
@@ -284,22 +284,21 @@ export default function TranscriptPanel() {
return () => document.removeEventListener("mousedown", handler);
}, [correcting, closeCorrect]);
- // Delete / Backspace cuts the selected words. Driven by the shared selection so
- // it works for words picked in the timeline wordbar too.
+ // Escape clears the transcript selection chrome. Delete / Backspace are handled
+ // globally in Editor (cut words restore; kept words / clips delete).
useEffect(() => {
const handler = (e: KeyboardEvent) => {
- if (e.key !== "Delete" && e.key !== "Backspace" && e.key !== "Escape") return;
+ if (e.key !== "Escape") return;
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
return;
if (selectedWordIds.length === 0) return;
e.preventDefault();
- if (e.key !== "Escape") deleteWords(selectedWordIds);
clearSelection();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
- }, [selectedWordIds, deleteWords, clearSelection]);
+ }, [selectedWordIds, clearSelection]);
// "@" opens the speaker picker for the current selection.
useEffect(() => {
diff --git a/lib/edits.ts b/lib/edits.ts
index 0397b2e..eb0b7ac 100644
--- a/lib/edits.ts
+++ b/lib/edits.ts
@@ -470,6 +470,37 @@ export function carrySceneBoundaries(
return out;
}
+/**
+ * Restore media in the given cut ranges: shrink overlapping manual cuts and
+ * undelete words fully covered by each range. Inverse of cutting a clip / silence.
+ */
+export function restoreRangesResult(
+ words: Word[],
+ manualCuts: ManualCut[],
+ ranges: TimeRange[],
+ nextCutId: number
+): { words: Word[]; manualCuts: ManualCut[]; nextCutId: number } | null {
+ const usable = ranges.filter((r) => r.end - r.start > 1e-4);
+ if (usable.length === 0) return null;
+
+ let nextWords = words;
+ let cuts = manualCuts;
+ let id = nextCutId;
+ let changed = false;
+
+ for (const r of usable) {
+ const shrunk = shrinkManualCuts(cuts, r.start, r.end, id);
+ const restored = restoreWordsCoveredBy(nextWords, r.start, r.end);
+ if (shrunk.cuts !== cuts || restored !== nextWords) changed = true;
+ cuts = shrunk.cuts;
+ id = shrunk.nextId;
+ nextWords = restored;
+ }
+
+ if (!changed) return null;
+ return { words: nextWords, manualCuts: cuts, nextCutId: id };
+}
+
/**
* Move one kept-region edge from `from` to `to`. `edge` names the side that
* stays kept ("out" = the clip left of the edge, "in" = the clip right of it),
diff --git a/lib/store.ts b/lib/store.ts
index 207a76c..6387cc5 100644
--- a/lib/store.ts
+++ b/lib/store.ts
@@ -22,6 +22,7 @@ import {
getCutRanges,
getKeepRanges,
PLAYHEAD_EPSILON_S,
+ restoreRangesResult,
shrinkManualCuts,
trimEdgeResult,
} from "./edits";
@@ -101,6 +102,11 @@ interface EditorState {
future: EditSnapshot[];
/** Selected timeline clip index, or null. */
selectedClipIndex: number | null;
+ /**
+ * Selected cut-range index (from `getCutRanges`), or null.
+ * Used to restore a deleted clip / silence section from the timeline.
+ */
+ selectedCutIndex: number | null;
/**
* Words selected in the transcript or the timeline wordbar. Shared so both
* views highlight the same selection and the same shortcuts apply.
@@ -173,6 +179,12 @@ interface EditorState {
restoreWords: (ids: number[]) => void;
/** Cut arbitrary time ranges (e.g. detected silences) as manual cuts. */
cutRanges: (ranges: TimeRange[]) => void;
+ /** Restore arbitrary cut ranges (manual cuts + covered deleted words). */
+ restoreRanges: (ranges: TimeRange[]) => void;
+ /** Cut the currently selected timeline clip out of the edited media. */
+ deleteSelectedClip: () => boolean;
+ /** Restore the currently selected cut range (deleted clip / silence). */
+ restoreSelectedCut: () => boolean;
/** Replace the selected (contiguous) words with corrected text. */
correctWords: (ids: number[], text: string) => void;
/** Nudge a word's start/end on the timeline (may steal time from neighbors). */
@@ -190,6 +202,7 @@ interface EditorState {
*/
trimEdge: (edge: "in" | "out", from: number, to: number) => void;
setSelectedClipIndex: (index: number | null) => void;
+ setSelectedCutIndex: (index: number | null) => void;
setSelectedWords: (ids: number[]) => void;
/** Start a drag gesture so subsequent edits share one undo entry. */
beginGesture: () => void;
@@ -256,6 +269,7 @@ function pushEdit(
| "manualCuts"
| "sceneBoundaries"
| "selectedClipIndex"
+ | "selectedCutIndex"
| "selectedWordIds"
| "nextManualCutId"
| "nextBoundaryId"
@@ -301,6 +315,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
nextManualCutId: 1,
nextBoundaryId: 1,
@@ -344,6 +359,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
nextManualCutId: 1,
nextBoundaryId: 1,
@@ -388,6 +404,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
nextManualCutId: maxId(manualCuts, 1),
nextBoundaryId: maxId(sceneBoundaries, 1),
@@ -442,6 +459,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
});
if (get().status === "ready") bumpAutosave();
@@ -466,6 +484,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
partialText: "",
error: null,
@@ -568,6 +587,7 @@ export const useEditorStore = create((set, get) => ({
words: words.map((w) =>
idSet.has(w.id) && !w.deleted ? { ...w, deleted: true } : w
),
+ selectedCutIndex: null,
});
},
cutRanges: (ranges) => {
@@ -583,7 +603,54 @@ export const useEditorStore = create((set, get) => ({
nextManualCutId = added.nextId;
words = deleteWordsCoveredBy(words, r.start, r.end);
}
- pushEdit(get, set, { words, manualCuts, nextManualCutId });
+ pushEdit(get, set, {
+ words,
+ manualCuts,
+ nextManualCutId,
+ selectedClipIndex: null,
+ selectedCutIndex: null,
+ selectedWordIds: [],
+ });
+ },
+ restoreRanges: (ranges) => {
+ const s = get();
+ const result = restoreRangesResult(
+ s.words,
+ s.manualCuts,
+ ranges,
+ s.nextManualCutId
+ );
+ if (!result) return;
+ pushEdit(get, set, {
+ words: result.words,
+ manualCuts: result.manualCuts,
+ nextManualCutId: result.nextCutId,
+ selectedClipIndex: null,
+ selectedCutIndex: null,
+ selectedWordIds: [],
+ });
+ },
+ deleteSelectedClip: () => {
+ const s = get();
+ if (s.selectedClipIndex == null || s.duration <= 0) return false;
+ const cuts = getCutRanges(s.words, s.duration, s.manualCuts);
+ const clips = getClipSegments(
+ getKeepRanges(cuts, s.duration),
+ s.sceneBoundaries
+ );
+ const clip = clips.find((c) => c.index === s.selectedClipIndex);
+ if (!clip || clip.end - clip.start <= 1e-4) return false;
+ get().cutRanges([{ start: clip.start, end: clip.end }]);
+ return true;
+ },
+ restoreSelectedCut: () => {
+ const s = get();
+ if (s.selectedCutIndex == null || s.duration <= 0) return false;
+ const cuts = getCutRanges(s.words, s.duration, s.manualCuts);
+ const cut = cuts[s.selectedCutIndex];
+ if (!cut || cut.end - cut.start <= 1e-4) return false;
+ get().restoreRanges([{ start: cut.start, end: cut.end }]);
+ return true;
},
restoreWords: (ids) => {
if (ids.length === 0) return;
@@ -615,6 +682,7 @@ export const useEditorStore = create((set, get) => ({
words,
manualCuts,
nextManualCutId,
+ selectedCutIndex: null,
});
},
correctWords: (ids, text) => {
@@ -693,6 +761,7 @@ export const useEditorStore = create((set, get) => ({
pushEdit(get, set, {
sceneBoundaries: sceneBoundaries.filter((b) => b.id !== id),
selectedClipIndex: null,
+ selectedCutIndex: null,
});
},
@@ -731,12 +800,29 @@ export const useEditorStore = create((set, get) => ({
sceneBoundaries,
nextManualCutId: result.nextCutId,
selectedClipIndex: owner?.index ?? s.selectedClipIndex,
+ selectedCutIndex: null,
});
},
- setSelectedClipIndex: (selectedClipIndex) => set({ selectedClipIndex }),
+ setSelectedClipIndex: (selectedClipIndex) =>
+ set({
+ selectedClipIndex,
+ ...(selectedClipIndex != null ? { selectedCutIndex: null } : {}),
+ }),
+
+ setSelectedCutIndex: (selectedCutIndex) =>
+ set({
+ selectedCutIndex,
+ ...(selectedCutIndex != null ? { selectedClipIndex: null } : {}),
+ }),
- setSelectedWords: (selectedWordIds) => set({ selectedWordIds }),
+ setSelectedWords: (selectedWordIds) =>
+ set({
+ selectedWordIds,
+ // A fresh word selection from the transcript supersedes a prior cut pick.
+ // Timeline cut-word clicks re-select the cut afterward.
+ ...(selectedWordIds.length > 0 ? { selectedCutIndex: null } : {}),
+ }),
beginGesture: () => {
const s = get();
@@ -776,6 +862,7 @@ export const useEditorStore = create((set, get) => ({
...future,
],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
gestureActive: false,
});
@@ -794,6 +881,7 @@ export const useEditorStore = create((set, get) => ({
future: future.slice(1),
past: [...past, { words, speakers, manualCuts, sceneBoundaries }],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
gestureActive: false,
});
@@ -855,6 +943,7 @@ export const useEditorStore = create((set, get) => ({
past: [],
future: [],
selectedClipIndex: null,
+ selectedCutIndex: null,
selectedWordIds: [],
nextManualCutId: 1,
nextBoundaryId: 1,
diff --git a/tests/edits-test.ts b/tests/edits-test.ts
index 52b349b..0dc7c47 100644
--- a/tests/edits-test.ts
+++ b/tests/edits-test.ts
@@ -14,6 +14,7 @@ import {
getWordCutRanges,
mapSplitsToWords,
MIN_CLIP_DURATION,
+ restoreRangesResult,
trimEdgeBounds,
trimEdgeResult,
} from "../lib/edits";
@@ -295,4 +296,69 @@ function nearly(a: number, b: number, eps = 1e-3) {
assert(nearly(next![0].end, 0.55), "hello keeps its audio");
}
+// --- Restoring a cut range undeletes words and shrinks manual cuts ---
+{
+ const words = [
+ word(1, "keep", 0, 1),
+ word(2, "gone", 1, 2, true),
+ word(3, "also", 2, 3, true),
+ word(4, "end", 3, 4),
+ ];
+ const manual: ManualCut[] = [{ id: 1, start: 1, end: 3 }];
+ const result = restoreRangesResult(words, manual, [{ start: 1, end: 3 }], 10);
+ assert(result !== null, "restore ok");
+ assert(result!.words[1].deleted === false, "gone restored");
+ assert(result!.words[2].deleted === false, "also restored");
+ assert(result!.manualCuts.length === 0, "manual cut removed");
+ const keeps = getKeepRanges(
+ getCutRanges(result!.words, 4, result!.manualCuts),
+ 4
+ );
+ assert(keeps.length === 1, "one continuous keep after restore");
+}
+
+// --- Partial restore splits a manual cut (only fully covered words return) ---
+{
+ const words = [
+ word(1, "left", 0, 1, true),
+ word(2, "mid", 1, 3, true),
+ word(3, "right", 3, 5, true),
+ ];
+ const manual: ManualCut[] = [{ id: 1, start: 0, end: 5 }];
+ const result = restoreRangesResult(words, manual, [{ start: 1, end: 3 }], 10);
+ assert(result !== null, "partial restore ok");
+ assert(result!.words[0].deleted === true, "left stays cut");
+ assert(result!.words[1].deleted === false, "mid restored");
+ assert(result!.words[2].deleted === true, "right stays cut");
+ assert(result!.manualCuts.length === 2, "manual cut split into remnants");
+ assert(nearly(result!.manualCuts[0].end, 1), "left remnant");
+ assert(nearly(result!.manualCuts[1].start, 3), "right remnant");
+}
+
+// --- Delete clip then restore that cut brings media back ---
+{
+ const words = [
+ word(1, "a", 0, 2),
+ word(2, "b", 2, 4),
+ word(3, "c", 4, 6),
+ ];
+ // Simulate deleting the middle clip [2,4) via a manual cut + covered words.
+ const cut = trimEdgeResult(words, [], "out", 4, 2, 1);
+ assert(cut !== null, "cut middle");
+ assert(cut!.words[1].deleted === true, "b deleted");
+ const restored = restoreRangesResult(
+ cut!.words,
+ cut!.manualCuts,
+ [{ start: 2, end: 4 }],
+ cut!.nextCutId
+ );
+ assert(restored !== null, "restore middle");
+ assert(restored!.words[1].deleted === false, "b back");
+ assert(
+ getKeepRanges(getCutRanges(restored!.words, 6, restored!.manualCuts), 6)
+ .length === 1,
+ "fully restored keep"
+ );
+}
+
console.log("edits-test: all passed");