mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add flat Stroke and Radius rows to the Style group
This commit is contained in:
@@ -47,6 +47,7 @@ function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelectio
|
||||
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);
|
||||
@@ -62,7 +63,7 @@ function renderSection(
|
||||
styles={mergedStyles}
|
||||
assets={[]}
|
||||
onSetStyle={onSetStyle}
|
||||
gsapBorderRadius={null}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -122,3 +123,94 @@ describe("FlatStyleSection — Fill", () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||
import { extractBackgroundImageUrl } from "./propertyPanelHelpers";
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
import { Link as LinkIcon } from "../../icons/SystemIcons";
|
||||
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
||||
import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
|
||||
import {
|
||||
buildStrokeStyleUpdates,
|
||||
buildStrokeWidthStyleUpdates,
|
||||
extractBackgroundImageUrl,
|
||||
formatNumericValue,
|
||||
formatPxMetricValue,
|
||||
normalizePanelPxValue,
|
||||
parseNumericValue,
|
||||
parsePxMetricValue,
|
||||
} from "./propertyPanelHelpers";
|
||||
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
import { resolveValueTier } from "./propertyPanelValueTier";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { GradientField, ImageFillField } from "./propertyPanelFill";
|
||||
@@ -115,6 +125,149 @@ function FlatFillFields({
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlatStyleSection({
|
||||
projectId,
|
||||
element,
|
||||
@@ -122,7 +275,6 @@ export function FlatStyleSection({
|
||||
assets,
|
||||
onSetStyle,
|
||||
onImportAssets,
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
gsapBorderRadius,
|
||||
}: {
|
||||
projectId: string;
|
||||
@@ -133,6 +285,7 @@ export function FlatStyleSection({
|
||||
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
|
||||
@@ -143,6 +296,13 @@ export function FlatStyleSection({
|
||||
onSetStyle={onSetStyle}
|
||||
onImportAssets={onImportAssets}
|
||||
/>
|
||||
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
|
||||
<FlatRadiusRow
|
||||
styles={styles}
|
||||
gsapBorderRadius={gsapBorderRadius}
|
||||
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