mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): scope timeline ease focus lifecycle (#2710)
This commit is contained in:
@@ -401,8 +401,8 @@ export function StudioRightPanel({
|
||||
onUpdateArcSegment={handleUpdateArcSegment}
|
||||
onUnroll={handleUnroll}
|
||||
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
||||
onUpdateSegmentEase={handleUpdateSegmentEase}
|
||||
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
||||
recordingState={recordingState}
|
||||
recordingDuration={recordingDuration}
|
||||
onToggleRecording={onToggleRecording}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
|
||||
interface GsapAnimationListProps extends GsapAnimationEditCallbacks {
|
||||
elementId: string;
|
||||
animations: GsapAnimation[];
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
variant: "classic" | "flat";
|
||||
}
|
||||
|
||||
/** Shared animation cards, telemetry, and timeline-ease focus ownership for both inspectors. */
|
||||
export function GsapAnimationList({
|
||||
elementId,
|
||||
animations,
|
||||
onAddAnimation,
|
||||
variant,
|
||||
...callbacks
|
||||
}: GsapAnimationListProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch, selectedElementId } =
|
||||
usePlayerStore(
|
||||
useShallow((state) => ({
|
||||
focusedEaseSegment: state.focusedEaseSegment,
|
||||
timelineProjectId: state.timelineProjectId,
|
||||
timelineSessionEpoch: state.timelineSessionEpoch,
|
||||
selectedElementId: state.selectedElementId,
|
||||
})),
|
||||
);
|
||||
const focusedHere =
|
||||
focusedEaseSegment &&
|
||||
focusedEaseSegment.elementId === elementId &&
|
||||
isFocusedEaseRequestCurrent(focusedEaseSegment, {
|
||||
timelineProjectId,
|
||||
timelineSessionEpoch,
|
||||
selectedElementId,
|
||||
}) &&
|
||||
animations.some((animation) => animation.id === focusedEaseSegment.animationId)
|
||||
? focusedEaseSegment
|
||||
: null;
|
||||
// Stable while the request is unchanged: AnimationCard includes this callback
|
||||
// in its focus-effect deps, and a fresh closure would replay that effect on
|
||||
// unrelated inspector renders. Reading the action lazily also avoids a store
|
||||
// subscription for a function whose identity never changes.
|
||||
const consumeFocusedEaseSegment = useCallback(() => {
|
||||
if (focusedHere) {
|
||||
usePlayerStore.getState().clearFocusedEaseSegment(focusedHere.nonce);
|
||||
}
|
||||
}, [focusedHere]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{animations.map((animation, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={animation.id}
|
||||
animation={animation}
|
||||
defaultExpanded={index === 0}
|
||||
flat={variant === "flat"}
|
||||
focusedSegment={focusedHere?.animationId === animation.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={consumeFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant={variant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { GsapAnimationSection } from "./GsapAnimationSection";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const animationCardMock = vi.hoisted(() => ({
|
||||
consumers: [] as Array<{ tweenPercentage: number | null; consume: () => void }>,
|
||||
}));
|
||||
|
||||
vi.mock("./AnimationCard", () => ({
|
||||
AnimationCard: ({
|
||||
animation,
|
||||
@@ -19,22 +23,27 @@ vi.mock("./AnimationCard", () => ({
|
||||
animation: GsapAnimation;
|
||||
focusedSegment: { tweenPercentage: number } | null;
|
||||
onFocusSegmentConsumed: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`animation-${animation.id}`}
|
||||
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
|
||||
// Mirrors the real card: the consume callback only fires from the effect
|
||||
// that runs when this card actually received a focusedSegment.
|
||||
onClick={() => focusedSegment && onFocusSegmentConsumed()}
|
||||
/>
|
||||
),
|
||||
}) => {
|
||||
animationCardMock.consumers.push({
|
||||
tweenPercentage: focusedSegment?.tweenPercentage ?? null,
|
||||
consume: onFocusSegmentConsumed,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`animation-${animation.id}`}
|
||||
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
|
||||
onClick={onFocusSegmentConsumed}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
animationCardMock.consumers = [];
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -76,8 +85,22 @@ function renderSection(elementId: string) {
|
||||
return { host, root, render };
|
||||
}
|
||||
|
||||
function focusSharedAnimation(elementId: string, tweenPercentage: number) {
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setSelectedElementId(elementId);
|
||||
store.setFocusedEaseSegment({
|
||||
elementId,
|
||||
animationId: sharedAnimation.id,
|
||||
tweenPercentage,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("GsapAnimationSection", () => {
|
||||
it("consumes a shared animation id only for the focused element", () => {
|
||||
usePlayerStore.getState().beginTimelineSession("project-a");
|
||||
usePlayerStore.getState().setSelectedElementId("index.html#second");
|
||||
usePlayerStore.getState().setFocusedEaseSegment({
|
||||
elementId: "index.html#second",
|
||||
animationId: sharedAnimation.id,
|
||||
@@ -99,4 +122,70 @@ describe("GsapAnimationSection", () => {
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("does not let an older consumer clear a newer request", () => {
|
||||
const store = focusSharedAnimation("index.html#first", 25);
|
||||
const view = renderSection("index.html#first");
|
||||
const staleConsumer = animationCardMock.consumers.at(-1)?.consume;
|
||||
if (!staleConsumer) throw new Error("expected first focus consumer");
|
||||
|
||||
act(() => {
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#first",
|
||||
animationId: sharedAnimation.id,
|
||||
tweenPercentage: 75,
|
||||
});
|
||||
});
|
||||
const current = usePlayerStore.getState().focusedEaseSegment;
|
||||
if (!current) throw new Error("expected replacement request");
|
||||
|
||||
act(() => staleConsumer());
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBe(current);
|
||||
|
||||
const currentConsumer = animationCardMock.consumers.at(-1)?.consume;
|
||||
if (!currentConsumer) throw new Error("expected replacement focus consumer");
|
||||
act(() => currentConsumer());
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the focus consumer stable across unrelated parent renders", () => {
|
||||
focusSharedAnimation("index.html#first", 25);
|
||||
const view = renderSection("index.html#first");
|
||||
const firstConsumer = animationCardMock.consumers.at(-1)?.consume;
|
||||
if (!firstConsumer) throw new Error("expected focus consumer");
|
||||
|
||||
view.render("index.html#first");
|
||||
|
||||
expect(animationCardMock.consumers.at(-1)?.consume).toBe(firstConsumer);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("rejects a request from an earlier project session", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setSelectedElementId("index.html#first");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#first",
|
||||
animationId: sharedAnimation.id,
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
const staleRequest = usePlayerStore.getState().focusedEaseSegment;
|
||||
if (!staleRequest) throw new Error("expected request");
|
||||
|
||||
const view = renderSection("index.html#first");
|
||||
|
||||
act(() => {
|
||||
store.beginTimelineSession("project-b");
|
||||
store.setSelectedElementId("index.html#first");
|
||||
usePlayerStore.setState({ focusedEaseSegment: staleRequest });
|
||||
});
|
||||
|
||||
const card = view.host.querySelector<HTMLButtonElement>(
|
||||
"[data-testid='animation-shared-animation']",
|
||||
);
|
||||
expect(card?.dataset.focused).toBe("");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { Film } from "../../icons/SystemIcons";
|
||||
import { Section } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import { GsapAnimationList } from "./GsapAnimationList";
|
||||
|
||||
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
elementId: string;
|
||||
@@ -21,18 +14,13 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
|
||||
}
|
||||
|
||||
export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
animations,
|
||||
elementId,
|
||||
animations,
|
||||
multipleTimelines,
|
||||
unsupportedTimelinePattern,
|
||||
onAddAnimation,
|
||||
...callbacks
|
||||
}: GsapAnimationSectionProps) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
|
||||
return (
|
||||
<Section title="Animation" icon={<Film size={15} />}>
|
||||
{multipleTimelines && (
|
||||
@@ -49,31 +37,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
|
||||
</p>
|
||||
)}
|
||||
{multipleTimelines || unsupportedTimelinePattern ? null : (
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
focusedSegment={
|
||||
focusedEaseSegment?.elementId === elementId &&
|
||||
focusedEaseSegment.animationId === anim.id
|
||||
? focusedEaseSegment
|
||||
: null
|
||||
}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="classic"
|
||||
/>
|
||||
</div>
|
||||
<GsapAnimationList
|
||||
{...callbacks}
|
||||
elementId={elementId}
|
||||
animations={animations}
|
||||
onAddAnimation={onAddAnimation}
|
||||
variant="classic"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -570,8 +570,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onUpdateArcSegment={onUpdateArcSegment}
|
||||
onUnroll={onUnroll}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
||||
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
||||
import { isTextEditableSelection } from "./domEditing";
|
||||
@@ -18,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview } from "./propertyPanelSections";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
|
||||
import {
|
||||
FlatColorGradingAccessory,
|
||||
FlatColorGradingSection,
|
||||
@@ -31,24 +33,10 @@ import {
|
||||
deriveMediaOverlayPlacement,
|
||||
FlatOverlaysSection,
|
||||
} from "./propertyPanelFlatOverlaysSection";
|
||||
|
||||
type FlatGroupDescriptor = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
accessory?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
// Required callback shape for the gated-off Motion effect list.
|
||||
const EMPTY_GSAP_EFFECT_HANDLERS = {
|
||||
onAddAnimation: () => {},
|
||||
onUpdateProperty: () => {},
|
||||
onUpdateMeta: () => {},
|
||||
onDeleteAnimation: () => {},
|
||||
onAddProperty: () => {},
|
||||
onRemoveProperty: () => {},
|
||||
};
|
||||
import {
|
||||
EMPTY_GSAP_EFFECT_HANDLERS,
|
||||
type FlatGroupDescriptor,
|
||||
} from "./propertyPanelFlatDescriptors";
|
||||
|
||||
/** The flat inspector shell with one shared open-group state. */
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -161,11 +149,18 @@ export function PropertyPanelFlat({
|
||||
// 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 { focusedEaseSegment, timelineProjectId, timelineSessionEpoch } = usePlayerStore(
|
||||
useShallow((state) => ({
|
||||
focusedEaseSegment: state.focusedEaseSegment,
|
||||
timelineProjectId: state.timelineProjectId,
|
||||
timelineSessionEpoch: state.timelineSessionEpoch,
|
||||
})),
|
||||
);
|
||||
// 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 = scopedElementKey(element);
|
||||
// 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
|
||||
@@ -174,8 +169,16 @@ export function PropertyPanelFlat({
|
||||
if (focusedEaseSegment !== consumedFocus) {
|
||||
setConsumedFocus(focusedEaseSegment);
|
||||
const focusesThisPanel =
|
||||
focusedEaseSegment?.elementId === renderedElementId &&
|
||||
gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
|
||||
focusedEaseSegment !== null &&
|
||||
// A request from a previous project/session/selection is stale: it must
|
||||
// not reopen Motion on whichever panel happens to be mounted now.
|
||||
isFocusedEaseRequestCurrent(focusedEaseSegment, {
|
||||
timelineProjectId,
|
||||
timelineSessionEpoch,
|
||||
selectedElementId,
|
||||
}) &&
|
||||
focusedEaseSegment.elementId === renderedElementId &&
|
||||
gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId);
|
||||
if (focusesThisPanel) setOpenGroupId("motion");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||
|
||||
/**
|
||||
@@ -164,12 +163,3 @@ export function withTrackedGsapAnimationCallbacks(
|
||||
: 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type FlatGroupDescriptor = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
accessory?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
// FlatMotionSection never calls these while effect cards are hidden; they only
|
||||
// provide its required callback shape on the gated-off path.
|
||||
export const EMPTY_GSAP_EFFECT_HANDLERS = {
|
||||
onAddAnimation: () => {},
|
||||
onUpdateProperty: () => {},
|
||||
onUpdateMeta: () => {},
|
||||
onDeleteAnimation: () => {},
|
||||
onAddProperty: () => {},
|
||||
onRemoveProperty: () => {},
|
||||
};
|
||||
@@ -273,13 +273,14 @@ describe("FlatMotionSection", () => {
|
||||
expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards focused bulk segment easing through the flat animation card", () => {
|
||||
const onUpdateKeyframeEase = vi.fn();
|
||||
const onUpdateSegmentEase = vi.fn();
|
||||
usePlayerStore.setState({
|
||||
focusedEaseSegment: {
|
||||
it("forwards focused bulk segment easing through the flat animation card", () => {
|
||||
const onUpdateKeyframeEase = vi.fn();
|
||||
const onUpdateSegmentEase = vi.fn();
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setSelectedElementId("index.html#hero");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#hero",
|
||||
animationId: "a1",
|
||||
tweenPercentage: 50,
|
||||
@@ -287,55 +288,55 @@ it("forwards focused bulk segment easing through the flat animation card", () =>
|
||||
{ animationId: "a1", tweenPercentage: 50 },
|
||||
{ animationId: "a2", tweenPercentage: 75 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 100 },
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
]}
|
||||
showTiming={false}
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
||||
expect(dropdown).not.toBeNull();
|
||||
act(() => dropdown?.click());
|
||||
const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
|
||||
expect(preset).not.toBeNull();
|
||||
act(() => preset?.click());
|
||||
|
||||
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
|
||||
[
|
||||
{ animationId: "a1", tweenPercentage: 50 },
|
||||
{ animationId: "a2", tweenPercentage: 75 },
|
||||
],
|
||||
"power2.out",
|
||||
);
|
||||
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 100 },
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
]}
|
||||
showTiming={false}
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
onUpdateSegmentEase={onUpdateSegmentEase}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
||||
expect(dropdown).not.toBeNull();
|
||||
act(() => dropdown?.click());
|
||||
const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
|
||||
expect(preset).not.toBeNull();
|
||||
act(() => preset?.click());
|
||||
|
||||
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
|
||||
[
|
||||
{ animationId: "a1", tweenPercentage: 50 },
|
||||
{ animationId: "a2", tweenPercentage: 75 },
|
||||
],
|
||||
"power2.out",
|
||||
);
|
||||
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
||||
import { useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { parseTimingValue } from "./propertyPanelTimingSection";
|
||||
import { CommitField } from "./propertyPanelPrimitives";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import {
|
||||
type GsapAnimationEditCallbacks,
|
||||
withTrackedGsapAnimationCallbacks,
|
||||
clearFocusedEaseSegment,
|
||||
} from "./gsapAnimationCallbacks";
|
||||
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
|
||||
import { GsapAnimationList } from "./GsapAnimationList";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
@@ -136,19 +129,11 @@ export function FlatMotionSection({
|
||||
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const track = useTrackDesignInput();
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
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 = scopedElementKey(element);
|
||||
const focusedHere =
|
||||
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
|
||||
? focusedEaseSegment
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -174,26 +159,13 @@ export function FlatMotionSection({
|
||||
</p>
|
||||
)}
|
||||
{!multipleTimelines && !unsupportedTimelinePattern && (
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
{...trackedCallbacks}
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null}
|
||||
onFocusSegmentConsumed={clearFocusedEaseSegment}
|
||||
/>
|
||||
))}
|
||||
<GsapAddAnimationControl
|
||||
open={addMenuOpen}
|
||||
setOpen={setAddMenuOpen}
|
||||
onAddAnimation={onAddAnimation}
|
||||
track={track}
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
<GsapAnimationList
|
||||
{...callbacks}
|
||||
elementId={renderedElementId}
|
||||
animations={animations}
|
||||
onAddAnimation={onAddAnimation}
|
||||
variant="flat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -123,8 +123,8 @@ export interface PropertyPanelProps {
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
|
||||
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
||||
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
|
||||
@@ -200,8 +200,8 @@ export function DomEditProvider({
|
||||
commitMutation,
|
||||
applyMarqueeSelection,
|
||||
handleUpdateKeyframeEase,
|
||||
handleSetAllKeyframeEases,
|
||||
handleUpdateSegmentEase,
|
||||
handleSetAllKeyframeEases,
|
||||
},
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -57,9 +57,9 @@ const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async
|
||||
const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
|
||||
fn: undefined,
|
||||
};
|
||||
|
||||
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
||||
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
||||
|
||||
vi.mock("../utils/sdkResolverShadow", () => ({
|
||||
runResolverShadow: vi.fn(),
|
||||
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
|
||||
|
||||
@@ -537,11 +537,11 @@ export function useDomEditSession({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
handleUpdateKeyframeEase,
|
||||
handleUpdateSegmentEase,
|
||||
handleSetAllKeyframeEases,
|
||||
commitAnimatedProperty,
|
||||
commitAnimatedProperties,
|
||||
handleSetArcPath,
|
||||
handleUpdateSegmentEase,
|
||||
handleUpdateArcSegment,
|
||||
handleUnroll,
|
||||
invalidateGsapCache: bumpGsapCache,
|
||||
|
||||
@@ -12,6 +12,15 @@ import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
|
||||
|
||||
type HookApi = ReturnType<typeof useGsapKeyframeOps>;
|
||||
type CommitResult = { ok: boolean; changed: boolean };
|
||||
type CommitOptions = { onResult?: (result: CommitResult) => void };
|
||||
type CommitCall = [selection: unknown, mutation: unknown, options: CommitOptions];
|
||||
|
||||
function readCommitOptions(args: unknown[]): CommitOptions {
|
||||
// The hook accepts the production writer type; these test doubles expose only
|
||||
// the third tuple member they exercise.
|
||||
return (args as unknown as CommitCall)[2];
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
@@ -23,9 +32,7 @@ const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSe
|
||||
|
||||
function successfulCommitMutation() {
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
||||
const options = args[2] as {
|
||||
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
||||
};
|
||||
const options = readCommitOptions(args);
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
});
|
||||
}
|
||||
@@ -138,7 +145,7 @@ describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
||||
|
||||
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
||||
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
||||
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
||||
const options = readCommitOptions(args);
|
||||
options.onResult?.({ ok: true, changed: false });
|
||||
});
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
@@ -206,12 +213,16 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the successful commit refresh own delete-all cache invalidation", async () => {
|
||||
it("clears delete-all cache only when persistence confirms the change", async () => {
|
||||
let finishCommit: (() => void) | undefined;
|
||||
const commitMutationSafely = vi.fn(
|
||||
() =>
|
||||
(...args: unknown[]) =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCommit = resolve;
|
||||
const options = readCommitOptions(args);
|
||||
finishCommit = () => {
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
const api = renderKeyframeOps({
|
||||
@@ -232,13 +243,42 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
expect(commitMutationSafely).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{ type: "remove-all-keyframes", animationId: "box-to-0-position" },
|
||||
{ label: "Remove all keyframes", softReload: true },
|
||||
expect.objectContaining({ label: "Remove all keyframes", softReload: true }),
|
||||
);
|
||||
expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
|
||||
|
||||
finishCommit?.();
|
||||
await pending;
|
||||
expect(usePlayerStore.getState().keyframeCache.get("index.html#box")).toBe(cached);
|
||||
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the live DOM identity for a selector-only selection", async () => {
|
||||
const element = document.createElement("div");
|
||||
element.id = "box";
|
||||
const selectorOnlySelection: DomEditSelection = {
|
||||
...selection,
|
||||
id: undefined,
|
||||
selector: ".box",
|
||||
element,
|
||||
};
|
||||
const commitMutationSafely = vi.fn(async (...args: unknown[]) => {
|
||||
const options = readCommitOptions(args);
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
});
|
||||
const api = renderKeyframeOps({
|
||||
commitMutation: successfulCommitMutation(),
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure: vi.fn(),
|
||||
});
|
||||
const cached: KeyframeCacheEntry = {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 0, properties: { x: 0 } }],
|
||||
};
|
||||
usePlayerStore.setState({ keyframeCache: new Map([["index.html#box", cached]]) });
|
||||
|
||||
await api.removeAllKeyframes(selectorOnlySelection, "box-to-0-position");
|
||||
|
||||
expect(usePlayerStore.getState().keyframeCache.has("index.html#box")).toBe(false);
|
||||
});
|
||||
|
||||
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
} from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
||||
import {
|
||||
clearKeyframeCacheForElement,
|
||||
readKeyframeSnapshot,
|
||||
writeKeyframeCache,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import type {
|
||||
CommitMutation,
|
||||
CommitMutationOptions,
|
||||
@@ -331,6 +335,9 @@ export function useGsapKeyframeOps({
|
||||
const removeAllKeyframes = useCallback(
|
||||
async (selection: DomEditSelection, animationId: string) => {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
// A class/descendant selector can resolve a live element whose selection
|
||||
// deliberately has no id. The cache is still keyed by that DOM id.
|
||||
const cacheElementId = selection.id || selection.element?.id;
|
||||
if (sdkSession && sdkDeps) {
|
||||
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
||||
targetPath,
|
||||
@@ -339,12 +346,26 @@ export function useGsapKeyframeOps({
|
||||
sdkDeps,
|
||||
{ label: "Remove all keyframes" },
|
||||
);
|
||||
if (cutoverCommittedOrThrow(handled)) return;
|
||||
if (cutoverCommittedOrThrow(handled)) {
|
||||
if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await commitMutationSafely(
|
||||
selection,
|
||||
{ type: "remove-all-keyframes", animationId },
|
||||
{ label: "Remove all keyframes", softReload: true },
|
||||
{
|
||||
label: "Remove all keyframes",
|
||||
softReload: true,
|
||||
// The committed result is the single success boundary: clearing
|
||||
// before it makes failed saves lie, while waiting for the reload leaves
|
||||
// stale diamonds visible during the source round-trip.
|
||||
onResult: (result) => {
|
||||
if (result.changed !== false && cacheElementId) {
|
||||
clearKeyframeCacheForElement(targetPath, cacheElementId);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
||||
|
||||
@@ -51,8 +51,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
|
||||
scrollWidth: { configurable: true, value: 2_000 },
|
||||
scrollHeight: { configurable: true, value: 2_000 },
|
||||
});
|
||||
viewport.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 1_000, top: 0, bottom: 500, width: 1_000, height: 500 }) as DOMRect;
|
||||
viewport.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500);
|
||||
Object.assign(viewport, {
|
||||
setPointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
@@ -275,6 +274,74 @@ describe("BeatStrip gesture ownership", () => {
|
||||
expect(commitBeatEditsSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the dragged beat pinned by time when another beat is inserted before it", () => {
|
||||
const { root } = mountBeatStrip();
|
||||
startBeatDrag();
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
pointerEvent("pointermove", {
|
||||
bubbles: true,
|
||||
clientX: 140,
|
||||
clientY: 100,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
usePlayerStore.setState({
|
||||
beatAnalysis: {
|
||||
...BEAT_ANALYSIS,
|
||||
beatTimes: [0.5, 1, 3],
|
||||
beatStrengths: [0.3, 0.5, 0.8],
|
||||
},
|
||||
});
|
||||
root.render(<BeatStrip beatTimes={[0.5, 1, 3]} beatStrengths={[0.3, 0.5, 0.8]} pps={100} />);
|
||||
});
|
||||
|
||||
const lefts = Array.from(
|
||||
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
|
||||
(beat) => beat.style.left,
|
||||
);
|
||||
expect(lefts).toContain("134px");
|
||||
|
||||
releaseBeatDrag(140);
|
||||
expectCommittedBeatAt(1.4);
|
||||
});
|
||||
|
||||
it("keeps the first pointer in control when a second touch starts", () => {
|
||||
mountBeatStrip();
|
||||
startBeatDrag();
|
||||
const beats = document.querySelectorAll<HTMLDivElement>(
|
||||
'[title="Drag to move · double-click to delete"]',
|
||||
);
|
||||
act(() => {
|
||||
beats[1]?.dispatchEvent(
|
||||
pointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 300,
|
||||
clientY: 100,
|
||||
pointerId: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
releaseBeatDrag(140, 1);
|
||||
expectCommittedBeatAt(1.4);
|
||||
});
|
||||
|
||||
it("lets Escape bubble before cancelling the active drag", () => {
|
||||
mountBeatStrip();
|
||||
startBeatDrag();
|
||||
const sawEscape = vi.fn();
|
||||
document.addEventListener("keydown", sawEscape, { once: true });
|
||||
|
||||
act(() => {
|
||||
firstBeat().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(sawEscape).toHaveBeenCalledOnce();
|
||||
expectCancelledBeatDrag();
|
||||
});
|
||||
|
||||
it("does not autoscroll or mutate below the drag threshold", () => {
|
||||
const requestAnimationFrame = vi.fn(() => 1);
|
||||
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
|
||||
|
||||
@@ -18,7 +18,6 @@ const BEAT_HIT_W = 12; // grab width per beat (px)
|
||||
|
||||
interface BeatDragActor {
|
||||
readonly pointerId: number;
|
||||
readonly index: number;
|
||||
readonly startX: number;
|
||||
readonly clientX: number;
|
||||
readonly clientY: number;
|
||||
@@ -68,7 +67,7 @@ function releaseBeatDragResources(actor: BeatDragActor): void {
|
||||
window.removeEventListener("pointerup", handleBeatDragPointerUp);
|
||||
window.removeEventListener("pointercancel", handleBeatDragPointerCancel);
|
||||
window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel);
|
||||
window.removeEventListener("keydown", handleBeatDragKeyDown, true);
|
||||
window.removeEventListener("keydown", handleBeatDragKeyDown);
|
||||
window.removeEventListener("blur", cancelBeatDrag);
|
||||
try {
|
||||
if (actor.scroll.hasPointerCapture?.(actor.pointerId)) {
|
||||
@@ -186,7 +185,6 @@ function handleBeatDragPointerCancel(event: PointerEvent): void {
|
||||
function handleBeatDragKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape" || !beatDragActor) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancelBeatDrag();
|
||||
}
|
||||
|
||||
@@ -200,7 +198,6 @@ function resolveBeatDragViewport(
|
||||
|
||||
function createBeatDragActor(
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
index: number,
|
||||
originalTime: number,
|
||||
pixelsPerSecond: number,
|
||||
scroll: HTMLElement,
|
||||
@@ -210,7 +207,6 @@ function createBeatDragActor(
|
||||
if (!musicElement?.src) return null;
|
||||
const actor: BeatDragActor = {
|
||||
pointerId: event.pointerId,
|
||||
index,
|
||||
startX: event.clientX,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
@@ -235,15 +231,31 @@ function activateBeatDrag(actor: BeatDragActor): void {
|
||||
window.addEventListener("pointerup", handleBeatDragPointerUp);
|
||||
window.addEventListener("pointercancel", handleBeatDragPointerCancel);
|
||||
window.addEventListener("lostpointercapture", handleBeatDragPointerCancel);
|
||||
window.addEventListener("keydown", handleBeatDragKeyDown, true);
|
||||
window.addEventListener("keydown", handleBeatDragKeyDown);
|
||||
window.addEventListener("blur", cancelBeatDrag);
|
||||
unsubscribeBeatDragSession = usePlayerStore.subscribe((state) => {
|
||||
unsubscribeBeatDragSession = usePlayerStore.subscribe((state, previous) => {
|
||||
// Unlike gestures that can validate only at pointerup, beat drag must stop
|
||||
// immediately when its music source disappears or changes mid-stream. This
|
||||
// subscription owns that interruption; the identity guard keeps requestSeek's
|
||||
// per-frame store writes allocation-free unless a source input changed.
|
||||
if (
|
||||
state.timelineSessionEpoch === previous.timelineSessionEpoch &&
|
||||
state.timelineProjectId === previous.timelineProjectId &&
|
||||
state.elements === previous.elements &&
|
||||
state.beatAnalysis === previous.beatAnalysis &&
|
||||
state.beatEdits === previous.beatEdits
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag();
|
||||
});
|
||||
beatDragViewportObserver = new MutationObserver(() => {
|
||||
if (!actor.scroll.isConnected) cancelBeatDrag();
|
||||
});
|
||||
beatDragViewportObserver.observe(document, { childList: true, subtree: true });
|
||||
const viewportParent = actor.scroll.parentNode;
|
||||
if (viewportParent) {
|
||||
beatDragViewportObserver = new MutationObserver(() => {
|
||||
if (!actor.scroll.isConnected) cancelBeatDrag();
|
||||
});
|
||||
beatDragViewportObserver.observe(viewportParent, { childList: true });
|
||||
}
|
||||
try {
|
||||
actor.scroll.setPointerCapture?.(actor.pointerId);
|
||||
} catch {
|
||||
@@ -256,14 +268,15 @@ function activateBeatDrag(actor: BeatDragActor): void {
|
||||
|
||||
function beginBeatDrag(
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
index: number,
|
||||
originalTime: number,
|
||||
pixelsPerSecond: number,
|
||||
): void {
|
||||
// One pointer owns the actor until a terminal event claims it. A second touch
|
||||
// must not silently discard the first gesture and make its release a no-op.
|
||||
if (beatDragActor) return;
|
||||
const scroll = resolveBeatDragViewport(event, pixelsPerSecond);
|
||||
if (!scroll) return;
|
||||
cancelBeatDrag();
|
||||
const actor = createBeatDragActor(event, index, originalTime, pixelsPerSecond, scroll);
|
||||
const actor = createBeatDragActor(event, originalTime, pixelsPerSecond, scroll);
|
||||
if (actor) activateBeatDrag(actor);
|
||||
}
|
||||
|
||||
@@ -365,11 +378,14 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
||||
|
||||
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
||||
const activeBeatIndex = activeActor
|
||||
? beatTimes.findIndex((time) => Math.abs(time - activeActor.originalTime) < 1e-3)
|
||||
: -1;
|
||||
const drag =
|
||||
activeActor &&
|
||||
activeActor.sessionEpoch === sessionEpoch &&
|
||||
activeActor.projectId === projectId &&
|
||||
Math.abs((beatTimes[activeActor.index] ?? Number.NaN) - activeActor.originalTime) < 1e-3
|
||||
activeBeatIndex >= 0
|
||||
? activeActor
|
||||
: null;
|
||||
const cy = BEAT_BAND_H / 2;
|
||||
@@ -377,7 +393,7 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
beatTimes,
|
||||
beatStrengths,
|
||||
renderTimeRange,
|
||||
drag ? new Set([drag.index]) : undefined,
|
||||
drag ? new Set([activeBeatIndex]) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -390,7 +406,7 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
||||
const r = 1.5 + strength * 2.5;
|
||||
const opacity = 0.25 + strength * 0.75;
|
||||
const dxPx = drag?.index === i ? drag.dx : 0;
|
||||
const dxPx = drag && activeBeatIndex === i ? drag.dx : 0;
|
||||
const x = t * pps + dxPx;
|
||||
return (
|
||||
<div
|
||||
@@ -412,7 +428,7 @@ export const BeatStrip = memo(function BeatStrip({
|
||||
// selection (which otherwise "selects" the whole panel mid-drag).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
beginBeatDrag(e, i, t, pps);
|
||||
beginBeatDrag(e, t, pps);
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { memo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
@@ -30,7 +29,7 @@ interface KeyframeDiamondContextMenuProps {
|
||||
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
}
|
||||
|
||||
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
||||
export function KeyframeDiamondContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
onDelete,
|
||||
@@ -104,4 +103,4 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const { host, root } = renderBasicTimeline();
|
||||
|
||||
@@ -404,8 +403,8 @@ describe("Timeline provider boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
|
||||
expect(viewport).toBeInstanceOf(HTMLElement);
|
||||
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
expect(viewport).not.toBeNull();
|
||||
act(() => {
|
||||
viewport?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// @vitest-environment happy-dom
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { resolveTimelineContextElement } from "./TimelineOverlays";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const roots: Root[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
|
||||
});
|
||||
|
||||
const captured: TimelineElement = {
|
||||
id: "child",
|
||||
@@ -52,3 +67,95 @@ describe("resolveTimelineContextElement", () => {
|
||||
expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderKeyframeOverlay(options: {
|
||||
capturedElement: TimelineElement;
|
||||
currentElement: TimelineElement;
|
||||
setKfContextMenu?: ReturnType<typeof vi.fn>;
|
||||
onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const elements = [options.currentElement];
|
||||
const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
|
||||
const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
|
||||
const menu: KeyframeDiamondContextMenuState = {
|
||||
x: 10,
|
||||
y: 10,
|
||||
sessionEpoch: 2,
|
||||
element: options.capturedElement,
|
||||
elementId: options.capturedElement.key ?? options.capturedElement.id,
|
||||
percentage: 50,
|
||||
animationId: "child-position",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({
|
||||
selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
|
||||
timelineSessionEpoch: 2,
|
||||
});
|
||||
root.render(
|
||||
createElement(TimelineOverlays, {
|
||||
elements,
|
||||
elementsRef: { current: elements },
|
||||
theme: defaultTimelineTheme,
|
||||
showShortcutHint: false,
|
||||
showPopover: false,
|
||||
rangeSelection: null,
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelection: vi.fn(),
|
||||
kfContextMenu: menu,
|
||||
setKfContextMenu,
|
||||
onDeleteKeyframe: vi.fn(),
|
||||
onDeleteAllKeyframes,
|
||||
onMoveKeyframeToPlayhead: vi.fn(),
|
||||
clipContextMenu: null,
|
||||
setClipContextMenu: vi.fn(),
|
||||
currentTime: 0,
|
||||
onSplitElement: vi.fn(),
|
||||
pinZoomBeforeEdit: vi.fn(),
|
||||
onDeleteElement: vi.fn(),
|
||||
gapContextMenu: null,
|
||||
onDismissGapContextMenu: vi.fn(),
|
||||
onCloseTrackGap: vi.fn(),
|
||||
onCloseAllTrackGaps: vi.fn(),
|
||||
onHoverGapAction: vi.fn(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return { setKfContextMenu, onDeleteAllKeyframes };
|
||||
}
|
||||
|
||||
describe("TimelineOverlays context lifecycle", () => {
|
||||
it("dismisses a keyframe menu when its selected target becomes stale", () => {
|
||||
const setKfContextMenu = vi.fn();
|
||||
renderKeyframeOverlay({
|
||||
capturedElement: captured,
|
||||
currentElement: captured,
|
||||
setKfContextMenu,
|
||||
});
|
||||
|
||||
act(() => usePlayerStore.setState({ selectedElementId: "other" }));
|
||||
|
||||
expect(setKfContextMenu).toHaveBeenCalledExactlyOnceWith(null);
|
||||
});
|
||||
|
||||
it("dispatches a menu action with the current model element", () => {
|
||||
const current = { ...captured, start: 4, track: 7 };
|
||||
const onDeleteAllKeyframes = vi.fn();
|
||||
renderKeyframeOverlay({
|
||||
capturedElement: captured,
|
||||
currentElement: current,
|
||||
onDeleteAllKeyframes,
|
||||
});
|
||||
const button = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent === "Delete All Keyframes",
|
||||
);
|
||||
|
||||
act(() => button?.click());
|
||||
|
||||
expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ export interface TimelineEditCallbacks {
|
||||
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
||||
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
|
||||
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
|
||||
|
||||
@@ -388,7 +388,6 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
});
|
||||
if (!decision.cancel) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
cancelGesture({ suppressClick: decision.suppressClick });
|
||||
};
|
||||
@@ -397,7 +396,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||
window.addEventListener("pointercancel", handleWindowPointerCancel);
|
||||
window.addEventListener("lostpointercapture", handleLostPointerCapture);
|
||||
window.addEventListener("keydown", handleWindowKeyDown, true);
|
||||
window.addEventListener("keydown", handleWindowKeyDown);
|
||||
return () => {
|
||||
cancelGesture({ updateReact: false });
|
||||
cancelGestureRef.current = () => false;
|
||||
@@ -405,6 +404,6 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
window.removeEventListener("pointercancel", handleWindowPointerCancel);
|
||||
window.removeEventListener("lostpointercapture", handleLostPointerCapture);
|
||||
window.removeEventListener("keydown", handleWindowKeyDown, true);
|
||||
window.removeEventListener("keydown", handleWindowKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ function renderResizeHarness(
|
||||
},
|
||||
pressEscape() {
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
scroll.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
},
|
||||
unmount() {
|
||||
@@ -430,8 +430,11 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
||||
it("Escape discards the projection and persists nothing", () => {
|
||||
const { h } = startGroupResize();
|
||||
expect(h.getResizeProjection()).toHaveLength(2);
|
||||
const sawEscape = vi.fn();
|
||||
document.addEventListener("keydown", sawEscape, { once: true });
|
||||
|
||||
h.pressEscape();
|
||||
expect(sawEscape).toHaveBeenCalledOnce();
|
||||
expect(h.getResizeProjection()).toHaveLength(0);
|
||||
expect(h.storeById("b").duration).toBe(3);
|
||||
expect(h.onResizeElement).not.toHaveBeenCalled();
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("useTimelineKeyframeHandlers", () => {
|
||||
const { root, handlers } = mountHandlers();
|
||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
|
||||
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
|
||||
animationId: "position-tween",
|
||||
collidingAnimationTargets: [
|
||||
{ animationId: "position-tween", tweenPercentage: 100 },
|
||||
@@ -112,7 +112,7 @@ describe("useTimelineKeyframeHandlers", () => {
|
||||
// Selecting a segment must NOT move the playhead.
|
||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
||||
expect(onSeek).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
|
||||
animationId: "position-tween",
|
||||
tweenPercentage: 100,
|
||||
elementId: ELEMENT.id,
|
||||
|
||||
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
export interface FocusedEaseSegment {
|
||||
animationId: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
tweenPercentage: number;
|
||||
elementId: string;
|
||||
projectId: string | null;
|
||||
sessionEpoch: number;
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
|
||||
|
||||
interface TimelineSessionIdentity {
|
||||
timelineProjectId: string | null;
|
||||
timelineSessionEpoch: number;
|
||||
}
|
||||
|
||||
export function isFocusedEaseRequestCurrent(
|
||||
request: FocusedEaseSegment,
|
||||
state: TimelineSessionIdentity & { selectedElementId: string | null },
|
||||
): boolean {
|
||||
return (
|
||||
request.projectId === state.timelineProjectId &&
|
||||
request.sessionEpoch === state.timelineSessionEpoch &&
|
||||
request.elementId === state.selectedElementId
|
||||
);
|
||||
}
|
||||
|
||||
export interface KeyframeSlice {
|
||||
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
|
||||
selectedKeyframes: Set<string>;
|
||||
@@ -35,22 +63,14 @@ export interface KeyframeSlice {
|
||||
/** Union-expand clips (keyframed clips are expanded by default on load). */
|
||||
expandClips: (ids: readonly string[]) => void;
|
||||
|
||||
/** elementId scopes the request to one element so a shared (class-selector)
|
||||
* animation id can't open the ease editor on the wrong element. */
|
||||
focusedEaseSegment: {
|
||||
animationId: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
tweenPercentage: number;
|
||||
elementId: string;
|
||||
} | null;
|
||||
setFocusedEaseSegment: (
|
||||
target: {
|
||||
animationId: string;
|
||||
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
||||
tweenPercentage: number;
|
||||
elementId: string;
|
||||
} | null,
|
||||
) => void;
|
||||
/**
|
||||
* Project/session/element-scoped request. Its nonce is monotonic across store
|
||||
* resets so a stale consumer can never collide with a later request.
|
||||
*/
|
||||
focusedEaseSegment: FocusedEaseSegment | null;
|
||||
focusedEaseRequestNonce: number;
|
||||
setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
|
||||
clearFocusedEaseSegment: (nonce: number) => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
@@ -60,7 +80,10 @@ export interface KeyframeSlice {
|
||||
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
||||
}
|
||||
|
||||
export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): KeyframeSlice {
|
||||
export function createKeyframeSlice(
|
||||
set: StoreApi<KeyframeSlice>["setState"],
|
||||
getTimelineSessionIdentity: () => TimelineSessionIdentity,
|
||||
): KeyframeSlice {
|
||||
return {
|
||||
selectedKeyframes: new Set(),
|
||||
toggleSelectedKeyframe: (key) =>
|
||||
@@ -97,7 +120,25 @@ export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): K
|
||||
}),
|
||||
|
||||
focusedEaseSegment: null,
|
||||
setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
|
||||
focusedEaseRequestNonce: 0,
|
||||
setFocusedEaseSegment: (target) =>
|
||||
set((state) => {
|
||||
const nonce = state.focusedEaseRequestNonce + 1;
|
||||
const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
|
||||
return {
|
||||
focusedEaseRequestNonce: nonce,
|
||||
focusedEaseSegment: {
|
||||
...target,
|
||||
projectId: timelineProjectId,
|
||||
sessionEpoch: timelineSessionEpoch,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
}),
|
||||
clearFocusedEaseSegment: (nonce) =>
|
||||
set((state) =>
|
||||
state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
|
||||
),
|
||||
|
||||
keyframeCache: new Map(),
|
||||
setKeyframeCache: (elementId, data) =>
|
||||
|
||||
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("focused ease requests", () => {
|
||||
it("stamps the current project session and only lets its nonce clear it", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setSelectedElementId("index.html#hero");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#hero",
|
||||
animationId: "animation-a",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
const first = usePlayerStore.getState().focusedEaseSegment;
|
||||
if (!first) throw new Error("expected focused ease request");
|
||||
expect(first.projectId).toBe("project-a");
|
||||
expect(first.sessionEpoch).toBeGreaterThan(0);
|
||||
expect(first.nonce).toBeGreaterThan(0);
|
||||
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#hero",
|
||||
animationId: "animation-a",
|
||||
tweenPercentage: 75,
|
||||
});
|
||||
const second = usePlayerStore.getState().focusedEaseSegment;
|
||||
if (!second) throw new Error("expected replacement request");
|
||||
expect(second.nonce).toBe(first.nonce + 1);
|
||||
|
||||
store.clearFocusedEaseSegment(first.nonce);
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
|
||||
store.clearFocusedEaseSegment(second.nonce);
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a pending request when the project session changes", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.beginTimelineSession("project-a");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#hero",
|
||||
animationId: "animation-a",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
|
||||
store.beginTimelineSession("project-b");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
});
|
||||
|
||||
it("does not revive an old request after selecting away and back", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelectedElementId("index.html#a");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#a",
|
||||
animationId: "animation-a",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
|
||||
store.setSelectedElementId("index.html#b");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
store.setSelectedElementId("index.html#a");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
});
|
||||
|
||||
it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
|
||||
store.setFocusedEaseSegment({
|
||||
elementId: "index.html#a",
|
||||
animationId: "animation-a",
|
||||
tweenPercentage: 50,
|
||||
});
|
||||
const request = usePlayerStore.getState().focusedEaseSegment;
|
||||
|
||||
store.setSelectionAnchor("index.html#a");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
|
||||
store.setSelectionAnchor("index.html#b");
|
||||
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setIsPlaying", () => {
|
||||
it("sets isPlaying to true", () => {
|
||||
usePlayerStore.getState().setIsPlaying(true);
|
||||
|
||||
@@ -352,7 +352,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
activeTool: "select",
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
...createKeyframeSlice(set),
|
||||
...createKeyframeSlice(set, () => ({
|
||||
timelineProjectId: get().timelineProjectId,
|
||||
timelineSessionEpoch: get().timelineSessionEpoch,
|
||||
})),
|
||||
|
||||
activeKeyframePct: null,
|
||||
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
||||
@@ -541,6 +544,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
selectedElementIds,
|
||||
activeKeyframePct: null,
|
||||
motionPathArmed: false,
|
||||
focusedEaseSegment: null,
|
||||
}
|
||||
: { selectedElementId: id, selectedElementIds };
|
||||
}),
|
||||
@@ -550,9 +554,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setSelectionAnchor: (id) =>
|
||||
set((s) => {
|
||||
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
|
||||
return { selectedElementId: id };
|
||||
return {
|
||||
selectedElementId: id,
|
||||
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
||||
};
|
||||
}
|
||||
return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set<string>() };
|
||||
return {
|
||||
selectedElementId: id,
|
||||
selectedElementIds: id ? new Set([id]) : new Set<string>(),
|
||||
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
||||
};
|
||||
}),
|
||||
updateElement: (elementId, updates) =>
|
||||
set((state) => ({
|
||||
@@ -560,9 +571,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
||||
),
|
||||
})),
|
||||
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are
|
||||
// intentionally absent from createTimelineResetState because they are user
|
||||
// preferences that survive both source refreshes and project switches.
|
||||
// UI preferences intentionally survive reset. So do timelineSessionEpoch and
|
||||
// focusedEaseRequestNonce: the epoch advances only when project identity
|
||||
// changes, while a monotonic nonce prevents collisions with stale consumers.
|
||||
beginTimelineSession: (projectId) =>
|
||||
set((state) => {
|
||||
if (state.timelineProjectId === projectId) return state;
|
||||
@@ -575,18 +586,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
reset: () => set(createTimelineResetState()),
|
||||
}));
|
||||
|
||||
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
||||
// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
|
||||
// handle; no behavioural effect.
|
||||
// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
|
||||
// Turbopack), so guard the access like the telemetry client does.
|
||||
function isDevBuild(): boolean {
|
||||
try {
|
||||
return import.meta.env.DEV === true;
|
||||
} catch {
|
||||
// Turbopack and other non-Vite bundlers may not provide import.meta.env.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isDevBuild() && typeof window !== "undefined") {
|
||||
// Console handle for dumping live Studio state during bug-bash reproduction.
|
||||
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user