mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:49:49 +00:00
Merge pull request #2791 from heygen-com/codex/studio-timeline-b-expanded-lanes-v2
feat(studio): wire expanded keyframe timeline lanes
This commit is contained in:
@@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
expect(result.after).not.toContain("data-hf-studio-rotation");
|
||||
});
|
||||
|
||||
it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const PATH_COMP = `<!DOCTYPE html><html><body data-duration="32">
|
||||
<div id="box"></div>
|
||||
<script data-hyperframes-gsap>
|
||||
const tl = gsap.timeline();
|
||||
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, duration: 16.055, ease: "power1.inOut" }, 12.17);
|
||||
</script>
|
||||
</body></html>`;
|
||||
writeHtml(projectDir, "path.html", PATH_COMP);
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const anim = await getFirstAnimation(app, "path.html");
|
||||
const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: "#box",
|
||||
position: 12.17,
|
||||
duration: 16.055,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 23.2, properties: { x: 25, y: 30 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 100 } },
|
||||
],
|
||||
ease: "none",
|
||||
}),
|
||||
});
|
||||
const result = (await res.json()) as { ok: boolean; after: string };
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.after).toContain('"23.2%"');
|
||||
expect(result.after).toContain('easeEach: "power1.inOut"');
|
||||
expect(result.after).toContain('ease: "none"');
|
||||
expect(result.after).not.toContain("motionPath");
|
||||
});
|
||||
|
||||
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
|
||||
|
||||
@@ -997,6 +997,7 @@ export type GsapMutationRequest =
|
||||
auto?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
| {
|
||||
type: "split-animations";
|
||||
@@ -1052,6 +1053,18 @@ export type GsapMutationRequest =
|
||||
|
||||
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
|
||||
|
||||
function resolveReplacementEaseEach(
|
||||
scriptText: string,
|
||||
request: { animationId: string; easeEach?: string },
|
||||
): string | undefined {
|
||||
if (request.easeEach !== undefined) return request.easeEach;
|
||||
const original = parseGsapScriptAcorn(scriptText).animations.find(
|
||||
(animation) => animation.id === request.animationId,
|
||||
);
|
||||
if (!original?.arcPath?.enabled) return undefined;
|
||||
return original?.keyframes?.easeEach ?? original?.ease;
|
||||
}
|
||||
|
||||
// Mutations that can change a position tween's first keyframe (value/existence/timing)
|
||||
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
|
||||
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
|
||||
@@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn(
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
resolveReplacementEaseEach(block.scriptText, body),
|
||||
);
|
||||
return added.script;
|
||||
}
|
||||
@@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast(
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
resolveReplacementEaseEach(block.scriptText, body),
|
||||
);
|
||||
return added.script;
|
||||
}
|
||||
|
||||
+16
-17
@@ -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<typeof persistTimelineMoveEditsAtomically>[2];
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioApp() {
|
||||
const { projectId, resolving, waitingForServer } = useServerConnection();
|
||||
@@ -154,6 +156,10 @@ export function StudioApp() {
|
||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
||||
// Stable identity — what the ref indirection is for. An inline arrow re-created
|
||||
// the memoized timeline handlers (it is in their deps) on every render.
|
||||
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
|
||||
const timelineEditing = useTimelineEditing({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -171,20 +177,11 @@ export function StudioApp() {
|
||||
sdkSession: editFlowSdkSession,
|
||||
publishSdkSession: sdkHandle.publish,
|
||||
forceReloadSdkSession: sdkHandle.forceReload,
|
||||
invalidateGsapCache,
|
||||
handleDomZIndexReorderCommitRef,
|
||||
});
|
||||
const handleTimelineElementsMove = useCallback(
|
||||
async (
|
||||
edits: Array<{
|
||||
element: TimelineElement;
|
||||
updates: Pick<TimelineElement, "start" | "track"> & {
|
||||
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 +225,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,
|
||||
@@ -402,12 +398,14 @@ export function StudioApp() {
|
||||
designPanelActive,
|
||||
inspectorPanelActive,
|
||||
inspectorButtonActive,
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
} = useInspectorState(
|
||||
panelLayout.rightPanelTab,
|
||||
panelLayout.rightInspectorPanes,
|
||||
panelLayout.rightCollapsed,
|
||||
isPlaying,
|
||||
domEditSession.domEditSelection,
|
||||
gestureState === "recording",
|
||||
);
|
||||
useStudioUrlState({
|
||||
@@ -558,6 +556,7 @@ export function StudioApp() {
|
||||
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
||||
setCompIdToSrc={setCompIdToSrc}
|
||||
setCompositionLoading={setCompositionLoading}
|
||||
shouldShowMotionPath={shouldShowMotionPath}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
isGestureRecording={gestureState === "recording"}
|
||||
recordingState={gestureState}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
||||
) => Promise<void> | void;
|
||||
setCompIdToSrc: (map: Map<string, string>) => void;
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -90,6 +91,7 @@ export function EditorShell({
|
||||
handleRazorSplitAll,
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
isGestureRecording,
|
||||
recordingState,
|
||||
@@ -149,6 +151,7 @@ export function EditorShell({
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
previewOverlay={
|
||||
<PreviewOverlays
|
||||
shouldShowMotionPath={shouldShowMotionPath}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
blockPreview={blockPreview}
|
||||
isGestureRecording={isGestureRecording}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
||||
import { TimelineToolbar } from "./TimelineToolbar";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -13,12 +15,14 @@ afterEach(() => {
|
||||
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
||||
});
|
||||
|
||||
function renderToolbar() {
|
||||
function renderToolbar(
|
||||
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
|
||||
) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<TimelineToolbar />);
|
||||
root.render(<TimelineToolbar domEditSession={domEditSession} />);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
describe("TimelineToolbar — motion path endpoints", () => {
|
||||
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
|
||||
usePlayerStore.setState({ currentTime: 10 });
|
||||
const animation: GsapAnimation = {
|
||||
id: "#el-to-0-position",
|
||||
targetSelector: "#el",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
properties: {},
|
||||
keyframes: {
|
||||
format: "object-array",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }],
|
||||
},
|
||||
};
|
||||
const element = document.createElement("div");
|
||||
element.id = "el";
|
||||
const session = {
|
||||
domEditSelection: makeSelection("Element", element),
|
||||
selectedGsapAnimations: [animation],
|
||||
handleGsapAddAnimation: vi.fn(),
|
||||
handleGsapConvertToKeyframes: vi.fn(),
|
||||
handleGsapRemoveKeyframe: vi.fn(),
|
||||
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
|
||||
|
||||
const { host, root } = renderToolbar(session);
|
||||
const button = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Motion path endpoint"]',
|
||||
);
|
||||
expect(button?.disabled).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isPlayheadWithinTween,
|
||||
type EnableKeyframesSession,
|
||||
} from "../hooks/useEnableKeyframes";
|
||||
import { computeElementPercentage } from "../hooks/gsapShared";
|
||||
import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
|
||||
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
||||
import {
|
||||
getNextTimelineZoomPercent,
|
||||
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
||||
}
|
||||
|
||||
interface KeyframeToggleState {
|
||||
state: "active" | "inactive" | "none";
|
||||
isMotionPath: boolean;
|
||||
pathEndpoint: boolean;
|
||||
willExtend: boolean;
|
||||
}
|
||||
|
||||
const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
|
||||
state: "none",
|
||||
isMotionPath: false,
|
||||
pathEndpoint: false,
|
||||
willExtend: false,
|
||||
};
|
||||
|
||||
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
|
||||
if (!animation?.keyframes) return false;
|
||||
const keyframes = animation.keyframes.keyframes;
|
||||
return (
|
||||
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
|
||||
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKeyframeToggleState(
|
||||
session: DomEditSessionSlice | undefined,
|
||||
currentTime: number,
|
||||
): KeyframeToggleState {
|
||||
if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
|
||||
const arcAnimation = session.selectedGsapAnimations.find(
|
||||
(animation) => animation.arcPath && animation.keyframes,
|
||||
);
|
||||
const animation =
|
||||
arcAnimation ??
|
||||
session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
|
||||
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
|
||||
|
||||
const isMotionPath = Boolean(arcAnimation);
|
||||
if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
|
||||
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
|
||||
}
|
||||
|
||||
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
|
||||
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
|
||||
const active = animation.keyframes.keyframes.some(
|
||||
(keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
|
||||
);
|
||||
return {
|
||||
state: pathEndpoint ? "none" : active ? "active" : "inactive",
|
||||
isMotionPath,
|
||||
pathEndpoint,
|
||||
willExtend: false,
|
||||
};
|
||||
}
|
||||
|
||||
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const sessionRef = useRef(session);
|
||||
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
|
||||
);
|
||||
|
||||
if (!session) return { state: "none" as const, onToggle: undefined };
|
||||
const toggleState = resolveKeyframeToggleState(session, currentTime);
|
||||
|
||||
const sel = session.domEditSelection;
|
||||
const anims = session.selectedGsapAnimations;
|
||||
const kfAnim = anims.find((a) => a.keyframes);
|
||||
|
||||
let state: "active" | "inactive" | "none" = "none";
|
||||
// Outside the tween, clicking extends the animation to the playhead rather than
|
||||
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
|
||||
let willExtend = false;
|
||||
if (kfAnim?.keyframes && sel) {
|
||||
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
|
||||
state = "inactive";
|
||||
willExtend = true;
|
||||
} else {
|
||||
// Tween-relative percentage (not the clip range) so the button state matches
|
||||
// where the keyframe would actually land.
|
||||
const pct = computeElementPercentage(currentTime, sel, kfAnim);
|
||||
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
||||
? "active"
|
||||
: "inactive";
|
||||
}
|
||||
}
|
||||
|
||||
return { state, willExtend, onToggle: sel ? onToggle : undefined };
|
||||
return {
|
||||
...toggleState,
|
||||
onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
||||
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
||||
const {
|
||||
state: keyframeState,
|
||||
isMotionPath: keyframeIsMotionPath,
|
||||
pathEndpoint: keyframePathEndpoint,
|
||||
willExtend: keyframeWillExtend,
|
||||
onToggle: onToggleKeyframe,
|
||||
} = useKeyframeToggle(domEditSession);
|
||||
@@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
||||
// toolbar layout never shifts.
|
||||
<Tooltip
|
||||
label={
|
||||
!onToggleKeyframe
|
||||
? "Select an animated element to add keyframes"
|
||||
: keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
: keyframeState === "inactive"
|
||||
keyframePathEndpoint
|
||||
? "Motion path endpoints cannot be removed"
|
||||
: !onToggleKeyframe
|
||||
? "Select an animated element to add keyframes"
|
||||
: keyframeIsMotionPath
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead, extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
? "Extend motion path to playhead (K)"
|
||||
: keyframeState === "active"
|
||||
? "Remove waypoint from motion path (K)"
|
||||
: "Add waypoint to motion path (K)"
|
||||
: keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
: keyframeState === "inactive"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead, extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
}
|
||||
>
|
||||
<button
|
||||
@@ -196,9 +241,17 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
||||
disabled={!onToggleKeyframe}
|
||||
onClick={onToggleKeyframe}
|
||||
aria-label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: "Add keyframe at playhead"
|
||||
keyframePathEndpoint
|
||||
? "Motion path endpoint"
|
||||
: keyframeIsMotionPath
|
||||
? keyframeState === "active"
|
||||
? "Remove motion path waypoint"
|
||||
: keyframeWillExtend
|
||||
? "Extend motion path to playhead"
|
||||
: "Add motion path waypoint"
|
||||
: keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: "Add keyframe at playhead"
|
||||
}
|
||||
className={
|
||||
!onToggleKeyframe
|
||||
|
||||
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
||||
return presetConfig.ease;
|
||||
}
|
||||
|
||||
function renderExpandedCard({
|
||||
animation,
|
||||
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
||||
function renderCard({
|
||||
animation = baseAnimation(),
|
||||
defaultExpanded = true,
|
||||
flat,
|
||||
onUpdateMeta = vi.fn(),
|
||||
onUpdateKeyframeEase = vi.fn(),
|
||||
onDeleteAnimation = noop,
|
||||
}: {
|
||||
animation: GsapAnimation;
|
||||
animation?: GsapAnimation;
|
||||
defaultExpanded?: boolean;
|
||||
flat?: boolean;
|
||||
onUpdateMeta?: ReturnType<typeof vi.fn>;
|
||||
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
onDeleteAnimation?: (id: string) => void;
|
||||
} = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -63,11 +68,11 @@ function renderExpandedCard({
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={animation}
|
||||
defaultExpanded
|
||||
defaultExpanded={defaultExpanded}
|
||||
flat={flat}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
onDeleteAnimation={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
|
||||
const view = renderCard({ animation, onUpdateKeyframeEase });
|
||||
|
||||
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("0% → 50%"),
|
||||
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
|
||||
action: "commit",
|
||||
ease,
|
||||
});
|
||||
act(() => view.root.unmount());
|
||||
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
|
||||
const onUpdateMeta = vi.fn();
|
||||
const onUpdateKeyframeEase = vi.fn();
|
||||
const animation = baseAnimation({ id: "flat-tween" });
|
||||
const view = renderExpandedCard({
|
||||
const view = renderCard({
|
||||
animation,
|
||||
flat: true,
|
||||
onUpdateMeta,
|
||||
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
describe("AnimationCard flat branch", () => {
|
||||
it("renders a mint border-left and panel-token colors when flat", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
|
||||
const card = host.querySelector('[data-flat-effect-card="true"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.className).toContain("border-panel-accent");
|
||||
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
|
||||
});
|
||||
|
||||
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false });
|
||||
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
|
||||
expect(host.textContent).toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
|
||||
for (const flat of [false, true]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat={flat || undefined}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
|
||||
expect(host.textContent).not.toContain("Remove");
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
|
||||
const onDeleteAnimation = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={true}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const removeButton = buttons.find((b) => b.textContent === "Remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
|
||||
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||
@@ -23,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
||||
animation: GsapAnimation;
|
||||
defaultExpanded: boolean;
|
||||
flat?: boolean;
|
||||
focusedSegment?: { tweenPercentage: number } | null;
|
||||
onFocusSegmentConsumed?: () => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -30,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
animation,
|
||||
defaultExpanded,
|
||||
flat,
|
||||
focusedSegment,
|
||||
onFocusSegmentConsumed,
|
||||
onUpdateProperty,
|
||||
onUpdateMeta,
|
||||
onDeleteAnimation,
|
||||
@@ -50,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
const [addingProp, setAddingProp] = useState(false);
|
||||
const [addingFromProp, setAddingFromProp] = useState(false);
|
||||
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const pendingAutoScrollRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedSegment) return;
|
||||
setExpanded(true);
|
||||
pendingAutoScrollRef.current = true;
|
||||
setExpandedKfPct(focusedSegment.tweenPercentage);
|
||||
onFocusSegmentConsumed?.();
|
||||
}, [focusedSegment, onFocusSegmentConsumed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAutoScrollRef.current || expandedKfPct === null) return;
|
||||
const segment = cardRef.current?.querySelector<HTMLElement>(
|
||||
`[data-ease-segment-pct="${expandedKfPct}"]`,
|
||||
);
|
||||
segment?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
pendingAutoScrollRef.current = false;
|
||||
}, [expandedKfPct]);
|
||||
|
||||
const usedProps = useMemo(
|
||||
() => new Set(Object.keys(animation.properties)),
|
||||
@@ -154,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-flat-effect-card={flat ? "true" : undefined}
|
||||
className={
|
||||
flat
|
||||
@@ -267,7 +291,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
onToggle={setExpandedKfPct}
|
||||
onEaseCommit={(pct, ease) => {
|
||||
onUpdateKeyframeEase(animation.id, pct, ease);
|
||||
trackStudioSegmentEaseEdit({ ease });
|
||||
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
||||
}}
|
||||
onApplyAll={
|
||||
onSetAllKeyframeEases
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
|
||||
const STYLES = {
|
||||
classic: {
|
||||
method:
|
||||
"rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white",
|
||||
cancel: "px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300",
|
||||
trigger: "text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200",
|
||||
},
|
||||
flat: {
|
||||
method:
|
||||
"rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0",
|
||||
cancel: "px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1",
|
||||
trigger: "text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1",
|
||||
},
|
||||
};
|
||||
|
||||
export function GsapAddAnimationControl({
|
||||
open,
|
||||
setOpen,
|
||||
onAddAnimation,
|
||||
track,
|
||||
variant,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
track: (control: string, name: string) => void;
|
||||
variant: keyof typeof STYLES;
|
||||
}) {
|
||||
const styles = STYLES[variant];
|
||||
|
||||
return (
|
||||
<div className="relative pt-1">
|
||||
{open ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles.method}
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setOpen(false)} className={styles.cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={styles.trigger}
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { memo, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
animations: GsapAnimation[];
|
||||
@@ -21,41 +23,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
animations,
|
||||
multipleTimelines,
|
||||
unsupportedTimelinePattern,
|
||||
onUpdateProperty,
|
||||
onUpdateMeta,
|
||||
onDeleteAnimation,
|
||||
onAddProperty,
|
||||
onRemoveProperty,
|
||||
onUpdateFromProperty,
|
||||
onAddFromProperty,
|
||||
onRemoveFromProperty,
|
||||
onAddAnimation,
|
||||
onLivePreview,
|
||||
onLivePreviewEnd,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
onUnroll,
|
||||
...callbacks
|
||||
}: GsapAnimationSectionProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
const updateMeta = (
|
||||
animationId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
onUpdateMeta(animationId, updates);
|
||||
};
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
|
||||
return (
|
||||
<Section title="Animation" icon={<Film size={15} />}>
|
||||
@@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={updateMeta}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
onUpdateFromProperty(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
onAddFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
onRemoveFromProperty(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={onLivePreview}
|
||||
onLivePreviewEnd={onLivePreviewEnd}
|
||||
onSetArcPath={
|
||||
onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
onSetArcPath(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
onUpdateArcSegment(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
onUpdateKeyframeEase(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
onSetAllKeyframeEases(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
onUnroll(animationId);
|
||||
}
|
||||
: undefined
|
||||
focusedSegment={
|
||||
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
|
||||
}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="relative pt-1">
|
||||
{addMenuOpen ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 py-1.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(false)}
|
||||
className="px-1.5 text-[11px] text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(true)}
|
||||
className="text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200"
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="classic"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -93,7 +93,11 @@ export function KeyframeEaseList({
|
||||
? "Custom"
|
||||
: (EASE_LABELS[segEase] ?? segEase);
|
||||
return (
|
||||
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
|
||||
<div
|
||||
key={`${i}-${kf.percentage}`}
|
||||
data-ease-segment-pct={kf.percentage}
|
||||
className="rounded-md bg-neutral-900/50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
|
||||
|
||||
@@ -11,17 +11,50 @@ interface KeyframeNavigationProps {
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** The tween that authored this keyframe, when the cache knows it. */
|
||||
animationId?: string;
|
||||
}> | null;
|
||||
/** Current playhead percentage within the element's lifetime (0-100) */
|
||||
currentPercentage: number;
|
||||
onSeek: (percentage: number) => void;
|
||||
onAddKeyframe: (percentage: number) => void;
|
||||
onRemoveKeyframe: (percentage: number) => void;
|
||||
/** `animationId` is the clicked keyframe's OWN tween; see handleDiamondClick. */
|
||||
onRemoveKeyframe: (percentage: number, animationId?: string) => void;
|
||||
onConvertToKeyframes: () => void;
|
||||
}
|
||||
|
||||
const TOLERANCE = 0.5;
|
||||
|
||||
interface NavigableKeyframe {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
|
||||
keyframes: readonly Keyframe[],
|
||||
currentPercentage: number,
|
||||
property?: string,
|
||||
) {
|
||||
const propertyKeyframes = property
|
||||
? keyframes.filter((keyframe) => property in keyframe.properties)
|
||||
: keyframes;
|
||||
return {
|
||||
propertyKeyframes,
|
||||
prevKeyframe:
|
||||
propertyKeyframes
|
||||
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
|
||||
.at(-1) ?? null,
|
||||
nextKeyframe:
|
||||
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
|
||||
null,
|
||||
currentKeyframe:
|
||||
propertyKeyframes.find(
|
||||
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
|
||||
) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
|
||||
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
|
||||
@@ -92,18 +125,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
}: KeyframeNavigationProps) {
|
||||
// Find keyframes that contain this property
|
||||
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
|
||||
|
||||
const prevKf =
|
||||
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
|
||||
|
||||
const nextKf =
|
||||
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
|
||||
|
||||
const atCurrent =
|
||||
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
|
||||
null;
|
||||
const {
|
||||
propertyKeyframes,
|
||||
prevKeyframe: prevKf,
|
||||
nextKeyframe: nextKf,
|
||||
currentKeyframe: atCurrent,
|
||||
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
|
||||
|
||||
// Diamond state
|
||||
let diamondState: DiamondState;
|
||||
@@ -128,7 +155,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
|
||||
if (diamondState === "ghost") {
|
||||
onConvertToKeyframes();
|
||||
} else if (diamondState === "active" && atCurrent) {
|
||||
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
|
||||
// Report the keyframe's OWN tween. A merged gutter row shows the keyframes
|
||||
// of every tween in the property group, so the caller's "the group's
|
||||
// animation" guess names the wrong tween whenever the clicked keyframe
|
||||
// belongs to a sibling — and the writer then finds no keyframe at that
|
||||
// percentage and silently does nothing.
|
||||
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage, atCurrent.animationId);
|
||||
} else {
|
||||
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
|
||||
beforeAll(installReactActEnvironment);
|
||||
|
||||
/**
|
||||
* Regression: a merged gutter row shows the keyframes of EVERY tween in the
|
||||
* property group. The panel guesses "the group's animation" for the write, so a
|
||||
* click on a keyframe authored by a sibling tween named the wrong animation and
|
||||
* the writer silently found nothing to remove. The diamond now reports the
|
||||
* clicked keyframe's own animationId.
|
||||
*/
|
||||
const MERGED_ROW = [
|
||||
{ percentage: 0, tweenPercentage: 0, properties: { x: 0 }, animationId: "delta-to-500-position" },
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
properties: { x: 120 },
|
||||
animationId: "delta-to-4000-position",
|
||||
},
|
||||
];
|
||||
|
||||
function clickDiamond(currentPercentage: number, onRemoveKeyframe: (...args: never[]) => void) {
|
||||
const root = mountReactHarness(
|
||||
<KeyframeNavigation
|
||||
property="x"
|
||||
keyframes={MERGED_ROW}
|
||||
currentPercentage={currentPercentage}
|
||||
onSeek={vi.fn()}
|
||||
onAddKeyframe={vi.fn()}
|
||||
onRemoveKeyframe={onRemoveKeyframe as never}
|
||||
onConvertToKeyframes={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const diamond = document.querySelector<HTMLElement>('[title="Remove x keyframe"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
act(() => {
|
||||
diamond?.click();
|
||||
});
|
||||
act(() => root.unmount());
|
||||
}
|
||||
|
||||
describe("KeyframeNavigation diamond", () => {
|
||||
it("reports the clicked keyframe's own animation on a merged row", () => {
|
||||
const onRemoveKeyframe = vi.fn();
|
||||
clickDiamond(50, onRemoveKeyframe);
|
||||
expect(onRemoveKeyframe).toHaveBeenCalledWith(25, "delta-to-4000-position");
|
||||
});
|
||||
|
||||
it("still reports the first tween's keyframe as its own", () => {
|
||||
const onRemoveKeyframe = vi.fn();
|
||||
clickDiamond(0, onRemoveKeyframe);
|
||||
expect(onRemoveKeyframe).toHaveBeenCalledWith(0, "delta-to-500-position");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,19 @@ type DragState = {
|
||||
ref: MotionNodeRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tween-% for a stop inserted at fraction `t` along the segment between two
|
||||
* nodes. null when either end is not a keyframe (arc waypoints carry no %).
|
||||
*/
|
||||
function interpolatedKeyframePct(
|
||||
a: MotionNodeRef | undefined,
|
||||
b: MotionNodeRef | undefined,
|
||||
t: number,
|
||||
): number | null {
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return null;
|
||||
return Math.round((a.pct + (b.pct - a.pct) * t) * 1000) / 1000;
|
||||
}
|
||||
|
||||
const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
||||
// Click-vs-drag cutoff in SCREEN pixels. Below this the pointer-up is a click
|
||||
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
||||
@@ -88,6 +101,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
// The keyframe % selected by clicking its node — highlighted, and the next drag
|
||||
// modifies it rather than adding a keyframe.
|
||||
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
|
||||
const timelineElement = usePlayerStore((state) => {
|
||||
if (!selection) return undefined;
|
||||
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
|
||||
return state.elements.find(
|
||||
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
|
||||
);
|
||||
});
|
||||
// Set-destination mode is armed from the preview toolbar (replaces the old
|
||||
// double-click-on-canvas UX). See createMode effects below.
|
||||
const armed = usePlayerStore((s) => s.motionPathArmed);
|
||||
@@ -395,13 +415,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
|
||||
} else {
|
||||
// Linear keyframe path: interpolate the new stop's tween-% from the two
|
||||
// keyframes bounding the clicked segment (np.t = fraction along it), then
|
||||
// insert it. Lands ON the current line, so the dot doesn't jump — drag it
|
||||
// after to bend the path.
|
||||
const a = abs[np.segIndex]?.ref;
|
||||
const b = abs[np.segIndex + 1]?.ref;
|
||||
if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
|
||||
const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
|
||||
// keyframes bounding the clicked segment, then insert it. Lands ON the
|
||||
// current line, so the dot doesn't jump — drag it after to bend the path.
|
||||
const pct = interpolatedKeyframePct(abs[np.segIndex]?.ref, abs[np.segIndex + 1]?.ref, np.t);
|
||||
if (pct === null) return;
|
||||
e.stopPropagation();
|
||||
void commitAddKeyframe(animId, pct, x, y, commitMutation);
|
||||
}
|
||||
@@ -418,12 +435,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
// Right-click a keyframe node → the timeline's keyframe context menu (delete
|
||||
// this keyframe / delete all), so motion-path keyframes are removable in place.
|
||||
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
|
||||
if (ref.type !== "keyframe" || !animId || !elementId) return;
|
||||
if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setKfMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
element: timelineElement,
|
||||
elementId,
|
||||
percentage: ref.pct,
|
||||
tweenPercentage: ref.pct,
|
||||
@@ -512,9 +530,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
<KeyframeDiamondContextMenu
|
||||
state={kfMenu}
|
||||
onClose={() => setKfMenu(null)}
|
||||
onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
|
||||
onDelete={(_elId, keyframe) =>
|
||||
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
||||
}
|
||||
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
||||
onMoveToPlayhead={(_elId, pct) => animId && handleGsapMoveKeyframeToPlayhead(animId, pct)}
|
||||
onMoveToPlayhead={(_element, keyframe) =>
|
||||
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -408,7 +408,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "x", displayX)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("x"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("x"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("x"))}
|
||||
/>
|
||||
)}
|
||||
@@ -433,7 +435,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "y", displayY)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("y"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("y"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("y"))}
|
||||
/>
|
||||
)}
|
||||
@@ -458,7 +462,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "width", displayW)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("width"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("width"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("width"))}
|
||||
/>
|
||||
)}
|
||||
@@ -483,7 +489,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "height", displayH)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("height"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("height"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("height"))}
|
||||
/>
|
||||
)}
|
||||
@@ -507,7 +515,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "rotation", displayR)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp("rotation"), pct)}
|
||||
onRemoveKeyframe={(pct, animationId) =>
|
||||
onRemoveKeyframe?.(animationId ?? animIdForProp("rotation"), pct)
|
||||
}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp("rotation"))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview } from "./propertyPanelSections";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import {
|
||||
FlatColorGradingAccessory,
|
||||
FlatColorGradingSection,
|
||||
@@ -231,7 +232,40 @@ export function PropertyPanelFlat({
|
||||
: "layout",
|
||||
);
|
||||
|
||||
// Animate only the groups that changed during this toggle cycle.
|
||||
// Tracks which group(s) are actively transitioning this toggle cycle, so
|
||||
// their header/body gets the fast entrance animation (hf-flat-group-enter)
|
||||
// and no one else's does. Deliberately NOT derived from remounting alone:
|
||||
// FlatGroupHeader instances are keyed by group id and React normally
|
||||
// preserves them across re-renders, but toggling a non-adjacent group still
|
||||
// shifts the untouched collapsed siblings between the before/after-open
|
||||
// slices below, and Chromium restarts a CSS animation on that kind of
|
||||
// position shift even though nothing about the sibling actually changed.
|
||||
// Gating on these ids (cleared shortly after the 120ms CSS animation
|
||||
// finishes) keeps the animation scoped to only the groups that actually
|
||||
// just toggled. Two ids, not one: the clicked (newly-opening/closing) group
|
||||
// AND whichever group was open immediately before the click and got
|
||||
// implicitly closed by it — both freshly-mounted headers need to animate.
|
||||
// When the inline timeline ease button focuses a segment on this element,
|
||||
// force the Motion group open so its AnimationCard (which only mounts while
|
||||
// the group is expanded) can consume the focus and reveal the ease editor.
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
// The element THIS panel renders, not the store's selectedElementId: that
|
||||
// flips synchronously while the panel still renders its predecessor, so a
|
||||
// stale panel would consume a request meant for its successor whenever the
|
||||
// two share a class-selector animation id.
|
||||
const renderedElementId = `${element.sourceFile}#${element.id}`;
|
||||
// Adjusted during render (not an effect) so the card mounts on the same
|
||||
// commit the request lands on. Keyed on request identity: a group the user
|
||||
// closes afterwards stays closed.
|
||||
const [consumedFocus, setConsumedFocus] = useState(focusedEaseSegment);
|
||||
if (focusedEaseSegment !== consumedFocus) {
|
||||
setConsumedFocus(focusedEaseSegment);
|
||||
const focusesThisPanel =
|
||||
focusedEaseSegment?.elementId === renderedElementId &&
|
||||
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
|
||||
if (focusesThisPanel) setOpenGroupId("motion");
|
||||
}
|
||||
|
||||
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
|
||||
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const panelBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -493,6 +527,17 @@ export function PropertyPanelFlat({
|
||||
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
|
||||
const openGroup = openIndex === -1 ? null : groups[openIndex];
|
||||
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
|
||||
const renderClosedGroup = (group: FlatGroupDescriptor) => (
|
||||
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
|
||||
<FlatGroupHeader
|
||||
title={group.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(group.id)}
|
||||
summary={group.summary}
|
||||
animateEntrance={justToggledIds.includes(group.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<DesignPanelInputProvider ui="flat">
|
||||
@@ -520,17 +565,7 @@ export function PropertyPanelFlat({
|
||||
data-flat-panel-body="true"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
{beforeOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
{beforeOpen.map(renderClosedGroup)}
|
||||
{openGroup && (
|
||||
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
|
||||
<div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
|
||||
@@ -549,17 +584,7 @@ export function PropertyPanelFlat({
|
||||
</div>
|
||||
</DesignPanelInputProvider>
|
||||
)}
|
||||
{afterOpen.map((g) => (
|
||||
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
|
||||
<FlatGroupHeader
|
||||
title={g.title}
|
||||
isOpen={false}
|
||||
onToggleOpen={() => toggleOpen(g.id)}
|
||||
summary={g.summary}
|
||||
animateEntrance={justToggledIds.includes(g.id)}
|
||||
/>
|
||||
</DesignPanelInputProvider>
|
||||
))}
|
||||
{afterOpen.map(renderClosedGroup)}
|
||||
</div>
|
||||
<DesignPanelInputProvider section="footer">
|
||||
<PropertyPanelFlatFooter
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
|
||||
function requiredCallbacks(): GsapAnimationEditCallbacks {
|
||||
return {
|
||||
onUpdateProperty: vi.fn(),
|
||||
onUpdateMeta: vi.fn(),
|
||||
onDeleteAnimation: vi.fn(),
|
||||
onAddProperty: vi.fn(),
|
||||
onRemoveProperty: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function requireCallback<T>(callback: T | undefined): T {
|
||||
if (callback === undefined) throw new Error("expected callback to be present");
|
||||
return callback;
|
||||
}
|
||||
|
||||
describe("withTrackedGsapAnimationCallbacks", () => {
|
||||
it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => {
|
||||
const callbacks = requiredCallbacks();
|
||||
const onLivePreview = vi.fn();
|
||||
const onLivePreviewEnd = vi.fn();
|
||||
callbacks.onLivePreview = onLivePreview;
|
||||
callbacks.onLivePreviewEnd = onLivePreviewEnd;
|
||||
|
||||
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
|
||||
|
||||
expect(tracked.onUpdateFromProperty).toBeUndefined();
|
||||
expect(tracked.onAddFromProperty).toBeUndefined();
|
||||
expect(tracked.onRemoveFromProperty).toBeUndefined();
|
||||
expect(tracked.onSetArcPath).toBeUndefined();
|
||||
expect(tracked.onUpdateArcSegment).toBeUndefined();
|
||||
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
|
||||
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
|
||||
expect(tracked.onUnroll).toBeUndefined();
|
||||
expect(tracked.onLivePreview).toBe(onLivePreview);
|
||||
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
|
||||
});
|
||||
|
||||
it("tracks each edit once before invoking its mutation callback", () => {
|
||||
const events: string[] = [];
|
||||
const mutation = (name: string) => () => events.push(`mutate:${name}`);
|
||||
const callbacks: GsapAnimationEditCallbacks = {
|
||||
onUpdateProperty: mutation("update-property"),
|
||||
onUpdateMeta: mutation("update-meta"),
|
||||
onDeleteAnimation: mutation("delete"),
|
||||
onAddProperty: mutation("add-property"),
|
||||
onRemoveProperty: mutation("remove-property"),
|
||||
onUpdateFromProperty: mutation("update-from"),
|
||||
onAddFromProperty: mutation("add-from"),
|
||||
onRemoveFromProperty: mutation("remove-from"),
|
||||
onSetArcPath: mutation("arc-path"),
|
||||
onUpdateArcSegment: mutation("arc-segment"),
|
||||
onUpdateKeyframeEase: mutation("keyframe-ease"),
|
||||
onSetAllKeyframeEases: mutation("all-eases"),
|
||||
onUnroll: mutation("unroll"),
|
||||
};
|
||||
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
|
||||
events.push(`track:${control}:${name}`);
|
||||
});
|
||||
|
||||
tracked.onUpdateProperty("a1", "visibility", 1);
|
||||
tracked.onUpdateProperty("a1", "filter", "blur(2px)");
|
||||
tracked.onUpdateProperty("a1", "opacity", 0.5);
|
||||
tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 });
|
||||
tracked.onDeleteAnimation("a1");
|
||||
tracked.onAddProperty("a1", "scale");
|
||||
tracked.onRemoveProperty("a1", "scale");
|
||||
requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none");
|
||||
requireCallback(tracked.onAddFromProperty)("a1", "x");
|
||||
requireCallback(tracked.onRemoveFromProperty)("a1", "x");
|
||||
requireCallback(tracked.onSetArcPath)("a1", { enabled: true });
|
||||
requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true });
|
||||
requireCallback(tracked.onUpdateArcSegment)("a1", 1, {});
|
||||
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
|
||||
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
|
||||
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
|
||||
requireCallback(tracked.onUnroll)("a1");
|
||||
|
||||
expect(events).toEqual([
|
||||
"track:toggle:visibility",
|
||||
"mutate:update-property",
|
||||
"track:text:filter",
|
||||
"mutate:update-property",
|
||||
"track:metric:opacity",
|
||||
"mutate:update-property",
|
||||
"track:metric:Length",
|
||||
"track:select:Speed",
|
||||
"track:metric:Starts at",
|
||||
"mutate:update-meta",
|
||||
"track:button:Remove animation",
|
||||
"mutate:delete",
|
||||
"track:select:Add effect property",
|
||||
"mutate:add-property",
|
||||
"track:button:Remove scale",
|
||||
"mutate:remove-property",
|
||||
"track:text:clipPath",
|
||||
"mutate:update-from",
|
||||
"track:select:Add from property",
|
||||
"mutate:add-from",
|
||||
"track:button:Remove from x",
|
||||
"mutate:remove-from",
|
||||
"track:toggle:Arc motion",
|
||||
"mutate:arc-path",
|
||||
"track:toggle:Auto rotate",
|
||||
"mutate:arc-path",
|
||||
"track:button:Reset arc segment 2",
|
||||
"mutate:arc-segment",
|
||||
"mutate:arc-segment",
|
||||
"track:select:Keyframe ease",
|
||||
"mutate:keyframe-ease",
|
||||
"track:select:All keyframe eases",
|
||||
"mutate:all-eases",
|
||||
"track:button:Unroll animation",
|
||||
"mutate:unroll",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
||||
import { usePlayerStore } from "../../player";
|
||||
|
||||
/**
|
||||
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
|
||||
@@ -35,6 +36,18 @@ export interface GsapAnimationEditCallbacks {
|
||||
onUnroll?: (animationId: string) => void;
|
||||
}
|
||||
|
||||
type TrackDesignInput = (control: string, name: string) => void;
|
||||
|
||||
function trackAnimationProperty(track: TrackDesignInput, property: string): void {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
}
|
||||
|
||||
// User-facing control label for each animation-meta field. The ease control is
|
||||
// labelled "Speed" in the card UI, so ease/easeEach map there.
|
||||
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
|
||||
@@ -51,13 +64,104 @@ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> =
|
||||
* added later is attributed honestly by its own key instead of poisoning another
|
||||
* control's usage count.
|
||||
*/
|
||||
export function trackAnimationMetaUpdate(
|
||||
track: (control: string, name: string) => void,
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(updates)) {
|
||||
const mapped = ANIMATION_META_LABELS[key];
|
||||
if (mapped) track(mapped.control, mapped.name);
|
||||
else track("select", key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add design-input telemetry to the shared animation-edit callback surface.
|
||||
* Optional callbacks remain absent, pass-through preview callbacks keep their
|
||||
* original identity, and every tracked event fires once before its mutation.
|
||||
*/
|
||||
export function withTrackedGsapAnimationCallbacks(
|
||||
callbacks: GsapAnimationEditCallbacks,
|
||||
track: TrackDesignInput,
|
||||
): GsapAnimationEditCallbacks {
|
||||
return {
|
||||
onUpdateProperty: (animationId, property, value) => {
|
||||
trackAnimationProperty(track, property);
|
||||
callbacks.onUpdateProperty(animationId, property, value);
|
||||
},
|
||||
onUpdateMeta: (animationId, updates) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
callbacks.onUpdateMeta(animationId, updates);
|
||||
},
|
||||
onDeleteAnimation: (animationId) => {
|
||||
track("button", "Remove animation");
|
||||
callbacks.onDeleteAnimation(animationId);
|
||||
},
|
||||
onAddProperty: (animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
callbacks.onAddProperty(animationId, property);
|
||||
},
|
||||
onRemoveProperty: (animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
callbacks.onRemoveProperty(animationId, property);
|
||||
},
|
||||
onUpdateFromProperty: callbacks.onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackAnimationProperty(track, property);
|
||||
callbacks.onUpdateFromProperty?.(animationId, property, value);
|
||||
}
|
||||
: undefined,
|
||||
onAddFromProperty: callbacks.onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
callbacks.onAddFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined,
|
||||
onRemoveFromProperty: callbacks.onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
callbacks.onRemoveFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined,
|
||||
onLivePreview: callbacks.onLivePreview,
|
||||
onLivePreviewEnd: callbacks.onLivePreviewEnd,
|
||||
onSetArcPath: callbacks.onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
|
||||
callbacks.onSetArcPath?.(animationId, config);
|
||||
}
|
||||
: undefined,
|
||||
onUpdateArcSegment: callbacks.onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined,
|
||||
onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
|
||||
}
|
||||
: undefined,
|
||||
onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
callbacks.onSetAllKeyframeEases?.(animationId, ease);
|
||||
}
|
||||
: undefined,
|
||||
onUnroll: callbacks.onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
callbacks.onUnroll?.(animationId);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable consumer for the store's one-shot ease-focus request. Module-level on
|
||||
* purpose: an inline arrow in the section components is a dep of AnimationCard's
|
||||
* focus effect, so a fresh identity each render re-runs that effect every render.
|
||||
*/
|
||||
export function clearFocusedEaseSegment(): void {
|
||||
usePlayerStore.getState().setFocusedEaseSegment(null);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Boundary cases share an arrange/assert shape on purpose: each case states its
|
||||
// own window, drag, and expected remap so a failure reads without cross-referencing.
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime";
|
||||
|
||||
@@ -11,6 +14,22 @@ const KEYFRAMES: RetimeKeyframe[] = [
|
||||
const WINDOW = { tweenStart: 2, tweenDuration: 4 };
|
||||
const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 };
|
||||
|
||||
function expectLeftResize(
|
||||
keyframes: RetimeKeyframe[],
|
||||
draggedTweenPct: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
): void {
|
||||
const result = resolveKeyframeRetime({
|
||||
...LEFT_BOUNDARY_DROP,
|
||||
keyframes,
|
||||
draggedTweenPct,
|
||||
});
|
||||
expect(result.kind).toBe("resize");
|
||||
expect(result.position).toBeCloseTo(0.5, 5);
|
||||
expect(result.duration).toBeCloseTo(5.5, 5);
|
||||
expect(result.pctRemap).toEqual(pctRemap);
|
||||
}
|
||||
|
||||
describe("resolveKeyframeRetime — move (within the tween window)", () => {
|
||||
it("re-keys an interior keyframe to the tween-% of the drop", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
@@ -94,29 +113,21 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(2, 5); // start unchanged
|
||||
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
|
||||
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
|
||||
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
|
||||
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
|
||||
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
|
||||
expect(r.pctRemap).toEqual([
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 33.3 },
|
||||
{ from: 50, to: 33.333 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extends the FIRST keyframe before the start, shifting position earlier", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...LEFT_BOUNDARY_DROP,
|
||||
keyframes: KEYFRAMES,
|
||||
draggedTweenPct: 0,
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(0.5, 5);
|
||||
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
|
||||
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
|
||||
expect(r.pctRemap).toEqual([
|
||||
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
|
||||
expectLeftResize(KEYFRAMES, 0, [
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 50, to: 63.6 },
|
||||
{ from: 50, to: 63.636 },
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
@@ -139,15 +150,7 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () =
|
||||
});
|
||||
|
||||
it("resizes left before the start", () => {
|
||||
const r = resolveKeyframeRetime({
|
||||
...LEFT_BOUNDARY_DROP,
|
||||
keyframes: lone,
|
||||
draggedTweenPct: 100,
|
||||
});
|
||||
expect(r.kind).toBe("resize");
|
||||
expect(r.position).toBeCloseTo(0.5, 5);
|
||||
expect(r.duration).toBeCloseTo(5.5, 5);
|
||||
expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]);
|
||||
expectLeftResize(lone, 100, [{ from: 100, to: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
|
||||
const MIN_TWEEN_DURATION = 0.01;
|
||||
|
||||
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
|
||||
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||
|
||||
/** Resolve timing for a flat tween's synthesized start/end diamond. */
|
||||
@@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
|
||||
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
|
||||
const absTime =
|
||||
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
|
||||
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
|
||||
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -83,10 +83,10 @@ function KeyframeGutter({
|
||||
track("button", `Add ${property} keyframe`);
|
||||
void onCommitAnimatedProperty(element, property, displayValue);
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => {
|
||||
onRemoveKeyframe={(pct, animationId) => {
|
||||
if (!onRemoveKeyframe) return;
|
||||
track("button", `Remove ${property} keyframe`);
|
||||
onRemoveKeyframe(animIdForProp(property), pct);
|
||||
onRemoveKeyframe(animationId ?? animIdForProp(property), pct);
|
||||
}}
|
||||
onConvertToKeyframes={() => {
|
||||
if (!onConvertToKeyframes) return;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { parseTimingValue } from "./propertyPanelTimingSection";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
|
||||
import {
|
||||
trackAnimationMetaUpdate,
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
@@ -135,15 +137,17 @@ export function FlatMotionSection({
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackProperty = (property: string) => {
|
||||
const control =
|
||||
property === "visibility"
|
||||
? "toggle"
|
||||
: property === "filter" || property === "clipPath"
|
||||
? "text"
|
||||
: "metric";
|
||||
track(control, property);
|
||||
};
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
// Only consume a focus request aimed at the element THIS panel renders (not
|
||||
// the store's selectedElementId, which flips synchronously during async
|
||||
// selection resolution), so a shared class-selector animation id can't open
|
||||
// the wrong element's editor.
|
||||
const renderedElementId = `${element.sourceFile}#${element.id}`;
|
||||
const focusedHere =
|
||||
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
||||
? focusedEaseSegment
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -172,140 +176,22 @@ export function FlatMotionSection({
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
onUpdateProperty={(animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateProperty(animationId, property, value);
|
||||
}}
|
||||
onUpdateMeta={(animationId, updates) => {
|
||||
trackAnimationMetaUpdate(track, updates);
|
||||
callbacks.onUpdateMeta(animationId, updates);
|
||||
}}
|
||||
onDeleteAnimation={(animationId) => {
|
||||
track("button", "Remove animation");
|
||||
callbacks.onDeleteAnimation(animationId);
|
||||
}}
|
||||
onAddProperty={(animationId, property) => {
|
||||
track("select", "Add effect property");
|
||||
callbacks.onAddProperty(animationId, property);
|
||||
}}
|
||||
onRemoveProperty={(animationId, property) => {
|
||||
track("button", `Remove ${property}`);
|
||||
callbacks.onRemoveProperty(animationId, property);
|
||||
}}
|
||||
onUpdateFromProperty={
|
||||
callbacks.onUpdateFromProperty
|
||||
? (animationId, property, value) => {
|
||||
trackProperty(property);
|
||||
callbacks.onUpdateFromProperty?.(animationId, property, value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onAddFromProperty={
|
||||
callbacks.onAddFromProperty
|
||||
? (animationId, property) => {
|
||||
track("select", "Add from property");
|
||||
callbacks.onAddFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemoveFromProperty={
|
||||
callbacks.onRemoveFromProperty
|
||||
? (animationId, property) => {
|
||||
track("button", `Remove from ${property}`);
|
||||
callbacks.onRemoveFromProperty?.(animationId, property);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLivePreview={callbacks.onLivePreview}
|
||||
onLivePreviewEnd={callbacks.onLivePreviewEnd}
|
||||
onSetArcPath={
|
||||
callbacks.onSetArcPath
|
||||
? (animationId, config) => {
|
||||
track(
|
||||
"toggle",
|
||||
config.autoRotate !== undefined ? "Auto rotate" : "Arc motion",
|
||||
);
|
||||
callbacks.onSetArcPath?.(animationId, config);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateArcSegment={
|
||||
callbacks.onUpdateArcSegment
|
||||
? (animationId, segmentIndex, update) => {
|
||||
if (update.curviness === undefined) {
|
||||
track("button", `Reset arc segment ${segmentIndex + 1}`);
|
||||
}
|
||||
callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUpdateKeyframeEase={
|
||||
callbacks.onUpdateKeyframeEase
|
||||
? (animationId, percentage, ease) => {
|
||||
track("select", "Keyframe ease");
|
||||
callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetAllKeyframeEases={
|
||||
callbacks.onSetAllKeyframeEases
|
||||
? (animationId, ease) => {
|
||||
track("select", "All keyframe eases");
|
||||
callbacks.onSetAllKeyframeEases?.(animationId, ease);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onUnroll={
|
||||
callbacks.onUnroll
|
||||
? (animationId) => {
|
||||
track("button", "Unroll animation");
|
||||
callbacks.onUnroll?.(animationId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<div className="relative pt-1">
|
||||
{addMenuOpen ? (
|
||||
<div className="flex gap-1.5">
|
||||
{ADD_METHODS.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
type="button"
|
||||
title={METHOD_TOOLTIPS[method]}
|
||||
onClick={() => {
|
||||
track("button", `Add ${method} animation`);
|
||||
onAddAnimation(method);
|
||||
setAddMenuOpen(false);
|
||||
}}
|
||||
className="rounded-lg border border-panel-border-input bg-panel-input px-2.5 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:border-panel-text-4 hover:text-panel-text-0"
|
||||
>
|
||||
{ADD_METHOD_LABELS[method] ?? method}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(false)}
|
||||
className="px-1.5 text-[11px] text-panel-text-3 hover:text-panel-text-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddMenuOpen(true)}
|
||||
className="text-[11px] font-medium text-panel-text-3 transition-colors hover:text-panel-text-1"
|
||||
title="Add a new animation effect to this element"
|
||||
>
|
||||
+ Add effect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -299,22 +299,17 @@ describe("FlatTextFieldEditor controls", () => {
|
||||
|
||||
describe("FlatTextSection — multi-field", () => {
|
||||
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatTextSection
|
||||
element={makeMultiFieldElement()}
|
||||
styles={{}}
|
||||
fontAssets={[]}
|
||||
onSetText={vi.fn()}
|
||||
onSetTextFieldStyle={vi.fn()}
|
||||
onAddTextField={vi.fn()}
|
||||
onRemoveTextField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderInto(
|
||||
<FlatTextSection
|
||||
element={makeMultiFieldElement()}
|
||||
styles={{}}
|
||||
fontAssets={[]}
|
||||
onSetText={vi.fn()}
|
||||
onSetTextFieldStyle={vi.fn()}
|
||||
onAddTextField={vi.fn()}
|
||||
onRemoveTextField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).toContain("Headline");
|
||||
expect(host.textContent).toContain("Subhead");
|
||||
// Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
|
||||
@@ -408,6 +403,27 @@ describe("FlatTextSection — multi-field", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not steal canvas focus when a multi-field element is selected", () => {
|
||||
const focusOwner = document.createElement("button");
|
||||
document.body.append(focusOwner);
|
||||
focusOwner.focus();
|
||||
|
||||
const { root } = renderInto(
|
||||
<FlatTextSection
|
||||
element={makeMultiFieldElement()}
|
||||
styles={{}}
|
||||
fontAssets={[]}
|
||||
onSetText={vi.fn()}
|
||||
onSetTextFieldStyle={vi.fn()}
|
||||
onAddTextField={vi.fn()}
|
||||
onRemoveTextField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.activeElement).toBe(focusOwner);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("auto-focuses the Content textarea when a new text field is added", async () => {
|
||||
let addResolved = false;
|
||||
|
||||
|
||||
@@ -257,6 +257,12 @@ export function FlatTextSection({
|
||||
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
|
||||
element.textFields[0]?.key ?? null,
|
||||
);
|
||||
// Armed by the add handler so the newly added field mounts focused. State, not
|
||||
// a ref cleared during render: Strict Mode renders twice, so the first pass
|
||||
// would eat the marker and the second would mount the field unfocused. Nothing
|
||||
// clears it on read either — `autoFocus` is a mount-only DOM prop and the
|
||||
// editor is keyed on the field, so it can only fire once per added field.
|
||||
const [autoFocusFieldKey, setAutoFocusFieldKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextFields = element.textFields;
|
||||
@@ -271,6 +277,8 @@ export function FlatTextSection({
|
||||
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
|
||||
if (!activeField) return null;
|
||||
|
||||
const autoFocusActiveField = autoFocusFieldKey === activeField.key;
|
||||
|
||||
if (textFields.length > 1) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
@@ -278,10 +286,15 @@ export function FlatTextSection({
|
||||
fields={textFields}
|
||||
activeFieldKey={activeField.key}
|
||||
styles={styles}
|
||||
onSelect={setActiveFieldKey}
|
||||
onSelect={(fieldKey) => {
|
||||
setAutoFocusFieldKey(null);
|
||||
setActiveFieldKey(fieldKey);
|
||||
}}
|
||||
onAdd={() =>
|
||||
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
|
||||
if (nextKey) setActiveFieldKey(nextKey);
|
||||
if (!nextKey) return;
|
||||
setAutoFocusFieldKey(nextKey);
|
||||
setActiveFieldKey(nextKey);
|
||||
})
|
||||
}
|
||||
onRemove={onRemoveTextField}
|
||||
@@ -295,7 +308,7 @@ export function FlatTextSection({
|
||||
onSetText={onSetText}
|
||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||
onPreviewTextFieldStyle={onPreviewTextFieldStyle}
|
||||
autoFocus
|
||||
autoFocus={autoFocusActiveField}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -316,7 +329,11 @@ export function FlatTextSection({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
track("button", "Add text field");
|
||||
void onAddTextField(activeField.key);
|
||||
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
|
||||
if (!nextKey) return;
|
||||
setAutoFocusFieldKey(nextKey);
|
||||
setActiveFieldKey(nextKey);
|
||||
});
|
||||
}}
|
||||
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PreviewOverlaysProps {
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -132,6 +133,7 @@ export function resolveZIndexEntries(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PreviewOverlays({
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
blockPreview,
|
||||
isGestureRecording,
|
||||
@@ -274,7 +276,7 @@ export function PreviewOverlays({
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<MotionPathOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
selection={shouldShowMotionPath ? domEditSelection : null}
|
||||
compositionSize={compositionDimensions}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,36 @@ describe("resolveTimelineKeyframeTarget", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the rendered animation identity to resolve a same-group collision", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
50,
|
||||
[
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "position-b",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: "position-a", propertyGroup: "position" },
|
||||
{ id: "position-b", propertyGroup: "position" },
|
||||
],
|
||||
),
|
||||
).toEqual({ animId: "position-b", tweenPct: 25 });
|
||||
});
|
||||
|
||||
it("rejects a rendered animation identity absent from the element", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
50,
|
||||
[{ percentage: 50, animationId: "stale-position" }],
|
||||
[{ id: "position", propertyGroup: "position" }],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a keyframed and flat tween in the same property group unresolved", () => {
|
||||
expect(
|
||||
resolveTimelineKeyframeTarget(
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||
|
||||
installReactActEnvironment();
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: {
|
||||
handleGsapRemoveKeyframe: vi.fn(),
|
||||
handleGsapMoveKeyframeToPlayhead: vi.fn(),
|
||||
handleGsapMoveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
handleGsapUpdateMeta: vi.fn().mockResolvedValue(true),
|
||||
handleGsapAddKeyframe: vi.fn(),
|
||||
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleGsapConvertToKeyframes: vi.fn(),
|
||||
handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true),
|
||||
handleGsapDeleteAnimation: vi.fn(),
|
||||
buildDomSelectionForTimelineElement: vi.fn(),
|
||||
},
|
||||
selection: { id: "box", selector: "#box", sourceFile: "index.html" },
|
||||
animations: Array<GsapAnimation>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/StudioContext", () => ({
|
||||
useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../contexts/DomEditContext", () => ({
|
||||
useDomEditActionsContext: () => mocks.actions,
|
||||
useDomEditSelectionContext: () => ({
|
||||
domEditSelection: mocks.selection,
|
||||
selectedGsapAnimations: mocks.animations,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
|
||||
|
||||
const element: TimelineElement = {
|
||||
id: "box",
|
||||
key: "index.html#box",
|
||||
domId: "box",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
sourceFile: "index.html",
|
||||
};
|
||||
|
||||
const flatAnimation: GsapAnimation = {
|
||||
id: "box-to-0-position",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 0,
|
||||
resolvedStart: 0,
|
||||
duration: 1,
|
||||
properties: { x: 420 },
|
||||
propertyGroup: "position",
|
||||
};
|
||||
|
||||
const otherFlatAnimation: GsapAnimation = {
|
||||
...flatAnimation,
|
||||
id: "circle-to-0-position",
|
||||
targetSelector: "#circle",
|
||||
};
|
||||
|
||||
const otherKeyframedAnimation: GsapAnimation = {
|
||||
...otherFlatAnimation,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function authoredInteriorAnimation(): GsapAnimation {
|
||||
return {
|
||||
...flatAnimation,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 210 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
|
||||
let callbacks: TimelineEditCallbacks | null = null;
|
||||
function Harness() {
|
||||
callbacks = useTimelineEditCallbacks({
|
||||
handleTimelineElementMove: vi.fn(),
|
||||
handleTimelineElementsMove: vi.fn(),
|
||||
handleTimelineElementResize: vi.fn(),
|
||||
handleTimelineGroupResize: vi.fn(),
|
||||
handleToggleTrackHidden: vi.fn(),
|
||||
handleBlockedTimelineEdit: vi.fn(),
|
||||
handleTimelineElementSplit: vi.fn(),
|
||||
handleRazorSplit: vi.fn(),
|
||||
handleRazorSplitAll: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
if (!callbacks) throw new Error("timeline callbacks did not initialize");
|
||||
return { callbacks, unmount: () => act(() => root.unmount()) };
|
||||
}
|
||||
|
||||
// One selection PER element, so a callback that resolves the selection for the
|
||||
// wrong element gets a visibly different object. A single mockResolvedValue
|
||||
// hands every element the same selection, which passes just as happily when the
|
||||
// write is committed through whatever happens to be selected.
|
||||
function selectionForElement(el: TimelineElement): {
|
||||
id: string;
|
||||
selector: string;
|
||||
sourceFile: string;
|
||||
} {
|
||||
if (el.id === "box") return mocks.selection;
|
||||
return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" };
|
||||
}
|
||||
|
||||
function arrangeClickedCircle(): {
|
||||
circle: TimelineElement;
|
||||
selection: { id: string; selector: string; sourceFile: string };
|
||||
} {
|
||||
const elementKey = "scenes/main.html#circle";
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: elementKey,
|
||||
domId: "circle",
|
||||
sourceFile: "scenes/main.html",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]),
|
||||
});
|
||||
return { circle, selection: selectionForElement(circle) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.animations = [flatAnimation];
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) =>
|
||||
Promise.resolve(selectionForElement(el)),
|
||||
);
|
||||
usePlayerStore.setState({
|
||||
currentTime: 0.5,
|
||||
elements: [element],
|
||||
domClipChildren: [],
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map([["box", [flatAnimation]]]),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({
|
||||
elements: [],
|
||||
domClipChildren: [],
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
||||
it("adds an interior point through the add-keyframe persist boundary", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
|
||||
animationId: flatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
properties: { x: 210 },
|
||||
remove: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
50,
|
||||
{ x: 210 },
|
||||
undefined,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
},
|
||||
25,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// The start boundary moved to 0.25s; the end stays put, so the window is 0.75s.
|
||||
expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
{ position: 0.25, duration: 0.75 },
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
||||
// resize-keyframed-tween would convert the flat tween to keyframes form.
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("reports an unsettled flat-boundary retime as uncommitted", async () => {
|
||||
mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false);
|
||||
const view = renderCallbacks();
|
||||
|
||||
// The diamond snaps back on `false`. Answering `true` the moment update-meta
|
||||
// was dispatched left a rejected boundary drag rendered at its drop position.
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
},
|
||||
25,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("refuses a non-selected element flat boundary instead of deleting the tween", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: otherFlatAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Persisted through the CLICKED element's own selection, not the current one,
|
||||
// and as a remove-keyframe the writer can refuse — never a whole-tween delete.
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("deletes all keyframes through the clicked non-selected element's identity", async () => {
|
||||
const { circle, selection } = arrangeClickedCircle();
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
selection,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => {
|
||||
const opacityAnimation: GsapAnimation = {
|
||||
...otherKeyframedAnimation,
|
||||
id: "circle-to-0-visual",
|
||||
propertyGroup: "visual",
|
||||
};
|
||||
const { circle } = arrangeClickedCircle();
|
||||
usePlayerStore.setState({
|
||||
gsapAnimations: new Map([
|
||||
["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]],
|
||||
]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([
|
||||
otherKeyframedAnimation.id,
|
||||
opacityAnimation.id,
|
||||
]);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("aborts every mutation when the clicked element resolves no selection", async () => {
|
||||
const { circle } = arrangeClickedCircle();
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null);
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteAllKeyframes?.(circle);
|
||||
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// No selection for the clicked element means there is nothing safe to write
|
||||
// to: falling back to the current selection would edit a different file.
|
||||
expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => {
|
||||
const { circle, selection } = arrangeClickedCircle();
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onMoveKeyframeToPlayhead?.(circle, {
|
||||
percentage: 100,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
animationId: otherKeyframedAnimation.id,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The retime target, the selection it commits through, and the animation the
|
||||
// playhead percentage is computed against all come from the CLICKED element.
|
||||
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
selection,
|
||||
otherKeyframedAnimation,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps a selected-element flat boundary on the remove-keyframe path", () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onDeleteKeyframe?.("box", {
|
||||
percentage: 0,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
animationId: flatAnimation.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("routes the flat lane-header remove toggle through the refusable remove path", async () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
|
||||
animationId: flatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 100,
|
||||
properties: { x: 420 },
|
||||
remove: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// The lane-header toggle fires on whichever element owns the lane, which need
|
||||
// not be the selected one. It must still commit through that element's own
|
||||
// selection, and it must never escalate a flat tween to a whole-tween delete.
|
||||
it("removes a non-selected element's flat tween through that element's own selection", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, {
|
||||
animationId: otherFlatAnimation.id,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 0,
|
||||
properties: { x: 0 },
|
||||
remove: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherFlatAnimation.id,
|
||||
0,
|
||||
undefined,
|
||||
selectionForElement(circle),
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps authored interior deletion on the per-keyframe path", () => {
|
||||
mocks.animations = [authoredInteriorAnimation()];
|
||||
usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onDeleteKeyframe?.("box", {
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: flatAnimation.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps an authored interior drag on the per-keyframe move path", async () => {
|
||||
const authored = authoredInteriorAnimation();
|
||||
mocks.animations = [authored];
|
||||
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: authored.id,
|
||||
},
|
||||
75,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
authored.id,
|
||||
50,
|
||||
75,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// A drag starts on whatever diamond the pointer is over, which need not be the
|
||||
// selected element. Resolving against the selection would retime the selected
|
||||
// element's tween and commit it through the selected element's file.
|
||||
it("retimes a non-selected element's keyframe through that element's own selection", async () => {
|
||||
const circle: TimelineElement = {
|
||||
...element,
|
||||
id: "circle",
|
||||
key: "scenes/main.html#circle",
|
||||
domId: "circle",
|
||||
sourceFile: "scenes/main.html",
|
||||
};
|
||||
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
|
||||
const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
|
||||
usePlayerStore.setState({
|
||||
elements: [element, circle],
|
||||
gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
|
||||
});
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
await view.callbacks.onMoveKeyframe?.(
|
||||
"scenes/main.html#circle",
|
||||
{
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: circleAnimation.id,
|
||||
},
|
||||
75,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
circleAnimation.id,
|
||||
50,
|
||||
75,
|
||||
circleSelection,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// The diamond's rapid-second-retime path reports the PENDING clip-% (where the
|
||||
// first drag put the keyframe), which the keyframe cache has not caught up to.
|
||||
// TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves
|
||||
// the real callback resolves that stale-cache position off the identity fields
|
||||
// instead of failing the lookup.
|
||||
it("retimes from a pending position the keyframe cache has not caught up to", async () => {
|
||||
const authored = authoredInteriorAnimation();
|
||||
mocks.animations = [authored];
|
||||
usePlayerStore.setState({
|
||||
elements: [element],
|
||||
gsapAnimations: new Map([["index.html#box", [authored]]]),
|
||||
// Still the pre-drag positions: 75% is not in here.
|
||||
keyframeCache: new Map([
|
||||
[
|
||||
"index.html#box",
|
||||
{
|
||||
format: "percentage" as const,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 210 } },
|
||||
{ percentage: 100, properties: { x: 420 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"index.html#box",
|
||||
{
|
||||
percentage: 75,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: authored.id,
|
||||
},
|
||||
85,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
authored.id,
|
||||
50,
|
||||
85,
|
||||
mocks.selection,
|
||||
);
|
||||
|
||||
// Control: the same drag WITHOUT the identity fields falls back to the cache
|
||||
// lookup, finds nothing at 75%, and cannot retime.
|
||||
mocks.actions.handleGsapMoveKeyframe.mockClear();
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85),
|
||||
).resolves.toBe(false);
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("uses the clip timing basis when retiming a duration-less tween", async () => {
|
||||
const durationless = {
|
||||
...authoredInteriorAnimation(),
|
||||
position: 3.2,
|
||||
resolvedStart: 3.2,
|
||||
duration: undefined,
|
||||
};
|
||||
const wideElement = { ...element, start: 10.94, duration: 16.26 };
|
||||
mocks.animations = [durationless];
|
||||
usePlayerStore.setState({
|
||||
elements: [wideElement],
|
||||
gsapAnimations: new Map([["box", [durationless]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await expect(
|
||||
view.callbacks.onMoveKeyframe?.(
|
||||
"box",
|
||||
{
|
||||
percentage: 19.1,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
animationId: durationless.id,
|
||||
},
|
||||
40,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
|
||||
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
|
||||
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
|
||||
// zero-length tween, or the clip's own 0-100 %) produces a different number.
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
||||
durationless.id,
|
||||
50,
|
||||
expect.closeTo(87.601, 3),
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
@@ -10,8 +11,15 @@ import {
|
||||
} from "../../contexts/DomEditContext";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
||||
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
||||
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
||||
import type { DomEditSelection } from "../editor/domEditingTypes";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import {
|
||||
getTimelineElementIdentity,
|
||||
splitTimelineElementKey,
|
||||
} from "../../player/lib/timelineElementHelpers";
|
||||
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
|
||||
|
||||
export interface TimelineEditCallbackDeps {
|
||||
handleTimelineElementMove: (
|
||||
@@ -46,15 +54,14 @@ interface TimelineCachedKeyframe {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a rendered timeline diamond back to the animation that authored it.
|
||||
* Flat tweens use synthesized diamonds, so a mixed flat tween may have neither
|
||||
* a property group nor real keyframes. The cache currently carries a property
|
||||
* group, not an animation id, so resolution is safe only when that group has a
|
||||
* single candidate. Ambiguous candidates remain unresolved rather than
|
||||
* retiming an arbitrary tween.
|
||||
* Prefer the animation identity carried by the rendered keyframe. Legacy cache
|
||||
* entries without one are safe only when their property group has one candidate;
|
||||
* ambiguous candidates remain unresolved rather than retiming an arbitrary tween.
|
||||
*/
|
||||
export function resolveTimelineKeyframeTarget(
|
||||
pct: number,
|
||||
@@ -63,6 +70,14 @@ export function resolveTimelineKeyframeTarget(
|
||||
): { animId: string; tweenPct: number } | null {
|
||||
const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
||||
if (!kf) return null;
|
||||
const identifiedAnimation = kf.animationId
|
||||
? animations.find((animation) => animation.id === kf.animationId)
|
||||
: undefined;
|
||||
if (kf.animationId) {
|
||||
return identifiedAnimation
|
||||
? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct }
|
||||
: null;
|
||||
}
|
||||
const group = kf?.propertyGroup;
|
||||
const candidates = group
|
||||
? animations.filter((animation) => animation.propertyGroup === group)
|
||||
@@ -98,22 +113,69 @@ export function useTimelineEditCallbacks({
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
buildDomSelectionForTimelineElement,
|
||||
} = useDomEditActionsContext();
|
||||
|
||||
const resolveElementAnimations = useCallback(
|
||||
(elementKey: string): GsapAnimation[] => {
|
||||
const { gsapAnimations } = usePlayerStore.getState();
|
||||
const { sourceFile, domId } = splitTimelineElementKey(elementKey);
|
||||
const scope = sourceFile ?? activeCompPath ?? "index.html";
|
||||
// elementCacheKeys owns the key-variant list the writers use; reading it
|
||||
// back by hand here is how the two sides drift.
|
||||
for (const key of elementCacheKeys(scope, domId)) {
|
||||
const animations = gsapAnimations.get(key);
|
||||
if (animations) return animations;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[activeCompPath],
|
||||
);
|
||||
|
||||
// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
|
||||
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
|
||||
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
||||
// anim in the keyframe's property group, falling back to the first keyframed one.
|
||||
const resolveKeyframeTarget = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(pct: number): { animId: string; tweenPct: number } | null => {
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
||||
return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
|
||||
(
|
||||
elementKey: string,
|
||||
target: TimelineKeyframeTarget,
|
||||
animations: GsapAnimation[] = selectedGsapAnimations,
|
||||
): { animId: string; tweenPct: number } | null => {
|
||||
const carriesIdentity =
|
||||
target.propertyGroup !== undefined ||
|
||||
target.tweenPercentage !== undefined ||
|
||||
target.animationId !== undefined;
|
||||
// The clicked element's own cache: the diamond context menu can open on an
|
||||
// element that is not the selected one, and reading the selection's cache
|
||||
// there resolves against the wrong element.
|
||||
const keyframeCache = usePlayerStore.getState().keyframeCache;
|
||||
const cached =
|
||||
keyframeCache.get(elementKey) ??
|
||||
keyframeCache.get(splitTimelineElementKey(elementKey).domId);
|
||||
return resolveTimelineKeyframeTarget(
|
||||
target.percentage,
|
||||
carriesIdentity ? [target] : (cached?.keyframes ?? []),
|
||||
animations,
|
||||
);
|
||||
},
|
||||
[domEditSelection?.id, selectedGsapAnimations],
|
||||
[selectedGsapAnimations],
|
||||
);
|
||||
|
||||
const removeKeyframeTarget = useCallback(
|
||||
(animationId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
|
||||
// A flat tween's two diamonds are SYNTHESIZED endpoints, not authored
|
||||
// keyframes, so "remove keyframe" has nothing to remove. Escalating to a
|
||||
// whole-animation delete here destroyed the authored tween and its source
|
||||
// comment on a single click, with no undo beyond the editor's own stack.
|
||||
// Always post remove-keyframe: the writer refuses it for a flat tween
|
||||
// (`changed:false`, file untouched), which is the correct no-op.
|
||||
handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
|
||||
},
|
||||
[handleGsapRemoveKeyframe],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -127,22 +189,59 @@ export function useTimelineEditCallbacks({
|
||||
onSplitElement: handleTimelineElementSplit,
|
||||
onRazorSplit: handleRazorSplit,
|
||||
onRazorSplitAll: handleRazorSplitAll,
|
||||
onDeleteAllKeyframes: () => {
|
||||
onDeleteAllKeyframes: (element) => {
|
||||
// Hold the element where it is (collapse keyframes to a static set) rather
|
||||
// than deleting the whole animation — deleting strands a stale GSAP base
|
||||
// that the next drag adds to, flinging the element off-screen.
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (!anim) return;
|
||||
handleGsapRemoveAllKeyframes(anim.id);
|
||||
const elementKey = getTimelineElementIdentity(element);
|
||||
// Every keyframed tween on the layer, not just the first: a layer with
|
||||
// position AND opacity keyframes left the second one keyframed, so
|
||||
// "Delete All Keyframes" visibly did half the job.
|
||||
const anims = resolveElementAnimations(elementKey).filter(
|
||||
(animation) => animation.keyframes,
|
||||
);
|
||||
if (anims.length === 0) return;
|
||||
void buildDomSelectionForTimelineElement(element).then(async (selection) => {
|
||||
if (!selection) return;
|
||||
// Serial: each removal rewrites the same source file, so dispatching
|
||||
// them together would have the later writes read a pre-edit document.
|
||||
for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
|
||||
});
|
||||
},
|
||||
onDeleteKeyframe: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
|
||||
onDeleteKeyframe: (elId, keyframe) => {
|
||||
const animations = resolveElementAnimations(elId);
|
||||
const target = resolveKeyframeTarget(elId, keyframe, animations);
|
||||
if (!target) return;
|
||||
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
||||
if (!element) {
|
||||
removeKeyframeTarget(target.animId, target.tweenPct);
|
||||
return;
|
||||
}
|
||||
// Persist through the CLICKED element's own selection so a deletion on a
|
||||
// non-selected element (especially one in a different source file) commits
|
||||
// against the right element instead of the current domEditSelection.
|
||||
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
||||
if (selection) removeKeyframeTarget(target.animId, target.tweenPct, selection);
|
||||
});
|
||||
},
|
||||
// Retime the keyframe to the playhead, preserving its value + ease.
|
||||
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
|
||||
// Retime the keyframe to the playhead, preserving its value + ease. The
|
||||
// clicked element owns the whole write: its animations resolve the target,
|
||||
// its selection commits it, and its animation computes the playhead
|
||||
// percentage. Mixing frames here retimed against the selected element's
|
||||
// tween and wrote the result into the clicked element's file.
|
||||
onMoveKeyframeToPlayhead: (element, keyframe) => {
|
||||
const elementKey = getTimelineElementIdentity(element);
|
||||
const animations = resolveElementAnimations(elementKey);
|
||||
const target = resolveKeyframeTarget(elementKey, keyframe, animations);
|
||||
const animation = target
|
||||
? animations.find((candidate) => candidate.id === target.animId)
|
||||
: undefined;
|
||||
if (!target || !animation) return;
|
||||
void buildDomSelectionForTimelineElement(element).then((selection) => {
|
||||
if (selection) {
|
||||
handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation);
|
||||
}
|
||||
});
|
||||
},
|
||||
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
||||
// the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
|
||||
@@ -152,14 +251,19 @@ export function useTimelineEditCallbacks({
|
||||
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
||||
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
||||
// fallow-ignore-next-line complexity
|
||||
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
const sel = domEditSelection;
|
||||
if (!target || !sel) return;
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
||||
onMoveKeyframe: async (elId, keyframe, toClipPct) => {
|
||||
const animations = resolveElementAnimations(elId);
|
||||
const target = resolveKeyframeTarget(elId, keyframe, animations);
|
||||
if (!target) return false;
|
||||
// The dragged diamond's OWN element, not the selected one: a drag on a
|
||||
// non-selected clip has to read that clip's animations and commit
|
||||
// through that clip's selection, or it retimes whatever is selected.
|
||||
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
||||
const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection;
|
||||
if (!sel) return false;
|
||||
const anim = animations.find((a) => a.id === target.animId);
|
||||
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
||||
if (!anim || tweenStart === null) return;
|
||||
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
||||
if (!anim || tweenStart === null) return Promise.resolve(false);
|
||||
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(
|
||||
@@ -168,6 +272,7 @@ export function useTimelineEditCallbacks({
|
||||
elements,
|
||||
domClipChildren,
|
||||
);
|
||||
const tweenDuration = resolveTweenDuration(anim, elDuration);
|
||||
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
||||
const decision = resolveKeyframeRetime({
|
||||
keyframes: anim.keyframes?.keyframes ?? [],
|
||||
@@ -177,35 +282,37 @@ export function useTimelineEditCallbacks({
|
||||
dropAbsTime,
|
||||
});
|
||||
if (decision.kind === "move" && decision.toTweenPct != null) {
|
||||
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
|
||||
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
|
||||
} else if (
|
||||
decision.kind === "resize" &&
|
||||
decision.pctRemap &&
|
||||
decision.position != null &&
|
||||
decision.duration != null
|
||||
) {
|
||||
if (anim.keyframes) {
|
||||
handleGsapResizeKeyframedTween(
|
||||
// An empty remap means a FLAT tween's synthesized boundary: there is no
|
||||
// keyframe node to re-key, only the window to move. Sending it through
|
||||
// the keyframed-resize writer would rewrite the authored flat tween into
|
||||
// keyframes form as a side effect of a pure position/duration change, so
|
||||
// dispatch update-meta and leave the tween as the author wrote it.
|
||||
if (decision.pctRemap.length === 0) {
|
||||
// Report the write's real settlement, like every other branch here:
|
||||
// answering `true` while the meta update is still in flight tells the
|
||||
// diamond the retime landed, so a rejected write never snaps back.
|
||||
return handleGsapUpdateMeta(
|
||||
target.animId,
|
||||
decision.position,
|
||||
decision.duration,
|
||||
decision.pctRemap,
|
||||
{ position: decision.position, duration: decision.duration },
|
||||
sel,
|
||||
);
|
||||
} else {
|
||||
// resize-keyframed-tween requires an authored `keyframes` AST node
|
||||
// and intentionally no-ops for a flat tween. Update its real tween
|
||||
// window through the metadata writer (and SDK cutover path) instead.
|
||||
handleGsapUpdateMeta(target.animId, {
|
||||
position: decision.position,
|
||||
duration: decision.duration,
|
||||
});
|
||||
}
|
||||
return handleGsapResizeKeyframedTween(
|
||||
target.animId,
|
||||
decision.position,
|
||||
decision.duration,
|
||||
decision.pctRemap,
|
||||
sel,
|
||||
);
|
||||
}
|
||||
},
|
||||
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
|
||||
for (const anim of selectedGsapAnimations) {
|
||||
if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
|
||||
@@ -214,18 +321,49 @@ export function useTimelineEditCallbacks({
|
||||
el.duration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
|
||||
: 0;
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim?.keyframes) {
|
||||
const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
if (existing) {
|
||||
handleGsapRemoveKeyframe(anim.id, existing.percentage);
|
||||
// Same frame for read and write: the toggled element's animations decide
|
||||
// add-vs-remove, and its selection is what the mutation commits through.
|
||||
const animations = resolveElementAnimations(getTimelineElementIdentity(el));
|
||||
void buildDomSelectionForTimelineElement(el).then((selection) => {
|
||||
if (!selection) return;
|
||||
const anim = animations.find((a) => a.keyframes);
|
||||
if (anim?.keyframes) {
|
||||
const existing = anim.keyframes.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= 1,
|
||||
);
|
||||
if (existing) {
|
||||
handleGsapRemoveKeyframe(anim.id, existing.percentage, undefined, selection);
|
||||
} else {
|
||||
handleGsapAddKeyframe(anim.id, pct, "x", 0, selection);
|
||||
}
|
||||
} else {
|
||||
handleGsapAddKeyframe(anim.id, pct, "x", 0);
|
||||
const flatAnim = animations.find((a) => !a.keyframes);
|
||||
if (flatAnim) {
|
||||
void handleGsapConvertToKeyframes(
|
||||
flatAnim.id,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selection,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
|
||||
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
|
||||
});
|
||||
},
|
||||
onTogglePropertyGroupKeyframe: async (element, target) => {
|
||||
const selection = await buildDomSelectionForTimelineElement(element);
|
||||
if (!selection) return;
|
||||
if (target.remove) {
|
||||
removeKeyframeTarget(target.animationId, target.tweenPercentage, selection);
|
||||
return;
|
||||
}
|
||||
await handleGsapAddKeyframeBatch(
|
||||
target.animationId,
|
||||
target.tweenPercentage,
|
||||
target.properties,
|
||||
undefined,
|
||||
selection,
|
||||
);
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -240,14 +378,16 @@ export function useTimelineEditCallbacks({
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
resolveElementAnimations,
|
||||
resolveKeyframeTarget,
|
||||
removeKeyframeTarget,
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapMoveKeyframeToPlayhead,
|
||||
handleGsapMoveKeyframe,
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
buildDomSelectionForTimelineElement,
|
||||
projectId,
|
||||
|
||||
@@ -40,10 +40,10 @@ export function TimelineEditProvider({
|
||||
value.onRazorSplitAll,
|
||||
value.onDeleteKeyframe,
|
||||
value.onDeleteAllKeyframes,
|
||||
value.onChangeKeyframeEase,
|
||||
value.onMoveKeyframeToPlayhead,
|
||||
value.onMoveKeyframe,
|
||||
value.onToggleKeyframeAtPlayhead,
|
||||
value.onTogglePropertyGroupKeyframe,
|
||||
],
|
||||
);
|
||||
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
|
||||
import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
|
||||
let deleteKeyframesCommitCounter = 0;
|
||||
@@ -18,18 +18,34 @@ export function deleteSelectedKeyframes(session: {
|
||||
) => 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.
|
||||
const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
|
||||
if (!selectedElementId) return;
|
||||
const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes);
|
||||
// A collapsed selection key (an ungrouped animation) carries no animation id,
|
||||
// so it only resolves when there is exactly one keyframed animation it could
|
||||
// mean. Taking the first of several deletes an arbitrary tween's keyframe.
|
||||
const fallbackAnimation = keyframedAnimations.length === 1 ? keyframedAnimations[0] : undefined;
|
||||
const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation]));
|
||||
const removals = new Map<string, { animationId: string; percentage: number }>();
|
||||
for (const key of selectedKeyframes) {
|
||||
const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key);
|
||||
if (!target) continue;
|
||||
const animation = target.animationId
|
||||
? animationsById.get(target.animationId)
|
||||
: fallbackAnimation;
|
||||
if (!animation) continue;
|
||||
const percentage = target.tweenPercentage ?? target.percentage;
|
||||
removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage });
|
||||
}
|
||||
const targets = [...removals.values()];
|
||||
if (targets.length === 0) return;
|
||||
const coalesceOptions = {
|
||||
coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
|
||||
coalesceMs: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
for (const [index, pct] of percentages.entries()) {
|
||||
session.handleGsapRemoveKeyframe(animation.id, pct, {
|
||||
for (const [index, target] of targets.entries()) {
|
||||
session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
|
||||
...coalesceOptions,
|
||||
...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { computeElementPercentage } from "./gsapShared";
|
||||
import { computeElementPercentage, idSelector } from "./gsapShared";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
||||
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
||||
@@ -117,7 +117,7 @@ export async function materializeIfDynamic(
|
||||
const allScanned = scanAllRuntimeKeyframes(iframe);
|
||||
if (allScanned.size === 0) return;
|
||||
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
||||
selector: `#${id}`,
|
||||
selector: idSelector(id),
|
||||
keyframes: data.keyframes,
|
||||
easeEach: data.easeEach,
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import {
|
||||
@@ -11,6 +12,31 @@ import {
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
|
||||
/**
|
||||
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
|
||||
* within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the
|
||||
* server takes a replace-with-keyframes list verbatim, so an append-only build
|
||||
* could hand it two keyframes a fraction of a percent apart. The invariant lives
|
||||
* here rather than in each caller's own pre-check, which is how the two callers
|
||||
* ended up with different tolerances in the first place.
|
||||
*/
|
||||
export function buildTemporalArcKeyframes(
|
||||
anim: GsapAnimation,
|
||||
percentage: number,
|
||||
properties: Record<string, number>,
|
||||
) {
|
||||
return [
|
||||
...(anim.keyframes?.keyframes ?? [])
|
||||
.filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
|
||||
.map((keyframe) => ({
|
||||
percentage: keyframe.percentage,
|
||||
properties: { ...keyframe.properties },
|
||||
...(keyframe.ease ? { ease: keyframe.ease } : {}),
|
||||
})),
|
||||
{ percentage, properties },
|
||||
].sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
async function extendTweenAndAddKeyframe(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
@@ -143,7 +169,7 @@ async function commitFlatViaKeyframes(
|
||||
): Promise<void> {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const td = resolveEditableTweenDuration(anim, selection);
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const outsideRange =
|
||||
activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
@@ -184,8 +210,13 @@ async function commitFlatViaKeyframes(
|
||||
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
||||
// By id first: a target with several tweens (two `to`s on the same selector)
|
||||
// matches the selector lookup on whichever one happens to be first, and the
|
||||
// extend-and-add below would then rewrite a tween the drag never touched.
|
||||
const converted =
|
||||
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
|
||||
fresh.find((a) => a.id === anim.id && a.keyframes) ??
|
||||
fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
|
||||
anim;
|
||||
const convertedStart = resolveTweenStart(converted) ?? ts;
|
||||
const convertedDur = resolveTweenDuration(converted) || td;
|
||||
await extendTweenAndAddKeyframe(
|
||||
@@ -259,13 +290,61 @@ export async function commitGsapPositionFromDrag(
|
||||
|
||||
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
if (anim.arcPath?.enabled) {
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
const keyframes = anim.keyframes?.keyframes ?? [];
|
||||
// Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
|
||||
// tighter one here meant a drag that landed a fraction of a percent off an
|
||||
// authored waypoint skipped the update-point branch and appended instead.
|
||||
const pointIndex = keyframes.findIndex(
|
||||
(kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
||||
);
|
||||
if (pointIndex >= 0) {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: anim.id,
|
||||
pointIndex,
|
||||
x: newX,
|
||||
y: newY,
|
||||
},
|
||||
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
setActiveKeyframePct(null);
|
||||
parkPlayheadOnKeyframe(anim, pct);
|
||||
return;
|
||||
}
|
||||
|
||||
const tweenStart = resolveTweenStart(anim);
|
||||
// Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
|
||||
// default here would collapse a duration-less arc's window on drag.
|
||||
const tweenDuration = resolveEditableTweenDuration(anim, selection);
|
||||
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
|
||||
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(tweenStart),
|
||||
duration: roundTo3(tweenDuration),
|
||||
keyframes: temporalKeyframes,
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||
|
||||
const ts = resolveTweenStart(effectiveAnim);
|
||||
const td = resolveTweenDuration(effectiveAnim);
|
||||
const td = resolveEditableTweenDuration(effectiveAnim, selection);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||
if (outsideRange && !hasSelectedKeyframe) {
|
||||
@@ -293,7 +372,7 @@ export async function commitGsapPositionFromDrag(
|
||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const td = resolveEditableTweenDuration(anim, selection);
|
||||
const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
|
||||
const outsideRange =
|
||||
!hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
@@ -313,7 +392,7 @@ export async function commitGsapPositionFromDrag(
|
||||
|
||||
if (existingPosAnim?.keyframes) {
|
||||
const posTs = resolveTweenStart(existingPosAnim);
|
||||
const posTd = resolveTweenDuration(existingPosAnim);
|
||||
const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
|
||||
if (posTs !== null) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerS
|
||||
import {
|
||||
clearKeyframeCacheForElement,
|
||||
clearKeyframeCacheForFile,
|
||||
pruneKeyframeCacheToFiles,
|
||||
updateKeyframeCacheFromParsed,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
|
||||
@@ -28,7 +29,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
|
||||
usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
|
||||
});
|
||||
|
||||
describe("clearKeyframeCacheForElement", () => {
|
||||
@@ -84,6 +85,20 @@ describe("clearKeyframeCacheForFile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Several composition files re-scan concurrently, so a clear that walked the
|
||||
// index.html alias would delete rows a sibling file had just written.
|
||||
it("leaves an index.html-owned element alone when another file re-scans", () => {
|
||||
seed("index.html#title");
|
||||
seed("title");
|
||||
seed("comp.html#a");
|
||||
|
||||
clearKeyframeCacheForFile("comp.html");
|
||||
|
||||
expect(cache().has("index.html#title")).toBe(true);
|
||||
expect(cache().has("title")).toBe(true);
|
||||
expect(cache().has("comp.html#a")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves entries that belong to a different source file", () => {
|
||||
seed("comp.html#a");
|
||||
seed("a");
|
||||
@@ -97,7 +112,84 @@ describe("clearKeyframeCacheForFile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneKeyframeCacheToFiles", () => {
|
||||
// Switching composition leaves the previous comp's elements cached with no
|
||||
// owner left to clear them: each file only ever clears its own entries.
|
||||
it("drops every element of a file the next scan no longer covers", () => {
|
||||
seed("index.html#stress-1");
|
||||
seed("stress-1");
|
||||
seed("index.html#stress-2");
|
||||
seed("stress-2");
|
||||
seed("kf200.html#kf200");
|
||||
seed("index.html#kf200");
|
||||
seed("kf200");
|
||||
|
||||
pruneKeyframeCacheToFiles(["kf200.html"]);
|
||||
|
||||
for (const key of ["index.html#stress-1", "stress-1", "index.html#stress-2", "stress-2"]) {
|
||||
expect(cache().has(key)).toBe(false);
|
||||
}
|
||||
expect(cache().has("kf200.html#kf200")).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes gsapAnimations alongside keyframeCache", () => {
|
||||
usePlayerStore.getState().setGsapAnimations("index.html#stress-1", [animWithKeyframes("t")]);
|
||||
usePlayerStore.getState().setGsapAnimations("kf200.html#kf200", [animWithKeyframes("u")]);
|
||||
|
||||
pruneKeyframeCacheToFiles(["kf200.html"]);
|
||||
|
||||
const animations = usePlayerStore.getState().gsapAnimations;
|
||||
expect(animations.has("index.html#stress-1")).toBe(false);
|
||||
expect(animations.has("kf200.html#kf200")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps everything when every cached file is still covered", () => {
|
||||
seed("index.html#hero");
|
||||
seed("comp.html#a");
|
||||
|
||||
pruneKeyframeCacheToFiles(["index.html", "comp.html"]);
|
||||
|
||||
expect(cache().has("index.html#hero")).toBe(true);
|
||||
expect(cache().has("comp.html#a")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateKeyframeCacheFromParsed", () => {
|
||||
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
|
||||
const animation: GsapAnimation = {
|
||||
...animWithKeyframes("hero"),
|
||||
duration: 2,
|
||||
resolvedStart: 3,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 }, ease: "power1.inOut" },
|
||||
{ percentage: 100, properties: { x: 200 } },
|
||||
],
|
||||
easeEach: "power1.inOut",
|
||||
},
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{
|
||||
id: "hero-clip",
|
||||
domId: "hero",
|
||||
tag: "div",
|
||||
start: 2,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {});
|
||||
|
||||
expect(JSON.stringify(cache().get("scene.html#hero"))).toBe(
|
||||
'{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the bare key when the selected element no longer has keyframes", () => {
|
||||
// Element previously had keyframes, so a bare entry exists (writes set both).
|
||||
seed("index.html#box");
|
||||
@@ -118,4 +210,87 @@ describe("updateKeyframeCacheFromParsed", () => {
|
||||
expect(cache().has("index.html#hero")).toBe(true);
|
||||
expect(cache().has("hero")).toBe(true);
|
||||
});
|
||||
|
||||
it("caches flat tweens as clip-relative start and end keyframes", () => {
|
||||
const animation: GsapAnimation = {
|
||||
id: "flat-box",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 1,
|
||||
properties: { x: 420 },
|
||||
duration: 2,
|
||||
resolvedStart: 1,
|
||||
ease: "power2.out",
|
||||
propertyGroup: "position",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }],
|
||||
});
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
|
||||
|
||||
expect(cache().get("scene.html#box")).toEqual({
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
properties: { x: 0 },
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "flat-box",
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
properties: { x: 420 },
|
||||
ease: "power2.out",
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "flat-box",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
|
||||
});
|
||||
|
||||
it("records an ungrouped tween in gsapAnimations too, so the two stores agree", () => {
|
||||
// `{ x, opacity }` spans two property groups, so the parser leaves
|
||||
// propertyGroup undefined. Skipping it here used to cache diamonds with no
|
||||
// source animation behind them: the collapsed row drew keyframes the
|
||||
// expanded lanes could not render.
|
||||
const animation: GsapAnimation = {
|
||||
id: "mixed-box",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { x: 100, opacity: 0 },
|
||||
duration: 1,
|
||||
resolvedStart: 0,
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "box-clip", domId: "box", tag: "div", start: 0, duration: 1, track: 0 }],
|
||||
});
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
|
||||
|
||||
expect(cache().has("scene.html#box")).toBe(true);
|
||||
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
|
||||
});
|
||||
|
||||
it("does not cache a flat tween without animatable numeric properties", () => {
|
||||
const animation: GsapAnimation = {
|
||||
id: "flat-box",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { backgroundColor: "#fff" },
|
||||
duration: 1,
|
||||
propertyGroup: "visual",
|
||||
};
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
|
||||
|
||||
expect(cache().has("scene.html#box")).toBe(false);
|
||||
expect(cache().has("box")).toBe(false);
|
||||
expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { toAbsoluteTime } from "./gsapShared";
|
||||
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
export function updateKeyframeCacheFromParsed(
|
||||
animations: GsapAnimation[],
|
||||
@@ -15,57 +16,52 @@ export function updateKeyframeCacheFromParsed(
|
||||
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
||||
const idsWithKeyframes = new Set<string>();
|
||||
const merged = new Map<string, KeyframeCacheEntry>();
|
||||
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
||||
for (const anim of animations) {
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id || !anim.keyframes) continue;
|
||||
const id = idFromSelector(anim.targetSelector);
|
||||
const kfSource =
|
||||
anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
|
||||
if (!id || kfSource.length === 0) continue;
|
||||
idsWithKeyframes.add(id);
|
||||
// Every tween that fed keyframeCache also lands in gsapAnimations, group or
|
||||
// not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
|
||||
// cache diamonds with no source animation behind them, so the collapsed row
|
||||
// drew keyframes the expanded lanes couldn't render. Lane consumers do the
|
||||
// group filtering themselves (animationContributesLane).
|
||||
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
|
||||
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
||||
const tweenDur = anim.duration ?? 1;
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 1;
|
||||
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
const clipPct =
|
||||
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
||||
return {
|
||||
...kf,
|
||||
percentage: clipPct,
|
||||
tweenPercentage: kf.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
};
|
||||
});
|
||||
const clipKeyframes = toClipKeyframes(
|
||||
kfSource,
|
||||
anim,
|
||||
timelineEl?.start ?? 0,
|
||||
timelineEl?.duration ?? 1,
|
||||
);
|
||||
|
||||
const existing = merged.get(id);
|
||||
if (existing) {
|
||||
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
|
||||
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
|
||||
const prev = byPct.get(kf.percentage);
|
||||
if (prev) {
|
||||
prev.properties = { ...prev.properties, ...kf.properties };
|
||||
if (kf.ease) prev.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
}
|
||||
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
|
||||
// flag downstream lanes read); a second copy of that rule here is how the
|
||||
// two writers drift.
|
||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
||||
} else {
|
||||
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
|
||||
merged.set(id, {
|
||||
...anim.keyframes,
|
||||
format: anim.keyframes?.format ?? "percentage",
|
||||
keyframes: clipKeyframes,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of merged) {
|
||||
setKeyframeCache(`${targetPath}#${id}`, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
|
||||
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
|
||||
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
|
||||
}
|
||||
const targetId =
|
||||
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
||||
selectionId;
|
||||
idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
|
||||
if (targetId && !idsWithKeyframes.has(targetId)) {
|
||||
clearKeyframeCacheForElement(targetPath, targetId);
|
||||
}
|
||||
@@ -84,40 +80,85 @@ export function updateKeyframeCacheFromParsed(
|
||||
* a new cache map and re-render every subscriber.
|
||||
*/
|
||||
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
||||
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
||||
const keys =
|
||||
sourceFile === "index.html"
|
||||
? [`index.html#${elementId}`, elementId]
|
||||
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||
const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
|
||||
usePlayerStore.getState();
|
||||
const keys = elementCacheKeys(sourceFile, elementId);
|
||||
for (const key of keys) {
|
||||
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
||||
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear every cached element of `sourceFile` before a full re-scan repopulates
|
||||
* it. Collects the element ids that currently have a prefixed or index.html
|
||||
* fallback key for the file and drops each through clearKeyframeCacheForElement
|
||||
* so the bare key goes too — an element whose keyframes were removed (and so is
|
||||
* absent from the re-scan) leaves no stale bare entry behind.
|
||||
* it. Only the file's OWN prefixed keys name the ids to clear: every write sets
|
||||
* the prefixed key (see elementCacheKeys), so the file's elements are all
|
||||
* reachable that way, and clearKeyframeCacheForElement then takes the
|
||||
* index.html alias and the bare key with them — an element whose keyframes were
|
||||
* removed (and so is absent from the re-scan) leaves no stale bare entry
|
||||
* behind. Reading the alias prefix here instead would collect ids owned by
|
||||
* OTHER files, and several files re-scan concurrently, so this file's clear
|
||||
* would wipe the entries a sibling file had just written.
|
||||
*/
|
||||
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
||||
const { keyframeCache } = usePlayerStore.getState();
|
||||
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
||||
const sfPrefix = `${sourceFile}#`;
|
||||
const fallbackPrefix = "index.html#";
|
||||
const ids = new Set<string>();
|
||||
for (const key of keyframeCache.keys()) {
|
||||
const matchesFile =
|
||||
key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
|
||||
if (!matchesFile) continue;
|
||||
const hashIdx = key.indexOf("#");
|
||||
if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
|
||||
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
||||
if (!key.startsWith(sfPrefix)) continue;
|
||||
ids.add(key.slice(sfPrefix.length));
|
||||
}
|
||||
for (const id of ids) {
|
||||
clearKeyframeCacheForElement(sourceFile, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every cached element owned by a file that is no longer on screen. Each
|
||||
* file only ever clears its OWN entries (see clearKeyframeCacheForFile), so
|
||||
* switching composition left the previous composition's elements cached forever
|
||||
* — 240 entries per switch on a 120-clip comp, in both keyframeCache and
|
||||
* gsapAnimations, with nothing to evict them. Called once before a re-scan, with
|
||||
* the full set of files that scan covers.
|
||||
*/
|
||||
export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
||||
const keep = new Set(files);
|
||||
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
||||
const stale = new Map<string, Set<string>>();
|
||||
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
||||
const hash = key.indexOf("#");
|
||||
// Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them
|
||||
// with their prefixed key, so skipping them here loses nothing.
|
||||
if (hash < 0) continue;
|
||||
const sourceFile = key.slice(0, hash);
|
||||
if (keep.has(sourceFile)) continue;
|
||||
const ids = stale.get(sourceFile) ?? new Set<string>();
|
||||
ids.add(key.slice(hash + 1));
|
||||
stale.set(sourceFile, ids);
|
||||
}
|
||||
for (const [sourceFile, ids] of stale) {
|
||||
for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every cache key a write for this element sets, in read-preference order. */
|
||||
export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
|
||||
return sourceFile === "index.html"
|
||||
? [`index.html#${elementId}`, elementId]
|
||||
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||
}
|
||||
|
||||
export function writeGsapAnimationsForElement(
|
||||
sourceFile: string,
|
||||
elementId: string,
|
||||
animations: GsapAnimation[] | undefined,
|
||||
): void {
|
||||
const { setGsapAnimations } = usePlayerStore.getState();
|
||||
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
||||
setGsapAnimations(key, animations);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCacheKey(sourceFile: string, elementId: string): string {
|
||||
return `${sourceFile}#${elementId}`;
|
||||
}
|
||||
|
||||
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
||||
expect(types).not.toContain("replace-with-keyframes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryGsapDragIntercept — motion paths", () => {
|
||||
const motionPathAnim = {
|
||||
id: "#puck-b-to-12170-position",
|
||||
targetSelector: "#puck-b",
|
||||
propertyGroup: "position",
|
||||
method: "to",
|
||||
position: 12.17,
|
||||
resolvedStart: 12.17,
|
||||
duration: 16.055,
|
||||
ease: "power1.inOut",
|
||||
properties: {},
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
const liveTween = {
|
||||
targets: () => [{ id: "puck-b" }],
|
||||
vars: { motionPath: { path: [] }, duration: 16.055 },
|
||||
duration: () => 16.055,
|
||||
startTime: () => 12.17,
|
||||
};
|
||||
|
||||
async function dragMotionPath(activeKeyframePct: number | null) {
|
||||
usePlayerStore.setState({
|
||||
autoKeyframeEnabled: true,
|
||||
activeKeyframePct,
|
||||
currentTime: 15.9,
|
||||
});
|
||||
const commitMutation = vi.fn();
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
{ x: -50, y: 30 },
|
||||
[motionPathAnim],
|
||||
fakeIframe("puck-b", [liveTween]),
|
||||
commitMutation,
|
||||
);
|
||||
return { commitMutation, handled };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({ activeKeyframePct: null });
|
||||
});
|
||||
|
||||
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(null);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: motionPathAnim.id,
|
||||
targetSelector: "#puck-b",
|
||||
position: 12.17,
|
||||
duration: 16.055,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 23.233, properties: { x: -50, y: 30 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"add-motion-path-point",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(50);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: motionPathAnim.id,
|
||||
pointIndex: 1,
|
||||
x: -50,
|
||||
y: 30,
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"replace-with-keyframes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
|
||||
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
||||
import { idSelector } from "./gsapShared";
|
||||
|
||||
export function ensureElementAddressable(selection: DomEditSelection): {
|
||||
selector: string;
|
||||
autoId?: string;
|
||||
} {
|
||||
if (selection.id) return { selector: `#${selection.id}` };
|
||||
if (selection.id) return { selector: idSelector(selection.id) };
|
||||
if (selection.selector) return { selector: selection.selector };
|
||||
|
||||
const el = selection.element;
|
||||
@@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): {
|
||||
id = `${tag}-${n}`;
|
||||
}
|
||||
el.setAttribute("id", id);
|
||||
return { selector: `#${id}`, autoId: id };
|
||||
return { selector: idSelector(id), autoId: id };
|
||||
}
|
||||
|
||||
export class GsapMutationHttpError extends Error {
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface MutationResult {
|
||||
|
||||
export interface CommitMutationOptions {
|
||||
label: string;
|
||||
/** Observe the durable writer result without duplicating the request path. */
|
||||
onResult?: (result: MutationResult) => void;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
softReload?: boolean;
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { isInstantHold, parsePercentageKeyframes } from "./gsapShared";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
idFromSelector,
|
||||
idSelector,
|
||||
isInstantHold,
|
||||
parsePercentageKeyframes,
|
||||
resolveEditableTweenDuration,
|
||||
toClipKeyframes,
|
||||
toClipPercentage,
|
||||
} from "./gsapShared";
|
||||
|
||||
// Fixtures carry only the fields the function under test reads; the double-cast
|
||||
// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
|
||||
const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
|
||||
|
||||
describe("resolveEditableTweenDuration", () => {
|
||||
const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
|
||||
|
||||
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
||||
expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
|
||||
});
|
||||
|
||||
it("keeps an explicitly-authored tween duration", () => {
|
||||
expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInstantHold", () => {
|
||||
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
||||
@@ -74,3 +99,86 @@ describe("parsePercentageKeyframes", () => {
|
||||
expect(parsePercentageKeyframes({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("idSelector", () => {
|
||||
it("uses #id for valid CSS identifiers", () => {
|
||||
expect(idSelector("hero-word")).toBe("#hero-word");
|
||||
expect(idSelector("el_1")).toBe("#el_1");
|
||||
});
|
||||
|
||||
it("uses an attribute selector for ids that #id can't address (digit-leading, dots, spaces)", () => {
|
||||
// #01-... / #a.b / #a b throw a SyntaxError in querySelector / GSAP, crashing
|
||||
// the preview when such a target is committed (e.g. dragging the element).
|
||||
expect(idSelector("01-hook-hero-word")).toBe('[id="01-hook-hero-word"]');
|
||||
expect(idSelector("my.class")).toBe('[id="my.class"]');
|
||||
expect(idSelector("1box")).toBe('[id="1box"]');
|
||||
});
|
||||
|
||||
it("escapes quotes and backslashes in the attribute selector value", () => {
|
||||
expect(idSelector('1"x')).toBe('[id="1\\"x"]');
|
||||
});
|
||||
|
||||
it("only ever emits #id for ids that can't break querySelector", () => {
|
||||
// Every id resolves to either a plain #id (only when safe) or an attribute
|
||||
// selector — never a #id that would throw a SyntaxError.
|
||||
for (const id of ["hero-word", "01-hook", "a.b", "a b", "1", "--x", '1"q']) {
|
||||
const sel = idSelector(id);
|
||||
if (sel.startsWith("#")) expect(sel).toBe(`#${id}`);
|
||||
else expect(sel.startsWith('[id="')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toClipPercentage", () => {
|
||||
// Selection keys embed this number, so every keyframe-cache writer has to round
|
||||
// it identically: a coarser writer rewrites the cache with a different value and
|
||||
// orphans the live selection key built from the finer one.
|
||||
it("keeps three decimals so a beat-snapped keyframe lands on its beat", () => {
|
||||
expect(toClipPercentage(1 / 3, 0, 1, 0)).toBe(33.333);
|
||||
expect(toClipPercentage(2.5, 2, 4, 0)).toBe(12.5);
|
||||
});
|
||||
|
||||
it("passes the tween percentage through for a zero-length clip", () => {
|
||||
expect(toClipPercentage(5, 0, 0, 42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toClipKeyframes", () => {
|
||||
// Fixture carries only the fields the function under test reads; the
|
||||
// double-cast is the documented way to stand in for the full runtime shape
|
||||
// (CONTRIBUTING.md).
|
||||
const durationless = {
|
||||
id: "a1",
|
||||
method: "to",
|
||||
targetSelector: "#box",
|
||||
vars: {},
|
||||
resolvedStart: 0,
|
||||
} as unknown as GsapAnimation;
|
||||
|
||||
// A tween with no duration spans its clip everywhere else in Studio
|
||||
// (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
|
||||
// basis put the end keyframe at 25% of a 4s clip instead of 100%.
|
||||
it("spans the clip when the tween has no duration", () => {
|
||||
const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
|
||||
expect(rows.map((row) => row.percentage)).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("keeps the tween percentage and the animation identity on every row", () => {
|
||||
const rows = toClipKeyframes([{ percentage: 50 }], durationless, 0, 4);
|
||||
expect(rows[0]).toMatchObject({ tweenPercentage: 50, animationId: "a1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("idFromSelector", () => {
|
||||
it("round-trips every shape idSelector emits", () => {
|
||||
for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) {
|
||||
expect(idFromSelector(idSelector(id))).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for a selector that does not address an id", () => {
|
||||
expect(idFromSelector(".dot")).toBeNull();
|
||||
expect(idFromSelector("[data-hf-id='x']")).toBeNull();
|
||||
expect(idFromSelector(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,14 +53,87 @@ export function isInstantHold(animation: GsapAnimation): boolean {
|
||||
* Returns `#id` if the selection has an id, otherwise the raw selector,
|
||||
* or null if neither exists.
|
||||
*/
|
||||
/**
|
||||
* A CSS-valid selector for an element id. `#id` for a valid CSS identifier,
|
||||
* otherwise an `[id="..."]` attribute selector. IDs that start with a digit
|
||||
* (e.g. "01-hook-hero-word") make `#id` an invalid selector, so
|
||||
* `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a
|
||||
* SyntaxError — which surfaces as a masked cross-origin "Script error." and
|
||||
* crashes the preview the moment such a target is committed (e.g. dragging).
|
||||
*/
|
||||
// Conservative: matches only ids that are unquestionably safe as a `#id`
|
||||
// selector — ASCII identifier, starts with a letter/underscore (or a single
|
||||
// leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects
|
||||
// (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through
|
||||
// to the attribute selector below, which is always valid. It can only ever err
|
||||
// toward the safe form, never toward a `#id` that throws — and, unlike
|
||||
// `CSS.escape`, it needs no browser global (this runs in node tests too).
|
||||
const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
|
||||
|
||||
/**
|
||||
* How close (in tween-%) a playhead has to be to count as sitting ON an existing
|
||||
* keyframe. Every "is there already a keyframe here?" test shares this: with two
|
||||
* different tolerances in play, one path decided "no keyframe here, append one"
|
||||
* while another decided "yes, edit that one", and a drag near a waypoint left two
|
||||
* keyframes a fraction of a percent apart.
|
||||
*/
|
||||
export const KEYFRAME_PCT_MATCH = 1;
|
||||
|
||||
export function idSelector(id: string): string {
|
||||
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
|
||||
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
|
||||
// GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked
|
||||
// cross-origin "Script error." that crashes the preview the moment such a
|
||||
// target is committed (e.g. dragging the element). Address those via an
|
||||
// attribute selector instead (quotes/backslashes escaped for the string).
|
||||
return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link idSelector}: the element id a target selector addresses, or
|
||||
* null for a selector that is not id-based (a class, a tag, a descendant path).
|
||||
*
|
||||
* Both shapes have to be read back, not just `#id`. Every writer emits through
|
||||
* `idSelector`, so a digit-leading, dotted or otherwise CSS-unsafe id lands in
|
||||
* the source as `[id="01-hook-hero"]`. A reader that only matched `#id` saw no
|
||||
* id at all for those elements and skipped them — which is how the post-commit
|
||||
* keyframe-cache refresh silently stopped running for exactly the ids
|
||||
* `idSelector` was added to support.
|
||||
*/
|
||||
export function idFromSelector(selector: string | undefined | null): string | null {
|
||||
if (!selector) return null;
|
||||
const hash = selector.match(/^#([\w-]+)/);
|
||||
if (hash) return hash[1] ?? null;
|
||||
const attribute = selector.match(/^\[id="((?:\\.|[^"\\])*)"\]/);
|
||||
if (!attribute) return null;
|
||||
// Undo the quote/backslash escaping idSelector applies.
|
||||
return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
|
||||
}
|
||||
|
||||
export function selectorFromSelection(selection: DomEditSelection): string | null {
|
||||
if (selection.id) return `#${selection.id}`;
|
||||
if (selection.id) return idSelector(selection.id);
|
||||
if (selection.selector) return selection.selector;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Percentage computation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
||||
* duration-less tween across its owning clip, so mutations must use that same
|
||||
* duration instead of silently falling back to GSAP's 0.5s default.
|
||||
*/
|
||||
export function resolveEditableTweenDuration(
|
||||
animation: GsapAnimation,
|
||||
selection: DomEditSelection,
|
||||
): number {
|
||||
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
||||
return resolveTweenDuration(
|
||||
animation,
|
||||
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current playback percentage within an element's animation range.
|
||||
* Uses the animation's resolved timing if available, otherwise falls back to
|
||||
@@ -73,7 +146,7 @@ export function computeElementPercentage(
|
||||
): number {
|
||||
if (animation) {
|
||||
const start = resolveTweenStart(animation);
|
||||
const duration = resolveTweenDuration(animation);
|
||||
const duration = resolveEditableTweenDuration(animation, selection);
|
||||
if (duration <= 0) return 0;
|
||||
if (start !== null) {
|
||||
return absoluteToPercentage(currentTime, start, duration);
|
||||
@@ -81,9 +154,7 @@ export function computeElementPercentage(
|
||||
}
|
||||
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
return absoluteToPercentage(currentTime, elStart, elDuration);
|
||||
}
|
||||
|
||||
// ── Iframe accessors ──────────────────────────────────────────────────────────
|
||||
@@ -118,6 +189,16 @@ export interface ParsedPercentageKeyframes {
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
function collectAnimatableKeyframeProperties(entry: object): Record<string, number | string> {
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [property, value] of Object.entries(entry)) {
|
||||
if (property === "ease") continue;
|
||||
if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000;
|
||||
else if (typeof value === "string") properties[property] = value;
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`)
|
||||
* into a sorted array of `{ percentage, properties }` entries.
|
||||
@@ -146,12 +227,7 @@ export function parsePercentageKeyframes(
|
||||
steps.forEach((entry, i) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
const properties = collectAnimatableKeyframeProperties(entry);
|
||||
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
|
||||
});
|
||||
return keyframes.length > 0 ? { keyframes } : null;
|
||||
@@ -165,12 +241,7 @@ export function parsePercentageKeyframes(
|
||||
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||
const percentage = parseFloat(pctMatch[1]);
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
const properties = collectAnimatableKeyframeProperties(val);
|
||||
if (Object.keys(properties).length > 0) {
|
||||
keyframes.push({ percentage, properties });
|
||||
}
|
||||
@@ -187,3 +258,57 @@ export function parsePercentageKeyframes(
|
||||
export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: number): number {
|
||||
return tweenPos + (percentage / 100) * tweenDur;
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute time as a percentage of a timeline clip, at the one precision every
|
||||
* keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered
|
||||
* on the beat dot, and because selection keys embed this number, a writer that
|
||||
* rounds coarser would orphan a live selection the moment it rewrites the cache.
|
||||
* A zero-length clip has no percentage to give, so the tween-% passes through.
|
||||
*/
|
||||
export function toClipPercentage(
|
||||
absoluteTime: number,
|
||||
clipStart: number,
|
||||
clipDuration: number,
|
||||
fallbackPercentage: number,
|
||||
): number {
|
||||
if (clipDuration <= 0) return fallbackPercentage;
|
||||
return Math.round(((absoluteTime - clipStart) / clipDuration) * 100000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* One keyframe-cache row per tween keyframe: the percentage re-based onto the
|
||||
* clip, the original tween percentage kept alongside it, and the animation
|
||||
* identity every lane and selection key needs. Shared by the cache writers so
|
||||
* they cannot drift in precision or in which identity fields they record.
|
||||
*/
|
||||
export function toClipKeyframes<T extends { percentage: number }>(
|
||||
source: readonly T[],
|
||||
anim: GsapAnimation,
|
||||
clipStart: number,
|
||||
clipDuration: number,
|
||||
): Array<
|
||||
T & {
|
||||
tweenPercentage: number;
|
||||
propertyGroup: GsapAnimation["propertyGroup"];
|
||||
animationId: string;
|
||||
}
|
||||
> {
|
||||
const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
||||
// A duration-less tween spans the clip, the same rule the edit paths use
|
||||
// (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
|
||||
// percentage no editor agreed with.
|
||||
const tweenDuration = anim.duration ?? clipDuration;
|
||||
return source.map((keyframe) => ({
|
||||
...keyframe,
|
||||
percentage: toClipPercentage(
|
||||
toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
|
||||
clipStart,
|
||||
clipDuration,
|
||||
keyframe.percentage,
|
||||
),
|
||||
tweenPercentage: keyframe.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
animationId: anim.id,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
||||
return {
|
||||
@@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
||||
expect(out).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicateKeyframes ease ambiguity", () => {
|
||||
it("flags a same-% collision from different animations (different eases)", () => {
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
|
||||
]);
|
||||
const kf = merged.find((k) => k.percentage === 45);
|
||||
expect(kf?.easeAmbiguous).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a cross-animation collision even when the raw eases match", () => {
|
||||
// The button can still only target one arbitrary animation, and each may
|
||||
// inherit a different easeEach/animation ease that raw comparison misses.
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
|
||||
]);
|
||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a same-% collision within a single animation", () => {
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
|
||||
]);
|
||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,51 @@ import type {
|
||||
} from "@hyperframes/core/gsap-parser";
|
||||
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
||||
|
||||
export function deduplicateKeyframes(
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
): GsapPercentageKeyframe[] {
|
||||
const byPct = new Map<number, GsapPercentageKeyframe>();
|
||||
/**
|
||||
* A static position hold (only x/y, no real motion) is a `set`, not a keyframe —
|
||||
* it must not synthesize a diamond. Covers both `tl.set(...)` and the
|
||||
* `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes
|
||||
* collapses to (otherwise shown as a stray 0% keyframe).
|
||||
*
|
||||
* Single owner: the collapsed keyframe cache and the expanded property lanes'
|
||||
* `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane
|
||||
* with no matching collapsed diamond.
|
||||
*/
|
||||
export function isStaticPositionHold(anim: GsapAnimation): boolean {
|
||||
if (anim.keyframes) return false;
|
||||
if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false;
|
||||
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
||||
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
|
||||
}
|
||||
|
||||
export function deduplicateKeyframes<
|
||||
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
|
||||
>(keyframes: T[]): T[] {
|
||||
const byPct = new Map<number, T>();
|
||||
for (const kf of keyframes) {
|
||||
const existing = byPct.get(kf.percentage);
|
||||
if (existing) {
|
||||
existing.properties = { ...existing.properties, ...kf.properties };
|
||||
if (kf.ease) existing.ease = kf.ease;
|
||||
// Two DIFFERENT source animations with a keyframe at the same clip %: a
|
||||
// single inline ease button can only target one of them, and which one is
|
||||
// arbitrary (each may also inherit a different easeEach/animation ease, so
|
||||
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
|
||||
// hides the button there and the user edits per-lane instead.
|
||||
if (
|
||||
existing.animationId !== undefined &&
|
||||
kf.animationId !== undefined &&
|
||||
existing.animationId !== kf.animationId
|
||||
) {
|
||||
existing.easeAmbiguous = true;
|
||||
}
|
||||
// Whichever tween iterated last used to win `ease`, so the merged
|
||||
// keyframe carried an arbitrary one of the colliding curves. Readers that
|
||||
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
|
||||
// curve belonging to a different animation than the one an edit targets.
|
||||
// Drop it instead: ambiguous means "no single ease", and the flag is the
|
||||
// only honest answer.
|
||||
if (existing.easeAmbiguous) delete existing.ease;
|
||||
else if (kf.ease) existing.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
@@ -41,29 +77,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes
|
||||
const fromProps = anim.fromProperties;
|
||||
if (!toProps || Object.keys(toProps).length === 0) return null;
|
||||
|
||||
const startProps: Record<string, number | string> = {};
|
||||
const endProps: Record<string, number | string> = {};
|
||||
const rawStart: Record<string, number | string> = {};
|
||||
const rawEnd: Record<string, number | string> = {};
|
||||
|
||||
if (anim.method === "from") {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = v;
|
||||
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
rawStart[k] = v;
|
||||
rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
}
|
||||
} else if (anim.method === "fromTo" && fromProps) {
|
||||
Object.assign(startProps, fromProps);
|
||||
Object.assign(endProps, toProps);
|
||||
Object.assign(rawStart, fromProps);
|
||||
Object.assign(rawEnd, toProps);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
endProps[k] = v;
|
||||
rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
rawEnd[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Only numeric props are keyframe-interpolatable — a flat tween of a
|
||||
// non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
|
||||
const numericKeys = Object.keys(rawEnd).filter(
|
||||
(k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
|
||||
);
|
||||
if (numericKeys.length === 0) return null;
|
||||
const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
|
||||
const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
|
||||
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: startProps },
|
||||
{ percentage: 100, properties: endProps },
|
||||
// Segment ease lives on the destination keyframe (Figma/AE model) so the
|
||||
// lane + cache surface it; also kept data-level for useGsapTweenCache.
|
||||
{ percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
|
||||
],
|
||||
...(anim.ease ? { ease: anim.ease } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Reading a composition file's GSAP tweens into the keyframe cache: fetch,
|
||||
* selector -> element id resolution, and the clip-relative timing basis.
|
||||
* Split from useGsapTweenCache to keep that file under the 600-line limit.
|
||||
*/
|
||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import {
|
||||
clearKeyframeCacheForFile,
|
||||
elementCacheKeys,
|
||||
writeGsapAnimationsForElement,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
||||
import {
|
||||
deduplicateKeyframes,
|
||||
isStaticPositionHold,
|
||||
synthesizeFlatTweenKeyframes,
|
||||
} from "./gsapTweenSynth";
|
||||
|
||||
/**
|
||||
* Resolve a tween's target selector to the ids of the element(s) it animates.
|
||||
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
|
||||
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
|
||||
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
|
||||
* element they animate — not just one parsed from the string. Falls back to a
|
||||
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function resolveSelectorElementIds(
|
||||
selector: string,
|
||||
doc: Document | null | undefined,
|
||||
): string[] {
|
||||
// A whole-selector id match (either shape) addresses exactly one element.
|
||||
const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
|
||||
? idFromSelector(selector)
|
||||
: null;
|
||||
if (bareId) return [bareId];
|
||||
if (!doc) {
|
||||
const lead = idFromSelector(selector);
|
||||
return lead ? [lead] : [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const part of selector.split(",")) {
|
||||
const sel = part.trim();
|
||||
if (!sel) continue;
|
||||
try {
|
||||
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
||||
if (el.id) ids.add(el.id);
|
||||
}
|
||||
} catch {
|
||||
const lead = idFromSelector(sel);
|
||||
if (lead) ids.add(lead);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
/**
|
||||
* The slice of the parse response callers actually read. The endpoint returns
|
||||
* the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
|
||||
* this fetch touches the source-text fields, so the guard below only has to
|
||||
* vouch for what gets used.
|
||||
*/
|
||||
type ParsedGsapAnimations = Pick<
|
||||
ParsedGsap,
|
||||
"animations" | "multipleTimelines" | "unsupportedTimelinePattern"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A proxy, an error page, or a stale server can answer 200 with something that
|
||||
* has no `animations` array — the case where the old blind cast crashed on
|
||||
* `.animations.filter`.
|
||||
*/
|
||||
function hasAnimations(value: unknown): value is ParsedGsapAnimations {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"animations" in value &&
|
||||
Array.isArray(value.animations)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchParsedAnimations(
|
||||
projectId: string,
|
||||
sourceFile: string,
|
||||
): Promise<ParsedGsapAnimations | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
||||
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
||||
// would defeat caching forever and is a deterministic-render no-no).
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const parsed: unknown = await res.json();
|
||||
if (!hasAnimations(parsed)) return null;
|
||||
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
|
||||
// hold an element's first keyframe before its tween). They must not surface as
|
||||
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
|
||||
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
||||
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
||||
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
||||
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
||||
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
||||
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
||||
*/
|
||||
export function resolveClipTimingBasis(
|
||||
elementId: string,
|
||||
sourceFile: string,
|
||||
elements: ReadonlyArray<{
|
||||
domId?: string;
|
||||
key?: string;
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}>,
|
||||
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
||||
): { elStart: number; elDuration: number } {
|
||||
const direct = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
||||
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
||||
const host = hostId
|
||||
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
||||
: undefined;
|
||||
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one composition file's tweens into the keyframe cache. Split out of the
|
||||
* hook so the effect can run it per file without re-nesting the whole body.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function populateKeyframeCacheFromAst(
|
||||
projectId: string,
|
||||
sf: string,
|
||||
doc: Document | null | undefined,
|
||||
): Promise<void> {
|
||||
const parsed = await fetchParsedAnimations(projectId, sf);
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
clearKeyframeCacheForFile(sf);
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
const sourceByElement = new Map<string, GsapAnimation[]>();
|
||||
for (const anim of parsed.animations) {
|
||||
if (anim.hasUnresolvedKeyframes) continue;
|
||||
if (isStaticPositionHold(anim)) continue;
|
||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kfData) continue;
|
||||
// Attribute the tween to every element it animates (handles class /
|
||||
// group / descendant selectors, not just `#id`).
|
||||
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
||||
// kfData is already resolved (real keyframes OR a synthesized flat
|
||||
// tween), so a flat tween joins the store like a keyframed one. No
|
||||
// property-group filter: this map must cover every tween the cache
|
||||
// below records, or expanded lanes have nothing to render.
|
||||
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
||||
const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
|
||||
const existing = mergedByElement.get(id);
|
||||
if (existing) {
|
||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
||||
} else {
|
||||
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, kfData] of mergedByElement) {
|
||||
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
|
||||
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
@@ -342,4 +343,83 @@ describe("deleteSelectedKeyframes", () => {
|
||||
expect(options[1]).not.toHaveProperty("softReload");
|
||||
expect(options[2]).not.toHaveProperty("skipReload");
|
||||
});
|
||||
|
||||
it("deletes two expanded lanes through their own animation and tween percentages", () => {
|
||||
usePlayerStore.setState({
|
||||
selectedElementId: "card",
|
||||
selectedKeyframes: new Set([
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 30,
|
||||
tweenPercentage: 20,
|
||||
propertyGroup: "position",
|
||||
animationId: "card-position",
|
||||
}),
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 70,
|
||||
tweenPercentage: 80,
|
||||
propertyGroup: "visual",
|
||||
animationId: "card-visual",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const handleGsapRemoveKeyframe =
|
||||
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
||||
|
||||
deleteSelectedKeyframes({
|
||||
selectedGsapAnimations: [
|
||||
{ id: "card-position", keyframes: {} },
|
||||
{ id: "card-visual", keyframes: {} },
|
||||
],
|
||||
handleGsapRemoveKeyframe,
|
||||
});
|
||||
|
||||
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
|
||||
animationId,
|
||||
percentage,
|
||||
]),
|
||||
).toEqual([
|
||||
["card-position", 20],
|
||||
["card-visual", 80],
|
||||
]);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ skipReload: true }),
|
||||
);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ softReload: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops keyframes that belong to other elements", () => {
|
||||
// A stale selection from a previously active element must not delete
|
||||
// anything on the element that is active now.
|
||||
usePlayerStore.setState({
|
||||
selectedElementId: "card",
|
||||
selectedKeyframes: new Set([
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 30,
|
||||
tweenPercentage: 20,
|
||||
propertyGroup: "position",
|
||||
animationId: "card-position",
|
||||
}),
|
||||
timelineKeyframeSelectionKey("other", {
|
||||
percentage: 70,
|
||||
tweenPercentage: 80,
|
||||
propertyGroup: "position",
|
||||
animationId: "card-position",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const handleGsapRemoveKeyframe =
|
||||
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
||||
|
||||
deleteSelectedKeyframes({
|
||||
selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
|
||||
handleGsapRemoveKeyframe,
|
||||
});
|
||||
|
||||
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
TimelineGroupMoveChange,
|
||||
} from "./useTimelineGroupEditing";
|
||||
|
||||
interface MoveEdit {
|
||||
export interface TimelineMoveEdit {
|
||||
element: TimelineElement;
|
||||
updates: Pick<TimelineElement, "start" | "track">;
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
export function persistTimelineMoveEditsAtomically(
|
||||
edits: MoveEdit[],
|
||||
edits: TimelineMoveEdit[],
|
||||
coalesceKey: string | undefined,
|
||||
operation: TimelineMoveOperation,
|
||||
deps: AtomicMoveDeps,
|
||||
|
||||
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
||||
import { useStudioTestHooks } from "./useStudioTestHooks";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -506,6 +507,9 @@ export function useDomSelection({
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
}, [applyDomSelection, captionEditMode]);
|
||||
|
||||
// Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod.
|
||||
useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection });
|
||||
|
||||
const applyMarqueeSelection = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(selections: DomEditSelection[], additive: boolean) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
applyArcKeyframeAtPlayhead,
|
||||
animatedProps,
|
||||
buildExtendedKeyframes,
|
||||
isPlayheadWithinTween,
|
||||
@@ -107,6 +108,19 @@ describe("isPlayheadWithinTween", () => {
|
||||
it("does not block when the start can't be resolved", () => {
|
||||
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
|
||||
});
|
||||
|
||||
// The toolbar's "extends animation" tooltip has to agree with what the edit
|
||||
// paths do. Those span a duration-less tween across its clip, so answering
|
||||
// from GSAP's 0.5s default reported the playhead outside a window the click
|
||||
// then treated as clip-wide.
|
||||
it("spans the clip for a duration-less tween when given the selection", () => {
|
||||
const durationless = anim({ position: 0 });
|
||||
const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection;
|
||||
|
||||
expect(isPlayheadWithinTween(durationless, 5)).toBe(false);
|
||||
expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true);
|
||||
expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExtendedKeyframes", () => {
|
||||
@@ -219,6 +233,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyArcKeyframeAtPlayhead", () => {
|
||||
const arcAnim = anim({
|
||||
id: "#el-to-0-position",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: {
|
||||
format: "object-array",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
function arcFixture(x: number, y: number) {
|
||||
const commitMutation = vi.fn(async () => undefined);
|
||||
const session = { commitMutation } as unknown as EnableKeyframesSession;
|
||||
const sel = {
|
||||
id: "el",
|
||||
selector: "#el",
|
||||
element: { isConnected: true } as HTMLElement,
|
||||
dataAttributes: { duration: "10" },
|
||||
} as DomEditSelection;
|
||||
const iframe = {
|
||||
contentWindow: {
|
||||
gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) },
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
return { commitMutation, iframe, sel, session };
|
||||
}
|
||||
|
||||
it("removes an existing interior stop without redistributing the remaining times", async () => {
|
||||
const fixture = arcFixture(50, 50);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the path endpoints", async () => {
|
||||
const fixture = arcFixture(0, 0);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe);
|
||||
expect(fixture.commitMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 25, properties: { x: 25, y: 25 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the owning clip duration when an arc omits its outer duration", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
const durationlessArc = { ...arcAnim, duration: undefined };
|
||||
|
||||
await applyArcKeyframeAtPlayhead(
|
||||
fixture.session,
|
||||
fixture.sel,
|
||||
durationlessArc,
|
||||
2.5,
|
||||
fixture.iframe,
|
||||
);
|
||||
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "replace-with-keyframes",
|
||||
duration: 10,
|
||||
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
||||
}),
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
|
||||
let enable: (() => Promise<void>) | null = null;
|
||||
function Probe() {
|
||||
|
||||
@@ -12,16 +12,23 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
||||
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
|
||||
import {
|
||||
selectorFromSelection,
|
||||
computeElementPercentage,
|
||||
KEYFRAME_PCT_MATCH,
|
||||
isInstantHold,
|
||||
resolveEditableTweenDuration,
|
||||
} from "./gsapShared";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
resolveTweenStart,
|
||||
resolveTweenDuration,
|
||||
isTimeWithinTween,
|
||||
} from "../utils/globalTimeCompiler";
|
||||
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
|
||||
|
||||
let enableKeyframesTransactionCounter = 0;
|
||||
|
||||
@@ -71,11 +78,22 @@ export function animatedProps(anim: GsapAnimation | null): string[] {
|
||||
* Whether the playhead sits inside an animation's tween range. When the tween's
|
||||
* start can't be resolved we don't block (the percentage falls back to clip range,
|
||||
* preserving prior behavior for elements without explicit timing).
|
||||
*
|
||||
* Pass the selection whenever the caller has one: a duration-less tween spans
|
||||
* its clip, and answering from GSAP's 0.5s default reports the playhead outside
|
||||
* a window the edit paths treat as clip-wide.
|
||||
*/
|
||||
export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
|
||||
export function isPlayheadWithinTween(
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
selection?: DomEditSelection | null,
|
||||
): boolean {
|
||||
const start = resolveTweenStart(anim);
|
||||
if (start === null) return true;
|
||||
return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
|
||||
const duration = selection
|
||||
? resolveEditableTweenDuration(anim, selection)
|
||||
: resolveTweenDuration(anim);
|
||||
return isTimeWithinTween(currentTime, start, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +107,10 @@ export function buildExtendedKeyframes(
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
position: Record<string, number>,
|
||||
sourceDuration = resolveTweenDuration(anim),
|
||||
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
||||
const oldStart = resolveTweenStart(anim) ?? 0;
|
||||
const oldDuration = resolveTweenDuration(anim);
|
||||
const oldDuration = sourceDuration;
|
||||
const newStart = Math.min(oldStart, currentTime);
|
||||
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
||||
const newDuration = roundTo3(newEnd - newStart);
|
||||
@@ -222,6 +241,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
|
||||
return (await tryFetchAnimationsForElement(sel)) ?? [];
|
||||
}
|
||||
|
||||
async function extendKeyframedTweenToPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
const selector = selectorFromSelection(sel);
|
||||
const position = readElementPosition(iframe, sel, anim);
|
||||
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
||||
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: anim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
|
||||
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
|
||||
@@ -237,32 +287,25 @@ async function applyKeyframeAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
if (!isPlayheadWithinTween(kfAnim, t)) {
|
||||
const position = readElementPosition(iframe, sel, kfAnim);
|
||||
const selector = selectorFromSelection(sel);
|
||||
if (selector && Object.keys(position).length > 0 && session.commitMutation) {
|
||||
const extended = buildExtendedKeyframes(kfAnim, t, position);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: kfAnim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: kfAnim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
const duration = resolveEditableTweenDuration(kfAnim, sel);
|
||||
const start = resolveTweenStart(kfAnim);
|
||||
if (start !== null && !isTimeWithinTween(t, start, duration)) {
|
||||
await extendKeyframedTweenToPlayhead(
|
||||
session,
|
||||
sel,
|
||||
kfAnim,
|
||||
t,
|
||||
duration,
|
||||
iframe,
|
||||
commitOverrides,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pct = computeElementPercentage(t, sel, kfAnim);
|
||||
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
const pct =
|
||||
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
|
||||
const existing = kfAnim.keyframes?.keyframes.find(
|
||||
(k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH,
|
||||
);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
return;
|
||||
@@ -332,14 +375,13 @@ export async function promoteSetToKeyframes(
|
||||
}
|
||||
|
||||
/**
|
||||
* An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
|
||||
* it must be edited as waypoints (not x/y keyframes, which would break the curve).
|
||||
* "Add keyframe at playhead" drops a waypoint where the element currently sits on
|
||||
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
||||
* range, extend the duration so the motion reaches the playhead.
|
||||
* Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the
|
||||
* playhead stop. A toolbar command named "Add keyframe at playhead" must preserve
|
||||
* every authored stop's time; inserting a spatial waypoint instead redistributes
|
||||
* the path and can silently compress the animation.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function applyArcWaypointAtPlayhead(
|
||||
export async function applyArcKeyframeAtPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
arcAnim: GsapAnimation,
|
||||
@@ -347,8 +389,11 @@ async function applyArcWaypointAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
): Promise<void> {
|
||||
if (!session.commitMutation) return;
|
||||
if (!isPlayheadWithinTween(arcAnim, t)) {
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const targetSelector = selectorFromSelection(sel);
|
||||
if (!targetSelector) return;
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
||||
if (!isTimeWithinTween(t, start, duration)) {
|
||||
if (t > start) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
@@ -361,30 +406,45 @@ async function applyArcWaypointAtPlayhead(
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nodes = arcAnim.keyframes?.keyframes ?? [];
|
||||
const playheadPercentage = absoluteToPercentage(t, start, duration);
|
||||
const timedNodeIndex = nodes.findIndex(
|
||||
(node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH,
|
||||
);
|
||||
if (timedNodeIndex !== -1) {
|
||||
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: nodes.filter((_, index) => index !== timedNodeIndex),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const live = readElementPosition(iframe, sel, arcAnim);
|
||||
if (typeof live.x !== "number" || typeof live.y !== "number") return;
|
||||
const liveX = live.x;
|
||||
const liveY = live.y;
|
||||
const nodes = (arcAnim.keyframes?.keyframes ?? [])
|
||||
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
|
||||
.filter(
|
||||
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
|
||||
);
|
||||
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
|
||||
// path endpoints).
|
||||
const WAYPOINT_MERGE_PX = 6;
|
||||
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
|
||||
const proj = nearestPointOnPath(liveX, liveY, nodes);
|
||||
if (!proj) return;
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "add-motion-path-point",
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
index: proj.segIndex + 1,
|
||||
x: liveX,
|
||||
y: liveY,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, {
|
||||
x: live.x,
|
||||
y: live.y,
|
||||
}),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add waypoint", softReload: true },
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -420,7 +480,7 @@ export function useEnableKeyframes(
|
||||
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
|
||||
|
||||
if (arcAnim) {
|
||||
await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
} else if (kfAnim) {
|
||||
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
|
||||
} else if (setAnim) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
||||
import { isInstantHold } from "./gsapShared";
|
||||
import { isInstantHold, idSelector } from "./gsapShared";
|
||||
|
||||
type RecordedKeyframe = {
|
||||
percentage: number;
|
||||
@@ -168,7 +168,7 @@ export function useGestureCommit({
|
||||
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
||||
}
|
||||
|
||||
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
||||
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
||||
if (!selector) {
|
||||
showToast("Cannot save — element has no selector", "error");
|
||||
return;
|
||||
|
||||
@@ -36,7 +36,7 @@ export type ElementAnimationsOutcome =
|
||||
* so the caller can apply the right retry budget to each.
|
||||
*/
|
||||
export function selectElementAnimationsOrRetry(
|
||||
parsed: ParsedGsap | null,
|
||||
parsed: Pick<ParsedGsap, "animations"> | null,
|
||||
target: { id: string | null; selector: string | null },
|
||||
): ElementAnimationsOutcome {
|
||||
if (!parsed) return { kind: "fetch-error" };
|
||||
|
||||
@@ -19,11 +19,21 @@ afterEach(() => {
|
||||
|
||||
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
||||
|
||||
function successfulCommitMutation() {
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
||||
const options = args[2] as {
|
||||
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
||||
};
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyframeOps(over: {
|
||||
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
||||
trackGsapSaveFailure: (...args: unknown[]) => void;
|
||||
}) {
|
||||
const captured: { api: HookApi | null } = { api: null };
|
||||
// This hook harness intentionally mirrors the separate script-commit harness.
|
||||
function Probe() {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
captured.api = useGsapKeyframeOps({
|
||||
@@ -49,11 +59,21 @@ function renderKeyframeOps(over: {
|
||||
return captured.api;
|
||||
}
|
||||
|
||||
async function moveKeyframeWith(
|
||||
commitMutation: (...args: unknown[]) => Promise<unknown>,
|
||||
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
|
||||
const trackGsapSaveFailure = vi.fn();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
|
||||
});
|
||||
return { committed, trackGsapSaveFailure };
|
||||
}
|
||||
|
||||
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
|
||||
@@ -61,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 100, to: 100 },
|
||||
];
|
||||
let committed = false;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
});
|
||||
|
||||
expect(commitMutation).toHaveBeenCalledTimes(1);
|
||||
@@ -76,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
pctRemap,
|
||||
});
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
expect(committed).toBe(true);
|
||||
});
|
||||
|
||||
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
||||
@@ -86,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
|
||||
// let the rejected commit promise settle inside act
|
||||
await Promise.resolve();
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
||||
@@ -98,14 +121,72 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
expect(selArg).toBe(selection);
|
||||
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
||||
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
||||
expect(committed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
||||
it("returns false when the commit settles without a durable writer result", async () => {
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
||||
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
||||
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
||||
options.onResult?.({ ok: true, changed: false });
|
||||
});
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and tracks a rejected move", async () => {
|
||||
const error = new Error("write failed");
|
||||
const commitMutation = vi.fn().mockRejectedValue(error);
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
|
||||
error,
|
||||
selection,
|
||||
{
|
||||
type: "move-keyframe",
|
||||
animationId: "box-to-0-position",
|
||||
fromPercentage: 50,
|
||||
toPercentage: 75,
|
||||
},
|
||||
"Move keyframe to 75%",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
it("routes a flat-lane add through the add-keyframe writer mutation", async () => {
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
|
||||
await act(async () => {
|
||||
await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 });
|
||||
});
|
||||
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: "box-to-0-position",
|
||||
percentage: 50,
|
||||
properties: { x: 210 },
|
||||
},
|
||||
{ label: "Add keyframe at 50%", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("soft-reloads a standalone convert when the SDK path is unavailable", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
|
||||
await act(async () => {
|
||||
@@ -123,9 +204,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
});
|
||||
|
||||
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
import { idFromSelector } from "./gsapShared";
|
||||
import {
|
||||
clearKeyframeCacheForElement,
|
||||
readKeyframeSnapshot,
|
||||
@@ -236,7 +237,7 @@ export function useGsapKeyframeOps({
|
||||
);
|
||||
|
||||
const moveKeyframe = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
fromPercentage: number,
|
||||
@@ -247,18 +248,26 @@ export function useGsapKeyframeOps({
|
||||
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
|
||||
// optimistic cache write is needed (mapping the tween-% to clip-% here would
|
||||
// duplicate that math). softReload mirrors remove-keyframe.
|
||||
void commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
|
||||
const resizeKeyframedTween = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
position: number,
|
||||
@@ -275,12 +284,20 @@ export function useGsapKeyframeOps({
|
||||
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
|
||||
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
|
||||
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
|
||||
void commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
@@ -322,7 +339,7 @@ export function useGsapKeyframeOps({
|
||||
// remove-all-keyframes collapses the tween to a static hold and the commit
|
||||
// path doesn't return parsed animations, so the keyframe cache is never
|
||||
// refreshed — clear it here so the timeline diamonds disappear immediately.
|
||||
const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null;
|
||||
const elementId = selection.id ?? idFromSelector(selection.selector);
|
||||
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
|
||||
if (sdkSession && sdkDeps) {
|
||||
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
||||
|
||||
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
||||
});
|
||||
|
||||
it("publishes the server mutation outcome to callers", async () => {
|
||||
mockFetchResult({ changed: false });
|
||||
const deps = renderCommitHook();
|
||||
let commitResult: MutationResult | undefined;
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
|
||||
{
|
||||
label: "Move keyframe",
|
||||
onResult: (result) => {
|
||||
commitResult = result;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
|
||||
});
|
||||
|
||||
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
mockFetchResult({ changed: false });
|
||||
|
||||
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScript(pid, targetPath, mutation),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScriptBatch(pid, targetPath, mutations),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -37,8 +39,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
|
||||
addKeyframe: vi.fn(),
|
||||
addKeyframeBatch: resolved(),
|
||||
removeKeyframe: vi.fn(),
|
||||
moveKeyframe: vi.fn(),
|
||||
resizeKeyframedTween: vi.fn(),
|
||||
moveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
convertToKeyframes: resolved(),
|
||||
removeAllKeyframes: resolved(),
|
||||
handleDomManualEditsReset: vi.fn(),
|
||||
@@ -79,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => {
|
||||
makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }),
|
||||
);
|
||||
|
||||
act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }));
|
||||
// Braces, not a bare arrow: the handler returns its settlement promise now,
|
||||
// and returning a thenable from act() turns it into an un-awaited async act.
|
||||
act(() => {
|
||||
void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 });
|
||||
});
|
||||
await flushRejection();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error");
|
||||
@@ -114,3 +120,63 @@ describe("useGsapSelectionHandlers save failures", () => {
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapSelectionHandlers selection override", () => {
|
||||
it("aborts on an explicit null override instead of writing to the current selection", () => {
|
||||
const removeKeyframe = vi.fn();
|
||||
const rendered = renderHandlers(makeParams({ removeKeyframe }));
|
||||
|
||||
// Explicit null: the caller resolved a selection for its own element and
|
||||
// found none, so the write must not land on the selected element.
|
||||
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null);
|
||||
expect(removeKeyframe).not.toHaveBeenCalled();
|
||||
|
||||
// Omitted override: falls back to the current selection as before.
|
||||
rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50);
|
||||
expect(removeKeyframe).toHaveBeenCalledOnce();
|
||||
rendered.unmount();
|
||||
});
|
||||
|
||||
it("computes the playhead percentage from the passed animation, not the selection's", () => {
|
||||
const moveKeyframe = vi.fn();
|
||||
const selection = makeSelection();
|
||||
// The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without
|
||||
// the animation the handler falls back to the selection's own element window
|
||||
// (0s→1s here), which reads the same playhead as 100%. Asserting the exact
|
||||
// 25 is what separates the two; `expect.any(Number)` even accepts the NaN a
|
||||
// missing window would produce.
|
||||
const animation = {
|
||||
id: "anim-1",
|
||||
position: 2,
|
||||
resolvedStart: 2,
|
||||
duration: 4,
|
||||
keyframes: { keyframes: [] },
|
||||
} as unknown as GsapAnimation;
|
||||
usePlayerStore.setState({ currentTime: 3 });
|
||||
const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] }));
|
||||
|
||||
rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation);
|
||||
|
||||
expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25);
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapSelectionHandlers retime settlement", () => {
|
||||
it("returns false without a selection and forwards the mutation result with one", async () => {
|
||||
const moveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
|
||||
await expect(
|
||||
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
|
||||
).resolves.toBe(false);
|
||||
expect(moveKeyframe).not.toHaveBeenCalled();
|
||||
withoutSelection.unmount();
|
||||
|
||||
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
|
||||
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(moveKeyframe).toHaveBeenCalledOnce();
|
||||
withSelection.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
@@ -118,6 +118,19 @@ export function useGsapSelectionHandlers({
|
||||
const lastSelectionRef = useRef<DomEditSelection | null>(null);
|
||||
if (domEditSelection) lastSelectionRef.current = domEditSelection;
|
||||
|
||||
// `undefined` means the caller passed no override and accepts the current
|
||||
// selection. An explicit `null` means the caller RESOLVED a selection for the
|
||||
// element it is editing and there is none: falling back to domEditSelection
|
||||
// there commits the edit onto whichever element happens to be selected, which
|
||||
// is a different element's file. Only `undefined` may fall back.
|
||||
const resolveWriteSelection = useCallback(
|
||||
(selectionOverride?: DomEditSelection | null): DomEditSelection | null =>
|
||||
selectionOverride === undefined
|
||||
? (domEditSelection ?? lastSelectionRef.current)
|
||||
: selectionOverride,
|
||||
[domEditSelection],
|
||||
);
|
||||
|
||||
const trackGsapHandlerFailure = useCallback(
|
||||
(error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
|
||||
trackStudioSaveFailure({
|
||||
@@ -137,12 +150,24 @@ export function useGsapSelectionHandlers({
|
||||
[showToast],
|
||||
);
|
||||
|
||||
// Resolves to whether the mutation landed. Callers that only fire-and-forget
|
||||
// can ignore it (the rejection is always handled here), but a caller that
|
||||
// reports a commit result to the UI has to await the real settlement instead
|
||||
// of assuming success the moment it dispatched.
|
||||
const observeGsapMutation = useCallback(
|
||||
(mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
|
||||
void mutation.catch((error) => {
|
||||
trackGsapHandlerFailure(error, selection, mutationType, label);
|
||||
});
|
||||
},
|
||||
(
|
||||
mutation: Promise<void>,
|
||||
selection: DomEditSelection,
|
||||
mutationType: string,
|
||||
label: string,
|
||||
): Promise<boolean> =>
|
||||
mutation.then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
trackGsapHandlerFailure(error, selection, mutationType, label);
|
||||
return false;
|
||||
},
|
||||
),
|
||||
[trackGsapHandlerFailure],
|
||||
);
|
||||
|
||||
@@ -160,25 +185,25 @@ export function useGsapSelectionHandlers({
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
observeGsapMutation(
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return Promise.resolve(false);
|
||||
return observeGsapMutation(
|
||||
updateGsapMeta(sel, animId, updates),
|
||||
sel,
|
||||
"update-meta",
|
||||
"Edit GSAP animation",
|
||||
);
|
||||
},
|
||||
[domEditSelection, observeGsapMutation, updateGsapMeta],
|
||||
[resolveWriteSelection, observeGsapMutation, updateGsapMeta],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAnimation = useCallback(
|
||||
(animId: string) => {
|
||||
const sel = domEditSelection ?? lastSelectionRef.current;
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
|
||||
},
|
||||
[domEditSelection, deleteGsapAnimation, observeGsapMutation],
|
||||
[resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
|
||||
);
|
||||
|
||||
const handleGsapDeleteAllForElement = useCallback(
|
||||
@@ -284,12 +309,12 @@ export function useGsapSelectionHandlers({
|
||||
value: number | string,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
trackStudioEvent("keyframe", { action: "add", property });
|
||||
addKeyframe(sel, animId, percentage, property, value);
|
||||
},
|
||||
[domEditSelection, addKeyframe],
|
||||
[resolveWriteSelection, addKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapAddKeyframeBatch = useCallback(
|
||||
@@ -298,19 +323,17 @@ export function useGsapSelectionHandlers({
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
if (!domEditSelection) return Promise.resolve();
|
||||
return addKeyframeBatch(
|
||||
domEditSelection,
|
||||
animId,
|
||||
percentage,
|
||||
properties,
|
||||
commitOverrides,
|
||||
).catch((error) => {
|
||||
trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
|
||||
});
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return Promise.resolve();
|
||||
return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch(
|
||||
(error) => {
|
||||
trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe");
|
||||
},
|
||||
);
|
||||
},
|
||||
[domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
|
||||
[resolveWriteSelection, addKeyframeBatch, trackGsapHandlerFailure],
|
||||
);
|
||||
const handleGsapRemoveKeyframe = useCallback(
|
||||
(
|
||||
@@ -319,26 +342,34 @@ export function useGsapSelectionHandlers({
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
trackStudioEvent("keyframe", { action: "remove" });
|
||||
removeKeyframe(sel, animId, percentage, commitOverrides);
|
||||
},
|
||||
[domEditSelection, removeKeyframe],
|
||||
[resolveWriteSelection, removeKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapMoveKeyframeToPlayhead = useCallback(
|
||||
(animId: string, fromPercentage: number, selectionOverride?: DomEditSelection | null) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
(
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
animationOverride?: GsapAnimation,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
// Retime the keyframe to the playhead, preserving its value + ease. The
|
||||
// playhead's tween-relative percentage is the move target.
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === animId);
|
||||
// playhead's tween-relative percentage is the move target, and it has to
|
||||
// come from the SAME element the write lands on: reading the animation off
|
||||
// the current selection while the percentage came from the clicked element
|
||||
// computes the target against one tween and writes it into another.
|
||||
const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
|
||||
const toPercentage = computeCurrentPercentage(sel, anim);
|
||||
trackStudioEvent("keyframe", { action: "move_to_playhead" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
void moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[domEditSelection, selectedGsapAnimations, moveKeyframe],
|
||||
[resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapMoveKeyframe = useCallback(
|
||||
@@ -348,16 +379,16 @@ export function useGsapSelectionHandlers({
|
||||
toPercentage: number,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
|
||||
// percentages are tween-relative (the drag handler converts the drop
|
||||
// position before calling). No optimistic runtime hold — the soft-reload
|
||||
// re-keys the diamond from source.
|
||||
trackStudioEvent("keyframe", { action: "retime" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
return moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[domEditSelection, moveKeyframe],
|
||||
[resolveWriteSelection, moveKeyframe],
|
||||
);
|
||||
|
||||
const handleGsapResizeKeyframedTween = useCallback(
|
||||
@@ -368,14 +399,14 @@ export function useGsapSelectionHandlers({
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
|
||||
// in place. Distinct telemetry action so resize is separable from in-window move.
|
||||
trackStudioEvent("keyframe", { action: "retime_resize" });
|
||||
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
},
|
||||
[domEditSelection, resizeKeyframedTween],
|
||||
[resolveWriteSelection, resizeKeyframedTween],
|
||||
);
|
||||
|
||||
const handleGsapConvertToKeyframes = useCallback(
|
||||
@@ -384,37 +415,31 @@ export function useGsapSelectionHandlers({
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
duration?: number,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
if (!domEditSelection) return Promise.resolve();
|
||||
return convertToKeyframes(
|
||||
domEditSelection,
|
||||
animId,
|
||||
resolvedFromValues,
|
||||
duration,
|
||||
commitOverrides,
|
||||
).catch((error) => {
|
||||
trackGsapHandlerFailure(
|
||||
error,
|
||||
domEditSelection,
|
||||
"convert-to-keyframes",
|
||||
"Convert to keyframes",
|
||||
);
|
||||
});
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return Promise.resolve();
|
||||
return convertToKeyframes(sel, animId, resolvedFromValues, duration, commitOverrides).catch(
|
||||
(error) => {
|
||||
trackGsapHandlerFailure(error, sel, "convert-to-keyframes", "Convert to keyframes");
|
||||
},
|
||||
);
|
||||
},
|
||||
[domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
|
||||
[resolveWriteSelection, convertToKeyframes, trackGsapHandlerFailure],
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
observeGsapMutation(
|
||||
removeAllKeyframes(domEditSelection, animId),
|
||||
domEditSelection,
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const selection = resolveWriteSelection(selectionOverride);
|
||||
if (!selection) return Promise.resolve(false);
|
||||
return observeGsapMutation(
|
||||
removeAllKeyframes(selection, animId),
|
||||
selection,
|
||||
"remove-all-keyframes",
|
||||
"Remove all keyframes",
|
||||
);
|
||||
},
|
||||
[domEditSelection, observeGsapMutation, removeAllKeyframes],
|
||||
[resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
|
||||
);
|
||||
|
||||
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
|
||||
|
||||
@@ -90,4 +90,20 @@ describe("resolveSelectorElementIds", () => {
|
||||
expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
|
||||
expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
|
||||
});
|
||||
|
||||
// The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
|
||||
// dotted). The old local `#id`-only regex read no id at all for those, so they
|
||||
// silently dropped out of both DOM-less paths.
|
||||
it("falls back to a bracketed id when there is no DOM", () => {
|
||||
expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]);
|
||||
});
|
||||
|
||||
it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
|
||||
const doc = {
|
||||
querySelectorAll: () => {
|
||||
throw new SyntaxError("bad selector");
|
||||
},
|
||||
} as unknown as Document;
|
||||
expect(resolveSelectorElementIds('[id="01-hook"]:has(>*)', doc)).toEqual(["01-hook"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,30 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
||||
import {
|
||||
clearKeyframeCacheForElement,
|
||||
clearKeyframeCacheForFile,
|
||||
pruneKeyframeCacheToFiles,
|
||||
writeGsapAnimationsForElement,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import { toAbsoluteTime } from "./gsapShared";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
||||
import {
|
||||
deduplicateKeyframes,
|
||||
isStaticPositionHold,
|
||||
synthesizeFlatTweenKeyframes,
|
||||
} from "./gsapTweenSynth";
|
||||
import {
|
||||
fetchParsedAnimations,
|
||||
populateKeyframeCacheFromAst,
|
||||
resolveClipTimingBasis,
|
||||
} from "./keyframeCacheAstLoad";
|
||||
|
||||
function extractIdFromSelector(selector: string): string | null {
|
||||
const match = selector.match(/^#([\w-]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tween's target selector to the ids of the element(s) it animates.
|
||||
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
|
||||
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
|
||||
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
|
||||
* element they animate — not just one parsed from the string. Falls back to a
|
||||
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function resolveSelectorElementIds(
|
||||
selector: string,
|
||||
doc: Document | null | undefined,
|
||||
): string[] {
|
||||
const bareId = selector.match(/^#([\w-]+)$/);
|
||||
if (bareId) return [bareId[1]];
|
||||
if (!doc) {
|
||||
const lead = extractIdFromSelector(selector);
|
||||
return lead ? [lead] : [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const part of selector.split(",")) {
|
||||
const sel = part.trim();
|
||||
if (!sel) continue;
|
||||
try {
|
||||
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
||||
if (el.id) ids.add(el.id);
|
||||
}
|
||||
} catch {
|
||||
const lead = extractIdFromSelector(sel);
|
||||
if (lead) ids.add(lead);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
||||
export {
|
||||
fetchParsedAnimations,
|
||||
resolveClipTimingBasis,
|
||||
resolveSelectorElementIds,
|
||||
} from "./keyframeCacheAstLoad";
|
||||
|
||||
/** The selected element's identity for matching tweens to it. */
|
||||
export interface GsapElementTarget {
|
||||
@@ -98,59 +74,6 @@ export function getAnimationsForElement(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchParsedAnimations(
|
||||
projectId: string,
|
||||
sourceFile: string,
|
||||
): Promise<ParsedGsap | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
||||
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
||||
// would defeat caching forever and is a deterministic-render no-no).
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const parsed = (await res.json()) as ParsedGsap;
|
||||
// Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
|
||||
// hold an element's first keyframe before its tween). They must not surface as
|
||||
// user animations — otherwise they pollute the keyframe cache / timeline diamonds.
|
||||
return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
||||
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
||||
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
||||
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
||||
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
||||
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
||||
*/
|
||||
export function resolveClipTimingBasis(
|
||||
elementId: string,
|
||||
sourceFile: string,
|
||||
elements: ReadonlyArray<{
|
||||
domId?: string;
|
||||
key?: string;
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}>,
|
||||
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
||||
): { elStart: number; elDuration: number } {
|
||||
const direct = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
||||
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
||||
const host = hostId
|
||||
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
||||
: undefined;
|
||||
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
||||
}
|
||||
|
||||
export function useGsapAnimationsForElement(
|
||||
projectId: string | null,
|
||||
sourceFile: string,
|
||||
@@ -328,6 +251,17 @@ export function useGsapAnimationsForElement(
|
||||
// fallow-ignore-next-line complexity
|
||||
useEffect(() => {
|
||||
if (!elementId) return;
|
||||
// Same admission rule as the keyframe cache below (hold skip included) and
|
||||
// no property-group filter: the two stores must agree, or a hold draws an
|
||||
// expanded property lane with no collapsed diamond behind it and an
|
||||
// ungrouped tween draws diamonds with no lane source.
|
||||
const sourceAnimations = animations.filter(
|
||||
(animation) =>
|
||||
!isStaticPositionHold(animation) &&
|
||||
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
||||
);
|
||||
if (sourceAnimations.length > 0)
|
||||
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
||||
|
||||
// Resolve the element's time range from the player store so we can
|
||||
// convert tween-relative keyframe percentages to clip-relative ones.
|
||||
@@ -340,24 +274,17 @@ export function useGsapAnimationsForElement(
|
||||
);
|
||||
|
||||
const allKeyframes: Array<
|
||||
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
|
||||
GsapKeyframesData["keyframes"][0] & {
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
> = [];
|
||||
let format: GsapKeyframesData["format"] = "percentage";
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
for (const anim of animations) {
|
||||
// A static position hold (only x/y, no real motion) is a `set`, not a
|
||||
// keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)`
|
||||
// and the `tl.to({ duration: 0, immediateRender: true })` hold that
|
||||
// remove-all-keyframes collapses to (which is otherwise shown as a stray
|
||||
// 0% keyframe).
|
||||
if (
|
||||
!anim.keyframes &&
|
||||
Object.keys(anim.properties).length > 0 &&
|
||||
Object.keys(anim.properties).every((k) => k === "x" || k === "y") &&
|
||||
(anim.method === "set" || (anim.duration ?? 0) === 0)
|
||||
)
|
||||
continue;
|
||||
if (isStaticPositionHold(anim)) continue;
|
||||
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kf) continue;
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
@@ -367,17 +294,13 @@ export function useGsapAnimationsForElement(
|
||||
const tweenDur = anim.duration ?? elDuration;
|
||||
for (const k of kf.keyframes) {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
|
||||
// 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly
|
||||
// on the beat dot, which is rendered at the true beat time.
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
||||
: k.percentage;
|
||||
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
|
||||
allKeyframes.push({
|
||||
...k,
|
||||
percentage: clipPct,
|
||||
tweenPercentage: k.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
animationId: anim.id,
|
||||
});
|
||||
}
|
||||
format = kf.format;
|
||||
@@ -424,6 +347,7 @@ export function useGsapCacheVersion() {
|
||||
* elements. Called from the Timeline component so diamonds show without
|
||||
* requiring a selection.
|
||||
*/
|
||||
|
||||
export function usePopulateKeyframeCacheForFile(
|
||||
projectId: string | null,
|
||||
sourceFile: string,
|
||||
@@ -431,6 +355,16 @@ export function usePopulateKeyframeCacheForFile(
|
||||
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||
): void {
|
||||
const elementCount = usePlayerStore((s) => s.elements.length);
|
||||
// Every sub-composition file the timeline shows rows for. The cache is loaded
|
||||
// for all of them up front, so keyframe lanes are populated on open instead of
|
||||
// only once a clip from that file is selected (which is what switches
|
||||
// `sourceFile`). Only files reachable from the store's elements are covered;
|
||||
// a composition nested inside another still loads on first selection.
|
||||
const compositionSrcKey = usePlayerStore((s) =>
|
||||
Array.from(new Set(s.elements.map((el) => el.compositionSrc).filter((src) => !!src)))
|
||||
.sort()
|
||||
.join("|"),
|
||||
);
|
||||
// Re-run when sub-comp DOM children appear (they supply the host bounds the
|
||||
// clip-relative keyframe percentages are computed against; without this the
|
||||
// cache is computed once before they exist and the percentages stay wrong).
|
||||
@@ -443,72 +377,24 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const astFetchDoneRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`;
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
runtimeScanDoneRef.current = "";
|
||||
astFetchDoneRef.current = "";
|
||||
if (!projectId) return;
|
||||
|
||||
const sf = sourceFile;
|
||||
// fallow-ignore-next-line complexity
|
||||
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
clearKeyframeCacheForFile(sf);
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const doc = iframeRef?.current?.contentDocument;
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
for (const anim of parsed.animations) {
|
||||
if (anim.hasUnresolvedKeyframes) continue;
|
||||
// Position-only static holds are not keyframed animations — skip them so
|
||||
// they don't draw a timeline diamond. Covers both a `tl.set(...)` and the
|
||||
// `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes
|
||||
// collapses a keyframed tween to.
|
||||
if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) {
|
||||
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
||||
if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kfData) continue;
|
||||
const tweenPos =
|
||||
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
||||
const tweenDur = anim.duration ?? 1;
|
||||
// Attribute the tween to every element it animates (handles class /
|
||||
// group / descendant selectors, not just `#id`).
|
||||
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
||||
// beat-snapped keyframe centers exactly on the beat dot and the two
|
||||
// caches agree on a keyframe's percentage.
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
||||
: kf.percentage;
|
||||
return {
|
||||
...kf,
|
||||
percentage: clipPct,
|
||||
tweenPercentage: kf.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
};
|
||||
});
|
||||
const existing = mergedByElement.get(id);
|
||||
if (existing) {
|
||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
||||
} else {
|
||||
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, kfData] of mergedByElement) {
|
||||
setKeyframeCache(`${sf}#${id}`, kfData);
|
||||
setKeyframeCache(id, kfData);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
||||
}
|
||||
// The active file first: it owns the selection, and each file clears only
|
||||
// its own cache entries, so the order just decides who writes the bare
|
||||
// `id` alias last.
|
||||
const files = Array.from(
|
||||
new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
|
||||
);
|
||||
const doc = iframeRef?.current?.contentDocument;
|
||||
// Everything the previous scan cached for a file this one no longer covers
|
||||
// (the composition just switched away from) has no owner left to clear it.
|
||||
pruneKeyframeCacheToFiles(files);
|
||||
Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
|
||||
astFetchDoneRef.current = fetchKey;
|
||||
});
|
||||
// elementCount is in the deps because new timeline elements (e.g. after a
|
||||
@@ -517,7 +403,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
||||
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey]);
|
||||
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]);
|
||||
|
||||
// Separate effect for runtime keyframe discovery — polls until the iframe
|
||||
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
|
||||
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
function HookHost() {
|
||||
usePopulateKeyframeCacheForFile("demo", "index.html", 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{
|
||||
id: "lab",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 12,
|
||||
track: 1,
|
||||
compositionSrc: "compositions/keyframe-lab.html",
|
||||
},
|
||||
] as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
container?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("usePopulateKeyframeCacheForFile", () => {
|
||||
it("loads every sub-composition file the timeline shows, not just the active one", async () => {
|
||||
// Keyframe lanes must be populated when the project opens. Fetching only the
|
||||
// active file left them empty until a clip from the sub-composition was
|
||||
// selected (which is the only thing that switched `sourceFile`).
|
||||
const urls: string[] = [];
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
urls.push(String(input));
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
act(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
root.render(<HookHost />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true);
|
||||
expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
||||
import { makeSelection } from "./domSelectionTestHarness";
|
||||
import { useInspectorState, type InspectorState } from "./useStudioContextValue";
|
||||
|
||||
interface HarnessProps {
|
||||
rightPanelTab: string;
|
||||
rightInspectorPanes: RightInspectorPanes;
|
||||
rightCollapsed: boolean;
|
||||
isPlaying: boolean;
|
||||
isGestureRecording: boolean;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
}
|
||||
|
||||
function renderInspectorState(props: HarnessProps): InspectorState {
|
||||
let state: InspectorState | null = null;
|
||||
|
||||
function Harness() {
|
||||
state = useInspectorState(
|
||||
props.rightPanelTab,
|
||||
props.rightInspectorPanes,
|
||||
props.rightCollapsed,
|
||||
props.isPlaying,
|
||||
props.domEditSelection,
|
||||
props.isGestureRecording,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
renderToStaticMarkup(React.createElement(Harness));
|
||||
if (!state) throw new Error("Expected inspector state");
|
||||
return state;
|
||||
}
|
||||
|
||||
function selectedProps(
|
||||
overrides: Partial<HarnessProps> = {},
|
||||
): HarnessProps & { domEditSelection: DomEditSelection } {
|
||||
const element = document.createElement("div");
|
||||
return {
|
||||
rightPanelTab: "renders",
|
||||
rightInspectorPanes: { layers: false, design: false },
|
||||
rightCollapsed: true,
|
||||
isPlaying: false,
|
||||
isGestureRecording: false,
|
||||
domEditSelection: makeSelection("Selected", element),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useInspectorState", () => {
|
||||
it("shows the motion path for pure selection with the inspector collapsed", () => {
|
||||
expect(renderInspectorState(selectedProps()).shouldShowMotionPath).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the motion path without a selection", () => {
|
||||
expect(
|
||||
renderInspectorState({ ...selectedProps(), domEditSelection: null }).shouldShowMotionPath,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the motion path during playback", () => {
|
||||
expect(renderInspectorState(selectedProps({ isPlaying: true })).shouldShowMotionPath).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the motion path during gesture recording", () => {
|
||||
expect(
|
||||
renderInspectorState(selectedProps({ isGestureRecording: true })).shouldShowMotionPath,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps selected DOM bounds coupled to the inspector or variables panel", () => {
|
||||
expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false);
|
||||
expect(
|
||||
renderInspectorState(
|
||||
selectedProps({
|
||||
rightPanelTab: "design",
|
||||
rightInspectorPanes: { layers: false, design: true },
|
||||
}),
|
||||
).shouldShowSelectedDomBounds,
|
||||
).toBe(true);
|
||||
expect(
|
||||
renderInspectorState(selectedProps({ rightPanelTab: "variables" }))
|
||||
.shouldShowSelectedDomBounds,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { StudioContextValue } from "../contexts/StudioContext";
|
||||
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
||||
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
|
||||
@@ -69,6 +70,7 @@ export interface InspectorState {
|
||||
designPanelActive: boolean;
|
||||
inspectorPanelActive: boolean;
|
||||
inspectorButtonActive: boolean;
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ export function useInspectorState(
|
||||
rightInspectorPanes: RightInspectorPanes,
|
||||
rightCollapsed: boolean,
|
||||
isPlaying: boolean,
|
||||
domEditSelection: DomEditSelection | null,
|
||||
isGestureRecording?: boolean,
|
||||
): InspectorState {
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -93,8 +96,12 @@ export function useInspectorState(
|
||||
inspectorPanelActive,
|
||||
inspectorButtonActive:
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
||||
// Keep the selection box + motion path drawn even when the Inspector is
|
||||
// collapsed — closing the panel shouldn't visually deselect the element.
|
||||
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
|
||||
// handles ARE the arc-drag affordance, so gating them on an open Inspector
|
||||
// would make keyframe path editing reachable only from a side panel.
|
||||
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
|
||||
// Keep the selection box drawn even when the Inspector is collapsed —
|
||||
// closing the panel shouldn't visually deselect the element.
|
||||
// The Variables tab also works against the canvas selection (bind card),
|
||||
// so the selection outline stays visible there too.
|
||||
shouldShowSelectedDomBounds:
|
||||
@@ -102,7 +109,14 @@ export function useInspectorState(
|
||||
!isPlaying &&
|
||||
!isGestureRecording,
|
||||
};
|
||||
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
|
||||
}, [
|
||||
rightPanelTab,
|
||||
rightInspectorPanes,
|
||||
rightCollapsed,
|
||||
isPlaying,
|
||||
isGestureRecording,
|
||||
domEditSelection,
|
||||
]);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
interface StudioTestHookDeps {
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
buildDomSelectionFromTarget: (target: HTMLElement) => Promise<DomEditSelection | null>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean },
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface StudioTestApi {
|
||||
selectByDomId: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__studioTest?: StudioTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only headless-QA shortcut. Selecting an element normally requires a
|
||||
* pixel-precise click inside the preview iframe, which automated verification
|
||||
* can't reliably land. `window.__studioTest.selectByDomId(id)` resolves the
|
||||
* DomEditSelection for a preview element by id and reveals the inspector —
|
||||
* exactly what a click does — so a driver can open the property/ease panels and
|
||||
* then focus a segment via `__playerStore.getState().setFocusedEaseSegment`.
|
||||
* No-op in production builds.
|
||||
*/
|
||||
export function useStudioTestHooks({
|
||||
previewIframeRef,
|
||||
buildDomSelectionFromTarget,
|
||||
applyDomSelection,
|
||||
}: StudioTestHookDeps): void {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
let isDev = false;
|
||||
try {
|
||||
isDev = import.meta.env.DEV === true;
|
||||
} catch {
|
||||
isDev = false;
|
||||
}
|
||||
if (!isDev || typeof window === "undefined") return;
|
||||
const api: StudioTestApi = {
|
||||
selectByDomId: async (id: string): Promise<boolean> => {
|
||||
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
||||
if (!element) return false;
|
||||
const selection = await buildDomSelectionFromTarget(element);
|
||||
if (!selection) return false;
|
||||
applyDomSelection(selection, { revealPanel: true });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
window.__studioTest = api;
|
||||
return () => {
|
||||
// delete, not `= undefined`: an own key holding undefined keeps
|
||||
// `"__studioTest" in window` true, which defeats feature detection.
|
||||
delete window.__studioTest;
|
||||
};
|
||||
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
|
||||
}
|
||||
@@ -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<typeof import("../components/editor/manualEditingAvailability")>();
|
||||
return {
|
||||
...actual,
|
||||
STUDIO_SDK_CUTOVER_ENABLED: true,
|
||||
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
|
||||
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
||||
};
|
||||
});
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type ZIndexEntry = {
|
||||
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
|
||||
}) => Promise<void>;
|
||||
reloadPreview?: () => void;
|
||||
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
|
||||
publishSdkSession?: NonNullable<Parameters<typeof useTimelineEditing>[0]["publishSdkSession"]>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
invalidateGsapCache?: () => void;
|
||||
showToast?: (message: string, kind?: string) => void;
|
||||
}): {
|
||||
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
|
||||
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
|
||||
pendingTimelineEditPathRef: { current: new Set<string>() },
|
||||
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<typeof renderTimelineEditingHook>[0]["recordEdit"]
|
||||
>;
|
||||
type TimelinePublishSdkSession = NonNullable<
|
||||
Parameters<typeof renderTimelineEditingHook>[0]["publishSdkSession"]
|
||||
>;
|
||||
|
||||
function renderTimelineEditingHookWithLifecycle(input: {
|
||||
timelineElements: TimelineElement[];
|
||||
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
|
||||
* with `gsapBody`. Returns the mock for call inspection.
|
||||
*/
|
||||
function stubProjectFetch(files: string | Record<string, string>, 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<typeof fetch>[0]): Promise<Response> => {
|
||||
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<typeof fetch>[0],
|
||||
_init?: Parameters<typeof fetch>[1],
|
||||
): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
const route = routes.find(([marker]) => url.includes(marker));
|
||||
if (!route) throw new Error(`Unexpected fetch: ${url}`);
|
||||
return route[1](url);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
|
||||
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
|
||||
}
|
||||
|
||||
const SDK_KEYFRAMED_SOURCE = [
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
|
||||
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
||||
`</div>`,
|
||||
`<script>`,
|
||||
`const tl = gsap.timeline({ paused: true });`,
|
||||
`tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
|
||||
`window.__timelines = [tl];`,
|
||||
`</script>`,
|
||||
].join("\n");
|
||||
|
||||
async function setupSdkKeyframedClipHarness() {
|
||||
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
|
||||
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
|
||||
const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const invalidateGsapCache = vi.fn();
|
||||
const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
|
||||
usePlayerStore.getState().setDuration(10);
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [clip],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
sdkSession,
|
||||
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
||||
invalidateGsapCache,
|
||||
});
|
||||
return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
|
||||
}
|
||||
|
||||
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
|
||||
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 = [
|
||||
`<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
|
||||
` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
|
||||
` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
|
||||
`</div>`,
|
||||
`<script>`,
|
||||
`const tl = gsap.timeline({ paused: true });`,
|
||||
`tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
|
||||
`tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
|
||||
`window.__timelines = [tl];`,
|
||||
`</script>`,
|
||||
].join("\n");
|
||||
const { iframe, a, b } = makeTwoClipPair();
|
||||
const sdkSession = await openComposition(source);
|
||||
const fetchMock = stubProjectFetch(source);
|
||||
const invalidateGsapCache = vi.fn();
|
||||
usePlayerStore.getState().setDuration(10);
|
||||
const hook = renderTimelineEditingHook({
|
||||
timelineElements: [a, b],
|
||||
iframe,
|
||||
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
||||
projectId: "p1",
|
||||
writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
sdkSession,
|
||||
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
||||
invalidateGsapCache,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await hook.groupMove([
|
||||
{ element: a, start: 1 },
|
||||
{ element: b, start: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
const mutations = fetchMock.mock.calls
|
||||
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
||||
.map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
|
||||
expect(mutations).toEqual([
|
||||
{ type: "shift-positions", targetSelector: "#a", delta: 1 },
|
||||
{ type: "shift-positions", targetSelector: "#b", delta: 1 },
|
||||
]);
|
||||
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
|
||||
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("partitions a group move by source file while keeping one undo entry", async () => {
|
||||
const files: Record<string, string> = {
|
||||
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<TimelineZIndexReorderCommit | null>;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions {
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
editQueueRef: MutableRefObject<Promise<unknown>>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
invalidateGsapCache?: () => void;
|
||||
isRecordingRef?: RefObject<boolean>;
|
||||
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
@@ -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,47 +289,55 @@ 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.
|
||||
if (trackOnly) return;
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Move timeline clips",
|
||||
errorLabel: "Failed to shift GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) return null;
|
||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||
},
|
||||
});
|
||||
// The timing persist above already committed to disk, so the cached
|
||||
// GSAP read is stale whether or not the position rewrite succeeded —
|
||||
// invalidate on the error path too (matches the single-element path's
|
||||
// `.finally`), or a failed rewrite leaves the editor reading old tweens.
|
||||
try {
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Move timeline clips",
|
||||
errorLabel: "Failed to shift GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const delta = change.start - change.element.start;
|
||||
const domId = change.element.domId;
|
||||
if (delta === 0 || !domId) return null;
|
||||
return shiftGsapPositions(projectId, changePath, domId, delta);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
invalidateGsapCache?.();
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
@@ -340,6 +355,7 @@ export function useTimelineGroupEditing({
|
||||
reloadPreview,
|
||||
trySdkBatchPersist,
|
||||
showToast,
|
||||
invalidateGsapCache,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -384,50 +400,57 @@ 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,
|
||||
);
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Resize timeline clips",
|
||||
errorLabel: "Failed to scale GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start || change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) return null;
|
||||
return scaleGsapPositions(
|
||||
projectId,
|
||||
changePath,
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
},
|
||||
});
|
||||
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,
|
||||
);
|
||||
}
|
||||
// See the move path: the timing persist is already on disk, so the GSAP
|
||||
// cache must be invalidated even when the position rewrite throws.
|
||||
try {
|
||||
await finishGroupTimingGsapFallback({
|
||||
projectId,
|
||||
iframe: previewIframeRef.current,
|
||||
reloadPreview,
|
||||
label: "Resize timeline clips",
|
||||
errorLabel: "Failed to scale GSAP positions",
|
||||
coalesceKey,
|
||||
recordEdit,
|
||||
activeCompPath,
|
||||
changes,
|
||||
resolveChangePath: (element) => targetPathFor(element, activeCompPath),
|
||||
mutateChange: (change, changePath) => {
|
||||
const domId = change.element.domId;
|
||||
const timingChanged =
|
||||
change.start !== change.element.start ||
|
||||
change.duration !== change.element.duration;
|
||||
if (!timingChanged || !domId) return null;
|
||||
return scaleGsapPositions(
|
||||
projectId,
|
||||
changePath,
|
||||
domId,
|
||||
change.element.start,
|
||||
change.element.duration,
|
||||
change.start,
|
||||
change.duration,
|
||||
);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
invalidateGsapCache?.();
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Failed persist: revert the optimistic duration readout + live root
|
||||
// alongside the gesture owner's store rollback.
|
||||
@@ -445,6 +468,7 @@ export function useTimelineGroupEditing({
|
||||
reloadPreview,
|
||||
trySdkBatchPersist,
|
||||
showToast,
|
||||
invalidateGsapCache,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import {
|
||||
KeyframeDiamondContextMenu,
|
||||
type KeyframeDiamondContextMenuState,
|
||||
} from "./KeyframeDiamondContextMenu";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
|
||||
|
||||
const state: KeyframeDiamondContextMenuState = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
element,
|
||||
elementId: "box",
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "box-to-1-position",
|
||||
};
|
||||
|
||||
function clickMenuItem(label: string, props: Partial<Record<string, unknown>>) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() =>
|
||||
root.render(
|
||||
<KeyframeDiamondContextMenu
|
||||
state={state}
|
||||
onClose={() => {}}
|
||||
onDelete={vi.fn()}
|
||||
onDeleteAll={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const button = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent === label,
|
||||
);
|
||||
act(() => button?.click());
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
}
|
||||
|
||||
describe("KeyframeDiamondContextMenu", () => {
|
||||
// Two animations can carry a keyframe at the same clip percentage. Dropping the
|
||||
// property group / tween percentage / animation id here sends the mutation back
|
||||
// to first-match-by-percentage, which retimes or deletes the wrong tween.
|
||||
it("hands every action the full keyframe identity, not just the percentage", () => {
|
||||
const onDelete = vi.fn();
|
||||
const onMoveToPlayhead = vi.fn();
|
||||
|
||||
clickMenuItem("Delete Keyframe", { onDelete });
|
||||
clickMenuItem("Move to Playhead", { onMoveToPlayhead });
|
||||
|
||||
const target = {
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "box-to-1-position",
|
||||
};
|
||||
expect(onDelete).toHaveBeenCalledWith("box", target);
|
||||
expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,28 @@
|
||||
import { memo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
|
||||
export interface KeyframeDiamondContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
element: TimelineElement;
|
||||
elementId: string;
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
currentEase?: string;
|
||||
}
|
||||
|
||||
interface KeyframeDiamondContextMenuProps {
|
||||
state: KeyframeDiamondContextMenuState;
|
||||
onClose: () => void;
|
||||
onDelete: (elementId: string, percentage: number) => void;
|
||||
onDeleteAll: (elementId: string) => void;
|
||||
onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onCopyProperties?: (elementId: string, percentage: number) => void;
|
||||
onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAll: (element: TimelineElement) => void;
|
||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||
onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void;
|
||||
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
}
|
||||
|
||||
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
||||
@@ -30,6 +33,14 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
onMoveToPlayhead,
|
||||
}: KeyframeDiamondContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
// The clicked diamond's identity, built once: the menu's two mutating entries
|
||||
// both act on it, and they must not disagree about which keyframe was clicked.
|
||||
const keyframe: TimelineKeyframeTarget = {
|
||||
percentage: state.percentage,
|
||||
tweenPercentage: state.tweenPercentage,
|
||||
propertyGroup: state.propertyGroup,
|
||||
animationId: state.animationId,
|
||||
};
|
||||
|
||||
const menuWidth = 200;
|
||||
const menuHeight = onMoveToPlayhead ? 100 : 70;
|
||||
@@ -51,7 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||
// miss the lookup on any tween whose window is shorter than the clip.
|
||||
onMoveToPlayhead(state.elementId, state.percentage);
|
||||
onMoveToPlayhead(state.element, keyframe);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -64,7 +75,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.elementId, state.percentage);
|
||||
onDelete(state.elementId, keyframe);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -75,7 +86,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDeleteAll(state.elementId);
|
||||
onDeleteAll(state.element);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CaretRight } from "@phosphor-icons/react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
|
||||
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
|
||||
// here, not on the clip bar, and re-expands a collapsed layer.
|
||||
export function LayerDisclosureRow({
|
||||
keyframeClip,
|
||||
clipCount,
|
||||
isExpanded,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
lanesId,
|
||||
onToggleClipExpanded,
|
||||
children,
|
||||
}: {
|
||||
keyframeClip: TimelineElement;
|
||||
clipCount: number;
|
||||
isExpanded: boolean;
|
||||
gutterBackground: string;
|
||||
/** Same adaptive width the lane rows use: a narrowed header column must not
|
||||
* leave this row hanging over the clips it labels. */
|
||||
columnWidth: number;
|
||||
/** Id of the element holding the lanes this row's caret expands. */
|
||||
lanesId: string;
|
||||
onToggleClipExpanded: () => void;
|
||||
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
||||
style={{
|
||||
width: columnWidth,
|
||||
height: TRACK_H,
|
||||
color: "#ffffff",
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={lanesId}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
|
||||
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
|
||||
// only the hit box grows.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleClipExpanded();
|
||||
}}
|
||||
>
|
||||
<CaretRight
|
||||
size={11}
|
||||
weight="bold"
|
||||
aria-hidden="true"
|
||||
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
|
||||
/>
|
||||
</button>
|
||||
{/* Decorative: the disclosure button above already names the row's keyframe
|
||||
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
||||
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
||||
◇
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<TrackClipCount clipCount={clipCount} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./Timeline";
|
||||
import {
|
||||
CLIP_Y,
|
||||
FIT_ZOOM_HEADROOM,
|
||||
GUTTER,
|
||||
LABEL_COL_W,
|
||||
LANE_H,
|
||||
MIN_TIMELINE_EXTENT_S,
|
||||
PLAYHEAD_HEAD_W,
|
||||
RULER_H,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelineDisplayContentWidth,
|
||||
getTimelineFitPps,
|
||||
getTimelineLaneTop,
|
||||
} from "./timelineLayout";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
@@ -40,52 +44,216 @@ afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
||||
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
|
||||
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
|
||||
const trackContent = clip.parentElement;
|
||||
if (!trackContent) throw new Error(`Missing content row for ${clipId}`);
|
||||
const trackHeader = trackContent.previousElementSibling;
|
||||
if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`);
|
||||
const rulerTickLabel = Array.from(host.querySelectorAll("span")).find(
|
||||
(node) => node.textContent === tickLabel,
|
||||
);
|
||||
const rulerTick = rulerTickLabel?.parentElement;
|
||||
if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`);
|
||||
const ruler = rulerTick.parentElement;
|
||||
if (!ruler) throw new Error("Missing timeline ruler");
|
||||
const rulerOrigin = ruler.previousElementSibling;
|
||||
if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
|
||||
const playhead = Array.from(host.querySelectorAll<HTMLElement>("div")).find(
|
||||
(node) => node.style.zIndex === "100",
|
||||
);
|
||||
if (!playhead) throw new Error("Missing timeline playhead");
|
||||
return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
|
||||
}
|
||||
|
||||
function renderTimelineGeometry(clipId: string) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") };
|
||||
}
|
||||
|
||||
function createSizedTimelineHost(width: number): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
||||
return host;
|
||||
}
|
||||
|
||||
function expectTrackExpansion(
|
||||
row: HTMLElement | null | undefined,
|
||||
expandedClipIds: string[],
|
||||
height: number,
|
||||
) {
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds));
|
||||
expect(row?.style.height).toBe(`${height}px`);
|
||||
}
|
||||
|
||||
function renderBasicTimeline() {
|
||||
const host = createSizedTimelineHost(640);
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
});
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe("Timeline provider boundary", () => {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the public Timeline export without TimelineEditProvider", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
|
||||
it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
duration: 11,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
currentTime: 10,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 100,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||
renderTimelineGeometry("clip-1");
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
||||
expect(clip.style.left).toBe("1000px");
|
||||
expect(clip.style.height).toBe("");
|
||||
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
||||
expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
||||
expect(rulerTick.style.left).toBe("999.5px");
|
||||
expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`);
|
||||
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
||||
expect(
|
||||
resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: 100,
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackOrder: [0],
|
||||
},
|
||||
1132,
|
||||
100,
|
||||
).start,
|
||||
).toBe(10);
|
||||
expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
|
||||
it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => {
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
duration: 20,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
currentTime: 10,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 100,
|
||||
selectedElementId: "clip-1",
|
||||
expandedClipIds: new Set(["clip-1"]),
|
||||
elements: [
|
||||
{ id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 },
|
||||
{ id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 },
|
||||
],
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
[
|
||||
{
|
||||
id: "position-tween",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 20,
|
||||
properties: {},
|
||||
propertyGroup: "position",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||
renderTimelineGeometry("clip-1");
|
||||
const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10");
|
||||
const diamond = host.querySelector<HTMLElement>(
|
||||
'[data-keyframe-group="position"][data-keyframe-percentage="50"]',
|
||||
);
|
||||
if (!diamond) throw new Error("Missing expanded position keyframe");
|
||||
const propertyLane = diamond.closest<HTMLElement>("[data-timeline-property-lane]");
|
||||
if (!propertyLane) throw new Error("Missing flat position property lane");
|
||||
const headerLane = trackHeader.querySelector<HTMLElement>('[data-property-group="position"]');
|
||||
if (!headerLane) throw new Error("Missing position property header");
|
||||
// Absolute x rebuilds from the content origin (the ruler-origin spacer),
|
||||
// which now insets a GUTTER past the LABEL_COL_W label column so a 0%
|
||||
// diamond has room to its left. The content row reaches that same origin via
|
||||
// header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still
|
||||
// coincide on the shared time x.
|
||||
const contentOrigin = Number.parseFloat(rulerOrigin.style.width);
|
||||
const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5;
|
||||
const diamondX =
|
||||
contentOrigin +
|
||||
Number.parseFloat(propertyLane.style.left) +
|
||||
Number.parseFloat(diamond.style.left) +
|
||||
Number.parseFloat(diamond.style.width) / 2;
|
||||
|
||||
expect(clip.contains(propertyLane)).toBe(false);
|
||||
expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`);
|
||||
expect(clip.style.bottom).toBe("");
|
||||
expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`);
|
||||
expect(propertyLane.style.top).toBe(headerLane.style.top);
|
||||
expect(propertyLane.style.background).toBe("");
|
||||
expect(propertyLane.style.border).toBe("");
|
||||
expect(propertyLane.style.borderRadius).toBe("");
|
||||
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
|
||||
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
|
||||
expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
|
||||
expect(diamondX).toBe(rulerX);
|
||||
expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
|
||||
expect(collapsedHeader.textContent).toContain("Outro");
|
||||
expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
|
||||
(640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
expect(
|
||||
resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: 100,
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: LABEL_COL_W + GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackOrder: [0],
|
||||
},
|
||||
100 + LABEL_COL_W + GUTTER + 1000,
|
||||
100,
|
||||
).start,
|
||||
).toBe(10);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the public Timeline export without TimelineEditProvider", () => {
|
||||
const { root } = renderBasicTimeline();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const { host, root } = renderBasicTimeline();
|
||||
|
||||
const hueDot = Array.from(host.querySelectorAll("div")).find(
|
||||
(node) =>
|
||||
@@ -99,14 +267,8 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("requests persisted track visibility from the gutter without seeking", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
const host = createSizedTimelineHost(640);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
@@ -153,8 +315,8 @@ describe("Timeline provider boundary", () => {
|
||||
});
|
||||
|
||||
const row = button.parentElement?.parentElement;
|
||||
// Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
|
||||
const trackContent = row?.children.item(2);
|
||||
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
||||
const trackContent = row?.children.item(1);
|
||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
||||
expect(trackContent).toBeInstanceOf(HTMLElement);
|
||||
if (!(trackContent instanceof HTMLElement)) {
|
||||
@@ -165,14 +327,49 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
it("splits all tracks once when shift-clicking the timeline with the razor", () => {
|
||||
const host = createSizedTimelineHost(640);
|
||||
usePlayerStore.setState({
|
||||
activeTool: "razor",
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
});
|
||||
|
||||
const onRazorSplitAll = vi.fn();
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
TimelineEditProvider,
|
||||
{ value: { onRazorSplitAll } },
|
||||
React.createElement(Timeline),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
|
||||
expect(viewport).toBeInstanceOf(HTMLElement);
|
||||
act(() => {
|
||||
viewport?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientX: 240,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onRazorSplitAll).toHaveBeenCalledTimes(1);
|
||||
expect(onRazorSplitAll).toHaveBeenCalledWith(expect.any(Number));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
@@ -215,14 +412,101 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks every clip in selectedElementIds as selected", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
it("shows a disclosure only for grouped keyframes and toggles the track height", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 },
|
||||
{ id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 },
|
||||
],
|
||||
keyframeCache: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 }, propertyGroup: "position" },
|
||||
{ percentage: 50, properties: { x: 100 }, propertyGroup: "position" },
|
||||
{ percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
[
|
||||
{
|
||||
id: "clip-1-position",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup: "position",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clip-1-visual",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup: "visual",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 100, properties: { opacity: 0 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
|
||||
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
|
||||
// lives in the left column. clip-2 has no keyframes so it never shows one.
|
||||
const collapseButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Collapse clip-1 keyframes"]',
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
|
||||
|
||||
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
|
||||
const row = clip?.parentElement?.parentElement;
|
||||
expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
|
||||
|
||||
// Collapsing sticks (does not bounce back open via auto-expand).
|
||||
act(() => collapseButton?.click());
|
||||
expectTrackExpansion(row, [], TRACK_H);
|
||||
|
||||
const expandButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand clip-1 keyframes"]',
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
act(() => expandButton?.click());
|
||||
expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks every clip in selectedElementIds as selected", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 6,
|
||||
timelineReady: true,
|
||||
@@ -461,23 +745,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
||||
it("computes fit pps against the 60s floor for short compositions", () => {
|
||||
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
|
||||
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
|
||||
const pps = getTimelineFitPps(viewport, 10);
|
||||
expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
|
||||
expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
|
||||
const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD);
|
||||
expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
|
||||
expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
|
||||
});
|
||||
|
||||
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
|
||||
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
|
||||
expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
});
|
||||
|
||||
it("subtracts the expanded keyframe label column before fitting headroom", () => {
|
||||
expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo(
|
||||
(viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
|
||||
const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
|
||||
const pps = getTimelineFitPps(viewport, 120);
|
||||
const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
|
||||
const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD);
|
||||
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
|
||||
// droppable ruler/lane surface past the end.
|
||||
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
|
||||
@@ -485,17 +775,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
||||
});
|
||||
|
||||
it("falls back to 100 pps before the viewport is measured", () => {
|
||||
expect(getTimelineFitPps(0, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100);
|
||||
expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100);
|
||||
expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100);
|
||||
});
|
||||
|
||||
it("uses the floor for zero/invalid durations", () => {
|
||||
expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -504,14 +794,24 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
|
||||
// 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }),
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 200,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 20,
|
||||
}),
|
||||
).toBe(MIN_TIMELINE_EXTENT_S * 20);
|
||||
});
|
||||
|
||||
it("still fills the viewport when that is larger than the 60s floor", () => {
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
|
||||
).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 200,
|
||||
viewportWidth: 2000,
|
||||
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
||||
pps: 5,
|
||||
}),
|
||||
).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2);
|
||||
});
|
||||
|
||||
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
|
||||
@@ -519,6 +819,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 500,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 5,
|
||||
dragGhostEndPx: 5000,
|
||||
}),
|
||||
@@ -530,6 +831,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 500,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 5,
|
||||
resizeGhostEndPx: 4200,
|
||||
}),
|
||||
@@ -538,7 +840,12 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
|
||||
it("keeps long content authoritative", () => {
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }),
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 9000,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 50,
|
||||
}),
|
||||
).toBe(9000);
|
||||
});
|
||||
});
|
||||
@@ -565,7 +872,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 200,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 10,
|
||||
nextPixelsPerSecond: 20,
|
||||
duration: 120,
|
||||
@@ -578,7 +885,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 0,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 20,
|
||||
nextPixelsPerSecond: 5,
|
||||
duration: 120,
|
||||
@@ -591,7 +898,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 120,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 0,
|
||||
nextPixelsPerSecond: 20,
|
||||
duration: 120,
|
||||
@@ -601,38 +908,59 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
});
|
||||
|
||||
describe("getTimelinePlayheadLeft", () => {
|
||||
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
|
||||
it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => {
|
||||
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
|
||||
// equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
|
||||
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
// equal contentOrigin + t*pps at any zoom, for both the padded default
|
||||
// origin and the plain gutter origin.
|
||||
expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD + 4 * 20,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD + 75,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
|
||||
expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
|
||||
});
|
||||
|
||||
it("uses the expanded keyframe label column as the playhead origin", () => {
|
||||
expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
LABEL_COL_W + 4 * 20,
|
||||
);
|
||||
});
|
||||
|
||||
it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
|
||||
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
|
||||
expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD,
|
||||
);
|
||||
});
|
||||
|
||||
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
|
||||
expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
|
||||
});
|
||||
|
||||
it("guards invalid input", () => {
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe(
|
||||
LABEL_COL_W - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("includes bottom scroll buffer below the last track", () => {
|
||||
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
|
||||
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
|
||||
RULER_H + 3 * TRACK_H,
|
||||
);
|
||||
});
|
||||
|
||||
it("still keeps ruler space when there are no tracks", () => {
|
||||
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
||||
expect(getTimelineCanvasHeight([])).toBeGreaterThan(24);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,14 +1014,15 @@ describe("resolveTimelineAssetDrop", () => {
|
||||
rectTop: 200,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 10,
|
||||
trackHeight: 72,
|
||||
trackOrder: [0, 3, 7],
|
||||
},
|
||||
480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
|
||||
// clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
|
||||
// TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
|
||||
432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
|
||||
// clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48)
|
||||
// + TRACK_H/2(24) = 368 → row 1 → track 3.
|
||||
368,
|
||||
),
|
||||
).toEqual({ start: 3, track: 3 });
|
||||
@@ -707,12 +1036,13 @@ describe("resolveTimelineAssetDrop", () => {
|
||||
rectTop: 200,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 10,
|
||||
trackHeight: 72,
|
||||
trackOrder: [0, 3, 7],
|
||||
},
|
||||
250 + TRACKS_LEFT_PAD,
|
||||
250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER)
|
||||
600,
|
||||
),
|
||||
).toEqual({ start: 1.18, track: 8 });
|
||||
|
||||
@@ -9,7 +9,6 @@ import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { getTrackStyle } from "./timelineIcons";
|
||||
import { useTimelineZoom } from "./useTimelineZoom";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
@@ -17,18 +16,27 @@ import { TimelineCanvas } from "./TimelineCanvas";
|
||||
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { TimelineOverlays } from "./TimelineOverlays";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
|
||||
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
||||
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout";
|
||||
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import {
|
||||
getTrackStyle,
|
||||
useTimelineDisplayLayout,
|
||||
useTimelineTrackLayout,
|
||||
} from "./useTimelineTrackLayout";
|
||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import { useTrackGapMenu } from "./useTrackGapMenu";
|
||||
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
||||
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
@@ -74,7 +82,6 @@ export const Timeline = memo(function Timeline({
|
||||
onRazorSplitAll,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
onChangeKeyframeEase,
|
||||
onMoveKeyframeToPlayhead,
|
||||
onMoveKeyframe,
|
||||
} = useResolvedTimelineEditCallbacks({
|
||||
@@ -106,6 +113,25 @@ export const Timeline = memo(function Timeline({
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
||||
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
|
||||
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
|
||||
const hasKeyframedClips = useMemo(
|
||||
() =>
|
||||
Array.from(gsapAnimations.values()).some((list) =>
|
||||
// Same lane-contribution predicate the layout uses: real keyframes OR a
|
||||
// synthesizable flat tween. Checking animation.keyframes alone left a
|
||||
// flat-tween-only comp without its reserved label column.
|
||||
list.some((animation) => animationContributesLane(animation)),
|
||||
),
|
||||
[gsapAnimations],
|
||||
);
|
||||
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
|
||||
// Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD
|
||||
// (dropping it would jam clip 0 against the gutter on every non-keyframed
|
||||
// composition); in label mode the 232px label column already provides it.
|
||||
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
||||
const contentGutter = labelMode ? GUTTER : 0;
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
@@ -117,7 +143,6 @@ export const Timeline = memo(function Timeline({
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
|
||||
@@ -154,9 +179,10 @@ export const Timeline = memo(function Timeline({
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
useAutoExpandKeyframedClips(gsapAnimations);
|
||||
const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
|
||||
useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
|
||||
const expandedElementsRef = useRef(expandedElements);
|
||||
expandedElementsRef.current = expandedElements;
|
||||
|
||||
@@ -223,6 +249,7 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
onMoveElement: pinnedOnMoveElement,
|
||||
onMoveElements: pinnedOnMoveElements,
|
||||
onResizeElement: pinnedOnResizeElement,
|
||||
@@ -241,26 +268,32 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
contentOrigin,
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
onCompositionDrop: pinnedOnCompositionDrop,
|
||||
});
|
||||
|
||||
const displayTrackOrder = useMemo(() => {
|
||||
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||
}, [draggedClip, trackOrder]);
|
||||
|
||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
|
||||
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
|
||||
timelineReady,
|
||||
expandedElements.length,
|
||||
totalH,
|
||||
displayLayout.totalH,
|
||||
]);
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
|
||||
useTimelineKeyframeHandlers({
|
||||
expandedElements,
|
||||
keyframeCache,
|
||||
onSelectElement,
|
||||
onSeek,
|
||||
setSelectedElementId,
|
||||
setKfContextMenu,
|
||||
toggleSelectedKeyframe,
|
||||
});
|
||||
|
||||
const selectedElement = useMemo(
|
||||
() =>
|
||||
@@ -291,6 +324,7 @@ export const Timeline = memo(function Timeline({
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
contentOrigin,
|
||||
});
|
||||
|
||||
const laneGapStrips = useTimelineGapHighlights({
|
||||
@@ -323,12 +357,21 @@ export const Timeline = memo(function Timeline({
|
||||
setZoomMode,
|
||||
setManualZoomPercent,
|
||||
onSeek,
|
||||
contentOrigin,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
});
|
||||
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
|
||||
useTimelineRazorInteraction({
|
||||
active: activeTool === "razor",
|
||||
scrollRef,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: pps,
|
||||
onSplitAll: onRazorSplitAll,
|
||||
});
|
||||
|
||||
const {
|
||||
rangeSelection,
|
||||
@@ -352,7 +395,9 @@ export const Timeline = memo(function Timeline({
|
||||
setShowPopover,
|
||||
elementsRef: expandedElementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
});
|
||||
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
|
||||
|
||||
@@ -411,13 +456,8 @@ export const Timeline = memo(function Timeline({
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
onMouseMove={(e) => {
|
||||
if (activeTool === "razor" && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setRazorGuideX(null)}
|
||||
onMouseMove={updateRazorGuide}
|
||||
onMouseLeave={clearRazorGuide}
|
||||
style={{
|
||||
touchAction: "pan-x pan-y",
|
||||
background: theme.shellBackground,
|
||||
@@ -435,14 +475,10 @@ export const Timeline = memo(function Timeline({
|
||||
onDragLeave={() => clearDropPreview()}
|
||||
onDrop={handleAssetDrop}
|
||||
onPointerDown={(e) => {
|
||||
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
const x =
|
||||
e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const splitTime = Math.max(0, x / pps);
|
||||
onRazorSplitAll?.(splitTime);
|
||||
return;
|
||||
}
|
||||
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
||||
// their own clicks — scrubbing here would preventDefault and eat them.
|
||||
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
|
||||
if (splitAllAtPointer(e)) return;
|
||||
handlePointerDown(e);
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -453,18 +489,22 @@ export const Timeline = memo(function Timeline({
|
||||
major={major}
|
||||
minor={minor}
|
||||
pps={pps}
|
||||
contentOrigin={contentOrigin}
|
||||
contentGutter={contentGutter}
|
||||
trackContentWidth={displayContentWidth}
|
||||
totalH={totalH}
|
||||
totalH={displayLayout.totalH}
|
||||
effectiveDuration={effectiveDuration}
|
||||
majorTickInterval={majorTickInterval}
|
||||
rangeSelection={rangeSelection}
|
||||
marqueeRect={marqueeRect}
|
||||
laneGapStrips={laneGapStrips}
|
||||
theme={theme}
|
||||
displayTrackOrder={displayTrackOrder}
|
||||
displayTrackOrder={displayLayout.displayTrackOrder}
|
||||
rowHeights={displayLayout.displayRowHeights}
|
||||
trackOrder={trackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={trackStyles}
|
||||
laneCounts={laneCounts}
|
||||
selectedElementId={selectedElementId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
hoveredClip={hoveredClip}
|
||||
@@ -490,43 +530,16 @@ export const Timeline = memo(function Timeline({
|
||||
getPreviewElement={getPreviewElement}
|
||||
getTrackStyle={getTrackStyle}
|
||||
keyframeCache={keyframeCache}
|
||||
gsapAnimations={gsapAnimations}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
beatAnalysis={adjustedBeatAnalysis}
|
||||
onClickKeyframe={(el, pct) => {
|
||||
usePlayerStore.getState().clearSelectedKeyframes();
|
||||
const elKey = el.key ?? el.id;
|
||||
setSelectedElementId(elKey);
|
||||
onSelectElement?.(el);
|
||||
// Select the clicked diamond (matches shift-click); cleared above so this single-selects.
|
||||
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
||||
const absTime = el.start + (pct / 100) * el.duration;
|
||||
onSeek?.(absTime);
|
||||
const kfData = keyframeCache?.get(elKey);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
|
||||
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
||||
}}
|
||||
onShiftClickKeyframe={(elId, pct) => {
|
||||
toggleSelectedKeyframe(`${elId}:${pct}`);
|
||||
}}
|
||||
onSelectSegment={onSelectSegment}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onContextMenuKeyframe={(e, elId, pct) => {
|
||||
const el = expandedElements.find((x) => (x.key ?? x.id) === elId);
|
||||
if (el) {
|
||||
setSelectedElementId(elId);
|
||||
onSelectElement?.(el);
|
||||
}
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
||||
setKfContextMenu({
|
||||
x: e.clientX + 4,
|
||||
y: e.clientY + 2,
|
||||
elementId: elId,
|
||||
percentage: pct,
|
||||
tweenPercentage: kf?.tweenPercentage,
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
}}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onContextMenuClip={(e, el) => {
|
||||
e.preventDefault();
|
||||
setSelectedElementId(el.key ?? el.id);
|
||||
@@ -540,16 +553,7 @@ export const Timeline = memo(function Timeline({
|
||||
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
|
||||
}}
|
||||
/>
|
||||
{activeTool === "razor" && razorGuideX !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
||||
style={{
|
||||
left: razorGuideX,
|
||||
width: 1,
|
||||
background: "rgba(239,68,68,0.7)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
|
||||
</div>
|
||||
<TimelineOverlays
|
||||
theme={theme}
|
||||
@@ -562,9 +566,7 @@ export const Timeline = memo(function Timeline({
|
||||
setKfContextMenu={setKfContextMenu}
|
||||
onDeleteKeyframe={onDeleteKeyframe}
|
||||
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
||||
onChangeKeyframeEase={onChangeKeyframeEase}
|
||||
onMoveKeyframeToPlayhead={onMoveKeyframeToPlayhead}
|
||||
keyframeCache={keyframeCache}
|
||||
clipContextMenu={clipContextMenu}
|
||||
setClipContextMenu={setClipContextMenu}
|
||||
currentTime={currentTime}
|
||||
|
||||
@@ -4,16 +4,15 @@ import { PlayheadIndicator } from "./PlayheadIndicator";
|
||||
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
|
||||
import { getRenderedTimelineElement } from "./timelineTheme";
|
||||
import {
|
||||
GUTTER,
|
||||
TRACK_H,
|
||||
RULER_H,
|
||||
CLIP_Y,
|
||||
TRACKS_TOP_PAD,
|
||||
TRACKS_BOTTOM_PAD,
|
||||
TRACKS_LEFT_PAD,
|
||||
TRACK_H,
|
||||
PLAYHEAD_HEAD_W,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineRowTop,
|
||||
getTimelineRowHeight,
|
||||
} from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { ResizingClipState } from "./useTimelineClipDrag";
|
||||
@@ -45,8 +44,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
|
||||
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
|
||||
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
|
||||
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
|
||||
useTimelineEditContextOptional();
|
||||
const draggedRowIndex =
|
||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
||||
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
||||
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
||||
// ghost and drop placeholder must clamp to it or they stretch to the full
|
||||
// expanded row height and stop matching the clip being dragged.
|
||||
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
|
||||
const {
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
onToggleTrackHidden,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
||||
useTimelineRevealClip(scrollRef);
|
||||
@@ -63,8 +76,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
|
||||
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
|
||||
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
|
||||
const draggedRowIndex =
|
||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||
// Live multi-selection drag: while a selected clip is dragged, ALL selected
|
||||
// clips move together as one rigid formation. The GRABBED clip is the free
|
||||
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
|
||||
@@ -101,7 +112,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
|
||||
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
|
||||
>
|
||||
<TimelineRuler
|
||||
major={props.major}
|
||||
@@ -113,6 +124,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
majorTickInterval={props.majorTickInterval}
|
||||
theme={props.theme}
|
||||
beatAnalysis={props.beatAnalysis}
|
||||
contentOrigin={props.contentOrigin}
|
||||
/>
|
||||
|
||||
{/* Breathing room between the sticky ruler and the first track lane — the
|
||||
@@ -124,6 +136,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
draggedElement={draggedElement}
|
||||
multiDragPreview={multiDragPreview}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onResizeElement={onResizeElement}
|
||||
onMoveElement={onMoveElement}
|
||||
onRazorSplit={onRazorSplit}
|
||||
@@ -148,8 +161,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: getTimelineRowTop(rowIndex) + CLIP_Y,
|
||||
left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps,
|
||||
top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + gap.start * props.pps,
|
||||
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
|
||||
@@ -166,10 +179,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
|
||||
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
|
||||
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
||||
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
height: draggedClipHeight,
|
||||
border: "1px solid rgba(60,230,172,0.55)",
|
||||
background: "rgba(60,230,172,0.12)",
|
||||
borderRadius: 4,
|
||||
@@ -184,8 +197,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
|
||||
left: GUTTER + TRACKS_LEFT_PAD,
|
||||
top: getTimelineRowTop(draggedClip.insertRow, props.rowHeights) - 0.5,
|
||||
left: props.contentOrigin,
|
||||
width: props.trackContentWidth,
|
||||
height: 1,
|
||||
background: "#3CE6AC",
|
||||
@@ -200,7 +213,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
|
||||
left: props.contentOrigin + draggedClip.snapTime * props.pps,
|
||||
top: RULER_H,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
@@ -222,7 +235,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
top: activeDraggedPosition.top,
|
||||
left: activeDraggedPosition.left,
|
||||
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
height: draggedClipHeight,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
@@ -280,8 +293,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left:
|
||||
GUTTER +
|
||||
TRACKS_LEFT_PAD +
|
||||
props.contentOrigin +
|
||||
Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
|
||||
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
|
||||
top: RULER_H,
|
||||
@@ -297,13 +309,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
||||
track the scrub and clutter the beat being moved. Explicit width +
|
||||
the half-head offset baked into getTimelinePlayheadLeft keep the
|
||||
inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
|
||||
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
||||
ticks' center), instead of relying on shrink-wrap sizing. */}
|
||||
<div
|
||||
ref={props.playheadRef}
|
||||
className="absolute top-0 bottom-0 pointer-events-none"
|
||||
style={{
|
||||
left: `${getTimelinePlayheadLeft(0, 0)}px`,
|
||||
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
|
||||
width: PLAYHEAD_HEAD_W,
|
||||
zIndex: 100,
|
||||
display: beatDragging ? "none" : undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@ interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
pps: number;
|
||||
clipY: number;
|
||||
clipHeight?: number;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
@@ -30,6 +31,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
el,
|
||||
pps,
|
||||
clipY,
|
||||
clipHeight,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
@@ -71,7 +73,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
left: leftPx,
|
||||
width: widthPx,
|
||||
top: clipY,
|
||||
bottom: clipY,
|
||||
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
|
||||
borderRadius: theme.clipRadius,
|
||||
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
|
||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -45,6 +46,45 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||
}
|
||||
|
||||
describe("TimelineClipDiamonds", () => {
|
||||
// Dense rows narrow the DIAMOND so neighbours stay individually readable, but
|
||||
// the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
|
||||
// (~7px) at the zoom floor.
|
||||
it("narrows dense keyframe visuals while flooring their hit regions", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [30, 60, 90].map((percentage) => ({
|
||||
percentage,
|
||||
propertyGroup: "position",
|
||||
properties: { x: percentage },
|
||||
})),
|
||||
}}
|
||||
clipWidthPx={36}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button[title]"));
|
||||
expect(diamonds).toHaveLength(3);
|
||||
for (const diamond of diamonds) {
|
||||
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
|
||||
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
|
||||
}
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("treats primary pointerup without drag as a keyframe click", () => {
|
||||
const { host, root, onClickKeyframe } = renderDiamonds();
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
@@ -54,7 +94,57 @@ describe("TimelineClipDiamonds", () => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(
|
||||
"clip-1",
|
||||
expect.objectContaining({ percentage: 50 }),
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// The collapsed clip row and the expanded property lanes read the same cache,
|
||||
// so a keyframe that carries a property group has to hash to the same key in
|
||||
// both — otherwise collapsing a track silently drops the selection.
|
||||
it("keys a grouped keyframe the same way collapsed as expanded", () => {
|
||||
const groupedKeyframe = {
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
};
|
||||
const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const onClickKeyframe = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set([sharedKey])}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
// Highlighted from the shared key alone (the playhead is off-clip here).
|
||||
expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
||||
|
||||
act(() => {
|
||||
diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", {
|
||||
percentage: 50,
|
||||
tweenPercentage: 25,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -84,12 +174,24 @@ describe("TimelineClipDiamonds", () => {
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={5000}
|
||||
@@ -101,6 +203,7 @@ describe("TimelineClipDiamonds", () => {
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -117,7 +220,12 @@ describe("TimelineClipDiamonds", () => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith({
|
||||
percentage: 50,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
});
|
||||
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
@@ -126,20 +234,339 @@ describe("TimelineClipDiamonds", () => {
|
||||
// keyframe) committed the move but never selected/parked on the result —
|
||||
// the diamond it was just dragged looked exactly like one nothing happened
|
||||
// to. Select it at its NEW position too.
|
||||
it("selects the keyframe at its new position after a real drag-retime", () => {
|
||||
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
{
|
||||
percentage: 20,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 40,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 60,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith(
|
||||
{
|
||||
percentage: 40,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
50,
|
||||
);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith({
|
||||
percentage: 50,
|
||||
tweenPercentage: 75,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("leaves the selection alone when a stale retime fails after a newer drag", async () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
let failFirstDrag: (() => void) | undefined;
|
||||
const onMoveKeyframe = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
failFirstDrag = () => resolve(false);
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
|
||||
// Two drags back to back; the first one's commit is still in flight.
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
||||
});
|
||||
onClickKeyframe.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failFirstDrag?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The stale failure must not drag the selection back to the first drag's
|
||||
// source: the second retime, which the user can see, owns it now.
|
||||
expect(onClickKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("composes a rapid second retime from the pending position", () => {
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
|
||||
// The cache still exposes 50%, but this second +10% drag starts at the
|
||||
// pending 75% destination and must therefore land at 85%, not 60%.
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
||||
});
|
||||
|
||||
// The second move must identify the FROM keyframe by the pending (already-
|
||||
// moved) position 75%, not the stale rendered 50%; otherwise the serialized
|
||||
// mutation can't locate the keyframe the first move relocated.
|
||||
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
percentage: 75,
|
||||
tweenPercentage: 75,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
85,
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it.each([
|
||||
["returns false", () => Promise.resolve(false)],
|
||||
["rejects", () => Promise.reject(new Error("retime failed"))],
|
||||
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
||||
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 0 },
|
||||
},
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 100 },
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: 200 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
percentage: 50,
|
||||
tweenPercentage: 50,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
},
|
||||
60,
|
||||
);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("cancels an in-flight retime on Escape without committing or selecting", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
|
||||
{ percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
@@ -154,20 +581,21 @@ describe("TimelineClipDiamonds", () => {
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
||||
);
|
||||
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
||||
// a real retime.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
||||
// Escape ends the gesture: no retime is written, and the release is not
|
||||
// reinterpreted as a click that would park the playhead on the keyframe.
|
||||
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||
expect(onClickKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -212,4 +640,100 @@ describe("TimelineClipDiamonds", () => {
|
||||
expect(suppressClickRef.current).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
const renderSegmentLane = (lastAmbiguous: boolean) => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
||||
percentage,
|
||||
tweenPercentage: percentage,
|
||||
propertyGroup: "position",
|
||||
animationId: "anim-1",
|
||||
properties: { x: percentage },
|
||||
...extra,
|
||||
});
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onSelectSegment={vi.fn()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
it("hides the inline ease button on an ambiguous merged segment", () => {
|
||||
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
||||
// keyframe, so its hover/ease-button area is not rendered.
|
||||
const { host, root } = renderSegmentLane(true);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the inline ease button on unambiguous merged segments", () => {
|
||||
const { host, root } = renderSegmentLane(false);
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the ease button above nearby diamonds without blocking the segment", () => {
|
||||
const { host, root } = renderSegmentLane(false);
|
||||
const segment = host.querySelector<HTMLElement>("[data-keyframe-ease-segment]");
|
||||
const ease = segment?.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
|
||||
expect(segment?.style.pointerEvents).toBe("none");
|
||||
expect(ease?.style.pointerEvents).toBe("auto");
|
||||
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides the inline ease button on a segment with no source animation id", () => {
|
||||
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
||||
// target; the segment ending on it must not render a (dead) ease button.
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const kf = (percentage: number, animationId?: string) => ({
|
||||
percentage,
|
||||
tweenPercentage: percentage,
|
||||
propertyGroup: "position",
|
||||
...(animationId ? { animationId } : {}),
|
||||
properties: { x: percentage },
|
||||
});
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineDiamondLane
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onSelectSegment={vi.fn()}
|
||||
groupAware
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,72 +1,40 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { BEAT_BAND_H } from "./BeatStrip";
|
||||
import {
|
||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
||||
previewClipPct,
|
||||
resolveKeyframeDrag,
|
||||
} from "../../components/editor/keyframeDrag";
|
||||
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
||||
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
||||
import { LANE_H } from "./timelineLayout";
|
||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||
import {
|
||||
DIAMOND_RATIO,
|
||||
KF_MAX_PCT,
|
||||
KF_MIN_PCT,
|
||||
keyframeTarget,
|
||||
type DragState,
|
||||
type TimelineClipDiamondsProps,
|
||||
type TimelineDiamondKeyframe,
|
||||
type TimelineDiamondLaneProps,
|
||||
} from "./timelineDiamondTypes";
|
||||
|
||||
interface KeyframeEntry {
|
||||
percentage: number;
|
||||
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
||||
|
||||
// Floor for a diamond's clickable width. The visual size still narrows to the
|
||||
// neighbour gap so packed diamonds stay individually readable, but the hit box
|
||||
// stops there: at the zoom floor the gap alone left a ~7px target, which is
|
||||
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
|
||||
// below this width; each diamond still owns the half-gap around its own centre.
|
||||
const KF_MIN_HIT_W = 12;
|
||||
|
||||
/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
|
||||
function roundPct(percentage: number): number {
|
||||
return Math.round(percentage * 1000) / 1000;
|
||||
}
|
||||
|
||||
interface KeyframeCacheEntry {
|
||||
format: string;
|
||||
keyframes: KeyframeEntry[];
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
interface TimelineClipDiamondsProps {
|
||||
keyframesData: KeyframeCacheEntry;
|
||||
clipWidthPx: number;
|
||||
clipHeightPx: number;
|
||||
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
||||
* the bottom half so they clear the strip at the top. */
|
||||
beatsActive?: boolean;
|
||||
accentColor: string;
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
elementId: string;
|
||||
selectedKeyframes: Set<string>;
|
||||
onClickKeyframe?: (percentage: number) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
|
||||
* Both percentages are clip-relative: `fromClipPercentage` identifies the
|
||||
* dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
|
||||
* The handler decides move (within the tween) vs resize (past its boundary). */
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||
* toggles selection off when already selected) ignores the native "click"
|
||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||
suppressClickRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
||||
// slightly outside [0, 100] (from rounding or stale cache during the async
|
||||
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
|
||||
// their true position rather than hidden.
|
||||
const KF_MIN_PCT = -5;
|
||||
const KF_MAX_PCT = 105;
|
||||
|
||||
type DragState = {
|
||||
kfKey: string;
|
||||
startX: number;
|
||||
fromClipPct: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
||||
keyframesData,
|
||||
clipWidthPx,
|
||||
clipHeightPx,
|
||||
@@ -80,14 +48,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
onSelectSegment,
|
||||
suppressClickRef,
|
||||
}: TimelineClipDiamondsProps) {
|
||||
groupAware = false,
|
||||
globalEase = "none",
|
||||
}: TimelineDiamondLaneProps) {
|
||||
// Hooks must run before the early return below.
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
// Pending retime destination (clip + tween %) per keyframe key, so a rapid
|
||||
// second drag composes from where the first move left the keyframe (whose
|
||||
// cache entry has not rebuilt yet) instead of the stale rendered value.
|
||||
const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
|
||||
// Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
|
||||
// the first away, once per mounted lane.
|
||||
pendingRetimeRef.current ??= new Map();
|
||||
const pendingRetimes = pendingRetimeRef.current;
|
||||
// The most recent retime dispatched from this lane, whichever diamond it came
|
||||
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
|
||||
// question, not a per-keyframe one.
|
||||
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
|
||||
useEffect(() => {
|
||||
// Clear a pending entry once the authoritative cache reflects THAT keyframe
|
||||
// at ~its destination. Match by tolerance, not equality: cache writers round
|
||||
// clip %s, so an exact check would leak an entry after every successful
|
||||
// retime. Match by identity too: a bare "some keyframe is near that %" test
|
||||
// cleared the entry whenever an unrelated sibling happened to sit there,
|
||||
// which is easy to hit on an evenly spaced row.
|
||||
const pendingEntries = pendingRetimeRef.current;
|
||||
if (!pendingEntries) return;
|
||||
for (const [key, pending] of pendingEntries) {
|
||||
const settled = keyframesData.keyframes.some(
|
||||
(k) =>
|
||||
timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
|
||||
Math.abs(k.percentage - pending.clipPct) < 0.2,
|
||||
);
|
||||
if (settled) pendingEntries.delete(key);
|
||||
}
|
||||
}, [keyframesData.keyframes, elementId]);
|
||||
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
||||
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||
// on drop re-keys the diamond from source.
|
||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
||||
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
|
||||
// than the lane can repaint, and every diamond in the row re-evaluates its
|
||||
// memo on each of those renders.
|
||||
const previewFrameRef = useRef<number | null>(null);
|
||||
const cancelPreviewFrame = () => {
|
||||
if (previewFrameRef.current === null) return;
|
||||
cancelAnimationFrame(previewFrameRef.current);
|
||||
previewFrameRef.current = null;
|
||||
};
|
||||
// Escape backs out of an in-flight retime, the way clip and element drags
|
||||
// already do. Nothing was written yet (the commit happens on pointerup), so
|
||||
// dropping the preview is the whole undo.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
|
||||
dragRef.current.cancelled = true;
|
||||
cancelPreviewFrame();
|
||||
setPreview(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelPreviewFrame();
|
||||
};
|
||||
}, []);
|
||||
// The button element can re-render (reposition/unmount) synchronously from
|
||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||
// browser gets to auto-synthesize the "click" event that normally follows
|
||||
@@ -108,15 +134,67 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
|
||||
// When the beat strip occupies the top band, shrink the diamonds and center
|
||||
// them in the remaining bottom region so they don't collide with it.
|
||||
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
|
||||
const half = diamondSize / 2;
|
||||
// One consistent keyframe-diamond size everywhere (clip bars + property lanes),
|
||||
// matching the property-lane size (LANE_H · ratio). Beat-strip tracks still
|
||||
// shrink to fit under the strip.
|
||||
const diamondSize = beatsActive
|
||||
? Math.round(clipHeightPx * 0.45)
|
||||
: Math.round(LANE_H * DIAMOND_RATIO);
|
||||
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
||||
const sorted = keyframesData.keyframes
|
||||
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
||||
.sort((a, b) => a.percentage - b.percentage);
|
||||
// Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs
|
||||
// the whole row to bound the dragged diamond between its immediate siblings.
|
||||
const sortedClipPcts = sorted.map((k) => k.percentage);
|
||||
// The neighbour clamp bounds a dragged diamond between its immediate siblings
|
||||
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
|
||||
// tween": a merged row interleaves several animations, and two of them
|
||||
// colliding at one percentage would otherwise pin each other's diamonds in
|
||||
// place — the drag clamped back onto its own position and resolved to a click.
|
||||
const siblingRowOf = (keyframe: TimelineDiamondKeyframe) =>
|
||||
keyframe.animationId === undefined
|
||||
? sorted
|
||||
: sorted.filter((k) => k.animationId === keyframe.animationId);
|
||||
// Compose each sibling's pending destination in first: clamping against
|
||||
// cached positions while the dragged keyframe reads its pending one let a
|
||||
// second drag cross a neighbour that had already moved past it. Built once
|
||||
// per render, keyed by tween: every diamond of a row needs the same row, and
|
||||
// rebuilding + re-sorting it inside the marker loop below made this
|
||||
// O(keyframes squared) allocations on every playhead tick.
|
||||
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
||||
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
||||
?.clipPct ?? keyframe.percentage;
|
||||
const siblingRows = new Map<
|
||||
string | undefined,
|
||||
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
||||
>();
|
||||
for (const keyframe of sorted) {
|
||||
if (siblingRows.has(keyframe.animationId)) continue;
|
||||
const row = siblingRowOf(keyframe)
|
||||
.map((k) => ({ keyframe: k, clipPct: pendingClipPctOf(k) }))
|
||||
.sort((a, b) => a.clipPct - b.clipPct);
|
||||
siblingRows.set(keyframe.animationId, {
|
||||
keyframes: row.map((s) => s.keyframe),
|
||||
clipPcts: row.map((s) => s.clipPct),
|
||||
});
|
||||
}
|
||||
const centerXOf = (percentage: number) =>
|
||||
Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
|
||||
// One record per diamond, carrying its own geometry, so the connector and
|
||||
// button passes below read neighbours as values instead of index lookups.
|
||||
const markers = sorted.map((keyframe, index) => {
|
||||
const centerX = centerXOf(keyframe.percentage);
|
||||
const previous = sorted[index - 1];
|
||||
const next = sorted[index + 1];
|
||||
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
|
||||
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
|
||||
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
||||
const gapWidth = Math.min(diamondSize, nearestGap);
|
||||
return {
|
||||
keyframe,
|
||||
centerX,
|
||||
hitWidth: Math.max(KF_MIN_HIT_W, gapWidth),
|
||||
visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2),
|
||||
};
|
||||
});
|
||||
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
||||
const baseOpacity = isSelected ? 0.4 : 0.25;
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
@@ -135,39 +213,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{sorted.map((kf, i) => {
|
||||
if (i === 0) return null;
|
||||
const prev = sorted[i - 1]!;
|
||||
const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
|
||||
const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
|
||||
if (x2 - x1 < 1) return null;
|
||||
return (
|
||||
<div
|
||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: x1,
|
||||
top: centerY,
|
||||
width: x2 - x1,
|
||||
height: 2,
|
||||
transform: "translateY(-1px)",
|
||||
background: baseColor,
|
||||
opacity: baseOpacity,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TimelineDiamondConnectors
|
||||
markers={markers}
|
||||
centerY={centerY}
|
||||
baseColor={baseColor}
|
||||
baseOpacity={baseOpacity}
|
||||
groupAware={groupAware}
|
||||
globalEase={globalEase}
|
||||
keyframeTarget={keyframeTarget}
|
||||
onSelectSegment={onSelectSegment}
|
||||
/>
|
||||
|
||||
{sorted.map((kf, i) => {
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
{markers.map((marker, i) => {
|
||||
const kf = marker.keyframe;
|
||||
const target = keyframeTarget(kf);
|
||||
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
||||
// Clamp against this keyframe's own tween, not the whole merged row.
|
||||
const siblingRow = siblingRows.get(kf.animationId);
|
||||
const siblingClipPcts = siblingRow?.clipPcts ?? [];
|
||||
const siblingIndex = siblingRow?.keyframes.indexOf(kf) ?? -1;
|
||||
// While dragging this diamond, render it at the live preview clip-%.
|
||||
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
||||
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
||||
// is the clip's left edge (the left half overflows, which the
|
||||
// overflow-visible clip shows) — NOT shifted fully inside.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - half;
|
||||
// Center the marker's non-overlapping hit region ON its keyframe %, so
|
||||
// the diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
||||
// The 0% diamond's left half lands in the reserved left gutter (the
|
||||
// content origin is inset past the label column, Figma-style) so it stays
|
||||
// fully visible instead of being clipped by the sticky label column.
|
||||
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
const isHighlighted = isKfSelected || atPlayhead;
|
||||
@@ -181,53 +253,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
dragRef.current = {
|
||||
kfKey,
|
||||
startX: e.clientX,
|
||||
fromClipPct: kf.percentage,
|
||||
lastX: e.clientX,
|
||||
index: siblingIndex,
|
||||
fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.kfKey !== kfKey) return;
|
||||
if (!d || d.kfKey !== kfKey || d.cancelled) return;
|
||||
d.lastX = e.clientX;
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
|
||||
d.moved = true;
|
||||
}
|
||||
if (d.moved) {
|
||||
if (!d.moved || previewFrameRef.current !== null) return;
|
||||
previewFrameRef.current = requestAnimationFrame(() => {
|
||||
previewFrameRef.current = null;
|
||||
const live = dragRef.current;
|
||||
if (!live || live.kfKey !== kfKey || live.cancelled) return;
|
||||
setPreview({
|
||||
kfKey,
|
||||
clipPct: previewClipPct({
|
||||
pointerDownX: d.startX,
|
||||
pointerMoveX: e.clientX,
|
||||
pointerDownX: live.startX,
|
||||
pointerMoveX: live.lastX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
draggedClipPct: live.fromClipPct,
|
||||
draggedIndex: live.index,
|
||||
sortedClipPcts: siblingClipPcts,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (d?.kfKey === kfKey && d.cancelled) {
|
||||
// Escape already ended this drag; the release is not a click.
|
||||
dragRef.current = null;
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
suppressNextClick();
|
||||
return;
|
||||
}
|
||||
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
||||
if (!d || d.kfKey !== kfKey) {
|
||||
if (e.button !== 0) return;
|
||||
suppressNextClick();
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||
else onClickKeyframe?.(target);
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
dragRef.current = null;
|
||||
cancelPreviewFrame();
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
suppressNextClick();
|
||||
// Single-diamond retime by design: a multi-select drag would have to
|
||||
// move every selected keyframe as one mutation, which the script ops
|
||||
// do not express yet. Selecting several and dragging one moves only
|
||||
// the dragged one.
|
||||
const res = resolveKeyframeDrag({
|
||||
pointerDownX: d.startX,
|
||||
pointerUpX: e.clientX,
|
||||
clipWidthPx,
|
||||
draggedClipPct: d.fromClipPct,
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
draggedIndex: siblingIndex,
|
||||
sortedClipPcts: siblingClipPcts,
|
||||
});
|
||||
if (res.kind === "click" || res.kind === "noop") {
|
||||
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
||||
@@ -235,27 +326,92 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
// back onto ~the same position — no real retime, so treat it as the
|
||||
// click it was. Otherwise a normal click with a few px of mouse/
|
||||
// trackpad drift silently does nothing: no selection, no move.
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||
else onClickKeyframe?.(target);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
const animKfs =
|
||||
target.animationId === undefined
|
||||
? keyframesData.keyframes
|
||||
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
|
||||
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
|
||||
// linearly, so a boundary drag past the range would otherwise reselect
|
||||
// an out-of-range tween % (e.g. 150%) even though the mutation clamps
|
||||
// the moved endpoint back to the boundary.
|
||||
const tweenPcts = animKfs
|
||||
.map((k) => k.tweenPercentage)
|
||||
.filter((v): v is number => typeof v === "number");
|
||||
const clampTween = (v: number) =>
|
||||
tweenPcts.length
|
||||
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
|
||||
: v;
|
||||
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
|
||||
// For a rapid second retime the diamond still renders the stale cache
|
||||
// position, so identify the FROM keyframe by the pending (already-moved)
|
||||
// position; the mutation locates the source keyframe by this identity.
|
||||
const pendingBefore = pendingRetimes.get(kfKey);
|
||||
const fromTarget = pendingBefore
|
||||
? {
|
||||
...target,
|
||||
percentage: pendingBefore.clipPct,
|
||||
tweenPercentage: pendingBefore.tweenPct,
|
||||
}
|
||||
: target;
|
||||
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
||||
pendingRetimes.set(kfKey, pending);
|
||||
latestRetimeRef.current = pending;
|
||||
const clearPending = () => {
|
||||
if (pendingRetimes.get(kfKey) === pending) {
|
||||
pendingRetimes.delete(kfKey);
|
||||
}
|
||||
};
|
||||
// A rejected drop (the destination time is already occupied) snaps
|
||||
// the diamond back to its source position, so the pending entry AND
|
||||
// the selection have to revert with it — parking on the ghost drop
|
||||
// position strands the playhead + selection on a keyframe that does
|
||||
// not exist there.
|
||||
const revertRetime = () => {
|
||||
// Only the newest gesture owns the selection. A rejected first drag
|
||||
// whose commit settles after a second one started would otherwise
|
||||
// park the selection back on ITS source keyframe, undoing a retime
|
||||
// the user has already made and moving the playhead with it.
|
||||
const isLatest = latestRetimeRef.current === pending;
|
||||
clearPending();
|
||||
if (isLatest) onClickKeyframe?.(fromTarget);
|
||||
};
|
||||
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
||||
if (!committed) revertRetime();
|
||||
}, revertRetime);
|
||||
// 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
|
||||
// something looks identical to one that silently did nothing.
|
||||
onClickKeyframe?.(res.toClipPct);
|
||||
// something looks identical to one that silently did nothing. Done
|
||||
// optimistically so the gesture stays responsive; revertRetime puts
|
||||
// it back if the move is rejected.
|
||||
onClickKeyframe?.({
|
||||
...target,
|
||||
percentage: res.toClipPct,
|
||||
tweenPercentage: newTweenPct,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${i}-${kf.percentage}`}
|
||||
// Key on the authored identity (tween-%), not the rendered clip-% or
|
||||
// the row index: a clip resize, a neighbour's retime, or a re-sort
|
||||
// changes both of those without changing WHICH keyframe this is, and
|
||||
// a key change remounts the button mid-drag (losing pointer capture).
|
||||
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
|
||||
type="button"
|
||||
className="absolute"
|
||||
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
||||
data-keyframe-percentage={
|
||||
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
||||
}
|
||||
style={{
|
||||
left: leftPx,
|
||||
top: centerY,
|
||||
transform: "translateY(-50%)",
|
||||
width: diamondSize,
|
||||
width: marker.hitWidth,
|
||||
height: diamondSize,
|
||||
zIndex: isHighlighted ? 2 : 1,
|
||||
pointerEvents: "auto",
|
||||
@@ -264,18 +420,37 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
cursor: canDrag ? "ew-resize" : "pointer",
|
||||
padding: 0,
|
||||
touchAction: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={(e) => {
|
||||
// Browser/OS cancellation (or lost capture) ends the drag without a
|
||||
// pointerup, so clear the armed drag and preview or a ghost diamond
|
||||
// stays stuck at the last previewed position.
|
||||
if (dragRef.current?.kfKey !== kfKey) return;
|
||||
dragRef.current = null;
|
||||
cancelPreviewFrame();
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenuKeyframe?.(e, elementId, kf.percentage);
|
||||
onContextMenuKeyframe?.(e, target);
|
||||
}}
|
||||
title={`${kf.percentage}%`}
|
||||
title={`${roundPct(kf.percentage)}%`}
|
||||
>
|
||||
<svg width={diamondSize} height={diamondSize} viewBox="0 0 10 10">
|
||||
<svg
|
||||
width={marker.visualSize}
|
||||
height={marker.visualSize}
|
||||
viewBox="0 0 10 10"
|
||||
style={{ flexShrink: 0, pointerEvents: "none" }}
|
||||
>
|
||||
{isKfSelected && (
|
||||
<path
|
||||
d="M5 0L10 5L5 10L0 5Z"
|
||||
@@ -297,3 +472,31 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
|
||||
props: TimelineClipDiamondsProps,
|
||||
) {
|
||||
return (
|
||||
<TimelineDiamondLane
|
||||
{...props}
|
||||
globalEase={props.keyframesData.ease}
|
||||
onClickKeyframe={(target) => props.onClickKeyframe?.(props.elementId, target)}
|
||||
onShiftClickKeyframe={(target) => props.onShiftClickKeyframe?.(props.elementId, target)}
|
||||
onContextMenuKeyframe={(e, target) =>
|
||||
props.onContextMenuKeyframe?.(e, props.elementId, target)
|
||||
}
|
||||
onMoveKeyframe={
|
||||
props.onMoveKeyframe
|
||||
? (target, toClipPercentage) =>
|
||||
props.onMoveKeyframe?.(props.elementId, target, toClipPercentage) ??
|
||||
Promise.resolve(false)
|
||||
: undefined
|
||||
}
|
||||
onSelectSegment={
|
||||
props.onSelectSegment
|
||||
? (target) => props.onSelectSegment?.(props.elementId, target)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Fragment, useRef } from "react";
|
||||
import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
|
||||
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
||||
|
||||
/** One diamond's geometry within its row, as computed by the lane. */
|
||||
export interface TimelineDiamondMarker {
|
||||
keyframe: TimelineDiamondKeyframe;
|
||||
centerX: number;
|
||||
hitWidth: number;
|
||||
visualSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line between each pair of adjacent diamonds, plus the ease control that
|
||||
* sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
|
||||
* that file inside the repo's file-size cap; it has no state of its own beyond
|
||||
* the press-position guard below.
|
||||
*/
|
||||
export function TimelineDiamondConnectors({
|
||||
markers,
|
||||
centerY,
|
||||
baseColor,
|
||||
baseOpacity,
|
||||
groupAware,
|
||||
globalEase,
|
||||
keyframeTarget,
|
||||
onSelectSegment,
|
||||
}: {
|
||||
markers: readonly TimelineDiamondMarker[];
|
||||
centerY: number;
|
||||
baseColor: string;
|
||||
baseOpacity: number;
|
||||
groupAware: boolean;
|
||||
globalEase: string;
|
||||
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
}) {
|
||||
// The ease button sits dead centre of its segment, which on a two-keyframe clip
|
||||
// is the centre of the clip bar — the natural place to grab a clip and drag it.
|
||||
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
|
||||
// through to the clip (so the drag starts normally) and the button keeps only
|
||||
// the click, which we drop if the pointer actually travelled.
|
||||
const pressXRef = useRef<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map((marker, i) => {
|
||||
const previous = markers[i - 1];
|
||||
if (!previous) return null;
|
||||
const kf = marker.keyframe;
|
||||
const x1 = previous.centerX;
|
||||
const x2 = marker.centerX;
|
||||
if (x2 - x1 < 1) return null;
|
||||
const connectorLeft = x1 + previous.visualSize / 2;
|
||||
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
|
||||
// The ease button targets one segment, so it needs the keyframe's own
|
||||
// animationId/tweenPercentage. On a merged inline row the button is
|
||||
// hidden where the segment is ambiguous (two source animations collide
|
||||
// at this % with different eases; see easeAmbiguous) or the keyframe has
|
||||
// no source animation id (runtime-scanned) so there is no tween to target.
|
||||
const target = keyframeTarget(kf);
|
||||
const ease = kf.ease ?? globalEase;
|
||||
return (
|
||||
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
|
||||
<div
|
||||
className="absolute"
|
||||
data-keyframe-connector={groupAware ? "" : undefined}
|
||||
style={{
|
||||
left: connectorLeft,
|
||||
top: centerY,
|
||||
width: Math.max(0, connectorWidth),
|
||||
height: 2,
|
||||
transform: "translateY(-1px)",
|
||||
background: baseColor,
|
||||
opacity: baseOpacity,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && (
|
||||
<div
|
||||
className="group absolute"
|
||||
data-keyframe-ease-segment=""
|
||||
style={{
|
||||
left: x1,
|
||||
top: centerY,
|
||||
width: x2 - x1,
|
||||
height: 18,
|
||||
transform: "translateY(-50%)",
|
||||
// Own a stacking context above the diamond buttons. At fit
|
||||
// zoom the 16px ease control can overlap its neighbouring
|
||||
// diamond; without a z-index here the later diamond wins the
|
||||
// hit test even though the child button has z-index 3.
|
||||
zIndex: 3,
|
||||
// Only the centered control is interactive. The transparent
|
||||
// segment wrapper must not swallow connector/clip gestures.
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-keyframe-ease-button=""
|
||||
aria-label={`Edit ${ease} easing`}
|
||||
title={`Edit ${ease} easing`}
|
||||
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 3,
|
||||
pointerEvents: "auto",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(255, 255, 255, 0.14)",
|
||||
background: "#171717",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
pressXRef.current = e.clientX;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const pressX = pressXRef.current;
|
||||
pressXRef.current = null;
|
||||
if (
|
||||
pressX !== null &&
|
||||
Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onSelectSegment(target);
|
||||
}}
|
||||
>
|
||||
<MiniCurveSvg ease={ease} active size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
interface TimelineDragGhostProps {
|
||||
element: TimelineElement;
|
||||
position: { left: number; top: number };
|
||||
pps: number;
|
||||
selectedElementId: string | null;
|
||||
hasCustomContent: boolean;
|
||||
theme: TimelineTheme;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TimelineDragGhost({
|
||||
element,
|
||||
position,
|
||||
pps,
|
||||
selectedElementId,
|
||||
hasCustomContent,
|
||||
theme,
|
||||
children,
|
||||
}: TimelineDragGhostProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: Math.max(element.duration * pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...element, start: 0 }}
|
||||
pps={pps}
|
||||
clipY={0}
|
||||
isSelected={selectedElementId === (element.key ?? element.id)}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={hasCustomContent}
|
||||
capabilities={getTimelineEditCapabilities(element)}
|
||||
theme={theme}
|
||||
isComposition={!!element.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{children}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
||||
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
|
||||
import {
|
||||
usePlayerStore,
|
||||
type TimelineElement,
|
||||
@@ -21,9 +25,9 @@ import {
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
||||
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
||||
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
|
||||
/**
|
||||
@@ -34,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren";
|
||||
*/
|
||||
export interface TimelineLaneBaseProps {
|
||||
pps: number;
|
||||
contentOrigin: number;
|
||||
contentGutter: number;
|
||||
trackContentWidth: number;
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: number[];
|
||||
rowHeights: readonly number[];
|
||||
trackOrder: number[];
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
laneCounts: ReadonlyMap<string, number>;
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: Set<string>;
|
||||
hoveredClip: string | null;
|
||||
@@ -69,16 +77,26 @@ export interface TimelineLaneBaseProps {
|
||||
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
||||
getTrackStyle: (tag: string) => TrackVisualStyle;
|
||||
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
||||
gsapAnimations: Map<string, GsapAnimation[]>;
|
||||
selectedKeyframes: Set<string>;
|
||||
currentTime: number;
|
||||
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
target: TimelineKeyframeTarget,
|
||||
) => void;
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
) => Promise<boolean>;
|
||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
/**
|
||||
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
||||
@@ -94,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
draggedElement: TimelineElement | null;
|
||||
multiDragPreview: MultiDragPreviewInput | null;
|
||||
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
||||
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
||||
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
||||
@@ -102,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
|
||||
export function TimelineLanes({
|
||||
pps,
|
||||
contentOrigin,
|
||||
contentGutter,
|
||||
trackContentWidth,
|
||||
theme,
|
||||
displayTrackOrder,
|
||||
rowHeights,
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
hoveredClip,
|
||||
@@ -132,8 +155,11 @@ export function TimelineLanes({
|
||||
getPreviewElement,
|
||||
getTrackStyle,
|
||||
keyframeCache,
|
||||
gsapAnimations,
|
||||
selectedKeyframes,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSelectSegment,
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
@@ -142,11 +168,19 @@ export function TimelineLanes({
|
||||
onContextMenuLane,
|
||||
beatAnalysis,
|
||||
onToggleTrackHidden,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
}: TimelineLanesProps) {
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
||||
const toggleClipExpandedTracked = (key: string) => {
|
||||
const willExpand = !expandedClipIds.has(key);
|
||||
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
||||
toggleClipExpanded(key);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{
|
||||
@@ -156,7 +190,8 @@ export function TimelineLanes({
|
||||
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
|
||||
// in a virtualizer if editorial workflows ever push very high clip counts.
|
||||
// fallow-ignore-next-line complexity
|
||||
displayTrackOrder.map((trackNum) => {
|
||||
displayTrackOrder.map((trackNum, row) => {
|
||||
const rowHeight = getTimelineRowHeight(row, rowHeights);
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
@@ -173,58 +208,50 @@ export function TimelineLanes({
|
||||
: els.some(isMusicTrack));
|
||||
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
||||
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
||||
// The one keyframed element this track shows lanes for (selected, else
|
||||
// most lanes). A track can hold several elements; scoping to one keeps
|
||||
// their keyframes from cramming into a single row.
|
||||
const keyframeClip = STUDIO_KEYFRAMES_ENABLED
|
||||
? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds)
|
||||
: null;
|
||||
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
||||
const keyframeClipExpanded =
|
||||
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
||||
return (
|
||||
<div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: rowHeight,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
<TimelineTrackHeader
|
||||
trackNumber={trackNum}
|
||||
trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
|
||||
contentOrigin={contentOrigin}
|
||||
keyframeClip={keyframeClip}
|
||||
clipCount={els.length}
|
||||
isExpanded={keyframeClipExpanded}
|
||||
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
|
||||
currentTime={currentTime}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
theme={theme}
|
||||
onToggleClipExpanded={() => {
|
||||
if (keyframeClipKey) {
|
||||
toggleClipExpandedTracked(keyframeClipKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAudioTrack && (
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isTrackHidden
|
||||
? "text-[#3CE6AC] hover:text-white"
|
||||
: "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
|
||||
}}
|
||||
>
|
||||
{isTrackHidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Left breathing pad — empty lane surface before t=0, scrolling
|
||||
with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
|
||||
the time-mapped content div so clip/beat/menu math stays
|
||||
content-relative (clip left = t·pps). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0"
|
||||
style={{ width: TRACKS_LEFT_PAD }}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: trackContentWidth,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
marginLeft: contentGutter, // room for a 0% diamond left of t=0
|
||||
opacity: isTrackHidden ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
@@ -282,6 +309,13 @@ export function TimelineLanes({
|
||||
els.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
// Only the track's active keyframe clip shows expanded lanes;
|
||||
// other clips (incl. siblings on a shared track) show compact
|
||||
// diamonds on their own bar instead.
|
||||
const showsLanes =
|
||||
STUDIO_KEYFRAMES_ENABLED &&
|
||||
elementKey === keyframeClipKey &&
|
||||
keyframeClipExpanded;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected =
|
||||
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
||||
@@ -315,6 +349,7 @@ export function TimelineLanes({
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
@@ -457,32 +492,70 @@ export function TimelineLanes({
|
||||
renderClipContent,
|
||||
renderClipOverlay,
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED &&
|
||||
!showsLanes &&
|
||||
keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
||||
beatsActive={beatStripOnTrack}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) /
|
||||
previewElement.duration) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onClickKeyframe={(_elId, target) =>
|
||||
onClickKeyframe?.(previewElement, target)
|
||||
}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onSelectSegment={onSelectSegment}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
if (!isPassenger) return clip;
|
||||
const propertyLanes = showsLanes && (
|
||||
<TimelinePropertyLanes
|
||||
key={`${clipKey}-property-lanes`}
|
||||
animations={gsapAnimations.get(elementKey) ?? []}
|
||||
clipStart={previewElement.start}
|
||||
clipDuration={previewElement.duration}
|
||||
clipLeftPx={previewElement.start * pps}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
|
||||
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
|
||||
onShiftClickKeyframe={(target) =>
|
||||
onShiftClickKeyframe?.(elementKey, target)
|
||||
}
|
||||
onContextMenuKeyframe={(e, target) =>
|
||||
onContextMenuKeyframe?.(e, elementKey, target)
|
||||
}
|
||||
onMoveKeyframe={(target, toClipPercentage) =>
|
||||
onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
|
||||
Promise.resolve(false)
|
||||
}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
);
|
||||
if (!isPassenger) return [clip, propertyLanes];
|
||||
return (
|
||||
<div
|
||||
key={clipKey}
|
||||
@@ -495,6 +568,7 @@ export function TimelineLanes({
|
||||
}}
|
||||
>
|
||||
{clip}
|
||||
{propertyLanes}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineRangeSelection } from "./timelineEditing";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
@@ -38,9 +38,7 @@ interface TimelineOverlaysProps {
|
||||
setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
|
||||
onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
|
||||
onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
|
||||
onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
|
||||
onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
clipContextMenu: ClipContextMenuState | null;
|
||||
setClipContextMenu: (value: ClipContextMenuState | null) => void;
|
||||
currentTime: number;
|
||||
@@ -68,9 +66,7 @@ export function TimelineOverlays({
|
||||
setKfContextMenu,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
onChangeKeyframeEase,
|
||||
onMoveKeyframeToPlayhead,
|
||||
keyframeCache,
|
||||
clipContextMenu,
|
||||
setClipContextMenu,
|
||||
currentTime,
|
||||
@@ -106,21 +102,11 @@ export function TimelineOverlays({
|
||||
<KeyframeDiamondContextMenu
|
||||
state={kfContextMenu}
|
||||
onClose={() => setKfContextMenu(null)}
|
||||
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
||||
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
||||
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
||||
onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
|
||||
onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
|
||||
onMoveToPlayhead={
|
||||
onMoveKeyframeToPlayhead
|
||||
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
|
||||
: undefined
|
||||
onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
|
||||
}
|
||||
onCopyProperties={(elId, pct) => {
|
||||
const kfData = keyframeCache.get(elId);
|
||||
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
||||
if (kf) {
|
||||
void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import {
|
||||
getTimelinePropertyLanes,
|
||||
TimelinePropertyLanes,
|
||||
type TimelinePropertyLanesProps,
|
||||
} from "./TimelinePropertyLanes";
|
||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function animation(
|
||||
id: string,
|
||||
propertyGroup: PropertyGroupName,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
id,
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: {},
|
||||
propertyGroup,
|
||||
keyframes: { format: "percentage", keyframes },
|
||||
};
|
||||
}
|
||||
|
||||
function flatAnimation(
|
||||
id: string,
|
||||
propertyGroup: PropertyGroupName,
|
||||
properties: Record<string, number | string>,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
id,
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties,
|
||||
propertyGroup,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}): {
|
||||
host: HTMLDivElement;
|
||||
root: Root;
|
||||
} {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelinePropertyLanes
|
||||
animations={[]}
|
||||
clipStart={0}
|
||||
clipDuration={1}
|
||||
clipLeftPx={0}
|
||||
clipWidthPx={200}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
function laneDiamonds(host: HTMLElement, group: string): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>(
|
||||
`[data-property-group="${group}"] button[data-keyframe-percentage]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function expectLanePercentages(host: HTMLElement, group: string, percentages: string[]) {
|
||||
expect(laneDiamonds(host, group).map((diamond) => diamond.dataset.keyframePercentage)).toEqual(
|
||||
percentages,
|
||||
);
|
||||
}
|
||||
|
||||
function laneEaseButtons(host: HTMLElement, group: string): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>(
|
||||
`[data-property-group="${group}"] button[data-keyframe-ease-button]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] {
|
||||
return Array.from(
|
||||
host.querySelectorAll<HTMLElement>(
|
||||
`[data-property-group="${group}"] [data-keyframe-ease-segment]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null {
|
||||
return segment.querySelector<HTMLButtonElement>("button[data-keyframe-ease-button]");
|
||||
}
|
||||
|
||||
const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
]);
|
||||
|
||||
describe("TimelinePropertyLanes", () => {
|
||||
it("returns a position lane with synthesized endpoints for a flat tween", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[flatAnimation("position-tween", "position", { x: 420 })],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]?.group).toBe("position");
|
||||
expect(lanes[0]?.keyframes).toEqual([
|
||||
{
|
||||
percentage: 0,
|
||||
tweenPercentage: 0,
|
||||
properties: { x: 0 },
|
||||
propertyGroup: "position",
|
||||
animationId: "position-tween",
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
tweenPercentage: 100,
|
||||
properties: { x: 420 },
|
||||
propertyGroup: "position",
|
||||
animationId: "position-tween",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns both flat and authored keyframe property groups", () => {
|
||||
const lanes = getTimelinePropertyLanes(
|
||||
[
|
||||
flatAnimation("position-tween", "position", { x: 420 }),
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]),
|
||||
],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(lanes.map((lane) => lane.group)).toEqual(["position", "visual"]);
|
||||
expect(lanes.map((lane) => lane.keyframes.map((keyframe) => keyframe.percentage))).toEqual([
|
||||
[0, 100],
|
||||
[0, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders each source property group at its independent keyframe positions", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 100, y: 20 } },
|
||||
{ percentage: 100, properties: { x: 200, y: 40 } },
|
||||
]),
|
||||
animation("visual-tween", "visual", [{ percentage: 25, properties: { opacity: 0.5 } }]),
|
||||
];
|
||||
|
||||
const { host, root } = renderPropertyLanes({ animations });
|
||||
const position = laneDiamonds(host, "position");
|
||||
const visual = laneDiamonds(host, "visual");
|
||||
|
||||
expect(position).toHaveLength(3);
|
||||
expect(visual).toHaveLength(1);
|
||||
// Diamonds are centered on their true keyframe time (0% at -half); the
|
||||
// reserved left gutter (content origin inset, tested at the Timeline level)
|
||||
// keeps the overflowing left half visible rather than clamping it inward.
|
||||
expect(position.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px", "189px"]);
|
||||
expect(visual[0]?.style.left).toBe("39px");
|
||||
expect(
|
||||
host.querySelectorAll('[data-property-group="position"] [data-keyframe-connector]'),
|
||||
).toHaveLength(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]),
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]),
|
||||
];
|
||||
|
||||
const { host, root } = renderPropertyLanes({ animations });
|
||||
|
||||
expectLanePercentages(host, "position", ["0", "100"]);
|
||||
expectLanePercentages(host, "visual", ["0", "100"]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders an authored hold keyframe whose value equals its predecessor", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 10 } },
|
||||
{ percentage: 50, properties: { x: 10 } },
|
||||
{ percentage: 100, properties: { x: 20 } },
|
||||
]),
|
||||
];
|
||||
|
||||
const { host, root } = renderPropertyLanes({ animations });
|
||||
|
||||
expectLanePercentages(host, "position", ["0", "50", "100"]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps Position@50% selection distinct from Opacity@50%", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const animations = [
|
||||
animation("position-tween", "position", [{ percentage: 50, properties: { x: 50 } }]),
|
||||
animation("visual-tween", "visual", [{ percentage: 50, properties: { opacity: 0.5 } }]),
|
||||
];
|
||||
const { host, root } = renderPropertyLanes({ animations, onClickKeyframe });
|
||||
const position = laneDiamonds(host, "position")[0]!;
|
||||
|
||||
act(() => {
|
||||
position.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
|
||||
const target = onClickKeyframe.mock.calls[0]?.[0];
|
||||
expect(target).toEqual({
|
||||
animationId: "position-tween",
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelinePropertyLanes
|
||||
animations={animations}
|
||||
clipStart={0}
|
||||
clipDuration={1}
|
||||
clipLeftPx={0}
|
||||
clipWidthPx={200}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", target)])}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
|
||||
const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
|
||||
expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
|
||||
expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps one accessible midpoint ease button per segment, regardless of selection", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
]),
|
||||
];
|
||||
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
|
||||
|
||||
const segments = laneEaseSegments(host, "position");
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]);
|
||||
expect(laneDiamonds(host, "position")).toHaveLength(3);
|
||||
const buttons = laneEaseButtons(host, "position");
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true);
|
||||
expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(buttons.every((button) => button.classList.contains("focus-visible:opacity-100"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The ease button is available on hover even when the element is NOT selected
|
||||
// (a lane shows for the track's active/primary clip, not only the selected one).
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelinePropertyLanes
|
||||
animations={animations}
|
||||
clipStart={0}
|
||||
clipDuration={1}
|
||||
clipLeftPx={0}
|
||||
clipWidthPx={200}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected={false}
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onSelectSegment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const unselectedSegments = laneEaseSegments(host, "position");
|
||||
expect(unselectedSegments).toHaveLength(2);
|
||||
expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("reveals each segment's button with its destination keyframe ease curve", () => {
|
||||
const animations = [
|
||||
animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 33, properties: { x: 33 }, ease: "none" },
|
||||
{ percentage: 66, properties: { x: 66 }, ease: "power2.out" },
|
||||
{
|
||||
percentage: 100,
|
||||
properties: { x: 100 },
|
||||
ease: "custom(M0,0 C0.1,0.2 0.3,0.9 1,1)",
|
||||
},
|
||||
]),
|
||||
];
|
||||
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
|
||||
|
||||
const segments = laneEaseSegments(host, "position");
|
||||
expect(segments).toHaveLength(3);
|
||||
const paths = segments.map((segment) =>
|
||||
revealEaseButton(segment)?.querySelector("path")?.getAttribute("d"),
|
||||
);
|
||||
expect(paths).toHaveLength(3);
|
||||
expect(new Set(paths).size).toBe(3);
|
||||
// Uniqueness alone passes even when the curves are swapped between segments.
|
||||
// Each segment is labelled with the ease it draws, so pin the ORDER: a
|
||||
// segment carries the ease of the keyframe it arrives at.
|
||||
expect(
|
||||
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
|
||||
).toEqual([
|
||||
"Edit none easing",
|
||||
"Edit power2.out easing",
|
||||
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
|
||||
]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("selects the destination keyframe when a hovered segment's ease button is clicked", () => {
|
||||
const onSelectSegment = vi.fn();
|
||||
const { host, root } = renderPropertyLanes({
|
||||
animations: [POSITION_SEGMENT_ANIMATION],
|
||||
onSelectSegment,
|
||||
});
|
||||
|
||||
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
|
||||
act(() => button?.click());
|
||||
|
||||
expect(onSelectSegment).toHaveBeenCalledWith({
|
||||
animationId: "position-tween",
|
||||
percentage: 50,
|
||||
propertyGroup: "position",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("routes a colliding Position segment to the Position animation", () => {
|
||||
const onSelectSegment = vi.fn();
|
||||
const animations = [
|
||||
POSITION_SEGMENT_ANIMATION,
|
||||
animation("visual-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 50, properties: { opacity: 0.5 } },
|
||||
]),
|
||||
];
|
||||
const { host, root } = renderPropertyLanes({ animations, onSelectSegment });
|
||||
|
||||
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
|
||||
act(() => button?.click());
|
||||
|
||||
expect(onSelectSegment.mock.calls[0]?.[0]).toMatchObject({
|
||||
animationId: "position-tween",
|
||||
propertyGroup: "position",
|
||||
});
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
|
||||
const COLLAPSED_TARGET = { ...COLLAPSED_IDENTITY, percentage: 50, tweenPercentage: 50 };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, ...COLLAPSED_IDENTITY, tweenPercentage: 0, properties: { x: 0 } },
|
||||
{
|
||||
percentage: 50,
|
||||
...COLLAPSED_IDENTITY,
|
||||
tweenPercentage: 50,
|
||||
properties: { x: 100 },
|
||||
},
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={-10}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", COLLAPSED_TARGET)])}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button"));
|
||||
|
||||
// Unified keyframe-diamond size (LANE_H·ratio ≈ 22px, half 11) on collapsed
|
||||
// clips too, so 0% sits at -11px regardless of clip-bar height.
|
||||
expect(diamonds.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px"]);
|
||||
expect(diamonds[1]?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
||||
act(() => {
|
||||
diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
// The whole identity, not just the percentage: objectContaining on the one
|
||||
// field passes even when the animation id / property group / tween-% the
|
||||
// diamond-identity refactor added are dropped on the way out.
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", COLLAPSED_TARGET);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
||||
import {
|
||||
classifyPropertyGroup,
|
||||
type GsapAnimation,
|
||||
type PropertyGroupName,
|
||||
} from "@hyperframes/core/gsap-parser";
|
||||
import { toClipKeyframes } from "../../hooks/gsapShared";
|
||||
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
||||
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
||||
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
|
||||
export interface TimelinePropertyLanesProps {
|
||||
animations: readonly GsapAnimation[];
|
||||
clipStart: number;
|
||||
clipDuration: number;
|
||||
clipLeftPx: number;
|
||||
clipWidthPx: number;
|
||||
accentColor: string;
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
elementId: string;
|
||||
selectedKeyframes: ReadonlySet<string>;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
|
||||
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
||||
suppressClickRef?: RefObject<boolean>;
|
||||
}
|
||||
|
||||
function hasGroupProperty(
|
||||
properties: Record<string, number | string>,
|
||||
group: PropertyGroupName,
|
||||
): boolean {
|
||||
return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group);
|
||||
}
|
||||
|
||||
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
||||
* synthesized for a flat tween. Empty for a tween that animates nothing. */
|
||||
function animationKeyframes(animation: GsapAnimation) {
|
||||
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
||||
}
|
||||
|
||||
/** A tween contributes a property lane when it has a group and at least one
|
||||
* editable keyframe (real or synthesized). */
|
||||
export function animationContributesLane(animation: GsapAnimation): boolean {
|
||||
return !!animation.propertyGroup && animationKeyframes(animation).length > 0;
|
||||
}
|
||||
|
||||
function sourceGroups(animations: readonly GsapAnimation[]) {
|
||||
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
||||
for (const animation of animations) {
|
||||
if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
|
||||
const groupAnimations = groups.get(animation.propertyGroup) ?? [];
|
||||
groupAnimations.push(animation);
|
||||
groups.set(animation.propertyGroup, groupAnimations);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Resolve the ease from THIS keyframe's own source tween. A lane can merge
|
||||
* several tweens, so a shared lane-level fallback would label a segment with a
|
||||
* different animation's ease than the one the ease editor targets (it routes
|
||||
* by animationId). */
|
||||
function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
|
||||
return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
||||
}
|
||||
|
||||
/**
|
||||
* One lane row per keyframe of `group`. The clip-% re-basing goes through the
|
||||
* shared toClipKeyframes so lane rows land on the exact same percentage the
|
||||
* keyframe cache writes: this file used to derive it inline and skipped that
|
||||
* helper's rounding, which is the one precision every keyframe-cache writer has
|
||||
* to agree on (selection keys embed the number).
|
||||
*/
|
||||
function groupKeyframes(
|
||||
animations: readonly GsapAnimation[],
|
||||
group: PropertyGroupName,
|
||||
clipStart: number,
|
||||
clipDuration: number,
|
||||
): TimelineDiamondKeyframe[] {
|
||||
const keyframes: TimelineDiamondKeyframe[] = [];
|
||||
for (const animation of animations) {
|
||||
const inGroup = animationKeyframes(animation).filter((keyframe) =>
|
||||
hasGroupProperty(keyframe.properties, group),
|
||||
);
|
||||
for (const keyframe of toClipKeyframes(inGroup, animation, clipStart, clipDuration)) {
|
||||
keyframes.push({
|
||||
...keyframe,
|
||||
// The LANE's group, not the tween's own classification: a mixed-property
|
||||
// tween classifies to undefined yet still feeds every group it touches.
|
||||
propertyGroup: group,
|
||||
ease: keyframeEase(keyframe, animation),
|
||||
});
|
||||
}
|
||||
}
|
||||
return keyframes;
|
||||
}
|
||||
|
||||
export function getTimelinePropertyLanes(
|
||||
animations: readonly GsapAnimation[],
|
||||
clipStart: number,
|
||||
clipDuration: number,
|
||||
) {
|
||||
if (clipDuration <= 0) return [];
|
||||
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
|
||||
group,
|
||||
animations: groupAnimations,
|
||||
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
|
||||
})).filter((lane) => lane.keyframes.length > 0);
|
||||
}
|
||||
|
||||
export function TimelinePropertyLanes({
|
||||
animations,
|
||||
clipStart,
|
||||
clipDuration,
|
||||
clipLeftPx,
|
||||
clipWidthPx,
|
||||
accentColor,
|
||||
isSelected,
|
||||
currentPercentage,
|
||||
elementId,
|
||||
selectedKeyframes,
|
||||
onSelectSegment,
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
suppressClickRef,
|
||||
}: TimelinePropertyLanesProps) {
|
||||
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
|
||||
// a fresh keyframesData literal per lane) on every render would re-render every
|
||||
// diamond in every expanded clip on each playhead tick.
|
||||
const lanes = useMemo(
|
||||
() =>
|
||||
clipWidthPx < 20 || clipDuration <= 0
|
||||
? []
|
||||
: getTimelinePropertyLanes(animations, clipStart, clipDuration),
|
||||
[animations, clipStart, clipDuration, clipWidthPx],
|
||||
);
|
||||
const laneData = useMemo(
|
||||
() =>
|
||||
lanes.map((lane) => ({
|
||||
...lane,
|
||||
keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
|
||||
})),
|
||||
[lanes],
|
||||
);
|
||||
|
||||
if (laneData.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
||||
<div
|
||||
key={group}
|
||||
role="group"
|
||||
aria-label={`${group} keyframes`}
|
||||
data-property-group={group}
|
||||
data-timeline-property-lane=""
|
||||
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: clipLeftPx,
|
||||
top: getTimelineLaneTop(laneIndex),
|
||||
width: clipWidthPx,
|
||||
height: LANE_H,
|
||||
}}
|
||||
>
|
||||
<TimelineDiamondLane
|
||||
keyframesData={keyframesData}
|
||||
clipWidthPx={clipWidthPx}
|
||||
clipHeightPx={LANE_H}
|
||||
accentColor={accentColor}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={currentPercentage}
|
||||
elementId={elementId}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onSelectSegment={onSelectSegment}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
groupAware
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
|
||||
import { getTimelineContentXFromClient } from "./timelineLayout";
|
||||
|
||||
interface TimelineRazorInteractionOptions {
|
||||
active: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
onSplitAll?: (time: number) => void;
|
||||
}
|
||||
|
||||
export function useTimelineRazorInteraction({
|
||||
active,
|
||||
scrollRef,
|
||||
contentOrigin,
|
||||
pixelsPerSecond,
|
||||
onSplitAll,
|
||||
}: TimelineRazorInteractionOptions) {
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
const updateRazorGuide = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!active || !scroll) return;
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
setRazorGuideX(event.clientX - rect.left + scroll.scrollLeft);
|
||||
},
|
||||
[active, scrollRef],
|
||||
);
|
||||
|
||||
const clearRazorGuide = useCallback(() => setRazorGuideX(null), []);
|
||||
|
||||
const splitAllAtPointer = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!active || !event.shiftKey || event.button !== 0 || !scroll) return false;
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX: event.clientX,
|
||||
rectLeft: rect.left,
|
||||
scrollLeft: scroll.scrollLeft,
|
||||
contentOrigin,
|
||||
});
|
||||
onSplitAll?.(Math.max(0, x / pixelsPerSecond));
|
||||
return true;
|
||||
},
|
||||
[active, contentOrigin, onSplitAll, pixelsPerSecond, scrollRef],
|
||||
);
|
||||
|
||||
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
||||
}
|
||||
|
||||
export function TimelineRazorGuide({ x }: { x: number }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
||||
style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from "react";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { secondsToFrame } from "../lib/time";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
@@ -15,6 +15,7 @@ interface TimelineRulerProps {
|
||||
majorTickInterval: number;
|
||||
theme: TimelineTheme;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
export const TimelineRuler = memo(function TimelineRuler({
|
||||
@@ -27,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
majorTickInterval,
|
||||
theme,
|
||||
beatAnalysis,
|
||||
contentOrigin,
|
||||
}: TimelineRulerProps) {
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
||||
@@ -45,7 +47,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
the ruler's own small ticks mark intervals (no full-height lines). */}
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
|
||||
style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
|
||||
height={totalH}
|
||||
>
|
||||
{showBeats &&
|
||||
@@ -69,35 +71,23 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
</svg>
|
||||
|
||||
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
|
||||
vertically. Opaque background (plus the gutter corner block) so clips
|
||||
vertically. Opaque background (plus the label-column corner block) so clips
|
||||
scrolling underneath don't bleed through; z-index sits above the track
|
||||
rows and drag overlays but below the playhead (z 100). */}
|
||||
<div
|
||||
className="sticky top-0 flex"
|
||||
style={{
|
||||
height: RULER_H,
|
||||
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
|
||||
zIndex: 70,
|
||||
}}
|
||||
style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
|
||||
>
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
// Ruler corner uses the panel surface — same as the ruler strip
|
||||
// itself, and NO right border: the ruler band stays completely
|
||||
// clean until 00:00 (the header-boundary line belongs to the track
|
||||
// rows below, not the ruler).
|
||||
width: contentOrigin,
|
||||
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
||||
background: theme.shellBackground,
|
||||
}}
|
||||
/>
|
||||
{/* Left breathing pad — scrolls with the content, so 00:00 starts a
|
||||
beat right of the gutter (see TRACKS_LEFT_PAD). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0"
|
||||
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
|
||||
/>
|
||||
{/* Breathing pad before 00:00 is folded into contentOrigin (see
|
||||
Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
@@ -110,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
>
|
||||
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
|
||||
t * pps — matching the playhead line, which is also centered on
|
||||
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
a tick spans [x, x+1) and its center is half a pixel right. */}
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { LABEL_COL_W } from "./timelineLayout";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const ELEMENT: TimelineElement = {
|
||||
id: "clip-1",
|
||||
label: "Hero card",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
};
|
||||
|
||||
function animation(
|
||||
id: string,
|
||||
propertyGroup: PropertyGroupName,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
): GsapAnimation {
|
||||
return {
|
||||
id,
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup,
|
||||
keyframes: { format: "percentage", keyframes },
|
||||
};
|
||||
}
|
||||
|
||||
const POSITION = animation("position-tween", "position", [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 100, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 200, y: 100 } },
|
||||
]);
|
||||
|
||||
const OPACITY = animation("opacity-tween", "visual", [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 50, properties: { opacity: 0.5 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
]);
|
||||
|
||||
interface RenderHeaderOptions {
|
||||
animations?: GsapAnimation[];
|
||||
clipCount?: number;
|
||||
currentTime?: number;
|
||||
expanded?: boolean;
|
||||
onSeek?: (time: number) => void;
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
}
|
||||
|
||||
function renderHeader(options: RenderHeaderOptions = {}): {
|
||||
host: HTMLDivElement;
|
||||
root: Root;
|
||||
rerender: (next: RenderHeaderOptions) => void;
|
||||
} {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const render = (next: RenderHeaderOptions) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineTrackHeader
|
||||
trackNumber={0}
|
||||
trackLabel="Hero card"
|
||||
contentOrigin={LABEL_COL_W}
|
||||
keyframeClip={ELEMENT}
|
||||
clipCount={next.clipCount ?? 1}
|
||||
isExpanded={next.expanded !== false}
|
||||
animations={next.animations ?? [POSITION, OPACITY]}
|
||||
currentTime={next.currentTime ?? 0}
|
||||
isTrackHidden={false}
|
||||
isAudioTrack={false}
|
||||
theme={defaultTimelineTheme}
|
||||
onToggleClipExpanded={vi.fn()}
|
||||
onToggleTrackHidden={vi.fn()}
|
||||
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
||||
onSeek={next.onSeek}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
render(options);
|
||||
return { host, root, rerender: render };
|
||||
}
|
||||
|
||||
function click(host: HTMLElement, label: string) {
|
||||
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
|
||||
expect(button).not.toBeNull();
|
||||
act(() => button?.click());
|
||||
}
|
||||
|
||||
describe("TimelineTrackHeader", () => {
|
||||
// The header shows one clip's lanes, so how many clips the track holds is
|
||||
// otherwise invisible from the label column. A single-clip track stays silent.
|
||||
it("shows the track's clip count only once the track holds more than one clip", () => {
|
||||
const view = renderHeader({ clipCount: 1 });
|
||||
expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
|
||||
|
||||
view.rerender({ clipCount: 3 });
|
||||
expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// The eye acts on the layer, so it has to be reachable without a pointer and
|
||||
// in every disclosure state — a hover-gated eye is unusable by keyboard.
|
||||
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
|
||||
const view = renderHeader({ expanded: true });
|
||||
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
|
||||
|
||||
view.rerender({ expanded: false });
|
||||
expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
|
||||
const onTogglePropertyGroupKeyframe = vi.fn();
|
||||
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
|
||||
|
||||
click(view.host, "Add Opacity keyframe");
|
||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||
ELEMENT,
|
||||
expect.objectContaining({
|
||||
animationId: "opacity-tween",
|
||||
propertyGroup: "visual",
|
||||
tweenPercentage: 25,
|
||||
properties: { opacity: 0.25 },
|
||||
remove: false,
|
||||
}),
|
||||
);
|
||||
|
||||
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
|
||||
click(view.host, "Remove Opacity keyframe");
|
||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
|
||||
ELEMENT,
|
||||
expect.objectContaining({
|
||||
animationId: "opacity-tween",
|
||||
propertyGroup: "visual",
|
||||
tweenPercentage: 50,
|
||||
properties: { opacity: 0.5 },
|
||||
remove: true,
|
||||
}),
|
||||
);
|
||||
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
|
||||
ELEMENT,
|
||||
expect.objectContaining({ animationId: "position-tween" }),
|
||||
);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("seeks only to the selected group's adjacent keyframes", () => {
|
||||
const onSeek = vi.fn();
|
||||
const view = renderHeader({
|
||||
currentTime: 1,
|
||||
animations: [
|
||||
POSITION,
|
||||
animation("opacity-tween", "visual", [
|
||||
{ percentage: 25, properties: { opacity: 0.25 } },
|
||||
{ percentage: 50, properties: { opacity: 0.5 } },
|
||||
{ percentage: 75, properties: { opacity: 0.75 } },
|
||||
]),
|
||||
],
|
||||
onSeek,
|
||||
});
|
||||
|
||||
click(view.host, "Next Position keyframe");
|
||||
expect(onSeek).toHaveBeenLastCalledWith(2);
|
||||
click(view.host, "Previous Position keyframe");
|
||||
expect(onSeek).toHaveBeenLastCalledWith(0);
|
||||
expect(onSeek).not.toHaveBeenCalledWith(1.5);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// The lane header sits inside the track row, whose own click handler selects
|
||||
// the track. Every control in the label column has to own its click, or
|
||||
// seeking to a keyframe also reselects whatever is behind the header.
|
||||
it("keeps lane-header control clicks off the ancestor track row", () => {
|
||||
const onAncestorClick = vi.fn();
|
||||
const view = renderHeader({
|
||||
currentTime: 1,
|
||||
onSeek: vi.fn(),
|
||||
onTogglePropertyGroupKeyframe: vi.fn(),
|
||||
});
|
||||
// React 18 delegates from the root container, so an ancestor of it is where
|
||||
// a leaked click actually shows up.
|
||||
document.body.addEventListener("click", onAncestorClick);
|
||||
|
||||
// Every control in the lane's label column, found by row rather than by
|
||||
// label, so a wording change to one button can't silently drop it here.
|
||||
const controls = view.host.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-property-group="position"] button',
|
||||
);
|
||||
expect(controls.length).toBeGreaterThanOrEqual(3);
|
||||
for (const button of controls) {
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
document.body.removeEventListener("click", onAncestorClick);
|
||||
expect(onAncestorClick).not.toHaveBeenCalled();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("fills the toggle diamond exactly at that group's keyframe", () => {
|
||||
const view = renderHeader({ currentTime: 0.5 });
|
||||
const positionToggle = view.host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Add Position keyframe"]',
|
||||
);
|
||||
expect(positionToggle?.textContent).toBe("◇");
|
||||
|
||||
view.rerender({ currentTime: 1 });
|
||||
expect(
|
||||
view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
|
||||
?.textContent,
|
||||
).toBe("◆");
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("updates formatted group values when the playhead moves", () => {
|
||||
const view = renderHeader({ currentTime: 0.5 });
|
||||
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
||||
"50, 25",
|
||||
);
|
||||
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
|
||||
|
||||
view.rerender({ currentTime: 1.5 });
|
||||
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
||||
"150, 75",
|
||||
);
|
||||
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("samples mid-segment values along the segment's ease, not linearly", () => {
|
||||
// GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
|
||||
// runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
|
||||
// the readout is 12.5/6.25 and NOT the linear 50/25.
|
||||
const eased = animation("eased-position", "position", [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
|
||||
{ percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
|
||||
]);
|
||||
const onTogglePropertyGroupKeyframe = vi.fn();
|
||||
const view = renderHeader({
|
||||
animations: [eased],
|
||||
currentTime: 0.5,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
});
|
||||
|
||||
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
|
||||
"12.5, 6.25",
|
||||
);
|
||||
|
||||
// The same sampled value is what an added keyframe gets stamped with, so a
|
||||
// header insert lands on the existing curve instead of deforming it.
|
||||
click(view.host, "Add Position keyframe");
|
||||
expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
|
||||
expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
|
||||
properties: { x: 12.5, y: 6.25 },
|
||||
});
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("disables the previous chevron at or before the group's first keyframe", () => {
|
||||
const view = renderHeader({ currentTime: 0 });
|
||||
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Previous Position keyframe"]',
|
||||
);
|
||||
expect(prevAt0).not.toBeNull();
|
||||
expect(prevAt0?.disabled).toBe(true);
|
||||
|
||||
view.rerender({ currentTime: 1 });
|
||||
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Previous Position keyframe"]',
|
||||
);
|
||||
expect(prevAt1?.disabled).toBe(false);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
|
||||
const view = renderHeader({ expanded: false });
|
||||
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
|
||||
|
||||
const assertAligned = (animations: GsapAnimation[]) => {
|
||||
view.rerender({ animations });
|
||||
const lanesHost = document.createElement("div");
|
||||
document.body.append(lanesHost);
|
||||
const lanesRoot = createRoot(lanesHost);
|
||||
act(() => {
|
||||
lanesRoot.render(
|
||||
<TimelinePropertyLanes
|
||||
animations={animations}
|
||||
clipStart={0}
|
||||
clipDuration={2}
|
||||
clipLeftPx={120}
|
||||
clipWidthPx={200}
|
||||
accentColor="#3CE6AC"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
|
||||
(row) => row.style.top,
|
||||
),
|
||||
).toEqual(
|
||||
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
|
||||
(row) => row.style.top,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
|
||||
(row) => row.style.left,
|
||||
),
|
||||
).toEqual(animations.map(() => "120px"));
|
||||
act(() => lanesRoot.unmount());
|
||||
};
|
||||
|
||||
assertAligned([POSITION]);
|
||||
assertAligned([POSITION, OPACITY]);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import {
|
||||
resolveLaneHeaderState,
|
||||
type KeyframeNavigationState,
|
||||
type TimelinePropertyLane,
|
||||
} from "./trackHeaderLaneState";
|
||||
import { valueReadout } from "./trackHeaderLaneValues";
|
||||
|
||||
interface TimelineTrackHeaderProps {
|
||||
trackNumber: number;
|
||||
trackLabel: string;
|
||||
contentOrigin: number;
|
||||
/** The track's active keyframe clip (selected, else primary) — the one whose
|
||||
* disclosure + property rows this header shows, whether expanded or not. */
|
||||
keyframeClip: TimelineElement | null;
|
||||
/** Clips on this track, so the header can say how many the row holds. */
|
||||
clipCount: number;
|
||||
isExpanded: boolean;
|
||||
animations: readonly GsapAnimation[];
|
||||
currentTime: number;
|
||||
isTrackHidden: boolean;
|
||||
isAudioTrack: boolean;
|
||||
theme: TimelineTheme;
|
||||
onToggleClipExpanded: () => void;
|
||||
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
function VisibilityButton({
|
||||
hidden,
|
||||
trackNumber,
|
||||
visible,
|
||||
onToggle,
|
||||
}: {
|
||||
hidden: boolean;
|
||||
trackNumber: number;
|
||||
visible: boolean;
|
||||
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
}) {
|
||||
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
||||
const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onToggle?.(trackNumber, !hidden);
|
||||
}}
|
||||
>
|
||||
{hidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// The header a track gets when it has no keyframe clip to disclose: label, clip
|
||||
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
||||
function PlainTrackHeader({
|
||||
trackNumber,
|
||||
trackLabel,
|
||||
clipCount,
|
||||
showTrackLabel,
|
||||
isTrackHidden,
|
||||
isAudioTrack,
|
||||
onToggleTrackHidden,
|
||||
}: Pick<
|
||||
TimelineTrackHeaderProps,
|
||||
| "trackNumber"
|
||||
| "trackLabel"
|
||||
| "clipCount"
|
||||
| "isTrackHidden"
|
||||
| "isAudioTrack"
|
||||
| "onToggleTrackHidden"
|
||||
> & { showTrackLabel: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{isAudioTrack && (
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
{showTrackLabel && (
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
|
||||
{trackLabel}
|
||||
</span>
|
||||
)}
|
||||
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
||||
<VisibilityButton
|
||||
hidden={isTrackHidden}
|
||||
trackNumber={trackNumber}
|
||||
visible
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
||||
function PropertyGroupNavigation({
|
||||
navigation,
|
||||
label,
|
||||
expandedElement,
|
||||
onSeek,
|
||||
children,
|
||||
}: {
|
||||
navigation: KeyframeNavigationState;
|
||||
label: string;
|
||||
expandedElement: TimelineElement;
|
||||
onSeek?: (time: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
|
||||
// target is met with a centered transparent ::before overlay instead of a
|
||||
// bigger box; focus-visible matches every other control in this header.
|
||||
const CHEVRON_BUTTON_CLASS =
|
||||
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
|
||||
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
|
||||
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
|
||||
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
|
||||
const seekTo = (keyframe: { percentage: number } | null) => {
|
||||
if (keyframe) {
|
||||
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Previous ${label} keyframe`}
|
||||
disabled={!navigation.prevKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.prevKeyframe);
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Next ${label} keyframe`}
|
||||
disabled={!navigation.nextKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.nextKeyframe);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyGroupHeaderRow({
|
||||
lane,
|
||||
laneIndex,
|
||||
isLastLane,
|
||||
expandedElement,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onSeek,
|
||||
}: {
|
||||
lane: TimelinePropertyLane;
|
||||
laneIndex: number;
|
||||
isLastLane: boolean;
|
||||
expandedElement: TimelineElement;
|
||||
currentTime: number;
|
||||
clipPercentage: number;
|
||||
gutterBackground: string;
|
||||
columnWidth: number;
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onSeek?: (time: number) => void;
|
||||
}) {
|
||||
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
||||
lane,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-property-group={lane.group}
|
||||
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
||||
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
style={{
|
||||
top: getTimelineLaneTop(laneIndex),
|
||||
// The header column narrows to contentOrigin whenever that is under
|
||||
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
|
||||
// readout over the canvas, on top of the clips it is labelling.
|
||||
width: columnWidth,
|
||||
height: LANE_H,
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
|
||||
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute left-1.5 top-0 w-px bg-white/15"
|
||||
style={{ height: isLastLane ? "50%" : "100%" }}
|
||||
/>
|
||||
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
||||
</span>
|
||||
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<PropertyGroupNavigation
|
||||
navigation={navigation}
|
||||
label={label}
|
||||
expandedElement={expandedElement}
|
||||
onSeek={onSeek}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!!navigation.currentKeyframe}
|
||||
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onClick={(event) => {
|
||||
// Same as the disclosure caret and the eye: a control in the label
|
||||
// column owns its click, it does not also hit the track row behind it.
|
||||
event.stopPropagation();
|
||||
if (toggleTarget) {
|
||||
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{navigation.currentKeyframe ? "◆" : "◇"}
|
||||
</button>
|
||||
</PropertyGroupNavigation>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
|
||||
title={valueReadout(lane.group, values)}
|
||||
>
|
||||
{valueReadout(lane.group, values)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineTrackHeader({
|
||||
trackNumber,
|
||||
trackLabel,
|
||||
contentOrigin,
|
||||
keyframeClip,
|
||||
clipCount,
|
||||
isExpanded,
|
||||
animations,
|
||||
currentTime,
|
||||
isTrackHidden,
|
||||
isAudioTrack,
|
||||
theme,
|
||||
onToggleClipExpanded,
|
||||
onToggleTrackHidden,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onSeek,
|
||||
}: TimelineTrackHeaderProps) {
|
||||
const clipPercentage = keyframeClip
|
||||
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
||||
: 0;
|
||||
const lanes = keyframeClip
|
||||
? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
|
||||
: [];
|
||||
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
||||
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
||||
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
||||
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
|
||||
const lanesId = `timeline-lanes-track-${trackNumber}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sticky left-0 z-[12] shrink-0 ${
|
||||
!isKeyframeLayer
|
||||
? showTrackLabel
|
||||
? "flex items-center gap-1 px-1.5 text-white/55"
|
||||
: "flex flex-col items-center justify-center gap-0.5"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
}}
|
||||
>
|
||||
{!keyframeClip || lanes.length === 0 ? (
|
||||
<PlainTrackHeader
|
||||
trackNumber={trackNumber}
|
||||
trackLabel={trackLabel}
|
||||
clipCount={clipCount}
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<LayerDisclosureRow
|
||||
keyframeClip={keyframeClip}
|
||||
clipCount={clipCount}
|
||||
isExpanded={isExpanded}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||
lanesId={lanesId}
|
||||
onToggleClipExpanded={onToggleClipExpanded}
|
||||
>
|
||||
{/* The eye belongs to the LAYER, so it lives on the always-mounted
|
||||
layer row exactly like a plain track's. Hanging it off a lane row
|
||||
(hover-gated, and only while expanded) left a keyframed track with
|
||||
no way to be hidden at all by keyboard, and put the control on a
|
||||
row it does not act on. */}
|
||||
<VisibilityButton
|
||||
hidden={isTrackHidden}
|
||||
trackNumber={trackNumber}
|
||||
visible
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</LayerDisclosureRow>
|
||||
{/* Always mounted so the caret's aria-controls resolves in both states. */}
|
||||
<div id={lanesId}>
|
||||
{isExpanded &&
|
||||
lanes.map((lane, laneIndex) => (
|
||||
<PropertyGroupHeaderRow
|
||||
key={lane.group}
|
||||
lane={lane}
|
||||
laneIndex={laneIndex}
|
||||
isLastLane={laneIndex === lanes.length - 1}
|
||||
expandedElement={keyframeClip}
|
||||
currentTime={currentTime}
|
||||
clipPercentage={clipPercentage}
|
||||
gutterBackground={theme.gutterBackground}
|
||||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* How many clips the track holds, shown next to the track's identity so a
|
||||
* multi-clip track reads as one at a glance. A single-clip track shows nothing:
|
||||
* the clip bar already says "one", and the header stays low-density.
|
||||
*/
|
||||
export function TrackClipCount({ clipCount }: { clipCount: number }) {
|
||||
if (clipCount < 2) return null;
|
||||
return (
|
||||
<span
|
||||
aria-label={`${clipCount} clips`}
|
||||
title={`${clipCount} clips`}
|
||||
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
||||
>
|
||||
{clipCount}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,16 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
|
||||
export interface TimelinePropertyGroupKeyframeToggle {
|
||||
animationId: string;
|
||||
propertyGroup: PropertyGroupName;
|
||||
tweenPercentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
remove: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared callback signatures for timeline editing operations.
|
||||
@@ -62,14 +72,19 @@ export interface TimelineEditCallbacks {
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
|
||||
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAllKeyframes?: (element: TimelineElement) => void;
|
||||
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
||||
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
fromClipPercentage: number,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
onTogglePropertyGroupKeyframe?: (
|
||||
element: TimelineElement,
|
||||
target: TimelinePropertyGroupKeyframeToggle,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -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<string>(),
|
||||
buildSnapTargets: () => [],
|
||||
audioTracks: new Set<number>(),
|
||||
@@ -145,6 +149,49 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
||||
const next = computeDragPreview(drag, originClientX, yForRow(-0.6), ctx());
|
||||
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", () => {
|
||||
|
||||
@@ -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<string>;
|
||||
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,30 @@ 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 row indices, which is why the pointer
|
||||
// and scroll pixels are folded into originRow/currentRow above.
|
||||
const nextMove = resolveTimelineMove(
|
||||
{
|
||||
start: drag.element.start,
|
||||
track: drag.element.track,
|
||||
duration: drag.element.duration,
|
||||
originClientX: drag.originClientX,
|
||||
originClientY: drag.originClientY,
|
||||
originRow,
|
||||
originScrollLeft: drag.originScrollLeft,
|
||||
originScrollTop: drag.originScrollTop,
|
||||
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
||||
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
||||
pixelsPerSecond: pps,
|
||||
trackHeight: TRACK_H,
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The diamond row's data + prop contract, split out of TimelineClipDiamonds so
|
||||
* that file stays under the 600-line studio gate. Types and the shared
|
||||
* keyframe-identity helper live here; the rendering lives there.
|
||||
*/
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
|
||||
export interface TimelineDiamondKeyframe {
|
||||
percentage: number;
|
||||
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
||||
* ease button can't target one): the collapsed row hides the button here. */
|
||||
easeAmbiguous?: boolean;
|
||||
}
|
||||
|
||||
interface KeyframeCacheEntry {
|
||||
format: string;
|
||||
keyframes: TimelineDiamondKeyframe[];
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
export interface TimelineClipDiamondsProps {
|
||||
keyframesData: KeyframeCacheEntry;
|
||||
clipWidthPx: number;
|
||||
clipHeightPx: number;
|
||||
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
||||
* the bottom half so they clear the strip at the top. */
|
||||
beatsActive?: boolean;
|
||||
accentColor: string;
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
elementId: string;
|
||||
selectedKeyframes: ReadonlySet<string>;
|
||||
onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
) => void;
|
||||
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
|
||||
* `keyframe` identifies the dragged keyframe (clip-relative percentage plus
|
||||
* whatever animation identity the row carries); `toClipPercentage` is the
|
||||
* neighbour-clamped drop position, also clip-relative. The handler decides
|
||||
* move (within the tween) vs resize (past its boundary). */
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
toClipPercentage: number,
|
||||
) => Promise<boolean>;
|
||||
/** Open the segment ease editor for the hovered mid-point button — available on
|
||||
* the inline clip row too, not just the expanded lanes. */
|
||||
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||
* toggles selection off when already selected) ignores the native "click"
|
||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||
suppressClickRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
export interface TimelineDiamondLaneProps extends Omit<
|
||||
TimelineClipDiamondsProps,
|
||||
| "onClickKeyframe"
|
||||
| "onShiftClickKeyframe"
|
||||
| "onContextMenuKeyframe"
|
||||
| "onMoveKeyframe"
|
||||
| "onSelectSegment"
|
||||
> {
|
||||
groupAware?: boolean;
|
||||
globalEase?: string;
|
||||
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
||||
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void;
|
||||
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const DIAMOND_RATIO = 0.8;
|
||||
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
||||
// slightly outside [0, 100] (from rounding or stale cache during the async
|
||||
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
|
||||
// their true position rather than hidden.
|
||||
export const KF_MIN_PCT = -5;
|
||||
export const KF_MAX_PCT = 105;
|
||||
|
||||
export type DragState = {
|
||||
kfKey: string;
|
||||
startX: number;
|
||||
fromClipPct: number;
|
||||
moved: boolean;
|
||||
/** Latest pointer x, flushed to the preview once per frame. */
|
||||
lastX: number;
|
||||
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
|
||||
index: number;
|
||||
/** Escape was pressed: the drag is dead, and the pointerup that follows is
|
||||
* swallowed rather than falling through to the click branch. */
|
||||
cancelled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The full identity of a diamond, used by every callback and by the selection
|
||||
* key. Collapsed clip rows and expanded property lanes read the same cache, so
|
||||
* they must hash a shared keyframe to the same key: dropping the group here for
|
||||
* the collapsed row would leave a diamond selected in one view and unselected in
|
||||
* the other, and would strip the animation id the retime/delete mutations use to
|
||||
* pick between two animations that collide at one percentage.
|
||||
*/
|
||||
export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
|
||||
return {
|
||||
percentage: keyframe.percentage,
|
||||
tweenPercentage: keyframe.tweenPercentage,
|
||||
propertyGroup: keyframe.propertyGroup,
|
||||
animationId: keyframe.animationId,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TIMELINE_COMPOSITION_MIME,
|
||||
} from "../../utils/timelineCompositionDrop";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import { resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
@@ -13,6 +13,8 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
rowHeightsRef: RefObject<readonly number[]>;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
type TimelinePlacement = { start: number; track: number };
|
||||
@@ -54,6 +56,8 @@ export function useTimelineAssetDrop({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
contentOrigin,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
@@ -85,10 +89,11 @@ export function useTimelineAssetDrop({
|
||||
rectTop: rect?.top ?? 0,
|
||||
scrollLeft: scroll?.scrollLeft ?? 0,
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
clampStartToDuration: !usePointerStart,
|
||||
trackHeight: TRACK_H,
|
||||
rowHeights: rowHeightsRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
},
|
||||
clientX,
|
||||
@@ -99,7 +104,7 @@ export function useTimelineAssetDrop({
|
||||
track: pointer.track,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
|
||||
);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
|
||||
@@ -27,14 +27,13 @@ describe("resolveTimelineMove", () => {
|
||||
track: 2,
|
||||
duration: 2,
|
||||
originClientX: 100,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
trackOrder: [0, 1, 2, 3, 4],
|
||||
},
|
||||
245,
|
||||
200,
|
||||
0,
|
||||
),
|
||||
).toEqual({ start: 2.7, track: 2 });
|
||||
});
|
||||
@@ -47,14 +46,13 @@ describe("resolveTimelineMove", () => {
|
||||
track: 1,
|
||||
duration: 3,
|
||||
originClientX: 200,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 10,
|
||||
trackOrder: [0, 1, 5, 9],
|
||||
},
|
||||
150,
|
||||
390,
|
||||
190 / 72,
|
||||
),
|
||||
).toEqual({ start: 1.5, track: 9 });
|
||||
});
|
||||
@@ -67,14 +65,13 @@ describe("resolveTimelineMove", () => {
|
||||
track: 0,
|
||||
duration: 4,
|
||||
originClientX: 300,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 6,
|
||||
trackOrder: [0, 10, 20],
|
||||
},
|
||||
-100,
|
||||
-200,
|
||||
-400 / 72,
|
||||
),
|
||||
).toEqual({ start: 0, track: -1 });
|
||||
|
||||
@@ -85,14 +82,13 @@ describe("resolveTimelineMove", () => {
|
||||
track: 10,
|
||||
duration: 4,
|
||||
originClientX: 300,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 6,
|
||||
trackOrder: [0, 10, 20],
|
||||
},
|
||||
500,
|
||||
200,
|
||||
0,
|
||||
),
|
||||
).toEqual({ start: 6, track: 10 });
|
||||
});
|
||||
@@ -105,14 +101,13 @@ describe("resolveTimelineMove", () => {
|
||||
track: 0,
|
||||
duration: 2,
|
||||
originClientX: 100,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
trackOrder: [0, 10, 20],
|
||||
},
|
||||
100,
|
||||
150,
|
||||
-50 / 72,
|
||||
),
|
||||
).toEqual({ start: 1, track: -1 });
|
||||
});
|
||||
@@ -125,19 +120,18 @@ describe("resolveTimelineMove", () => {
|
||||
track: 20,
|
||||
duration: 2,
|
||||
originClientX: 100,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
trackOrder: [0, 10, 20],
|
||||
},
|
||||
100,
|
||||
250,
|
||||
50 / 72,
|
||||
),
|
||||
).toEqual({ start: 1, track: 21 });
|
||||
});
|
||||
|
||||
it("accounts for scroll displacement while dragging", () => {
|
||||
it("accounts for horizontal scroll displacement while dragging", () => {
|
||||
expect(
|
||||
resolveTimelineMove(
|
||||
{
|
||||
@@ -145,18 +139,17 @@ describe("resolveTimelineMove", () => {
|
||||
track: 0,
|
||||
duration: 2,
|
||||
originClientX: 100,
|
||||
originClientY: 200,
|
||||
originRow: 0,
|
||||
originScrollLeft: 0,
|
||||
originScrollTop: 0,
|
||||
currentScrollLeft: 100,
|
||||
currentScrollTop: 144,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
// Vertical scroll never reaches here: the drag preview folds it into the
|
||||
// row index it passes, because rows no longer share one pixel height.
|
||||
trackOrder: [0, 1, 2, 3],
|
||||
},
|
||||
100,
|
||||
200,
|
||||
2,
|
||||
),
|
||||
).toEqual({ start: 2, track: 2 });
|
||||
});
|
||||
@@ -195,9 +188,8 @@ describe("resolveTimelineMove", () => {
|
||||
track: 1,
|
||||
duration: 2,
|
||||
originClientX: 0,
|
||||
originClientY: 0,
|
||||
originRow: 0,
|
||||
pixelsPerSecond: 100,
|
||||
trackHeight: 72,
|
||||
maxStart: 8,
|
||||
trackOrder: [0, 1],
|
||||
layerOrder: layers.map((layer) => layer.id),
|
||||
@@ -206,7 +198,7 @@ describe("resolveTimelineMove", () => {
|
||||
stackingElements,
|
||||
},
|
||||
0,
|
||||
-72,
|
||||
-1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -46,13 +46,11 @@ export interface TimelineMoveInput {
|
||||
track: number;
|
||||
duration: number;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
/** Vertical position as a track-row index, not pixels: rows vary in height. */
|
||||
originRow: number;
|
||||
originScrollLeft?: number;
|
||||
originScrollTop?: number;
|
||||
currentScrollLeft?: number;
|
||||
currentScrollTop?: number;
|
||||
pixelsPerSecond: number;
|
||||
trackHeight: number;
|
||||
maxStart: number;
|
||||
trackOrder: number[];
|
||||
layerOrder?: TimelineLayerId[];
|
||||
@@ -108,7 +106,7 @@ export function resolveTimelineAutoScroll(
|
||||
export function resolveTimelineMove(
|
||||
input: TimelineMoveInput,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
currentRow: number,
|
||||
): {
|
||||
start: number;
|
||||
track: number;
|
||||
@@ -117,11 +115,12 @@ export function resolveTimelineMove(
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
} {
|
||||
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
|
||||
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
|
||||
const deltaTime =
|
||||
(clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
|
||||
const trackDeltaRaw =
|
||||
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
|
||||
// Rows, so vertical scroll and per-row heights are the caller's problem: rows
|
||||
// have varied in height since lanes expand, and a single trackHeight can't
|
||||
// describe them.
|
||||
const trackDeltaRaw = currentRow - input.originRow;
|
||||
const deltaTrack = Math.round(trackDeltaRaw);
|
||||
const nextStart = clamp(
|
||||
roundToCentiseconds(input.start + deltaTime),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
timelineKeyframeSelectionKey,
|
||||
timelineKeyframeTargetFromSelectionKey,
|
||||
} from "./timelineKeyframeIdentity";
|
||||
|
||||
describe("timeline keyframe selection identity", () => {
|
||||
it("round-trips an expanded lane with colon-bearing identities", () => {
|
||||
const key = timelineKeyframeSelectionKey("comp#a:child", {
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child:position",
|
||||
});
|
||||
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child:position",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not confuse an expanded lane whose element id extends the active id", () => {
|
||||
const key = timelineKeyframeSelectionKey("comp#a:child", {
|
||||
percentage: 75,
|
||||
tweenPercentage: 40,
|
||||
propertyGroup: "position",
|
||||
animationId: "child-position",
|
||||
});
|
||||
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull();
|
||||
});
|
||||
|
||||
// Timeline.tsx still writes the collapsed form for clip-lane shift-clicks, and
|
||||
// an element id can itself contain a colon — the split has to be the LAST one.
|
||||
it("splits the collapsed key at the last colon so a colon-bearing id survives", () => {
|
||||
expect(timelineKeyframeTargetFromSelectionKey("a:b", "a:b:40")).toEqual({ percentage: 40 });
|
||||
expect(timelineKeyframeTargetFromSelectionKey("a", "a:b:40")).toBeNull();
|
||||
});
|
||||
|
||||
it("retains the collapsed key fallback and rejects malformed percentages", () => {
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({
|
||||
percentage: 30,
|
||||
});
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull();
|
||||
expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface TimelineKeyframeTarget {
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note the asymmetry: `tweenPercentage` is optional here and defaults to
|
||||
* `percentage`, but the reader treats both slots as authoritative. Callers must
|
||||
* therefore keep the pair consistent — writing a new `tweenPercentage` without
|
||||
* the matching `percentage` hashes two logically-equal selections to different
|
||||
* keys.
|
||||
*/
|
||||
export function timelineKeyframeSelectionKey(
|
||||
elementId: string,
|
||||
target: TimelineKeyframeTarget,
|
||||
): string {
|
||||
if (!target.propertyGroup) return `${elementId}:${target.percentage}`;
|
||||
return JSON.stringify([
|
||||
elementId,
|
||||
target.propertyGroup,
|
||||
target.animationId ?? "",
|
||||
target.percentage,
|
||||
target.tweenPercentage ?? target.percentage,
|
||||
]);
|
||||
}
|
||||
|
||||
export function timelineKeyframeTargetFromSelectionKey(
|
||||
elementId: string,
|
||||
key: string,
|
||||
): TimelineKeyframeTarget | null {
|
||||
if (key.startsWith("[")) {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(decoded) || decoded.length !== 5) return null;
|
||||
const [selectedElementId, propertyGroup, animationId, percentage, tweenPercentage] = decoded;
|
||||
if (
|
||||
selectedElementId !== elementId ||
|
||||
typeof propertyGroup !== "string" ||
|
||||
propertyGroup.length === 0 ||
|
||||
typeof animationId !== "string" ||
|
||||
typeof percentage !== "number" ||
|
||||
!Number.isFinite(percentage) ||
|
||||
typeof tweenPercentage !== "number" ||
|
||||
!Number.isFinite(tweenPercentage)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
propertyGroup,
|
||||
animationId: animationId || undefined,
|
||||
percentage,
|
||||
tweenPercentage,
|
||||
};
|
||||
}
|
||||
|
||||
const separator = key.lastIndexOf(":");
|
||||
if (separator < 0 || key.slice(0, separator) !== elementId) return null;
|
||||
const percentage = Number(key.slice(separator + 1));
|
||||
return Number.isFinite(percentage) ? { percentage } : null;
|
||||
}
|
||||
@@ -1,17 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CLIP_Y,
|
||||
INSERT_BOUNDARY_BAND,
|
||||
getTimelineInsertBoundaryBand,
|
||||
RULER_H,
|
||||
TRACK_H,
|
||||
LANE_H,
|
||||
TRACKS_TOP_PAD,
|
||||
TRACKS_BOTTOM_PAD,
|
||||
GUTTER,
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelineRowTop,
|
||||
getTimelineScrubTime,
|
||||
getTimelineRowFromY,
|
||||
getTimelineRowOffsets,
|
||||
getTimelineCanvasHeight,
|
||||
trackHeights,
|
||||
resolveTimelineAssetDrop,
|
||||
} from "./timelineLayout";
|
||||
|
||||
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
|
||||
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
|
||||
|
||||
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([[], [], []])).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(baseRows(trackCount))).toBe(expectedHeight);
|
||||
});
|
||||
});
|
||||
|
||||
describe("track-area breathing pad y-math", () => {
|
||||
describe("getTimelineRowTop", () => {
|
||||
it("offsets the first lane below the ruler by the top pad", () => {
|
||||
@@ -53,20 +135,16 @@ describe("track-area breathing pad y-math", () => {
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("reserves ruler + top pad + lanes + bottom pad", () => {
|
||||
expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
||||
expect(getTimelineCanvasHeight(3)).toBe(
|
||||
expect(getTimelineCanvasHeight([])).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
||||
expect(getTimelineCanvasHeight(baseRows(3))).toBe(
|
||||
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps a negative track count to zero lanes", () => {
|
||||
expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
|
||||
});
|
||||
|
||||
it("leaves room below the last lane for a drag-into-void new track", () => {
|
||||
// The gap below the final lane must be at least a full track height so a
|
||||
// clip can be dropped there to create a new bottom track.
|
||||
const oneLane = getTimelineCanvasHeight(1);
|
||||
const oneLane = getTimelineCanvasHeight(baseRows(1));
|
||||
const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
|
||||
expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
|
||||
});
|
||||
@@ -78,15 +156,16 @@ describe("track-area breathing pad y-math", () => {
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackHeight: TRACK_H,
|
||||
rowHeights: baseRows(3),
|
||||
trackOrder: [0, 1, 2],
|
||||
};
|
||||
|
||||
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
||||
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
||||
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
|
||||
const clientX = GUTTER + 100; // t = 1s (contentOrigin = GUTTER)
|
||||
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
||||
expect(track).toBe(0);
|
||||
expect(start).toBe(1);
|
||||
@@ -103,5 +182,79 @@ describe("track-area breathing pad y-math", () => {
|
||||
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
||||
expect(track).toBe(3); // max(trackOrder)+1
|
||||
});
|
||||
|
||||
it("keeps a drop in an expanded lane region on that track", () => {
|
||||
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
||||
const clientY = getTimelineRowTop(0, rowHeights) + TRACK_H + LANE_H;
|
||||
const { track } = resolveTimelineAssetDrop({ ...base, rowHeights }, GUTTER, clientY);
|
||||
expect(track).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineScrubTime", () => {
|
||||
const at = (clientX: number, duration = 10) =>
|
||||
getTimelineScrubTime({
|
||||
clientX,
|
||||
viewportLeft: 0,
|
||||
scrollLeft: 0,
|
||||
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
||||
pixelsPerSecond: 100,
|
||||
duration,
|
||||
});
|
||||
const origin = GUTTER + TRACKS_LEFT_PAD;
|
||||
|
||||
it("maps the content origin to t=0", () => {
|
||||
expect(at(origin)).toBe(0);
|
||||
expect(at(origin + 250)).toBe(2.5);
|
||||
});
|
||||
|
||||
// The bug: a pointer left of the origin used to abort the scrub instead of
|
||||
// clamping, so dragging the playhead to the start only worked if a sample
|
||||
// happened to land in the few px before t=0.
|
||||
it("clamps a pointer left of the origin to 0 instead of dropping the scrub", () => {
|
||||
expect(at(origin - 1)).toBe(0);
|
||||
expect(at(origin - 500)).toBe(0);
|
||||
expect(at(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps past the end to the duration", () => {
|
||||
expect(at(origin + 5000)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns 0 for a degenerate zoom or duration", () => {
|
||||
expect(
|
||||
getTimelineScrubTime({
|
||||
clientX: 500,
|
||||
viewportLeft: 0,
|
||||
scrollLeft: 0,
|
||||
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
||||
pixelsPerSecond: 0,
|
||||
duration: 10,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(at(origin + 250, Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The only production hook keeping resolveInsertRow's band aligned with the
|
||||
// rendered clip inset once rows can be taller than TRACK_H. Pinned directly so a
|
||||
// change to CLIP_Y or the invalid-height fallback can't silently drift it.
|
||||
describe("getTimelineInsertBoundaryBand", () => {
|
||||
it("matches the fixed band for a plain track row", () => {
|
||||
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(INSERT_BOUNDARY_BAND);
|
||||
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(CLIP_Y / TRACK_H);
|
||||
});
|
||||
|
||||
it("shrinks as the row grows, so the band stays CLIP_Y pixels tall", () => {
|
||||
const expanded = TRACK_H + 2 * LANE_H;
|
||||
expect(getTimelineInsertBoundaryBand(expanded)).toBeCloseTo(CLIP_Y / expanded, 10);
|
||||
expect(getTimelineInsertBoundaryBand(expanded)).toBeLessThan(INSERT_BOUNDARY_BAND);
|
||||
});
|
||||
|
||||
it("falls back to the plain-track band for a height that is not usable", () => {
|
||||
for (const height of [0, -10, Number.NaN]) {
|
||||
expect(getTimelineInsertBoundaryBand(height)).toBe(INSERT_BOUNDARY_BAND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,26 +3,25 @@ import type { ZoomMode } from "../store/playerStore";
|
||||
|
||||
/* ── Layout constants ──────────────────────────────────────────────── */
|
||||
export const GUTTER = 32;
|
||||
export const LABEL_COL_W = 232;
|
||||
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;
|
||||
|
||||
export function getTimelineLaneTop(laneIndex: number): number {
|
||||
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
@@ -43,6 +42,79 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
|
||||
*/
|
||||
export const TRACKS_LEFT_PAD = 48;
|
||||
|
||||
export 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: TimelineTrackHeightInput,
|
||||
expandedClipIds?: ReadonlySet<string>,
|
||||
): number[] {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized by the rowHeights array identity: a marquee/drag pointer tick calls
|
||||
* getTimelineRowTop once per clip, and each call would otherwise rebuild the
|
||||
* whole cumulative array (O(clips x rows) allocations per tick). rowHeights is
|
||||
* itself memoized upstream (useTimelineTrackLayout), so the identity is stable
|
||||
* for the life of a gesture. Callers must treat the result as read-only.
|
||||
*/
|
||||
const rowOffsetsCache = new WeakMap<readonly number[], number[]>();
|
||||
|
||||
/** Cumulative top offsets, including the final bottom boundary. */
|
||||
export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] {
|
||||
const cached = rowOffsetsCache.get(rowHeights);
|
||||
if (cached) return cached;
|
||||
const offsets = [0];
|
||||
for (const height of rowHeights) {
|
||||
offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height));
|
||||
}
|
||||
rowOffsetsCache.set(rowHeights, offsets);
|
||||
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) {
|
||||
// Deliberately TRACK_H, not the last row's height: rows past the end do not
|
||||
// exist yet, and a row created by dropping there starts unexpanded. The
|
||||
// pre-first-row branch above uses row 0's concrete height instead because
|
||||
// that row DOES exist — the pointer is in the top pad above a real lane.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The y (content-space) of the top edge of track ROW index `row` (0 = first
|
||||
* displayed lane). The single source of truth for row→y — the ruler height plus
|
||||
@@ -50,17 +122,48 @@ 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;
|
||||
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
|
||||
@@ -216,12 +319,16 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
||||
* remaining ruler runs to 1:00.
|
||||
* Manual zoom multiplies this base, so the floor only anchors the default.
|
||||
*/
|
||||
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
||||
export function getTimelineFitPps(
|
||||
viewportWidth: number,
|
||||
effectiveDuration: number,
|
||||
contentOrigin: number,
|
||||
): number {
|
||||
const safeDuration =
|
||||
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
||||
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
|
||||
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
|
||||
return (viewportWidth - contentOrigin - 2) / span;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +341,7 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
|
||||
export function getTimelineDisplayContentWidth(input: {
|
||||
trackContentWidth: number;
|
||||
viewportWidth: number;
|
||||
contentOrigin: number;
|
||||
pps: number;
|
||||
dragGhostEndPx?: number;
|
||||
resizeGhostEndPx?: number;
|
||||
@@ -241,7 +349,7 @@ export function getTimelineDisplayContentWidth(input: {
|
||||
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
||||
return Math.max(
|
||||
input.trackContentWidth,
|
||||
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
|
||||
input.viewportWidth - input.contentOrigin - 2,
|
||||
input.dragGhostEndPx ?? 0,
|
||||
input.resizeGhostEndPx ?? 0,
|
||||
MIN_TIMELINE_EXTENT_S * safePps,
|
||||
@@ -249,6 +357,15 @@ export function getTimelineDisplayContentWidth(input: {
|
||||
}
|
||||
|
||||
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
||||
export function getTimelineContentXFromClient(input: {
|
||||
clientX: number;
|
||||
rectLeft: number;
|
||||
scrollLeft: number;
|
||||
contentOrigin: number;
|
||||
}): number {
|
||||
return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
|
||||
}
|
||||
|
||||
export function shouldAutoScrollTimeline(
|
||||
zoomMode: ZoomMode,
|
||||
scrollWidth: number,
|
||||
@@ -271,7 +388,7 @@ export function getTimelineScrollLeftForZoomTransition(
|
||||
export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
pointerX: number;
|
||||
currentScrollLeft: number;
|
||||
gutter: number;
|
||||
contentOrigin: number;
|
||||
currentPixelsPerSecond: number;
|
||||
nextPixelsPerSecond: number;
|
||||
duration: number;
|
||||
@@ -288,9 +405,17 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
) {
|
||||
return Math.max(0, input.currentScrollLeft);
|
||||
}
|
||||
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
||||
const timelineX = Math.max(
|
||||
0,
|
||||
getTimelineContentXFromClient({
|
||||
clientX: input.pointerX,
|
||||
rectLeft: 0,
|
||||
scrollLeft: input.currentScrollLeft,
|
||||
contentOrigin: input.contentOrigin,
|
||||
}),
|
||||
);
|
||||
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
||||
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
||||
return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
|
||||
}
|
||||
|
||||
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
||||
@@ -299,33 +424,62 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
||||
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
||||
* shifted LEFT by half this width for the line's center to land exactly on
|
||||
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
||||
* `contentOrigin + time * pps` — see {@link getTimelinePlayheadLeft}.
|
||||
*/
|
||||
export const PLAYHEAD_HEAD_W = 9;
|
||||
|
||||
/**
|
||||
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
||||
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
||||
* sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
|
||||
* on) at every zoom level. Without the half-head offset the line sat
|
||||
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
||||
*/
|
||||
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
||||
export function getTimelinePlayheadLeft(
|
||||
time: number,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
): number {
|
||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
||||
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
|
||||
return contentOrigin - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
return (
|
||||
GUTTER +
|
||||
TRACKS_LEFT_PAD +
|
||||
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
|
||||
PLAYHEAD_HEAD_W / 2
|
||||
);
|
||||
return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(trackCount: number): number {
|
||||
/**
|
||||
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
|
||||
* clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
|
||||
* `contentOrigin` px right of the viewport edge, so any pointer left of t=0
|
||||
* maps to a negative offset. Callers used to bail on that instead of
|
||||
* clamping, which made the last 80px of the drag to zero silently do nothing:
|
||||
* the playhead stuck wherever the last in-range sample landed, and only a very
|
||||
* slow drag that happened to sample inside the sliver before the origin reached
|
||||
* 0. Every scrub path shares this so they cannot diverge on the edge again.
|
||||
*/
|
||||
export function getTimelineScrubTime(input: {
|
||||
clientX: number;
|
||||
viewportLeft: number;
|
||||
scrollLeft: number;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
|
||||
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX,
|
||||
rectLeft: viewportLeft,
|
||||
scrollLeft,
|
||||
contentOrigin,
|
||||
});
|
||||
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(rowHeights: 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 rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0;
|
||||
return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD;
|
||||
}
|
||||
|
||||
/* ── UI helpers ───────────────────────────────────────────────────── */
|
||||
@@ -381,16 +535,22 @@ export function resolveTimelineAssetDrop(
|
||||
rectTop: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
clampStartToDuration?: boolean;
|
||||
trackHeight: number;
|
||||
rowHeights?: readonly number[];
|
||||
trackOrder: number[];
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { start: number; track: number } {
|
||||
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX,
|
||||
rectLeft: input.rectLeft,
|
||||
scrollLeft: input.scrollLeft,
|
||||
contentOrigin: input.contentOrigin,
|
||||
});
|
||||
const contentY = clientY - input.rectTop + input.scrollTop;
|
||||
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
||||
const start = Math.max(
|
||||
@@ -400,7 +560,7 @@ export function resolveTimelineAssetDrop(
|
||||
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
||||
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
||||
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
||||
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
|
||||
const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
|
||||
return {
|
||||
start,
|
||||
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
GUTTER,
|
||||
TRACK_H,
|
||||
RULER_H,
|
||||
CLIP_Y,
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelineRowTop,
|
||||
} from "./timelineLayout";
|
||||
import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
|
||||
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
||||
|
||||
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
||||
@@ -68,22 +61,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<MarqueeClipInput, "start" | "duration" | "track">,
|
||||
trackOrder: number[],
|
||||
pps: number,
|
||||
contentOrigin: number,
|
||||
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 +98,21 @@ export function computeMarqueeSelection(input: {
|
||||
clips: MarqueeClipInput[];
|
||||
trackOrder: number[];
|
||||
pps: number;
|
||||
contentOrigin: number;
|
||||
marquee: Rect;
|
||||
baseSelection?: Iterable<string>;
|
||||
rowHeights?: readonly number[];
|
||||
}): MarqueeSelectionResult {
|
||||
const ids = new Set<string>(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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user