feat(studio): add flat Layout geometry rows (X/Y/W/H/Angle) with keyframe gutters

This commit is contained in:
Vance Ingalls
2026-07-14 15:50:40 -07:00
parent fea582f2b4
commit 588895f317
2 changed files with 267 additions and 0 deletions
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LayoutGeometryRows } 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 {
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());
});
});
@@ -0,0 +1,158 @@
import { FlatRow } from "./propertyPanelFlatPrimitives";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
type KeyframeEntry = Array<{
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
interface GeometryRowsProps {
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: unknown,
property: string,
value: number,
) => void | Promise<void>;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string) => void;
}
function KeyframeGutter({
property,
displayValue,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: {
property: string;
displayValue: number;
} & Pick<
GeometryRowsProps,
| "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(null, property, displayValue)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
/>
</span>
);
}
export function LayoutGeometryRows({
displayX,
displayY,
displayW,
displayH,
displayR,
manualOffsetEditingDisabled,
manualSizeEditingDisabled,
manualRotationEditingDisabled,
commitManualOffset,
commitManualSize,
commitManualRotation,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: GeometryRowsProps) {
const gutterProps = {
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} />}
/>
</>
);
}