diff --git a/bun.lock b/bun.lock index 9b76975680..23953f88e2 100644 --- a/bun.lock +++ b/bun.lock @@ -326,6 +326,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", + "@colordx/core": "^6.4.0", "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", @@ -550,6 +551,8 @@ "@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="], + "@colordx/core": ["@colordx/core@6.4.0", "", {}, "sha512-KJf2x955gXdCXTVQVd4kh4V89UJq/iUg366DHL1dv5+Bdugehak9lK2veBvOBwOqlusCCo+hKpVXDG437EW2/g=="], + "@commitlint/cli": ["@commitlint/cli@20.5.3", "", { "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.3", "@commitlint/load": "^20.5.3", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA=="], "@commitlint/config-conventional": ["@commitlint/config-conventional@20.5.3", "", { "dependencies": { "@commitlint/types": "^20.5.0", "conventional-changelog-conventionalcommits": "^9.2.0" } }, "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 50f8e6ebfc..702db8a051 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -67,6 +67,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", + "@colordx/core": "^6.4.0", "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index 0d75ddcfeb..fe3711c43c 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -246,7 +246,7 @@ function placeOverSelection( placeBelow, styles, colours, - pickerColour: toPickerColour(styles.color ?? colours[0], doc), + pickerColour: toPickerColour(styles.color ?? colours[0]), }; } @@ -257,25 +257,7 @@ function isBold(weight: string | undefined): boolean { } /** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */ -function toPickerColour(value: string | undefined, doc: Document): string { - if (!value) return DEFAULT_COLOR; - const parsed = parseCssColor(value); - if (parsed) return toHexColor(parsed); - - // Canvas delegates the full CSS colour grammar to the browser, including - // named colours that the small serialisation parser intentionally omits. - // DOM-only test environments can lack a canvas implementation, in which - // case the picker degrades to its explicit default while the swatch remains - // truthful because CSS still paints the original value. - try { - const context = doc.createElement("canvas").getContext("2d"); - if (!context) return DEFAULT_COLOR; - context.fillStyle = DEFAULT_COLOR; - context.fillStyle = value; - const normalised = - typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null; - return normalised ? toHexColor(normalised) : DEFAULT_COLOR; - } catch { - return DEFAULT_COLOR; - } +function toPickerColour(value: string | undefined): string { + const parsed = value ? parseCssColor(value) : null; + return parsed ? toHexColor(parsed) : DEFAULT_COLOR; } diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index fe0cb0b97d..f28273a97f 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -36,12 +36,46 @@ describe("parseCssColor", () => { alpha: 0, }); }); + + it.each([ + ["#fff", { red: 255, green: 255, blue: 255, alpha: 1 }], + ["#0f172acc", { red: 15, green: 23, blue: 42, alpha: 0.8 }], + ["white", { red: 255, green: 255, blue: 255, alpha: 1 }], + ["rgb(255 0 0 / 50%)", { red: 255, green: 0, blue: 0, alpha: 0.5 }], + ["hsl(210 40% 50%)", { red: 77, green: 127, blue: 179, alpha: 1 }], + ["color(srgb 0.4 0 0.6)", { red: 102, green: 0, blue: 153, alpha: 1 }], + ["oklch(0.7 0.15 200)", { red: 0, green: 185, blue: 195, alpha: 1 }], + ["oklab(0.6 0.1 0.1)", { red: 195, green: 96, blue: 46, alpha: 1 }], + ])("parses %s", (input, expected) => { + expect(parseCssColor(input)).toEqual(expected); + }); + + it("clips out-of-gamut colors to srgb", () => { + expect(parseCssColor("oklch(0.9 0.35 140)")).toEqual({ + red: 0, + green: 255, + blue: 0, + alpha: 1, + }); + }); + + it.each(["", "#12", "notacolor", "currentcolor", "none"])("rejects %s", (input) => { + expect(parseCssColor(input)).toBeNull(); + }); }); describe("toColorPickerValue", () => { it("converts css color to hex", () => { expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a"); }); + + it("converts modern css colors to hex", () => { + expect(toColorPickerValue("oklch(0.7 0.15 200)")).toBe("#00b9c3"); + }); + + it("falls back to black for values that are not colors", () => { + expect(toColorPickerValue("currentcolor")).toBe("#000000"); + }); }); describe("toHexColor", () => { diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index b2cb7bef8b..f5e1f56c3f 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -1,5 +1,9 @@ +import { colordx, extend } from "@colordx/core"; +import names from "@colordx/core/plugins/names"; import { roundToCenti } from "../../utils/rounding"; +extend([names]); + export interface ParsedColor { red: number; green: number; @@ -30,46 +34,15 @@ function formatAlpha(value: number): string { } export function parseCssColor(value: string): ParsedColor | null { - const trimmed = value.trim().toLowerCase(); - if (!trimmed) return null; - if (trimmed === "transparent") { - return { red: 0, green: 0, blue: 0, alpha: 0 }; - } - - const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i); - if (shortHex) { - const [r, g, b] = shortHex[1].split(""); - return { - red: Number.parseInt(r + r, 16), - green: Number.parseInt(g + g, 16), - blue: Number.parseInt(b + b, 16), - alpha: 1, - }; - } - - 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 { - red: clampChannel(Number.parseFloat(rgba[1])), - green: clampChannel(Number.parseFloat(rgba[2])), - blue: clampChannel(Number.parseFloat(rgba[3])), - alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1), - }; - } - - return null; + const color = colordx(value.trim()); + if (!color.isValid()) return null; + const { r, g, b, alpha } = color.toRgb(); + return { + red: clampChannel(r), + green: clampChannel(g), + blue: clampChannel(b), + alpha: clampAlpha(alpha), + }; } export function toColorPickerValue(value: string): string { diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts index 4a5dc6ca42..04a9e2b513 100644 --- a/packages/studio/src/components/editor/gradientValue.test.ts +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -86,4 +86,30 @@ describe("insertGradientStop", () => { ], }); }); + + it("interpolates between named stops", () => { + const parsed = parseGradient("linear-gradient(90deg, black 0%, white 100%)"); + expect(parsed).not.toBeNull(); + + expect(insertGradientStop(parsed!, 50)).toMatchObject({ + stops: [ + { color: "black", position: 0 }, + { color: "#808080", position: 50 }, + { color: "white", position: 100 }, + ], + }); + }); + + it("interpolates the alpha of 8-digit hex stops", () => { + const parsed = parseGradient("linear-gradient(90deg, #00000000 0%, #000000ff 100%)"); + expect(parsed).not.toBeNull(); + + expect(insertGradientStop(parsed!, 50)).toMatchObject({ + stops: [ + { color: "#00000000", position: 0 }, + { color: "rgba(0, 0, 0, 0.5)", position: 50 }, + { color: "#000000ff", position: 100 }, + ], + }); + }); }); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 95f9c5a138..0f4f0bd11e 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -1,4 +1,5 @@ import { roundToCenti } from "../../utils/rounding"; +import { parseCssColor } from "./colorValue"; export type GradientKind = "linear" | "radial" | "conic"; @@ -384,8 +385,8 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const leftColor = left.color; const rightColor = right.color; - const leftParsed = leftColor ? parseColorString(leftColor) : null; - const rightParsed = rightColor ? parseColorString(rightColor) : null; + const leftParsed = leftColor ? parseCssColor(leftColor) : null; + const rightParsed = rightColor ? parseCssColor(rightColor) : null; if (!leftParsed || !rightParsed) return left.color; const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position); @@ -412,34 +413,3 @@ export function insertGradientStop(model: GradientModel, position: number): Grad 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, - }; -}