diff options
Diffstat (limited to 'apps/web/src/utils')
| -rw-r--r-- | apps/web/src/utils/color.ts | 364 | ||||
| -rw-r--r-- | apps/web/src/utils/id.ts | 56 | ||||
| -rw-r--r-- | apps/web/src/utils/math.ts | 168 |
3 files changed, 294 insertions, 294 deletions
diff --git a/apps/web/src/utils/color.ts b/apps/web/src/utils/color.ts index 7b01a92e..1176ffd3 100644 --- a/apps/web/src/utils/color.ts +++ b/apps/web/src/utils/color.ts @@ -1,182 +1,182 @@ -import { converter, formatHex, formatHex8, parse, type Rgb } from "culori"; - -export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv"; - -const toRgb = converter("rgb"); -const toHsv = converter("hsv"); -const toHsl = converter("hsl"); - -export function hexToHsv({ hex }: { hex: string }): [number, number, number] { - const color = toHsv(`#${hex}`); - if (!color) return [0, 0, 0]; - return [color.h ?? 0, color.s ?? 0, color.v ?? 0]; -} - -export function hsvToHex({ - h, - s, - v, -}: { - h: number; - s: number; - v: number; -}): string { - const hex = formatHex({ mode: "hsv", h, s, v }); - return hex.slice(1); -} - -export function parseHexAlpha({ hex }: { hex: string }): { - rgb: string; - alpha: number; -} { - const color = parse(`#${hex}`); - const rgbHex = color - ? formatHex(color).slice(1) - : hex.slice(0, 6).toLowerCase(); - return { - rgb: rgbHex, - alpha: color?.alpha ?? 1, - }; -} - -export function appendAlpha({ - rgbHex, - alpha, -}: { - rgbHex: string; - alpha: number; -}): string { - if (alpha >= 1) return rgbHex; - const hex8 = formatHex8({ mode: "rgb", r: 0, g: 0, b: 0, alpha }); - const alphaHex = hex8.slice(7, 9); - return rgbHex + alphaHex; -} - -function stripCssNoise({ text }: { text: string }): string { - let cleaned = text.trim(); - cleaned = cleaned - .replace(/\s*!important\s*/gi, "") - .replace(/;+\s*$/, "") - .trim(); - - const colonIndex = cleaned.indexOf(":"); - const parenIndex = cleaned.indexOf("("); - if (colonIndex !== -1 && (parenIndex === -1 || colonIndex < parenIndex)) { - cleaned = cleaned.slice(colonIndex + 1).trim(); - } - - return cleaned; -} - -function colorToHexWithAlpha({ color }: { color: Rgb }): string { - const hex = formatHex(color).slice(1); - if (color.alpha !== undefined && color.alpha < 1) { - const hex8 = formatHex8(color); - return hex8.slice(1); - } - return hex; -} - -export function extractColorFromText({ - text, -}: { - text: string; -}): string | null { - const cleaned = stripCssNoise({ text }); - - const color = toRgb(cleaned); - if (color) return colorToHexWithAlpha({ color }); - - // bare hex without # (culori needs the prefix) - const bareHexMatch = cleaned.match(/^([0-9a-fA-F]{3,8})$/); - if (bareHexMatch) { - const withHash = toRgb(`#${bareHexMatch[1]}`); - if (withHash) return colorToHexWithAlpha({ color: withHash }); - } - - // fallback: find #hex anywhere in the original text - const embeddedHexMatch = text.match(/#([0-9a-fA-F]{3,8})\b/); - if (embeddedHexMatch) { - const embedded = toRgb(`#${embeddedHexMatch[1]}`); - if (embedded) return colorToHexWithAlpha({ color: embedded }); - } - - return null; -} - -export function formatColorValue({ - hex, - format, -}: { - hex: string; - format: ColorFormat; -}): string { - switch (format) { - case "hex": - return hex; - case "rgb": { - const color = toRgb(`#${hex}`); - if (!color) return hex; - return `${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}`; - } - case "hsl": { - const color = toHsl(`#${hex}`); - if (!color) return hex; - return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.l ?? 0) * 100)}%`; - } - case "hsv": { - const color = toHsv(`#${hex}`); - if (!color) return hex; - return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.v ?? 0) * 100)}%`; - } - } -} - -export function parseColorInput({ - input, - format, -}: { - input: string; - format: ColorFormat; -}): string | null { - switch (format) { - case "hex": { - const cleaned = input.replace("#", ""); - const isValidHex = /^[0-9a-fA-F]{3,8}$/.test(cleaned); - return isValidHex ? cleaned : null; - } - case "rgb": { - const parts = input.split(",").map((part) => parseInt(part.trim(), 10)); - if (parts.length < 3 || parts.some(Number.isNaN)) return null; - const color = { - mode: "rgb" as const, - r: parts[0] / 255, - g: parts[1] / 255, - b: parts[2] / 255, - }; - return formatHex(color).slice(1); - } - case "hsl": { - const parts = input.split(",").map((part) => parseFloat(part.trim())); - if (parts.length < 3 || parts.some(Number.isNaN)) return null; - const color = { - mode: "hsl" as const, - h: parts[0], - s: parts[1] / 100, - l: parts[2] / 100, - }; - return formatHex(color).slice(1); - } - case "hsv": { - const parts = input.split(",").map((part) => parseFloat(part.trim())); - if (parts.length < 3 || parts.some(Number.isNaN)) return null; - const color = { - mode: "hsv" as const, - h: parts[0], - s: parts[1] / 100, - v: parts[2] / 100, - }; - return formatHex(color).slice(1); - } - } -} +import { converter, formatHex, formatHex8, parse, type Rgb } from "culori";
+
+export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv";
+
+const toRgb = converter("rgb");
+const toHsv = converter("hsv");
+const toHsl = converter("hsl");
+
+export function hexToHsv({ hex }: { hex: string }): [number, number, number] {
+ const color = toHsv(`#${hex}`);
+ if (!color) return [0, 0, 0];
+ return [color.h ?? 0, color.s ?? 0, color.v ?? 0];
+}
+
+export function hsvToHex({
+ h,
+ s,
+ v,
+}: {
+ h: number;
+ s: number;
+ v: number;
+}): string {
+ const hex = formatHex({ mode: "hsv", h, s, v });
+ return hex.slice(1);
+}
+
+export function parseHexAlpha({ hex }: { hex: string }): {
+ rgb: string;
+ alpha: number;
+} {
+ const color = parse(`#${hex}`);
+ const rgbHex = color
+ ? formatHex(color).slice(1)
+ : hex.slice(0, 6).toLowerCase();
+ return {
+ rgb: rgbHex,
+ alpha: color?.alpha ?? 1,
+ };
+}
+
+export function appendAlpha({
+ rgbHex,
+ alpha,
+}: {
+ rgbHex: string;
+ alpha: number;
+}): string {
+ if (alpha >= 1) return rgbHex;
+ const hex8 = formatHex8({ mode: "rgb", r: 0, g: 0, b: 0, alpha });
+ const alphaHex = hex8.slice(7, 9);
+ return rgbHex + alphaHex;
+}
+
+function stripCssNoise({ text }: { text: string }): string {
+ let cleaned = text.trim();
+ cleaned = cleaned
+ .replace(/\s*!important\s*/gi, "")
+ .replace(/;+\s*$/, "")
+ .trim();
+
+ const colonIndex = cleaned.indexOf(":");
+ const parenIndex = cleaned.indexOf("(");
+ if (colonIndex !== -1 && (parenIndex === -1 || colonIndex < parenIndex)) {
+ cleaned = cleaned.slice(colonIndex + 1).trim();
+ }
+
+ return cleaned;
+}
+
+function colorToHexWithAlpha({ color }: { color: Rgb }): string {
+ const hex = formatHex(color).slice(1);
+ if (color.alpha !== undefined && color.alpha < 1) {
+ const hex8 = formatHex8(color);
+ return hex8.slice(1);
+ }
+ return hex;
+}
+
+export function extractColorFromText({
+ text,
+}: {
+ text: string;
+}): string | null {
+ const cleaned = stripCssNoise({ text });
+
+ const color = toRgb(cleaned);
+ if (color) return colorToHexWithAlpha({ color });
+
+ // bare hex without # (culori needs the prefix)
+ const bareHexMatch = cleaned.match(/^([0-9a-fA-F]{3,8})$/);
+ if (bareHexMatch) {
+ const withHash = toRgb(`#${bareHexMatch[1]}`);
+ if (withHash) return colorToHexWithAlpha({ color: withHash });
+ }
+
+ // fallback: find #hex anywhere in the original text
+ const embeddedHexMatch = text.match(/#([0-9a-fA-F]{3,8})\b/);
+ if (embeddedHexMatch) {
+ const embedded = toRgb(`#${embeddedHexMatch[1]}`);
+ if (embedded) return colorToHexWithAlpha({ color: embedded });
+ }
+
+ return null;
+}
+
+export function formatColorValue({
+ hex,
+ format,
+}: {
+ hex: string;
+ format: ColorFormat;
+}): string {
+ switch (format) {
+ case "hex":
+ return hex;
+ case "rgb": {
+ const color = toRgb(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}`;
+ }
+ case "hsl": {
+ const color = toHsl(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.l ?? 0) * 100)}%`;
+ }
+ case "hsv": {
+ const color = toHsv(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.v ?? 0) * 100)}%`;
+ }
+ }
+}
+
+export function parseColorInput({
+ input,
+ format,
+}: {
+ input: string;
+ format: ColorFormat;
+}): string | null {
+ switch (format) {
+ case "hex": {
+ const cleaned = input.replace("#", "");
+ const isValidHex = /^[0-9a-fA-F]{3,8}$/.test(cleaned);
+ return isValidHex ? cleaned : null;
+ }
+ case "rgb": {
+ const parts = input.split(",").map((part) => parseInt(part.trim(), 10));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "rgb" as const,
+ r: parts[0] / 255,
+ g: parts[1] / 255,
+ b: parts[2] / 255,
+ };
+ return formatHex(color).slice(1);
+ }
+ case "hsl": {
+ const parts = input.split(",").map((part) => parseFloat(part.trim()));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "hsl" as const,
+ h: parts[0],
+ s: parts[1] / 100,
+ l: parts[2] / 100,
+ };
+ return formatHex(color).slice(1);
+ }
+ case "hsv": {
+ const parts = input.split(",").map((part) => parseFloat(part.trim()));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "hsv" as const,
+ h: parts[0],
+ s: parts[1] / 100,
+ v: parts[2] / 100,
+ };
+ return formatHex(color).slice(1);
+ }
+ }
+}
diff --git a/apps/web/src/utils/id.ts b/apps/web/src/utils/id.ts index e452af43..a47ef733 100644 --- a/apps/web/src/utils/id.ts +++ b/apps/web/src/utils/id.ts @@ -1,28 +1,28 @@ -export function generateUUID(): string { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID(); - } - - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); - - return ( - hex.slice(0, 4).join("") + - "-" + - hex.slice(4, 6).join("") + - "-" + - hex.slice(6, 8).join("") + - "-" + - hex.slice(8, 10).join("") + - "-" + - hex.slice(10, 16).join("") - ); -} +export function generateUUID(): string {
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return crypto.randomUUID();
+ }
+
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
+
+ return (
+ hex.slice(0, 4).join("") +
+ "-" +
+ hex.slice(4, 6).join("") +
+ "-" +
+ hex.slice(6, 8).join("") +
+ "-" +
+ hex.slice(8, 10).join("") +
+ "-" +
+ hex.slice(10, 16).join("")
+ );
+}
diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index 24f9eaf4..6e912b43 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -1,84 +1,84 @@ -export function clamp({ - value, - min, - max, -}: { - value: number; - min: number; - max: number; -}): number { - return Math.max(min, Math.min(max, value)); -} - -export function clampRound({ - value, - min, - max, -}: { - value: number; - min: number; - max: number; -}): number { - return Math.round(clamp({ value, min, max })); -} - -function getFractionDigitsForStep({ step }: { step: number }): number { - const normalizedStep = step.toString().toLowerCase(); - if (normalizedStep.includes("e-")) { - return Number(normalizedStep.split("e-")[1] ?? 0); - } - const [, fractionalPart = ""] = normalizedStep.split("."); - return fractionalPart.length; -} - -export function snapToStep({ - value, - step, -}: { - value: number; - step: number; -}): number { - if (step <= 0) return value; - const snappedValue = Math.round(value / step) * step; - return Number( - snappedValue.toFixed(getFractionDigitsForStep({ step })), - ); -} - -export function isNearlyEqual({ - leftValue, - rightValue, - epsilon = 0.0001, -}: { - leftValue: number; - rightValue: number; - epsilon?: number; -}): boolean { - return Math.abs(leftValue - rightValue) <= epsilon; -} - -export function formatNumberForDisplay({ - value, - maxFractionDigits = 6, -}: { - value: number; - maxFractionDigits?: number; -}): string { - return Number(value.toFixed(maxFractionDigits)).toString(); -} - -export function evaluateMathExpression({ - input, -}: { - input: string; -}): number | null { - const sanitized = input.trim(); - if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null; - try { - const result = new Function(`return (${sanitized})`)(); - if (typeof result !== "number" || !Number.isFinite(result)) return null; - return result; - } catch { - return null; - } -} +export function clamp({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min: number;
+ max: number;
+}): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+export function clampRound({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min: number;
+ max: number;
+}): number {
+ return Math.round(clamp({ value, min, max }));
+}
+
+function getFractionDigitsForStep({ step }: { step: number }): number {
+ const normalizedStep = step.toString().toLowerCase();
+ if (normalizedStep.includes("e-")) {
+ return Number(normalizedStep.split("e-")[1] ?? 0);
+ }
+ const [, fractionalPart = ""] = normalizedStep.split(".");
+ return fractionalPart.length;
+}
+
+export function snapToStep({
+ value,
+ step,
+}: {
+ value: number;
+ step: number;
+}): number {
+ if (step <= 0) return value;
+ const snappedValue = Math.round(value / step) * step;
+ return Number(
+ snappedValue.toFixed(getFractionDigitsForStep({ step })),
+ );
+}
+
+export function isNearlyEqual({
+ leftValue,
+ rightValue,
+ epsilon = 0.0001,
+}: {
+ leftValue: number;
+ rightValue: number;
+ epsilon?: number;
+}): boolean {
+ return Math.abs(leftValue - rightValue) <= epsilon;
+}
+
+export function formatNumberForDisplay({
+ value,
+ maxFractionDigits = 6,
+}: {
+ value: number;
+ maxFractionDigits?: number;
+}): string {
+ return Number(value.toFixed(maxFractionDigits)).toString();
+}
+
+export function evaluateMathExpression({
+ input,
+}: {
+ input: string;
+}): number | null {
+ const sanitized = input.trim();
+ if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
+ try {
+ const result = new Function(`return (${sanitized})`)();
+ if (typeof result !== "number" || !Number.isFinite(result)) return null;
+ return result;
+ } catch {
+ return null;
+ }
+}
|
