mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2121 from heygen-com/studio-flat-02-style
feat(studio): flat inspector — Style group
This commit is contained in:
@@ -100,6 +100,20 @@ function multiFieldTextElement() {
|
||||
};
|
||||
}
|
||||
|
||||
// Style-only fixture: no text fields (Text group must not render), but
|
||||
// canEditStyles stays true (inherited from baseElement()) so the Style group
|
||||
// is gated in.
|
||||
function styleOnlyElement() {
|
||||
return {
|
||||
...baseElement(),
|
||||
id: "stat-card",
|
||||
selector: ".stat-card",
|
||||
label: "Stat Card",
|
||||
textFields: [],
|
||||
inlineStyles: { "background-color": "#0D0C09" },
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(
|
||||
flatEnabled: boolean,
|
||||
elementOverride: ReturnType<typeof baseElement> = baseElement(),
|
||||
@@ -185,9 +199,19 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
||||
it(
|
||||
"renders no Text group at all for a non-text element (bug 1)",
|
||||
async () => {
|
||||
// nonTextElement() inherits canEditStyles: true from baseElement(), so
|
||||
// the Style group (Task 10) renders and opens by default here — the
|
||||
// invariant under test is narrower than "no flat group at all": no
|
||||
// group titled "Text" may appear, open or collapsed.
|
||||
const { host, root } = await renderPanel(true, nonTextElement());
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
|
||||
expect(host.querySelector('[data-flat-group-collapsed="true"]')).toBeNull();
|
||||
const openTitle = host.querySelector(
|
||||
'[data-flat-group-open="true"] .text-panel-text-0',
|
||||
)?.textContent;
|
||||
const collapsedTitles = Array.from(
|
||||
host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
|
||||
).map((el) => el.textContent);
|
||||
expect(openTitle).not.toBe("Text");
|
||||
expect(collapsedTitles).not.toContain("Text");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
@@ -209,3 +233,36 @@ describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PropertyPanel — Style group (flag on)", () => {
|
||||
it(
|
||||
"renders the Style group for a style-editable, non-text element",
|
||||
async () => {
|
||||
const { host, root } = await renderPanel(true, styleOnlyElement());
|
||||
expect(host.textContent).toContain("Style");
|
||||
expect(host.textContent).toContain("Fill");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"one-open accordion: opening Style closes Text",
|
||||
async () => {
|
||||
// baseElement() is text-editable and has capabilities.canEditStyles:
|
||||
// true, so both the Text and Style groups render for it.
|
||||
const { host, root } = await renderPanel(true);
|
||||
const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
|
||||
expect(textGroup()?.textContent).toContain("Text");
|
||||
const styleCollapsedRow = Array.from(
|
||||
host.querySelectorAll('[data-flat-group-collapsed="true"]'),
|
||||
).find((el) => el.textContent?.includes("Style"));
|
||||
if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
|
||||
act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(textGroup()?.textContent).not.toContain("Text");
|
||||
expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
|
||||
act(() => root.unmount());
|
||||
},
|
||||
RENDER_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
||||
return (
|
||||
<PropertyPanelFlat
|
||||
{...props}
|
||||
key={element.id ?? element.selector}
|
||||
element={element}
|
||||
styles={styles}
|
||||
sections={sections}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
||||
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
||||
import { FlatGroup } from "./propertyPanelFlatPrimitives";
|
||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||
import { formatTextFieldPreview, StyleSections } from "./propertyPanelSections";
|
||||
import { TimingSection } from "./propertyPanelTimingSection";
|
||||
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
|
||||
@@ -102,15 +103,25 @@ export function PropertyPanelFlat({
|
||||
clipboardCopied: boolean;
|
||||
onCopyElementInfo: () => void;
|
||||
}) {
|
||||
// Defaulting to "text" is harmless for a non-text element even though the
|
||||
// Text FlatGroup won't render (nothing else reads openGroupId yet) — this
|
||||
// only matters once a second FlatGroup exists (Plan 2+), at which point a
|
||||
// non-text element should default-open that group instead.
|
||||
const [openGroupId, setOpenGroupId] = useState<string>("text");
|
||||
// Lazy initializer: pick whichever group actually renders for this element
|
||||
// (Text if text-editable, else Style if style-editable, else none open) so a
|
||||
// style-only element doesn't start with everything collapsed. Only runs on
|
||||
// mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
|
||||
// switching the selection re-mounts this component and re-derives the
|
||||
// default instead of preserving stale state across unrelated elements.
|
||||
const [openGroupId, setOpenGroupId] = useState<string>(() =>
|
||||
isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "",
|
||||
);
|
||||
const [pinnedGroupIds, setPinnedGroupIds] = useState<string[]>([]);
|
||||
|
||||
const isTextEditable = isTextEditableSelection(element);
|
||||
const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
|
||||
const toggleOpen = (groupId: string) =>
|
||||
setOpenGroupId((current) => (current === groupId ? "" : groupId));
|
||||
const togglePin = (groupId: string) =>
|
||||
setPinnedGroupIds((current) =>
|
||||
current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
||||
@@ -136,14 +147,8 @@ export function PropertyPanelFlat({
|
||||
title="Text"
|
||||
isOpen={openGroupId === "text" || pinnedGroupIds.includes("text")}
|
||||
isPinned={pinnedGroupIds.includes("text")}
|
||||
onToggleOpen={() => setOpenGroupId((current) => (current === "text" ? "" : "text"))}
|
||||
onTogglePin={() =>
|
||||
setPinnedGroupIds((current) =>
|
||||
current.includes("text")
|
||||
? current.filter((id) => id !== "text")
|
||||
: [...current, "text"],
|
||||
)
|
||||
}
|
||||
onToggleOpen={() => toggleOpen("text")}
|
||||
onTogglePin={() => togglePin("text")}
|
||||
summary={formatTextFieldPreview(element.textFields[0]?.value ?? "")}
|
||||
>
|
||||
<FlatTextSection
|
||||
@@ -159,6 +164,27 @@ export function PropertyPanelFlat({
|
||||
</FlatGroup>
|
||||
)}
|
||||
|
||||
{showEditableSections && (
|
||||
<FlatGroup
|
||||
title="Style"
|
||||
isOpen={openGroupId === "style" || pinnedGroupIds.includes("style")}
|
||||
isPinned={pinnedGroupIds.includes("style")}
|
||||
onToggleOpen={() => toggleOpen("style")}
|
||||
onTogglePin={() => togglePin("style")}
|
||||
summary={`fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`}
|
||||
>
|
||||
<FlatStyleSection
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
styles={styles}
|
||||
assets={assets}
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
/>
|
||||
</FlatGroup>
|
||||
)}
|
||||
|
||||
{sections.timing && (
|
||||
<TimingSection
|
||||
element={element}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
FlatGroup,
|
||||
FlatRow,
|
||||
FlatSegmentedRow,
|
||||
FlatSelectRow,
|
||||
FlatSlider,
|
||||
PinnedZoneDivider,
|
||||
} from "./propertyPanelFlatPrimitives";
|
||||
|
||||
@@ -157,3 +159,130 @@ describe("PinnedZoneDivider", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatSlider", () => {
|
||||
it("renders the default tier with a dim knob at the correct position", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatSlider
|
||||
label="Layer blur"
|
||||
value={0}
|
||||
min={0}
|
||||
max={40}
|
||||
tier="default"
|
||||
displayValue="0px"
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
|
||||
expect(knob).not.toBeNull();
|
||||
expect(knob?.className).toContain("bg-panel-text-4");
|
||||
expect(knob?.style.left).toBe("0%");
|
||||
const value = host.querySelector('[data-flat-slider-value="true"]');
|
||||
expect(value?.className).toContain("text-panel-text-3");
|
||||
expect(value?.textContent).toBe("0px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the explicitCustom tier with a filled track and bright knob", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatSlider
|
||||
label="Opacity"
|
||||
value={100}
|
||||
min={0}
|
||||
max={100}
|
||||
tier="explicitCustom"
|
||||
displayValue="100%"
|
||||
onCommit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const fill = host.querySelector<HTMLElement>('[data-flat-slider-fill="true"]');
|
||||
expect(fill?.style.width).toBe("100%");
|
||||
const knob = host.querySelector<HTMLElement>('[data-flat-slider-knob="true"]');
|
||||
expect(knob?.className).toContain("bg-white");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a value on track click, proportional to click position", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatSlider
|
||||
label="Opacity"
|
||||
value={50}
|
||||
min={0}
|
||||
max={100}
|
||||
tier="explicitCustom"
|
||||
displayValue="50%"
|
||||
onCommit={onCommit}
|
||||
/>,
|
||||
);
|
||||
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
|
||||
if (!track) throw new Error("expected a track element");
|
||||
Object.defineProperty(track, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 200, top: 0, height: 2, right: 200, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
|
||||
});
|
||||
expect(onCommit).toHaveBeenCalledWith(50);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatSelectRow", () => {
|
||||
it("renders the default tier with no reset button", () => {
|
||||
const { host, root } = renderInto(
|
||||
<FlatSelectRow
|
||||
label="Blend"
|
||||
value="normal"
|
||||
options={["normal", "multiply", "screen"]}
|
||||
tier="default"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = host.querySelector("select");
|
||||
expect(select?.value).toBe("normal");
|
||||
expect(host.querySelector('[data-flat-select-reset="true"]')).toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the explicitCustom tier with a reset button and fires onReset", () => {
|
||||
const onReset = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatSelectRow
|
||||
label="Shadow"
|
||||
value="soft"
|
||||
options={["none", "soft", "lift", "glow"]}
|
||||
tier="explicitCustom"
|
||||
onChange={vi.fn()}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
const select = host.querySelector<HTMLSelectElement>("select");
|
||||
expect(select?.className).toContain("text-panel-accent");
|
||||
const reset = host.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
|
||||
act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onReset).toHaveBeenCalledTimes(1);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("fires onChange when the select value changes", () => {
|
||||
const onChange = vi.fn();
|
||||
const { host, root } = renderInto(
|
||||
<FlatSelectRow
|
||||
label="Overflow"
|
||||
value="visible"
|
||||
options={["visible", "hidden", "clip", "auto", "scroll"]}
|
||||
tier="default"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const select = host.querySelector<HTMLSelectElement>("select");
|
||||
if (!select) throw new Error("expected a select");
|
||||
act(() => {
|
||||
select.value = "hidden";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith("hidden");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,3 +233,145 @@ export function PinnedZoneDivider() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* FlatSlider — full-width label/track/value row */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function FlatSlider({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
tier,
|
||||
displayValue,
|
||||
disabled,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
tier: "default" | "explicitCustom";
|
||||
displayValue: string;
|
||||
disabled?: boolean;
|
||||
onCommit: (nextValue: number) => void;
|
||||
}) {
|
||||
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
|
||||
|
||||
const commitFromClientX = (clientX: number, rect: DOMRect) => {
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
|
||||
const raw = min + ratio * (max - min);
|
||||
const stepped = Math.round(raw / step) * step;
|
||||
onCommit(Math.max(min, Math.min(max, stepped)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[28px] items-center gap-2.5">
|
||||
<span className="w-[86px] flex-shrink-0 text-[11px] text-panel-text-3">{label}</span>
|
||||
<div
|
||||
data-flat-slider-track="true"
|
||||
role="slider"
|
||||
aria-label={label}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled}
|
||||
className={`relative h-0.5 flex-1 rounded-full bg-panel-hover ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return;
|
||||
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
{tier === "explicitCustom" && (
|
||||
<div
|
||||
data-flat-slider-fill="true"
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-panel-text-5"
|
||||
style={{ width: `${clampedPct}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-flat-slider-knob="true"
|
||||
className={`absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ${
|
||||
tier === "explicitCustom" ? "h-2 w-2 bg-white" : "h-[7px] w-[7px] bg-panel-text-4"
|
||||
}`}
|
||||
style={{ left: `${clampedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
data-flat-slider-value="true"
|
||||
className={`w-11 flex-shrink-0 text-right font-mono text-[10px] ${
|
||||
tier === "explicitCustom" ? "text-panel-text-0" : "text-panel-text-3"
|
||||
}`}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* FlatSelectRow — label/value row backed by a native <select> */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function FlatSelectRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
tier,
|
||||
disabled,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
tier: PropertyValueTier;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex min-h-[30px] items-center justify-between">
|
||||
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
className="flex-shrink-0 text-panel-text-5"
|
||||
>
|
||||
<path d="M2 3l3 4 3-4z" />
|
||||
</svg>
|
||||
</label>
|
||||
{tier === "explicitCustom" && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
data-flat-select-reset="true"
|
||||
title="Remove — fall back to default"
|
||||
onClick={onReset}
|
||||
className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
|
||||
|
||||
describe("formatStrokeSummary", () => {
|
||||
it("formats width and style into one string", () => {
|
||||
expect(formatStrokeSummary(1, "solid")).toBe("1px solid");
|
||||
expect(formatStrokeSummary(2.5, "dashed")).toBe("2.5px dashed");
|
||||
expect(formatStrokeSummary(0, "none")).toBe("0px none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStrokeSummary", () => {
|
||||
it("parses a well-formed summary back into width and style", () => {
|
||||
expect(parseStrokeSummary("1px solid")).toEqual({ widthPx: 1, style: "solid" });
|
||||
expect(parseStrokeSummary(" 2.5px dashed ")).toEqual({ widthPx: 2.5, style: "dashed" });
|
||||
});
|
||||
|
||||
it("returns null for unparseable input", () => {
|
||||
expect(parseStrokeSummary("garbage")).toBeNull();
|
||||
expect(parseStrokeSummary("")).toBeNull();
|
||||
expect(parseStrokeSummary("1px")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export function formatStrokeSummary(widthPx: number, style: string): string {
|
||||
return `${widthPx}px ${style}`;
|
||||
}
|
||||
|
||||
export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null {
|
||||
const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text);
|
||||
if (!match) return null;
|
||||
const widthPx = Number.parseFloat(match[1]);
|
||||
const style = match[2];
|
||||
if (!Number.isFinite(widthPx) || !style) return null;
|
||||
return { widthPx, style };
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
// @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> = {},
|
||||
gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = null,
|
||||
) {
|
||||
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={gsapBorderRadius}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function commitFlatRowInput(host: HTMLElement, label: string, nextValue: string) {
|
||||
const input = getFlatRowInput(host, label);
|
||||
act(() => {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, nextValue);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const STROKE_STYLES = {
|
||||
"border-width": "1px",
|
||||
"border-style": "solid",
|
||||
"border-color": "rgba(255,255,255,.12)",
|
||||
};
|
||||
|
||||
describe("FlatStyleSection — Stroke and Radius", () => {
|
||||
it("renders the combined stroke row and commits width+style together on blur", () => {
|
||||
const { host, root } = renderSection(STROKE_STYLES);
|
||||
expect(host.textContent).toContain("Stroke");
|
||||
expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits the stroke row's new width and style together on blur", async () => {
|
||||
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
||||
await commitFlatRowInput(host, "Stroke", "2px dashed");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders a single Radius value with a Linked indicator when corners are uniform", () => {
|
||||
const { host, root } = renderSection({ "border-radius": "12px" });
|
||||
expect(host.textContent).toContain("Radius");
|
||||
expect(getFlatRowInput(host, "Radius").value).toBe("12px");
|
||||
expect(host.textContent).toContain("Linked");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
|
||||
await commitFlatRowInput(host, "Radius", "20px");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => {
|
||||
const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
|
||||
expect(host.textContent).not.toContain("Linked");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a per-corner radius update through the legacy BorderRadiusEditor when unlinked", () => {
|
||||
const { host, root, onSetStyle } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
|
||||
const trInput = Array.from(host.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(el) => el.value === "12",
|
||||
);
|
||||
if (!trInput) throw new Error("expected the TR corner input");
|
||||
act(() => {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(trInput, "18");
|
||||
trInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
trInput.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
function getFlatSelectRow(host: HTMLElement, label: string) {
|
||||
const rows = Array.from(host.querySelectorAll<HTMLElement>(".group"));
|
||||
const row = rows.find((el) => el.querySelector("span")?.textContent === label);
|
||||
if (!row) throw new Error(`expected a select row for "${label}"`);
|
||||
const select = row.querySelector<HTMLSelectElement>("select");
|
||||
if (!select) throw new Error(`expected a select for "${label}"`);
|
||||
const resetButton = row.querySelector<HTMLButtonElement>('[data-flat-select-reset="true"]');
|
||||
return { row, select, resetButton };
|
||||
}
|
||||
|
||||
function changeFlatSelectRow(host: HTMLElement, label: string, nextValue: string) {
|
||||
const { select } = getFlatSelectRow(host, label);
|
||||
act(() => {
|
||||
select.value = nextValue;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("FlatStyleSection — Shadow and Blend", () => {
|
||||
it("renders Shadow with the inferred preset and a reset when set, Blend with a plain select", () => {
|
||||
const { host, root } = renderSection({ "box-shadow": "0 8px 24px rgba(0,0,0,.35)" });
|
||||
expect(host.textContent).toContain("Shadow");
|
||||
expect(host.textContent).toContain("Blend");
|
||||
const selects = host.querySelectorAll("select");
|
||||
expect(selects.length).toBeGreaterThanOrEqual(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a shadow preset change through onSetStyle", () => {
|
||||
const { host, root, onSetStyle } = renderSection({});
|
||||
changeFlatSelectRow(host, "Shadow", "soft");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("box-shadow", expect.any(String));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a blend mode change through onSetStyle", () => {
|
||||
const { host, root, onSetStyle } = renderSection({});
|
||||
changeFlatSelectRow(host, "Blend", "multiply");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "multiply");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("resets the shadow preset back to none via the reset button", () => {
|
||||
const { host, root, onSetStyle } = renderSection({
|
||||
"box-shadow": "0 12px 36px rgba(0, 0, 0, 0.28)",
|
||||
});
|
||||
const { resetButton } = getFlatSelectRow(host, "Shadow");
|
||||
if (!resetButton) throw new Error("expected the shadow reset button");
|
||||
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("box-shadow", "none");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("resets the blend mode back to normal via the reset button", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "mix-blend-mode": "multiply" });
|
||||
const { resetButton } = getFlatSelectRow(host, "Blend");
|
||||
if (!resetButton) throw new Error("expected the blend reset button");
|
||||
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("mix-blend-mode", "normal");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatStyleSection — blur sliders", () => {
|
||||
it("renders Layer blur and Backdrop sliders and commits through onSetStyle", () => {
|
||||
const onSetStyle = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatStyleSection
|
||||
projectId="proj-1"
|
||||
element={makeElement()}
|
||||
styles={{ filter: "blur(4px)" }}
|
||||
assets={[]}
|
||||
onSetStyle={onSetStyle}
|
||||
gsapBorderRadius={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("Layer blur");
|
||||
expect(host.textContent).toContain("Backdrop");
|
||||
expect(host.textContent).toContain("4px");
|
||||
const track = host.querySelectorAll('[data-flat-slider-track="true"]')[0];
|
||||
Object.defineProperty(track, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
|
||||
});
|
||||
// filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
|
||||
expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the Backdrop slider from backdrop-filter and commits a new blur value on track click", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "backdrop-filter": "blur(6px)" });
|
||||
expect(host.textContent).toContain("Backdrop");
|
||||
expect(host.textContent).toContain("6px");
|
||||
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
|
||||
// Track order is Layer blur, Backdrop, Opacity — Backdrop is the second track.
|
||||
const backdropTrack = tracks[1];
|
||||
Object.defineProperty(backdropTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
|
||||
});
|
||||
// backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
|
||||
expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not render a fill/knob highlight for a zero-value blur (default tier)", () => {
|
||||
const { host, root } = renderSection({});
|
||||
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
|
||||
// Only the first two tracks are the blur sliders (Layer blur, Backdrop); Opacity
|
||||
// (the third track) always renders a fill by design, so it's excluded here.
|
||||
const blurTracks = Array.from(tracks).slice(0, 2);
|
||||
for (const track of blurTracks) {
|
||||
expect(track.querySelectorAll('[data-flat-slider-fill="true"]')).toHaveLength(0);
|
||||
}
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
function getInsetSideInputOrNull(host: HTMLElement, label: "T" | "R" | "B" | "L") {
|
||||
const span = Array.from(host.querySelectorAll("span")).find((el) => el.textContent === label);
|
||||
return span?.parentElement?.querySelector<HTMLInputElement>("input") ?? null;
|
||||
}
|
||||
|
||||
function getInsetSideInput(host: HTMLElement, label: "T" | "R" | "B" | "L"): HTMLInputElement {
|
||||
const input = getInsetSideInputOrNull(host, label);
|
||||
if (!input) throw new Error(`expected an inset side input for "${label}"`);
|
||||
return input;
|
||||
}
|
||||
|
||||
async function commitInsetSideInput(
|
||||
host: HTMLElement,
|
||||
label: "T" | "R" | "B" | "L",
|
||||
nextValue: string,
|
||||
) {
|
||||
const input = getInsetSideInput(host, label);
|
||||
act(() => {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, nextValue);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("FlatStyleSection — Overflow and Mask", () => {
|
||||
it("renders Overflow and Mask selects, and inset-side rows when the mask is an inset", () => {
|
||||
const { host, root } = renderSection({
|
||||
overflow: "hidden",
|
||||
"clip-path": "inset(8px round 4px)",
|
||||
});
|
||||
expect(host.textContent).toContain("Overflow");
|
||||
expect(host.textContent).toContain("Mask");
|
||||
expect(host.textContent).toContain("hidden");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits an overflow change through onSetStyle", () => {
|
||||
const onSetStyle = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatStyleSection
|
||||
projectId="proj-1"
|
||||
element={makeElement()}
|
||||
styles={{}}
|
||||
assets={[]}
|
||||
onSetStyle={onSetStyle}
|
||||
gsapBorderRadius={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const overflowSelect = Array.from(host.querySelectorAll("select")).find((s) =>
|
||||
Array.from(s.options).some((o) => o.value === "scroll"),
|
||||
);
|
||||
if (!overflowSelect) throw new Error("expected the overflow select");
|
||||
act(() => {
|
||||
overflowSelect.value = "hidden";
|
||||
overflowSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onSetStyle).toHaveBeenCalledWith("overflow", "hidden");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("resets overflow back to visible via the reset button", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ overflow: "scroll" });
|
||||
const { resetButton } = getFlatSelectRow(host, "Overflow");
|
||||
if (!resetButton) throw new Error("expected the overflow reset button");
|
||||
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("overflow", "visible");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a mask preset change through onSetStyle, building an inset() clip-path", () => {
|
||||
const { host, root, onSetStyle } = renderSection({});
|
||||
changeFlatSelectRow(host, "Mask", "inset");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(0 round 0px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("resets the mask back to none via the reset button", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "circle(50% at 50% 50%)" });
|
||||
const { resetButton } = getFlatSelectRow(host, "Mask");
|
||||
if (!resetButton) throw new Error("expected the mask reset button");
|
||||
act(() => resetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "none");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not render inset-side rows when the mask is not an inset", () => {
|
||||
const { host, root } = renderSection({ "clip-path": "circle(50% at 50% 50%)" });
|
||||
expect(getInsetSideInputOrNull(host, "T")).toBeNull();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a T inset-side edit through onSetStyle, preserving the other sides and radius", async () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
|
||||
await commitInsetSideInput(host, "T", "10");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(10px 8px 8px 8px round 4px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits an L inset-side edit through onSetStyle", async () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
|
||||
await commitInsetSideInput(host, "L", "2");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 8px 2px round 4px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits an R inset-side edit through onSetStyle", async () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
|
||||
await commitInsetSideInput(host, "R", "3");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 3px 8px 8px round 4px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a B inset-side edit through onSetStyle", async () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
|
||||
await commitInsetSideInput(host, "B", "5");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatStyleSection — Opacity", () => {
|
||||
it("renders the Opacity slider at 100% by default and commits a change through onSetStyle", () => {
|
||||
const onSetStyle = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatStyleSection
|
||||
projectId="proj-1"
|
||||
element={makeElement()}
|
||||
styles={{}}
|
||||
assets={[]}
|
||||
onSetStyle={onSetStyle}
|
||||
gsapBorderRadius={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(host.textContent).toContain("Opacity");
|
||||
expect(host.textContent).toContain("100%");
|
||||
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
|
||||
const opacityTrack = tracks[tracks.length - 1];
|
||||
Object.defineProperty(opacityTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
|
||||
});
|
||||
expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { useEffect, useState } from "react";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { Link as LinkIcon } from "../../icons/SystemIcons";
|
||||
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
||||
import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
|
||||
import {
|
||||
buildBoxShadowPresetValue,
|
||||
buildClipPathValue,
|
||||
buildInsetClipPathSides,
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
extractBackgroundImageUrl,
|
||||
formatNumericValue,
|
||||
formatPxMetricValue,
|
||||
getClipPathInsetPx,
|
||||
getCssFilterFunctionPx,
|
||||
inferBoxShadowPreset,
|
||||
inferClipPathPreset,
|
||||
normalizePanelPxValue,
|
||||
parseInsetClipPathSides,
|
||||
parseNumericValue,
|
||||
parsePxMetricValue,
|
||||
setCssFilterFunctionPx,
|
||||
type BoxShadowPreset,
|
||||
type ClipPathInsetSides,
|
||||
} from "./propertyPanelHelpers";
|
||||
import {
|
||||
FlatRow,
|
||||
FlatSegmentedRow,
|
||||
FlatSelectRow,
|
||||
FlatSlider,
|
||||
} from "./propertyPanelFlatPrimitives";
|
||||
import { MetricField } from "./propertyPanelPrimitives";
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Stroke row — combined width+style+color */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function FlatStrokeRow({
|
||||
styles,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const borderWidthValue =
|
||||
parsePxMetricValue(styles["border-width"] ?? "") ??
|
||||
parsePxMetricValue(styles["border-top-width"] ?? "") ??
|
||||
0;
|
||||
const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
|
||||
const borderColorValue =
|
||||
styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
|
||||
const summary = formatStrokeSummary(borderWidthValue, borderStyleValue);
|
||||
const tier = resolveValueTier(
|
||||
styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
|
||||
formatStrokeSummary(0, "none"),
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatRow
|
||||
label="Stroke"
|
||||
value={summary}
|
||||
tier={tier}
|
||||
disabled={disabled}
|
||||
onCommit={async (next) => {
|
||||
const parsed = parseStrokeSummary(next);
|
||||
if (!parsed) return;
|
||||
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
||||
formatPxMetricValue(parsed.widthPx),
|
||||
parsed.style,
|
||||
)) {
|
||||
await onSetStyle(property, value);
|
||||
}
|
||||
for (const [property, value] of buildStrokeStyleUpdates(
|
||||
parsed.style,
|
||||
formatPxMetricValue(parsed.widthPx),
|
||||
)) {
|
||||
await onSetStyle(property, value);
|
||||
}
|
||||
}}
|
||||
suffix={
|
||||
<>
|
||||
<span
|
||||
className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
|
||||
style={{ backgroundColor: borderColorValue }}
|
||||
/>
|
||||
<span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Radius row — uniform case; legacy fallback otherwise */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function FlatRadiusRow({
|
||||
styles,
|
||||
gsapBorderRadius,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const radiusTL =
|
||||
gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
|
||||
const radiusTR =
|
||||
gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
|
||||
const radiusBR =
|
||||
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
|
||||
const radiusBL =
|
||||
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
|
||||
const uniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL;
|
||||
|
||||
const commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => {
|
||||
const px = `${formatNumericValue(value)}px`;
|
||||
if (corner === "all") {
|
||||
void onSetStyle("border-radius", px);
|
||||
return;
|
||||
}
|
||||
const prop = {
|
||||
tl: "border-top-left-radius",
|
||||
tr: "border-top-right-radius",
|
||||
br: "border-bottom-right-radius",
|
||||
bl: "border-bottom-left-radius",
|
||||
}[corner];
|
||||
void onSetStyle(prop, px);
|
||||
};
|
||||
|
||||
if (!uniform) {
|
||||
return (
|
||||
<BorderRadiusEditor
|
||||
tl={radiusTL}
|
||||
tr={radiusTR}
|
||||
br={radiusBR}
|
||||
bl={radiusBL}
|
||||
disabled={disabled}
|
||||
onCommit={commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatRow
|
||||
label="Radius"
|
||||
value={`${formatNumericValue(radiusTL)}px`}
|
||||
tier={resolveValueTier(styles["border-radius"], "0px")}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => {
|
||||
const parsed = parsePxMetricValue(next.endsWith("px") ? next : `${next}px`);
|
||||
if (parsed == null) return;
|
||||
const normalized = normalizePanelPxValue(`${parsed}px`, {
|
||||
min: 0,
|
||||
max: 400,
|
||||
fallback: radiusTL,
|
||||
});
|
||||
commit("all", normalized != null ? (parsePxMetricValue(normalized) ?? radiusTL) : radiusTL);
|
||||
}}
|
||||
suffix={
|
||||
<span className="flex items-center gap-1 text-[10px] text-panel-text-4">
|
||||
<LinkIcon size={10} />
|
||||
Linked
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Shadow + Blend rows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FlatShadowBlendRows({
|
||||
styles,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
|
||||
const blendValue = styles["mix-blend-mode"] || "normal";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatSelectRow
|
||||
label="Shadow"
|
||||
value={boxShadowPreset}
|
||||
options={["none", "soft", "lift", "glow", "custom"]}
|
||||
tier={resolveValueTier(boxShadowPreset === "none" ? undefined : boxShadowPreset, "none")}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
if (next === "custom") return;
|
||||
void onSetStyle(
|
||||
"box-shadow",
|
||||
buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
|
||||
);
|
||||
}}
|
||||
onReset={() => void onSetStyle("box-shadow", "none")}
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Blend"
|
||||
value={blendValue}
|
||||
options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
|
||||
tier={resolveValueTier(styles["mix-blend-mode"], "normal")}
|
||||
disabled={disabled}
|
||||
onChange={(next) => void onSetStyle("mix-blend-mode", next)}
|
||||
onReset={() => void onSetStyle("mix-blend-mode", "normal")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Layer blur + Backdrop sliders */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FlatBlurSliders({
|
||||
styles,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
|
||||
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatSlider
|
||||
label="Layer blur"
|
||||
value={filterBlurValue}
|
||||
min={0}
|
||||
max={Math.max(40, Math.ceil(filterBlurValue))}
|
||||
tier={filterBlurValue > 0 ? "explicitCustom" : "default"}
|
||||
displayValue={`${formatNumericValue(filterBlurValue)}px`}
|
||||
disabled={disabled}
|
||||
onCommit={(next) =>
|
||||
void onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
|
||||
}
|
||||
/>
|
||||
<FlatSlider
|
||||
label="Backdrop"
|
||||
value={backdropBlurValue}
|
||||
min={0}
|
||||
max={Math.max(60, Math.ceil(backdropBlurValue))}
|
||||
tier={backdropBlurValue > 0 ? "explicitCustom" : "default"}
|
||||
displayValue={`${formatNumericValue(backdropBlurValue)}px`}
|
||||
disabled={disabled}
|
||||
onCommit={(next) =>
|
||||
void onSetStyle(
|
||||
"backdrop-filter",
|
||||
setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Overflow + Mask rows (+ inset sides) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FlatOverflowMaskRows({
|
||||
styles,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const clipPathValue = styles["clip-path"] || "none";
|
||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
||||
const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
||||
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
||||
const clipInsetSides = parsedClipInsets ?? {
|
||||
top: clipInsetValue,
|
||||
right: clipInsetValue,
|
||||
bottom: clipInsetValue,
|
||||
left: clipInsetValue,
|
||||
radius: radiusValue,
|
||||
};
|
||||
const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null;
|
||||
|
||||
const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => {
|
||||
const next = parsePxMetricValue(nextValue);
|
||||
if (next == null) return;
|
||||
const sides: ClipPathInsetSides = {
|
||||
top: clipInsetSides.top,
|
||||
right: clipInsetSides.right,
|
||||
bottom: clipInsetSides.bottom,
|
||||
left: clipInsetSides.left,
|
||||
};
|
||||
sides[side] = next;
|
||||
void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatSelectRow
|
||||
label="Overflow"
|
||||
value={styles.overflow || "visible"}
|
||||
options={["visible", "hidden", "clip", "auto", "scroll"]}
|
||||
tier={resolveValueTier(styles.overflow, "visible")}
|
||||
disabled={disabled}
|
||||
onChange={(next) => void onSetStyle("overflow", next)}
|
||||
onReset={() => void onSetStyle("overflow", "visible")}
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Mask"
|
||||
value={clipPathPreset === "custom" ? "none" : clipPathPreset}
|
||||
options={["none", "inset", "circle"]}
|
||||
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
void onSetStyle(
|
||||
"clip-path",
|
||||
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
|
||||
);
|
||||
}}
|
||||
onReset={() => void onSetStyle("clip-path", "none")}
|
||||
/>
|
||||
{showClipInsetSides && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<MetricField
|
||||
label="T"
|
||||
value={formatPxMetricValue(clipInsetSides.top)}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => commitClipInsetSide("top", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="R"
|
||||
value={formatPxMetricValue(clipInsetSides.right)}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => commitClipInsetSide("right", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="B"
|
||||
value={formatPxMetricValue(clipInsetSides.bottom)}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => commitClipInsetSide("bottom", next)}
|
||||
/>
|
||||
<MetricField
|
||||
label="L"
|
||||
value={formatPxMetricValue(clipInsetSides.left)}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => commitClipInsetSide("left", next)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Opacity slider */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function FlatOpacitySlider({
|
||||
styles,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
||||
|
||||
return (
|
||||
<FlatSlider
|
||||
label="Opacity"
|
||||
value={opacityValue}
|
||||
min={0}
|
||||
max={100}
|
||||
tier="explicitCustom"
|
||||
displayValue={`${opacityValue}%`}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => void onSetStyle("opacity", formatNumericValue(next / 100))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlatStyleSection({
|
||||
projectId,
|
||||
element,
|
||||
styles,
|
||||
assets,
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
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;
|
||||
}) {
|
||||
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FlatFillFields
|
||||
projectId={projectId}
|
||||
element={element}
|
||||
styles={styles}
|
||||
assets={assets}
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
/>
|
||||
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
|
||||
<FlatRadiusRow
|
||||
styles={styles}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
disabled={styleEditingDisabled}
|
||||
onSetStyle={onSetStyle}
|
||||
/>
|
||||
<FlatShadowBlendRows
|
||||
styles={styles}
|
||||
disabled={styleEditingDisabled}
|
||||
onSetStyle={onSetStyle}
|
||||
/>
|
||||
<FlatBlurSliders styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
|
||||
<FlatOverflowMaskRows
|
||||
styles={styles}
|
||||
disabled={styleEditingDisabled}
|
||||
onSetStyle={onSetStyle}
|
||||
/>
|
||||
<FlatOpacitySlider styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowClockwise,
|
||||
Gear,
|
||||
Scissors as PhScissors,
|
||||
Link as PhLink,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
||||
|
||||
@@ -69,3 +70,4 @@ export const Camera = makeIcon(PhCamera);
|
||||
export const RotateCw = makeIcon(ArrowClockwise);
|
||||
export const Settings = makeIcon(Gear);
|
||||
export const Scissors = makeIcon(PhScissors);
|
||||
export const Link = makeIcon(PhLink);
|
||||
|
||||
Reference in New Issue
Block a user