mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
Merge pull request #2791 from heygen-com/codex/studio-timeline-b-expanded-lanes-v2
feat(studio): wire expanded keyframe timeline lanes
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user