mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): restore stroke color, radius unlink, and mask inset in flat Style
This commit is contained in:
@@ -337,6 +337,16 @@
|
||||
"file": "packages/studio/src/components/editor/propertyPanelSections.tsx",
|
||||
"exports": ["TextAreaField"],
|
||||
},
|
||||
// Link: its only consumer was FlatRadiusRow's uniform-only fallback row in
|
||||
// propertyPanelFlatStyleSections.tsx, deleted by the Style parity fix
|
||||
// (p8-task-style-parity) — that row was unreachable from a uniform radius
|
||||
// and is now replaced by BorderRadiusEditor's own unlink toggle. Kept in
|
||||
// the icon set for future reuse rather than deleted from a file outside
|
||||
// this fix's scope.
|
||||
{
|
||||
"file": "packages/studio/src/icons/SystemIcons.tsx",
|
||||
"exports": ["Link"],
|
||||
},
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
// Mirrors legacy `propertyPanelStyleSections.tsx`'s `SelectField` "Style" options —
|
||||
// the single source of truth for which border-style tokens are valid.
|
||||
export const STROKE_STYLE_OPTIONS: string[] = [
|
||||
"none",
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"hidden",
|
||||
"groove",
|
||||
"ridge",
|
||||
"inset",
|
||||
"outset",
|
||||
];
|
||||
|
||||
export function formatStrokeSummary(widthPx: number, style: string): string {
|
||||
return `${widthPx}px ${style}`;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,24 @@ const STROKE_STYLES = {
|
||||
"border-color": "rgba(255,255,255,.12)",
|
||||
};
|
||||
|
||||
function getMetricFieldInput(host: HTMLElement, label: string): HTMLInputElement {
|
||||
const spans = Array.from(host.querySelectorAll("span")).filter((el) => el.textContent === label);
|
||||
for (const span of spans) {
|
||||
const input = span.parentElement?.querySelector<HTMLInputElement>("input");
|
||||
if (input) return input;
|
||||
}
|
||||
throw new Error(`expected a metric field input for "${label}"`);
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, nextValue: string) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(input, nextValue);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("FlatStyleSection — Stroke and Radius", () => {
|
||||
it("renders the combined stroke row and commits width+style together on blur", () => {
|
||||
const { host, root } = renderSection(STROKE_STYLES);
|
||||
@@ -172,21 +190,77 @@ describe("FlatStyleSection — Stroke and Radius", () => {
|
||||
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");
|
||||
it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
|
||||
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
||||
await commitFlatRowInput(host, "Stroke", "9999px solid");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => {
|
||||
it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => {
|
||||
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
||||
await commitFlatRowInput(host, "Stroke", "12px bogus");
|
||||
expect(onSetStyle).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a stroke style change through the discoverable Stroke style select (fix 2)", () => {
|
||||
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
||||
changeFlatSelectRow(host, "Stroke style", "dashed");
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a new stroke color through the flat ColorField (fix 1)", () => {
|
||||
const { host, root, onSetStyle } = renderSection({
|
||||
"border-width": "1px",
|
||||
"border-style": "solid",
|
||||
"border-color": "rgb(10, 20, 30)",
|
||||
});
|
||||
const trigger = Array.from(
|
||||
host.querySelectorAll<HTMLButtonElement>('[data-flat-color-trigger="true"]'),
|
||||
).find((btn) => btn.getAttribute("aria-label") === "Pick stroke color color");
|
||||
if (!trigger) throw new Error("expected the stroke color trigger");
|
||||
act(() => trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const hexInput = Array.from(document.querySelectorAll<HTMLInputElement>("input")).find(
|
||||
(input) => !host.contains(input),
|
||||
);
|
||||
if (!hexInput) throw new Error("expected the color picker's hex input");
|
||||
act(() => setInputValue(hexInput, "#112233"));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("uses BorderRadiusEditor for radius, linked by default, even when corners are uniform (fix 3)", () => {
|
||||
const { host, root } = renderSection({ "border-radius": "12px" });
|
||||
const unlinkButton = host.querySelector<HTMLButtonElement>('button[title="Unlink corners"]');
|
||||
expect(unlinkButton).not.toBeNull();
|
||||
expect(getMetricFieldInput(host, "All").value).toBe("12");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a uniform radius value through BorderRadiusEditor's linked All field", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
|
||||
await commitFlatRowInput(host, "Radius", "20px");
|
||||
const allInput = getMetricFieldInput(host, "All");
|
||||
act(() => setInputValue(allInput, "20"));
|
||||
act(() => allInput.dispatchEvent(new Event("focusout", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a single-corner radius update after unlinking a uniform radius (fix 3)", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" });
|
||||
const unlinkButton = host.querySelector<HTMLButtonElement>('button[title="Unlink corners"]');
|
||||
if (!unlinkButton) throw new Error("expected the unlink toggle button");
|
||||
act(() => unlinkButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
const trInput = getMetricFieldInput(host, "TR");
|
||||
act(() => setInputValue(trInput, "18"));
|
||||
act(() => trInput.dispatchEvent(new Event("focusout", { bubbles: true })));
|
||||
expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px");
|
||||
expect(onSetStyle).not.toHaveBeenCalledWith("border-radius", expect.anything());
|
||||
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");
|
||||
@@ -199,14 +273,7 @@ describe("FlatStyleSection — Stroke and Radius", () => {
|
||||
(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(() => setInputValue(trInput, "18"));
|
||||
act(() => {
|
||||
trInput.dispatchEvent(new Event("focusout", { bubbles: true }));
|
||||
});
|
||||
@@ -472,6 +539,25 @@ describe("FlatStyleSection — Overflow and Mask", () => {
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders a uniform Mask inset slider and commits clip-path via buildInsetClipPathValue (fix 4)", () => {
|
||||
const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" });
|
||||
expect(host.textContent).toContain("Mask inset");
|
||||
const tracks = host.querySelectorAll('[data-flat-slider-track="true"]');
|
||||
// Track order: Layer blur, Backdrop, Mask inset, Opacity.
|
||||
const maskInsetTrack = tracks[2];
|
||||
Object.defineProperty(maskInsetTrack, "getBoundingClientRect", {
|
||||
value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }),
|
||||
});
|
||||
act(() => {
|
||||
maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
|
||||
});
|
||||
// clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px.
|
||||
// border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue
|
||||
// (read from the `border-radius` style, matching legacy) is 0.
|
||||
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(60px round 0px)");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlatStyleSection — Opacity", () => {
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
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 {
|
||||
formatStrokeSummary,
|
||||
parseStrokeSummary,
|
||||
STROKE_STYLE_OPTIONS,
|
||||
} from "./propertyPanelFlatStyleHelpers";
|
||||
import {
|
||||
buildBoxShadowPresetValue,
|
||||
buildClipPathValue,
|
||||
buildInsetClipPathSides,
|
||||
buildInsetClipPathValue,
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
extractBackgroundImageUrl,
|
||||
@@ -170,6 +174,7 @@ function FlatStrokeRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatRow
|
||||
label="Stroke"
|
||||
value={summary}
|
||||
@@ -178,16 +183,20 @@ function FlatStrokeRow({
|
||||
onCommit={async (next) => {
|
||||
const parsed = parseStrokeSummary(next);
|
||||
if (!parsed) return;
|
||||
if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
|
||||
const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
|
||||
min: 0,
|
||||
max: 200,
|
||||
fallback: borderWidthValue,
|
||||
});
|
||||
if (!normalizedWidth) return;
|
||||
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
||||
formatPxMetricValue(parsed.widthPx),
|
||||
normalizedWidth,
|
||||
parsed.style,
|
||||
)) {
|
||||
await onSetStyle(property, value);
|
||||
}
|
||||
for (const [property, value] of buildStrokeStyleUpdates(
|
||||
parsed.style,
|
||||
formatPxMetricValue(parsed.widthPx),
|
||||
)) {
|
||||
for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
|
||||
await onSetStyle(property, value);
|
||||
}
|
||||
}}
|
||||
@@ -201,6 +210,29 @@ function FlatStrokeRow({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Stroke style"
|
||||
value={borderStyleValue}
|
||||
options={STROKE_STYLE_OPTIONS}
|
||||
tier={resolveValueTier(styles["border-style"], "none")}
|
||||
disabled={disabled}
|
||||
onChange={async (next) => {
|
||||
for (const [property, value] of buildStrokeStyleUpdates(
|
||||
next,
|
||||
formatPxMetricValue(borderWidthValue),
|
||||
)) {
|
||||
await onSetStyle(property, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ColorField
|
||||
flat
|
||||
label="Stroke color"
|
||||
value={borderColorValue}
|
||||
disabled={disabled}
|
||||
onCommit={(next) => onSetStyle("border-color", next)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,7 +261,6 @@ function FlatRadiusRow({
|
||||
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`;
|
||||
@@ -246,7 +277,6 @@ function FlatRadiusRow({
|
||||
void onSetStyle(prop, px);
|
||||
};
|
||||
|
||||
if (!uniform) {
|
||||
return (
|
||||
<BorderRadiusEditor
|
||||
tl={radiusTL}
|
||||
@@ -257,32 +287,6 @@ function FlatRadiusRow({
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -380,10 +384,7 @@ function FlatBlurSliders({
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flat Overflow + Mask rows (+ inset sides) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Flat Overflow + Mask rows (+ inset sides).
|
||||
function FlatOverflowMaskRows({
|
||||
styles,
|
||||
disabled,
|
||||
@@ -396,6 +397,55 @@ function FlatOverflowMaskRows({
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const clipPathValue = styles["clip-path"] || "none";
|
||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
||||
|
||||
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")}
|
||||
/>
|
||||
<FlatMaskInsetRows
|
||||
clipPathValue={clipPathValue}
|
||||
radiusValue={radiusValue}
|
||||
disabled={disabled}
|
||||
onSetStyle={onSetStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Flat Mask inset — uniform slider + per-side fields.
|
||||
function FlatMaskInsetRows({
|
||||
clipPathValue,
|
||||
radiusValue,
|
||||
disabled,
|
||||
onSetStyle,
|
||||
}: {
|
||||
clipPathValue: string;
|
||||
radiusValue: number;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
||||
const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
||||
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
||||
const clipInsetSides = parsedClipInsets ?? {
|
||||
@@ -422,28 +472,18 @@ function FlatOverflowMaskRows({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatSelectRow
|
||||
label="Overflow"
|
||||
value={styles.overflow || "visible"}
|
||||
options={["visible", "hidden", "clip", "auto", "scroll"]}
|
||||
tier={resolveValueTier(styles.overflow, "visible")}
|
||||
<FlatSlider
|
||||
label="Mask inset"
|
||||
value={clipInsetValue}
|
||||
min={0}
|
||||
max={Math.max(120, Math.ceil(clipInsetValue))}
|
||||
step={1}
|
||||
tier={clipInsetValue > 0 ? "explicitCustom" : "default"}
|
||||
displayValue={`${formatNumericValue(clipInsetValue)}px`}
|
||||
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")}
|
||||
onCommit={(next) =>
|
||||
void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
|
||||
}
|
||||
/>
|
||||
{showClipInsetSides && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
|
||||
Reference in New Issue
Block a user