mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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