Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions apps/desktop/src/routes/editor/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
sortTrackSegments,
} from "./timelineTracks";
import { createProgressBar } from "./utils";
import { splitZoomSegmentAt } from "./zoom-segments";

export type ModalDialog =
| { type: "createPreset" }
Expand Down Expand Up @@ -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(() => {
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/routes/editor/zoom-segments.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 39 additions & 0 deletions apps/desktop/src/routes/editor/zoom-segments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { sortTrackSegments } from "./timelineTracks";

type ZoomSegmentLike = {
start: number;
end: number;
};

export type SplitZoomResult<ZoomSegment extends ZoomSegmentLike> = {
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<ZoomSegment extends ZoomSegmentLike>(
segments: ZoomSegment[],
index: number,
time: number,
): SplitZoomResult<ZoomSegment> | 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 };
}