mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2122 from heygen-com/studio-flat-03-layout-motion
feat(studio): flat inspector — Layout + Motion groups
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AnimationCard } from "./AnimationCard";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
||||
return {
|
||||
id: "anim-1",
|
||||
method: "to",
|
||||
position: 0.8,
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
properties: { opacity: 1 },
|
||||
...overrides,
|
||||
} as GsapAnimation;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("AnimationCard flat branch", () => {
|
||||
it("renders a mint border-left and panel-token colors when flat", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const card = host.querySelector('[data-flat-effect-card="true"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.className).toContain("border-panel-accent");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
|
||||
expect(host.textContent).toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
|
||||
for (const flat of [false, true]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat={flat || undefined}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).not.toContain("Remove");
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(host.textContent).toContain("Remove");
|
||||
act(() => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
|
||||
const onDeleteAnimation = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={true}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const removeButton = buttons.find((b) => b.textContent === "Remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
act(() => {
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onDeleteAnimation).toHaveBeenCalledWith("anim-1");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
||||
animation: GsapAnimation;
|
||||
defaultExpanded: boolean;
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const AnimationCard = memo(function AnimationCard({
|
||||
animation,
|
||||
defaultExpanded,
|
||||
flat,
|
||||
onUpdateProperty,
|
||||
onUpdateMeta,
|
||||
onDeleteAnimation,
|
||||
@@ -150,7 +152,14 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-800 pb-3">
|
||||
<div
|
||||
data-flat-effect-card={flat ? "true" : undefined}
|
||||
className={
|
||||
flat
|
||||
? "border-b border-l-2 border-panel-accent/40 border-b-panel-hairline pb-3 pl-2"
|
||||
: "border-b border-neutral-800 pb-3"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
@@ -162,13 +171,19 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
>
|
||||
{methodLabel}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
|
||||
<span
|
||||
className={`text-[11px] font-medium ${flat ? "text-panel-text-3" : "text-neutral-400"}`}
|
||||
title="When this effect plays"
|
||||
>
|
||||
{typeof animation.position === "number"
|
||||
? `${parseFloat(animation.position.toFixed(3))}s`
|
||||
: animation.position}{" "}
|
||||
– {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
|
||||
<span
|
||||
className={`ml-auto text-[10px] ${flat ? "text-panel-text-3" : "text-neutral-500"}`}
|
||||
title={easeName}
|
||||
>
|
||||
{easeLabel}
|
||||
</span>
|
||||
<svg
|
||||
@@ -176,7 +191,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
|
||||
className={`flex-shrink-0 transition-transform ${flat ? "text-panel-text-5" : "text-neutral-500"} ${expanded ? "" : "-rotate-90"}`}
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
|
||||
@@ -114,9 +114,74 @@ function styleOnlyElement() {
|
||||
};
|
||||
}
|
||||
|
||||
// Flex fixture (Plan 3a Task 5): display:flex drives BOTH the legacy
|
||||
// StyleSections Flex `Section` AND the new flat Layout group's
|
||||
// LayoutFlexBlock. Used to prove Flex renders exactly once on the flat path.
|
||||
// styles are read from computedStyles (PropertyPanel line ~113), so set it
|
||||
// there.
|
||||
function flexElement() {
|
||||
return {
|
||||
...baseElement(),
|
||||
id: "flex-row",
|
||||
selector: ".flex-row",
|
||||
label: "Flex Row",
|
||||
textFields: [],
|
||||
computedStyles: { display: "flex" },
|
||||
};
|
||||
}
|
||||
|
||||
// Motion fixture (Plan 3b Task 4): an authored clip range (data-start present)
|
||||
// makes resolveEditingSections turn on `sections.timing`, so the Motion group
|
||||
// renders via its Timing gate even with no GSAP edit handlers wired.
|
||||
function animatedElement() {
|
||||
return {
|
||||
...baseElement(),
|
||||
id: "anim-clip",
|
||||
selector: ".anim-clip",
|
||||
label: "Anim Clip",
|
||||
dataAttributes: { start: "0", duration: "4" },
|
||||
};
|
||||
}
|
||||
|
||||
// Inferred-timing fixture (whole-plan coherence fix): NO explicit data-start
|
||||
// or data-duration — sections.timing must turn on via animationCount (fed
|
||||
// from gsapAnimations.length), not an authored attribute, so both the Motion
|
||||
// Timing row and the Layout keyframe gutter are forced to infer the range
|
||||
// from the element's own GSAP tween instead of reading it off an attribute.
|
||||
function inferredMotionElement() {
|
||||
return {
|
||||
...baseElement(),
|
||||
id: "inferred-anim",
|
||||
selector: "#inferred-anim",
|
||||
label: "Inferred Anim",
|
||||
};
|
||||
}
|
||||
|
||||
// A single "to" tween running from t=2 to t=5 (position 2, duration 3), with
|
||||
// keyframes on "x" at 0/50/100% — enough to drive both FlatTimingRow's
|
||||
// inference and the Layout "x" row's keyframe-seek gutter.
|
||||
const INFERRED_TIMING_ANIMATION = {
|
||||
id: "a1",
|
||||
targetSelector: "#inferred-anim",
|
||||
method: "to",
|
||||
position: 2,
|
||||
duration: 3,
|
||||
properties: { x: 100 },
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 50 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
} as never;
|
||||
|
||||
async function renderPanel(
|
||||
flatEnabled: boolean,
|
||||
elementOverride: ReturnType<typeof baseElement> = baseElement(),
|
||||
propsOverride: Partial<PropertyPanelProps> = {},
|
||||
currentTime?: number,
|
||||
) {
|
||||
vi.resetModules();
|
||||
vi.doMock("./manualEditingAvailability", async () => {
|
||||
@@ -125,6 +190,13 @@ async function renderPanel(
|
||||
);
|
||||
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
|
||||
});
|
||||
// Seed the playhead on the SAME store instance PropertyPanel.tsx will read via
|
||||
// usePlayerStore (module-fresh since the resetModules() above) — must happen
|
||||
// before PropertyPanel is imported/rendered so its initial render sees it.
|
||||
if (currentTime !== undefined) {
|
||||
const { usePlayerStore } = await import("../../player/store/playerStore");
|
||||
usePlayerStore.getState().setCurrentTime(currentTime);
|
||||
}
|
||||
const { PropertyPanel } = await import("./PropertyPanel");
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
@@ -138,6 +210,7 @@ async function renderPanel(
|
||||
onSetStyle: vi.fn(),
|
||||
onSetText: vi.fn(),
|
||||
onSetAttributeLive: vi.fn(),
|
||||
...propsOverride,
|
||||
} as unknown as PropertyPanelProps;
|
||||
act(() => {
|
||||
root.render(<PropertyPanel {...props} />);
|
||||
@@ -150,6 +223,18 @@ async function renderPanel(
|
||||
// default under heavy parallel full-suite load, so give these a wider margin.
|
||||
const RENDER_TIMEOUT_MS = 20_000;
|
||||
|
||||
// Find the collapsed accordion row whose title matches and click it open.
|
||||
function openFlatGroup(host: HTMLElement, title: string) {
|
||||
const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
|
||||
el.textContent?.includes(title),
|
||||
);
|
||||
if (!row) throw new Error(`expected a collapsed ${title} row`);
|
||||
act(() => row.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
const openGroupText = (host: HTMLElement) =>
|
||||
host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
|
||||
|
||||
describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
|
||||
it(
|
||||
"renders the legacy header, not the flat header",
|
||||
@@ -266,3 +351,267 @@ describe("PropertyPanel — Style group (flag on)", () => {
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PropertyPanel — Layout group (Plan 3a)", () => {
|
||||
it(
|
||||
"always renders the Layout group, and opening it closes whichever other group was open",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true);
|
||||
// Text group is open by default for the base text-editable fixture.
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
|
||||
|
||||
const layoutCollapsedRow = Array.from(
|
||||
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
||||
).find((el) => el.textContent?.includes("Layout"));
|
||||
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
||||
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
expect(openGroup?.textContent).toContain("Layout");
|
||||
expect(openGroup?.textContent).toContain("X");
|
||||
expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, flexElement());
|
||||
const layoutCollapsedRow = Array.from(
|
||||
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
||||
).find((el) => el.textContent?.includes("Layout"));
|
||||
if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
|
||||
act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
// The legacy StyleSections Flex `Section` (data-panel-section="flex") must
|
||||
// NOT render on the flat path — the only two Flex renderers are the legacy
|
||||
// Section and the flat LayoutFlexBlock, so its absence + the flat block's
|
||||
// presence proves Flex renders exactly once (not twice, not zero).
|
||||
expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
|
||||
const openGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
expect(openGroup?.textContent).toContain("Layout");
|
||||
expect(openGroup?.textContent).toContain("Flex");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PropertyPanel — Motion group (Plan 3b)", () => {
|
||||
it(
|
||||
"renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, animatedElement());
|
||||
// Text is open by default for the text-editable fixture.
|
||||
expect(openGroupText(host)).toContain("Text");
|
||||
|
||||
openFlatGroup(host, "Motion");
|
||||
const openGroup = openGroupText(host);
|
||||
expect(openGroup).toContain("Motion");
|
||||
// FlatTimingRow (Start/End/Duration) renders inside the Motion group.
|
||||
expect(openGroup).toContain("Start");
|
||||
expect(openGroup).toContain("Duration");
|
||||
// One-open accordion: opening Motion closed the Text group.
|
||||
expect(openGroup).not.toContain("Text");
|
||||
|
||||
// Reverse direction: opening Layout closes Motion.
|
||||
openFlatGroup(host, "Layout");
|
||||
const openAfter = openGroupText(host);
|
||||
expect(openAfter).toContain("Layout");
|
||||
expect(openAfter).not.toContain("Motion");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"hides the effect list (showEffects off) when the GSAP edit handlers are absent",
|
||||
async () => {
|
||||
// STUDIO_GSAP_PANEL_ENABLED defaults on, but none of the five required
|
||||
// edit handlers are supplied here, so the effect-list half of the
|
||||
// double-gate stays closed — only the Timing row shows.
|
||||
const { host, root } = await renderPanel(true, animatedElement());
|
||||
openFlatGroup(host, "Motion");
|
||||
const openGroup = openGroupText(host);
|
||||
expect(openGroup).toContain("Motion");
|
||||
expect(openGroup).toContain("Duration"); // Timing still shows
|
||||
expect(openGroup).not.toContain("Add effect"); // effects gated off
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"shows the effect list (showEffects on) when the flag and all five handlers are present",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, animatedElement(), {
|
||||
onUpdateGsapProperty: vi.fn(),
|
||||
onUpdateGsapMeta: vi.fn(),
|
||||
onDeleteGsapAnimation: vi.fn(),
|
||||
onAddGsapProperty: vi.fn(),
|
||||
onAddGsapAnimation: vi.fn(),
|
||||
});
|
||||
openFlatGroup(host, "Motion");
|
||||
expect(openGroupText(host)).toContain("Add effect");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
// Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing
|
||||
// row basis must agree on the same start/duration for an element that has
|
||||
// animations but no explicit data-duration — before the fix, Layout fell back
|
||||
// to a naive `duration ?? 1` while Motion correctly inferred the range from
|
||||
// the tween (position 2, duration 3 -> start 2 / duration 3 / end 5).
|
||||
describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => {
|
||||
it(
|
||||
"Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, inferredMotionElement(), {
|
||||
gsapAnimations: [INFERRED_TIMING_ANIMATION],
|
||||
});
|
||||
openFlatGroup(host, "Motion");
|
||||
const motionGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
if (!motionGroup) throw new Error("expected the Motion group to be open");
|
||||
expect(motionGroup.textContent).toContain("Inferred");
|
||||
const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
|
||||
// FlatTimingRow renders Start, End, Duration in that order.
|
||||
expect(inputs[0]?.value).toBe("2.00s");
|
||||
expect(inputs[1]?.value).toBe("5.00s");
|
||||
expect(inputs[2]?.value).toBe("3.00s");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)",
|
||||
async () => {
|
||||
const onSeekToTime = vi.fn();
|
||||
// Seed the playhead at the clip's real start (t=2, the 0% keyframe's
|
||||
// absolute time) — now that the follow-up fix also recomputes
|
||||
// `currentPct` from the corrected elStart/elDuration basis, "current
|
||||
// position is at the 0% keyframe" must be expressed as an actual t=2
|
||||
// seek rather than relying on the store's untouched t=0 default (which,
|
||||
// post-fix, resolves to a currentPct of -66.7% — well outside the 0%
|
||||
// keyframe's tolerance window, and no longer "the case the coherence
|
||||
// bug affected" that this test documents).
|
||||
const { host, root } = await renderPanel(
|
||||
true,
|
||||
inferredMotionElement(),
|
||||
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
||||
2,
|
||||
);
|
||||
openFlatGroup(host, "Layout");
|
||||
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
||||
|
||||
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
||||
(el) => el.querySelector("span")?.textContent === "X",
|
||||
);
|
||||
if (!xRow) throw new Error("expected an X row");
|
||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||
// The diamond button always carries a `title`; the two plain arrow
|
||||
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
|
||||
// arrow is disabled (no earlier keyframe) and the next arrow seeks to
|
||||
// the 50% keyframe — exactly the case the coherence bug affected.
|
||||
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(b) => !b.title && !b.disabled,
|
||||
);
|
||||
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
|
||||
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
|
||||
// Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
|
||||
expect(onSeekToTime).toHaveBeenCalledWith(3.5);
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
// Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected
|
||||
// WHERE a keyframe click seeks to, but `currentPct` — the value that drives
|
||||
// KeyframeNavigation's diamond active/inactive state and prev/next arrow
|
||||
// targeting — still used the OLD naive basis. For an inferred-duration
|
||||
// element, seeking to a keyframe's actual absolute time no longer lit that
|
||||
// keyframe's diamond as active. Prove the round-trip here: seek to the exact
|
||||
// absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond
|
||||
// renders "active" (title="Remove x keyframe"), not "inactive"/"ghost".
|
||||
describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => {
|
||||
it(
|
||||
"lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(
|
||||
true,
|
||||
inferredMotionElement(),
|
||||
{ gsapAnimations: [INFERRED_TIMING_ANIMATION] },
|
||||
3.5,
|
||||
);
|
||||
openFlatGroup(host, "Layout");
|
||||
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
||||
|
||||
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
||||
(el) => el.querySelector("span")?.textContent === "X",
|
||||
);
|
||||
if (!xRow) throw new Error("expected an X row");
|
||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||
const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
|
||||
if (!diamond) throw new Error("expected a keyframe diamond button");
|
||||
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
|
||||
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
|
||||
// Before this fix, currentPct was computed against the naive
|
||||
// elStart=0/elDuration=1 basis, so t=3.5 produced currentPct=350% —
|
||||
// nowhere near the 50% keyframe within KeyframeNavigation's tolerance —
|
||||
// and the diamond stayed "inactive" even though the playhead was
|
||||
// exactly on that keyframe's real time.
|
||||
expect(diamond.title).toBe("Remove x keyframe");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis",
|
||||
async () => {
|
||||
const onSeekToTime = vi.fn();
|
||||
const { host, root } = await renderPanel(
|
||||
true,
|
||||
inferredMotionElement(),
|
||||
{ gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
|
||||
3.5,
|
||||
);
|
||||
openFlatGroup(host, "Layout");
|
||||
const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
|
||||
if (!layoutGroup) throw new Error("expected the Layout group to be open");
|
||||
|
||||
const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
|
||||
(el) => el.querySelector("span")?.textContent === "X",
|
||||
);
|
||||
if (!xRow) throw new Error("expected an X row");
|
||||
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
|
||||
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
|
||||
const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const [prevArrow, , nextArrow] = buttons;
|
||||
if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
|
||||
// At the 50% keyframe (t=3.5), prev should target the 0% keyframe
|
||||
// (absolute t=2) and next should target the 100% keyframe (absolute
|
||||
// t=5) — both only resolvable once currentPct agrees with elStart=2/
|
||||
// elDuration=3, the same basis the seek fix already uses.
|
||||
expect(prevArrow.disabled).toBe(false);
|
||||
act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSeekToTime).toHaveBeenLastCalledWith(2);
|
||||
|
||||
expect(nextArrow.disabled).toBe(false);
|
||||
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSeekToTime).toHaveBeenLastCalledWith(5);
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
STUDIO_KEYFRAMES_ENABLED,
|
||||
} from "./manualEditingAvailability";
|
||||
import { PropertyPanelFlat } from "./PropertyPanelFlat";
|
||||
import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { usePlayerStore, liveTime } from "../../player";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
@@ -279,6 +280,24 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
selectedElementId={selectedElementId}
|
||||
clipboardCopied={clipboardCopied}
|
||||
onCopyElementInfo={handleCopyElementInfo}
|
||||
displayX={displayX}
|
||||
displayY={displayY}
|
||||
displayW={displayW}
|
||||
displayH={displayH}
|
||||
displayR={displayR}
|
||||
manualOffsetEditingDisabled={manualOffsetEditingDisabled}
|
||||
manualSizeEditingDisabled={manualSizeEditingDisabled}
|
||||
manualRotationEditingDisabled={manualRotationEditingDisabled}
|
||||
commitManualOffset={commitManualOffset}
|
||||
commitManualSize={commitManualSize}
|
||||
commitManualRotation={commitManualRotation}
|
||||
gsapAnimId={gsapAnimId}
|
||||
navKeyframes={navKeyframes}
|
||||
currentTime={currentTime}
|
||||
animIdForProp={animIdForProp}
|
||||
gsapRuntimeValues={gsap3dValues}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -522,16 +541,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
onSeekToTime={onSeekToTime}
|
||||
onRemoveKeyframe={onRemoveKeyframe}
|
||||
onConvertToKeyframes={onConvertToKeyframes}
|
||||
onLivePreviewProps={(el, props) => {
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow as
|
||||
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
|
||||
| null
|
||||
| undefined;
|
||||
const sel = el.id ? `#${el.id}` : el.selector;
|
||||
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
|
||||
if (win?.gsap && node) win.gsap.set(node, props);
|
||||
}}
|
||||
onLivePreviewProps={createGsapLivePreview(iframeRef)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
|
||||
@@ -3,18 +3,37 @@ import { resolveEditingSections } from "@hyperframes/core/editing";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { isTextEditableSelection } from "./domEditing";
|
||||
import type { PropertyPanelProps } from "./propertyPanelHelpers";
|
||||
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
||||
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
||||
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
||||
import { FlatGroup } from "./propertyPanelFlatPrimitives";
|
||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
||||
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import { createGsapLivePreview } from "./gsapLivePreview";
|
||||
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||
import { MediaSection } from "./propertyPanelMediaSection";
|
||||
|
||||
type EditingSections = ReturnType<typeof resolveEditingSections>;
|
||||
|
||||
// Type-only fallback for the Motion effect-card callbacks. Used solely to
|
||||
// satisfy FlatMotionSection's required-callback shape when the effect list is
|
||||
// gated off (showEffects === false, so none of these are ever invoked). Keeps
|
||||
// the gated-off path free of `!` non-null assertions — the real, narrowed
|
||||
// handlers flow through only when the double-gate below passes.
|
||||
const EMPTY_GSAP_EFFECT_HANDLERS = {
|
||||
onAddAnimation: () => {},
|
||||
onUpdateProperty: () => {},
|
||||
onUpdateMeta: () => {},
|
||||
onDeleteAnimation: () => {},
|
||||
onAddProperty: () => {},
|
||||
onRemoveProperty: () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* The flat "Ledger" inspector shell (design_handoff_studio_inspector).
|
||||
*
|
||||
@@ -22,10 +41,8 @@ type EditingSections = ReturnType<typeof resolveEditingSections>;
|
||||
* (same one-directional-import precedent as FlatTextSection). Rendered only
|
||||
* when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open/pin group state.
|
||||
*
|
||||
* Intentionally omits the Layout `Section` and `GsapAnimationSection` (Motion)
|
||||
* — flattening those is Layout/Motion plan territory (plans 3–4). A text
|
||||
* element with the flag on will not show Layout/Motion controls; that
|
||||
* regression is scoped and acceptable for an unreleased, flag-gated feature.
|
||||
* The Text/Style/Layout/Motion groups share the one-open accordion. The legacy
|
||||
* Media and Color-Grading sections render unchanged below the flat groups.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PropertyPanelFlat({
|
||||
@@ -64,6 +81,49 @@ export function PropertyPanelFlat({
|
||||
recordingState,
|
||||
recordingDuration,
|
||||
onToggleRecording,
|
||||
displayX,
|
||||
displayY,
|
||||
displayW,
|
||||
displayH,
|
||||
displayR,
|
||||
manualOffsetEditingDisabled,
|
||||
manualSizeEditingDisabled,
|
||||
manualRotationEditingDisabled,
|
||||
commitManualOffset,
|
||||
commitManualSize,
|
||||
commitManualRotation,
|
||||
gsapAnimId,
|
||||
navKeyframes,
|
||||
currentTime,
|
||||
animIdForProp,
|
||||
gsapRuntimeValues,
|
||||
// Renamed: PropertyPanel.tsx still computes/passes these for its own legacy
|
||||
// (non-flat) panel, but the flat path recomputes its own basis below via
|
||||
// deriveElementTiming so it agrees with Motion's Timing row — ignore the
|
||||
// parent's naive `elDuration ?? 1` fallback.
|
||||
elStart: _elStart,
|
||||
elDuration: _elDuration,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
gsapMultipleTimelines,
|
||||
gsapUnsupportedTimelinePattern,
|
||||
onUpdateGsapProperty,
|
||||
onUpdateGsapMeta,
|
||||
onDeleteGsapAnimation,
|
||||
onAddGsapProperty,
|
||||
onRemoveGsapProperty,
|
||||
onUpdateGsapFromProperty,
|
||||
onAddGsapFromProperty,
|
||||
onRemoveGsapFromProperty,
|
||||
onAddGsapAnimation,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUnroll,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
}: Pick<
|
||||
PropertyPanelProps,
|
||||
| "projectId"
|
||||
@@ -88,9 +148,53 @@ export function PropertyPanelFlat({
|
||||
| "onImportFonts"
|
||||
| "fontAssets"
|
||||
| "gsapAnimations"
|
||||
| "gsapMultipleTimelines"
|
||||
| "gsapUnsupportedTimelinePattern"
|
||||
| "onUpdateGsapProperty"
|
||||
| "onUpdateGsapMeta"
|
||||
| "onDeleteGsapAnimation"
|
||||
| "onAddGsapProperty"
|
||||
| "onRemoveGsapProperty"
|
||||
| "onUpdateGsapFromProperty"
|
||||
| "onAddGsapFromProperty"
|
||||
| "onRemoveGsapFromProperty"
|
||||
| "onAddGsapAnimation"
|
||||
| "onSetArcPath"
|
||||
| "onUpdateArcSegment"
|
||||
| "onUnroll"
|
||||
| "onUpdateKeyframeEase"
|
||||
| "onSetAllKeyframeEases"
|
||||
| "recordingState"
|
||||
| "recordingDuration"
|
||||
| "onToggleRecording"
|
||||
> &
|
||||
// Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
|
||||
// PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
|
||||
// via Pick so a signature change there propagates here instead of drifting.
|
||||
Pick<
|
||||
Parameters<typeof FlatLayoutSection>[0],
|
||||
| "displayX"
|
||||
| "displayY"
|
||||
| "displayW"
|
||||
| "displayH"
|
||||
| "displayR"
|
||||
| "manualOffsetEditingDisabled"
|
||||
| "manualSizeEditingDisabled"
|
||||
| "manualRotationEditingDisabled"
|
||||
| "commitManualOffset"
|
||||
| "commitManualSize"
|
||||
| "commitManualRotation"
|
||||
| "gsapAnimId"
|
||||
| "navKeyframes"
|
||||
| "animIdForProp"
|
||||
| "gsapRuntimeValues"
|
||||
| "elStart"
|
||||
| "elDuration"
|
||||
| "onCommitAnimatedProperty"
|
||||
| "onCommitAnimatedProperties"
|
||||
| "onSeekToTime"
|
||||
| "onRemoveKeyframe"
|
||||
| "onConvertToKeyframes"
|
||||
> & {
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
@@ -102,6 +206,7 @@ export function PropertyPanelFlat({
|
||||
selectedElementId: string | null;
|
||||
clipboardCopied: boolean;
|
||||
onCopyElementInfo: () => void;
|
||||
currentTime: number;
|
||||
}) {
|
||||
// Lazy initializer: pick whichever group actually renders for this element
|
||||
// (Text if text-editable, else Style if style-editable, else none open) so a
|
||||
@@ -110,7 +215,7 @@ export function PropertyPanelFlat({
|
||||
// switching the selection re-mounts this component and re-derives the
|
||||
// default instead of preserving stale state across unrelated elements.
|
||||
const [openGroupId, setOpenGroupId] = useState<string>(() =>
|
||||
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
|
||||
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "layout",
|
||||
);
|
||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
||||
|
||||
@@ -122,6 +227,60 @@ export function PropertyPanelFlat({
|
||||
setPinnedGroupIds((current) =>
|
||||
current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId],
|
||||
);
|
||||
// Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
|
||||
// must agree with Motion's Timing row (FlatTimingRow), which infers the
|
||||
// range from animations when there's no explicit data-duration. Computed
|
||||
// here (not threaded from PropertyPanel) both to keep that file under its
|
||||
// 600-LOC gate and because element/gsapAnimations are already in scope.
|
||||
const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations);
|
||||
// Trivial percentage→time seek, derived here rather than threaded from
|
||||
// PropertyPanel (keeps that file under its 600-LOC gate).
|
||||
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
|
||||
// Playhead position within the SAME corrected elStart/elDuration basis as
|
||||
// seekFromKfPct above — recomputed here (not threaded as `currentPct` from
|
||||
// PropertyPanel, which still derives it against its own naive basis for the
|
||||
// legacy panel) so KeyframeNavigation's diamond active-state and prev/next
|
||||
// arrow targeting agree with where a keyframe click actually seeks to
|
||||
// (follow-up fix to 684ec4e87, which corrected the seek basis but left this
|
||||
// one still naive).
|
||||
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
|
||||
|
||||
// Motion group double-gate — reproduces the legacy PropertyPanel gate exactly:
|
||||
// • Timing (sections.timing) shows via resolveEditingSections, same as today.
|
||||
// • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
|
||||
// all five edit handlers are present (identical to PropertyPanel's legacy
|
||||
// `<GsapAnimationSection>` guard).
|
||||
// Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
|
||||
// TypeScript prove each handler non-undefined without a `!` assertion; the
|
||||
// noop bundle only fills the type when the gate is off (never invoked, since
|
||||
// FlatMotionSection guards every call behind showEffects).
|
||||
const showMotionTiming = Boolean(sections.timing);
|
||||
const gsapEffectHandlers =
|
||||
STUDIO_GSAP_PANEL_ENABLED &&
|
||||
onUpdateGsapProperty &&
|
||||
onUpdateGsapMeta &&
|
||||
onDeleteGsapAnimation &&
|
||||
onAddGsapProperty &&
|
||||
onAddGsapAnimation
|
||||
? {
|
||||
onAddAnimation: onAddGsapAnimation,
|
||||
onUpdateProperty: onUpdateGsapProperty,
|
||||
onUpdateMeta: onUpdateGsapMeta,
|
||||
onDeleteAnimation: onDeleteGsapAnimation,
|
||||
onAddProperty: onAddGsapProperty,
|
||||
onRemoveProperty: onRemoveGsapProperty ?? (() => {}),
|
||||
onUpdateFromProperty: onUpdateGsapFromProperty,
|
||||
onAddFromProperty: onAddGsapFromProperty,
|
||||
onRemoveFromProperty: onRemoveGsapFromProperty,
|
||||
onSetArcPath,
|
||||
onUpdateArcSegment,
|
||||
onUnroll,
|
||||
onUpdateKeyframeEase,
|
||||
onSetAllKeyframeEases,
|
||||
}
|
||||
: null;
|
||||
const showMotionEffects = gsapEffectHandlers !== null;
|
||||
const showMotionGroup = showMotionTiming || showMotionEffects;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
@@ -185,12 +344,70 @@ export function PropertyPanelFlat({
|
||||
</FlatGroup>
|
||||
)}
|
||||
|
||||
{sections.timing && (
|
||||
<TimingSection
|
||||
<FlatGroup
|
||||
title="Layout"
|
||||
isOpen={openGroupId === "layout" || pinnedGroupIds.includes("layout")}
|
||||
isPinned={pinnedGroupIds.includes("layout")}
|
||||
onToggleOpen={() => toggleOpen("layout")}
|
||||
onTogglePin={() => togglePin("layout")}
|
||||
accessory={<span className="text-[9px] text-panel-text-5">drag values to scrub</span>}
|
||||
summary={`${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`}
|
||||
>
|
||||
<FlatLayoutSection
|
||||
element={element}
|
||||
styles={styles}
|
||||
onSetStyle={onSetStyle}
|
||||
disabled={!element.capabilities.canEditStyles}
|
||||
displayX={displayX}
|
||||
displayY={displayY}
|
||||
displayW={displayW}
|
||||
displayH={displayH}
|
||||
displayR={displayR}
|
||||
manualOffsetEditingDisabled={manualOffsetEditingDisabled}
|
||||
manualSizeEditingDisabled={manualSizeEditingDisabled}
|
||||
manualRotationEditingDisabled={manualRotationEditingDisabled}
|
||||
commitManualOffset={commitManualOffset}
|
||||
commitManualSize={commitManualSize}
|
||||
commitManualRotation={commitManualRotation}
|
||||
gsapAnimId={gsapAnimId}
|
||||
navKeyframes={navKeyframes}
|
||||
currentPct={currentPct}
|
||||
seekFromKfPct={seekFromKfPct}
|
||||
animIdForProp={animIdForProp}
|
||||
resolveAnimIdForProp={animIdForProp}
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
gsapKeyframes={navKeyframes}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
||||
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
||||
onSeekToTime={onSeekToTime}
|
||||
onRemoveKeyframe={onRemoveKeyframe}
|
||||
onConvertToKeyframes={onConvertToKeyframes}
|
||||
onLivePreviewProps={createGsapLivePreview(previewIframeRef ?? { current: null })}
|
||||
/>
|
||||
</FlatGroup>
|
||||
|
||||
{showMotionGroup && (
|
||||
<FlatGroup
|
||||
title="Motion"
|
||||
isOpen={openGroupId === "motion" || pinnedGroupIds.includes("motion")}
|
||||
isPinned={pinnedGroupIds.includes("motion")}
|
||||
onToggleOpen={() => toggleOpen("motion")}
|
||||
onTogglePin={() => togglePin("motion")}
|
||||
summary={`${gsapAnimations.length} effect${gsapAnimations.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
<FlatMotionSection
|
||||
element={element}
|
||||
animations={gsapAnimations}
|
||||
showTiming={showMotionTiming}
|
||||
showEffects={showMotionEffects}
|
||||
multipleTimelines={gsapMultipleTimelines}
|
||||
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
||||
onSetAttribute={onSetAttribute}
|
||||
{...(gsapEffectHandlers ?? EMPTY_GSAP_EFFECT_HANDLERS)}
|
||||
/>
|
||||
</FlatGroup>
|
||||
)}
|
||||
{sections.colorGrading && (
|
||||
<ColorGradingSection
|
||||
@@ -229,6 +446,9 @@ export function PropertyPanelFlat({
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
// Flex now lives in the flat Layout group (LayoutFlexBlock); suppress
|
||||
// the legacy StyleSections Flex `Section` so it renders exactly once.
|
||||
hideFlex
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/**
|
||||
* Build the "live preview" callback the 3D-transform sub-view fires while a
|
||||
* value is being dragged: apply a gsap.set() to the matching node inside the
|
||||
* preview iframe so the edit is reflected immediately, before it's committed.
|
||||
*
|
||||
* Extracted so the identical closure exists once — shared by the legacy
|
||||
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
|
||||
*/
|
||||
export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
|
||||
return (el: DomEditSelection, props: Record<string, number>) => {
|
||||
const iframe = iframeRef.current;
|
||||
const win = iframe?.contentWindow as
|
||||
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
|
||||
| null
|
||||
| undefined;
|
||||
const sel = el.id ? `#${el.id}` : el.selector;
|
||||
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
|
||||
if (win?.gsap && node) win.gsap.set(node, props);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FlatLayoutSection,
|
||||
LayoutFlexBlock,
|
||||
LayoutGeometryRows,
|
||||
LayoutTransform3DBlock,
|
||||
LayoutZIndexRow,
|
||||
} from "./propertyPanelFlatLayoutSection";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderInto(node: React.ReactElement) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(node);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement {
|
||||
const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
|
||||
const row = rows.find((el) => el.querySelector("span")?.textContent === label);
|
||||
const input = row?.querySelector<HTMLInputElement>("input");
|
||||
if (!input) throw new Error(`expected an input for row "${label}"`);
|
||||
return input;
|
||||
}
|
||||
|
||||
function baseGeometryProps(overrides: Partial<Parameters<typeof LayoutGeometryRows>[0]> = {}) {
|
||||
return {
|
||||
element: {} as never,
|
||||
displayX: 0,
|
||||
displayY: -24,
|
||||
displayW: 257.4,
|
||||
displayH: 29,
|
||||
displayR: 0,
|
||||
manualOffsetEditingDisabled: false,
|
||||
manualSizeEditingDisabled: false,
|
||||
manualRotationEditingDisabled: false,
|
||||
commitManualOffset: vi.fn(),
|
||||
commitManualSize: vi.fn(),
|
||||
commitManualRotation: vi.fn(),
|
||||
gsapAnimId: null,
|
||||
navKeyframes: null,
|
||||
currentPct: 0,
|
||||
seekFromKfPct: vi.fn(),
|
||||
animIdForProp: (prop: string) => prop,
|
||||
onCommitAnimatedProperty: vi.fn(),
|
||||
onRemoveKeyframe: vi.fn(),
|
||||
onConvertToKeyframes: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LayoutGeometryRows", () => {
|
||||
it("renders X, Y, W, H, Angle labels and formatted values", () => {
|
||||
const { host, root } = renderInto(<LayoutGeometryRows {...baseGeometryProps()} />);
|
||||
expect(host.textContent).toContain("X");
|
||||
expect(host.textContent).toContain("Y");
|
||||
expect(host.textContent).toContain("W");
|
||||
expect(host.textContent).toContain("H");
|
||||
expect(host.textContent).toContain("Angle");
|
||||
expect(getFlatRowInput(host, "W").value).toBe("257.4px");
|
||||
expect(getFlatRowInput(host, "Y").value).toBe("-24px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits an X edit through commitManualOffset", () => {
|
||||
const commitManualOffset = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<LayoutGeometryRows {...baseGeometryProps({ commitManualOffset })} />,
|
||||
);
|
||||
const input = host.querySelectorAll("input")[0];
|
||||
if (!input) throw new Error("expected an X input");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "40px");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(commitManualOffset).toHaveBeenCalledWith("x", "40px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("wraps the keyframe gutter cluster at 30% opacity when the property has no keyframes", () => {
|
||||
const { host, root } = renderInto(
|
||||
<LayoutGeometryRows {...baseGeometryProps({ gsapAnimId: "anim-1", navKeyframes: null })} />,
|
||||
);
|
||||
const dimmed = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 0.3"]');
|
||||
expect(dimmed.length).toBeGreaterThan(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not dim the gutter cluster when the property has keyframes", () => {
|
||||
const { host, root } = renderInto(
|
||||
<LayoutGeometryRows
|
||||
{...baseGeometryProps({
|
||||
gsapAnimId: "anim-1",
|
||||
navKeyframes: [{ percentage: 0, properties: { x: 0 } }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const full = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 1"]');
|
||||
expect(full.length).toBeGreaterThan(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("passes the real element/selection (not null) to onCommitAnimatedProperty when adding a keyframe", () => {
|
||||
const onCommitAnimatedProperty = vi.fn();
|
||||
const element = { id: "el-1" } as unknown as Parameters<
|
||||
typeof LayoutGeometryRows
|
||||
>[0]["element"];
|
||||
const { host, root } = renderInto(
|
||||
<LayoutGeometryRows
|
||||
{...baseGeometryProps({
|
||||
element,
|
||||
gsapAnimId: "anim-1",
|
||||
navKeyframes: [{ percentage: 50, properties: { x: 5 } }],
|
||||
currentPct: 0,
|
||||
onCommitAnimatedProperty,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const addButton = host.querySelector('[title="Add x keyframe"]');
|
||||
if (!addButton) throw new Error("expected an Add x keyframe button");
|
||||
act(() => {
|
||||
(addButton as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onCommitAnimatedProperty).toHaveBeenCalledWith(element, "x", 0);
|
||||
expect(onCommitAnimatedProperty).not.toHaveBeenCalledWith(null, "x", 0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutZIndexRow", () => {
|
||||
it("renders the current z-index at the default tier and commits edits", () => {
|
||||
const onSetStyle = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<LayoutZIndexRow styles={{ "z-index": "3" }} onSetStyle={onSetStyle} />,
|
||||
);
|
||||
expect(host.textContent).toContain("Z-index");
|
||||
const input = host.querySelector("input");
|
||||
if (!input) throw new Error("expected an input");
|
||||
expect(input.value).toBe("3");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, "5");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(onSetStyle).toHaveBeenCalledWith("z-index", "5");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutFlexBlock", () => {
|
||||
it("renders nothing when the element is not flex", () => {
|
||||
const { host, root } = renderInto(
|
||||
<LayoutFlexBlock styles={{ display: "block" }} onSetStyle={vi.fn()} disabled={false} />,
|
||||
);
|
||||
expect(host.textContent).toBe("");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders direction/justify/align/gap and commits a direction change", () => {
|
||||
const onSetStyle = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<LayoutFlexBlock
|
||||
styles={{ display: "flex", "flex-direction": "row", gap: "8px" }}
|
||||
onSetStyle={onSetStyle}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).toContain("Flex");
|
||||
const columnOption = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
|
||||
(el) => el.textContent === "Column",
|
||||
);
|
||||
if (!columnOption) throw new Error("expected a Column segment option");
|
||||
act(() =>
|
||||
(columnOption as HTMLElement).dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
||||
);
|
||||
expect(onSetStyle).toHaveBeenCalledWith("flex-direction", "column");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutTransform3DBlock", () => {
|
||||
it("renders the nested 3D transform sub-view", () => {
|
||||
const { host, root } = renderInto(
|
||||
<LayoutTransform3DBlock
|
||||
gsapRuntimeValues={{}}
|
||||
gsapAnimId={null}
|
||||
gsapKeyframes={null}
|
||||
currentPct={0}
|
||||
elStart={0}
|
||||
elDuration={0}
|
||||
element={{} as never}
|
||||
onCommitAnimatedProperty={vi.fn()}
|
||||
onCommitAnimatedProperties={vi.fn()}
|
||||
onSeekToTime={vi.fn()}
|
||||
onRemoveKeyframe={vi.fn()}
|
||||
onConvertToKeyframes={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// PropertyPanel3dTransform's own internals aren't this task's concern (it's
|
||||
// reused unmodified) — just confirm the wrapper mounted something.
|
||||
expect(host.children.length).toBeGreaterThan(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatLayoutSection", () => {
|
||||
it("renders geometry rows, z-index, flex (when applicable), and the 3D transform block in order", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatLayoutSection
|
||||
element={{} as never}
|
||||
styles={{ display: "flex", "flex-direction": "row" }}
|
||||
onSetStyle={vi.fn()}
|
||||
disabled={false}
|
||||
displayX={0}
|
||||
displayY={0}
|
||||
displayW={100}
|
||||
displayH={100}
|
||||
displayR={0}
|
||||
manualOffsetEditingDisabled={false}
|
||||
manualSizeEditingDisabled={false}
|
||||
manualRotationEditingDisabled={false}
|
||||
commitManualOffset={vi.fn()}
|
||||
commitManualSize={vi.fn()}
|
||||
commitManualRotation={vi.fn()}
|
||||
gsapAnimId={null}
|
||||
navKeyframes={null}
|
||||
currentPct={0}
|
||||
seekFromKfPct={vi.fn()}
|
||||
animIdForProp={(p) => p}
|
||||
gsapRuntimeValues={{}}
|
||||
gsapKeyframes={null}
|
||||
elStart={0}
|
||||
elDuration={0}
|
||||
onSeekToTime={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const text = host.textContent ?? "";
|
||||
expect(text).toContain("X");
|
||||
expect(text).toContain("Z-index");
|
||||
expect(text).toContain("Flex");
|
||||
expect(text).toContain("3D Transform");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("omits the Flex block for a non-flex element", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatLayoutSection
|
||||
element={{} as never}
|
||||
styles={{ display: "block" }}
|
||||
onSetStyle={vi.fn()}
|
||||
disabled={false}
|
||||
displayX={0}
|
||||
displayY={0}
|
||||
displayW={100}
|
||||
displayH={100}
|
||||
displayR={0}
|
||||
manualOffsetEditingDisabled={false}
|
||||
manualSizeEditingDisabled={false}
|
||||
manualRotationEditingDisabled={false}
|
||||
commitManualOffset={vi.fn()}
|
||||
commitManualSize={vi.fn()}
|
||||
commitManualRotation={vi.fn()}
|
||||
gsapAnimId={null}
|
||||
navKeyframes={null}
|
||||
currentPct={0}
|
||||
seekFromKfPct={vi.fn()}
|
||||
animIdForProp={(p) => p}
|
||||
gsapRuntimeValues={{}}
|
||||
gsapKeyframes={null}
|
||||
elStart={0}
|
||||
elDuration={0}
|
||||
onSeekToTime={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).not.toContain("Flex");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives";
|
||||
import { KeyframeNavigation } from "./KeyframeNavigation";
|
||||
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
||||
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
type KeyframeEntry = Array<{
|
||||
percentage: number;
|
||||
tweenPercentage?: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}> | null;
|
||||
|
||||
interface GeometryRowsProps {
|
||||
element: DomEditSelection;
|
||||
displayX: number;
|
||||
displayY: number;
|
||||
displayW: number;
|
||||
displayH: number;
|
||||
displayR: number;
|
||||
manualOffsetEditingDisabled: boolean;
|
||||
manualSizeEditingDisabled: boolean;
|
||||
manualRotationEditingDisabled: boolean;
|
||||
commitManualOffset: (axis: "x" | "y", value: string) => void;
|
||||
commitManualSize: (dimension: "width" | "height", value: string) => void;
|
||||
commitManualRotation: (value: string) => void;
|
||||
gsapAnimId: string | null;
|
||||
navKeyframes: KeyframeEntry;
|
||||
currentPct: number;
|
||||
seekFromKfPct: (pct: number) => void;
|
||||
animIdForProp: (prop: string) => string;
|
||||
onCommitAnimatedProperty?: (
|
||||
element: DomEditSelection,
|
||||
property: string,
|
||||
value: number,
|
||||
) => Promise<void>;
|
||||
onRemoveKeyframe?: (animId: string, pct: number) => void;
|
||||
onConvertToKeyframes?: (animId: string) => void;
|
||||
}
|
||||
|
||||
function KeyframeGutter({
|
||||
element,
|
||||
property,
|
||||
displayValue,
|
||||
gsapAnimId,
|
||||
navKeyframes,
|
||||
currentPct,
|
||||
seekFromKfPct,
|
||||
animIdForProp,
|
||||
onCommitAnimatedProperty,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
}: {
|
||||
property: string;
|
||||
displayValue: number;
|
||||
} & Pick<
|
||||
GeometryRowsProps,
|
||||
| "element"
|
||||
| "gsapAnimId"
|
||||
| "navKeyframes"
|
||||
| "currentPct"
|
||||
| "seekFromKfPct"
|
||||
| "animIdForProp"
|
||||
| "onCommitAnimatedProperty"
|
||||
| "onRemoveKeyframe"
|
||||
| "onConvertToKeyframes"
|
||||
>) {
|
||||
if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null;
|
||||
const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties));
|
||||
return (
|
||||
<span data-flat-kf-gutter="true" style={{ opacity: hasKeyframesOnProp ? 1 : 0.3 }}>
|
||||
<KeyframeNavigation
|
||||
property={property}
|
||||
keyframes={navKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={seekFromKfPct}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGeometryRows({
|
||||
element,
|
||||
displayX,
|
||||
displayY,
|
||||
displayW,
|
||||
displayH,
|
||||
displayR,
|
||||
manualOffsetEditingDisabled,
|
||||
manualSizeEditingDisabled,
|
||||
manualRotationEditingDisabled,
|
||||
commitManualOffset,
|
||||
commitManualSize,
|
||||
commitManualRotation,
|
||||
gsapAnimId,
|
||||
navKeyframes,
|
||||
currentPct,
|
||||
seekFromKfPct,
|
||||
animIdForProp,
|
||||
onCommitAnimatedProperty,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
}: GeometryRowsProps) {
|
||||
const gutterProps = {
|
||||
element,
|
||||
gsapAnimId,
|
||||
navKeyframes,
|
||||
currentPct,
|
||||
seekFromKfPct,
|
||||
animIdForProp,
|
||||
onCommitAnimatedProperty,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FlatRow
|
||||
label="X"
|
||||
value={formatPxMetricValue(displayX)}
|
||||
tier={displayX === 0 ? "default" : "explicitCustom"}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
onCommit={(next) => commitManualOffset("x", next)}
|
||||
suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
|
||||
/>
|
||||
<FlatRow
|
||||
label="Y"
|
||||
value={formatPxMetricValue(displayY)}
|
||||
tier={displayY === 0 ? "default" : "explicitCustom"}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
onCommit={(next) => commitManualOffset("y", next)}
|
||||
suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
|
||||
/>
|
||||
<FlatRow
|
||||
label="W"
|
||||
value={formatPxMetricValue(displayW)}
|
||||
tier="default"
|
||||
disabled={manualSizeEditingDisabled}
|
||||
onCommit={(next) => commitManualSize("width", next)}
|
||||
suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
|
||||
/>
|
||||
<FlatRow
|
||||
label="H"
|
||||
value={formatPxMetricValue(displayH)}
|
||||
tier="default"
|
||||
disabled={manualSizeEditingDisabled}
|
||||
onCommit={(next) => commitManualSize("height", next)}
|
||||
suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
|
||||
/>
|
||||
<FlatRow
|
||||
label="Angle"
|
||||
value={`${displayR}°`}
|
||||
tier="default"
|
||||
disabled={manualRotationEditingDisabled}
|
||||
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
||||
suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutZIndexRow({
|
||||
styles,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
|
||||
return (
|
||||
<FlatRow
|
||||
label="Z-index"
|
||||
value={zIndex}
|
||||
tier="default"
|
||||
onCommit={(next) => void onSetStyle("z-index", next)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutFlexBlock({
|
||||
styles,
|
||||
onSetStyle,
|
||||
disabled,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
||||
if (!isFlex) return null;
|
||||
const direction = styles["flex-direction"] || "row";
|
||||
return (
|
||||
<div className="border-l-2 border-panel-border-input py-0.5 pl-[10px]">
|
||||
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||
Flex
|
||||
</div>
|
||||
<FlatSegmentedRow
|
||||
label="Direction"
|
||||
options={[
|
||||
{ key: "row", node: "→ Row", active: direction === "row" },
|
||||
{ key: "column", node: "Column", active: direction === "column" },
|
||||
]}
|
||||
disabled={disabled}
|
||||
onChange={(next) => void onSetStyle("flex-direction", next)}
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Justify"
|
||||
value={styles["justify-content"] || "flex-start"}
|
||||
tier={resolveValueTier(styles["justify-content"], "flex-start")}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
"flex-start",
|
||||
"center",
|
||||
"space-between",
|
||||
"space-around",
|
||||
"space-evenly",
|
||||
"flex-end",
|
||||
]}
|
||||
onChange={(next) => void onSetStyle("justify-content", next)}
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Align"
|
||||
value={styles["align-items"] || "stretch"}
|
||||
tier={resolveValueTier(styles["align-items"], "stretch")}
|
||||
disabled={disabled}
|
||||
options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
|
||||
onChange={(next) => void onSetStyle("align-items", next)}
|
||||
/>
|
||||
<FlatRow
|
||||
label="Gap"
|
||||
value={styles.gap ?? "0px"}
|
||||
tier={resolveValueTier(styles.gap, "0px")}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => void onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutTransform3DBlock({
|
||||
gsapRuntimeValues,
|
||||
gsapAnimId,
|
||||
resolveAnimIdForProp,
|
||||
gsapKeyframes,
|
||||
currentPct,
|
||||
elStart,
|
||||
elDuration,
|
||||
element,
|
||||
onCommitAnimatedProperty,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onLivePreviewProps,
|
||||
}: {
|
||||
gsapRuntimeValues: Record<string, number>;
|
||||
gsapAnimId: string | null;
|
||||
resolveAnimIdForProp?: (prop: string) => string | null;
|
||||
gsapKeyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}> | null;
|
||||
currentPct: number;
|
||||
elStart: number;
|
||||
elDuration: number;
|
||||
element: DomEditSelection;
|
||||
onCommitAnimatedProperty?: (
|
||||
element: DomEditSelection,
|
||||
property: string,
|
||||
value: number,
|
||||
) => Promise<void>;
|
||||
onCommitAnimatedProperties?: (
|
||||
element: DomEditSelection,
|
||||
props: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
onRemoveKeyframe?: (animId: string, pct: number) => void;
|
||||
onConvertToKeyframes?: (animId: string, duration?: number) => void;
|
||||
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-panel-hairline pt-2.5">
|
||||
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
|
||||
3D Transform
|
||||
</div>
|
||||
<PropertyPanel3dTransform
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
gsapAnimId={gsapAnimId}
|
||||
resolveAnimIdForProp={resolveAnimIdForProp}
|
||||
gsapKeyframes={gsapKeyframes}
|
||||
currentPct={currentPct}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
element={element}
|
||||
onCommitAnimatedProperty={onCommitAnimatedProperty}
|
||||
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
||||
onSeekToTime={onSeekToTime}
|
||||
onRemoveKeyframe={onRemoveKeyframe}
|
||||
onConvertToKeyframes={onConvertToKeyframes}
|
||||
onLivePreviewProps={onLivePreviewProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FlatLayoutSectionProps
|
||||
extends
|
||||
Omit<GeometryRowsProps, never>,
|
||||
Pick<
|
||||
Parameters<typeof LayoutTransform3DBlock>[0],
|
||||
| "gsapRuntimeValues"
|
||||
| "resolveAnimIdForProp"
|
||||
| "gsapKeyframes"
|
||||
| "elStart"
|
||||
| "elDuration"
|
||||
| "onCommitAnimatedProperties"
|
||||
| "onSeekToTime"
|
||||
| "onLivePreviewProps"
|
||||
> {
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function FlatLayoutSection({
|
||||
element,
|
||||
styles,
|
||||
onSetStyle,
|
||||
disabled,
|
||||
gsapRuntimeValues,
|
||||
resolveAnimIdForProp,
|
||||
gsapKeyframes,
|
||||
elStart,
|
||||
elDuration,
|
||||
onCommitAnimatedProperties,
|
||||
onSeekToTime,
|
||||
onLivePreviewProps,
|
||||
...geometry
|
||||
}: FlatLayoutSectionProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<LayoutGeometryRows element={element} {...geometry} />
|
||||
<LayoutZIndexRow styles={styles} onSetStyle={onSetStyle} />
|
||||
<LayoutFlexBlock styles={styles} onSetStyle={onSetStyle} disabled={disabled} />
|
||||
<LayoutTransform3DBlock
|
||||
gsapRuntimeValues={gsapRuntimeValues}
|
||||
gsapAnimId={geometry.gsapAnimId}
|
||||
resolveAnimIdForProp={resolveAnimIdForProp}
|
||||
gsapKeyframes={gsapKeyframes}
|
||||
currentPct={geometry.currentPct}
|
||||
elStart={elStart}
|
||||
elDuration={elDuration}
|
||||
element={element}
|
||||
onCommitAnimatedProperty={geometry.onCommitAnimatedProperty}
|
||||
onCommitAnimatedProperties={onCommitAnimatedProperties}
|
||||
onSeekToTime={onSeekToTime}
|
||||
onRemoveKeyframe={geometry.onRemoveKeyframe}
|
||||
onConvertToKeyframes={geometry.onConvertToKeyframes}
|
||||
onLivePreviewProps={onLivePreviewProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
||||
return {
|
||||
element: document.createElement("div"),
|
||||
id: "hero",
|
||||
selector: "#hero",
|
||||
label: "Hero",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
||||
textContent: "",
|
||||
dataAttributes: { start: "8", duration: "4" },
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
...overrides,
|
||||
} as DomEditSelection;
|
||||
}
|
||||
|
||||
function renderInto(node: React.ReactElement) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(node);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe("FlatTimingRow", () => {
|
||||
it("renders Start, End, and Duration from the element's data attributes", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatTimingRow element={baseElement()} onSetAttribute={vi.fn()} />,
|
||||
);
|
||||
expect(host.textContent).toContain("Start");
|
||||
expect(host.textContent).toContain("End");
|
||||
expect(host.textContent).toContain("Duration");
|
||||
// Values render inside <input>s (CommitField), not as text nodes, so they
|
||||
// don't show up in textContent — assert on the rendered input values,
|
||||
// in the same Start/End/Duration order the row is built in.
|
||||
const inputs = host.querySelectorAll<HTMLInputElement>("input");
|
||||
expect(inputs[0]?.value).toBe("8.00s");
|
||||
expect(inputs[1]?.value).toBe("12.00s");
|
||||
expect(inputs[2]?.value).toBe("4.00s");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows the inferred note when duration is derived from animations, not authored", () => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const element = baseElement({ dataAttributes: { start: "0", duration: "0" } });
|
||||
const { host, root } = renderInto(
|
||||
<FlatTimingRow
|
||||
element={element}
|
||||
animations={[{ position: 2, duration: 3 } as never]}
|
||||
onSetAttribute={onSetAttribute}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).toContain("Inferred");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a Start edit through onSetAttribute", () => {
|
||||
const onSetAttribute = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatTimingRow element={baseElement()} onSetAttribute={onSetAttribute} />,
|
||||
);
|
||||
const startInput = host.querySelectorAll("input")[0];
|
||||
if (!startInput) throw new Error("expected a Start input");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(startInput, "10s");
|
||||
startInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
startInput.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatMotionSection", () => {
|
||||
it("renders Timing when showTiming is true and the effect list when showEffects is true", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0.8,
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
properties: { opacity: 1 },
|
||||
} as never,
|
||||
]}
|
||||
showTiming
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).toContain("Start");
|
||||
expect(host.textContent).toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("omits Timing entirely when showTiming is false", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[]}
|
||||
showTiming={false}
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).not.toContain("Start");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("omits the effect list entirely when showEffects is false", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[
|
||||
{
|
||||
id: "a1",
|
||||
method: "to",
|
||||
position: 0.8,
|
||||
duration: 1.2,
|
||||
ease: "power2.out",
|
||||
properties: { opacity: 1 },
|
||||
} as never,
|
||||
]}
|
||||
showTiming
|
||||
showEffects={false}
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.textContent).not.toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => {
|
||||
const onAddAnimation = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatMotionSection
|
||||
element={baseElement()}
|
||||
animations={[]}
|
||||
showTiming
|
||||
showEffects
|
||||
onSetAttribute={vi.fn()}
|
||||
onAddAnimation={onAddAnimation}
|
||||
onUpdateProperty={vi.fn()}
|
||||
onUpdateMeta={vi.fn()}
|
||||
onDeleteAnimation={vi.fn()}
|
||||
onAddProperty={vi.fn()}
|
||||
onRemoveProperty={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const buttons = () => Array.from(host.querySelectorAll("button"));
|
||||
const addTrigger = buttons().find((b) => b.textContent === "+ Add effect");
|
||||
if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button");
|
||||
act(() => {
|
||||
addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
const animateButton = buttons().find((b) => b.textContent === "Animate");
|
||||
if (!animateButton) throw new Error("expected an 'Animate' method button");
|
||||
act(() => {
|
||||
animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(onAddAnimation).toHaveBeenCalledWith("to");
|
||||
// The menu closes back to the trigger after a selection.
|
||||
expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
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 type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
|
||||
export function FlatTimingRow({
|
||||
element,
|
||||
animations = [],
|
||||
onSetAttribute,
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
animations?: GsapAnimation[];
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
|
||||
const end = start + duration;
|
||||
|
||||
const commitStart = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
void onSetAttribute("start", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitDuration = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= 0) return;
|
||||
void onSetAttribute("duration", parsed.toFixed(2));
|
||||
};
|
||||
|
||||
const commitEnd = (nextValue: string) => {
|
||||
const parsed = parseTimingValue(nextValue);
|
||||
if (parsed == null || parsed <= start) return;
|
||||
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
||||
};
|
||||
|
||||
const cell = (label: string, value: string, onCommit: (next: string) => void) => (
|
||||
<div className="grid gap-px">
|
||||
<span className="text-[9px] text-panel-text-4">{label}</span>
|
||||
<span className="border-b border-transparent font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
|
||||
<CommitField value={value} onCommit={onCommit} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
{cell("Start", formatTimingValue(start), commitStart)}
|
||||
{cell("End", formatTimingValue(end), commitEnd)}
|
||||
{cell("Duration", formatTimingValue(duration), commitDuration)}
|
||||
{derived && (
|
||||
<p className="col-span-3 mt-1 text-[10px] leading-snug text-panel-text-3">
|
||||
Inferred from this element's animation — edit to pin an explicit clip range.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlatMotionSection({
|
||||
element,
|
||||
animations,
|
||||
showTiming,
|
||||
showEffects,
|
||||
multipleTimelines,
|
||||
unsupportedTimelinePattern,
|
||||
onSetAttribute,
|
||||
onAddAnimation,
|
||||
...callbacks
|
||||
}: {
|
||||
element: DomEditSelection;
|
||||
animations: GsapAnimation[];
|
||||
showTiming: boolean;
|
||||
showEffects: boolean;
|
||||
multipleTimelines?: boolean;
|
||||
unsupportedTimelinePattern?: boolean;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||
} & GsapAnimationEditCallbacks) {
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showTiming && (
|
||||
<FlatTimingRow element={element} animations={animations} onSetAttribute={onSetAttribute} />
|
||||
)}
|
||||
{showEffects && (
|
||||
<>
|
||||
{multipleTimelines && (
|
||||
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
|
||||
This file has multiple GSAP timelines. Animation editing is disabled to prevent data
|
||||
loss — consolidate into a single timeline to enable editing.
|
||||
</p>
|
||||
)}
|
||||
{unsupportedTimelinePattern && (
|
||||
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
|
||||
This timeline uses a computed key the editor can't resolve statically.
|
||||
</p>
|
||||
)}
|
||||
{!multipleTimelines && !unsupportedTimelinePattern && (
|
||||
<div className="space-y-2">
|
||||
{animations.map((anim, index) => (
|
||||
<AnimationCard
|
||||
key={anim.id}
|
||||
animation={anim}
|
||||
defaultExpanded={index === 0}
|
||||
flat
|
||||
{...callbacks}
|
||||
/>
|
||||
))}
|
||||
<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={() => {
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
function withDataAttributes(
|
||||
dataAttributes: Record<string, string>,
|
||||
): Pick<DomEditSelection, "dataAttributes"> {
|
||||
return { dataAttributes };
|
||||
}
|
||||
|
||||
describe("deriveElementTiming", () => {
|
||||
it("uses the explicit data-start/data-duration attributes when duration is authored", () => {
|
||||
const result = deriveElementTiming(withDataAttributes({ start: "8", duration: "4" }));
|
||||
expect(result).toEqual({ start: 8, duration: 4, inferred: false });
|
||||
});
|
||||
|
||||
it("infers start/duration from animations when there is no explicit data-duration", () => {
|
||||
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
||||
const result = deriveElementTiming(
|
||||
withDataAttributes({ start: "0", duration: "0" }),
|
||||
animations,
|
||||
);
|
||||
expect(result).toEqual({ start: 2, duration: 3, inferred: true });
|
||||
});
|
||||
|
||||
it("spans the earliest tween start to the latest tween end across multiple animations", () => {
|
||||
const animations = [
|
||||
{ position: 1, duration: 2 } as unknown as GsapAnimation, // 1 -> 3
|
||||
{ position: 2, duration: 4 } as unknown as GsapAnimation, // 2 -> 6
|
||||
];
|
||||
const result = deriveElementTiming(withDataAttributes({}), animations);
|
||||
expect(result).toEqual({ start: 1, duration: 5, inferred: true });
|
||||
});
|
||||
|
||||
it("prefers an explicit data-duration over inference even when animations exist", () => {
|
||||
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
||||
const result = deriveElementTiming(
|
||||
withDataAttributes({ start: "0", duration: "10" }),
|
||||
animations,
|
||||
);
|
||||
expect(result).toEqual({ start: 0, duration: 10, inferred: false });
|
||||
});
|
||||
|
||||
it("falls back to hf-authored-duration when data-duration is absent", () => {
|
||||
const result = deriveElementTiming(
|
||||
withDataAttributes({ start: "1", "hf-authored-duration": "6" }),
|
||||
);
|
||||
expect(result).toEqual({ start: 1, duration: 6, inferred: false });
|
||||
});
|
||||
|
||||
it("returns a zero-duration, non-inferred result with no attributes and no animations", () => {
|
||||
const result = deriveElementTiming(withDataAttributes({}));
|
||||
expect(result).toEqual({ start: 0, duration: 0, inferred: false });
|
||||
});
|
||||
|
||||
// This is the exact bug from the whole-plan coherence review: Layout's
|
||||
// keyframe-seek basis must land on the same absolute time that Motion's
|
||||
// Timing row displays as the element's midpoint.
|
||||
it("agrees with a keyframe-percentage seek: 50% lands on the same midpoint the Timing row would show", () => {
|
||||
const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
|
||||
const timing = deriveElementTiming(
|
||||
withDataAttributes({ start: "0", duration: "0" }),
|
||||
animations,
|
||||
);
|
||||
const seekTimeAt50Pct = timing.start + (50 / 100) * timing.duration;
|
||||
const timingRowMidpoint = timing.start + timing.duration / 2;
|
||||
expect(seekTimeAt50Pct).toBe(timingRowMidpoint);
|
||||
expect(seekTimeAt50Pct).toBe(3.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/**
|
||||
* The single source of truth for an element's clip start/duration in the flat
|
||||
* inspector. Both the Motion group's Timing row (`FlatTimingRow`) and the
|
||||
* Layout group's keyframe gutter (fed via `elStart`/`elDuration` from
|
||||
* `PropertyPanel.tsx` through `PropertyPanelFlat.tsx`) must derive this the
|
||||
* same way — otherwise a keyframe-percentage seek in Layout lands on a
|
||||
* different absolute time than the range Motion displays for the same
|
||||
* element (found by the Plan 3a+3b whole-plan coherence review).
|
||||
*
|
||||
* Precedence: an explicit `data-duration` (or `data-hf-authored-duration`)
|
||||
* wins outright. Only when neither is present do we infer the range from the
|
||||
* element's own GSAP tweens (earliest tween start → latest tween end).
|
||||
*
|
||||
* Scoped to the FLAT inspector only — the legacy (non-flat) panel keeps its
|
||||
* own, unrelated `elStart`/`elDuration ?? 1` computation in `PropertyPanel.tsx`
|
||||
* untouched.
|
||||
*/
|
||||
export interface ElementTiming {
|
||||
start: number;
|
||||
duration: number;
|
||||
/** True when duration/start came from `deriveTimingFromAnimations`, not an authored attribute. */
|
||||
inferred: boolean;
|
||||
}
|
||||
|
||||
function deriveTimingFromAnimations(
|
||||
animations: GsapAnimation[],
|
||||
): { start: number; duration: number } | null {
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (const a of animations) {
|
||||
const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
||||
const d = a.duration ?? 0;
|
||||
lo = Math.min(lo, s);
|
||||
hi = Math.max(hi, s + d);
|
||||
}
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
|
||||
return { start: lo, duration: hi - lo };
|
||||
}
|
||||
|
||||
export function deriveElementTiming(
|
||||
element: Pick<DomEditSelection, "dataAttributes">,
|
||||
animations: GsapAnimation[] = [],
|
||||
): ElementTiming {
|
||||
const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
||||
const explicitDuration =
|
||||
Number.parseFloat(
|
||||
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
||||
) || 0;
|
||||
|
||||
const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
|
||||
return {
|
||||
start: derived ? derived.start : explicitStart,
|
||||
duration: derived ? derived.duration : explicitDuration,
|
||||
inferred: derived !== null,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export function StyleSections({
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
gsapBorderRadius,
|
||||
hideFlex = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
@@ -55,6 +56,10 @@ export function StyleSections({
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
// When true, the Flex `Section` is suppressed. The flat inspector renders
|
||||
// its own Flex controls inside the Layout group (LayoutFlexBlock), so the
|
||||
// flat path passes this to avoid a double-render. Non-flat callers omit it.
|
||||
hideFlex?: boolean;
|
||||
}) {
|
||||
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
||||
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
||||
@@ -145,7 +150,7 @@ export function StyleSections({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFlex && (
|
||||
{isFlex && !hideFlex && (
|
||||
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DomEditSelection } from "./domEditing";
|
||||
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||
|
||||
function parseTimingValue(input: string): number | null {
|
||||
export function parseTimingValue(input: string): number | null {
|
||||
const cleaned = input.replace(/s$/i, "").trim();
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
|
||||
Reference in New Issue
Block a user