fix(grid): prevent canvas selection from rendering black
This commit is contained in:
parent
a1d0178a7e
commit
7cfdcff8c7
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
Loading…
Reference in New Issue