mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): consolidate timeline editor callbacks
This commit is contained in:
@@ -401,6 +401,7 @@ export function StudioApp() {
|
||||
panelLayout.rightInspectorPanes,
|
||||
panelLayout.rightCollapsed,
|
||||
isPlaying,
|
||||
domEditSession.domEditSelection,
|
||||
gestureState === "recording",
|
||||
);
|
||||
useStudioUrlState({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 @@ 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,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
animations: GsapAnimation[];
|
||||
@@ -21,41 +22,14 @@ 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);
|
||||
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
|
||||
|
||||
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={() => setFocusedEaseSegment(null)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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,36 @@ 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);
|
||||
// Identity of the element THIS panel actually renders (not the store's
|
||||
// selectedElementId, which flips synchronously on selection while the panel
|
||||
// still renders the previous element during async DOM-selection resolution):
|
||||
// a stale panel would otherwise consume a focus request meant for its
|
||||
// successor when both share a class-selector animation id.
|
||||
const renderedElementId = `${element.sourceFile}#${element.id}`;
|
||||
useEffect(() => {
|
||||
if (!focusedEaseSegment || focusedEaseSegment.elementId !== renderedElementId) return;
|
||||
if (gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId)) {
|
||||
setOpenGroupId("motion");
|
||||
}
|
||||
}, [focusedEaseSegment, gsapAnimations, renderedElementId]);
|
||||
|
||||
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
|
||||
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const panelBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -492,6 +522,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">
|
||||
@@ -519,17 +560,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">
|
||||
@@ -548,17 +579,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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,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 +63,95 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ 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,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
@@ -135,15 +136,18 @@ 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);
|
||||
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
|
||||
// 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={() => setFocusedEaseSegment(null)}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,306 @@
|
||||
// @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(),
|
||||
handleGsapResizeKeyframedTween: vi.fn(),
|
||||
handleGsapUpdateMeta: vi.fn(),
|
||||
handleGsapAddKeyframe: vi.fn(),
|
||||
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleGsapConvertToKeyframes: vi.fn(),
|
||||
handleGsapRemoveAllKeyframes: vi.fn(),
|
||||
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()) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.animations = [flatAnimation];
|
||||
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
|
||||
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("safely no-ops a boundary drag while the tween is still flat", () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
||||
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("deletes a non-selected element flat boundary 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([["scenes/main.html#circle", [otherFlatAnimation]]]),
|
||||
});
|
||||
const view = renderCallbacks();
|
||||
|
||||
await act(async () => {
|
||||
view.callbacks.onDeleteKeyframe?.(
|
||||
"scenes/main.html#circle",
|
||||
0,
|
||||
"position",
|
||||
0,
|
||||
otherFlatAnimation.id,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Persisted through the CLICKED element's own selection, not the current one.
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
|
||||
otherFlatAnimation.id,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).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",
|
||||
100,
|
||||
"position",
|
||||
100,
|
||||
otherKeyframedAnimation.id,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
|
||||
otherKeyframedAnimation.id,
|
||||
100,
|
||||
undefined,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps selected-element flat boundary deletion on the animation delete path", () => {
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id);
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("routes the flat lane-header remove toggle through the guarded delete 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.handleGsapDeleteAnimation).toHaveBeenCalledWith(
|
||||
flatAnimation.id,
|
||||
mocks.selection,
|
||||
);
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).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", 50, "position", 50, flatAnimation.id);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50);
|
||||
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("keeps an authored interior drag on the per-keyframe move path", () => {
|
||||
mocks.animations = [authoredInteriorAnimation()];
|
||||
const view = renderCallbacks();
|
||||
|
||||
act(() => {
|
||||
view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id);
|
||||
});
|
||||
|
||||
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75);
|
||||
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";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
||||
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
||||
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
||||
import type { DomEditSelection } from "../editor/domEditingTypes";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
|
||||
export interface TimelineEditCallbackDeps {
|
||||
@@ -46,15 +48,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 +64,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,24 +107,76 @@ export function useTimelineEditCallbacks({
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleGsapDeleteAnimation,
|
||||
buildDomSelectionForTimelineElement,
|
||||
} = useDomEditActionsContext();
|
||||
|
||||
const resolveElementAnimations = useCallback(
|
||||
(elementKey: string): GsapAnimation[] => {
|
||||
const { gsapAnimations } = usePlayerStore.getState();
|
||||
const hashIndex = elementKey.lastIndexOf("#");
|
||||
const elementId = hashIndex === -1 ? elementKey : elementKey.slice(hashIndex + 1);
|
||||
const sourceFile =
|
||||
hashIndex === -1 ? (activeCompPath ?? "index.html") : elementKey.slice(0, hashIndex);
|
||||
return (
|
||||
gsapAnimations.get(`${sourceFile}#${elementId}`) ??
|
||||
gsapAnimations.get(`index.html#${elementId}`) ??
|
||||
gsapAnimations.get(elementId) ??
|
||||
[]
|
||||
);
|
||||
},
|
||||
[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 => {
|
||||
(
|
||||
pct: number,
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
animations: GsapAnimation[] = selectedGsapAnimations,
|
||||
): { animId: string; tweenPct: number } | null => {
|
||||
const explicitTarget =
|
||||
propertyGroup !== undefined || tweenPercentage !== undefined || animationId !== undefined
|
||||
? [{ percentage: pct, propertyGroup, tweenPercentage, animationId }]
|
||||
: undefined;
|
||||
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
||||
return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations);
|
||||
return resolveTimelineKeyframeTarget(
|
||||
pct,
|
||||
explicitTarget ?? cached?.keyframes ?? [],
|
||||
animations,
|
||||
);
|
||||
},
|
||||
[domEditSelection?.id, selectedGsapAnimations],
|
||||
);
|
||||
|
||||
const removeKeyframeTarget = useCallback(
|
||||
(
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
animations: GsapAnimation[],
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const animation = animations.find((candidate) => candidate.id === animationId);
|
||||
if (animation && !animation.keyframes) {
|
||||
if (selectionOverride === undefined) handleGsapDeleteAnimation(animationId);
|
||||
else handleGsapDeleteAnimation(animationId, selectionOverride);
|
||||
return;
|
||||
}
|
||||
if (selectionOverride === undefined) handleGsapRemoveKeyframe(animationId, percentage);
|
||||
else handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride);
|
||||
},
|
||||
[handleGsapDeleteAnimation, handleGsapRemoveKeyframe],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onMoveElement: handleTimelineElementMove,
|
||||
@@ -135,13 +196,25 @@ export function useTimelineEditCallbacks({
|
||||
if (!anim) return;
|
||||
handleGsapRemoveAllKeyframes(anim.id);
|
||||
},
|
||||
onDeleteKeyframe: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
|
||||
onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => {
|
||||
const animations = resolveElementAnimations(elId);
|
||||
const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations);
|
||||
if (!target) return;
|
||||
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
|
||||
if (!element) {
|
||||
removeKeyframeTarget(target.animId, target.tweenPct, animations);
|
||||
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) => {
|
||||
removeKeyframeTarget(target.animId, target.tweenPct, animations, selection);
|
||||
});
|
||||
},
|
||||
// Retime the keyframe to the playhead, preserving its value + ease.
|
||||
onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
|
||||
const target = resolveKeyframeTarget(pct);
|
||||
onMoveKeyframeToPlayhead: (_elId, pct, group, tweenPct, animationId) => {
|
||||
const target = resolveKeyframeTarget(pct, group, tweenPct, animationId);
|
||||
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
|
||||
},
|
||||
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
|
||||
@@ -152,13 +225,17 @@ 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: async (_elId: string, fromClipPct: number, toClipPct: number) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct);
|
||||
onMoveKeyframe: async (_elId, fromClipPct, toClipPct, group, tweenPct, animationId) => {
|
||||
const target = resolveKeyframeTarget(fromClipPct, group, tweenPct, animationId);
|
||||
const sel = domEditSelection;
|
||||
if (!target || !sel) return false;
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
|
||||
const tweenStart = anim ? resolveTweenStart(anim) : null;
|
||||
if (!anim || tweenStart === null) return false;
|
||||
// Synthesized flat endpoints are clip boundaries, not authored keyframes.
|
||||
// Boundary-to-clip resize wiring is intentionally deferred; ignore the
|
||||
// drag rather than dispatching a free keyframe move that cannot be written.
|
||||
if (!anim.keyframes) return false;
|
||||
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
|
||||
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
@@ -230,6 +307,26 @@ export function useTimelineEditCallbacks({
|
||||
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,
|
||||
selectedGsapAnimations,
|
||||
selection,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await handleGsapAddKeyframeBatch(
|
||||
target.animationId,
|
||||
target.tweenPercentage,
|
||||
target.properties,
|
||||
undefined,
|
||||
selection,
|
||||
);
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
@@ -243,14 +340,16 @@ export function useTimelineEditCallbacks({
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
resolveElementAnimations,
|
||||
resolveKeyframeTarget,
|
||||
removeKeyframeTarget,
|
||||
selectedGsapAnimations,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapMoveKeyframeToPlayhead,
|
||||
handleGsapMoveKeyframe,
|
||||
handleGsapResizeKeyframedTween,
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapAddKeyframeBatch,
|
||||
handleGsapConvertToKeyframes,
|
||||
buildDomSelectionForTimelineElement,
|
||||
projectId,
|
||||
|
||||
@@ -44,6 +44,7 @@ export function TimelineEditProvider({
|
||||
value.onMoveKeyframeToPlayhead,
|
||||
value.onMoveKeyframe,
|
||||
value.onToggleKeyframeAtPlayhead,
|
||||
value.onTogglePropertyGroupKeyframe,
|
||||
],
|
||||
);
|
||||
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
|
||||
|
||||
@@ -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,9 @@ 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.
|
||||
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 +106,14 @@ export function useInspectorState(
|
||||
!isPlaying &&
|
||||
!isGestureRecording,
|
||||
};
|
||||
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
|
||||
}, [
|
||||
rightPanelTab,
|
||||
rightInspectorPanes,
|
||||
rightCollapsed,
|
||||
isPlaying,
|
||||
isGestureRecording,
|
||||
domEditSelection,
|
||||
]);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
Reference in New Issue
Block a user