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:
Miguel Ángel
2026-07-28 02:37:23 +02:00
committed by GitHub
125 changed files with 9343 additions and 2026 deletions
@@ -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
View File
@@ -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 }),
});
}
}
+2 -2
View File
@@ -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;
+109 -1
View File
@@ -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();
});
});
+142 -17
View File
@@ -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();
});
});
+61 -14
View File
@@ -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() {
+116 -56
View File
@@ -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";
+30 -13
View File
@@ -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"]);
});
});
+63 -177
View File
@@ -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>',
+43 -32
View File
@@ -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