From 9bdb7d9363b8cc7493fa64ffe644e4157ac61525 Mon Sep 17 00:00:00 2001 From: JebKJ21 <299424397+JebKJ21@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:59:09 +0100 Subject: [PATCH 1/2] fix: keep zoom selection on the new segment after a split --- apps/desktop/src/routes/editor/context.ts | 38 +++++++----- .../src/routes/editor/zoom-segments.test.ts | 62 +++++++++++++++++++ .../src/routes/editor/zoom-segments.ts | 39 ++++++++++++ 3 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/routes/editor/zoom-segments.test.ts create mode 100644 apps/desktop/src/routes/editor/zoom-segments.ts diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 5ec4ab3c01..defd7cd3c9 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -112,6 +112,7 @@ import { sortTrackSegments, } from "./timelineTracks"; import { createProgressBar } from "./utils"; +import { splitZoomSegmentAt } from "./zoom-segments"; export type ModalDialog = | { type: "createPreset" } @@ -604,26 +605,33 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( }); }, splitZoomSegment: (index: number, time: number) => { + const segments = project.timeline?.zoomSegments; + const segment = segments?.[index]; + if (!segment) return; + + const newLengths = [segment.end - segment.start - time, time]; + if (newLengths.some((l) => l < 1)) return; + + let newSegmentIndex: number | null = null; setProject( "timeline", "zoomSegments", - produce((segments) => { - const segment = segments[index]; - if (!segment) return; - - const newLengths = [segment.end - segment.start - time, time]; - - if (newLengths.some((l) => l < 1)) return; - - segments.splice(index + 1, 0, { - ...segment, - start: segment.start + time, - end: segment.end, - }); - segments[index].end = segment.start + time; - sortTrackSegments(segments); + produce((zoomSegments) => { + const result = splitZoomSegmentAt(zoomSegments, index, time); + if (!result) return; + newSegmentIndex = result.newSegmentIndex; }), ); + + // The split + sort reorders the array, so the previously selected + // index can now point at the other half. Keep the user's selection + // on the new piece — the segment they were editing when they split it. + if (newSegmentIndex !== null) + setEditorState("timeline", "selection", { + type: "zoom", + indices: [newSegmentIndex], + }); + else setEditorState("timeline", "selection", null); }, deleteZoomSegments: (segmentIndices: number[]) => { batch(() => { diff --git a/apps/desktop/src/routes/editor/zoom-segments.test.ts b/apps/desktop/src/routes/editor/zoom-segments.test.ts new file mode 100644 index 0000000000..558c5134a5 --- /dev/null +++ b/apps/desktop/src/routes/editor/zoom-segments.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { splitZoomSegmentAt } from "./zoom-segments"; + +type Segment = { + start: number; + end: number; + amount: number; +}; + +function segment(start: number, end: number, amount = 1.5): Segment { + return { start, end, amount }; +} + +describe("splitZoomSegmentAt", () => { + it("splits into left [start, start+time] and right [start+time, end]", () => { + const segments = [segment(10, 30)]; + const result = splitZoomSegmentAt(segments, 0, 8); + + expect(result).not.toBeNull(); + expect(segments[0]).toMatchObject({ start: 10, end: 18 }); + expect(segments[1]).toMatchObject({ start: 18, end: 30 }); + expect(result?.newSegmentIndex).toBe(1); + }); + + it("preserves the segment's other properties on both halves", () => { + const segments = [segment(0, 10, 2.5)]; + splitZoomSegmentAt(segments, 0, 4); + + expect(segments[0].amount).toBe(2.5); + expect(segments[1].amount).toBe(2.5); + }); + + it("keeps the new (right) piece selectable after a re-sort around existing segments", () => { + const segments = [segment(0, 10), segment(20, 30), segment(40, 50)]; + const result = splitZoomSegmentAt(segments, 1, 5); + + expect(segments.map((s) => s.start)).toEqual([0, 20, 25, 40]); + expect(segments.map((s) => s.end)).toEqual([10, 25, 30, 50]); + expect(result?.newSegmentIndex).toBe(2); + expect(segments[result?.newSegmentIndex ?? -1]).toMatchObject({ + start: 25, + end: 30, + }); + }); + + it("rejects splits that would leave a piece shorter than one second", () => { + const segments = [segment(10, 30)]; + + expect(splitZoomSegmentAt(segments, 0, 0.5)).toBeNull(); + expect(splitZoomSegmentAt(segments, 0, 20.5)).toBeNull(); + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ start: 10, end: 30 }); + }); + + it("returns null for an out-of-bounds index without mutating the array", () => { + const segments = [segment(0, 10)]; + + expect(splitZoomSegmentAt(segments, 3, 5)).toBeNull(); + expect(segments).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/routes/editor/zoom-segments.ts b/apps/desktop/src/routes/editor/zoom-segments.ts new file mode 100644 index 0000000000..a379b9a978 --- /dev/null +++ b/apps/desktop/src/routes/editor/zoom-segments.ts @@ -0,0 +1,39 @@ +import { sortTrackSegments } from "./timelineTracks"; + +type ZoomSegmentLike = { + start: number; + end: number; +}; + +export type SplitZoomResult = { + segments: ZoomSegment[]; + // Post-sort index of the newly created (right) piece, so callers can keep + // the user's selection pointing at the segment they split instead of + // whichever piece the sort left at the old position. + newSegmentIndex: number; +}; + +export function splitZoomSegmentAt( + segments: ZoomSegment[], + index: number, + time: number, +): SplitZoomResult | null { + const segment = segments[index]; + if (!segment) return null; + + const newLengths = [segment.end - segment.start - time, time]; + if (newLengths.some((l) => l < 1)) return null; + + segments.splice(index + 1, 0, { + ...segment, + start: segment.start + time, + end: segment.end, + }); + segments[index].end = segment.start + time; + + const newSegmentIndex = sortTrackSegments(segments).indexOf( + segments[index + 1], + ); + + return { segments, newSegmentIndex }; +} From 42d704cdb55c7a59089ee0b1612a71a8d65de331 Mon Sep 17 00:00:00 2001 From: JebKJ21 <299424397+JebKJ21@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:07:59 +0100 Subject: [PATCH 2/2] fix: track the inserted zoom segment by identity across the sort --- .../desktop/src/routes/editor/zoom-segments.test.ts | 13 +++++++++++++ apps/desktop/src/routes/editor/zoom-segments.ts | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/editor/zoom-segments.test.ts b/apps/desktop/src/routes/editor/zoom-segments.test.ts index 558c5134a5..dab4b78c2d 100644 --- a/apps/desktop/src/routes/editor/zoom-segments.test.ts +++ b/apps/desktop/src/routes/editor/zoom-segments.test.ts @@ -23,6 +23,19 @@ describe("splitZoomSegmentAt", () => { expect(result?.newSegmentIndex).toBe(1); }); + it("tracks the new piece by identity when the sort moves it past later segments", () => { + const segments = [segment(10, 20), segment(30, 40)]; + const result = splitZoomSegmentAt(segments, 0, 5); + + expect(segments.map((s) => s.start)).toEqual([10, 15, 30]); + expect(segments.map((s) => s.end)).toEqual([15, 20, 40]); + expect(result?.newSegmentIndex).toBe(1); + expect(segments[result?.newSegmentIndex ?? -1]).toMatchObject({ + start: 15, + end: 20, + }); + }); + it("preserves the segment's other properties on both halves", () => { const segments = [segment(0, 10, 2.5)]; splitZoomSegmentAt(segments, 0, 4); diff --git a/apps/desktop/src/routes/editor/zoom-segments.ts b/apps/desktop/src/routes/editor/zoom-segments.ts index a379b9a978..6313aacb2b 100644 --- a/apps/desktop/src/routes/editor/zoom-segments.ts +++ b/apps/desktop/src/routes/editor/zoom-segments.ts @@ -31,9 +31,9 @@ export function splitZoomSegmentAt( }); segments[index].end = segment.start + time; - const newSegmentIndex = sortTrackSegments(segments).indexOf( - segments[index + 1], - ); + const inserted = segments[index + 1]; + const newSegmentIndex = sortTrackSegments(segments).indexOf(inserted); + if (newSegmentIndex === -1) return null; return { segments, newSegmentIndex }; }