Skip to content
Merged
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
235 changes: 235 additions & 0 deletions src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/contexts/ShortcutsContext", async () => {
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
return {
useShortcuts: () => ({
shortcuts: DEFAULT_SHORTCUTS,
isMac: false,
isConfigOpen: false,
openConfig: vi.fn(),
closeConfig: vi.fn(),
setShortcuts: vi.fn(),
persistShortcuts: () => Promise.resolve(true),
}),
};
});

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({
locale: "en",
setLocale: vi.fn(),
}),
useScopedT: () => (key: string) => key,
}));

import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
import { AUDIO_ROW_EXPANSION_PX } from "@/lib/ai-edition/document/audioTracks";
import { createAudioTrack, createEmptyDocument } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
DEFAULT_TIMELINE_HEIGHT_PX,
MAX_TIMELINE_HEIGHT_PX,
MIN_TIMELINE_HEIGHT_PX,
NewEditorShell,
} from "./NewEditorShell";

function renderShell() {
return render(
<EditorDialogsProvider>
<NewEditorShell />
</EditorDialogsProvider>,
);
}

describe("NewEditorShell timeline height", () => {
beforeEach(() => {
localStorage.clear();
(window as unknown as { electronAPI?: unknown }).electronAPI = {
onAiEditionChatEvent: () => () => {
/* unsubscribe */
},
sendAiEditionChatPrompt: () => {
/* mock */
},
setTitleBarOverlay: () => {
/* noop */
},
setHasUnsavedChanges: () => {
/* noop */
},
onRequestCloseConfirm: () => () => {
/* unsubscribe */
},
onRequestSaveBeforeClose: () => () => {
/* unsubscribe */
},
sendCloseConfirmResponse: () => {
/* noop */
},
findRecordingCamera: () => Promise.resolve(null),
preparePreviewAudioTrack: () => Promise.resolve(null),
isAppPackaged: () => false,
};
Element.prototype.scrollTo = () => {
/* no scrolling in jsdom */
};
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class {
observe() {
/* noop */
}
unobserve() {
/* noop */
}
disconnect() {
/* noop */
}
};
});

afterEach(() => {
cleanup();
localStorage.clear();
});

it("exports expected constants allowing all lanes to fit without vertical clipping", () => {
expect(DEFAULT_TIMELINE_HEIGHT_PX).toBe(392);
expect(MIN_TIMELINE_HEIGHT_PX).toBe(160);
expect(MAX_TIMELINE_HEIGHT_PX).toBe(560);
});

it("uses DEFAULT_TIMELINE_HEIGHT_PX on first opening when localStorage has no saved height", () => {
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root).not.toBeNull();
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
});

it("migrates legacy cramped 308px default to DEFAULT_TIMELINE_HEIGHT_PX and persists it", () => {
localStorage.setItem("os-editor-timeline-height", "308");
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
expect(localStorage.getItem("os-editor-timeline-height")).toBe(
String(DEFAULT_TIMELINE_HEIGHT_PX),
);
expect(localStorage.getItem("os-editor-timeline-height-migrated")).toBe("true");
});

it("migrates intermediate 344px default to DEFAULT_TIMELINE_HEIGHT_PX and persists it", () => {
localStorage.setItem("os-editor-timeline-height", "344");
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
expect(localStorage.getItem("os-editor-timeline-height")).toBe(
String(DEFAULT_TIMELINE_HEIGHT_PX),
);
});

it("preserves an intentional user choice of 308px after migration has run", () => {
localStorage.setItem("os-editor-timeline-height-migrated", "true");
localStorage.setItem("os-editor-timeline-height", "308");
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root.style.gridTemplateRows).toBe("58px 1fr 308px");
});

it("respects a custom user preference saved in localStorage within valid bounds", () => {
localStorage.setItem("os-editor-timeline-height", "450");
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root.style.gridTemplateRows).toBe("58px 1fr 450px");
});

it("clamps out-of-bounds custom heights from localStorage at lower and upper bounds", () => {
localStorage.setItem("os-editor-timeline-height-migrated", "true");
localStorage.setItem("os-editor-timeline-height", "50");
const { container: c1 } = renderShell();
const root1 = c1.firstElementChild as HTMLElement;
expect(root1.style.gridTemplateRows).toBe(`58px 1fr ${MIN_TIMELINE_HEIGHT_PX}px`);

cleanup();
localStorage.setItem("os-editor-timeline-height", "999");
const { container: c2 } = renderShell();
const root2 = c2.firstElementChild as HTMLElement;
expect(root2.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`);
});

it("clamps pointer resizing through startTimelineResize to MIN and MAX bounds", () => {
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
const handle = container.querySelector(
'[role="separator"][aria-orientation="horizontal"]',
) as HTMLElement;
expect(handle).not.toBeNull();

// Drag down significantly (clientY increases): should clamp to MIN_TIMELINE_HEIGHT_PX
act(() => {
fireEvent.pointerDown(handle, { clientY: 400 });
fireEvent.pointerMove(window, { clientY: 1000 });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MIN_TIMELINE_HEIGHT_PX}px`);
act(() => {
fireEvent.pointerUp(window);
});
expect(localStorage.getItem("os-editor-timeline-height")).toBe(String(MIN_TIMELINE_HEIGHT_PX));

// Drag up significantly (clientY decreases): should clamp to MAX_TIMELINE_HEIGHT_PX
act(() => {
fireEvent.pointerDown(handle, { clientY: 400 });
fireEvent.pointerMove(window, { clientY: -500 });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`);
act(() => {
fireEvent.pointerUp(window);
});
expect(localStorage.getItem("os-editor-timeline-height")).toBe(String(MAX_TIMELINE_HEIGHT_PX));
});

it("dynamically expands height by AUDIO_ROW_EXPANSION_PX when transitioning from 1 to 2 audio lanes and shrinks back", () => {
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);

// Add voiceover and music tracks (creating 2 audio rows)
const doc = createEmptyDocument({ projectId: "p", title: "t" });
doc.audioTracks = [
createAudioTrack({
assetId: "a1",
durationSec: 5,
timelineStartSec: 0,
spanSec: 5,
kind: "voiceover",
}),
createAudioTrack({
assetId: "a2",
durationSec: 5,
timelineStartSec: 0,
spanSec: 5,
kind: "music",
}),
];

act(() => {
useProjectStore.setState({ document: doc });
});

const expectedExpandedHeight = DEFAULT_TIMELINE_HEIGHT_PX + AUDIO_ROW_EXPANSION_PX;
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${expectedExpandedHeight}px`);

// Remove music track (returning to 1 audio row)
const singleTrackDoc = {
...doc,
audioTracks: [doc.audioTracks[0]],
};

act(() => {
useProjectStore.setState({ document: singleTrackDoc });
});

expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
});
});
59 changes: 51 additions & 8 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
AUDIO_ROW_EXPANSION_PX,
computeAudioRowCount,
} from "@/lib/ai-edition/document/audioTracks";
import { createId } from "@/lib/ai-edition/document/ids";
import {
migrateProjectDataToAxcutDocument,
Expand Down Expand Up @@ -106,6 +110,10 @@ function NativePlaybackSync({
return null;
}

export const DEFAULT_TIMELINE_HEIGHT_PX = 392;
export const MIN_TIMELINE_HEIGHT_PX = 160;
export const MAX_TIMELINE_HEIGHT_PX = 560;
Comment thread
EtienneLescot marked this conversation as resolved.

export function NewEditorShell() {
const te = useScopedT("editor");
const document = useProjectStore((s) => s.document);
Expand All @@ -132,8 +140,39 @@ export function NewEditorShell() {
const [chatWidthPx, setChatWidthPx] = useState(
() => Number(localStorage.getItem("os-editor-chat-width")) || 392,
);
const [timelineHeightPx, setTimelineHeightPx] = useState(
() => Number(localStorage.getItem("os-editor-timeline-height")) || 308,
const [timelineBaseHeightPx, setTimelineBaseHeightPx] = useState(() => {
const raw = localStorage.getItem("os-editor-timeline-height");
// One-time migration: if legacy 308px default exists without the migration marker,
// migrate it once to DEFAULT_TIMELINE_HEIGHT_PX so existing users see all lanes,
// but allow them to intentionally choose 308px in the future.
// Also migrate intermediate 344px default to DEFAULT_TIMELINE_HEIGHT_PX.
const migrated = localStorage.getItem("os-editor-timeline-height-migrated");
if (!migrated && (!raw || Number(raw) === 308 || Number(raw) === 344)) {
localStorage.setItem("os-editor-timeline-height-migrated", "true");
localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX));
return DEFAULT_TIMELINE_HEIGHT_PX;
}
if (raw === "344") {
localStorage.setItem("os-editor-timeline-height", String(DEFAULT_TIMELINE_HEIGHT_PX));
return DEFAULT_TIMELINE_HEIGHT_PX;
}
const val = raw ? Number(raw) : 0;
if (!val) {
return DEFAULT_TIMELINE_HEIGHT_PX;
}
return Math.min(MAX_TIMELINE_HEIGHT_PX, Math.max(MIN_TIMELINE_HEIGHT_PX, val));
});

// The timeline height automatically expands by AUDIO_ROW_EXPANSION_PX (+29px)
// whenever the project transitions between 1 and 2 stacked audio rows (e.g. voiceover + music).
const audioRowCount = useMemo(
() => computeAudioRowCount(document?.audioTracks ?? []),
[document?.audioTracks],
);
const extraAudioHeightPx = Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX;
const timelineHeightPx = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraAudioHeightPx),
);
const [inspectorOpen, setInspectorOpen] = useState(true);
const [facet, setFacet] = useState<Facet>("effects");
Expand Down Expand Up @@ -1391,23 +1430,27 @@ export function NewEditorShell() {
(e: React.PointerEvent) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = timelineHeightPx;
let latest = startHeight;
const startBase = timelineBaseHeightPx;
let latestBase = startBase;
const move = (ev: PointerEvent) => {
// Dragging the handle up (negative clientY delta) enlarges the
// timeline, since it sits below the handle.
latest = Math.min(480, Math.max(160, startHeight - (ev.clientY - startY)));
setTimelineHeightPx(latest);
const renderedTarget = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + extraAudioHeightPx - (ev.clientY - startY)),
);
latestBase = Math.max(MIN_TIMELINE_HEIGHT_PX, renderedTarget - extraAudioHeightPx);
setTimelineBaseHeightPx(latestBase);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
localStorage.setItem("os-editor-timeline-height", String(latest));
localStorage.setItem("os-editor-timeline-height", String(latestBase));
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
},
[timelineHeightPx],
[timelineBaseHeightPx, extraAudioHeightPx],
);

const transcriptProps = {
Expand Down
13 changes: 5 additions & 8 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
import {
AUDIO_LANE_PAD_PX,
AUDIO_ROW_GAP_PX,
AUDIO_ROW_HEIGHT_PX,
audioGhostExtent,
collapseTracksToPills,
packAudioTrackRows,
Expand Down Expand Up @@ -141,14 +144,8 @@ const PILL_HANDLE_OUT_PX = PILL_HANDLE_PX + PILL_MOVE_GAP_PX;
const PILL_CONTENT_MIN_PX = 34;
/** Edge-snap radius while dragging a pill, in screen px. */
const PILL_SNAP_PX = 8;
// One audio pill's height, and the vertical step between stacked rows. The lane
// grows by a row for each track that overlaps one already placed — see
// `packAudioTrackRows`.
const AUDIO_ROW_HEIGHT_PX = 26;
const AUDIO_ROW_GAP_PX = 3;
// Breathing room above the first row and below the last, so a pill never sits
// flush against the lane's rounded edge.
const AUDIO_LANE_PAD_PX = 3;
// One audio pill's height, the vertical step between stacked rows, and lane padding
// are defined in audioTracks.ts and imported above.
// The size a newly created pill aims for (PILL_CREATE_PX) lives in
// timeline/newRegionDuration, because the keyboard shortcuts create regions too
// and they are handled in NewEditorShell, outside this component.
Expand Down
Loading
Loading