mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): add FlatStyleSection with the flat Fill sub-block
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
||||
return {
|
||||
element: document.createElement("div"),
|
||||
id: "stat-card",
|
||||
selector: ".stat-card",
|
||||
label: "Stat Card",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 24, y: 120, width: 420, height: 260 },
|
||||
textContent: "",
|
||||
dataAttributes: {},
|
||||
inlineStyles: { "background-color": "#0D0C09" },
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
...overrides,
|
||||
} as DomEditSelection;
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
styles: Record<string, string> = {},
|
||||
overrides: Partial<DomEditSelection> = {},
|
||||
) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const element = makeElement(overrides);
|
||||
const onSetStyle = vi.fn();
|
||||
const mergedStyles = { "background-color": "#0D0C09", "border-width": "0px", ...styles };
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatStyleSection
|
||||
projectId="proj-1"
|
||||
element={element}
|
||||
styles={mergedStyles}
|
||||
assets={[]}
|
||||
onSetStyle={onSetStyle}
|
||||
gsapBorderRadius={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root, onSetStyle };
|
||||
}
|
||||
|
||||
function clickSegment(host: HTMLElement, label: string) {
|
||||
const segment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
|
||||
(el) => el.textContent === label,
|
||||
);
|
||||
act(() => segment?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
}
|
||||
|
||||
describe("FlatStyleSection — Fill", () => {
|
||||
it("renders the Fill segmented control defaulting to Solid, and a mint Color row when a color is set", () => {
|
||||
const { host, root } = renderSection();
|
||||
expect(host.textContent).toContain("Fill");
|
||||
expect(host.textContent).toContain("Solid");
|
||||
const swatch = host.querySelector('[data-flat-color-trigger="true"]');
|
||||
expect(swatch).not.toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("switches to the Gradient field when Gradient is selected", () => {
|
||||
const { host, root } = renderSection({
|
||||
"background-image": "linear-gradient(90deg, #000, #fff)",
|
||||
});
|
||||
const gradientSegment = Array.from(host.querySelectorAll('[data-flat-segment="true"]')).find(
|
||||
(el) => el.textContent === "Gradient",
|
||||
);
|
||||
expect(gradientSegment?.className).toContain("text-panel-text-0");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("clicking Gradient commits a serialized default gradient built from the current fill color", () => {
|
||||
const { host, root, onSetStyle } = renderSection();
|
||||
clickSegment(host, "Gradient");
|
||||
const expectedGradient = serializeGradient(buildDefaultGradientModel("#0D0C09"));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("background-image", expectedGradient);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("clicking Solid clears the background-image back to none", () => {
|
||||
const { host, root, onSetStyle } = renderSection({
|
||||
"background-image": "linear-gradient(90deg, #000, #fff)",
|
||||
});
|
||||
clickSegment(host, "Solid");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("background-image", "none");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("clicking Image switches to the image-fill field without committing a style", () => {
|
||||
const { host, root, onSetStyle } = renderSection();
|
||||
clickSegment(host, "Image");
|
||||
expect(host.textContent).toContain("Upload image");
|
||||
expect(onSetStyle).not.toHaveBeenCalledWith("background-image", expect.anything());
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { useEffect, useState } from "react";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { extractBackgroundImageUrl } from "./propertyPanelHelpers";
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { GradientField, ImageFillField } from "./propertyPanelFill";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Fill sub-block (design_handoff_studio_inspector, #11a) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function FlatFillFields({
|
||||
projectId,
|
||||
element,
|
||||
styles,
|
||||
assets,
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
}) {
|
||||
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
||||
const backgroundImage = styles["background-image"] ?? "none";
|
||||
const hasTextControls = isTextEditableSelection(element);
|
||||
const fillMode =
|
||||
backgroundImage && backgroundImage !== "none"
|
||||
? backgroundImage.includes("gradient")
|
||||
? "Gradient"
|
||||
: "Image"
|
||||
: "Solid";
|
||||
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
|
||||
const imageUrl = extractBackgroundImageUrl(backgroundImage);
|
||||
|
||||
useEffect(() => {
|
||||
setPreferredFillMode(fillMode);
|
||||
}, [fillMode, element.id, element.selector, backgroundImage]);
|
||||
|
||||
const handleFillModeChange = (nextMode: string) => {
|
||||
setPreferredFillMode(nextMode);
|
||||
if (nextMode === "Solid") {
|
||||
onSetStyle("background-image", "none");
|
||||
return;
|
||||
}
|
||||
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
|
||||
onSetStyle(
|
||||
"background-image",
|
||||
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatSegmentedRow
|
||||
label="Fill"
|
||||
options={[
|
||||
{ key: "Solid", node: "Solid", active: preferredFillMode === "Solid" },
|
||||
{ key: "Gradient", node: "Gradient", active: preferredFillMode === "Gradient" },
|
||||
{ key: "Image", node: "Image", active: preferredFillMode === "Image" },
|
||||
]}
|
||||
disabled={styleEditingDisabled}
|
||||
onChange={handleFillModeChange}
|
||||
/>
|
||||
{preferredFillMode === "Solid" ? (
|
||||
<ColorField
|
||||
flat
|
||||
label="Color"
|
||||
value={styles["background-color"] ?? "transparent"}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => onSetStyle("background-color", next)}
|
||||
/>
|
||||
) : preferredFillMode === "Gradient" ? (
|
||||
<GradientField
|
||||
value={
|
||||
backgroundImage !== "none"
|
||||
? backgroundImage
|
||||
: serializeGradient(buildDefaultGradientModel(styles["background-color"]))
|
||||
}
|
||||
fallbackColor={styles["background-color"]}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => onSetStyle("background-image", next)}
|
||||
/>
|
||||
) : (
|
||||
<ImageFillField
|
||||
projectId={projectId}
|
||||
sourceFile={element.sourceFile}
|
||||
value={imageUrl}
|
||||
assets={assets}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => onSetStyle("background-image", next)}
|
||||
onImportAssets={onImportAssets}
|
||||
/>
|
||||
)}
|
||||
{!hasTextControls && (
|
||||
<ColorField
|
||||
flat
|
||||
label="Text color"
|
||||
value={styles.color ?? "rgb(0, 0, 0)"}
|
||||
disabled={styleEditingDisabled}
|
||||
onCommit={(next) => onSetStyle("color", next)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlatStyleSection({
|
||||
projectId,
|
||||
element,
|
||||
styles,
|
||||
assets,
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
gsapBorderRadius,
|
||||
}: {
|
||||
projectId: string;
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FlatFillFields
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
styles={styles}
|
||||
assets={assets}
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user