diff --git a/packages/studio/package.json b/packages/studio/package.json index 156ce1636c..a0ae62f629 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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" }, diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index cc8f895490..7b633a599d 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -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; - } -} diff --git a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts new file mode 100644 index 0000000000..63aed15c0a --- /dev/null +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const CHROME_147_FILL_STYLE: Record = { + 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 = { + 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); + }); +}); diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index fe0cb0b97d..62e167744a 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -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, diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index b2cb7bef8b..b9240db567 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -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"; diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 95f9c5a138..9547920ce7 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, 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, - }; -} diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts index 322a4978e9..aaf24f78e4 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts @@ -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('Hello'); + 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(' Hello'); + + 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(' xy'); + const range = rangeOver(host, 0, 3); + + expect(readInlineStyleSpread(range, "color")).toEqual(["lime"]); + expect(readFirstPaintedElement(range, "color")).toBe(host.querySelectorAll("span")[1]); + }); +}); diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.ts b/packages/studio/src/components/editor/inlineTextStyleRange.ts index f48c72424e..6952836a08 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRange.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRange.ts @@ -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; @@ -185,6 +186,7 @@ function holdsBothEnds(host: Element, range: Range): boolean { export interface InlineStyleChar { char: string; style: Readonly>; + 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 & { char: st style: run.style, origin: run.origin, identity: run.identity, + painter: run.painter, }); } } diff --git a/packages/studio/src/components/editor/inlineTextStyleRead.ts b/packages/studio/src/components/editor/inlineTextStyleRead.ts index 3460e48dd9..37a1366645 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRead.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRead.ts @@ -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; +} diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index e46455dce7..6984e1aa8f 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -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); diff --git a/packages/studio/tests/e2e/native-css-colors.mjs b/packages/studio/tests/e2e/native-css-colors.mjs new file mode 100644 index 0000000000..eaba42e57b --- /dev/null +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -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 = + '
' + + 'x
' + + '
' + + 'xy
'; + 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 }); +}