mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add variable timeline timing and layout
This commit is contained in:
+10
-17
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
|
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
|
||||||
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
||||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||||
import { usePlayerStore, type TimelineElement } from "./player";
|
import { usePlayerStore } from "./player";
|
||||||
import { StudioOverlays } from "./components/StudioOverlays";
|
import { StudioOverlays } from "./components/StudioOverlays";
|
||||||
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
||||||
import { useCaptionStore } from "./captions/store";
|
import { useCaptionStore } from "./captions/store";
|
||||||
@@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
|
|||||||
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
||||||
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
|
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
|
||||||
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
||||||
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
|
import {
|
||||||
|
persistTimelineMoveEditsAtomically,
|
||||||
|
type TimelineMoveEditsHandler,
|
||||||
|
type TimelineMoveOperation,
|
||||||
|
} from "./hooks/timelineMoveAdapter";
|
||||||
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
||||||
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
|
|
||||||
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
||||||
import { useDomEditSession } from "./hooks/useDomEditSession";
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
||||||
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
||||||
@@ -62,7 +65,6 @@ import {
|
|||||||
} from "./utils/studioUrlState";
|
} from "./utils/studioUrlState";
|
||||||
import { trackStudioSessionStart } from "./telemetry/events";
|
import { trackStudioSessionStart } from "./telemetry/events";
|
||||||
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
||||||
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
export function StudioApp() {
|
export function StudioApp() {
|
||||||
const { projectId, resolving, waitingForServer } = useServerConnection();
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
||||||
@@ -154,6 +156,7 @@ export function StudioApp() {
|
|||||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
});
|
});
|
||||||
|
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
||||||
const timelineEditing = useTimelineEditing({
|
const timelineEditing = useTimelineEditing({
|
||||||
projectId,
|
projectId,
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
@@ -171,20 +174,11 @@ export function StudioApp() {
|
|||||||
sdkSession: editFlowSdkSession,
|
sdkSession: editFlowSdkSession,
|
||||||
publishSdkSession: sdkHandle.publish,
|
publishSdkSession: sdkHandle.publish,
|
||||||
forceReloadSdkSession: sdkHandle.forceReload,
|
forceReloadSdkSession: sdkHandle.forceReload,
|
||||||
|
invalidateGsapCache: () => invalidateGsapCacheRef.current(),
|
||||||
handleDomZIndexReorderCommitRef,
|
handleDomZIndexReorderCommitRef,
|
||||||
});
|
});
|
||||||
const handleTimelineElementsMove = useCallback(
|
const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
|
||||||
async (
|
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
|
||||||
edits: Array<{
|
|
||||||
element: TimelineElement;
|
|
||||||
updates: Pick<TimelineElement, "start" | "track"> & {
|
|
||||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
||||||
};
|
|
||||||
}>,
|
|
||||||
coalesceKey?: string,
|
|
||||||
operation: TimelineMoveOperation = "timing",
|
|
||||||
coalesceMs?: number,
|
|
||||||
) => {
|
|
||||||
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
|
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
|
||||||
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
|
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
|
||||||
},
|
},
|
||||||
@@ -228,7 +222,6 @@ export function StudioApp() {
|
|||||||
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
|
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
|
||||||
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
||||||
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
||||||
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
||||||
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
||||||
projectId,
|
projectId,
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
TimelineGroupMoveChange,
|
TimelineGroupMoveChange,
|
||||||
} from "./useTimelineGroupEditing";
|
} from "./useTimelineGroupEditing";
|
||||||
|
|
||||||
interface MoveEdit {
|
export interface TimelineMoveEdit {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
updates: Pick<TimelineElement, "start" | "track">;
|
updates: Pick<TimelineElement, "start" | "track">;
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,15 @@ interface AtomicMoveDeps {
|
|||||||
|
|
||||||
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
|
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
|
||||||
|
|
||||||
|
export type TimelineMoveEditsHandler = (
|
||||||
|
edits: TimelineMoveEdit[],
|
||||||
|
coalesceKey?: string,
|
||||||
|
operation?: TimelineMoveOperation,
|
||||||
|
coalesceMs?: number,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export function persistTimelineMoveEditsAtomically(
|
export function persistTimelineMoveEditsAtomically(
|
||||||
edits: MoveEdit[],
|
edits: TimelineMoveEdit[],
|
||||||
coalesceKey: string | undefined,
|
coalesceKey: string | undefined,
|
||||||
operation: TimelineMoveOperation,
|
operation: TimelineMoveOperation,
|
||||||
deps: AtomicMoveDeps,
|
deps: AtomicMoveDeps,
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|||||||
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||||
import { useTimelineEditing } from "./useTimelineEditing";
|
import { useTimelineEditing } from "./useTimelineEditing";
|
||||||
|
|
||||||
|
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("../components/editor/manualEditingAvailability")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
STUDIO_SDK_CUTOVER_ENABLED: true,
|
||||||
|
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
|
||||||
|
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
type ZIndexEntry = {
|
type ZIndexEntry = {
|
||||||
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
|
|||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
reloadPreview?: () => void;
|
reloadPreview?: () => void;
|
||||||
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
||||||
|
publishSdkSession?: NonNullable<Parameters<typeof useTimelineEditing>[0]["publishSdkSession"]>;
|
||||||
forceReloadSdkSession?: () => void;
|
forceReloadSdkSession?: () => void;
|
||||||
|
invalidateGsapCache?: () => void;
|
||||||
showToast?: (message: string, kind?: string) => void;
|
showToast?: (message: string, kind?: string) => void;
|
||||||
}): {
|
}): {
|
||||||
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||||
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
|
|||||||
pendingTimelineEditPathRef: { current: new Set<string>() },
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
||||||
uploadProjectFiles: vi.fn(),
|
uploadProjectFiles: vi.fn(),
|
||||||
sdkSession: input.sdkSession,
|
sdkSession: input.sdkSession,
|
||||||
|
publishSdkSession: input.publishSdkSession,
|
||||||
forceReloadSdkSession: input.forceReloadSdkSession,
|
forceReloadSdkSession: input.forceReloadSdkSession,
|
||||||
|
invalidateGsapCache: input.invalidateGsapCache,
|
||||||
handleDomZIndexReorderCommitRef: commitRef,
|
handleDomZIndexReorderCommitRef: commitRef,
|
||||||
});
|
});
|
||||||
move = hook.handleTimelineElementMove;
|
move = hook.handleTimelineElementMove;
|
||||||
@@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: {
|
|||||||
type TimelineRecordEdit = NonNullable<
|
type TimelineRecordEdit = NonNullable<
|
||||||
Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
|
Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
|
||||||
>;
|
>;
|
||||||
|
type TimelinePublishSdkSession = NonNullable<
|
||||||
|
Parameters<typeof renderTimelineEditingHook>[0]["publishSdkSession"]
|
||||||
|
>;
|
||||||
|
|
||||||
function renderTimelineEditingHookWithLifecycle(input: {
|
function renderTimelineEditingHookWithLifecycle(input: {
|
||||||
timelineElements: TimelineElement[];
|
timelineElements: TimelineElement[];
|
||||||
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
|
|||||||
* with `gsapBody`. Returns the mock for call inspection.
|
* with `gsapBody`. Returns the mock for call inspection.
|
||||||
*/
|
*/
|
||||||
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
|
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
|
||||||
// Keep this test server's capability, file-read, and mutation routes together;
|
const pathAfter = (url: string, marker: string) =>
|
||||||
// splitting the fixture would obscure the request sequence asserted by callers.
|
decodeURIComponent(url.split(marker)[1] ?? "index.html");
|
||||||
// fallow-ignore-next-line complexity
|
const fileContent = (path: string) => (typeof files === "string" ? files : files[path]);
|
||||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
// One handler per route, so the mock itself stays a lookup: the request
|
||||||
const url = requestUrl(input);
|
// sequence callers assert on is still readable top to bottom.
|
||||||
if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
|
const routes: Array<[marker: string, respond: (url: string) => Response]> = [
|
||||||
return jsonResponse({ atomicOwnershipPairs: true });
|
[
|
||||||
}
|
"/api/projects/p1/gsap-mutation-capabilities",
|
||||||
if (url.includes("/api/projects/p1/files/")) {
|
() => jsonResponse({ atomicOwnershipPairs: true }),
|
||||||
if (typeof files === "string") return jsonResponse({ content: files });
|
],
|
||||||
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
|
[
|
||||||
return jsonResponse({ content: files[path] });
|
"/api/projects/p1/files/",
|
||||||
}
|
(url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
|
||||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
],
|
||||||
const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
|
[
|
||||||
const content = typeof files === "string" ? files : (files[path] ?? "");
|
"/api/projects/p1/gsap-mutations/",
|
||||||
return jsonResponse(
|
(url) => {
|
||||||
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? "";
|
||||||
);
|
return jsonResponse(
|
||||||
}
|
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
|
||||||
throw new Error(`Unexpected fetch: ${url}`);
|
);
|
||||||
});
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async (
|
||||||
|
input: Parameters<typeof fetch>[0],
|
||||||
|
_init?: Parameters<typeof fetch>[1],
|
||||||
|
): Promise<Response> => {
|
||||||
|
const url = requestUrl(input);
|
||||||
|
const route = routes.find(([marker]) => url.includes(marker));
|
||||||
|
if (!route) throw new Error(`Unexpected fetch: ${url}`);
|
||||||
|
return route[1](url);
|
||||||
|
},
|
||||||
|
);
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
return fetchMock;
|
return fetchMock;
|
||||||
}
|
}
|
||||||
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
|
|||||||
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SDK_KEYFRAMED_SOURCE = [
|
||||||
|
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
|
||||||
|
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
||||||
|
`</div>`,
|
||||||
|
`<script>`,
|
||||||
|
`const tl = gsap.timeline({ paused: true });`,
|
||||||
|
`tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
|
||||||
|
`window.__timelines = [tl];`,
|
||||||
|
`</script>`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
async function setupSdkKeyframedClipHarness() {
|
||||||
|
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||||
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
|
||||||
|
const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
|
||||||
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||||
|
const invalidateGsapCache = vi.fn();
|
||||||
|
const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
|
||||||
|
usePlayerStore.getState().setDuration(10);
|
||||||
|
const hook = renderTimelineEditingHook({
|
||||||
|
timelineElements: [clip],
|
||||||
|
iframe,
|
||||||
|
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||||
|
projectId: "p1",
|
||||||
|
writeProjectFile,
|
||||||
|
recordEdit: vi.fn(async () => {}),
|
||||||
|
sdkSession,
|
||||||
|
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
||||||
|
invalidateGsapCache,
|
||||||
|
});
|
||||||
|
return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
|
||||||
|
}
|
||||||
|
|
||||||
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
||||||
function expectLanePersisted(
|
function expectLanePersisted(
|
||||||
iframe: HTMLIFrameElement,
|
iframe: HTMLIFrameElement,
|
||||||
@@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|||||||
h.unmount();
|
h.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
|
||||||
|
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
||||||
|
await setupSdkKeyframedClipHarness();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await hook.move(clip, { start: 2.25, track: clip.track });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
|
||||||
|
const mutationCall = fetchMock.mock.calls.find((call) =>
|
||||||
|
requestUrl(call[0]).includes("/gsap-mutations/"),
|
||||||
|
);
|
||||||
|
expect(mutationCall).toBeDefined();
|
||||||
|
const init = mutationCall?.[1] as RequestInit | undefined;
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({
|
||||||
|
type: "shift-positions",
|
||||||
|
targetSelector: "#clip",
|
||||||
|
delta: 1.25,
|
||||||
|
});
|
||||||
|
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hook.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
|
||||||
|
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
|
||||||
|
await setupSdkKeyframedClipHarness();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
|
||||||
|
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
|
||||||
|
const mutationCall = fetchMock.mock.calls.find((call) =>
|
||||||
|
requestUrl(call[0]).includes("/gsap-mutations/"),
|
||||||
|
);
|
||||||
|
expect(mutationCall).toBeDefined();
|
||||||
|
const init = mutationCall?.[1] as RequestInit | undefined;
|
||||||
|
expect(JSON.parse(String(init?.body))).toEqual({
|
||||||
|
type: "scale-positions",
|
||||||
|
targetSelector: "#clip",
|
||||||
|
oldStart: 1,
|
||||||
|
oldDuration: 2,
|
||||||
|
newStart: 2,
|
||||||
|
newDuration: 4,
|
||||||
|
});
|
||||||
|
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hook.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
|
||||||
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
|
||||||
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
// pure lane change routed through onMoveElement (no onMoveElements wired)
|
||||||
@@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
|
||||||
|
const source = [
|
||||||
|
`<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
|
||||||
|
` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
|
||||||
|
` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
|
||||||
|
`</div>`,
|
||||||
|
`<script>`,
|
||||||
|
`const tl = gsap.timeline({ paused: true });`,
|
||||||
|
`tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
|
||||||
|
`tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
|
||||||
|
`window.__timelines = [tl];`,
|
||||||
|
`</script>`,
|
||||||
|
].join("\n");
|
||||||
|
const { iframe, a, b } = makeTwoClipPair();
|
||||||
|
const sdkSession = await openComposition(source);
|
||||||
|
const fetchMock = stubProjectFetch(source);
|
||||||
|
const invalidateGsapCache = vi.fn();
|
||||||
|
usePlayerStore.getState().setDuration(10);
|
||||||
|
const hook = renderTimelineEditingHook({
|
||||||
|
timelineElements: [a, b],
|
||||||
|
iframe,
|
||||||
|
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||||
|
projectId: "p1",
|
||||||
|
writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
|
||||||
|
recordEdit: vi.fn(async () => {}),
|
||||||
|
sdkSession,
|
||||||
|
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
||||||
|
invalidateGsapCache,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await hook.groupMove([
|
||||||
|
{ element: a, start: 1 },
|
||||||
|
{ element: b, start: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutations = fetchMock.mock.calls
|
||||||
|
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
||||||
|
.map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
|
||||||
|
expect(mutations).toEqual([
|
||||||
|
{ type: "shift-positions", targetSelector: "#a", delta: 1 },
|
||||||
|
{ type: "shift-positions", targetSelector: "#b", delta: 1 },
|
||||||
|
]);
|
||||||
|
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hook.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("partitions a group move by source file while keeping one undo entry", async () => {
|
it("partitions a group move by source file while keeping one undo entry", async () => {
|
||||||
const files: Record<string, string> = {
|
const files: Record<string, string> = {
|
||||||
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function useTimelineEditing({
|
|||||||
sdkSession,
|
sdkSession,
|
||||||
publishSdkSession,
|
publishSdkSession,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
|
invalidateGsapCache,
|
||||||
handleDomZIndexReorderCommitRef,
|
handleDomZIndexReorderCommitRef,
|
||||||
}: UseTimelineEditingOptions) {
|
}: UseTimelineEditingOptions) {
|
||||||
const projectIdRef = useRef(projectId);
|
const projectIdRef = useRef(projectId);
|
||||||
@@ -118,6 +119,7 @@ export function useTimelineEditing({
|
|||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
editQueueRef,
|
editQueueRef,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
|
invalidateGsapCache,
|
||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
@@ -184,21 +186,24 @@ export function useTimelineEditing({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
||||||
|
const finishMoveGsapSync = () =>
|
||||||
|
// Every timing writer converges the same GSAP positions after its
|
||||||
|
// durable clip-start commit. The SDK owns the attribute write; this
|
||||||
|
// sync owns only the dependent animation rewrite and preview refresh.
|
||||||
|
finishClipTimingFallback({
|
||||||
|
iframe: previewIframeRef.current,
|
||||||
|
reloadPreview,
|
||||||
|
projectId: projectIdRef.current,
|
||||||
|
targetPath,
|
||||||
|
domId: element.domId,
|
||||||
|
label: "Move timeline clip",
|
||||||
|
coalesceKey,
|
||||||
|
recordEdit,
|
||||||
|
edit: { kind: "shift", delta: updates.start - element.start },
|
||||||
|
}).finally(() => invalidateGsapCache?.());
|
||||||
const moveFallback = () =>
|
const moveFallback = () =>
|
||||||
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
|
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
|
||||||
// Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
|
finishMoveGsapSync,
|
||||||
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
|
|
||||||
finishClipTimingFallback({
|
|
||||||
iframe: previewIframeRef.current,
|
|
||||||
reloadPreview,
|
|
||||||
projectId: projectIdRef.current,
|
|
||||||
targetPath,
|
|
||||||
domId: element.domId,
|
|
||||||
label: "Move timeline clip",
|
|
||||||
coalesceKey,
|
|
||||||
recordEdit,
|
|
||||||
edit: { kind: "shift", delta: updates.start - element.start },
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return reorderDone
|
return reorderDone
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -221,9 +226,10 @@ export function useTimelineEditing({
|
|||||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||||
publishSession: publishSdkSession,
|
publishSession: publishSdkSession,
|
||||||
},
|
},
|
||||||
{ label: "Move timeline clip", coalesceKey },
|
{ label: "Move timeline clip", coalesceKey, skipRefresh: true },
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
if (!cutoverCommittedOrThrow(result)) return moveFallback();
|
||||||
|
return finishMoveGsapSync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return moveFallback();
|
return moveFallback();
|
||||||
@@ -250,6 +256,7 @@ export function useTimelineEditing({
|
|||||||
timelineElements,
|
timelineElements,
|
||||||
handleDomZIndexReorderCommitRef,
|
handleDomZIndexReorderCommitRef,
|
||||||
showToast,
|
showToast,
|
||||||
|
invalidateGsapCache,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -287,23 +294,25 @@ export function useTimelineEditing({
|
|||||||
// script (timing-only resize) — same no-flash path as move; full reload is
|
// script (timing-only resize) — same no-flash path as move; full reload is
|
||||||
// the fallback.
|
// the fallback.
|
||||||
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
||||||
|
const finishResizeGsapSync = () =>
|
||||||
|
finishClipTimingFallback({
|
||||||
|
iframe: previewIframeRef.current,
|
||||||
|
reloadPreview,
|
||||||
|
projectId: projectIdRef.current,
|
||||||
|
targetPath,
|
||||||
|
domId: element.domId,
|
||||||
|
label: "Resize timeline clip",
|
||||||
|
coalesceKey,
|
||||||
|
recordEdit,
|
||||||
|
edit: {
|
||||||
|
kind: "scale",
|
||||||
|
from: { start: element.start, duration: element.duration },
|
||||||
|
to: { start: updates.start, duration: updates.duration },
|
||||||
|
},
|
||||||
|
}).finally(() => invalidateGsapCache?.());
|
||||||
const resizeFallback = () =>
|
const resizeFallback = () =>
|
||||||
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
|
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
|
||||||
finishClipTimingFallback({
|
finishResizeGsapSync,
|
||||||
iframe: previewIframeRef.current,
|
|
||||||
reloadPreview,
|
|
||||||
projectId: projectIdRef.current,
|
|
||||||
targetPath,
|
|
||||||
domId: element.domId,
|
|
||||||
label: "Resize timeline clip",
|
|
||||||
coalesceKey,
|
|
||||||
recordEdit,
|
|
||||||
edit: {
|
|
||||||
kind: "scale",
|
|
||||||
from: { start: element.start, duration: element.duration },
|
|
||||||
to: { start: updates.start, duration: updates.duration },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const persistDone =
|
const persistDone =
|
||||||
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
||||||
@@ -323,9 +332,10 @@ export function useTimelineEditing({
|
|||||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||||
publishSession: publishSdkSession,
|
publishSession: publishSdkSession,
|
||||||
},
|
},
|
||||||
{ label: "Resize timeline clip", coalesceKey },
|
{ label: "Resize timeline clip", coalesceKey, skipRefresh: true },
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
if (!cutoverCommittedOrThrow(result)) return resizeFallback();
|
||||||
|
return finishResizeGsapSync();
|
||||||
})
|
})
|
||||||
: resizeFallback();
|
: resizeFallback();
|
||||||
return persistDone.catch((error) => {
|
return persistDone.catch((error) => {
|
||||||
@@ -346,6 +356,7 @@ export function useTimelineEditing({
|
|||||||
reloadPreview,
|
reloadPreview,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
showToast,
|
showToast,
|
||||||
|
invalidateGsapCache,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface UseTimelineEditingOptions {
|
|||||||
publishSdkSession?: PublishSdkSession;
|
publishSdkSession?: PublishSdkSession;
|
||||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||||
forceReloadSdkSession?: () => void;
|
forceReloadSdkSession?: () => void;
|
||||||
|
/** Reparse authored animations after a timing rewrite changes their positions. */
|
||||||
|
invalidateGsapCache?: () => void;
|
||||||
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions {
|
|||||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||||
editQueueRef: MutableRefObject<Promise<unknown>>;
|
editQueueRef: MutableRefObject<Promise<unknown>>;
|
||||||
forceReloadSdkSession?: () => void;
|
forceReloadSdkSession?: () => void;
|
||||||
|
invalidateGsapCache?: () => void;
|
||||||
isRecordingRef?: RefObject<boolean>;
|
isRecordingRef?: RefObject<boolean>;
|
||||||
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||||
@@ -110,6 +111,7 @@ export function useTimelineGroupEditing({
|
|||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
editQueueRef,
|
editQueueRef,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
|
invalidateGsapCache,
|
||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
previewIframeRef,
|
previewIframeRef,
|
||||||
@@ -212,7 +214,12 @@ export function useTimelineGroupEditing({
|
|||||||
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
||||||
publishSession: publishSdkSession,
|
publishSession: publishSdkSession,
|
||||||
},
|
},
|
||||||
{ label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
|
{
|
||||||
|
label: input.label,
|
||||||
|
coalesceKey: input.coalesceKey,
|
||||||
|
coalesceMs: input.coalesceMs,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return cutoverCommittedOrThrow(result);
|
return cutoverCommittedOrThrow(result);
|
||||||
},
|
},
|
||||||
@@ -282,25 +289,25 @@ export function useTimelineGroupEditing({
|
|||||||
coalesceKey,
|
coalesceKey,
|
||||||
coalesceMs,
|
coalesceMs,
|
||||||
});
|
});
|
||||||
if (handledBySdk) return;
|
if (!handledBySdk) {
|
||||||
|
await persistServerBatch(
|
||||||
await persistServerBatch(
|
projectId,
|
||||||
projectId,
|
"Move timeline clips",
|
||||||
"Move timeline clips",
|
changes.map((change) => ({
|
||||||
changes.map((change) => ({
|
element: change.element,
|
||||||
element: change.element,
|
buildPatches: (original, target) =>
|
||||||
buildPatches: (original, target) =>
|
buildTimelineMoveTimingPatch(
|
||||||
buildTimelineMoveTimingPatch(
|
original,
|
||||||
original,
|
target,
|
||||||
target,
|
change.start,
|
||||||
change.start,
|
change.element.duration,
|
||||||
change.element.duration,
|
change.track,
|
||||||
change.track,
|
),
|
||||||
),
|
})),
|
||||||
})),
|
coalesceKey,
|
||||||
coalesceKey,
|
coalesceMs,
|
||||||
coalesceMs,
|
);
|
||||||
);
|
}
|
||||||
// Track-only: no timing delta → no GSAP positions to shift and no
|
// Track-only: no timing delta → no GSAP positions to shift and no
|
||||||
// reload (see the trackOnly doc above). Mixed batches (any start
|
// reload (see the trackOnly doc above). Mixed batches (any start
|
||||||
// change) keep the full fallback below.
|
// change) keep the full fallback below.
|
||||||
@@ -323,6 +330,7 @@ export function useTimelineGroupEditing({
|
|||||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
invalidateGsapCache?.();
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Failed persist: revert the optimistic duration readout + live root
|
// Failed persist: revert the optimistic duration readout + live root
|
||||||
// alongside the gesture owner's store rollback.
|
// alongside the gesture owner's store rollback.
|
||||||
@@ -340,6 +348,7 @@ export function useTimelineGroupEditing({
|
|||||||
reloadPreview,
|
reloadPreview,
|
||||||
trySdkBatchPersist,
|
trySdkBatchPersist,
|
||||||
showToast,
|
showToast,
|
||||||
|
invalidateGsapCache,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,23 +393,23 @@ export function useTimelineGroupEditing({
|
|||||||
coalesceKey,
|
coalesceKey,
|
||||||
coalesceMs,
|
coalesceMs,
|
||||||
});
|
});
|
||||||
if (handledBySdk) return;
|
if (!handledBySdk) {
|
||||||
|
await persistServerBatch(
|
||||||
await persistServerBatch(
|
projectId,
|
||||||
projectId,
|
"Resize timeline clips",
|
||||||
"Resize timeline clips",
|
changes.map((change) => ({
|
||||||
changes.map((change) => ({
|
element: change.element,
|
||||||
element: change.element,
|
buildPatches: (original, target) =>
|
||||||
buildPatches: (original, target) =>
|
buildTimelineResizeTimingPatch(original, target, change.element, {
|
||||||
buildTimelineResizeTimingPatch(original, target, change.element, {
|
start: change.start,
|
||||||
start: change.start,
|
duration: change.duration,
|
||||||
duration: change.duration,
|
playbackStart: change.playbackStart,
|
||||||
playbackStart: change.playbackStart,
|
}),
|
||||||
}),
|
})),
|
||||||
})),
|
coalesceKey,
|
||||||
coalesceKey,
|
coalesceMs,
|
||||||
coalesceMs,
|
);
|
||||||
);
|
}
|
||||||
await finishGroupTimingGsapFallback({
|
await finishGroupTimingGsapFallback({
|
||||||
projectId,
|
projectId,
|
||||||
iframe: previewIframeRef.current,
|
iframe: previewIframeRef.current,
|
||||||
@@ -428,6 +437,7 @@ export function useTimelineGroupEditing({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
invalidateGsapCache?.();
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Failed persist: revert the optimistic duration readout + live root
|
// Failed persist: revert the optimistic duration readout + live root
|
||||||
// alongside the gesture owner's store rollback.
|
// alongside the gesture owner's store rollback.
|
||||||
@@ -445,6 +455,7 @@ export function useTimelineGroupEditing({
|
|||||||
reloadPreview,
|
reloadPreview,
|
||||||
trySdkBatchPersist,
|
trySdkBatchPersist,
|
||||||
showToast,
|
showToast,
|
||||||
|
invalidateGsapCache,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type DragPreviewContext,
|
type DragPreviewContext,
|
||||||
} from "./timelineClipDragPreview";
|
} from "./timelineClipDragPreview";
|
||||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||||
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
|
// Regression bed for the live-reproduced BUG 1: a PLAIN HORIZONTAL drag of a clip
|
||||||
@@ -55,13 +55,17 @@ function fakeScroll(): HTMLDivElement {
|
|||||||
} as unknown as HTMLDivElement;
|
} as unknown as HTMLDivElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ctx(): DragPreviewContext {
|
function ctx(
|
||||||
|
rowHeights?: readonly number[],
|
||||||
|
elements: TimelineElement[] = fixtureElements,
|
||||||
|
): DragPreviewContext {
|
||||||
return {
|
return {
|
||||||
scroll: fakeScroll(),
|
scroll: fakeScroll(),
|
||||||
pps: PPS,
|
pps: PPS,
|
||||||
duration: 44.5,
|
duration: 44.5,
|
||||||
trackOrder: [0, 1, 2],
|
trackOrder: [0, 1, 2],
|
||||||
elements: fixtureElements,
|
elements,
|
||||||
|
rowHeights,
|
||||||
selectedKeys: new Set<string>(),
|
selectedKeys: new Set<string>(),
|
||||||
buildSnapTargets: () => [],
|
buildSnapTargets: () => [],
|
||||||
audioTracks: new Set<number>(),
|
audioTracks: new Set<number>(),
|
||||||
@@ -145,6 +149,49 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
|||||||
const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
|
const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
|
||||||
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a horizontal drag in the body of an expanded row out of insert mode", () => {
|
||||||
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
||||||
|
const clientY = RULER_H + TRACKS_TOP_PAD + rowHeights[0] - 8;
|
||||||
|
const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
|
||||||
|
const next = computeDragPreview(
|
||||||
|
{ ...drag, originClientY: clientY, pointerClientY: clientY },
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
ctx(rowHeights),
|
||||||
|
);
|
||||||
|
expect(next.insertRow).toBeNull();
|
||||||
|
expect(next.previewTrack).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the expanded row midpoint when choosing the side for an automatic insert", () => {
|
||||||
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H];
|
||||||
|
const dragged = clip("dragged", 0, 0, 1, 3);
|
||||||
|
const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
|
||||||
|
const clientY = RULER_H + TRACKS_TOP_PAD + 30;
|
||||||
|
const drag: DraggedClipState = {
|
||||||
|
element: dragged,
|
||||||
|
originClientX: 0,
|
||||||
|
originClientY: clientY,
|
||||||
|
originScrollLeft: 0,
|
||||||
|
originScrollTop: 0,
|
||||||
|
pointerClientX: 0,
|
||||||
|
pointerClientY: clientY,
|
||||||
|
pointerOffsetX: 0,
|
||||||
|
pointerOffsetY: 0,
|
||||||
|
previewStart: 0,
|
||||||
|
previewTrack: 0,
|
||||||
|
insertRow: null,
|
||||||
|
snapTime: null,
|
||||||
|
snapType: null,
|
||||||
|
started: true,
|
||||||
|
};
|
||||||
|
const next = computeDragPreview(drag, 0, clientY, {
|
||||||
|
...ctx(rowHeights, occupied),
|
||||||
|
trackOrder: [0, 1],
|
||||||
|
});
|
||||||
|
expect(next.insertRow).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeResizePreview — composition source continuity", () => {
|
describe("computeResizePreview — composition source continuity", () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
|
import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import { TRACK_H, getTimelineRowFromY, INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
import {
|
||||||
|
getTimelineInsertBoundaryBand,
|
||||||
|
getTimelineRowFromY,
|
||||||
|
getTimelineRowHeight,
|
||||||
|
getTimelineRowPositionFromY,
|
||||||
|
} from "./timelineLayout";
|
||||||
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
|
||||||
import {
|
import {
|
||||||
TIMELINE_SNAP_PX,
|
TIMELINE_SNAP_PX,
|
||||||
@@ -27,6 +32,7 @@ export interface DragPreviewContext {
|
|||||||
pps: number;
|
pps: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
trackOrder: number[];
|
trackOrder: number[];
|
||||||
|
rowHeights?: readonly number[];
|
||||||
elements: TimelineElement[];
|
elements: TimelineElement[];
|
||||||
selectedKeys: ReadonlySet<string>;
|
selectedKeys: ReadonlySet<string>;
|
||||||
buildSnapTargets: BuildSnapTargets;
|
buildSnapTargets: BuildSnapTargets;
|
||||||
@@ -81,20 +87,26 @@ function resolveDropPlacement(
|
|||||||
desiredTrack: number,
|
desiredTrack: number,
|
||||||
ctx: DragPreviewContext,
|
ctx: DragPreviewContext,
|
||||||
): { track: number; insertRow: number | null } {
|
): { track: number; insertRow: number | null } {
|
||||||
const { scroll, trackOrder, elements } = ctx;
|
const { scroll, trackOrder, rowHeights, elements } = ctx;
|
||||||
// rowFloat = the pointer's position in track-heights from the top lane; a
|
// rowFloat = the pointer's position in track-heights from the top lane; a
|
||||||
// near-boundary hover requests a deliberate new-track insert. Uses the
|
// near-boundary hover requests a deliberate new-track insert. Uses the
|
||||||
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
||||||
const rowFloat = scroll
|
const rowPosition = scroll
|
||||||
? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop)
|
? getTimelineRowPositionFromY(
|
||||||
: 0;
|
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
|
||||||
// Geometry-exact band (the clip inset) so an insert only arms in the visible
|
rowHeights,
|
||||||
// gutter BETWEEN clip bodies — dragging over a clip body is a lane move, never a
|
)
|
||||||
// phantom insert (the plain-horizontal-drag misfire). See INSERT_BOUNDARY_BAND.
|
: { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) };
|
||||||
const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND);
|
// Geometry-exact band (the clip inset divided by this row's actual height) so
|
||||||
|
// an insert only arms in the visible gutter between clip bodies.
|
||||||
|
const rawInsertRow = resolveInsertRow(
|
||||||
|
rowPosition.rowFloat,
|
||||||
|
trackOrder.length,
|
||||||
|
getTimelineInsertBoundaryBand(rowPosition.rowHeight),
|
||||||
|
);
|
||||||
// Pointer sub-row half: when a drop must auto-create a track (aimed span
|
// Pointer sub-row half: when a drop must auto-create a track (aimed span
|
||||||
// occupied, no free lane), open it on the side the pointer is nearer.
|
// occupied, no free lane), open it on the side the pointer is nearer.
|
||||||
const preferInsertAbove = rowFloat - Math.floor(rowFloat) < 0.5;
|
const preferInsertAbove = rowPosition.fraction < 0.5;
|
||||||
const audioTracks =
|
const audioTracks =
|
||||||
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
|
||||||
return resolveZoneDropPlacement({
|
return resolveZoneDropPlacement({
|
||||||
@@ -120,24 +132,34 @@ export function computeDragPreview(
|
|||||||
): DraggedClipState {
|
): DraggedClipState {
|
||||||
const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
|
const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx;
|
||||||
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
|
||||||
|
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
|
||||||
|
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
|
||||||
|
const originRow = getTimelineRowFromY(
|
||||||
|
drag.originClientY - scrollRectTop + drag.originScrollTop,
|
||||||
|
ctx.rowHeights,
|
||||||
|
);
|
||||||
|
const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
|
||||||
|
// resolveTimelineMove's vertical axis is expressed in track-height units.
|
||||||
|
// Feeding cumulative row coordinates with a unit height preserves its existing
|
||||||
|
// threshold/create-track behavior while supporting variable pixel heights.
|
||||||
const nextMove = resolveTimelineMove(
|
const nextMove = resolveTimelineMove(
|
||||||
{
|
{
|
||||||
start: drag.element.start,
|
start: drag.element.start,
|
||||||
track: drag.element.track,
|
track: drag.element.track,
|
||||||
duration: drag.element.duration,
|
duration: drag.element.duration,
|
||||||
originClientX: drag.originClientX,
|
originClientX: drag.originClientX,
|
||||||
originClientY: drag.originClientY,
|
originClientY: originRow,
|
||||||
originScrollLeft: drag.originScrollLeft,
|
originScrollLeft: drag.originScrollLeft,
|
||||||
originScrollTop: drag.originScrollTop,
|
originScrollTop: 0,
|
||||||
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
||||||
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
currentScrollTop: 0,
|
||||||
pixelsPerSecond: pps,
|
pixelsPerSecond: pps,
|
||||||
trackHeight: TRACK_H,
|
trackHeight: 1,
|
||||||
maxStart: dragMaxStart,
|
maxStart: dragMaxStart,
|
||||||
trackOrder,
|
trackOrder,
|
||||||
},
|
},
|
||||||
clientX,
|
clientX,
|
||||||
clientY,
|
currentRow,
|
||||||
);
|
);
|
||||||
// The music track defines the beats, so it must not snap to them —
|
// The music track defines the beats, so it must not snap to them —
|
||||||
// but it still snaps to the playhead and other clip edges.
|
// but it still snaps to the playhead and other clip edges.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
|
||||||
@@ -139,28 +140,18 @@ export function resolveZoneDropPlacement(input: {
|
|||||||
return { track: placement.track, insertRow: null };
|
return { track: placement.track, insertRow: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback half-width (fraction of a track height) of the insert band straddling
|
|
||||||
* a lane boundary — used only when the caller passes no explicit band. Production
|
|
||||||
* threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
|
|
||||||
* inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
|
|
||||||
* NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
|
|
||||||
* back toward the old 0.32 (which armed an insert across ~64% of every row — the
|
|
||||||
* misfire that turned a plain horizontal drag into a phantom track insert).
|
|
||||||
*/
|
|
||||||
const INSERT_BAND = 3 / 48;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
* Decide whether a vertical drag is inserting a new track at a lane boundary.
|
||||||
* `rowFloat` is the pointer's position in track-height units from the top of the
|
* `rowFloat` is the pointer's position in track-height units from the top of the
|
||||||
* first lane (0 = top of lane 0). Returns the boundary row to insert at
|
* first lane (0 = top of lane 0). Returns the boundary row to insert at
|
||||||
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
* (0 = above the top lane, `trackCount` = below the bottom), or null when the
|
||||||
* pointer is over a lane's middle band (a normal move/target).
|
* pointer is over a lane's middle band (a normal move/target). The default band
|
||||||
|
* preserves collapsed-row behavior; production passes the concrete row's band.
|
||||||
*/
|
*/
|
||||||
export function resolveInsertRow(
|
export function resolveInsertRow(
|
||||||
rowFloat: number,
|
rowFloat: number,
|
||||||
trackCount: number,
|
trackCount: number,
|
||||||
band: number = INSERT_BAND,
|
band: number = INSERT_BOUNDARY_BAND,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (trackCount === 0) return 0;
|
if (trackCount === 0) return 0;
|
||||||
if (rowFloat <= 0) return 0;
|
if (rowFloat <= 0) return 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
RULER_H,
|
RULER_H,
|
||||||
TRACK_H,
|
TRACK_H,
|
||||||
|
LANE_H,
|
||||||
TRACKS_TOP_PAD,
|
TRACKS_TOP_PAD,
|
||||||
TRACKS_BOTTOM_PAD,
|
TRACKS_BOTTOM_PAD,
|
||||||
GUTTER,
|
GUTTER,
|
||||||
@@ -9,10 +10,84 @@ import {
|
|||||||
getTimelineRowTop,
|
getTimelineRowTop,
|
||||||
getTimelineScrubTime,
|
getTimelineScrubTime,
|
||||||
getTimelineRowFromY,
|
getTimelineRowFromY,
|
||||||
|
getTimelineRowOffsets,
|
||||||
getTimelineCanvasHeight,
|
getTimelineCanvasHeight,
|
||||||
|
trackHeights,
|
||||||
resolveTimelineAssetDrop,
|
resolveTimelineAssetDrop,
|
||||||
} from "./timelineLayout";
|
} from "./timelineLayout";
|
||||||
|
|
||||||
|
describe("variable timeline row geometry", () => {
|
||||||
|
const tracks = [
|
||||||
|
[{ clipId: "a", laneCount: 0 }],
|
||||||
|
[{ clipId: "b", laneCount: 2 }],
|
||||||
|
[{ clipId: "c", laneCount: 1 }],
|
||||||
|
];
|
||||||
|
|
||||||
|
it("resolves every row to the base height when no clip is expanded", () => {
|
||||||
|
expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
|
||||||
|
expect(trackHeights(3)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds one lane height per lane on an expanded clip", () => {
|
||||||
|
expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives row tops from cumulative offsets", () => {
|
||||||
|
const heights = trackHeights(tracks, new Set(["b"]));
|
||||||
|
expect(getTimelineRowOffsets(heights)).toEqual([
|
||||||
|
0,
|
||||||
|
TRACK_H,
|
||||||
|
2 * TRACK_H + 2 * LANE_H,
|
||||||
|
3 * TRACK_H + 2 * LANE_H,
|
||||||
|
]);
|
||||||
|
expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps y inside an expanded lane region back to the expanded track", () => {
|
||||||
|
const heights = trackHeights(tracks, new Set(["b"]));
|
||||||
|
const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5;
|
||||||
|
const row = getTimelineRowFromY(yInSecondExpandedLane, heights);
|
||||||
|
expect(Math.floor(row)).toBe(1);
|
||||||
|
expect(row).toBeGreaterThan(1.5);
|
||||||
|
expect(row).toBeLessThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums resolved row heights into the canvas height", () => {
|
||||||
|
const heights = trackHeights(tracks, new Set(["b"]));
|
||||||
|
expect(getTimelineCanvasHeight(heights)).toBe(
|
||||||
|
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collapsed timeline row geometry characterization", () => {
|
||||||
|
it.each([
|
||||||
|
[0, 74],
|
||||||
|
[1, 122],
|
||||||
|
[4, 266],
|
||||||
|
])("keeps row %i at content y=%i", (row, expectedTop) => {
|
||||||
|
expect(getTimelineRowTop(row)).toBe(expectedTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[74, 0],
|
||||||
|
[86, 0.25],
|
||||||
|
[146, 1.5],
|
||||||
|
[290, 4.5],
|
||||||
|
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
|
||||||
|
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 146],
|
||||||
|
[1, 194],
|
||||||
|
[3, 290],
|
||||||
|
[5, 386],
|
||||||
|
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
|
||||||
|
expect(getTimelineCanvasHeight(trackCount)).toBe(expectedHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("track-area breathing pad y-math", () => {
|
describe("track-area breathing pad y-math", () => {
|
||||||
describe("getTimelineRowTop", () => {
|
describe("getTimelineRowTop", () => {
|
||||||
it("offsets the first lane below the ruler by the top pad", () => {
|
it("offsets the first lane below the ruler by the top pad", () => {
|
||||||
|
|||||||
@@ -4,25 +4,20 @@ import type { ZoomMode } from "../store/playerStore";
|
|||||||
/* ── Layout constants ──────────────────────────────────────────────── */
|
/* ── Layout constants ──────────────────────────────────────────────── */
|
||||||
export const GUTTER = 32;
|
export const GUTTER = 32;
|
||||||
export const TRACK_H = 48;
|
export const TRACK_H = 48;
|
||||||
|
export const LANE_H = 28;
|
||||||
export const RULER_H = 24;
|
export const RULER_H = 24;
|
||||||
export const CLIP_Y = 3;
|
export const CLIP_Y = 3;
|
||||||
export const CLIP_HANDLE_W = 18;
|
export const CLIP_HANDLE_W = 18;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Half-width (as a fraction of TRACK_H) of the new-track INSERT band that
|
* Collapsed-row characterization value for the new-track INSERT band. Runtime
|
||||||
* straddles each lane boundary. Deliberately equals the clip's vertical inset
|
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
|
||||||
* (`CLIP_Y / TRACK_H`): a clip body fills [CLIP_Y, TRACK_H − CLIP_Y] of its row,
|
|
||||||
* so the ONLY region this band covers is the visible empty gutter between two
|
|
||||||
* clip bodies (plus the top/bottom breathing pads, handled separately by the
|
|
||||||
* rowFloat ≤ 0 / ≥ trackCount extremes). Aiming at a clip body is therefore a
|
|
||||||
* move-to-that-lane; only the inter-clip gap arms an insert — see resolveInsertRow.
|
|
||||||
* Threaded into resolveInsertRow by the drag preview so the hit band can never
|
|
||||||
* drift from the rendered clip geometry.
|
|
||||||
*/
|
*/
|
||||||
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
|
||||||
/**
|
/**
|
||||||
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
* Breathing room INSIDE the scroll area (CapCut-style), threaded through every
|
||||||
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
* track-row y computation via {@link getTimelineRowTop} — never inline a magic
|
||||||
* offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
|
* offset; a track row's top is always ruler + top pad + cumulative row heights.
|
||||||
*
|
*
|
||||||
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
||||||
* (~half a track height) so the first clip isn't jammed under the ruler.
|
* (~half a track height) so the first clip isn't jammed under the ruler.
|
||||||
@@ -50,17 +45,108 @@ export const TRACKS_LEFT_PAD = 48;
|
|||||||
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
* placeholder/insertion top and every pointer-y→row inversion goes through this
|
||||||
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
* (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
|
||||||
*/
|
*/
|
||||||
export function getTimelineRowTop(row: number): number {
|
interface TimelineTrackHeightClip {
|
||||||
return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
|
clipId: string;
|
||||||
|
laneCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve each track's full height. Without expansion state every row is the
|
||||||
|
* legacy TRACK_H; if multiple clips in one track expand, the tallest one owns
|
||||||
|
* the shared row height.
|
||||||
|
*/
|
||||||
|
export function trackHeights(
|
||||||
|
tracks: number | TimelineTrackHeightInput,
|
||||||
|
expandedClipIds?: ReadonlySet<string>,
|
||||||
|
): number[] {
|
||||||
|
if (typeof tracks === "number") {
|
||||||
|
return Array.from({ length: Math.max(0, Math.trunc(tracks)) }, () => TRACK_H);
|
||||||
|
}
|
||||||
|
return tracks.map((clips) => {
|
||||||
|
let laneCount = 0;
|
||||||
|
if (expandedClipIds) {
|
||||||
|
for (const clip of clips) {
|
||||||
|
if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRowHeight(height: number | undefined): number {
|
||||||
|
if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cumulative top offsets, including the final bottom boundary. */
|
||||||
|
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
||||||
|
const offsets = [0];
|
||||||
|
for (const height of rowHeights) {
|
||||||
|
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
||||||
|
}
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number {
|
||||||
|
return validRowHeight(rowHeights[row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
|
||||||
|
if (rowHeights.length === 0) return row * TRACK_H;
|
||||||
|
const offsets = getTimelineRowOffsets(rowHeights);
|
||||||
|
if (row <= 0) return row * getTimelineRowHeight(0, rowHeights);
|
||||||
|
if (row >= rowHeights.length) {
|
||||||
|
return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H;
|
||||||
|
}
|
||||||
|
const wholeRow = Math.floor(row);
|
||||||
|
const fraction = row - wholeRow;
|
||||||
|
return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number {
|
||||||
|
return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
|
* Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
|
||||||
* space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
|
* space y (used for insert-row / drop-lane decisions). Locates the concrete row
|
||||||
* top pad before dividing by the track height.
|
* from cumulative offsets, then returns its local fractional position.
|
||||||
*/
|
*/
|
||||||
export function getTimelineRowFromY(contentY: number): number {
|
export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number {
|
||||||
return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
|
const y = contentY - RULER_H - TRACKS_TOP_PAD;
|
||||||
|
if (rowHeights.length === 0) return y / TRACK_H;
|
||||||
|
if (y < 0) return y / getTimelineRowHeight(0, rowHeights);
|
||||||
|
|
||||||
|
const offsets = getTimelineRowOffsets(rowHeights);
|
||||||
|
for (let row = 0; row < rowHeights.length; row += 1) {
|
||||||
|
const bottom = offsets[row + 1] ?? 0;
|
||||||
|
if (y < bottom) {
|
||||||
|
const top = offsets[row] ?? 0;
|
||||||
|
return row + (y - top) / getTimelineRowHeight(row, rowHeights);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimelineRowPositionFromY(
|
||||||
|
contentY: number,
|
||||||
|
rowHeights: readonly number[] = [],
|
||||||
|
): { rowFloat: number; row: number; fraction: number; rowHeight: number } {
|
||||||
|
const rowFloat = getTimelineRowFromY(contentY, rowHeights);
|
||||||
|
const row = Math.floor(rowFloat);
|
||||||
|
return {
|
||||||
|
rowFloat,
|
||||||
|
row,
|
||||||
|
fraction: rowFloat - row,
|
||||||
|
rowHeight: getTimelineRowHeight(row, rowHeights),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional insert band for the concrete row under a pointer. */
|
||||||
|
export function getTimelineInsertBoundaryBand(rowHeight: number): number {
|
||||||
|
return CLIP_Y / validRowHeight(rowHeight);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* While a clip drag is live, the rendered timeline extends this far past the
|
* While a clip drag is live, the rendered timeline extends this far past the
|
||||||
@@ -344,11 +430,16 @@ export function getTimelineScrubTime(input: {
|
|||||||
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTimelineCanvasHeight(trackCount: number): number {
|
export function getTimelineCanvasHeight(trackCountOrHeights: number | readonly number[]): number {
|
||||||
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
||||||
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
||||||
// below the last lane is real scrollable surface, not a hidden buffer.
|
// below the last lane is real scrollable surface, not a hidden buffer.
|
||||||
return RULER_H + TRACKS_TOP_PAD + Math.max(0, trackCount) * TRACK_H + TRACKS_BOTTOM_PAD;
|
const heights =
|
||||||
|
typeof trackCountOrHeights === "number"
|
||||||
|
? trackHeights(trackCountOrHeights)
|
||||||
|
: trackCountOrHeights;
|
||||||
|
const rowsHeight = getTimelineRowOffsets(heights).at(-1) ?? 0;
|
||||||
|
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── UI helpers ───────────────────────────────────────────────────── */
|
/* ── UI helpers ───────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "./timelineMarquee";
|
} from "./timelineMarquee";
|
||||||
import {
|
import {
|
||||||
GUTTER,
|
GUTTER,
|
||||||
|
LANE_H,
|
||||||
TRACK_H,
|
TRACK_H,
|
||||||
RULER_H,
|
RULER_H,
|
||||||
CLIP_Y,
|
CLIP_Y,
|
||||||
@@ -16,7 +17,9 @@ import {
|
|||||||
getTimelineRowTop,
|
getTimelineRowTop,
|
||||||
} from "./timelineLayout";
|
} from "./timelineLayout";
|
||||||
|
|
||||||
// Canvas-space time origin: right edge of the sticky gutter + the left pad.
|
// Canvas-space time origin used by the breathing-pad (default) test cases: right
|
||||||
|
// edge of the sticky gutter + the left pad. Other cases pass GUTTER or LABEL_COL_W
|
||||||
|
// directly as contentOrigin to test the plain/keyframe-label-column origins.
|
||||||
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
|
const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
|
||||||
|
|
||||||
describe("isTimelineRulerPress", () => {
|
describe("isTimelineRulerPress", () => {
|
||||||
@@ -94,9 +97,9 @@ describe("getTimelineClipRect", () => {
|
|||||||
const trackOrder = [0, 2, 5];
|
const trackOrder = [0, 2, 5];
|
||||||
|
|
||||||
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
|
||||||
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
|
const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100, GUTTER);
|
||||||
expect(rect).toEqual({
|
expect(rect).toEqual({
|
||||||
left: ORIGIN + 200,
|
left: GUTTER + 200,
|
||||||
top: getTimelineRowTop(1) + CLIP_Y,
|
top: getTimelineRowTop(1) + CLIP_Y,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: TRACK_H - CLIP_Y * 2,
|
height: TRACK_H - CLIP_Y * 2,
|
||||||
@@ -104,25 +107,55 @@ describe("getTimelineClipRect", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("places the first visible track below the ruler + top breathing pad", () => {
|
it("places the first visible track below the ruler + top breathing pad", () => {
|
||||||
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
|
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50, GUTTER);
|
||||||
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
|
||||||
expect(rect?.left).toBe(ORIGIN);
|
expect(rect?.left).toBe(GUTTER);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the row index in trackOrder, not the raw track number", () => {
|
it("uses the row index in trackOrder, not the raw track number", () => {
|
||||||
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50);
|
const rect = getTimelineClipRect({ start: 0, duration: 1, track: 5 }, trackOrder, 50, GUTTER);
|
||||||
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
expect(rect?.top).toBe(getTimelineRowTop(2) + CLIP_Y);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses cumulative tops and the resolved height for an expanded row", () => {
|
||||||
|
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
||||||
|
const rect = getTimelineClipRect(
|
||||||
|
{ start: 0, duration: 1, track: 0 },
|
||||||
|
trackOrder,
|
||||||
|
50,
|
||||||
|
GUTTER,
|
||||||
|
rowHeights,
|
||||||
|
);
|
||||||
|
expect(rect).toMatchObject({
|
||||||
|
top: getTimelineRowTop(0, rowHeights) + CLIP_Y,
|
||||||
|
height: rowHeights[0] - CLIP_Y * 2,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
getTimelineClipRect({ start: 0, duration: 1, track: 2 }, trackOrder, 50, GUTTER, rowHeights)
|
||||||
|
?.top,
|
||||||
|
).toBe(getTimelineRowTop(1, rowHeights) + CLIP_Y);
|
||||||
|
});
|
||||||
|
|
||||||
it("enforces the 4px minimum rendered width", () => {
|
it("enforces the 4px minimum rendered width", () => {
|
||||||
const rect = getTimelineClipRect({ start: 0, duration: 0.01, track: 0 }, trackOrder, 10);
|
const rect = getTimelineClipRect(
|
||||||
|
{ start: 0, duration: 0.01, track: 0 },
|
||||||
|
trackOrder,
|
||||||
|
10,
|
||||||
|
GUTTER,
|
||||||
|
);
|
||||||
expect(rect?.width).toBe(4);
|
expect(rect?.width).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for a track that is not displayed or an invalid pps", () => {
|
it("returns null for a track that is not displayed or an invalid pps", () => {
|
||||||
expect(getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100)).toBeNull();
|
expect(
|
||||||
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull();
|
getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER),
|
||||||
expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull();
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0, GUTTER),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN, GUTTER),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,29 +173,48 @@ describe("computeMarqueeSelection", () => {
|
|||||||
|
|
||||||
it("selects only the clips the marquee rect intersects", () => {
|
it("selects only the clips the marquee rect intersects", () => {
|
||||||
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
|
clips,
|
||||||
|
trackOrder,
|
||||||
|
pps,
|
||||||
|
contentOrigin: ORIGIN,
|
||||||
|
marquee,
|
||||||
|
});
|
||||||
expect(ids).toEqual(new Set(["a"]));
|
expect(ids).toEqual(new Set(["a"]));
|
||||||
expect(primaryId).toBe("a");
|
expect(primaryId).toBe("a");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects across tracks when the rect spans multiple rows", () => {
|
it("selects across tracks when the rect spans multiple rows", () => {
|
||||||
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
|
||||||
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
const { ids } = computeMarqueeSelection({
|
||||||
|
clips,
|
||||||
|
trackOrder,
|
||||||
|
pps,
|
||||||
|
contentOrigin: ORIGIN,
|
||||||
|
marquee,
|
||||||
|
});
|
||||||
expect(ids).toEqual(new Set(["a", "c"]));
|
expect(ids).toEqual(new Set(["a", "c"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes clips outside the rect horizontally", () => {
|
it("excludes clips outside the rect horizontally", () => {
|
||||||
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
|
const { ids } = computeMarqueeSelection({
|
||||||
|
clips,
|
||||||
|
trackOrder,
|
||||||
|
pps,
|
||||||
|
contentOrigin: ORIGIN,
|
||||||
|
marquee,
|
||||||
|
});
|
||||||
expect(ids).toEqual(new Set());
|
expect(ids).toEqual(new Set());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
|
||||||
const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
|
const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
trackOrder,
|
||||||
pps,
|
pps,
|
||||||
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
baseSelection: ["b"],
|
baseSelection: ["b"],
|
||||||
});
|
});
|
||||||
@@ -171,11 +223,12 @@ describe("computeMarqueeSelection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
it("unions additive base selection with new hits; primary comes from the marquee", () => {
|
||||||
const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
|
const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
|
||||||
const { ids, primaryId } = computeMarqueeSelection({
|
const { ids, primaryId } = computeMarqueeSelection({
|
||||||
clips,
|
clips,
|
||||||
trackOrder,
|
trackOrder,
|
||||||
pps,
|
pps,
|
||||||
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
baseSelection: ["b"],
|
baseSelection: ["b"],
|
||||||
});
|
});
|
||||||
@@ -186,12 +239,13 @@ describe("computeMarqueeSelection", () => {
|
|||||||
it("shrinking the rect live drops clips it no longer covers", () => {
|
it("shrinking the rect live drops clips it no longer covers", () => {
|
||||||
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
|
||||||
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
|
||||||
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
|
expect(
|
||||||
new Set(["a", "b"]),
|
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids,
|
||||||
);
|
).toEqual(new Set(["a", "b"]));
|
||||||
expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual(
|
expect(
|
||||||
new Set(["a"]),
|
computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow })
|
||||||
);
|
.ids,
|
||||||
|
).toEqual(new Set(["a"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores clips on hidden/undisplayed tracks", () => {
|
it("ignores clips on hidden/undisplayed tracks", () => {
|
||||||
@@ -200,6 +254,7 @@ describe("computeMarqueeSelection", () => {
|
|||||||
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
clips: [{ id: "x", start: 0, duration: 1, track: 7 }],
|
||||||
trackOrder,
|
trackOrder,
|
||||||
pps,
|
pps,
|
||||||
|
contentOrigin: GUTTER,
|
||||||
marquee,
|
marquee,
|
||||||
});
|
});
|
||||||
expect(ids).toEqual(new Set());
|
expect(ids).toEqual(new Set());
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
GUTTER,
|
GUTTER,
|
||||||
TRACK_H,
|
|
||||||
RULER_H,
|
RULER_H,
|
||||||
CLIP_Y,
|
CLIP_Y,
|
||||||
TRACKS_LEFT_PAD,
|
TRACKS_LEFT_PAD,
|
||||||
|
getTimelineRowHeight,
|
||||||
getTimelineRowTop,
|
getTimelineRowTop,
|
||||||
} from "./timelineLayout";
|
} from "./timelineLayout";
|
||||||
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
||||||
@@ -68,22 +68,24 @@ export function getMarqueeRect(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A clip's rendered rect in canvas/content coordinates (the same space the
|
* A clip's rendered rect in canvas/content coordinates (the same space the
|
||||||
* marquee rect lives in): x from GUTTER + start * pps, y from the clip's row
|
* marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
|
||||||
* index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y).
|
* index within the visible track order (cumulative row top + CLIP_Y).
|
||||||
* Returns null when the clip's track is not currently displayed.
|
* Returns null when the clip's track is not currently displayed.
|
||||||
*/
|
*/
|
||||||
export function getTimelineClipRect(
|
export function getTimelineClipRect(
|
||||||
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
||||||
trackOrder: number[],
|
trackOrder: number[],
|
||||||
pps: number,
|
pps: number,
|
||||||
|
contentOrigin: number = GUTTER + TRACKS_LEFT_PAD,
|
||||||
|
rowHeights: readonly number[] = [],
|
||||||
): Rect | null {
|
): Rect | null {
|
||||||
const row = trackOrder.indexOf(clip.track);
|
const row = trackOrder.indexOf(clip.track);
|
||||||
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
||||||
return {
|
return {
|
||||||
left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
|
left: contentOrigin + clip.start * pps,
|
||||||
top: getTimelineRowTop(row) + CLIP_Y,
|
top: getTimelineRowTop(row, rowHeights) + CLIP_Y,
|
||||||
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
||||||
height: TRACK_H - CLIP_Y * 2,
|
height: getTimelineRowHeight(row, rowHeights) - CLIP_Y * 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +105,21 @@ export function computeMarqueeSelection(input: {
|
|||||||
clips: MarqueeClipInput[];
|
clips: MarqueeClipInput[];
|
||||||
trackOrder: number[];
|
trackOrder: number[];
|
||||||
pps: number;
|
pps: number;
|
||||||
|
contentOrigin?: number;
|
||||||
marquee: Rect;
|
marquee: Rect;
|
||||||
baseSelection?: Iterable<string>;
|
baseSelection?: Iterable<string>;
|
||||||
|
rowHeights?: readonly number[];
|
||||||
}): MarqueeSelectionResult {
|
}): MarqueeSelectionResult {
|
||||||
const ids = new Set<string>(input.baseSelection ?? []);
|
const ids = new Set<string>(input.baseSelection ?? []);
|
||||||
let primaryId: string | null = null;
|
let primaryId: string | null = null;
|
||||||
for (const clip of input.clips) {
|
for (const clip of input.clips) {
|
||||||
const rect = getTimelineClipRect(clip, input.trackOrder, input.pps);
|
const rect = getTimelineClipRect(
|
||||||
|
clip,
|
||||||
|
input.trackOrder,
|
||||||
|
input.pps,
|
||||||
|
input.contentOrigin,
|
||||||
|
input.rowHeights,
|
||||||
|
);
|
||||||
if (rect && rectsOverlap(rect, input.marquee)) {
|
if (rect && rectsOverlap(rect, input.marquee)) {
|
||||||
ids.add(clip.id);
|
ids.add(clip.id);
|
||||||
primaryId = clip.id;
|
primaryId = clip.id;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface UseTimelineClipDragInput {
|
|||||||
ppsRef: React.RefObject<number>;
|
ppsRef: React.RefObject<number>;
|
||||||
durationRef: React.RefObject<number>;
|
durationRef: React.RefObject<number>;
|
||||||
trackOrderRef: React.RefObject<number[]>;
|
trackOrderRef: React.RefObject<number[]>;
|
||||||
|
rowHeightsRef?: React.RefObject<readonly number[]>;
|
||||||
onMoveElement?: (
|
onMoveElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
updates: Pick<TimelineElement, "start" | "track">,
|
updates: Pick<TimelineElement, "start" | "track">,
|
||||||
@@ -85,6 +86,7 @@ export function useTimelineClipDrag({
|
|||||||
ppsRef,
|
ppsRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
trackOrderRef,
|
trackOrderRef,
|
||||||
|
rowHeightsRef,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
onMoveElements,
|
onMoveElements,
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
@@ -241,13 +243,14 @@ export function useTimelineClipDrag({
|
|||||||
pps: ppsRef.current,
|
pps: ppsRef.current,
|
||||||
duration: durationRef.current,
|
duration: durationRef.current,
|
||||||
trackOrder: trackOrderRef.current,
|
trackOrder: trackOrderRef.current,
|
||||||
|
rowHeights: rowHeightsRef?.current,
|
||||||
elements: elementsRef.current,
|
elements: elementsRef.current,
|
||||||
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
selectedKeys: usePlayerStore.getState().selectedElementIds,
|
||||||
buildSnapTargets,
|
buildSnapTargets,
|
||||||
audioTracks: dragAudioTracksRef.current,
|
audioTracks: dragAudioTracksRef.current,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[scrollRef, ppsRef, durationRef, trackOrderRef, buildSnapTargets],
|
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
||||||
|
|||||||
Reference in New Issue
Block a user