mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): parse modern CSS colors without a new dependency (#3931)
* fix(studio): parse modern CSS colors without a new dependency * refactor(studio): one colour parser for the text toolbar, stricter rgb numbers The inline text toolbar kept its own canvas fallback for named colours. parseCssColor now resolves them, so the toolbar uses it alone. rgb() channels must be well-formed numbers; that replaces the NaN check after parsing and rejects rgb(1. 2 3) as CSS does. * refactor(studio): drop comment lines the comment ratchet refuses in colorValue * fix(studio): linear-time rgb() patterns and no new colour literal in the parser The rgb() number pattern now has one way to match each digit run, which clears CodeQL's slow-regex alerts. The canvas reset uses transparent so the Studio colour-literal ratchet stays at its baseline. * fix(studio): legacy colours resolve in browsers without relative colour syntax parseBrowserColor reads the colour itself from the canvas first and accepts a #rrggbb or rgba() result, then tries color(from ...). Named, hsl() and percent rgb() colours resolve again in the text toolbar on Chrome before 119 and Safari 17. * test(studio): a colour the canvas rejects parses to null, not transparent black * fix(studio): an inserted gradient stop takes the colour the gradient paints there The stop colour is color-mix() in oklab, or srgb when both stops are legacy, resolved by the browser, so it matches the painted midpoint including premultiplied alpha; the channel blend stays as the fallback. The parser tries color(from ...) before the legacy read, keeping alpha precise, and calls CSS.supports inside its try. A Chrome-147-shaped fake canvas covers the modern path in unit tests. * test(studio): keep the fake browser canvas inside its test file * fix(studio): mix an inserted gradient stop in the space the whole gradient uses * fix(studio): let an unresolved var() stop keep a legacy gradient in sRGB * fix(studio): only a bare var() stop leaves the gradient space to its neighbours * fix(studio): pick light-dark() text colour from what the element paints * refactor(studio): drop the neighbour clause the whole-gradient check already covers * fix(studio): resolve the picker colour from the element that paints the selected text * fix(studio): read the picker colour from the character the swatch colour came from --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
dc9c38efd8
commit
5c52f72399
@@ -81,6 +81,7 @@
|
||||
"test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
|
||||
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
||||
"test:watch": "vitest",
|
||||
"test:colors:browser": "node tests/e2e/native-css-colors.mjs",
|
||||
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
|
||||
"test:timeline-default": "bun run test:timeline-virtualization"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { applyInlineStyle } from "./inlineTextStyleRange";
|
||||
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
|
||||
import { parseCssColor, toHexColor } from "./colorValue";
|
||||
import {
|
||||
readFirstPaintedElement,
|
||||
readInlineStyle,
|
||||
readInlineStyleSpread,
|
||||
} from "./inlineTextStyleRead";
|
||||
import { resolvePickerColor } from "./colorValue";
|
||||
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
||||
|
||||
/**
|
||||
@@ -248,7 +252,11 @@ function placeOverSelection(
|
||||
placeBelow,
|
||||
styles,
|
||||
colours,
|
||||
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
|
||||
pickerColour: resolvePickerColor(
|
||||
styles.color ?? colours[0],
|
||||
readFirstPaintedElement(range, "color"),
|
||||
DEFAULT_COLOR,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,27 +265,3 @@ function isBold(weight: string | undefined): boolean {
|
||||
if (weight === "bold" || weight === "bolder") return true;
|
||||
return Number.parseInt(weight, 10) >= 600;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const CHROME_147_FILL_STYLE: Record<string, string> = {
|
||||
transparent: "rgba(0, 0, 0, 0)",
|
||||
white: "#ffffff",
|
||||
red: "#ff0000",
|
||||
currentcolor: "#000000",
|
||||
"hsl(210 40% 50%)": "#4d80b3",
|
||||
"rgb(100%, 0%, 0%)": "#ff0000",
|
||||
"hsl(0 100% 50% / 0.001)": "rgba(255, 0, 0, 0)",
|
||||
"oklch(0.7 0.15 200)": "oklch(0.7 0.15 200)",
|
||||
"oklch(0.7 0.15 200 / 0.25)": "oklch(0.7 0.15 200 / 0.25)",
|
||||
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "oklab(0.663983 0.0419629 0.0372782)",
|
||||
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
|
||||
};
|
||||
|
||||
const CHROME_147_RELATIVE_SRGB: Record<string, string> = {
|
||||
currentcolor: "color(srgb 0 0 0)",
|
||||
"hsl(0 100% 50% / 0.001)": "color(srgb 1 0 0 / 0.001)",
|
||||
"oklch(0.7 0.15 200)": "color(srgb -0.316663 0.724435 0.764448)",
|
||||
"oklch(0.7 0.15 200 / 0.25)": "color(srgb -0.316663 0.724435 0.764448 / 0.25)",
|
||||
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "color(srgb 0.698277 0.535308 0.473819)",
|
||||
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
|
||||
"color-mix(in srgb, hwb(0 0% 0%), hwb(240 0% 0%) 50%)": "color(srgb 0.5 0 0.5)",
|
||||
"color-mix(in oklab, red, blue 50%)": "color(srgb 0.550402 0.325634 0.636508)",
|
||||
"color-mix(in srgb, red, blue 50%)": "color(srgb 0.5 0 0.5)",
|
||||
"light-dark(white, red)": "color(srgb 1 1 1)",
|
||||
};
|
||||
|
||||
async function loadColorModulesWithChromeCanvas() {
|
||||
let fillStyle = "#000000";
|
||||
const context = {
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(next: string) {
|
||||
const relative = /^color\(from (.+) srgb r g b \/ alpha\)$/.exec(next);
|
||||
fillStyle =
|
||||
(relative ? CHROME_147_RELATIVE_SRGB[relative[1]] : CHROME_147_FILL_STYLE[next]) ??
|
||||
fillStyle;
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("CSS", { supports: () => true });
|
||||
vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) });
|
||||
vi.resetModules();
|
||||
return { ...(await import("./colorValue")), ...(await import("./gradientValue")) };
|
||||
}
|
||||
|
||||
describe("parseCssColor with a browser canvas", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["red", { red: 255, green: 0, blue: 0, alpha: 1 }],
|
||||
["hsl(210 40% 50%)", { red: 77, green: 128, blue: 179, alpha: 1 }],
|
||||
["rgb(100%, 0%, 0%)", { red: 255, green: 0, blue: 0, alpha: 1 }],
|
||||
["oklch(0.7 0.15 200)", { red: 0, green: 185, blue: 195, alpha: 1 }],
|
||||
["oklch(0.7 0.15 200 / 0.25)", { red: 0, green: 185, blue: 195, alpha: 0.25 }],
|
||||
["hsl(0 100% 50% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
|
||||
["inherit", null],
|
||||
["currentcolor", null],
|
||||
["light-dark(white, red)", null],
|
||||
])("resolves %s through the canvas, or null if it must not", async (input, expected) => {
|
||||
const { parseCssColor: parseFresh } = await loadColorModulesWithChromeCanvas();
|
||||
expect(parseFresh(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertGradientStop with a browser canvas", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["oklch(0.7 0.15 200)", "red", "#B28979"],
|
||||
["#ff000000", "#0000ffff", "rgba(0, 0, 255, 0.5)"],
|
||||
])("gives %s to %s the midpoint the browser paints", async (left, right, expected) => {
|
||||
const { parseGradient: parse, insertGradientStop: insert } =
|
||||
await loadColorModulesWithChromeCanvas();
|
||||
const model = parse(`linear-gradient(90deg, ${left} 0%, ${right} 100%)`);
|
||||
expect(model).not.toBeNull();
|
||||
expect(insert(model!, 50).stops[1].color).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["hwb stops mix in sRGB like rgb()", "hwb(0 0% 0%) 0%, hwb(240 0% 0%) 100%", 50, "#800080"],
|
||||
[
|
||||
"one modern stop moves every interval to Oklab",
|
||||
"red 0%, blue 50%, oklch(0.7 0.15 200) 100%",
|
||||
25,
|
||||
"#8C53A2",
|
||||
],
|
||||
[
|
||||
"an unresolved var() stop leaves a legacy interval in sRGB",
|
||||
"red 0%, blue 50%, var(--third, red) 100%",
|
||||
25,
|
||||
"#800080",
|
||||
],
|
||||
[
|
||||
"a modern stop with a var() inside still moves every interval to Oklab",
|
||||
"red 0%, blue 50%, oklch(var(--l, 0.7) 0.1 200) 100%",
|
||||
25,
|
||||
"#8C53A2",
|
||||
],
|
||||
])("%s", async (_case, stops, position, expected) => {
|
||||
const { parseGradient: parse, insertGradientStop: insert } =
|
||||
await loadColorModulesWithChromeCanvas();
|
||||
const model = parse(`linear-gradient(90deg, ${stops})`);
|
||||
expect(model).not.toBeNull();
|
||||
const inserted = insert(model!, position).stops.find((stop) => stop.position === position);
|
||||
expect(inserted?.color).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,22 @@ describe("parseCssColor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["#0f172acc", { red: 15, green: 23, blue: 42, alpha: 0.8 }],
|
||||
["#f008", { red: 255, green: 0, blue: 0, alpha: 136 / 255 }],
|
||||
["rgb(255 0 0 / 50%)", { red: 255, green: 0, blue: 0, alpha: 0.5 }],
|
||||
["rgb(100% 0% 0% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
|
||||
])("parses %s without a browser", (input, expected) => {
|
||||
expect(parseCssColor(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(["", "#12", "notacolor", "currentcolor", "none", "rgb(1..2, 3, 4)", "rgb(1. 2 3)"])(
|
||||
"rejects %s without a browser",
|
||||
(input) => {
|
||||
expect(parseCssColor(input)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("parses transparent", () => {
|
||||
expect(parseCssColor("transparent")).toEqual({
|
||||
red: 0,
|
||||
|
||||
@@ -29,49 +29,125 @@ function formatAlpha(value: number): string {
|
||||
return `${roundToCenti(clampAlpha(value))}`;
|
||||
}
|
||||
|
||||
export function parseCssColor(value: string): ParsedColor | null {
|
||||
function parseComponent(value: string, scale: number): number {
|
||||
return value.endsWith("%") ? (Number(value.slice(0, -1)) * scale) / 100 : Number(value);
|
||||
}
|
||||
|
||||
const NUMBER = String.raw`(?:\d+(?:\.\d+)?|\.\d+)`;
|
||||
const COMMA_RGB = new RegExp(
|
||||
String.raw`^rgba?\(\s*(${NUMBER})\s*,\s*(${NUMBER})\s*,\s*(${NUMBER})(?:\s*,\s*(${NUMBER}))?\s*\)$`,
|
||||
);
|
||||
const SPACE_RGB = new RegExp(
|
||||
String.raw`^rgba?\(\s*(${NUMBER}%?)\s+(${NUMBER}%?)\s+(${NUMBER}%?)(?:\s*\/\s*(${NUMBER}%?))?\s*\)$`,
|
||||
);
|
||||
|
||||
function parseSerializedColor(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);
|
||||
const hex = trimmed.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
||||
if (hex) {
|
||||
const digits = hex[1].length <= 4 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1];
|
||||
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,
|
||||
red: Number.parseInt(digits.slice(0, 2), 16),
|
||||
green: Number.parseInt(digits.slice(2, 4), 16),
|
||||
blue: Number.parseInt(digits.slice(4, 6), 16),
|
||||
alpha: digits.length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
const rgba = trimmed.match(
|
||||
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
||||
);
|
||||
const rgba = trimmed.match(COMMA_RGB) ?? trimmed.match(SPACE_RGB);
|
||||
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),
|
||||
red: clampChannel(parseComponent(rgba[1], 255)),
|
||||
green: clampChannel(parseComponent(rgba[2], 255)),
|
||||
blue: clampChannel(parseComponent(rgba[3], 255)),
|
||||
alpha: clampAlpha(rgba[4] != null ? parseComponent(rgba[4], 1) : 1),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let colorContext: CanvasRenderingContext2D | null = null;
|
||||
|
||||
function canResolveInBrowser(value: string): boolean {
|
||||
return (
|
||||
typeof document !== "undefined" &&
|
||||
typeof CSS !== "undefined" &&
|
||||
CSS.supports("color", value) &&
|
||||
!/\bcurrentcolor\b|\bvar\s*\(|\blight-dark\s*\(/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseBrowserColor(value: string): ParsedColor | null {
|
||||
try {
|
||||
if (!canResolveInBrowser(value)) return null;
|
||||
colorContext ??= document.createElement("canvas").getContext("2d");
|
||||
if (!colorContext) return null;
|
||||
return (
|
||||
parseCanvasColor(
|
||||
colorContext,
|
||||
`color(from ${value} srgb r g b / alpha)`,
|
||||
parseSrgbSerialization,
|
||||
) ?? parseCanvasColor(colorContext, value, parseSerializedColor)
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCanvasColor(
|
||||
context: CanvasRenderingContext2D,
|
||||
value: string,
|
||||
parse: (serialized: string) => ParsedColor | null,
|
||||
): ParsedColor | null {
|
||||
const serialized = readCanvasColor(context, value);
|
||||
return serialized === null ? null : parse(serialized);
|
||||
}
|
||||
|
||||
function readCanvasColor(context: CanvasRenderingContext2D, value: string): string | null {
|
||||
context.fillStyle = "transparent";
|
||||
context.fillStyle = value;
|
||||
const first = context.fillStyle;
|
||||
context.fillStyle = "white";
|
||||
context.fillStyle = value;
|
||||
return typeof first === "string" && first === context.fillStyle ? first : null;
|
||||
}
|
||||
|
||||
function parseSrgbSerialization(serialized: string): ParsedColor | null {
|
||||
const match = serialized.match(/^color\(srgb ([^ ]+) ([^ ]+) ([^ /)]+)(?: \/ ([^)]+))?\)$/);
|
||||
if (!match) return null;
|
||||
const channels = match.slice(1, 4).map(Number);
|
||||
const alpha = match[4] === undefined ? 1 : Number(match[4]);
|
||||
if (!channels.every(Number.isFinite) || !Number.isFinite(alpha)) return null;
|
||||
return {
|
||||
red: clampChannel(channels[0] * 255),
|
||||
green: clampChannel(channels[1] * 255),
|
||||
blue: clampChannel(channels[2] * 255),
|
||||
alpha: clampAlpha(alpha),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCssColor(value: string): ParsedColor | null {
|
||||
return parseSerializedColor(value) ?? parseBrowserColor(value.trim());
|
||||
}
|
||||
|
||||
export function resolvePickerColor(
|
||||
value: string | undefined,
|
||||
element: Element | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
const view = element?.ownerDocument.defaultView;
|
||||
const parsed =
|
||||
(value ? parseCssColor(value) : null) ??
|
||||
(element && view ? parseCssColor(view.getComputedStyle(element).color) : null);
|
||||
return parsed ? toHexColor(parsed) : fallback;
|
||||
}
|
||||
|
||||
export function toColorPickerValue(value: string): string {
|
||||
const parsed = parseCssColor(value);
|
||||
if (!parsed) return "#000000";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
import { parseCssColor, type ParsedColor } from "./colorValue";
|
||||
|
||||
export type GradientKind = "linear" | "radial" | "conic";
|
||||
|
||||
@@ -384,20 +385,42 @@ 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 ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
|
||||
const mixed = parseCssColor(mixLikeTheGradient(sortedStops, leftColor, rightColor, ratio));
|
||||
if (mixed) return formatStopColor(mixed);
|
||||
|
||||
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);
|
||||
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);
|
||||
const alpha = leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio;
|
||||
return formatStopColor({ red, green, blue, alpha });
|
||||
}
|
||||
|
||||
const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?|hwb)\(|[a-z]+$)/i;
|
||||
|
||||
const isLegacy = (color: string) => LEGACY_COLOR.test(color.trim());
|
||||
const isModern = (color: string) => !isLegacy(color) && !/^var\(/i.test(color.trim());
|
||||
|
||||
function mixLikeTheGradient(
|
||||
stops: GradientStop[],
|
||||
left: string,
|
||||
right: string,
|
||||
ratio: number,
|
||||
): string {
|
||||
const space = stops.some((stop) => isModern(stop.color)) ? "oklab" : "srgb";
|
||||
return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`;
|
||||
}
|
||||
|
||||
function formatStopColor(color: ParsedColor): string {
|
||||
const { red, green, blue } = color;
|
||||
const alpha = round(color.alpha);
|
||||
if (alpha >= 1) {
|
||||
return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase();
|
||||
}
|
||||
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
@@ -412,34 +435,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyInlineStyle } from "./inlineTextStyleRange";
|
||||
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
|
||||
import {
|
||||
readFirstPaintedElement,
|
||||
readInlineStyle,
|
||||
readInlineStyleSpread,
|
||||
} from "./inlineTextStyleRead";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -741,3 +745,29 @@ describe("readInlineStyleSpread", () => {
|
||||
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFirstPaintedElement", () => {
|
||||
it("names the span that paints the text when the selection starts on its parent", () => {
|
||||
const host = mount('<span style="color: red">Hello</span>');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(host);
|
||||
|
||||
expect(readFirstPaintedElement(range, "color")).toBe(host.querySelector("span"));
|
||||
});
|
||||
|
||||
it("skips a leading space, which paints nothing", () => {
|
||||
const host = mount(' <span style="color: red">Hello</span>');
|
||||
|
||||
expect(readFirstPaintedElement(rangeOver(host, 0, 6), "color")).toBe(
|
||||
host.querySelector("span"),
|
||||
);
|
||||
});
|
||||
|
||||
it("names the element of the colour the swatch shows, past text with no colour of its own", () => {
|
||||
const host = mount('<span style="color: red"> </span>x<span style="color: lime">y</span>');
|
||||
const range = rangeOver(host, 0, 3);
|
||||
|
||||
expect(readInlineStyleSpread(range, "color")).toEqual(["lime"]);
|
||||
expect(readFirstPaintedElement(range, "color")).toBe(host.querySelectorAll("span")[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ interface StyledRun {
|
||||
* identity to lose, and merges with its neighbour exactly as before.
|
||||
*/
|
||||
identity: string;
|
||||
painter?: Element | null;
|
||||
}
|
||||
|
||||
export type InlineStyleDelta = Record<string, string | null>;
|
||||
@@ -185,6 +186,7 @@ function holdsBothEnds(host: Element, range: Range): boolean {
|
||||
export interface InlineStyleChar {
|
||||
char: string;
|
||||
style: Readonly<Record<string, string>>;
|
||||
painter: Element | null;
|
||||
}
|
||||
|
||||
/** Every character the range covers with its style, or null when it covers none. */
|
||||
@@ -197,7 +199,7 @@ export function readCoveredInlineStyleChars(range: Range): readonly InlineStyleC
|
||||
const collapsed = start === end;
|
||||
const covered = charRuns(readRuns(host))
|
||||
.slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end)
|
||||
.map((entry) => ({ char: entry.char, style: entry.style }));
|
||||
.map((entry) => ({ char: entry.char, style: entry.style, painter: entry.painter ?? null }));
|
||||
return covered.length > 0 ? covered : null;
|
||||
}
|
||||
|
||||
@@ -244,7 +246,7 @@ function visitRunFrame(frame: RunWalkFrame, runs: StyledRun[]): RunWalkFrame[] {
|
||||
const { node, inherited, origin } = frame;
|
||||
if (node.nodeType === 3) {
|
||||
const text = node.textContent ?? "";
|
||||
if (text) runs.push(styledRun(text, inherited, origin));
|
||||
if (text) runs.push({ ...styledRun(text, inherited, origin), painter: node.parentElement });
|
||||
return [];
|
||||
}
|
||||
if (!isStyleElement(node)) return [];
|
||||
@@ -312,6 +314,7 @@ function charRuns(runs: StyledRun[]): Array<Omit<StyledRun, "text"> & { char: st
|
||||
style: run.style,
|
||||
origin: run.origin,
|
||||
identity: run.identity,
|
||||
painter: run.painter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,12 @@ export function readInlineStyleSpread(range: Range, property: string): string[]
|
||||
}
|
||||
return spread;
|
||||
}
|
||||
|
||||
export function readFirstPaintedElement(range: Range, property: string): Element | null {
|
||||
const covered = readCoveredInlineStyleChars(range);
|
||||
if (!covered) return null;
|
||||
const painted = covered.filter(({ char }) => char.trim());
|
||||
const owner =
|
||||
painted.find(({ style }) => style[property] !== undefined) ?? painted[0] ?? covered[0];
|
||||
return owner?.painter ?? null;
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function ColorField({
|
||||
const resolveColorGestureValue = useCallback((nextValue: string) => {
|
||||
const source = nextValue.startsWith("#") ? "hex" : "picker";
|
||||
// Only a COMPLETE hex resolves, so a half-typed one neither previews nor
|
||||
// commits. Both lengths parseCssColor accepts count as complete: gating on
|
||||
// commits. 3 and 6 digits count as complete (the field edits RGB only): gating on
|
||||
// 6 alone silently dropped #F00 and friends, which the old onBlur committed.
|
||||
if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
|
||||
const nextColor = parseCssColor(nextValue);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Run with: node packages/studio/tests/e2e/native-css-colors.mjs
|
||||
// Uses real browser CSS parsing; happy-dom cannot verify these conversions.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import puppeteer from "puppeteer-core";
|
||||
import { resolveChromeExecutable } from "./chrome-executable.mjs";
|
||||
|
||||
const output = mkdtempSync(join(tmpdir(), "native-css-colors-"));
|
||||
let browser;
|
||||
try {
|
||||
execFileSync(
|
||||
"bun",
|
||||
[
|
||||
"build",
|
||||
"packages/studio/src/components/editor/colorValue.ts",
|
||||
"packages/studio/src/components/editor/gradientValue.ts",
|
||||
"packages/studio/src/components/editor/inlineTextStyleRead.ts",
|
||||
"--target",
|
||||
"browser",
|
||||
"--outdir",
|
||||
output,
|
||||
],
|
||||
{ cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../../..") },
|
||||
);
|
||||
const moduleUrl = (name) =>
|
||||
`data:text/javascript;base64,${readFileSync(join(output, `${name}.js`)).toString("base64")}`;
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: resolveChromeExecutable(),
|
||||
headless: true,
|
||||
pipe: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const results = await page.evaluate(
|
||||
async (colorUrl, gradientUrl, readUrl) => {
|
||||
const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha, resolvePickerColor } =
|
||||
await import(colorUrl);
|
||||
const { parseGradient, insertGradientStop } = await import(gradientUrl);
|
||||
const { readFirstPaintedElement, readInlineStyleSpread } = await import(readUrl);
|
||||
const inputs = [
|
||||
"white",
|
||||
"rebeccapurple",
|
||||
"#0f172acc",
|
||||
"rgb(255 0 0 / 50%)",
|
||||
"hsl(210 40% 50%)",
|
||||
"color(srgb 0.4 0 0.6)",
|
||||
"oklch(0.7 0.15 200)",
|
||||
"oklab(0.6 0.1 0.1)",
|
||||
"lab(100 0 0)",
|
||||
"color(display-p3 1 0 0)",
|
||||
"oklch(0.7 0.15 200 / 0)",
|
||||
"color(srgb 0.4 0 0.6 / 0.001)",
|
||||
"color-mix(in srgb, red 40%, blue)",
|
||||
"notacolor",
|
||||
"#12",
|
||||
"currentcolor",
|
||||
"none",
|
||||
"var(--color)",
|
||||
];
|
||||
const colors = inputs.map((input) => parseCssColor(input));
|
||||
const gradient = parseGradient("linear-gradient(90deg, black 0%, white 100%)");
|
||||
const alphaGradient = parseGradient("linear-gradient(90deg, #00000000 0%, #000000ff 100%)");
|
||||
const element = document.createElement("span");
|
||||
element.style.color = "oklch(0.7 0.15 200)";
|
||||
document.body.append(element);
|
||||
const frame = document.createElement("iframe");
|
||||
frame.srcdoc =
|
||||
'<html style="color-scheme: dark"><body><div contenteditable="true" style="color: green">' +
|
||||
'<span style="color: light-dark(white, red)">x</span></div>' +
|
||||
'<div contenteditable="true" style="color: green"><span style="color: light-dark(white, red)"> </span>' +
|
||||
'x<span style="color: light-dark(white, red)">y</span></div></body></html>';
|
||||
await new Promise((loaded) => {
|
||||
frame.onload = loaded;
|
||||
document.body.append(frame);
|
||||
});
|
||||
const darkDoc = frame.contentDocument;
|
||||
const [wholeHost, mixedHost] = darkDoc.querySelectorAll("div");
|
||||
const darkSelection = darkDoc.createRange();
|
||||
darkSelection.selectNodeContents(wholeHost);
|
||||
const mixedSpans = mixedHost.querySelectorAll("span");
|
||||
const mixedSelection = darkDoc.createRange();
|
||||
mixedSelection.setStart(mixedSpans[0].firstChild, 0);
|
||||
mixedSelection.setEnd(mixedSpans[1].firstChild, 1);
|
||||
const pickFor = (selection) =>
|
||||
resolvePickerColor(
|
||||
readInlineStyleSpread(selection, "color")[0],
|
||||
readFirstPaintedElement(selection, "color"),
|
||||
"#ffffff",
|
||||
);
|
||||
return {
|
||||
colors,
|
||||
darkParse: parseCssColor(readInlineStyleSpread(darkSelection, "color")[0]),
|
||||
darkPicker: pickFor(darkSelection),
|
||||
mixedPicker: pickFor(mixedSelection),
|
||||
picker: toColorPickerValue(getComputedStyle(element).color),
|
||||
alpha: mergeColorWithExistingAlpha("#123456", "color(srgb 0.4 0 0.6 / 0.25)"),
|
||||
gradient: insertGradientStop(gradient, 50).stops[1].color,
|
||||
alphaGradient: insertGradientStop(alphaGradient, 50).stops[1].color,
|
||||
};
|
||||
},
|
||||
moduleUrl("colorValue"),
|
||||
moduleUrl("gradientValue"),
|
||||
moduleUrl("inlineTextStyleRead"),
|
||||
);
|
||||
const expected = [
|
||||
[255, 255, 255, 1],
|
||||
[102, 51, 153, 1],
|
||||
[15, 23, 42, 0.8],
|
||||
[255, 0, 0, 0.5],
|
||||
[77, 128, 179, 1],
|
||||
[102, 0, 153, 1],
|
||||
[0, 185, 195, 1],
|
||||
[195, 96, 46, 1],
|
||||
[255, 255, 255, 1],
|
||||
[255, 0, 0, 1],
|
||||
[0, 185, 195, 0],
|
||||
[102, 0, 153, 0.001],
|
||||
[102, 0, 153, 1],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
for (const [index, channels] of expected.entries()) {
|
||||
const actual = results.colors[index];
|
||||
if (!channels) {
|
||||
assert.equal(actual, null, `invalid color ${index}`);
|
||||
continue;
|
||||
}
|
||||
assert.ok(actual, `color ${index} parsed`);
|
||||
for (const [channel, value] of Object.entries({
|
||||
red: channels[0],
|
||||
green: channels[1],
|
||||
blue: channels[2],
|
||||
})) {
|
||||
assert.ok(
|
||||
Math.abs(actual[channel] - value) <= 1,
|
||||
`color ${index} ${channel}: ${actual[channel]} vs ${value}`,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(actual.alpha - channels[3]) < 0.000001, `color ${index} alpha`);
|
||||
}
|
||||
assert.equal(results.darkParse, null, "light-dark() needs the element's colour scheme");
|
||||
assert.equal(results.darkPicker, "#ff0000", "dark-scheme light-dark() picks what it paints");
|
||||
assert.equal(results.mixedPicker, "#ff0000", "the picker reads the element its colour came from");
|
||||
assert.equal(results.picker, "#00b9c3");
|
||||
assert.equal(results.alpha, "rgba(18, 52, 86, 0.25)");
|
||||
assert.equal(results.gradient, "#808080");
|
||||
assert.equal(results.alphaGradient, "rgba(0, 0, 0, 0.5)");
|
||||
console.log(
|
||||
"Passed 18 native CSS color cases, computed-style and dark-scheme pickers, alpha preservation, and two gradient cases.",
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user