From 2419845e0cb6fe0de58bef4f9516ea24920b1bd4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 12:13:06 +0000 Subject: [PATCH 1/6] Add timeline clip delete and cut restore Select a kept clip and press Delete/Backspace or use the new Delete button next to Split to cut it out. Select a cut/silence region (or a cut-out word) and press Delete or use Restore to bring that media back. Co-authored-by: Wassim Gharbi --- components/Editor.tsx | 38 ++++++- components/Timeline.tsx | 175 +++++++++++++++++++++++++++++---- components/TranscriptPanel.tsx | 9 +- lib/edits.ts | 31 ++++++ lib/store.ts | 95 +++++++++++++++++- tests/edits-test.ts | 66 +++++++++++++ 6 files changed, 388 insertions(+), 26 deletions(-) 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 + + + +
@@ -761,13 +884,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 003b2a1..52298c6 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -270,22 +270,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]); // Keep the active word in view during playback. 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 9f2520d..7c8d169 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -21,6 +21,7 @@ import { getCutRanges, getKeepRanges, PLAYHEAD_EPSILON_S, + restoreRangesResult, shrinkManualCuts, trimEdgeResult, } from "./edits"; @@ -87,6 +88,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. @@ -135,6 +141,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). */ @@ -152,6 +164,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; @@ -214,6 +227,7 @@ function pushEdit( | "manualCuts" | "sceneBoundaries" | "selectedClipIndex" + | "selectedCutIndex" | "selectedWordIds" | "nextManualCutId" | "nextBoundaryId" @@ -258,6 +272,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: 1, nextBoundaryId: 1, @@ -297,6 +312,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: 1, nextBoundaryId: 1, @@ -339,6 +355,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], nextManualCutId: maxId(manualCuts, 1), nextBoundaryId: maxId(sceneBoundaries, 1), @@ -392,6 +409,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], }); if (get().status === "ready") bumpAutosave(); @@ -415,6 +433,7 @@ export const useEditorStore = create((set, get) => ({ past: [], future: [], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], partialText: "", error: null, @@ -434,6 +453,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) => { @@ -449,7 +469,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; @@ -481,6 +548,7 @@ export const useEditorStore = create((set, get) => ({ words, manualCuts, nextManualCutId, + selectedCutIndex: null, }); }, correctWords: (ids, text) => { @@ -559,6 +627,7 @@ export const useEditorStore = create((set, get) => ({ pushEdit(get, set, { sceneBoundaries: sceneBoundaries.filter((b) => b.id !== id), selectedClipIndex: null, + selectedCutIndex: null, }); }, @@ -597,12 +666,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(); @@ -641,6 +727,7 @@ export const useEditorStore = create((set, get) => ({ ...future, ], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], gestureActive: false, }); @@ -658,6 +745,7 @@ export const useEditorStore = create((set, get) => ({ future: future.slice(1), past: [...past, { words, manualCuts, sceneBoundaries }], selectedClipIndex: null, + selectedCutIndex: null, selectedWordIds: [], gestureActive: false, }); @@ -718,6 +806,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"); From 362494a511c3294b1e0c590fd5fb1fd16d3bb324 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 12:13:14 +0000 Subject: [PATCH 2/6] Only enable clip Delete when no words are selected Word clicks also select the owning clip for trim handles; the Delete button should target a clip-body selection, not a word pick. Co-authored-by: Wassim Gharbi --- components/Timeline.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/Timeline.tsx b/components/Timeline.tsx index 5ef8632..966914f 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -115,8 +115,11 @@ export default function Timeline() { const pps = fitPps * zoom; const totalWidth = Math.max(width, duration * pps); const ready = status === "ready" && duration > 0; + // Clip delete is for a clip-body click (no word selection). Clicking a word + // also selects its clip for trim handles — don't treat that as "delete clip". const deleteOk = selectedClipIndex != null && + selectedWordIds.length === 0 && clips.some((c) => c.index === selectedClipIndex); const selectedWordsAllCutOut = useMemo(() => { if (selectedWordIds.length === 0) return false; From 099a3186e2f6eae5701cf15095ad9060eb658d7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 21:32:20 +0000 Subject: [PATCH 3/6] Match selected cut outline to clip border radius Use the same rounded-sm DOM ring for selected silence/cut regions instead of a sharp canvas strokeRect. Co-authored-by: Wassim Gharbi --- components/Timeline.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/components/Timeline.tsx b/components/Timeline.tsx index 966914f..ea29cd0 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -267,11 +267,6 @@ export default function Timeline() { ctx.stroke(); } ctx.restore(); - if (selected) { - ctx.strokeStyle = dark ? "rgba(252, 165, 165, 0.7)" : "rgba(239, 68, 68, 0.55)"; - ctx.lineWidth = 1.5; - ctx.strokeRect(x0 + 0.75, trackTop + 0.75, Math.max(0, x1 - x0 - 1.5), trackH - 1.5); - } }); // Waveform @@ -783,6 +778,22 @@ export default function Timeline() { ); })} + {/* Selected cut/silence outline — same rounded ring as selected clips */} + {selectedCutIndex != null && cuts[selectedCutIndex] && ( +
+ )} + {/* Clip trim handles (selected or hovered) */} {clips.map((clip) => { const active = From d0db4503624bd2eddc391a08752c0ca97c750633 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 21:32:27 +0000 Subject: [PATCH 4/6] Use the same neutral selection ring for cuts and clips Co-authored-by: Wassim Gharbi --- components/Timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Timeline.tsx b/components/Timeline.tsx index ea29cd0..d43874e 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -781,7 +781,7 @@ export default function Timeline() { {/* Selected cut/silence outline — same rounded ring as selected clips */} {selectedCutIndex != null && cuts[selectedCutIndex] && (
Date: Sun, 2 Aug 2026 21:41:04 +0000 Subject: [PATCH 5/6] Clip selected cut fills to the rounded ring; color rings by type Selected silence/cut hatch and wash now use the same inset rounded rect as the outline so they no longer spill past the corners. Clip rings are indigo; cut rings are red. Co-authored-by: Wassim Gharbi --- components/Timeline.tsx | 80 +++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/components/Timeline.tsx b/components/Timeline.tsx index d43874e..3a36fe6 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -55,6 +55,32 @@ const MAX_ZOOM = 256; const ZOOM_SPEED = 0.0028; /** How close (px) the pointer must be to a split marker to reveal its join button. */ const SPLIT_HOVER_PX = 10; +/** Inset + radius for selected clip/cut outlines (`rounded-sm` ≈ 2px). */ +const SELECTION_INSET = 2; +const SELECTION_RADIUS = 2; + +/** Canvas path for a rounded rect (used to keep cut fills inside the selection ring). */ +function roundRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +) { + const radius = Math.max(0, Math.min(r, w / 2, h / 2)); + ctx.beginPath(); + if (radius <= 0 || w <= 0 || h <= 0) { + ctx.rect(x, y, Math.max(0, w), Math.max(0, h)); + return; + } + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + ctx.closePath(); +} type DragKind = | { type: "seek" } @@ -221,7 +247,16 @@ export default function Timeline() { const hovered = clip.index === hoveredClipIndex && !selected; if (selected) { ctx.fillStyle = dark ? "rgba(99, 102, 241, 0.20)" : "rgba(99, 102, 241, 0.10)"; - ctx.fillRect(x0, trackTop, x1 - x0, trackH); + // Inset + round to sit inside the selection ring (same as cuts). + roundRectPath( + ctx, + x0 + SELECTION_INSET, + trackTop + SELECTION_INSET, + x1 - x0 - SELECTION_INSET * 2, + trackH - SELECTION_INSET * 2, + SELECTION_RADIUS + ); + ctx.fill(); } else if (hovered) { ctx.fillStyle = dark ? "rgba(99, 102, 241, 0.10)" : "rgba(99, 102, 241, 0.05)"; ctx.fillRect(x0, trackTop, x1 - x0, trackH); @@ -235,6 +270,12 @@ export default function Timeline() { if (x1 < 0 || x0 > width) return; const selected = cutIndex === selectedCutIndex; const hovered = cutIndex === hoveredCutIndex && !selected; + const fillX = selected ? x0 + SELECTION_INSET : x0; + const fillY = selected ? trackTop + SELECTION_INSET : trackTop; + const fillW = selected + ? x1 - x0 - SELECTION_INSET * 2 + : x1 - x0; + const fillH = selected ? trackH - SELECTION_INSET * 2 : trackH; ctx.fillStyle = selected ? dark ? "rgba(185, 28, 28, 0.62)" @@ -246,11 +287,20 @@ export default function Timeline() { : dark ? "rgba(127, 29, 29, 0.45)" : "rgba(254, 226, 226, 0.78)"; - ctx.fillRect(x0, trackTop, x1 - x0, trackH); - // subtle hatch + if (selected) { + roundRectPath(ctx, fillX, fillY, fillW, fillH, SELECTION_RADIUS); + ctx.fill(); + } else { + ctx.fillRect(fillX, fillY, fillW, fillH); + } + // subtle hatch — clipped to the same (rounded) shape as the fill ctx.save(); - ctx.beginPath(); - ctx.rect(x0, trackTop, x1 - x0, trackH); + if (selected) { + roundRectPath(ctx, fillX, fillY, fillW, fillH, SELECTION_RADIUS); + } else { + ctx.beginPath(); + ctx.rect(fillX, fillY, fillW, fillH); + } ctx.clip(); ctx.strokeStyle = selected ? dark @@ -260,10 +310,10 @@ export default function Timeline() { ? "rgba(248, 113, 113, 0.35)" : "rgba(252, 165, 165, 0.45)"; ctx.lineWidth = 1; - for (let x = x0 - trackH; x < x1 + trackH; x += 6) { + for (let x = fillX - fillH; x < fillX + fillW + fillH; x += 6) { ctx.beginPath(); - ctx.moveTo(x, trackTop); - ctx.lineTo(x + trackH, trackTop + trackH); + ctx.moveTo(x, fillY); + ctx.lineTo(x + fillH, fillY + fillH); ctx.stroke(); } ctx.restore(); @@ -778,18 +828,18 @@ export default function Timeline() { ); })} - {/* Selected cut/silence outline — same rounded ring as selected clips */} + {/* Selected cut/silence outline */} {selectedCutIndex != null && cuts[selectedCutIndex] && (
)} @@ -844,12 +894,12 @@ export default function Timeline() {
{selected && (
)} From 0afeb0175b6dc736baa6968dbccf5c9ebbf1384c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 21:41:08 +0000 Subject: [PATCH 6/6] Align selected fills with the ring box edges Co-authored-by: Wassim Gharbi --- components/Timeline.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/components/Timeline.tsx b/components/Timeline.tsx index 3a36fe6..0a9421a 100644 --- a/components/Timeline.tsx +++ b/components/Timeline.tsx @@ -247,12 +247,12 @@ export default function Timeline() { const hovered = clip.index === hoveredClipIndex && !selected; if (selected) { ctx.fillStyle = dark ? "rgba(99, 102, 241, 0.20)" : "rgba(99, 102, 241, 0.10)"; - // Inset + round to sit inside the selection ring (same as cuts). + // Match the selection ring box (vertically inset, rounded). roundRectPath( ctx, - x0 + SELECTION_INSET, + x0, trackTop + SELECTION_INSET, - x1 - x0 - SELECTION_INSET * 2, + x1 - x0, trackH - SELECTION_INSET * 2, SELECTION_RADIUS ); @@ -270,11 +270,10 @@ export default function Timeline() { if (x1 < 0 || x0 > width) return; const selected = cutIndex === selectedCutIndex; const hovered = cutIndex === hoveredCutIndex && !selected; - const fillX = selected ? x0 + SELECTION_INSET : x0; + // Selected fills share the ring's box so the hatch can't spill past the radius. + const fillX = x0; const fillY = selected ? trackTop + SELECTION_INSET : trackTop; - const fillW = selected - ? x1 - x0 - SELECTION_INSET * 2 - : x1 - x0; + const fillW = x1 - x0; const fillH = selected ? trackH - SELECTION_INSET * 2 : trackH; ctx.fillStyle = selected ? dark