From 588895f317c0eaeb00cc303f3c60642c11fdb54f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 10:46:48 -0700 Subject: [PATCH] feat(studio): add flat Layout geometry rows (X/Y/W/H/Angle) with keyframe gutters --- .../propertyPanelFlatLayoutSection.test.tsx | 109 ++++++++++++ .../editor/propertyPanelFlatLayoutSection.tsx | 158 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx new file mode 100644 index 000000000..db80e32ba --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.test.tsx @@ -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(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +function baseGeometryProps(overrides: Partial[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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + const full = host.querySelectorAll('[data-flat-kf-gutter="true"][style*="opacity: 1"]'); + expect(full.length).toBeGreaterThan(0); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx new file mode 100644 index 000000000..d5c8d5db9 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -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; + 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; + 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 ( + + + onCommitAnimatedProperty && void onCommitAnimatedProperty(null, property, displayValue) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))} + /> + + ); +} + +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 ( + <> + commitManualOffset("x", next)} + suffix={} + /> + commitManualOffset("y", next)} + suffix={} + /> + commitManualSize("width", next)} + suffix={} + /> + commitManualSize("height", next)} + suffix={} + /> + commitManualRotation(next.replace("°", ""))} + suffix={} + /> + + ); +}