feat(studio): compose FlatLayoutSection from geometry, z-index, flex, and 3D blocks

This commit is contained in:
Vance Ingalls
2026-07-09 11:52:15 -07:00
parent 00553e8cea
commit 58f2b1bbaa
2 changed files with 170 additions and 3 deletions
@@ -4,6 +4,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FlatLayoutSection,
LayoutFlexBlock,
LayoutGeometryRows,
LayoutTransform3DBlock,
@@ -36,6 +37,7 @@ function getFlatRowInput(host: HTMLElement, label: string): HTMLInputElement {
function baseGeometryProps(overrides: Partial<Parameters<typeof LayoutGeometryRows>[0]> = {}) {
return {
element: {} as never,
displayX: 0,
displayY: -24,
displayW: 257.4,
@@ -111,6 +113,32 @@ describe("LayoutGeometryRows", () => {
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", () => {
@@ -189,3 +217,77 @@ describe("LayoutTransform3DBlock", () => {
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());
});
});
@@ -14,6 +14,7 @@ type KeyframeEntry = Array<{
}> | null;
interface GeometryRowsProps {
element: DomEditSelection;
displayX: number;
displayY: number;
displayW: number;
@@ -31,15 +32,16 @@ interface GeometryRowsProps {
seekFromKfPct: (pct: number) => void;
animIdForProp: (prop: string) => string;
onCommitAnimatedProperty?: (
element: unknown,
element: DomEditSelection,
property: string,
value: number,
) => void | Promise<void>;
) => Promise<void>;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string) => void;
}
function KeyframeGutter({
element,
property,
displayValue,
gsapAnimId,
@@ -55,6 +57,7 @@ function KeyframeGutter({
displayValue: number;
} & Pick<
GeometryRowsProps,
| "element"
| "gsapAnimId"
| "navKeyframes"
| "currentPct"
@@ -74,7 +77,7 @@ function KeyframeGutter({
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty && void onCommitAnimatedProperty(null, property, displayValue)
onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
@@ -84,6 +87,7 @@ function KeyframeGutter({
}
export function LayoutGeometryRows({
element,
displayX,
displayY,
displayW,
@@ -105,6 +109,7 @@ export function LayoutGeometryRows({
onConvertToKeyframes,
}: GeometryRowsProps) {
const gutterProps = {
element,
gsapAnimId,
navKeyframes,
currentPct,
@@ -304,3 +309,63 @@ export function LayoutTransform3DBlock({
</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>
);
}