mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add flat Layout geometry rows (X/Y/W/H/Angle) with keyframe gutters
This commit is contained in:
@@ -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} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user