diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad131e3a6..eff75a40e 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react"; import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; -import { usePlayerStore, type TimelineElement } from "./player"; +import { usePlayerStore } from "./player"; import { StudioOverlays } from "./components/StudioOverlays"; import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner"; import { useCaptionStore } from "./captions/store"; @@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; 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 { TimelineStackingReorderIntent } from "./player/components/timelineStacking"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; @@ -62,7 +65,6 @@ import { } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; -type TimelineMoveOperation = Parameters[2]; // fallow-ignore-next-line complexity export function StudioApp() { const { projectId, resolving, waitingForServer } = useServerConnection(); @@ -154,6 +156,7 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); + const invalidateGsapCacheRef = useRef<() => void>(() => {}); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, @@ -171,20 +174,11 @@ export function StudioApp() { sdkSession: editFlowSdkSession, publishSdkSession: sdkHandle.publish, forceReloadSdkSession: sdkHandle.forceReload, + invalidateGsapCache: () => invalidateGsapCacheRef.current(), handleDomZIndexReorderCommitRef, }); - const handleTimelineElementsMove = useCallback( - async ( - edits: Array<{ - element: TimelineElement; - updates: Pick & { - stackingReorder?: TimelineStackingReorderIntent | null; - }; - }>, - coalesceKey?: string, - operation: TimelineMoveOperation = "timing", - coalesceMs?: number, - ) => { + const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback( + async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => { const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove }; await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs); }, @@ -228,7 +222,6 @@ export function StudioApp() { const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); const resetKeyframesRef = useRef<() => boolean>(() => false); const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); - const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, diff --git a/packages/studio/src/hooks/timelineMoveAdapter.ts b/packages/studio/src/hooks/timelineMoveAdapter.ts index f5b9b2fb6..ee7544e8e 100644 --- a/packages/studio/src/hooks/timelineMoveAdapter.ts +++ b/packages/studio/src/hooks/timelineMoveAdapter.ts @@ -4,7 +4,7 @@ import type { TimelineGroupMoveChange, } from "./useTimelineGroupEditing"; -interface MoveEdit { +export interface TimelineMoveEdit { element: TimelineElement; updates: Pick; } @@ -18,8 +18,15 @@ interface AtomicMoveDeps { export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert"; +export type TimelineMoveEditsHandler = ( + edits: TimelineMoveEdit[], + coalesceKey?: string, + operation?: TimelineMoveOperation, + coalesceMs?: number, +) => Promise; + export function persistTimelineMoveEditsAtomically( - edits: MoveEdit[], + edits: TimelineMoveEdit[], coalesceKey: string | undefined, operation: TimelineMoveOperation, deps: AtomicMoveDeps, diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index bc88aca67..18d81aa55 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; +vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => { + const actual = + await importOriginal(); + 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; type ZIndexEntry = { @@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: { }) => Promise; reloadPreview?: () => void; sdkSession?: Awaited> | null; + publishSdkSession?: NonNullable[0]["publishSdkSession"]>; forceReloadSdkSession?: () => void; + invalidateGsapCache?: () => void; showToast?: (message: string, kind?: string) => void; }): { move: ReturnType["handleTimelineElementMove"]; @@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: { pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), sdkSession: input.sdkSession, + publishSdkSession: input.publishSdkSession, forceReloadSdkSession: input.forceReloadSdkSession, + invalidateGsapCache: input.invalidateGsapCache, handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; @@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: { type TimelineRecordEdit = NonNullable< Parameters[0]["recordEdit"] >; +type TimelinePublishSdkSession = NonNullable< + Parameters[0]["publishSdkSession"] +>; function renderTimelineEditingHookWithLifecycle(input: { timelineElements: TimelineElement[]; @@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise { * with `gsapBody`. Returns the mock for call inspection. */ function stubProjectFetch(files: string | Record, gsapBody?: unknown) { - // Keep this test server's capability, file-read, and mutation routes together; - // splitting the fixture would obscure the request sequence asserted by callers. - // fallow-ignore-next-line complexity - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) { - return jsonResponse({ atomicOwnershipPairs: true }); - } - if (url.includes("/api/projects/p1/files/")) { - if (typeof files === "string") return jsonResponse({ content: files }); - const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); - return jsonResponse({ content: files[path] }); - } - 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] ?? ""); - return jsonResponse( - gsapBody ?? { mutated: false, scriptText: null, before: content, after: content }, - ); - } - throw new Error(`Unexpected fetch: ${url}`); - }); + const pathAfter = (url: string, marker: string) => + decodeURIComponent(url.split(marker)[1] ?? "index.html"); + const fileContent = (path: string) => (typeof files === "string" ? files : files[path]); + // One handler per route, so the mock itself stays a lookup: the request + // sequence callers assert on is still readable top to bottom. + const routes: Array<[marker: string, respond: (url: string) => Response]> = [ + [ + "/api/projects/p1/gsap-mutation-capabilities", + () => jsonResponse({ atomicOwnershipPairs: true }), + ], + [ + "/api/projects/p1/files/", + (url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }), + ], + [ + "/api/projects/p1/gsap-mutations/", + (url) => { + const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? ""; + return jsonResponse( + gsapBody ?? { mutated: false, scriptText: null, before: content, after: content }, + ); + }, + ], + ]; + const fetchMock = vi.fn( + async ( + input: Parameters[0], + _init?: Parameters[1], + ): Promise => { + 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); return fetchMock; } @@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: { return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook }; } +const SDK_KEYFRAMED_SOURCE = [ + `
`, + `
`, + `
`, + ``, +].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>(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(() => "published"), + invalidateGsapCache, + }); + return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile }; +} + /** Assert a lane write landed in both the live iframe DOM and the persisted file. */ function expectLanePersisted( iframe: HTMLIFrameElement, @@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => { 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 () => { // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a // pure lane change routed through onMoveElement (no onMoveElements wired) @@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => { + const source = [ + `
`, + `
`, + `
`, + `
`, + ``, + ].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>(async () => {}), + recordEdit: vi.fn(async () => {}), + sdkSession, + publishSdkSession: vi.fn(() => "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 () => { const files: Record = { "index.html": '
', diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 2c53299a2..bf846f06f 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -58,6 +58,7 @@ export function useTimelineEditing({ sdkSession, publishSdkSession, forceReloadSdkSession, + invalidateGsapCache, handleDomZIndexReorderCommitRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); @@ -118,6 +119,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, + invalidateGsapCache, isRecordingRef, pendingTimelineEditPathRef, previewIframeRef, @@ -184,21 +186,24 @@ export function useTimelineEditing({ ); }; 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 = () => - enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => - // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched - // 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 }, - }), + enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then( + finishMoveGsapSync, ); return reorderDone .then(() => { @@ -221,9 +226,10 @@ export function useTimelineEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, }, - { label: "Move timeline clip", coalesceKey }, + { label: "Move timeline clip", coalesceKey, skipRefresh: true }, ).then((result) => { if (!cutoverCommittedOrThrow(result)) return moveFallback(); + return finishMoveGsapSync(); }); } return moveFallback(); @@ -250,6 +256,7 @@ export function useTimelineEditing({ timelineElements, handleDomZIndexReorderCommitRef, showToast, + invalidateGsapCache, ], ); @@ -287,23 +294,25 @@ export function useTimelineEditing({ // script (timing-only resize) — same no-flash path as move; full reload is // the fallback. 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 = () => - enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => - 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 }, - }, - }), + enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then( + finishResizeGsapSync, ); const persistDone = sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension @@ -323,9 +332,10 @@ export function useTimelineEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, }, - { label: "Resize timeline clip", coalesceKey }, + { label: "Resize timeline clip", coalesceKey, skipRefresh: true }, ).then((result) => { if (!cutoverCommittedOrThrow(result)) return resizeFallback(); + return finishResizeGsapSync(); }) : resizeFallback(); return persistDone.catch((error) => { @@ -346,6 +356,7 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, showToast, + invalidateGsapCache, ], ); diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index a39117706..bd3df209d 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -46,6 +46,8 @@ export interface UseTimelineEditingOptions { publishSdkSession?: PublishSdkSession; /** Resync the SDK session after a server-authoritative timeline write. */ forceReloadSdkSession?: () => void; + /** Reparse authored animations after a timing rewrite changes their positions. */ + invalidateGsapCache?: () => void; handleDomZIndexReorderCommitRef?: MutableRefObject; } diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 8a3fe83a8..2b6ac46e7 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions { domEditSaveTimestampRef: MutableRefObject; editQueueRef: MutableRefObject>; forceReloadSdkSession?: () => void; + invalidateGsapCache?: () => void; isRecordingRef?: RefObject; pendingTimelineEditPathRef: MutableRefObject>; previewIframeRef: RefObject; @@ -110,6 +111,7 @@ export function useTimelineGroupEditing({ domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, + invalidateGsapCache, isRecordingRef, pendingTimelineEditPathRef, previewIframeRef, @@ -212,7 +214,12 @@ export function useTimelineGroupEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), 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); }, @@ -282,25 +289,25 @@ export function useTimelineGroupEditing({ coalesceKey, coalesceMs, }); - if (handledBySdk) return; - - await persistServerBatch( - projectId, - "Move timeline clips", - changes.map((change) => ({ - element: change.element, - buildPatches: (original, target) => - buildTimelineMoveTimingPatch( - original, - target, - change.start, - change.element.duration, - change.track, - ), - })), - coalesceKey, - coalesceMs, - ); + if (!handledBySdk) { + await persistServerBatch( + projectId, + "Move timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineMoveTimingPatch( + original, + target, + change.start, + change.element.duration, + change.track, + ), + })), + coalesceKey, + coalesceMs, + ); + } // Track-only: no timing delta → no GSAP positions to shift and no // reload (see the trackOnly doc above). Mixed batches (any start // change) keep the full fallback below. @@ -323,6 +330,7 @@ export function useTimelineGroupEditing({ return shiftGsapPositions(projectId, changePath, domId, delta); }, }); + invalidateGsapCache?.(); }).catch((error) => { // Failed persist: revert the optimistic duration readout + live root // alongside the gesture owner's store rollback. @@ -340,6 +348,7 @@ export function useTimelineGroupEditing({ reloadPreview, trySdkBatchPersist, showToast, + invalidateGsapCache, ], ); @@ -384,23 +393,23 @@ export function useTimelineGroupEditing({ coalesceKey, coalesceMs, }); - if (handledBySdk) return; - - await persistServerBatch( - projectId, - "Resize timeline clips", - changes.map((change) => ({ - element: change.element, - buildPatches: (original, target) => - buildTimelineResizeTimingPatch(original, target, change.element, { - start: change.start, - duration: change.duration, - playbackStart: change.playbackStart, - }), - })), - coalesceKey, - coalesceMs, - ); + if (!handledBySdk) { + await persistServerBatch( + projectId, + "Resize timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineResizeTimingPatch(original, target, change.element, { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }), + })), + coalesceKey, + coalesceMs, + ); + } await finishGroupTimingGsapFallback({ projectId, iframe: previewIframeRef.current, @@ -428,6 +437,7 @@ export function useTimelineGroupEditing({ ); }, }); + invalidateGsapCache?.(); }).catch((error) => { // Failed persist: revert the optimistic duration readout + live root // alongside the gesture owner's store rollback. @@ -445,6 +455,7 @@ export function useTimelineGroupEditing({ reloadPreview, trySdkBatchPersist, showToast, + invalidateGsapCache, ], ); diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts index 5f9bae4ef..c71e9e73f 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -6,7 +6,7 @@ import { type DragPreviewContext, } from "./timelineClipDragPreview"; 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 @@ -55,13 +55,17 @@ function fakeScroll(): HTMLDivElement { } as unknown as HTMLDivElement; } -function ctx(): DragPreviewContext { +function ctx( + rowHeights?: readonly number[], + elements: TimelineElement[] = fixtureElements, +): DragPreviewContext { return { scroll: fakeScroll(), pps: PPS, duration: 44.5, trackOrder: [0, 1, 2], - elements: fixtureElements, + elements, + rowHeights, selectedKeys: new Set(), buildSnapTargets: () => [], audioTracks: new Set(), @@ -145,6 +149,49 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx()); 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", () => { diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 0e2d92b55..e5c054c03 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -1,6 +1,11 @@ import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing"; 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 { TIMELINE_SNAP_PX, @@ -27,6 +32,7 @@ export interface DragPreviewContext { pps: number; duration: number; trackOrder: number[]; + rowHeights?: readonly number[]; elements: TimelineElement[]; selectedKeys: ReadonlySet; buildSnapTargets: BuildSnapTargets; @@ -81,20 +87,26 @@ function resolveDropPlacement( desiredTrack: number, ctx: DragPreviewContext, ): { 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 // near-boundary hover requests a deliberate new-track insert. Uses the // shared row→y inverse so the top breathing pad is subtracted consistently. - const rowFloat = scroll - ? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop) - : 0; - // Geometry-exact band (the clip inset) so an insert only arms in the visible - // 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. - const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length, INSERT_BOUNDARY_BAND); + const rowPosition = scroll + ? getTimelineRowPositionFromY( + clientY - scroll.getBoundingClientRect().top + scroll.scrollTop, + rowHeights, + ) + : { rowFloat: 0, row: 0, fraction: 0, rowHeight: getTimelineRowHeight(0, rowHeights) }; + // 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 // 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 = ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track)); return resolveZoneDropPlacement({ @@ -120,24 +132,34 @@ export function computeDragPreview( ): DraggedClipState { const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx; 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( { start: drag.element.start, track: drag.element.track, duration: drag.element.duration, originClientX: drag.originClientX, - originClientY: drag.originClientY, + originClientY: originRow, originScrollLeft: drag.originScrollLeft, - originScrollTop: drag.originScrollTop, + originScrollTop: 0, currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft, - currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop, + currentScrollTop: 0, pixelsPerSecond: pps, - trackHeight: TRACK_H, + trackHeight: 1, maxStart: dragMaxStart, trackOrder, }, clientX, - clientY, + currentRow, ); // The music track defines the beats, so it must not snap to them — // but it still snaps to the playhead and other clip edges. diff --git a/packages/studio/src/player/components/timelineCollision.ts b/packages/studio/src/player/components/timelineCollision.ts index 1c9c6c5a5..c4f4c728e 100644 --- a/packages/studio/src/player/components/timelineCollision.ts +++ b/packages/studio/src/player/components/timelineCollision.ts @@ -1,4 +1,5 @@ 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 @@ -139,28 +140,18 @@ export function resolveZoneDropPlacement(input: { 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. * `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 * (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( rowFloat: number, trackCount: number, - band: number = INSERT_BAND, + band: number = INSERT_BOUNDARY_BAND, ): number | null { if (trackCount === 0) return 0; if (rowFloat <= 0) return 0; diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index dde3a5cfc..c21618faa 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { RULER_H, TRACK_H, + LANE_H, TRACKS_TOP_PAD, TRACKS_BOTTOM_PAD, GUTTER, @@ -9,10 +10,84 @@ import { getTimelineRowTop, getTimelineScrubTime, getTimelineRowFromY, + getTimelineRowOffsets, getTimelineCanvasHeight, + trackHeights, resolveTimelineAssetDrop, } 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("getTimelineRowTop", () => { it("offsets the first lane below the ruler by the top pad", () => { diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 9a1b51108..615df7d67 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -4,25 +4,20 @@ import type { ZoomMode } from "../store/playerStore"; /* ── Layout constants ──────────────────────────────────────────────── */ export const GUTTER = 32; export const TRACK_H = 48; +export const LANE_H = 28; export const RULER_H = 24; export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; + /** - * Half-width (as a fraction of TRACK_H) of the new-track INSERT band that - * straddles each lane boundary. Deliberately equals the clip's vertical inset - * (`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. + * Collapsed-row characterization value for the new-track INSERT band. Runtime + * hit-testing uses getTimelineInsertBoundaryBand with the concrete row height. */ export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H; /** * Breathing room INSIDE the scroll area (CapCut-style), threaded through every * 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 * (~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 * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift. */ -export function getTimelineRowTop(row: number): number { - return RULER_H + TRACKS_TOP_PAD + row * TRACK_H; +interface TimelineTrackHeightClip { + 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, +): 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- - * space y (used for insert-row / drop-lane decisions). Subtracts the ruler and - * top pad before dividing by the track height. + * space y (used for insert-row / drop-lane decisions). Locates the concrete row + * from cumulative offsets, then returns its local fractional position. */ -export function getTimelineRowFromY(contentY: number): number { - return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H; +export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number { + 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 @@ -344,11 +430,16 @@ export function getTimelineScrubTime(input: { 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 // 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. - 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 ───────────────────────────────────────────────────── */ diff --git a/packages/studio/src/player/components/timelineMarquee.test.ts b/packages/studio/src/player/components/timelineMarquee.test.ts index 732076edb..7c6673b55 100644 --- a/packages/studio/src/player/components/timelineMarquee.test.ts +++ b/packages/studio/src/player/components/timelineMarquee.test.ts @@ -9,6 +9,7 @@ import { } from "./timelineMarquee"; import { GUTTER, + LANE_H, TRACK_H, RULER_H, CLIP_Y, @@ -16,7 +17,9 @@ import { getTimelineRowTop, } 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; describe("isTimelineRulerPress", () => { @@ -94,9 +97,9 @@ describe("getTimelineClipRect", () => { 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", () => { - 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({ - left: ORIGIN + 200, + left: GUTTER + 200, top: getTimelineRowTop(1) + CLIP_Y, width: 300, height: TRACK_H - CLIP_Y * 2, @@ -104,25 +107,55 @@ describe("getTimelineClipRect", () => { }); 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?.left).toBe(ORIGIN); + expect(rect?.left).toBe(GUTTER); }); 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); }); + 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", () => { - 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); }); 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(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 0)).toBeNull(); - expect(getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, NaN)).toBeNull(); + expect( + getTimelineClipRect({ start: 0, duration: 1, track: 9 }, trackOrder, 100, GUTTER), + ).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", () => { 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(primaryId).toBe("a"); }); it("selects across tracks when the rect spans multiple rows", () => { 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"])); }); it("excludes clips outside the rect horizontally", () => { 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()); }); 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({ clips, trackOrder, pps, + contentOrigin: GUTTER, marquee, baseSelection: ["b"], }); @@ -171,11 +223,12 @@ describe("computeMarqueeSelection", () => { }); 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({ clips, trackOrder, pps, + contentOrigin: GUTTER, marquee, baseSelection: ["b"], }); @@ -186,12 +239,13 @@ describe("computeMarqueeSelection", () => { it("shrinking the rect live drops clips it no longer covers", () => { const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 }; const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 }; - expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual( - new Set(["a", "b"]), - ); - expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: narrow }).ids).toEqual( - new Set(["a"]), - ); + expect( + computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: wide }).ids, + ).toEqual(new Set(["a", "b"])); + expect( + computeMarqueeSelection({ clips, trackOrder, pps, contentOrigin: ORIGIN, marquee: narrow }) + .ids, + ).toEqual(new Set(["a"])); }); it("ignores clips on hidden/undisplayed tracks", () => { @@ -200,6 +254,7 @@ describe("computeMarqueeSelection", () => { clips: [{ id: "x", start: 0, duration: 1, track: 7 }], trackOrder, pps, + contentOrigin: GUTTER, marquee, }); expect(ids).toEqual(new Set()); diff --git a/packages/studio/src/player/components/timelineMarquee.ts b/packages/studio/src/player/components/timelineMarquee.ts index 21c18913d..9ddc02d9f 100644 --- a/packages/studio/src/player/components/timelineMarquee.ts +++ b/packages/studio/src/player/components/timelineMarquee.ts @@ -1,9 +1,9 @@ import { GUTTER, - TRACK_H, RULER_H, CLIP_Y, TRACKS_LEFT_PAD, + getTimelineRowHeight, getTimelineRowTop, } from "./timelineLayout"; 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 - * marquee rect lives in): x from GUTTER + start * pps, y from the clip's row - * index within the visible track order (RULER_H + row * TRACK_H + CLIP_Y). + * marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row + * index within the visible track order (cumulative row top + CLIP_Y). * Returns null when the clip's track is not currently displayed. */ export function getTimelineClipRect( clip: Pick, trackOrder: number[], pps: number, + contentOrigin: number = GUTTER + TRACKS_LEFT_PAD, + rowHeights: readonly number[] = [], ): Rect | null { const row = trackOrder.indexOf(clip.track); if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null; return { - left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps, - top: getTimelineRowTop(row) + CLIP_Y, + left: contentOrigin + clip.start * pps, + top: getTimelineRowTop(row, rowHeights) + CLIP_Y, 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[]; trackOrder: number[]; pps: number; + contentOrigin?: number; marquee: Rect; baseSelection?: Iterable; + rowHeights?: readonly number[]; }): MarqueeSelectionResult { const ids = new Set(input.baseSelection ?? []); let primaryId: string | null = null; 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)) { ids.add(clip.id); primaryId = clip.id; diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 31050ccca..90d410081 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -48,6 +48,7 @@ interface UseTimelineClipDragInput { ppsRef: React.RefObject; durationRef: React.RefObject; trackOrderRef: React.RefObject; + rowHeightsRef?: React.RefObject; onMoveElement?: ( element: TimelineElement, updates: Pick, @@ -85,6 +86,7 @@ export function useTimelineClipDrag({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, onMoveElement, onMoveElements, onResizeElement, @@ -241,13 +243,14 @@ export function useTimelineClipDrag({ pps: ppsRef.current, duration: durationRef.current, trackOrder: trackOrderRef.current, + rowHeights: rowHeightsRef?.current, elements: elementsRef.current, selectedKeys: usePlayerStore.getState().selectedElementIds, buildSnapTargets, 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