mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): unify timeline vertical reorder with z-index stacking
Timeline rows now order by scoped stacking (z-index per stacking context) instead of data-track-index, and dragging a clip up/down commits a targeted z-index change through the same shared path the layers panel uses. Both panels stay consistent and moving a clip actually changes front/back. data-track-index is demoted to time-overlap layout only; no bulk z-index injection (#958 intact). Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat, orphaned when keyframe dragging was removed) which surfaced while unifying the model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep StudioApp / the timeline hook / Timeline under the studio 600-LOC cap. U3: scoped stacking row order in the timeline U4: vertical drag commits z-index via the shared reorder commit U8: restore keyframe beat-snap on retime
This commit is contained in:
@@ -11,6 +11,7 @@ import { usePanelLayout } from "./hooks/usePanelLayout";
|
|||||||
import { useFileManager } from "./hooks/useFileManager";
|
import { useFileManager } from "./hooks/useFileManager";
|
||||||
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
||||||
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
||||||
|
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
|
||||||
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 { useSdkSession } from "./hooks/useSdkSession";
|
import { useSdkSession } from "./hooks/useSdkSession";
|
||||||
@@ -19,7 +20,7 @@ import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|||||||
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
||||||
import { useClipboard } from "./hooks/useClipboard";
|
import { useClipboard } from "./hooks/useClipboard";
|
||||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
||||||
import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection";
|
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
|
||||||
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
||||||
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
||||||
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
||||||
@@ -140,6 +141,7 @@ export function StudioApp() {
|
|||||||
});
|
});
|
||||||
const editHistory = usePersistentEditHistory({ projectId });
|
const editHistory = usePersistentEditHistory({ projectId });
|
||||||
const domEditSaveTimestampRef = useRef(0);
|
const domEditSaveTimestampRef = useRef(0);
|
||||||
|
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
|
||||||
const pendingTimelineEditPathRef = useRef(new Set<string>());
|
const pendingTimelineEditPathRef = useRef(new Set<string>());
|
||||||
const isGestureRecordingRef = useRef(false);
|
const isGestureRecordingRef = useRef(false);
|
||||||
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
|
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
@@ -188,6 +190,7 @@ export function StudioApp() {
|
|||||||
isRecordingRef: isGestureRecordingRef,
|
isRecordingRef: isGestureRecordingRef,
|
||||||
sdkSession: sdkHandle.session,
|
sdkSession: sdkHandle.session,
|
||||||
forceReloadSdkSession: sdkHandle.forceReload,
|
forceReloadSdkSession: sdkHandle.forceReload,
|
||||||
|
handleDomZIndexReorderCommitRef,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
activeBlockParams,
|
activeBlockParams,
|
||||||
@@ -306,19 +309,12 @@ export function StudioApp() {
|
|||||||
forceReloadSdkSession: sdkHandle.forceReload,
|
forceReloadSdkSession: sdkHandle.forceReload,
|
||||||
});
|
});
|
||||||
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
||||||
|
handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
|
||||||
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
||||||
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
|
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
|
||||||
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
|
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
|
||||||
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
|
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
|
||||||
deleteSelectedKeyframesRef.current = () => {
|
deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
|
||||||
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
|
||||||
const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
|
|
||||||
if (!a) return;
|
|
||||||
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
|
||||||
for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
|
|
||||||
domEditSession.handleGsapRemoveKeyframe(a.id, p);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useSdkSelectionSync(
|
useSdkSelectionSync(
|
||||||
sdkHandle.session,
|
sdkHandle.session,
|
||||||
domEditSession.domEditSelection,
|
domEditSession.domEditSelection,
|
||||||
|
|||||||
@@ -1,8 +1,99 @@
|
|||||||
import type { TimelineElement } from "../player/store/playerStore";
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||||
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
import {
|
||||||
|
formatTimelineAttributeNumber,
|
||||||
|
resolveTimelineStackingReorderByTargetTrack,
|
||||||
|
type TimelineStackingReorderIntent,
|
||||||
|
} from "../player/components/timelineEditing";
|
||||||
|
import { computeReorderZValues, getElementZIndex } from "../player/lib/layerOrdering";
|
||||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||||
|
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
|
||||||
import type { EditHistoryKind } from "../utils/editHistory";
|
import type { EditHistoryKind } from "../utils/editHistory";
|
||||||
|
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
||||||
|
|
||||||
|
function isHTMLElement(element: Element | null): element is HTMLElement {
|
||||||
|
return element != null && element instanceof HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a timeline vertical move to a z-index stacking reorder and commit it
|
||||||
|
* through the shared layers-panel reorder path. Reads live sibling z-index from
|
||||||
|
* the preview DOM, remaps with the dup-preserving reorder math, and writes only
|
||||||
|
* z-index (never data-track-index). No-op when the move isn't a reorder or the
|
||||||
|
* live siblings can't be resolved. Extracted from StudioApp's timeline hook to
|
||||||
|
* keep it under the studio 600-LOC cap.
|
||||||
|
*/
|
||||||
|
export function applyTimelineStackingReorder(input: {
|
||||||
|
element: TimelineElement;
|
||||||
|
targetTrack: number;
|
||||||
|
stackingReorder: TimelineStackingReorderIntent | null | undefined;
|
||||||
|
timelineElements: readonly TimelineElement[];
|
||||||
|
iframe: HTMLIFrameElement | null;
|
||||||
|
activeCompPath: string | null;
|
||||||
|
commit: TimelineZIndexReorderCommit | null | undefined;
|
||||||
|
keyOf: (element: TimelineElement) => string;
|
||||||
|
}): void {
|
||||||
|
const intent =
|
||||||
|
input.stackingReorder ??
|
||||||
|
(input.targetTrack !== input.element.track
|
||||||
|
? resolveTimelineStackingReorderByTargetTrack({
|
||||||
|
element: input.element,
|
||||||
|
elements: input.timelineElements,
|
||||||
|
targetTrack: input.targetTrack,
|
||||||
|
})
|
||||||
|
: null);
|
||||||
|
if (intent == null || intent.fromIndex === intent.toIndex) return;
|
||||||
|
|
||||||
|
const siblingByKey = new Map(input.timelineElements.map((el) => [input.keyOf(el), el]));
|
||||||
|
const orderedSiblings = intent.siblingKeys
|
||||||
|
.map((key) => siblingByKey.get(key) ?? null)
|
||||||
|
.filter((sibling): sibling is TimelineElement => sibling != null);
|
||||||
|
if (orderedSiblings.length !== intent.siblingKeys.length) return;
|
||||||
|
|
||||||
|
const liveEntries = orderedSiblings
|
||||||
|
.map((sibling) => ({ sibling, element: findTimelineElementInIframe(input.iframe, sibling) }))
|
||||||
|
.filter((entry): entry is { sibling: TimelineElement; element: HTMLElement } =>
|
||||||
|
isHTMLElement(entry.element),
|
||||||
|
);
|
||||||
|
if (liveEntries.length !== orderedSiblings.length) return;
|
||||||
|
|
||||||
|
const reordered = [...liveEntries];
|
||||||
|
const [moved] = reordered.splice(intent.fromIndex, 1);
|
||||||
|
if (!moved) return;
|
||||||
|
reordered.splice(intent.toIndex, 0, moved);
|
||||||
|
|
||||||
|
const existingValues = liveEntries.map((entry) => getElementZIndex(entry.element));
|
||||||
|
const zValues = computeReorderZValues(existingValues, intent.fromIndex, intent.toIndex);
|
||||||
|
input.commit?.(
|
||||||
|
reordered.map((entry, index) => ({
|
||||||
|
element: entry.element,
|
||||||
|
zIndex: zValues[index] ?? 0,
|
||||||
|
id: entry.sibling.domId ?? entry.sibling.id,
|
||||||
|
selector: entry.sibling.selector,
|
||||||
|
selectorIndex: entry.sibling.selectorIndex,
|
||||||
|
sourceFile: entry.sibling.sourceFile || input.activeCompPath || "index.html",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the keyframes currently selected in the player store from the active
|
||||||
|
* element's GSAP animation. Reads selection lazily so it stays correct when
|
||||||
|
* invoked from a ref callback. Extracted from StudioApp to keep it under the
|
||||||
|
* studio 600-LOC cap.
|
||||||
|
*/
|
||||||
|
export function deleteSelectedKeyframes(session: {
|
||||||
|
selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
|
||||||
|
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
||||||
|
}): void {
|
||||||
|
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
||||||
|
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
|
||||||
|
if (!animation) return;
|
||||||
|
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
||||||
|
for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
|
||||||
|
session.handleGsapRemoveKeyframe(animation.id, pct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act, useRef } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TimelineElement } from "../player";
|
||||||
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||||
|
import { useTimelineEditing } from "./useTimelineEditing";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
type ZIndexEntry = {
|
||||||
|
element: HTMLElement;
|
||||||
|
zIndex: number;
|
||||||
|
id?: string;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
sourceFile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createPreviewIframe(
|
||||||
|
clips: Array<{
|
||||||
|
id: string;
|
||||||
|
track: number;
|
||||||
|
style?: string;
|
||||||
|
}> = [
|
||||||
|
{ id: "front", track: 0 },
|
||||||
|
{ id: "back", track: 1 },
|
||||||
|
],
|
||||||
|
): HTMLIFrameElement {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
document.body.append(iframe);
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) throw new Error("Expected iframe document");
|
||||||
|
doc.body.innerHTML = clips
|
||||||
|
.map(
|
||||||
|
(clip) =>
|
||||||
|
`<div id="${clip.id}" data-start="0" data-duration="2" data-track-index="${clip.track}"${
|
||||||
|
clip.style ? ` style="${clip.style}"` : ""
|
||||||
|
}></div>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return iframe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
domId: input.id,
|
||||||
|
hfId: `hf-${input.id}`,
|
||||||
|
tag: "div",
|
||||||
|
start: 0,
|
||||||
|
duration: 2,
|
||||||
|
track: input.track,
|
||||||
|
zIndex: input.zIndex,
|
||||||
|
stackingContextId: "root",
|
||||||
|
parentCompositionId: null,
|
||||||
|
compositionAncestors: ["root"],
|
||||||
|
sourceFile: "index.html",
|
||||||
|
timingSource: "authored",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimelineEditingHook(input: {
|
||||||
|
timelineElements: TimelineElement[];
|
||||||
|
iframe: HTMLIFrameElement;
|
||||||
|
onZIndexCommit: (entries: ZIndexEntry[]) => void;
|
||||||
|
projectId?: string | null;
|
||||||
|
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||||
|
recordEdit?: (input: {
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
coalesceKey?: string;
|
||||||
|
files: Record<string, { before: string; after: string }>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
reloadPreview?: () => void;
|
||||||
|
}): {
|
||||||
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const commitRef = useRef(input.onZIndexCommit);
|
||||||
|
commitRef.current = input.onZIndexCommit;
|
||||||
|
const hook = useTimelineEditing({
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
activeCompPath: "index.html",
|
||||||
|
timelineElements: input.timelineElements,
|
||||||
|
showToast: vi.fn(),
|
||||||
|
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
||||||
|
recordEdit: input.recordEdit ?? vi.fn(),
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
reloadPreview: input.reloadPreview ?? vi.fn(),
|
||||||
|
previewIframeRef: { current: input.iframe },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
||||||
|
uploadProjectFiles: vi.fn(),
|
||||||
|
handleDomZIndexReorderCommitRef: commitRef,
|
||||||
|
});
|
||||||
|
move = hook.handleTimelineElementMove;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!move) throw new Error("Expected hook to expose move handler");
|
||||||
|
return {
|
||||||
|
move,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimelineEditingHookWithLifecycle(input: {
|
||||||
|
timelineElements: TimelineElement[];
|
||||||
|
iframe: HTMLIFrameElement;
|
||||||
|
commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
|
||||||
|
}): {
|
||||||
|
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const lifecycle = useElementLifecycleOps({
|
||||||
|
activeCompPath: "index.html",
|
||||||
|
showToast: vi.fn(),
|
||||||
|
writeProjectFile: vi.fn(),
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
editHistory: { recordEdit: vi.fn() },
|
||||||
|
projectIdRef: { current: "p1" },
|
||||||
|
reloadPreview: vi.fn(),
|
||||||
|
clearDomSelection: vi.fn(),
|
||||||
|
commitPositionPatchToHtml: input.commitPositionPatchToHtml,
|
||||||
|
});
|
||||||
|
const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
|
||||||
|
commitRef.current = lifecycle.handleDomZIndexReorderCommit;
|
||||||
|
const hook = useTimelineEditing({
|
||||||
|
projectId: null,
|
||||||
|
activeCompPath: "index.html",
|
||||||
|
timelineElements: input.timelineElements,
|
||||||
|
showToast: vi.fn(),
|
||||||
|
writeProjectFile: vi.fn(),
|
||||||
|
recordEdit: vi.fn(),
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
reloadPreview: vi.fn(),
|
||||||
|
previewIframeRef: { current: input.iframe },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
||||||
|
uploadProjectFiles: vi.fn(),
|
||||||
|
handleDomZIndexReorderCommitRef: commitRef,
|
||||||
|
});
|
||||||
|
move = hook.handleTimelineElementMove;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!move) throw new Error("Expected hook to expose move handler");
|
||||||
|
return {
|
||||||
|
move,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
||||||
|
if (typeof input === "string") return input;
|
||||||
|
if (input instanceof URL) return input.toString();
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncWork(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTimelineEditing timeline z-index reorder", () => {
|
||||||
|
it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
|
||||||
|
const iframe = createPreviewIframe([
|
||||||
|
{ id: "front", track: 0 },
|
||||||
|
{ id: "middle", track: 1 },
|
||||||
|
{ id: "back", track: 2 },
|
||||||
|
]);
|
||||||
|
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
||||||
|
const middle = timelineElement({ id: "middle", track: 1, zIndex: 0 });
|
||||||
|
const back = timelineElement({ id: "back", track: 2, zIndex: 0 });
|
||||||
|
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||||
|
const { move, unmount } = renderTimelineEditingHook({
|
||||||
|
timelineElements: [front, middle, back],
|
||||||
|
iframe,
|
||||||
|
onZIndexCommit: commit,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await move(back, { start: back.start, track: front.track });
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) throw new Error("Expected iframe document");
|
||||||
|
|
||||||
|
expect(commit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
|
||||||
|
["back", 3],
|
||||||
|
["front", 2],
|
||||||
|
["middle", 1],
|
||||||
|
]);
|
||||||
|
expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaps distinct z-index values onto the reordered sibling group", async () => {
|
||||||
|
const iframe = createPreviewIframe([
|
||||||
|
{ id: "front", track: 0, style: "position: relative; z-index: 10" },
|
||||||
|
{ id: "middle", track: 1, style: "position: relative; z-index: 5" },
|
||||||
|
{ id: "back", track: 2, style: "position: relative; z-index: 1" },
|
||||||
|
]);
|
||||||
|
const front = timelineElement({ id: "front", track: 0, zIndex: 10 });
|
||||||
|
const middle = timelineElement({ id: "middle", track: 1, zIndex: 5 });
|
||||||
|
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
|
||||||
|
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||||
|
const { move, unmount } = renderTimelineEditingHook({
|
||||||
|
timelineElements: [front, middle, back],
|
||||||
|
iframe,
|
||||||
|
onZIndexCommit: commit,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await move(back, { start: back.start, track: front.track });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([
|
||||||
|
["back", 10],
|
||||||
|
["front", 5],
|
||||||
|
["middle", 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared lifecycle commit so static clips receive position relative", async () => {
|
||||||
|
const iframe = createPreviewIframe([
|
||||||
|
{ id: "front", track: 0, style: "position: static" },
|
||||||
|
{ id: "back", track: 1, style: "position: static" },
|
||||||
|
]);
|
||||||
|
const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
|
||||||
|
const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
|
||||||
|
const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||||
|
const { move, unmount } = renderTimelineEditingHookWithLifecycle({
|
||||||
|
timelineElements: [front, back],
|
||||||
|
iframe,
|
||||||
|
commitPositionPatchToHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await move(back, { start: back.start, track: front.track });
|
||||||
|
await flushAsyncWork();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commitPositionPatchToHtml).toHaveBeenCalled();
|
||||||
|
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
|
||||||
|
{ type: "inline-style", property: "z-index", value: "2" },
|
||||||
|
{ type: "inline-style", property: "position", value: "relative" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
|
||||||
|
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||||
|
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
|
||||||
|
const commit = vi.fn<(entries: ZIndexEntry[]) => void>();
|
||||||
|
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||||
|
const recordEdit = vi.fn(async () => {});
|
||||||
|
const reloadPreview = vi.fn();
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async (
|
||||||
|
input: Parameters<typeof fetch>[0],
|
||||||
|
_init?: Parameters<typeof fetch>[1],
|
||||||
|
): Promise<Response> => {
|
||||||
|
const url = requestUrl(input);
|
||||||
|
if (url.includes("/api/projects/p1/files/")) {
|
||||||
|
return jsonResponse({
|
||||||
|
content: '<div id="clip" data-start="0" data-track-index="0"></div>',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch: ${url}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const { move, unmount } = renderTimelineEditingHook({
|
||||||
|
timelineElements: [clip],
|
||||||
|
iframe,
|
||||||
|
onZIndexCommit: commit,
|
||||||
|
projectId: "p1",
|
||||||
|
writeProjectFile,
|
||||||
|
recordEdit,
|
||||||
|
reloadPreview,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await move(clip, { start: 1.25, track: clip.track });
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) throw new Error("Expected iframe document");
|
||||||
|
expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
|
||||||
|
expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
|
||||||
|
expect(commit).not.toHaveBeenCalled();
|
||||||
|
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
|
||||||
|
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
|
||||||
|
expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
resolveDroppedAssetDuration,
|
resolveDroppedAssetDuration,
|
||||||
} from "../utils/studioHelpers";
|
} from "../utils/studioHelpers";
|
||||||
import {
|
import {
|
||||||
|
applyTimelineStackingReorder,
|
||||||
buildPatchTarget,
|
buildPatchTarget,
|
||||||
patchIframeDomTiming,
|
patchIframeDomTiming,
|
||||||
resolveResizePlaybackStart,
|
resolveResizePlaybackStart,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
scaleGsapPositions,
|
scaleGsapPositions,
|
||||||
} from "./timelineEditingHelpers";
|
} from "./timelineEditingHelpers";
|
||||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||||
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||||
import {
|
import {
|
||||||
useTimelineElementVisibilityEditing,
|
useTimelineElementVisibilityEditing,
|
||||||
useTimelineTrackVisibilityEditing,
|
useTimelineTrackVisibilityEditing,
|
||||||
@@ -39,6 +41,10 @@ import {
|
|||||||
import { sdkTimingPersist } from "../utils/sdkCutover";
|
import { sdkTimingPersist } from "../utils/sdkCutover";
|
||||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||||
|
|
||||||
|
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||||
|
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Hook ──
|
// ── Hook ──
|
||||||
|
|
||||||
export function useTimelineEditing({
|
export function useTimelineEditing({
|
||||||
@@ -56,6 +62,7 @@ export function useTimelineEditing({
|
|||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
sdkSession,
|
sdkSession,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
|
handleDomZIndexReorderCommitRef,
|
||||||
}: UseTimelineEditingOptions) {
|
}: UseTimelineEditingOptions) {
|
||||||
const projectIdRef = useRef(projectId);
|
const projectIdRef = useRef(projectId);
|
||||||
projectIdRef.current = projectId;
|
projectIdRef.current = projectId;
|
||||||
@@ -115,23 +122,35 @@ export function useTimelineEditing({
|
|||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
const handleTimelineElementMove = useCallback(
|
const handleTimelineElementMove = useCallback(
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
(element: TimelineElement, updates: TimelineMoveUpdates) => {
|
||||||
patchIframeDomTiming(previewIframeRef.current, element, [
|
|
||||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
|
||||||
["data-track-index", String(updates.track)],
|
|
||||||
]);
|
|
||||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
|
const startChanged = updates.start !== element.start;
|
||||||
|
|
||||||
|
if (startChanged) {
|
||||||
|
patchIframeDomTiming(previewIframeRef.current, element, [
|
||||||
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTimelineStackingReorder({
|
||||||
|
element,
|
||||||
|
targetTrack: updates.track,
|
||||||
|
stackingReorder: updates.stackingReorder,
|
||||||
|
timelineElements,
|
||||||
|
iframe: previewIframeRef.current,
|
||||||
|
activeCompPath,
|
||||||
|
commit: handleDomZIndexReorderCommitRef?.current,
|
||||||
|
keyOf: (el) => el.key ?? el.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startChanged) return;
|
||||||
|
|
||||||
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||||
let patched = applyPatchByTarget(original, target, {
|
return applyPatchByTarget(original, target, {
|
||||||
type: "attribute",
|
type: "attribute",
|
||||||
property: "start",
|
property: "start",
|
||||||
value: formatTimelineAttributeNumber(updates.start),
|
value: formatTimelineAttributeNumber(updates.start),
|
||||||
});
|
});
|
||||||
return applyPatchByTarget(patched, target, {
|
|
||||||
type: "attribute",
|
|
||||||
property: "track-index",
|
|
||||||
value: String(updates.track),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
// Server-path fallback (no SDK session): persist the attr patch, then
|
// Server-path fallback (no SDK session): persist the attr patch, then
|
||||||
// shift GSAP tween positions on the server and reload the preview — the
|
// shift GSAP tween positions on the server and reload the preview — the
|
||||||
@@ -155,7 +174,7 @@ export function useTimelineEditing({
|
|||||||
return sdkTimingPersist(
|
return sdkTimingPersist(
|
||||||
element.hfId,
|
element.hfId,
|
||||||
targetPath,
|
targetPath,
|
||||||
{ start: updates.start, trackIndex: updates.track },
|
{ start: updates.start },
|
||||||
sdkSession,
|
sdkSession,
|
||||||
{
|
{
|
||||||
editHistory: { recordEdit },
|
editHistory: { recordEdit },
|
||||||
@@ -183,6 +202,8 @@ export function useTimelineEditing({
|
|||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
|
timelineElements,
|
||||||
|
handleDomZIndexReorderCommitRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ interface RecordEditInput {
|
|||||||
files: Record<string, { before: string; after: string }>;
|
files: Record<string, { before: string; after: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TimelineZIndexReorderCommit = (
|
||||||
|
entries: Array<{
|
||||||
|
element: HTMLElement;
|
||||||
|
zIndex: number;
|
||||||
|
id?: string;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
sourceFile: string;
|
||||||
|
}>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export interface UseTimelineEditingOptions {
|
export interface UseTimelineEditingOptions {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
activeCompPath: string | null;
|
activeCompPath: string | null;
|
||||||
@@ -27,4 +38,5 @@ export interface UseTimelineEditingOptions {
|
|||||||
sdkSession?: Composition | null;
|
sdkSession?: Composition | null;
|
||||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||||
forceReloadSdkSession?: () => void;
|
forceReloadSdkSession?: () => void;
|
||||||
|
handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
shouldHandleTimelineDeleteKey,
|
shouldHandleTimelineDeleteKey,
|
||||||
shouldAutoScrollTimeline,
|
shouldAutoScrollTimeline,
|
||||||
} from "./Timeline";
|
} from "./Timeline";
|
||||||
|
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
||||||
import { RULER_H, TRACK_H } from "./timelineLayout";
|
import { RULER_H, TRACK_H } from "./timelineLayout";
|
||||||
import { formatTime } from "../lib/time";
|
import { formatTime } from "../lib/time";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
||||||
|
|
||||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -201,6 +203,99 @@ describe("Timeline provider boundary", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rowElement(input: {
|
||||||
|
id: string;
|
||||||
|
track: number;
|
||||||
|
zIndex?: number;
|
||||||
|
start?: number;
|
||||||
|
duration?: number;
|
||||||
|
stackingContextId?: string | null;
|
||||||
|
parentCompositionId?: string | null;
|
||||||
|
compositionAncestors?: string[];
|
||||||
|
}): TimelineElement {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
tag: "div",
|
||||||
|
start: input.start ?? 0,
|
||||||
|
duration: input.duration ?? 1,
|
||||||
|
track: input.track,
|
||||||
|
zIndex: input.zIndex ?? 0,
|
||||||
|
stackingContextId: input.stackingContextId ?? "root",
|
||||||
|
parentCompositionId: input.parentCompositionId ?? null,
|
||||||
|
compositionAncestors: input.compositionAncestors ?? ["root"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildStackingTimelineTracks", () => {
|
||||||
|
it("keeps no-track-index clips in DOM order when stacking ties", () => {
|
||||||
|
const tracks = buildStackingTimelineTracks([
|
||||||
|
rowElement({ id: "a", track: 0 }),
|
||||||
|
rowElement({ id: "b", track: 1 }),
|
||||||
|
rowElement({ id: "c", track: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders authored track-index rows by stacking order instead of numeric track order", () => {
|
||||||
|
const tracks = buildStackingTimelineTracks([
|
||||||
|
rowElement({ id: "dom-first", track: 2 }),
|
||||||
|
rowElement({ id: "dom-second", track: 0 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders explicit z-index rows top-to-front by descending z-index", () => {
|
||||||
|
const tracks = buildStackingTimelineTracks([
|
||||||
|
rowElement({ id: "back", track: 0, zIndex: 1 }),
|
||||||
|
rowElement({ id: "front", track: 1, zIndex: 10 }),
|
||||||
|
rowElement({ id: "middle", track: 2, zIndex: 5 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([1, 2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps nested sub-composition clips scoped below parent-level clips", () => {
|
||||||
|
const tracks = buildStackingTimelineTracks([
|
||||||
|
rowElement({ id: "root-low", track: 1, zIndex: 1 }),
|
||||||
|
rowElement({
|
||||||
|
id: "nested-high",
|
||||||
|
track: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
stackingContextId: "scene",
|
||||||
|
parentCompositionId: "scene",
|
||||||
|
compositionAncestors: ["root", "scene"],
|
||||||
|
}),
|
||||||
|
rowElement({ id: "root-front", track: 2, zIndex: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([2, 1, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps time-overlapping equal-rank clips on separate literal-track rows", () => {
|
||||||
|
const tracks = buildStackingTimelineTracks([
|
||||||
|
rowElement({ id: "first", track: 0, start: 0, duration: 2 }),
|
||||||
|
rowElement({ id: "second", track: 1, start: 1, duration: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tracks).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
tracks.map(([track, elements]) => [track, elements.map((element) => element.id)]),
|
||||||
|
).toEqual([
|
||||||
|
[0, ["first"]],
|
||||||
|
[1, ["second"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertPreviewTrackOrder", () => {
|
||||||
|
it("preserves top and bottom drag-preview row insertion without numeric resorting", () => {
|
||||||
|
expect(insertPreviewTrackOrder([5, 2, 0], -1)).toEqual([-1, 5, 2, 0]);
|
||||||
|
expect(insertPreviewTrackOrder([5, 2, 0], 6)).toEqual([5, 2, 0, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("generateTicks", () => {
|
describe("generateTicks", () => {
|
||||||
it("returns empty arrays for duration <= 0", () => {
|
it("returns empty arrays for duration <= 0", () => {
|
||||||
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
|
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||||
import { ClipContextMenu } from "./ClipContextMenu";
|
import { ClipContextMenu } from "./ClipContextMenu";
|
||||||
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
||||||
|
import { buildStackingTimelineTracks, insertPreviewTrackOrder } from "./timelineTrackOrder";
|
||||||
import {
|
import {
|
||||||
GUTTER,
|
GUTTER,
|
||||||
generateTicks,
|
generateTicks,
|
||||||
@@ -186,15 +187,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
return Number.isFinite(result) ? result : safeDur;
|
return Number.isFinite(result) ? result : safeDur;
|
||||||
}, [rawElements, duration]);
|
}, [rawElements, duration]);
|
||||||
|
|
||||||
const tracks = useMemo(() => {
|
const tracks = useMemo(() => buildStackingTimelineTracks(expandedElements), [expandedElements]);
|
||||||
const map = new Map<number, typeof expandedElements>();
|
|
||||||
for (const el of expandedElements) {
|
|
||||||
const list = map.get(el.track) ?? [];
|
|
||||||
list.push(el);
|
|
||||||
map.set(el.track, list);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
||||||
}, [expandedElements]);
|
|
||||||
|
|
||||||
const trackStyles = useMemo(() => {
|
const trackStyles = useMemo(() => {
|
||||||
const map = new Map<number, TrackVisualStyle>();
|
const map = new Map<number, TrackVisualStyle>();
|
||||||
@@ -207,6 +200,8 @@ export const Timeline = memo(function Timeline({
|
|||||||
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
||||||
const trackOrderRef = useRef(trackOrder);
|
const trackOrderRef = useRef(trackOrder);
|
||||||
trackOrderRef.current = trackOrder;
|
trackOrderRef.current = trackOrder;
|
||||||
|
const expandedElementsRef = useRef(expandedElements);
|
||||||
|
expandedElementsRef.current = expandedElements;
|
||||||
|
|
||||||
const ppsRef = useRef(100);
|
const ppsRef = useRef(100);
|
||||||
const durationRef = useRef(effectiveDuration);
|
const durationRef = useRef(effectiveDuration);
|
||||||
@@ -228,6 +223,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
ppsRef,
|
ppsRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
trackOrderRef,
|
trackOrderRef,
|
||||||
|
timelineElementsRef: expandedElementsRef,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onBlockedEditAttempt,
|
onBlockedEditAttempt,
|
||||||
@@ -242,7 +238,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
trackOrder.includes(draggedClip.previewTrack)
|
trackOrder.includes(draggedClip.previewTrack)
|
||||||
)
|
)
|
||||||
return trackOrder;
|
return trackOrder;
|
||||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
return insertPreviewTrackOrder(trackOrder, draggedClip.previewTrack);
|
||||||
}, [draggedClip, trackOrder]);
|
}, [draggedClip, trackOrder]);
|
||||||
|
|
||||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|||||||
pointerOffsetY: e.clientY - rect.top,
|
pointerOffsetY: e.clientY - rect.top,
|
||||||
previewStart: el.start,
|
previewStart: el.start,
|
||||||
previewTrack: el.track,
|
previewTrack: el.track,
|
||||||
|
previewStackingReorder: null,
|
||||||
snapBeatTime: null,
|
snapBeatTime: null,
|
||||||
started: false,
|
started: false,
|
||||||
});
|
});
|
||||||
@@ -450,6 +451,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|||||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||||
beatsActive={beatStripOnTrack}
|
beatsActive={beatStripOnTrack}
|
||||||
|
beatTimes={beatAnalysis?.beatTimes}
|
||||||
|
clipStart={previewElement.start}
|
||||||
|
clipDurationSeconds={previewElement.duration}
|
||||||
|
pixelsPerSecond={pps}
|
||||||
accentColor={clipStyle.accent}
|
accentColor={clipStyle.accent}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
currentPercentage={
|
currentPercentage={
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { memo, useRef, useState } from "react";
|
import { memo, useRef, useState } from "react";
|
||||||
import { BEAT_BAND_H } from "./BeatStrip";
|
import { BEAT_BAND_H } from "./BeatStrip";
|
||||||
import {
|
import {
|
||||||
|
clampToNeighbors,
|
||||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||||
previewClipPct,
|
previewClipPct,
|
||||||
resolveKeyframeDrag,
|
resolveKeyframeDrag,
|
||||||
} from "../../components/editor/keyframeDrag";
|
} from "../../components/editor/keyframeDrag";
|
||||||
|
import { snapKeyframePctToBeat } from "./timelineEditing";
|
||||||
|
|
||||||
interface KeyframeEntry {
|
interface KeyframeEntry {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@@ -28,6 +30,14 @@ interface TimelineClipDiamondsProps {
|
|||||||
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
||||||
* the bottom half so they clear the strip at the top. */
|
* the bottom half so they clear the strip at the top. */
|
||||||
beatsActive?: boolean;
|
beatsActive?: boolean;
|
||||||
|
/** Composition-time beat positions (same source the beat strip renders from).
|
||||||
|
* When present and `beatsActive`, a dragged keyframe snaps to the nearest beat. */
|
||||||
|
beatTimes?: number[];
|
||||||
|
/** Clip start / duration (seconds) + pixels-per-second, needed to map a
|
||||||
|
* dragged keyframe's clip-% to composition time for beat snapping. */
|
||||||
|
clipStart?: number;
|
||||||
|
clipDurationSeconds?: number;
|
||||||
|
pixelsPerSecond?: number;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
currentPercentage: number;
|
currentPercentage: number;
|
||||||
@@ -71,6 +81,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
clipWidthPx,
|
clipWidthPx,
|
||||||
clipHeightPx,
|
clipHeightPx,
|
||||||
beatsActive,
|
beatsActive,
|
||||||
|
beatTimes,
|
||||||
|
clipStart = 0,
|
||||||
|
clipDurationSeconds = 0,
|
||||||
|
pixelsPerSecond = 1,
|
||||||
accentColor,
|
accentColor,
|
||||||
isSelected,
|
isSelected,
|
||||||
currentPercentage,
|
currentPercentage,
|
||||||
@@ -121,6 +135,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||||
const canDrag = isSelected && !!onMoveKeyframe;
|
const canDrag = isSelected && !!onMoveKeyframe;
|
||||||
|
|
||||||
|
// Snap a dragged keyframe's clip-% to the nearest beat (within ~8px), then
|
||||||
|
// re-clamp to neighbours so the snap can't cross a sibling keyframe. No-op
|
||||||
|
// when the beat strip isn't active for this track or no beats are loaded.
|
||||||
|
const snapClipPctToBeat = (clipPct: number, draggedIndex: number): number => {
|
||||||
|
if (!beatsActive || !beatTimes || beatTimes.length === 0) return clipPct;
|
||||||
|
const snapped = snapKeyframePctToBeat(
|
||||||
|
{ start: clipStart, duration: clipDurationSeconds },
|
||||||
|
clipPct,
|
||||||
|
beatTimes,
|
||||||
|
pixelsPerSecond,
|
||||||
|
);
|
||||||
|
return clampToNeighbors(snapped, sortedClipPcts, draggedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@@ -195,14 +223,17 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
if (d.moved) {
|
if (d.moved) {
|
||||||
setPreview({
|
setPreview({
|
||||||
kfKey,
|
kfKey,
|
||||||
clipPct: previewClipPct({
|
clipPct: snapClipPctToBeat(
|
||||||
pointerDownX: d.startX,
|
previewClipPct({
|
||||||
pointerMoveX: e.clientX,
|
pointerDownX: d.startX,
|
||||||
clipWidthPx,
|
pointerMoveX: e.clientX,
|
||||||
draggedClipPct: d.fromClipPct,
|
clipWidthPx,
|
||||||
draggedIndex: i,
|
draggedClipPct: d.fromClipPct,
|
||||||
sortedClipPcts,
|
draggedIndex: i,
|
||||||
}),
|
sortedClipPcts,
|
||||||
|
}),
|
||||||
|
i,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,7 +269,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|||||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||||
else onClickKeyframe?.(kf.percentage);
|
else onClickKeyframe?.(kf.percentage);
|
||||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
onMoveKeyframe?.(elementId, d.fromClipPct, snapClipPctToBeat(res.toClipPct, i));
|
||||||
// A retime still targeted this exact diamond — park/select it at its
|
// A retime still targeted this exact diamond — park/select it at its
|
||||||
// new position, same as a plain click, or a drag that actually moved
|
// new position, same as a plain click, or a drag that actually moved
|
||||||
// something looks identical to one that silently did nothing.
|
// something looks identical to one that silently did nothing.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// fallow-ignore-file code-duplication
|
// fallow-ignore-file code-duplication
|
||||||
// fallow-ignore-file dead-code
|
// fallow-ignore-file dead-code
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared callback signatures for timeline editing operations.
|
* Shared callback signatures for timeline editing operations.
|
||||||
@@ -26,7 +26,9 @@ export interface TimelineDropCallbacks {
|
|||||||
export interface TimelineEditCallbacks {
|
export interface TimelineEditCallbacks {
|
||||||
onMoveElement?: (
|
onMoveElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
updates: Pick<TimelineElement, "start" | "track">,
|
updates: Pick<TimelineElement, "start" | "track"> & {
|
||||||
|
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||||
|
},
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onResizeElement?: (
|
onResizeElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveTimelineAutoScroll,
|
resolveTimelineAutoScroll,
|
||||||
resolveTimelineMove,
|
resolveTimelineMove,
|
||||||
resolveTimelineResize,
|
resolveTimelineResize,
|
||||||
|
snapKeyframePctToBeat,
|
||||||
type TimelinePromptElement,
|
type TimelinePromptElement,
|
||||||
} from "./timelineEditing";
|
} from "./timelineEditing";
|
||||||
|
|
||||||
@@ -155,6 +156,69 @@ describe("resolveTimelineMove", () => {
|
|||||||
),
|
),
|
||||||
).toEqual({ start: 2, track: 2 });
|
).toEqual({ start: 2, track: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves vertical stacking movement within the dragged clip's context siblings", () => {
|
||||||
|
const result = resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
track: 1,
|
||||||
|
duration: 2,
|
||||||
|
originClientX: 0,
|
||||||
|
originClientY: 0,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 8,
|
||||||
|
trackOrder: [0, 99, 1],
|
||||||
|
stackingElement: {
|
||||||
|
id: "root-back",
|
||||||
|
track: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
stackingContextId: "root",
|
||||||
|
parentCompositionId: null,
|
||||||
|
compositionAncestors: ["root"],
|
||||||
|
},
|
||||||
|
stackingElements: [
|
||||||
|
{
|
||||||
|
id: "root-front",
|
||||||
|
track: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
stackingContextId: "root",
|
||||||
|
parentCompositionId: null,
|
||||||
|
compositionAncestors: ["root"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nested-row",
|
||||||
|
track: 99,
|
||||||
|
zIndex: 100,
|
||||||
|
stackingContextId: "scene",
|
||||||
|
parentCompositionId: "scene",
|
||||||
|
compositionAncestors: ["root", "scene"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "root-back",
|
||||||
|
track: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
stackingContextId: "root",
|
||||||
|
parentCompositionId: null,
|
||||||
|
compositionAncestors: ["root"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
-72,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
start: 0,
|
||||||
|
track: 0,
|
||||||
|
stackingReorder: {
|
||||||
|
contextKey: "root",
|
||||||
|
fromIndex: 1,
|
||||||
|
toIndex: 0,
|
||||||
|
siblingKeys: ["root-front", "root-back"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hasPatchableTimelineTarget", () => {
|
describe("hasPatchableTimelineTarget", () => {
|
||||||
@@ -613,3 +677,34 @@ describe("buildPromptCopyText", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("snapKeyframePctToBeat", () => {
|
||||||
|
// el spans 0–10s, so clip-% maps to composition time as pct * 0.1s.
|
||||||
|
// At pps=100 the snap window is 8 / 100 = 0.08s.
|
||||||
|
const el = { start: 0, duration: 10 };
|
||||||
|
const beats = [2, 5, 8];
|
||||||
|
|
||||||
|
it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
|
||||||
|
// pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
|
||||||
|
expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a keyframe unchanged when no beat is within the window", () => {
|
||||||
|
// pct 55 → 5.5s, 0.5s from the nearest beat → free.
|
||||||
|
expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when there are no beats", () => {
|
||||||
|
expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
|
||||||
|
expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for a zero-duration clip", () => {
|
||||||
|
expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widens the snap window as zoom (pps) decreases", () => {
|
||||||
|
// pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
|
||||||
|
expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { formatTime } from "../lib/time";
|
import { formatTime } from "../lib/time";
|
||||||
import { roundToCenti } from "../../utils/rounding";
|
import { roundToCenti } from "../../utils/rounding";
|
||||||
|
import { resolveContextOrder, resolveStackingContextKey } from "../lib/layerOrdering";
|
||||||
|
|
||||||
const roundToCentiseconds = roundToCenti;
|
const roundToCentiseconds = roundToCenti;
|
||||||
|
|
||||||
@@ -7,6 +8,88 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timeline clip described for stacking-order math: its track (timeline row),
|
||||||
|
* resolved z-index, and stacking-context identity. Structurally satisfied by the
|
||||||
|
* app's TimelineElement.
|
||||||
|
*/
|
||||||
|
export interface TimelineStackingElement {
|
||||||
|
id: string;
|
||||||
|
key?: string;
|
||||||
|
track: number;
|
||||||
|
zIndex?: number;
|
||||||
|
stackingContextId?: string | null;
|
||||||
|
parentCompositionId?: string | null;
|
||||||
|
compositionAncestors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved vertical reorder: move the dragged clip from `fromIndex` to
|
||||||
|
* `toIndex` within its stacking context's ordered siblings (top = front). */
|
||||||
|
export interface TimelineStackingReorderIntent {
|
||||||
|
contextKey: string;
|
||||||
|
fromIndex: number;
|
||||||
|
toIndex: number;
|
||||||
|
siblingKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineStackingOrderItem {
|
||||||
|
key: string;
|
||||||
|
track: number;
|
||||||
|
zIndex: number;
|
||||||
|
stackingContextId: string | null;
|
||||||
|
parentCompositionId: string | null;
|
||||||
|
compositionAncestors: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStackingOrderItem(element: TimelineStackingElement): TimelineStackingOrderItem {
|
||||||
|
return {
|
||||||
|
key: element.key ?? element.id,
|
||||||
|
track: element.track,
|
||||||
|
zIndex: element.zIndex ?? 0,
|
||||||
|
stackingContextId: element.stackingContextId ?? null,
|
||||||
|
parentCompositionId: element.parentCompositionId ?? null,
|
||||||
|
compositionAncestors: element.compositionAncestors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered siblings of `element` within its own stacking context (z-index desc,
|
||||||
|
* DOM order tiebreak) — the unit a vertical reorder operates on. */
|
||||||
|
function resolveContextSiblings(
|
||||||
|
element: TimelineStackingElement,
|
||||||
|
elements: readonly TimelineStackingElement[],
|
||||||
|
): TimelineStackingOrderItem[] {
|
||||||
|
const contextKey = resolveStackingContextKey(toStackingOrderItem(element));
|
||||||
|
const items = elements
|
||||||
|
.map(toStackingOrderItem)
|
||||||
|
.filter((item) => resolveStackingContextKey(item) === contextKey);
|
||||||
|
return resolveContextOrder(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the reorder implied by dropping `element` onto `targetTrack` (the track
|
||||||
|
* of the sibling whose slot it lands in). Returns null when the element has no
|
||||||
|
* reorderable siblings or the target track matches no sibling.
|
||||||
|
*/
|
||||||
|
export function resolveTimelineStackingReorderByTargetTrack(args: {
|
||||||
|
element: TimelineStackingElement;
|
||||||
|
elements: readonly TimelineStackingElement[];
|
||||||
|
targetTrack: number;
|
||||||
|
}): TimelineStackingReorderIntent | null {
|
||||||
|
const orderedSiblings = resolveContextSiblings(args.element, args.elements);
|
||||||
|
if (orderedSiblings.length <= 1) return null;
|
||||||
|
const draggedKey = args.element.key ?? args.element.id;
|
||||||
|
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey);
|
||||||
|
if (fromIndex < 0) return null;
|
||||||
|
const toIndex = orderedSiblings.findIndex((sibling) => sibling.track === args.targetTrack);
|
||||||
|
if (toIndex < 0) return null;
|
||||||
|
return {
|
||||||
|
contextKey: resolveStackingContextKey(toStackingOrderItem(args.element)),
|
||||||
|
fromIndex,
|
||||||
|
toIndex,
|
||||||
|
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
|
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
|
||||||
const AUTO_SCROLL_EDGE_ZONE = 40;
|
const AUTO_SCROLL_EDGE_ZONE = 40;
|
||||||
const AUTO_SCROLL_MAX_SPEED = 12;
|
const AUTO_SCROLL_MAX_SPEED = 12;
|
||||||
@@ -25,6 +108,10 @@ export interface TimelineMoveInput {
|
|||||||
trackHeight: number;
|
trackHeight: number;
|
||||||
maxStart: number;
|
maxStart: number;
|
||||||
trackOrder: number[];
|
trackOrder: number[];
|
||||||
|
/** When provided, vertical movement is resolved as a z-index stacking reorder
|
||||||
|
* within `stackingElement`'s context instead of a raw track change. */
|
||||||
|
stackingElement?: TimelineStackingElement;
|
||||||
|
stackingElements?: TimelineStackingElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineResizeInput {
|
export interface TimelineResizeInput {
|
||||||
@@ -73,7 +160,7 @@ export function resolveTimelineMove(
|
|||||||
input: TimelineMoveInput,
|
input: TimelineMoveInput,
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
): { start: number; track: number } {
|
): { start: number; track: number; stackingReorder?: TimelineStackingReorderIntent } {
|
||||||
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
|
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
|
||||||
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
|
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
|
||||||
const deltaTime =
|
const deltaTime =
|
||||||
@@ -81,6 +168,33 @@ export function resolveTimelineMove(
|
|||||||
const trackDeltaRaw =
|
const trackDeltaRaw =
|
||||||
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
|
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
|
||||||
const deltaTrack = Math.round(trackDeltaRaw);
|
const deltaTrack = Math.round(trackDeltaRaw);
|
||||||
|
const nextStart = clamp(
|
||||||
|
roundToCentiseconds(input.start + deltaTime),
|
||||||
|
0,
|
||||||
|
Math.max(0, input.maxStart),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stacking mode: vertical movement reorders z-index within the dragged clip's
|
||||||
|
// stacking context (top = front), rather than changing the raw track number.
|
||||||
|
if (input.stackingElement && input.stackingElements) {
|
||||||
|
const orderedSiblings = resolveContextSiblings(input.stackingElement, input.stackingElements);
|
||||||
|
const draggedKey = input.stackingElement.key ?? input.stackingElement.id;
|
||||||
|
const fromIndex = orderedSiblings.findIndex((sibling) => sibling.key === draggedKey);
|
||||||
|
if (fromIndex >= 0 && orderedSiblings.length > 1) {
|
||||||
|
const toIndex = clamp(fromIndex + deltaTrack, 0, orderedSiblings.length - 1);
|
||||||
|
return {
|
||||||
|
start: nextStart,
|
||||||
|
track: orderedSiblings[toIndex]!.track,
|
||||||
|
stackingReorder: {
|
||||||
|
contextKey: resolveStackingContextKey(toStackingOrderItem(input.stackingElement)),
|
||||||
|
fromIndex,
|
||||||
|
toIndex,
|
||||||
|
siblingKeys: orderedSiblings.map((sibling) => sibling.key),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
|
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
|
||||||
const desiredTrackIndex = currentTrackIndex + deltaTrack;
|
const desiredTrackIndex = currentTrackIndex + deltaTrack;
|
||||||
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
|
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
|
||||||
@@ -106,7 +220,7 @@ export function resolveTimelineMove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
|
start: nextStart,
|
||||||
track: nextTrack,
|
track: nextTrack,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { type TimelineElement } from "../store/playerStore";
|
||||||
|
import {
|
||||||
|
resolveContextOrder,
|
||||||
|
resolveStackingContextKey,
|
||||||
|
type ContextOrderItem,
|
||||||
|
} from "../lib/layerOrdering";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure timeline track-ordering logic. Timeline rows are ordered by scoped
|
||||||
|
* stacking (z-index per stacking context, top = front), with data-track-index
|
||||||
|
* used only to split time-overlapping clips of equal rank onto separate rows.
|
||||||
|
* Extracted from Timeline.tsx to keep the component under the studio 600-LOC cap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TimelineTrackOrderItem extends ContextOrderItem {
|
||||||
|
key: string;
|
||||||
|
track: number;
|
||||||
|
start: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineElementKey(element: TimelineElement): string {
|
||||||
|
return element.key ?? element.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimelineTrackOrderItem(element: TimelineElement): TimelineTrackOrderItem {
|
||||||
|
return {
|
||||||
|
key: getTimelineElementKey(element),
|
||||||
|
track: element.track,
|
||||||
|
start: element.start,
|
||||||
|
duration: element.duration,
|
||||||
|
zIndex: element.zIndex ?? 0,
|
||||||
|
stackingContextId: element.stackingContextId ?? null,
|
||||||
|
parentCompositionId: element.parentCompositionId ?? null,
|
||||||
|
compositionAncestors: element.compositionAncestors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineElementsOverlap(
|
||||||
|
a: Pick<TimelineElement, "start" | "duration">,
|
||||||
|
b: Pick<TimelineElement, "start" | "duration">,
|
||||||
|
): boolean {
|
||||||
|
return a.start < b.start + b.duration && b.start < a.start + a.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackFrontOrderIndex(
|
||||||
|
elements: readonly TimelineElement[],
|
||||||
|
orderIndexByKey: ReadonlyMap<string, number>,
|
||||||
|
): number {
|
||||||
|
let orderIndex = Number.POSITIVE_INFINITY;
|
||||||
|
for (const element of elements) {
|
||||||
|
orderIndex = Math.min(
|
||||||
|
orderIndex,
|
||||||
|
orderIndexByKey.get(getTimelineElementKey(element)) ?? Number.POSITIVE_INFINITY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orderIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOverlappingEqualRankElements(
|
||||||
|
aElements: readonly TimelineElement[],
|
||||||
|
bElements: readonly TimelineElement[],
|
||||||
|
): boolean {
|
||||||
|
for (const a of aElements) {
|
||||||
|
const aOrderItem = toTimelineTrackOrderItem(a);
|
||||||
|
const aContextKey = resolveStackingContextKey(aOrderItem);
|
||||||
|
for (const b of bElements) {
|
||||||
|
const bOrderItem = toTimelineTrackOrderItem(b);
|
||||||
|
if (aContextKey !== resolveStackingContextKey(bOrderItem)) continue;
|
||||||
|
if (aOrderItem.zIndex !== bOrderItem.zIndex) continue;
|
||||||
|
if (timelineElementsOverlap(a, b)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStackingTimelineTracks(
|
||||||
|
elements: readonly TimelineElement[],
|
||||||
|
): Array<[number, TimelineElement[]]> {
|
||||||
|
const tracks = new Map<number, TimelineElement[]>();
|
||||||
|
for (const element of elements) {
|
||||||
|
const list = tracks.get(element.track) ?? [];
|
||||||
|
list.push(element);
|
||||||
|
tracks.set(element.track, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedElements = resolveContextOrder(elements.map(toTimelineTrackOrderItem));
|
||||||
|
const orderIndexByKey = new Map<string, number>();
|
||||||
|
orderedElements.forEach((element, index) => {
|
||||||
|
orderIndexByKey.set(element.key, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tracks.entries()).sort(([aTrack, aElements], [bTrack, bElements]) => {
|
||||||
|
const aIndex = trackFrontOrderIndex(aElements, orderIndexByKey);
|
||||||
|
const bIndex = trackFrontOrderIndex(bElements, orderIndexByKey);
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
if (hasOverlappingEqualRankElements(aElements, bElements)) return aTrack - bTrack;
|
||||||
|
const aStart = Math.min(...aElements.map((element) => element.start));
|
||||||
|
const bStart = Math.min(...bElements.map((element) => element.start));
|
||||||
|
if (aStart !== bStart) return aStart - bStart;
|
||||||
|
return aTrack - bTrack;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertPreviewTrackOrder(
|
||||||
|
trackOrder: readonly number[],
|
||||||
|
previewTrack: number,
|
||||||
|
): number[] {
|
||||||
|
if (trackOrder.includes(previewTrack)) return [...trackOrder];
|
||||||
|
if (trackOrder.length === 0) return [previewTrack];
|
||||||
|
const minTrack = Math.min(...trackOrder);
|
||||||
|
const maxTrack = Math.max(...trackOrder);
|
||||||
|
if (previewTrack < minTrack) return [previewTrack, ...trackOrder];
|
||||||
|
if (previewTrack > maxTrack) return [...trackOrder, previewTrack];
|
||||||
|
return [...trackOrder, previewTrack];
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
import { TRACK_H } from "./timelineLayout";
|
||||||
|
import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||||
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function timelineElement(input: { id: string; track: number; zIndex: number }): TimelineElement {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
domId: input.id,
|
||||||
|
tag: "div",
|
||||||
|
start: 0,
|
||||||
|
duration: 2,
|
||||||
|
track: input.track,
|
||||||
|
zIndex: input.zIndex,
|
||||||
|
stackingContextId: "root",
|
||||||
|
parentCompositionId: null,
|
||||||
|
compositionAncestors: ["root"],
|
||||||
|
sourceFile: "index.html",
|
||||||
|
timingSource: "authored",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTimelineClipDrag", () => {
|
||||||
|
it("passes sibling-scoped stacking intent on vertical drag commit", async () => {
|
||||||
|
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
|
||||||
|
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
|
||||||
|
const back = timelineElement({ id: "back", track: 2, zIndex: 1 });
|
||||||
|
const scroll = document.createElement("div");
|
||||||
|
document.body.append(scroll);
|
||||||
|
const onMoveElement = vi.fn();
|
||||||
|
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const hook = useTimelineClipDrag({
|
||||||
|
scrollRef: { current: scroll },
|
||||||
|
ppsRef: { current: 100 },
|
||||||
|
durationRef: { current: 10 },
|
||||||
|
trackOrderRef: { current: [0, 1, 2] },
|
||||||
|
timelineElementsRef: { current: [front, middle, back] },
|
||||||
|
onMoveElement,
|
||||||
|
onResizeElement: vi.fn(),
|
||||||
|
onBlockedEditAttempt: vi.fn(),
|
||||||
|
setShowPopover: vi.fn(),
|
||||||
|
setRangeSelectionRef: { current: vi.fn() },
|
||||||
|
});
|
||||||
|
setDraggedClip = hook.setDraggedClip;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
});
|
||||||
|
if (!setDraggedClip) throw new Error("Expected drag setter");
|
||||||
|
const applyDraggedClip: (state: DraggedClipState | null) => void = setDraggedClip;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
applyDraggedClip({
|
||||||
|
element: back,
|
||||||
|
originClientX: 0,
|
||||||
|
originClientY: 0,
|
||||||
|
originScrollLeft: 0,
|
||||||
|
originScrollTop: 0,
|
||||||
|
pointerClientX: 0,
|
||||||
|
pointerClientY: 0,
|
||||||
|
pointerOffsetX: 0,
|
||||||
|
pointerOffsetY: 0,
|
||||||
|
previewStart: back.start,
|
||||||
|
previewTrack: back.track,
|
||||||
|
previewStackingReorder: null,
|
||||||
|
snapBeatTime: null,
|
||||||
|
started: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MouseEvent("pointermove", {
|
||||||
|
bubbles: true,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: -2 * TRACK_H,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onMoveElement).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onMoveElement.mock.calls[0]![1]).toMatchObject({
|
||||||
|
start: 0,
|
||||||
|
track: 0,
|
||||||
|
stackingReorder: {
|
||||||
|
fromIndex: 2,
|
||||||
|
toIndex: 0,
|
||||||
|
siblingKeys: ["front", "middle", "back"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
resolveTimelineResize,
|
resolveTimelineResize,
|
||||||
resolveTimelineAutoScroll,
|
resolveTimelineAutoScroll,
|
||||||
type BlockedTimelineEditIntent,
|
type BlockedTimelineEditIntent,
|
||||||
|
type TimelineStackingReorderIntent,
|
||||||
} from "./timelineEditing";
|
} from "./timelineEditing";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
@@ -83,6 +84,8 @@ export interface DraggedClipState {
|
|||||||
previewTrack: number;
|
previewTrack: number;
|
||||||
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
|
||||||
snapBeatTime: number | null;
|
snapBeatTime: number | null;
|
||||||
|
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
|
||||||
|
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||||
started: boolean;
|
started: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,9 +113,12 @@ 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[]>;
|
||||||
|
timelineElementsRef: React.RefObject<TimelineElement[]>;
|
||||||
onMoveElement?: (
|
onMoveElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
updates: Pick<TimelineElement, "start" | "track">,
|
updates: Pick<TimelineElement, "start" | "track"> & {
|
||||||
|
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||||
|
},
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onResizeElement?: (
|
onResizeElement?: (
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
@@ -129,6 +135,7 @@ export function useTimelineClipDrag({
|
|||||||
ppsRef,
|
ppsRef,
|
||||||
durationRef,
|
durationRef,
|
||||||
trackOrderRef,
|
trackOrderRef,
|
||||||
|
timelineElementsRef,
|
||||||
onMoveElement,
|
onMoveElement,
|
||||||
onResizeElement,
|
onResizeElement,
|
||||||
onBlockedEditAttempt,
|
onBlockedEditAttempt,
|
||||||
@@ -204,6 +211,8 @@ export function useTimelineClipDrag({
|
|||||||
trackHeight: TRACK_H,
|
trackHeight: TRACK_H,
|
||||||
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
||||||
trackOrder: trackOrderRef.current,
|
trackOrder: trackOrderRef.current,
|
||||||
|
stackingElement: drag.element,
|
||||||
|
stackingElements: timelineElementsRef.current,
|
||||||
},
|
},
|
||||||
clientX,
|
clientX,
|
||||||
clientY,
|
clientY,
|
||||||
@@ -225,10 +234,11 @@ export function useTimelineClipDrag({
|
|||||||
pointerClientY: clientY,
|
pointerClientY: clientY,
|
||||||
previewStart: snap.start,
|
previewStart: snap.start,
|
||||||
previewTrack: nextMove.track,
|
previewTrack: nextMove.track,
|
||||||
|
previewStackingReorder: nextMove.stackingReorder ?? null,
|
||||||
snapBeatTime: snap.beat,
|
snapBeatTime: snap.beat,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
[scrollRef, ppsRef, durationRef, trackOrderRef, timelineElementsRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopClipDragAutoScroll = useCallback(() => {
|
const stopClipDragAutoScroll = useCallback(() => {
|
||||||
@@ -299,6 +309,7 @@ export function useTimelineClipDrag({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const handleWindowPointerMove = (e: PointerEvent) => {
|
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||||
const drag = draggedClipRef.current;
|
const drag = draggedClipRef.current;
|
||||||
const resize = resizingClipRef.current;
|
const resize = resizingClipRef.current;
|
||||||
@@ -434,6 +445,7 @@ export function useTimelineClipDrag({
|
|||||||
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const handleWindowPointerUp = () => {
|
const handleWindowPointerUp = () => {
|
||||||
stopClipDragAutoScrollRef.current();
|
stopClipDragAutoScrollRef.current();
|
||||||
|
|
||||||
@@ -492,24 +504,30 @@ export function useTimelineClipDrag({
|
|||||||
suppressClickRef.current = true;
|
suppressClickRef.current = true;
|
||||||
clearSuppressedClick();
|
clearSuppressedClick();
|
||||||
|
|
||||||
|
const hasStackingReorder =
|
||||||
|
drag.previewStackingReorder != null &&
|
||||||
|
drag.previewStackingReorder.fromIndex !== drag.previewStackingReorder.toIndex;
|
||||||
const hasChanged =
|
const hasChanged =
|
||||||
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
|
drag.previewStart !== drag.element.start ||
|
||||||
|
drag.previewTrack !== drag.element.track ||
|
||||||
|
hasStackingReorder;
|
||||||
if (!hasChanged) return;
|
if (!hasChanged) return;
|
||||||
|
|
||||||
updateElement(drag.element.key ?? drag.element.id, {
|
updateElement(drag.element.key ?? drag.element.id, {
|
||||||
start: drag.previewStart,
|
start: drag.previewStart,
|
||||||
track: drag.previewTrack,
|
...(hasStackingReorder ? {} : { track: drag.previewTrack }),
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
onMoveElementRef.current?.(drag.element, {
|
onMoveElementRef.current?.(drag.element, {
|
||||||
start: drag.previewStart,
|
start: drag.previewStart,
|
||||||
track: drag.previewTrack,
|
track: drag.previewTrack,
|
||||||
|
stackingReorder: drag.previewStackingReorder,
|
||||||
}),
|
}),
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
updateElement(drag.element.key ?? drag.element.id, {
|
updateElement(drag.element.key ?? drag.element.id, {
|
||||||
start: drag.element.start,
|
start: drag.element.start,
|
||||||
track: drag.element.track,
|
...(hasStackingReorder ? {} : { track: drag.element.track }),
|
||||||
});
|
});
|
||||||
console.error("[Timeline] Failed to persist clip move", error);
|
console.error("[Timeline] Failed to persist clip move", error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ export function computeReorderZValues(
|
|||||||
return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
|
return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported in a later unit when the timeline consumes it; internal-only for now.
|
export function resolveStackingContextKey(item: StackingContextDescriptor): string {
|
||||||
function resolveStackingContextKey(item: StackingContextDescriptor): string {
|
|
||||||
return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? "";
|
return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user