mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols fallow's reachability analysis identifies as unused. Keeps only the cases where the symbol is still referenced internally in its own file (so removing `export` doesn't surface a new oxlint `no-unused-vars` error). Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused exports), with no behavior change — each symbol is still defined and used exactly the same way within its file. Reverted ~20 files where fallow's auto-fix would have created cascading "declared but never used" lint errors — those are cases where the symbol isn't used at all, and properly cleaning them up means deleting the declaration, not just dropping `export`. Better to land that as a separate, narrower PR rather than mixing it into a mechanical de-export. Also reverted four false positives where fallow missed real consumers: - `captureCost.ts` (renderOrchestrator has two separate import blocks from the same module; fallow only saw the first) - `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses fallow's reachability missed) - `render.ts` (functions imported via `await import()` dynamic import, which fallow's static analysis doesn't follow) Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean, cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
export type GradientKind = "linear" | "radial" | "conic";
|
|
|
|
export type RadialSizeKeyword =
|
|
| "closest-side"
|
|
| "closest-corner"
|
|
| "farthest-side"
|
|
| "farthest-corner";
|
|
|
|
export interface GradientStop {
|
|
color: string;
|
|
position: number;
|
|
}
|
|
|
|
export interface GradientModel {
|
|
kind: GradientKind;
|
|
repeating: boolean;
|
|
angle: number;
|
|
centerX: number;
|
|
centerY: number;
|
|
shape: "circle" | "ellipse";
|
|
radialSize: RadialSizeKeyword;
|
|
stops: GradientStop[];
|
|
}
|
|
|
|
const RADIAL_SIZE_KEYWORDS: RadialSizeKeyword[] = [
|
|
"closest-side",
|
|
"closest-corner",
|
|
"farthest-side",
|
|
"farthest-corner",
|
|
];
|
|
|
|
function isWhitespace(char: string | undefined): boolean {
|
|
return char === " " || char === "\n" || char === "\r" || char === "\t" || char === "\f";
|
|
}
|
|
|
|
function isDigit(char: string | undefined): boolean {
|
|
return char != null && char >= "0" && char <= "9";
|
|
}
|
|
|
|
function isSimpleNumber(value: string): boolean {
|
|
if (!value) return false;
|
|
let index = value[0] === "-" ? 1 : 0;
|
|
let digits = 0;
|
|
|
|
while (isDigit(value[index])) {
|
|
index += 1;
|
|
digits += 1;
|
|
}
|
|
|
|
if (value[index] === ".") {
|
|
index += 1;
|
|
while (isDigit(value[index])) {
|
|
index += 1;
|
|
digits += 1;
|
|
}
|
|
}
|
|
|
|
return digits > 0 && index === value.length;
|
|
}
|
|
|
|
function parseCssNumber(value: string | undefined): number | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!isSimpleNumber(trimmed)) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function splitCssWhitespace(value: string): string[] {
|
|
const tokens: string[] = [];
|
|
let current = "";
|
|
|
|
for (const char of value) {
|
|
if (isWhitespace(char)) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = "";
|
|
}
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
|
|
if (current) tokens.push(current);
|
|
return tokens;
|
|
}
|
|
|
|
function hasCssWord(value: string, word: string): boolean {
|
|
return splitCssWhitespace(value.toLowerCase()).includes(word);
|
|
}
|
|
|
|
function parsePercentToken(value: string | undefined, fallback: number): number {
|
|
if (!value?.endsWith("%")) return fallback;
|
|
const parsed = parseCssNumber(value.slice(0, -1));
|
|
return parsed == null ? fallback : clamp(parsed, 0, 100);
|
|
}
|
|
|
|
function parseAngleToken(value: string | undefined): number | null {
|
|
const trimmed = value?.trim().toLowerCase();
|
|
if (!trimmed?.endsWith("deg")) return null;
|
|
return parseCssNumber(trimmed.slice(0, -3));
|
|
}
|
|
|
|
function trailingPercentStart(value: string): number | null {
|
|
if (!value.endsWith("%")) return null;
|
|
const withoutUnit = value.slice(0, -1).trimEnd();
|
|
let start = withoutUnit.length;
|
|
|
|
while (start > 0 && (isDigit(withoutUnit[start - 1]) || withoutUnit[start - 1] === ".")) {
|
|
start -= 1;
|
|
}
|
|
|
|
if (start > 0 && withoutUnit[start - 1] === "-") {
|
|
start -= 1;
|
|
}
|
|
|
|
const token = withoutUnit.slice(start);
|
|
if (!isSimpleNumber(token)) return null;
|
|
if (start === 0 || !isWhitespace(withoutUnit[start - 1])) return null;
|
|
return start;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function round(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function parsePercent(value: string | undefined, fallback: number): number {
|
|
const parsed = parseCssNumber(value);
|
|
return parsed == null ? fallback : clamp(parsed, 0, 100);
|
|
}
|
|
|
|
function parseColorStop(raw: string): { color: string; position: number | null } {
|
|
const trimmed = raw.trim();
|
|
const percentStart = trailingPercentStart(trimmed);
|
|
if (percentStart == null) return { color: trimmed, position: null };
|
|
|
|
const withoutUnit = trimmed.slice(0, -1).trimEnd();
|
|
return {
|
|
color: withoutUnit.slice(0, percentStart).trim(),
|
|
position: parsePercent(withoutUnit.slice(percentStart), 0),
|
|
};
|
|
}
|
|
|
|
function normalizeStops(stops: Array<{ color: string; position: number | null }>): GradientStop[] {
|
|
if (stops.length === 0) {
|
|
return [
|
|
{ color: "rgba(60, 230, 172, 0.18)", position: 0 },
|
|
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
|
|
];
|
|
}
|
|
|
|
if (stops.length === 1) {
|
|
return [
|
|
{ color: stops[0].color, position: 0 },
|
|
{ color: stops[0].color, position: 100 },
|
|
];
|
|
}
|
|
|
|
const result = stops.map((stop, index) => ({
|
|
color: stop.color,
|
|
position: stop.position ?? (index / (stops.length - 1)) * 100,
|
|
}));
|
|
|
|
return result.map((stop) => ({
|
|
color: stop.color,
|
|
position: round(clamp(stop.position, 0, 100)),
|
|
}));
|
|
}
|
|
|
|
function splitGradientArgs(value: string): string[] {
|
|
const parts: string[] = [];
|
|
let current = "";
|
|
let depth = 0;
|
|
|
|
for (const char of value) {
|
|
if (char === "(") depth += 1;
|
|
if (char === ")") depth = Math.max(0, depth - 1);
|
|
|
|
if (char === "," && depth === 0) {
|
|
if (current.trim()) parts.push(current.trim());
|
|
current = "";
|
|
continue;
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
if (current.trim()) parts.push(current.trim());
|
|
return parts;
|
|
}
|
|
|
|
function directionToAngle(value: string): number | null {
|
|
const normalized = value.trim().toLowerCase();
|
|
const map: Record<string, number> = {
|
|
"to top": 0,
|
|
"to top right": 45,
|
|
"to right top": 45,
|
|
"to right": 90,
|
|
"to bottom right": 135,
|
|
"to right bottom": 135,
|
|
"to bottom": 180,
|
|
"to bottom left": 225,
|
|
"to left bottom": 225,
|
|
"to left": 270,
|
|
"to top left": 315,
|
|
"to left top": 315,
|
|
};
|
|
return normalized in map ? map[normalized] : null;
|
|
}
|
|
|
|
function parseLinearArgs(parts: string[]): GradientModel {
|
|
const first = parts[0] ?? "";
|
|
const angleFromDirection = directionToAngle(first);
|
|
const parsedAngle = parseAngleToken(first);
|
|
const firstIsAngle = parsedAngle != null;
|
|
const angle = parsedAngle ?? angleFromDirection ?? 180;
|
|
const stopParts = firstIsAngle || angleFromDirection != null ? parts.slice(1) : parts;
|
|
|
|
return {
|
|
kind: "linear",
|
|
repeating: false,
|
|
angle,
|
|
centerX: 50,
|
|
centerY: 50,
|
|
shape: "ellipse",
|
|
radialSize: "farthest-corner",
|
|
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
};
|
|
}
|
|
|
|
function parseRadialArgs(parts: string[]): GradientModel {
|
|
const first = parts[0] ?? "";
|
|
const firstLower = first.toLowerCase();
|
|
const hasConfig =
|
|
hasCssWord(firstLower, "at") ||
|
|
hasCssWord(firstLower, "circle") ||
|
|
hasCssWord(firstLower, "ellipse") ||
|
|
firstLower.includes("closest-") ||
|
|
firstLower.includes("farthest-");
|
|
const config = hasConfig ? first : "";
|
|
const stopParts = hasConfig ? parts.slice(1) : parts;
|
|
const configLower = config.toLowerCase();
|
|
const configTokens = splitCssWhitespace(configLower);
|
|
const atIndex = configTokens.indexOf("at");
|
|
|
|
const shape = hasCssWord(configLower, "circle") ? "circle" : "ellipse";
|
|
const radialSize =
|
|
RADIAL_SIZE_KEYWORDS.find((keyword) => configTokens.includes(keyword)) ?? "farthest-corner";
|
|
|
|
return {
|
|
kind: "radial",
|
|
repeating: false,
|
|
angle: 180,
|
|
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
|
|
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
|
|
shape,
|
|
radialSize,
|
|
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
};
|
|
}
|
|
|
|
function parseConicArgs(parts: string[]): GradientModel {
|
|
const first = parts[0] ?? "";
|
|
const firstLower = first.toLowerCase();
|
|
const hasConfig = hasCssWord(firstLower, "from") || hasCssWord(firstLower, "at");
|
|
const config = hasConfig ? first : "";
|
|
const stopParts = hasConfig ? parts.slice(1) : parts;
|
|
const configTokens = splitCssWhitespace(config.toLowerCase());
|
|
const fromIndex = configTokens.indexOf("from");
|
|
const atIndex = configTokens.indexOf("at");
|
|
const angle = parseAngleToken(configTokens[fromIndex + 1]);
|
|
|
|
return {
|
|
kind: "conic",
|
|
repeating: false,
|
|
angle: angle ?? 0,
|
|
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
|
|
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
|
|
shape: "ellipse",
|
|
radialSize: "farthest-corner",
|
|
stops: normalizeStops(stopParts.map(parseColorStop)),
|
|
};
|
|
}
|
|
|
|
export function buildDefaultGradientModel(fallbackColor?: string): GradientModel {
|
|
return {
|
|
kind: "linear",
|
|
repeating: false,
|
|
angle: 135,
|
|
centerX: 50,
|
|
centerY: 50,
|
|
shape: "ellipse",
|
|
radialSize: "farthest-corner",
|
|
stops: normalizeStops([
|
|
{
|
|
color:
|
|
fallbackColor && fallbackColor !== "transparent"
|
|
? fallbackColor
|
|
: "rgba(60, 230, 172, 0.18)",
|
|
position: 0,
|
|
},
|
|
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
|
|
]),
|
|
};
|
|
}
|
|
|
|
export function parseGradient(value: string | undefined): GradientModel | null {
|
|
if (!value || value === "none") return null;
|
|
const trimmed = value.trim();
|
|
const openParenIndex = trimmed.indexOf("(");
|
|
if (openParenIndex <= 0 || !trimmed.endsWith(")")) return null;
|
|
|
|
const functionName = trimmed.slice(0, openParenIndex).toLowerCase();
|
|
const kindByFunctionName: Record<string, { kind: GradientKind; repeating: boolean }> = {
|
|
"linear-gradient": { kind: "linear", repeating: false },
|
|
"radial-gradient": { kind: "radial", repeating: false },
|
|
"conic-gradient": { kind: "conic", repeating: false },
|
|
"repeating-linear-gradient": { kind: "linear", repeating: true },
|
|
"repeating-radial-gradient": { kind: "radial", repeating: true },
|
|
"repeating-conic-gradient": { kind: "conic", repeating: true },
|
|
};
|
|
const parsedFunction = kindByFunctionName[functionName];
|
|
if (!parsedFunction) return null;
|
|
|
|
const { kind, repeating } = parsedFunction;
|
|
const parts = splitGradientArgs(trimmed.slice(openParenIndex + 1, -1));
|
|
|
|
const parsed =
|
|
kind === "linear"
|
|
? parseLinearArgs(parts)
|
|
: kind === "radial"
|
|
? parseRadialArgs(parts)
|
|
: parseConicArgs(parts);
|
|
|
|
return { ...parsed, repeating };
|
|
}
|
|
|
|
function formatStop(stop: GradientStop): string {
|
|
return `${stop.color} ${round(stop.position)}%`;
|
|
}
|
|
|
|
export function serializeGradient(model: GradientModel): string {
|
|
const fn = `${model.repeating ? "repeating-" : ""}${model.kind}-gradient`;
|
|
const stops = model.stops.map(formatStop).join(", ");
|
|
|
|
if (model.kind === "linear") {
|
|
return `${fn}(${round(model.angle)}deg, ${stops})`;
|
|
}
|
|
|
|
if (model.kind === "radial") {
|
|
return `${fn}(${model.shape} ${model.radialSize} at ${round(model.centerX)}% ${round(
|
|
model.centerY,
|
|
)}%, ${stops})`;
|
|
}
|
|
|
|
return `${fn}(from ${round(model.angle)}deg at ${round(model.centerX)}% ${round(
|
|
model.centerY,
|
|
)}%, ${stops})`;
|
|
}
|
|
|
|
function blendChannel(start: number, end: number, ratio: number): number {
|
|
return Math.round(start + (end - start) * ratio);
|
|
}
|
|
|
|
function formatHex(channel: number): string {
|
|
return channel.toString(16).padStart(2, "0");
|
|
}
|
|
|
|
function interpolateGradientStopColor(model: GradientModel, position: number): string {
|
|
const clampedPosition = clamp(position, 0, 100);
|
|
const sortedStops = [...model.stops].sort((a, b) => a.position - b.position);
|
|
const exact = sortedStops.find((stop) => Math.abs(stop.position - clampedPosition) < 0.001);
|
|
if (exact) return exact.color;
|
|
|
|
const right = sortedStops.find((stop) => stop.position > clampedPosition) ?? sortedStops.at(-1);
|
|
const left =
|
|
[...sortedStops].reverse().find((stop) => stop.position < clampedPosition) ?? sortedStops[0];
|
|
if (!left || !right) return sortedStops[0]?.color ?? "rgba(255, 255, 255, 1)";
|
|
if (left === right) return left.color;
|
|
|
|
const leftColor = left.color;
|
|
const rightColor = right.color;
|
|
const leftParsed = leftColor ? parseColorString(leftColor) : null;
|
|
const rightParsed = rightColor ? parseColorString(rightColor) : null;
|
|
if (!leftParsed || !rightParsed) return left.color;
|
|
|
|
const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
|
|
const red = blendChannel(leftParsed.red, rightParsed.red, ratio);
|
|
const green = blendChannel(leftParsed.green, rightParsed.green, ratio);
|
|
const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio);
|
|
const alpha = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio);
|
|
|
|
if (alpha >= 1) {
|
|
return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase();
|
|
}
|
|
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
|
|
export function insertGradientStop(model: GradientModel, position: number): GradientModel {
|
|
const clampedPosition = round(clamp(position, 0, 100));
|
|
const color = interpolateGradientStopColor(model, clampedPosition);
|
|
const nextStops = [...model.stops, { color, position: clampedPosition }].sort(
|
|
(a, b) => a.position - b.position,
|
|
);
|
|
return {
|
|
...model,
|
|
stops: nextStops,
|
|
};
|
|
}
|
|
|
|
function parseColorString(
|
|
value: string,
|
|
): { red: number; green: number; blue: number; alpha: number } | null {
|
|
const trimmed = value.trim().toLowerCase();
|
|
if (trimmed === "transparent") {
|
|
return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
}
|
|
|
|
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
|
|
if (hex) {
|
|
return {
|
|
red: Number.parseInt(hex[1].slice(0, 2), 16),
|
|
green: Number.parseInt(hex[1].slice(2, 4), 16),
|
|
blue: Number.parseInt(hex[1].slice(4, 6), 16),
|
|
alpha: 1,
|
|
};
|
|
}
|
|
|
|
const rgba = trimmed.match(
|
|
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
|
);
|
|
if (!rgba) return null;
|
|
|
|
return {
|
|
red: Number.parseFloat(rgba[1]),
|
|
green: Number.parseFloat(rgba[2]),
|
|
blue: Number.parseFloat(rgba[3]),
|
|
alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1,
|
|
};
|
|
}
|