diff --git a/packages/studio/src/components/editor/PropertyPanel.test.tsx b/packages/studio/src/components/editor/PropertyPanel.test.tsx index 5c3845e591..d38f67b6dc 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.test.tsx @@ -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 = 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, + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index e0c544effd..574a8d7785 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -268,6 +268,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro return ( 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("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 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(() => + isTextEditableSelection(element) ? "text" : showEditableSections ? "style" : "", + ); const [pinnedGroupIds, setPinnedGroupIds] = useState([]); 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 (
@@ -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 ?? "")} > )} + {showEditableSections && ( + 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)}%`} + > + + + )} + {sections.timing && ( { act(() => root.unmount()); }); }); + +describe("FlatSlider", () => { + it("renders the default tier with a dim knob at the correct position", () => { + const { host, root } = renderInto( + , + ); + const knob = host.querySelector('[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( + , + ); + const fill = host.querySelector('[data-flat-slider-fill="true"]'); + expect(fill?.style.width).toBe("100%"); + const knob = host.querySelector('[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( + , + ); + const track = host.querySelector('[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( + , + ); + 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( + , + ); + const select = host.querySelector("select"); + expect(select?.className).toContain("text-panel-accent"); + const reset = host.querySelector('[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( + , + ); + const select = host.querySelector("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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 067ecb7a7b..6cc2217358 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -233,3 +233,145 @@ export function PinnedZoneDivider() {
); } + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ {label} +
{ + if (disabled) return; + commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); + }} + > + {tier === "explicitCustom" && ( +
+ )} +
+
+ + {displayValue} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* FlatSelectRow — label/value row backed by a native 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) => ( + + ))} + + + + + + {tier === "explicitCustom" && onReset && ( + + )} + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts new file mode 100644 index 0000000000..dff96a42f2 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts new file mode 100644 index 0000000000..3f1db7ef73 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts @@ -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 }; +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx new file mode 100644 index 0000000000..90767fec4d --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -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 { + 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 = {}, + overrides: Partial = {}, + 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( + , + ); + }); + 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(".group")); + const row = rows.find((el) => el.querySelector("span")?.textContent === label); + const input = row?.querySelector("input"); + if (!input) throw new Error(`expected an input for row "${label}"`); + return input; +} + +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("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(".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("select"); + if (!select) throw new Error(`expected a select for "${label}"`); + const resetButton = row.querySelector('[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( + , + ); + }); + 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("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( + , + ); + }); + 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( + , + ); + }); + 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()); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx new file mode 100644 index 0000000000..0819ae6603 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -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; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; +}) { + 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 ( + <> + + {preferredFillMode === "Solid" ? ( + onSetStyle("background-color", next)} + /> + ) : preferredFillMode === "Gradient" ? ( + onSetStyle("background-image", next)} + /> + ) : ( + onSetStyle("background-image", next)} + onImportAssets={onImportAssets} + /> + )} + {!hasTextControls && ( + onSetStyle("color", next)} + /> + )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Stroke row — combined width+style+color */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatStrokeRow({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + 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 ( + { + 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={ + <> + + {borderColorValue} + + } + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Radius row — uniform case; legacy fallback otherwise */ +/* ------------------------------------------------------------------ */ + +// fallow-ignore-next-line complexity +function FlatRadiusRow({ + styles, + gsapBorderRadius, + disabled, + onSetStyle, +}: { + styles: Record; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + 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 ( + + ); + } + + return ( + { + 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={ + + + Linked + + } + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Shadow + Blend rows */ +/* ------------------------------------------------------------------ */ + +function FlatShadowBlendRows({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]); + const blendValue = styles["mix-blend-mode"] || "normal"; + + return ( + <> + { + if (next === "custom") return; + void onSetStyle( + "box-shadow", + buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]), + ); + }} + onReset={() => void onSetStyle("box-shadow", "none")} + /> + 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; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur"); + const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur"); + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(filterBlurValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next)) + } + /> + 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; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + 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 ( + <> + void onSetStyle("overflow", next)} + onReset={() => void onSetStyle("overflow", "visible")} + /> + { + void onSetStyle( + "clip-path", + buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), + ); + }} + onReset={() => void onSetStyle("clip-path", "none")} + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Flat Opacity slider */ +/* ------------------------------------------------------------------ */ + +function FlatOpacitySlider({ + styles, + disabled, + onSetStyle, +}: { + styles: Record; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); + + return ( + void onSetStyle("opacity", formatNumericValue(next / 100))} + /> + ); +} + +export function FlatStyleSection({ + projectId, + element, + styles, + assets, + onSetStyle, + onImportAssets, + gsapBorderRadius, +}: { + projectId: string; + element: DomEditSelection; + styles: Record; + assets: string[]; + onSetStyle: (prop: string, value: string) => void | Promise; + onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; +}) { + const styleEditingDisabled = !element.capabilities.canEditStyles; + return ( +
+ + + + + + + +
+ ); +} diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 4f47d415b2..ca3f24d95d 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -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);