Files
hyperframes/packages/studio/src/components/editor/clipPathHelpers.ts
T

114 lines
3.9 KiB
TypeScript

import { roundToCenti } from "../../utils/rounding";
export interface ClipPathInsetSides {
top: number;
right: number;
bottom: number;
left: number;
}
export type ParsedInsetClipPathSides = ClipPathInsetSides & { radius: number };
function formatClipNumber(value: number): string {
const rounded = roundToCenti(value);
return Number.isInteger(rounded)
? `${rounded}`
: rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
function formatClipPx(value: number): string {
return `${formatClipNumber(Math.max(0, value))}px`;
}
function parseInsetLengthPx(value: string): number | null {
const normalized = value.trim();
if (normalized === "0") return 0;
const match = /^(-?\d+(?:\.\d+)?)px$/i.exec(normalized);
if (!match) return null;
const parsed = Number.parseFloat(match[1]);
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
}
function sidesFromInsetTokens(tokens: number[]): ClipPathInsetSides | null {
if (tokens.length < 1 || tokens.length > 4) return null;
// CSS shorthand expansion: T | T R | T R B | T R B L
const [top, right = top, bottom = top, left = right] = tokens;
if (top === undefined || right === undefined || bottom === undefined || left === undefined) {
return null;
}
return { top, right, bottom, left };
}
export function inferClipPathPreset(
value: string | undefined,
): "none" | "inset" | "circle" | "custom" {
const normalized = value?.trim();
if (!normalized || normalized === "none") return "none";
if (/^inset\(/i.test(normalized)) return "inset";
if (/^circle\(/i.test(normalized)) return "circle";
return "custom";
}
export function parseInsetClipPathSides(
value: string | undefined,
): ParsedInsetClipPathSides | null {
// Unambiguous pattern (no nested optional whitespace) to avoid polynomial
// backtracking on adversarial input; trim the payload instead.
const match = /^inset\(([^()]*)\)$/i.exec(value?.trim() ?? "");
if (!match) return null;
const parts = match[1]
.trim()
.replace(/\s+/g, " ")
.split(/ round /i);
const insetPart = parts[0]?.trim();
if (!insetPart || parts.length > 2) return null;
const tokens = insetPart.split(/\s+/).map(parseInsetLengthPx);
if (tokens.some((token) => token == null)) return null;
const numericTokens: number[] = [];
for (const token of tokens) {
if (token == null) return null;
numericTokens.push(token);
}
const sides = sidesFromInsetTokens(numericTokens);
if (!sides) return null;
const radiusPart = parts[1]?.trim();
const radius = radiusPart ? parseInsetLengthPx(radiusPart) : 0;
if (radius == null) return null;
return { ...sides, radius };
}
export function getClipPathInsetPx(value: string | undefined): number {
const parsed = parseInsetClipPathSides(value);
if (!parsed) return 0;
const { top, right, bottom, left } = parsed;
return top === right && top === bottom && top === left ? top : 0;
}
export function buildClipPathValue(
preset: "none" | "inset" | "circle" | "custom",
radiusValue: number,
fallback: string | undefined,
) {
if (preset === "custom") return fallback?.trim() || "none";
if (preset === "circle") return "circle(50% at 50% 50%)";
if (preset === "inset") {
return `inset(0 round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
}
return "none";
}
export function buildInsetClipPathSides(sides: ClipPathInsetSides, radiusPx: number = 0): string {
const values = [sides.top, sides.right, sides.bottom, sides.left].map(formatClipPx);
const [top, right, bottom, left] = values;
const inset =
top === right && top === bottom && top === left ? top : `${top} ${right} ${bottom} ${left}`;
const radius = Math.max(0, radiusPx);
return radius > 0 ? `inset(${inset} round ${formatClipNumber(radius)}px)` : `inset(${inset})`;
}
export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
return `inset(${formatClipPx(insetPx)} round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
}