Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
26 changes: 4 additions & 22 deletions packages/studio/src/components/editor/InlineTextToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ function placeOverSelection(
placeBelow,
styles,
colours,
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
pickerColour: toPickerColour(styles.color ?? colours[0]),
};
}

Expand All @@ -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;
}
34 changes: 34 additions & 0 deletions packages/studio/src/components/editor/colorValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
53 changes: 13 additions & 40 deletions packages/studio/src/components/editor/colorValue.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/components/editor/gradientValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
});
});
});
36 changes: 3 additions & 33 deletions packages/studio/src/components/editor/gradientValue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { roundToCenti } from "../../utils/rounding";
import { parseCssColor } from "./colorValue";

export type GradientKind = "linear" | "radial" | "conic";

Expand Down Expand Up @@ -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);
Expand All @@ -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,
};
}