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..dab4b78c2d --- /dev/null +++ b/apps/desktop/src/routes/editor/zoom-segments.test.ts @@ -0,0 +1,75 @@ +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("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); + + 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..6313aacb2b --- /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 inserted = segments[index + 1]; + const newSegmentIndex = sortTrackSegments(segments).indexOf(inserted); + if (newSegmentIndex === -1) return null; + + return { segments, newSegmentIndex }; +}