mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2127 from heygen-com/studio-flat-08-style-grade-parity
fix(studio): Style/Grade/Text parity gaps found in legacy-retirement audit
This commit is contained in:
@@ -337,6 +337,16 @@
|
|||||||
"file": "packages/studio/src/components/editor/propertyPanelSections.tsx",
|
"file": "packages/studio/src/components/editor/propertyPanelSections.tsx",
|
||||||
"exports": ["TextAreaField"],
|
"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": [
|
"ignoreDependencies": [
|
||||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||||
|
|||||||
@@ -103,6 +103,110 @@ describe("FlatColorGradingAccessory", () => {
|
|||||||
expect(resetGrading).toHaveBeenCalledTimes(1);
|
expect(resetGrading).toHaveBeenCalledTimes(1);
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the runtime status message as visible text next to the dot, not only as a title", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: neutralGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "pending", message: "Waiting for shader" },
|
||||||
|
commitCompare: vi.fn(),
|
||||||
|
resetGrading: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const messageEl = host.querySelector('[data-flat-grade-status-message="true"]');
|
||||||
|
expect(messageEl).not.toBeNull();
|
||||||
|
expect(messageEl?.textContent).toBe("Waiting for shader");
|
||||||
|
expect(host.textContent).toContain("Waiting for shader");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
function activeGrading() {
|
||||||
|
const grading = neutralGrading();
|
||||||
|
return { ...grading, adjust: { ...grading.adjust, contrast: 0.2 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("activates hold-to-compare on pointerdown and releases on window pointerup", () => {
|
||||||
|
const commitCompare = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: activeGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "active", message: "Shader active" },
|
||||||
|
commitCompare,
|
||||||
|
resetGrading: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const compareButton = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[aria-label="Hold to show original"]',
|
||||||
|
);
|
||||||
|
if (!compareButton) throw new Error("expected a compare button");
|
||||||
|
act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
|
||||||
|
act(() => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })));
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates hold-to-compare via keyboard Space and releases on keyup", () => {
|
||||||
|
const commitCompare = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: activeGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "active", message: "Shader active" },
|
||||||
|
commitCompare,
|
||||||
|
resetGrading: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const compareButton = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[aria-label="Hold to show original"]',
|
||||||
|
);
|
||||||
|
if (!compareButton) throw new Error("expected a compare button");
|
||||||
|
act(() =>
|
||||||
|
compareButton.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
|
||||||
|
act(() =>
|
||||||
|
compareButton.dispatchEvent(
|
||||||
|
new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases an active hold when the window loses focus mid-hold", () => {
|
||||||
|
const commitCompare = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingAccessory
|
||||||
|
state={{
|
||||||
|
grading: activeGrading(),
|
||||||
|
compareEnabled: false,
|
||||||
|
runtimeStatus: { state: "active", message: "Shader active" },
|
||||||
|
commitCompare,
|
||||||
|
resetGrading: vi.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const compareButton = host.querySelector<HTMLButtonElement>(
|
||||||
|
'[aria-label="Hold to show original"]',
|
||||||
|
);
|
||||||
|
if (!compareButton) throw new Error("expected a compare button");
|
||||||
|
act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(1, true);
|
||||||
|
act(() => window.dispatchEvent(new Event("blur")));
|
||||||
|
expect(commitCompare).toHaveBeenNthCalledWith(2, false);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function neutralPropsBase() {
|
function neutralPropsBase() {
|
||||||
@@ -280,6 +384,43 @@ describe("FlatColorGradingSection — Adjust sliders", () => {
|
|||||||
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2);
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("revives a grade parked at 0% strength back to 100% when an Adjust slider is committed", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = { ...neutralGrading(), intensity: 0 };
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast");
|
||||||
|
// min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 0.5
|
||||||
|
dragSliderTrack(contrastRow, 75, 100);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT force intensity to revive when the Strength slider itself is dragged — it writes the value directly", () => {
|
||||||
|
const onCommitColorGrading = vi.fn();
|
||||||
|
const grading = { ...neutralGrading(), intensity: 0 };
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
grading={grading}
|
||||||
|
onCommitColorGrading={onCommitColorGrading}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const strengthRow = findRowByText(host, "div", "Strength", "startsWith");
|
||||||
|
// min=0, max=100, step=1, ratio=0.4 -> raw=40 -> commit(40) -> intensity = 40/100 = 0.4
|
||||||
|
dragSliderTrack(strengthRow, 40, 100);
|
||||||
|
expect(onCommitColorGrading).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(0.4);
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FlatColorGradingSection — Vignette and Grain", () => {
|
describe("FlatColorGradingSection — Vignette and Grain", () => {
|
||||||
@@ -386,6 +527,46 @@ describe("FlatColorGradingSection — HDR banner and Apply scope", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a codec/profile/pixel-format/color detail line in the HDR banner when metadata provides it", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
mediaMetadata={{
|
||||||
|
kind: "video",
|
||||||
|
color: {
|
||||||
|
dynamicRange: "hdr",
|
||||||
|
hdrTransfer: "pq",
|
||||||
|
label: "HDR10",
|
||||||
|
isHdr: true,
|
||||||
|
codecName: "hevc",
|
||||||
|
profile: "Main10",
|
||||||
|
pixelFormat: "yuv420p10le",
|
||||||
|
colorPrimaries: "bt2020",
|
||||||
|
colorTransfer: "smpte2084",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const detail = host.querySelector('[data-flat-grade-hdr-detail="true"]');
|
||||||
|
expect(detail).not.toBeNull();
|
||||||
|
expect(detail?.textContent).toBe("hevc · Main10 · yuv420p10le · bt2020 · smpte2084");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the HDR detail line entirely when no detail fields are populated", () => {
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatColorGradingSection
|
||||||
|
{...neutralPropsBase()}
|
||||||
|
mediaMetadata={{
|
||||||
|
kind: "video",
|
||||||
|
color: { dynamicRange: "hdr", hdrTransfer: "pq", label: "HDR10", isHdr: true },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.querySelector('[data-flat-grade-hdr-detail="true"]')).toBeNull();
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("omits the HDR banner for SDR media", () => {
|
it("omits the HDR banner for SDR media", () => {
|
||||||
const { host, root } = renderInto(
|
const { host, root } = renderInto(
|
||||||
<FlatColorGradingSection
|
<FlatColorGradingSection
|
||||||
|
|||||||
@@ -49,20 +49,43 @@ export function FlatColorGradingAccessory({
|
|||||||
commitCompare(false);
|
commitCompare(false);
|
||||||
window.removeEventListener("pointerup", release);
|
window.removeEventListener("pointerup", release);
|
||||||
window.removeEventListener("pointercancel", release);
|
window.removeEventListener("pointercancel", release);
|
||||||
|
window.removeEventListener("blur", release);
|
||||||
};
|
};
|
||||||
window.addEventListener("pointerup", release);
|
window.addEventListener("pointerup", release);
|
||||||
window.addEventListener("pointercancel", release);
|
window.addEventListener("pointercancel", release);
|
||||||
|
window.addEventListener("blur", release);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (compareEnabled) commitCompare(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!compareEnabled) commitCompare(true);
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
commitCompare(false);
|
||||||
}}
|
}}
|
||||||
title="Hold to show original"
|
title="Hold to show original"
|
||||||
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Compare size={12} />
|
<Compare size={12} />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span className="flex min-w-0 items-center gap-1" title={runtimeStatus.message}>
|
||||||
data-flat-grade-status-dot="true"
|
<span
|
||||||
title={runtimeStatus.message}
|
data-flat-grade-status-dot="true"
|
||||||
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
|
title={runtimeStatus.message}
|
||||||
/>
|
className={`h-[5px] w-[5px] flex-shrink-0 rounded-full ${STATUS_DOT_CLASS[runtimeStatus.state]}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
data-flat-grade-status-message="true"
|
||||||
|
className="max-w-[84px] truncate text-[9px] text-panel-text-4"
|
||||||
|
>
|
||||||
|
{runtimeStatus.message}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-flat-grade-reset="true"
|
data-flat-grade-reset="true"
|
||||||
@@ -100,6 +123,11 @@ const ADJUST_SLIDERS: Array<{
|
|||||||
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
|
{ key: "saturation", label: "Saturation", min: -100, max: 100, step: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function visibleIntensity(grading: NormalizedHfColorGrading): number {
|
||||||
|
// Earlier drafts could persist 0% strength; the next manual edit should revive visible grading.
|
||||||
|
return grading.intensity === 0 ? 1 : grading.intensity;
|
||||||
|
}
|
||||||
|
|
||||||
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
|
function formatAdjustValue(key: HfColorGradingAdjustKey, rawPercent: number): string {
|
||||||
if (key === "exposure") {
|
if (key === "exposure") {
|
||||||
const stops = rawPercent / 100;
|
const stops = rawPercent / 100;
|
||||||
@@ -140,6 +168,15 @@ const EFFECT_SLIDERS: Array<{ key: HfColorGradingEffectKey; label: string }> = [
|
|||||||
|
|
||||||
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
||||||
if (metadata?.color.dynamicRange !== "hdr") return null;
|
if (metadata?.color.dynamicRange !== "hdr") return null;
|
||||||
|
const details = [
|
||||||
|
metadata.color.codecName,
|
||||||
|
metadata.color.profile,
|
||||||
|
metadata.color.pixelFormat,
|
||||||
|
metadata.color.colorPrimaries,
|
||||||
|
metadata.color.colorTransfer,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-flat-grade-hdr-banner="true"
|
data-flat-grade-hdr-banner="true"
|
||||||
@@ -155,6 +192,14 @@ function HdrBanner({ metadata }: { metadata: MediaMetadata | null }) {
|
|||||||
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
|
These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
|
||||||
is not true HDR color grading yet.
|
is not true HDR color grading yet.
|
||||||
</p>
|
</p>
|
||||||
|
{details && (
|
||||||
|
<p
|
||||||
|
data-flat-grade-hdr-detail="true"
|
||||||
|
className="mt-0.5 truncate text-[9px] text-amber-100/55"
|
||||||
|
>
|
||||||
|
{details}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,7 +246,11 @@ export function FlatColorGradingSection({
|
|||||||
onCommitColorGrading({ ...grading, intensity: value / 100 });
|
onCommitColorGrading({ ...grading, intensity: value / 100 });
|
||||||
};
|
};
|
||||||
const applyLut = (src: string | null, intensity = 1) => {
|
const applyLut = (src: string | null, intensity = 1) => {
|
||||||
onCommitColorGrading({ ...grading, lut: src ? { src, intensity } : null });
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
|
lut: src ? { src, intensity } : null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const importLuts = async (files: FileList | null) => {
|
const importLuts = async (files: FileList | null) => {
|
||||||
if (!files?.length || !onImportAssets) return;
|
if (!files?.length || !onImportAssets) return;
|
||||||
@@ -225,11 +274,16 @@ export function FlatColorGradingSection({
|
|||||||
displayValue={`${Math.round(value * 100)}%`}
|
displayValue={`${Math.round(value * 100)}%`}
|
||||||
centerTick={key === "vignetteRoundness"}
|
centerTick={key === "vignetteRoundness"}
|
||||||
onCommit={(next) =>
|
onCommit={(next) =>
|
||||||
onCommitColorGrading({ ...grading, details: { ...grading.details, [key]: next / 100 } })
|
onCommitColorGrading({
|
||||||
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
|
details: { ...grading.details, [key]: next / 100 },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
details: { ...grading.details, [key]: spec.defaultValue },
|
details: { ...grading.details, [key]: spec.defaultValue },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -361,12 +415,14 @@ export function FlatColorGradingSection({
|
|||||||
onCommit={(next) =>
|
onCommit={(next) =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
adjust: { ...grading.adjust, [slider.key]: next / 100 },
|
adjust: { ...grading.adjust, [slider.key]: next / 100 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
adjust: { ...grading.adjust, [slider.key]: 0 },
|
adjust: { ...grading.adjust, [slider.key]: 0 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -432,12 +488,14 @@ export function FlatColorGradingSection({
|
|||||||
onCommit={(next) =>
|
onCommit={(next) =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
effects: { ...grading.effects, [slider.key]: next / 100 },
|
effects: { ...grading.effects, [slider.key]: next / 100 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onReset={() =>
|
onReset={() =>
|
||||||
onCommitColorGrading({
|
onCommitColorGrading({
|
||||||
...grading,
|
...grading,
|
||||||
|
intensity: visibleIntensity(grading),
|
||||||
effects: { ...grading.effects, [slider.key]: 0 },
|
effects: { ...grading.effects, [slider.key]: 0 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function formatStrokeSummary(widthPx: number, style: string): string {
|
||||||
return `${widthPx}px ${style}`;
|
return `${widthPx}px ${style}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,24 @@ const STROKE_STYLES = {
|
|||||||
"border-color": "rgba(255,255,255,.12)",
|
"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", () => {
|
describe("FlatStyleSection — Stroke and Radius", () => {
|
||||||
it("renders the combined stroke row and commits width+style together on blur", () => {
|
it("renders the combined stroke row and commits width+style together on blur", () => {
|
||||||
const { host, root } = renderSection(STROKE_STYLES);
|
const { host, root } = renderSection(STROKE_STYLES);
|
||||||
@@ -172,21 +190,77 @@ describe("FlatStyleSection — Stroke and Radius", () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a single Radius value with a Linked indicator when corners are uniform", () => {
|
it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
|
||||||
const { host, root } = renderSection({ "border-radius": "12px" });
|
const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
|
||||||
expect(host.textContent).toContain("Radius");
|
await commitFlatRowInput(host, "Stroke", "9999px solid");
|
||||||
expect(getFlatRowInput(host, "Radius").value).toBe("12px");
|
expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
|
||||||
expect(host.textContent).toContain("Linked");
|
|
||||||
act(() => root.unmount());
|
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" });
|
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");
|
expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px");
|
||||||
act(() => root.unmount());
|
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", () => {
|
it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => {
|
||||||
const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
|
const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 });
|
||||||
expect(host.textContent).not.toContain("Linked");
|
expect(host.textContent).not.toContain("Linked");
|
||||||
@@ -199,14 +273,7 @@ describe("FlatStyleSection — Stroke and Radius", () => {
|
|||||||
(el) => el.value === "12",
|
(el) => el.value === "12",
|
||||||
);
|
);
|
||||||
if (!trInput) throw new Error("expected the TR corner input");
|
if (!trInput) throw new Error("expected the TR corner input");
|
||||||
act(() => {
|
act(() => setInputValue(trInput, "18"));
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
||||||
window.HTMLInputElement.prototype,
|
|
||||||
"value",
|
|
||||||
)?.set;
|
|
||||||
nativeInputValueSetter?.call(trInput, "18");
|
|
||||||
trInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
});
|
|
||||||
act(() => {
|
act(() => {
|
||||||
trInput.dispatchEvent(new Event("focusout", { bubbles: true }));
|
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)");
|
expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)");
|
||||||
act(() => root.unmount());
|
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", () => {
|
describe("FlatStyleSection — Opacity", () => {
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
|
||||||
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
|
||||||
import { Link as LinkIcon } from "../../icons/SystemIcons";
|
|
||||||
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
||||||
import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
|
import {
|
||||||
|
formatStrokeSummary,
|
||||||
|
parseStrokeSummary,
|
||||||
|
STROKE_STYLE_OPTIONS,
|
||||||
|
} from "./propertyPanelFlatStyleHelpers";
|
||||||
import {
|
import {
|
||||||
buildBoxShadowPresetValue,
|
buildBoxShadowPresetValue,
|
||||||
buildClipPathValue,
|
buildClipPathValue,
|
||||||
buildInsetClipPathSides,
|
buildInsetClipPathSides,
|
||||||
|
buildInsetClipPathValue,
|
||||||
buildStrokeStyleUpdates,
|
buildStrokeStyleUpdates,
|
||||||
buildStrokeWidthStyleUpdates,
|
buildStrokeWidthStyleUpdates,
|
||||||
extractBackgroundImageUrl,
|
extractBackgroundImageUrl,
|
||||||
@@ -170,42 +174,70 @@ function FlatStrokeRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatRow
|
<>
|
||||||
label="Stroke"
|
<FlatRow
|
||||||
value={summary}
|
label="Stroke"
|
||||||
tier={tier}
|
value={summary}
|
||||||
disabled={disabled}
|
tier={tier}
|
||||||
onCommit={async (next) => {
|
disabled={disabled}
|
||||||
const parsed = parseStrokeSummary(next);
|
onCommit={async (next) => {
|
||||||
if (!parsed) return;
|
const parsed = parseStrokeSummary(next);
|
||||||
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
if (!parsed) return;
|
||||||
formatPxMetricValue(parsed.widthPx),
|
if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
|
||||||
parsed.style,
|
const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
|
||||||
)) {
|
min: 0,
|
||||||
await onSetStyle(property, value);
|
max: 200,
|
||||||
|
fallback: borderWidthValue,
|
||||||
|
});
|
||||||
|
if (!normalizedWidth) return;
|
||||||
|
for (const [property, value] of buildStrokeWidthStyleUpdates(
|
||||||
|
normalizedWidth,
|
||||||
|
parsed.style,
|
||||||
|
)) {
|
||||||
|
await onSetStyle(property, value);
|
||||||
|
}
|
||||||
|
for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
for (const [property, value] of buildStrokeStyleUpdates(
|
/>
|
||||||
parsed.style,
|
<FlatSelectRow
|
||||||
formatPxMetricValue(parsed.widthPx),
|
label="Stroke style"
|
||||||
)) {
|
value={borderStyleValue}
|
||||||
await onSetStyle(property, value);
|
options={STROKE_STYLE_OPTIONS}
|
||||||
}
|
tier={resolveValueTier(styles["border-style"], "none")}
|
||||||
}}
|
disabled={disabled}
|
||||||
suffix={
|
onChange={async (next) => {
|
||||||
<>
|
for (const [property, value] of buildStrokeStyleUpdates(
|
||||||
<span
|
next,
|
||||||
className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
|
formatPxMetricValue(borderWidthValue),
|
||||||
style={{ backgroundColor: borderColorValue }}
|
)) {
|
||||||
/>
|
await onSetStyle(property, value);
|
||||||
<span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
|
}
|
||||||
</>
|
}}
|
||||||
}
|
/>
|
||||||
/>
|
<ColorField
|
||||||
|
flat
|
||||||
|
label="Stroke color"
|
||||||
|
value={borderColorValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onCommit={(next) => onSetStyle("border-color", next)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Flat Radius row — uniform case; legacy fallback otherwise */
|
/* Flat Radius row — always delegates to BorderRadiusEditor */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -229,7 +261,6 @@ function FlatRadiusRow({
|
|||||||
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
|
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
|
||||||
const radiusBL =
|
const radiusBL =
|
||||||
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
|
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 commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => {
|
||||||
const px = `${formatNumericValue(value)}px`;
|
const px = `${formatNumericValue(value)}px`;
|
||||||
@@ -246,41 +277,14 @@ function FlatRadiusRow({
|
|||||||
void onSetStyle(prop, px);
|
void onSetStyle(prop, px);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!uniform) {
|
|
||||||
return (
|
|
||||||
<BorderRadiusEditor
|
|
||||||
tl={radiusTL}
|
|
||||||
tr={radiusTR}
|
|
||||||
br={radiusBR}
|
|
||||||
bl={radiusBL}
|
|
||||||
disabled={disabled}
|
|
||||||
onCommit={commit}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatRow
|
<BorderRadiusEditor
|
||||||
label="Radius"
|
tl={radiusTL}
|
||||||
value={`${formatNumericValue(radiusTL)}px`}
|
tr={radiusTR}
|
||||||
tier={resolveValueTier(styles["border-radius"], "0px")}
|
br={radiusBR}
|
||||||
|
bl={radiusBL}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onCommit={(next) => {
|
onCommit={commit}
|
||||||
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({
|
function FlatOverflowMaskRows({
|
||||||
styles,
|
styles,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -396,6 +397,55 @@ function FlatOverflowMaskRows({
|
|||||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||||
const clipPathValue = styles["clip-path"] || "none";
|
const clipPathValue = styles["clip-path"] || "none";
|
||||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
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 parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
||||||
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
const clipInsetValue = getClipPathInsetPx(clipPathValue);
|
||||||
const clipInsetSides = parsedClipInsets ?? {
|
const clipInsetSides = parsedClipInsets ?? {
|
||||||
@@ -422,28 +472,18 @@ function FlatOverflowMaskRows({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FlatSelectRow
|
<FlatSlider
|
||||||
label="Overflow"
|
label="Mask inset"
|
||||||
value={styles.overflow || "visible"}
|
value={clipInsetValue}
|
||||||
options={["visible", "hidden", "clip", "auto", "scroll"]}
|
min={0}
|
||||||
tier={resolveValueTier(styles.overflow, "visible")}
|
max={Math.max(120, Math.ceil(clipInsetValue))}
|
||||||
|
step={1}
|
||||||
|
tier={clipInsetValue > 0 ? "explicitCustom" : "default"}
|
||||||
|
displayValue={`${formatNumericValue(clipInsetValue)}px`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(next) => void onSetStyle("overflow", next)}
|
onCommit={(next) =>
|
||||||
onReset={() => void onSetStyle("overflow", "visible")}
|
void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
|
||||||
/>
|
}
|
||||||
<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 && (
|
{showClipInsetSides && (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
|||||||
@@ -46,6 +46,26 @@ const FIELDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("FlatTextLayerList", () => {
|
describe("FlatTextLayerList", () => {
|
||||||
|
it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => {
|
||||||
|
const emptyFields = [
|
||||||
|
{ ...FIELDS[0], value: "" },
|
||||||
|
{ ...FIELDS[1], value: "" },
|
||||||
|
];
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextLayerList
|
||||||
|
fields={emptyFields as never}
|
||||||
|
activeFieldKey="a"
|
||||||
|
styles={{}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onAdd={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("Text 1");
|
||||||
|
expect(host.textContent).toContain("Text 2");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
|
it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn();
|
||||||
@@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection {
|
|||||||
} as DomEditSelection;
|
} as DomEditSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSingleFieldElement(overrides: Partial<DomEditTextField> = {}): DomEditSelection {
|
||||||
|
const base = makeMultiFieldElement();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
textFields: [
|
||||||
|
{
|
||||||
|
key: "a",
|
||||||
|
label: "Text",
|
||||||
|
value: "Headline",
|
||||||
|
tagName: "div",
|
||||||
|
attributes: [],
|
||||||
|
inlineStyles: {},
|
||||||
|
computedStyles: {},
|
||||||
|
source: "self",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as DomEditSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] {
|
||||||
|
const labelSpan = Array.from(host.querySelectorAll("span")).find(
|
||||||
|
(el) => el.textContent === label,
|
||||||
|
);
|
||||||
|
const row = labelSpan?.parentElement;
|
||||||
|
return Array.from(row?.querySelectorAll<HTMLButtonElement>('[data-flat-segment="true"]') ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FlatTextFieldEditor controls", () => {
|
||||||
|
it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement()}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const capitalizeButton = segmentedRowButtons(host, "Case · Style").find(
|
||||||
|
(button) => button.textContent === "Ag",
|
||||||
|
);
|
||||||
|
expect(capitalizeButton).not.toBeUndefined();
|
||||||
|
act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lights up 'right' for text-align: end and commits the concrete 'right' value on click", () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const alignButtons = segmentedRowButtons(host, "Align");
|
||||||
|
const rightButton = alignButtons.find((button) => button.textContent === "R");
|
||||||
|
expect(rightButton).not.toBeUndefined();
|
||||||
|
expect(rightButton?.className).toContain("border-panel-accent");
|
||||||
|
act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "right");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("live-commits the Size field on input, without requiring blur/Enter", async () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement()}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const sizeLabel = Array.from(host.querySelectorAll("span")).find(
|
||||||
|
(el) => el.textContent === "Size",
|
||||||
|
);
|
||||||
|
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
|
||||||
|
if (!input) throw new Error("expected the Size row's input");
|
||||||
|
act(() => {
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
nativeInputValueSetter?.call(input, "24px");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
// liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 160));
|
||||||
|
});
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FlatTextSection — multi-field", () => {
|
describe("FlatTextSection — multi-field", () => {
|
||||||
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
|
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => {
|
|||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-focuses the Content textarea when a new text field is added", async () => {
|
||||||
|
let addResolved = false;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [fields, setFields] = useState<DomEditTextField[]>(makeMultiFieldElement().textFields);
|
||||||
|
const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
|
||||||
|
return (
|
||||||
|
<FlatTextSection
|
||||||
|
element={element}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={vi.fn()}
|
||||||
|
onAddTextField={() =>
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
addResolved = true;
|
||||||
|
setFields((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: "c",
|
||||||
|
label: "Text",
|
||||||
|
value: "",
|
||||||
|
tagName: "div",
|
||||||
|
attributes: [],
|
||||||
|
inlineStyles: {},
|
||||||
|
computedStyles: {},
|
||||||
|
source: "self",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return "c";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
|
||||||
|
// Wait for onAddTextField's promise to resolve (adds field "c" and makes it
|
||||||
|
// active) before checking focus, mirroring the async add-field pattern above.
|
||||||
|
await act(async () => {
|
||||||
|
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(addResolved).toBe(true);
|
||||||
|
|
||||||
|
const contentTextarea = host.querySelector("textarea");
|
||||||
|
expect(contentTextarea).not.toBeNull();
|
||||||
|
expect(document.activeElement).toBe(contentTextarea);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const CASE_OPTIONS = [
|
|||||||
{ key: "none", node: "–" },
|
{ key: "none", node: "–" },
|
||||||
{ key: "uppercase", node: "AG" },
|
{ key: "uppercase", node: "AG" },
|
||||||
{ key: "lowercase", node: "ag" },
|
{ key: "lowercase", node: "ag" },
|
||||||
|
{ key: "capitalize", node: "Ag" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function FlatTextFieldEditor({
|
function FlatTextFieldEditor({
|
||||||
@@ -44,6 +45,7 @@ function FlatTextFieldEditor({
|
|||||||
onImportFonts,
|
onImportFonts,
|
||||||
onSetText,
|
onSetText,
|
||||||
onSetTextFieldStyle,
|
onSetTextFieldStyle,
|
||||||
|
autoFocus = false,
|
||||||
}: {
|
}: {
|
||||||
field: DomEditSelection["textFields"][number];
|
field: DomEditSelection["textFields"][number];
|
||||||
styles: Record<string, string>;
|
styles: Record<string, string>;
|
||||||
@@ -51,6 +53,7 @@ function FlatTextFieldEditor({
|
|||||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||||
onSetText: (value: string, fieldKey?: string) => void;
|
onSetText: (value: string, fieldKey?: string) => void;
|
||||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
||||||
const weightOptions = detectAvailableWeights(
|
const weightOptions = detectAvailableWeights(
|
||||||
@@ -66,6 +69,7 @@ function FlatTextFieldEditor({
|
|||||||
flat
|
flat
|
||||||
label="Content"
|
label="Content"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
|
autoFocus={autoFocus}
|
||||||
onCommit={(next) => onSetText(next, field.key)}
|
onCommit={(next) => onSetText(next, field.key)}
|
||||||
/>
|
/>
|
||||||
<FontFamilyField
|
<FontFamilyField
|
||||||
@@ -79,6 +83,7 @@ function FlatTextFieldEditor({
|
|||||||
label="Size"
|
label="Size"
|
||||||
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
||||||
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
||||||
|
liveCommit
|
||||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
||||||
/>
|
/>
|
||||||
<div className="flex min-h-[30px] items-center justify-between">
|
<div className="flex min-h-[30px] items-center justify-between">
|
||||||
@@ -148,7 +153,10 @@ function FlatTextFieldEditor({
|
|||||||
options={ALIGN_OPTIONS.map((option) => ({
|
options={ALIGN_OPTIONS.map((option) => ({
|
||||||
key: option.key,
|
key: option.key,
|
||||||
node: option.node,
|
node: option.node,
|
||||||
active: align === option.key || (option.key === "left" && align === "start"),
|
active:
|
||||||
|
align === option.key ||
|
||||||
|
(option.key === "left" && align === "start") ||
|
||||||
|
(option.key === "right" && align === "end"),
|
||||||
}))}
|
}))}
|
||||||
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
|
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
|
||||||
/>
|
/>
|
||||||
@@ -234,12 +242,14 @@ export function FlatTextSection({
|
|||||||
onRemove={onRemoveTextField}
|
onRemove={onRemoveTextField}
|
||||||
/>
|
/>
|
||||||
<FlatTextFieldEditor
|
<FlatTextFieldEditor
|
||||||
|
key={activeField.key}
|
||||||
field={activeField}
|
field={activeField}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
fontAssets={fontAssets}
|
fontAssets={fontAssets}
|
||||||
onImportFonts={onImportFonts}
|
onImportFonts={onImportFonts}
|
||||||
onSetText={onSetText}
|
onSetText={onSetText}
|
||||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -296,7 +306,7 @@ export function FlatTextLayerList({
|
|||||||
Text layers
|
Text layers
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{fields.map((field) => {
|
{fields.map((field, index) => {
|
||||||
const active = field.key === activeFieldKey;
|
const active = field.key === activeFieldKey;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -313,7 +323,7 @@ export function FlatTextLayerList({
|
|||||||
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
||||||
{formatTextFieldPreview(field.value) || "Text"}
|
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
||||||
{field.tagName}
|
{field.tagName}
|
||||||
|
|||||||
Reference in New Issue
Block a user