From 7cfdcff8c72dee40c0467276285a112ce0803188 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 6 Jun 2026 11:50:39 +0800 Subject: [PATCH] fix(grid): prevent canvas selection from rendering black --- apps/desktop/src/lib/dataGridPaintTheme.ts | 270 +++++++++++------- packages/app-tests/dataGridPaintTheme.test.ts | 43 +++ 2 files changed, 210 insertions(+), 103 deletions(-) create mode 100644 packages/app-tests/dataGridPaintTheme.test.ts diff --git a/apps/desktop/src/lib/dataGridPaintTheme.ts b/apps/desktop/src/lib/dataGridPaintTheme.ts index 9f449b090..9663012d6 100644 --- a/apps/desktop/src/lib/dataGridPaintTheme.ts +++ b/apps/desktop/src/lib/dataGridPaintTheme.ts @@ -29,34 +29,148 @@ export interface DataGridPaintTheme { } export const DATA_GRID_DARK_SEARCH_COLORS = { - match: "rgb(72 57 8)", - current: "rgb(116 87 0)", - currentBorder: "rgb(239 177 0)", + match: "rgb(72, 57, 8)", + current: "rgb(116, 87, 0)", + currentBorder: "rgb(239, 177, 0)", } as const; -export const DATA_GRID_DARK_ROW_NUMBER_BG = "rgb(35 37 42)"; -const DATA_GRID_DARK_ROW_NUMBER_NEW_BG = "rgb(33 45 40)"; -const DATA_GRID_DARK_ROW_NUMBER_EDITED_BG = "rgb(48 41 28)"; -const DATA_GRID_DARK_ROW_NUMBER_DELETED_BG = "rgb(55 31 32)"; -const DATA_GRID_LIGHT_ROW_NUMBER_NEW_BG = "rgb(219 244 233)"; -const DATA_GRID_LIGHT_ROW_NUMBER_EDITED_BG = "rgb(253 241 219)"; -const DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG = "rgb(255 244 244)"; -const SUPPORTS_COLOR_MIX = - typeof CSS !== "undefined" && - typeof CSS.supports === "function" && - CSS.supports("color", "color-mix(in oklab, black 50%, white)"); -const SUPPORTS_OKLCH = - typeof CSS !== "undefined" && - typeof CSS.supports === "function" && - CSS.supports("color", "oklch(0.845 0.143 164.978)"); +export const DATA_GRID_DARK_ROW_NUMBER_BG = "rgb(35, 37, 42)"; +const DATA_GRID_DARK_ROW_NUMBER_NEW_BG = "rgb(33, 45, 40)"; +const DATA_GRID_DARK_ROW_NUMBER_EDITED_BG = "rgb(48, 41, 28)"; +const DATA_GRID_DARK_ROW_NUMBER_DELETED_BG = "rgb(55, 31, 32)"; +const DATA_GRID_LIGHT_ROW_NUMBER_NEW_BG = "rgb(219, 244, 233)"; +const DATA_GRID_LIGHT_ROW_NUMBER_EDITED_BG = "rgb(253, 241, 219)"; +const DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG = "rgb(255, 244, 244)"; +const CANVAS_SAFE_COLOR_RE = /^(#|rgb\(|rgba\(|hsl\(|hsla\()/i; +const ADVANCED_COLOR_RE = /^(oklch\(|oklab\(|lab\(|lch\(|color\(|color-mix\()/i; -function colorValue(fallback: string, preferred: string, supported: boolean): string { - return supported ? preferred : fallback; +interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} + +function parseAlpha(value: string | undefined): number { + if (!value) return 1; + const trimmed = value.trim(); + if (trimmed.endsWith("%")) return clamp(Number.parseFloat(trimmed) / 100, 0, 1); + return clamp(Number.parseFloat(trimmed), 0, 1); +} + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function formatAlpha(value: number): string { + return Number(clamp(value, 0, 1).toFixed(3)).toString(); +} + +function formatRgb(color: RgbaColor): string { + const r = Math.round(clamp(color.r, 0, 255)); + const g = Math.round(clamp(color.g, 0, 255)); + const b = Math.round(clamp(color.b, 0, 255)); + if (color.a < 1) return `rgba(${r}, ${g}, ${b}, ${formatAlpha(color.a)})`; + return `rgb(${r}, ${g}, ${b})`; +} + +function parseRgbColor(value: string): RgbaColor | null { + const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const fullHex = hex.length === 3 ? [...hex].map((char) => `${char}${char}`).join("") : hex; + return { + r: Number.parseInt(fullHex.slice(0, 2), 16), + g: Number.parseInt(fullHex.slice(2, 4), 16), + b: Number.parseInt(fullHex.slice(4, 6), 16), + a: 1, + }; + } + + const rgb = value.match(/^rgba?\((.+)\)$/i)?.[1]?.trim(); + if (!rgb) return null; + const parts = rgb.includes(",") + ? rgb.split(",").map((part) => part.trim()) + : rgb.replace(/\s+\/\s+/, " / ").split(/\s+/); + const slashIndex = parts.indexOf("/"); + const channels = slashIndex >= 0 ? parts.slice(0, slashIndex) : parts.slice(0, 3); + const alpha = slashIndex >= 0 ? parts[slashIndex + 1] : parts[3]; + if (channels.length < 3) return null; + const [r, g, b] = channels.map((part) => Number.parseFloat(part)); + if ([r, g, b].some((channel) => Number.isNaN(channel))) return null; + return { r, g, b, a: parseAlpha(alpha) }; +} + +function splitTopLevelCommas(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (char === "," && depth === 0) { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(value.slice(start).trim()); + return parts; +} + +function parseColorStop(value: string): { color: string; percent?: number } | null { + const match = value.match(/^(.*)\s+([0-9.]+)%$/); + if (!match) return { color: value.trim() }; + return { + color: match[1]?.trim() ?? "", + percent: Number.parseFloat(match[2] ?? ""), + }; +} + +function mixRgb(first: RgbaColor, firstPercent: number, second: RgbaColor, secondPercent: number): RgbaColor { + const total = firstPercent + secondPercent || 100; + const firstWeight = firstPercent / total; + const secondWeight = secondPercent / total; + return { + r: first.r * firstWeight + second.r * secondWeight, + g: first.g * firstWeight + second.g * secondWeight, + b: first.b * firstWeight + second.b * secondWeight, + a: first.a * firstWeight + second.a * secondWeight, + }; +} + +function parseColorMix(value: string): string | null { + const body = value.match(/^color-mix\(\s*in\s+oklab\s*,\s*(.*)\)$/i)?.[1]; + if (!body) return null; + const [firstRaw, secondRaw] = splitTopLevelCommas(body); + if (!firstRaw || !secondRaw) return null; + const first = parseColorStop(firstRaw); + const second = parseColorStop(secondRaw); + if (!first?.color || !second?.color || first.percent === undefined) return null; + const firstColor = parseRgbColor(toCanvasSafeColor(first.color, "")); + if (!firstColor) return null; + const secondPercent = second.percent ?? 100 - first.percent; + if (second.color.toLowerCase() === "transparent") { + return formatRgb({ ...firstColor, a: firstColor.a * (first.percent / 100) }); + } + const secondColor = parseRgbColor(toCanvasSafeColor(second.color, "")); + if (!secondColor) return null; + return formatRgb(mixRgb(firstColor, first.percent, secondColor, secondPercent)); +} + +function toCanvasSafeColor(value: string, fallback: string): string { + const trimmed = value.trim(); + if (!trimmed) return fallback; + const mixed = parseColorMix(trimmed); + if (mixed) return mixed; + const rgb = parseRgbColor(trimmed); + if (rgb) return formatRgb(rgb); + if (CANVAS_SAFE_COLOR_RE.test(trimmed)) return trimmed; + if (ADVANCED_COLOR_RE.test(trimmed)) return fallback; + return `hsl(${trimmed})`; } export function cssVarColor(getVar: (name: string) => string, name: string, fallback: string): string { - const value = getVar(name).trim(); - if (!value) return fallback; - return value.startsWith("#") || /^(rgb|hsl|oklch|oklab|lab|lch|color-mix)\(/.test(value) ? value : `hsl(${value})`; + return toCanvasSafeColor(getVar(name), fallback); } function resolveCssVarReferences(value: string, getVar: (name: string) => string, depth = 0): string { @@ -64,17 +178,13 @@ function resolveCssVarReferences(value: string, getVar: (name: string) => string return value.replace(/var\(\s*(--[\w-]+)(?:\s*,\s*([^)]+))?\s*\)/g, (_match, name: string, fallback?: string) => { const resolved = getVar(name).trim() || fallback?.trim() || ""; if (!resolved) return ""; - const nested = resolveCssVarReferences(resolved, getVar, depth + 1); - return cssVarColor(() => nested, name, nested); + return resolveCssVarReferences(resolved, getVar, depth + 1); }); } function paintToken(getVar: (name: string) => string, name: string, fallback: string): string { const value = resolveCssVarReferences(getVar(name).trim(), getVar); - if (!value) return fallback; - return value.startsWith("#") || /^(rgb|hsl|oklch|oklab|lab|lch|color-mix)\(/.test(value) - ? value - : cssVarColor(() => value, name, fallback); + return toCanvasSafeColor(value, fallback); } export function resolveDataGridPaintTheme(options: { @@ -82,79 +192,41 @@ export function resolveDataGridPaintTheme(options: { isDark: boolean; }): DataGridPaintTheme { const { getVar, isDark } = options; - const background = cssVarColor(getVar, "--background", isDark ? "rgb(19 20 22)" : "rgb(255 255 255)"); - const foreground = cssVarColor(getVar, "--foreground", isDark ? "rgb(215 215 219)" : "rgb(10 10 10)"); - const mutedForeground = cssVarColor(getVar, "--muted-foreground", isDark ? "rgb(151 152 157)" : "rgb(115 115 115)"); - const primary = cssVarColor(getVar, "--primary", isDark ? "rgb(208 208 214)" : "rgb(23 23 23)"); - const destructive = cssVarColor(getVar, "--destructive", isDark ? "rgb(243 98 95)" : "rgb(231 0 11)"); - const accent = cssVarColor(getVar, "--accent", isDark ? "rgb(46 47 51)" : "rgb(245 245 245)"); - const activeSurface = isDark - ? "rgb(64 64 64)" - : colorValue("rgb(232 232 232)", `color-mix(in oklab, ${primary} 10%, ${background})`, SUPPORTS_COLOR_MIX); - const rowMuted = colorValue( - isDark ? "rgb(32 32 34)" : "rgb(248 248 248)", - `color-mix(in oklab, ${cssVarColor(getVar, "--muted", isDark ? "rgb(42 42 45)" : "rgb(245 245 245)")} 30%, transparent)`, - SUPPORTS_COLOR_MIX, - ); - const rowNew = colorValue( - isDark ? "rgb(51 51 55)" : "rgb(243 243 243)", - `color-mix(in oklab, ${primary} 5%, transparent)`, - SUPPORTS_COLOR_MIX, - ); - const rowDeleted = colorValue( - isDark ? "rgb(55 31 32)" : "rgb(255 244 244)", - `color-mix(in oklab, ${destructive} 5%, transparent)`, - SUPPORTS_COLOR_MIX, + const background = cssVarColor(getVar, "--background", isDark ? "rgb(19, 20, 22)" : "rgb(255, 255, 255)"); + const foreground = cssVarColor(getVar, "--foreground", isDark ? "rgb(215, 215, 219)" : "rgb(10, 10, 10)"); + const mutedForeground = cssVarColor( + getVar, + "--muted-foreground", + isDark ? "rgb(151, 152, 157)" : "rgb(115, 115, 115)", ); + const primary = cssVarColor(getVar, "--primary", isDark ? "rgb(208, 208, 214)" : "rgb(23, 23, 23)"); + const destructive = cssVarColor(getVar, "--destructive", isDark ? "rgb(243, 98, 95)" : "rgb(231, 0, 11)"); + const accent = cssVarColor(getVar, "--accent", isDark ? "rgb(46, 47, 51)" : "rgb(245, 245, 245)"); + const activeSurface = isDark ? "rgb(64, 64, 64)" : "rgb(232, 232, 232)"; + const rowMuted = isDark ? "rgb(32, 32, 34)" : "rgb(248, 248, 248)"; + const rowNew = isDark ? "rgb(51, 51, 55)" : "rgb(243, 243, 243)"; + const rowDeleted = isDark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)"; const cellActive = activeSurface; - const cellDirty = colorValue( - isDark ? "rgb(94 75 26)" : "rgb(255 248 230)", - `color-mix(in oklab, ${colorValue("rgb(240 177 0)", "oklch(0.795 0.184 86.047)", SUPPORTS_OKLCH)} 10%, transparent)`, - SUPPORTS_COLOR_MIX, - ); - const cellSelected = colorValue( - isDark ? "rgb(66 67 70)" : "rgb(226 226 226)", - `color-mix(in oklab, ${primary} 25%, transparent)`, - SUPPORTS_COLOR_MIX, - ); - const cellSelectedDirty = colorValue( - isDark ? "rgb(94 75 26)" : "rgb(244 229 186)", - `color-mix(in oklab, ${colorValue("rgb(234 181 50)", "oklch(0.8 0.15 85)", SUPPORTS_OKLCH)} 30%, color-mix(in oklab, ${primary} 18%, transparent))`, - SUPPORTS_COLOR_MIX, - ); - const cellSelectedBorder = colorValue( - isDark ? "rgb(170 170 175)" : "rgb(90 90 90)", - `color-mix(in oklab, ${primary} 70%, transparent)`, - SUPPORTS_COLOR_MIX, - ); - const cellHover = colorValue(accent, `color-mix(in oklab, ${accent} 50%, transparent)`, SUPPORTS_COLOR_MIX); - const cellSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : "rgb(253 245 184)"; - const cellCurrentSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : "rgb(253 224 71 / 52%)"; - const cellCurrentSearchBorder = isDark ? DATA_GRID_DARK_SEARCH_COLORS.currentBorder : "rgb(234 179 8 / 82%)"; + const cellDirty = isDark ? "rgb(94, 75, 26)" : "rgb(255, 248, 230)"; + const cellSelected = isDark ? "rgb(66, 67, 70)" : "rgb(226, 226, 226)"; + const cellSelectedDirty = isDark ? "rgb(94, 75, 26)" : "rgb(244, 229, 186)"; + const cellSelectedBorder = isDark ? "rgb(170, 170, 175)" : "rgb(90, 90, 90)"; + const cellHover = accent; + const cellSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : "rgb(253, 245, 184)"; + const cellCurrentSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : "rgba(253, 224, 71, 0.52)"; + const cellCurrentSearchBorder = isDark ? DATA_GRID_DARK_SEARCH_COLORS.currentBorder : "rgba(234, 179, 8, 0.82)"; const rowNumberDefault = isDark ? DATA_GRID_DARK_ROW_NUMBER_BG - : paintToken(getVar, "--data-grid-row-number-default-bg", "rgb(255 255 255)"); - const rowNumberNew = colorValue( - isDark ? DATA_GRID_DARK_ROW_NUMBER_NEW_BG : DATA_GRID_LIGHT_ROW_NUMBER_NEW_BG, - `color-mix(in oklab, rgb(16 185 129) 15%, ${background})`, - SUPPORTS_COLOR_MIX, - ); - const rowNumberEdited = colorValue( - isDark ? DATA_GRID_DARK_ROW_NUMBER_EDITED_BG : DATA_GRID_LIGHT_ROW_NUMBER_EDITED_BG, - `color-mix(in oklab, rgb(245 158 11) 15%, ${background})`, - SUPPORTS_COLOR_MIX, - ); - const rowNumberDeleted = colorValue( - isDark ? DATA_GRID_DARK_ROW_NUMBER_DELETED_BG : DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG, - `color-mix(in oklab, ${destructive} 15%, ${background})`, - SUPPORTS_COLOR_MIX, - ); + : paintToken(getVar, "--data-grid-row-number-default-bg", "rgb(255, 255, 255)"); + const rowNumberNew = isDark ? DATA_GRID_DARK_ROW_NUMBER_NEW_BG : DATA_GRID_LIGHT_ROW_NUMBER_NEW_BG; + const rowNumberEdited = isDark ? DATA_GRID_DARK_ROW_NUMBER_EDITED_BG : DATA_GRID_LIGHT_ROW_NUMBER_EDITED_BG; + const rowNumberDeleted = isDark ? DATA_GRID_DARK_ROW_NUMBER_DELETED_BG : DATA_GRID_LIGHT_ROW_NUMBER_DELETED_BG; const rowNumberActive = activeSurface; const rowNumberSelected = cellSelected; return { background, - border: cssVarColor(getVar, "--border", isDark ? "rgb(63 63 70)" : "rgb(229 231 235)"), + border: cssVarColor(getVar, "--border", isDark ? "rgb(63, 63, 70)" : "rgb(229, 231, 235)"), foreground, mutedForeground, primary, @@ -177,16 +249,8 @@ export function resolveDataGridPaintTheme(options: { rowNumberActive: paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive), rowNumberSelected: paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected), rowNumberTextClean: mutedForeground, - rowNumberTextNew: colorValue( - isDark ? "rgb(94 233 181)" : "rgb(0 122 85)", - isDark ? "oklch(0.845 0.143 164.978)" : "oklch(0.508 0.118 165.612)", - SUPPORTS_OKLCH, - ), - rowNumberTextEdited: colorValue( - isDark ? "rgb(255 210 48)" : "rgb(187 77 0)", - isDark ? "oklch(0.879 0.169 91.605)" : "oklch(0.555 0.163 48.998)", - SUPPORTS_OKLCH, - ), + rowNumberTextNew: isDark ? "rgb(94, 233, 181)" : "rgb(0, 122, 85)", + rowNumberTextEdited: isDark ? "rgb(255, 210, 48)" : "rgb(187, 77, 0)", rowNumberTextDeleted: destructive, }; } diff --git a/packages/app-tests/dataGridPaintTheme.test.ts b/packages/app-tests/dataGridPaintTheme.test.ts new file mode 100644 index 000000000..aadf42e02 --- /dev/null +++ b/packages/app-tests/dataGridPaintTheme.test.ts @@ -0,0 +1,43 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { resolveDataGridPaintTheme } from "../../apps/desktop/src/lib/dataGridPaintTheme.ts"; + +test("canvas grid paint theme falls back from advanced CSS colors", () => { + const vars = new Map([ + ["--background", "oklch(1 0 0)"], + ["--foreground", "oklch(0.145 0 0)"], + ["--primary", "oklch(0.205 0 0)"], + ["--border", "oklch(0.922 0 0)"], + ["--data-grid-cell-selected-bg", "color-mix(in oklab, var(--primary) 25%, transparent)"], + ["--data-grid-row-number-selected-bg", "color-mix(in oklab, var(--primary) 25%, var(--background))"], + ]); + + const theme = resolveDataGridPaintTheme({ + getVar: (name) => vars.get(name) ?? "", + isDark: false, + }); + + assert.equal(theme.background, "rgb(255, 255, 255)"); + assert.equal(theme.foreground, "rgb(10, 10, 10)"); + assert.equal(theme.border, "rgb(229, 231, 235)"); + assert.equal(theme.primary, "rgb(23, 23, 23)"); + assert.equal(theme.cellSelected, "rgb(226, 226, 226)"); + assert.equal(theme.rowNumberSelected, "rgb(226, 226, 226)"); +}); + +test("canvas grid paint theme converts rgb color-mix tokens to rgba", () => { + const vars = new Map([ + ["--primary", "rgb(23 23 23)"], + ["--background", "rgb(255 255 255)"], + ["--data-grid-cell-selected-bg", "color-mix(in oklab, var(--primary) 25%, transparent)"], + ["--data-grid-row-number-selected-bg", "color-mix(in oklab, var(--primary) 25%, var(--background))"], + ]); + + const theme = resolveDataGridPaintTheme({ + getVar: (name) => vars.get(name) ?? "", + isDark: false, + }); + + assert.equal(theme.cellSelected, "rgba(23, 23, 23, 0.25)"); + assert.equal(theme.rowNumberSelected, "rgb(197, 197, 197)"); +});