Merge branch 'dev/hybrid-canvas-grid'

# Conflicts:
#	apps/desktop/src/components/grid/DataGrid.vue
#	apps/desktop/src/stores/settingsStore.ts
This commit is contained in:
t8y2 committed 2026-06-02 21:48:02 +08:00
commit df32a46080
10 files changed
+1180 -39

No files matched your search

@@ -0,0 +1,396 @@
import type { CellValue } from "@/lib/cellValue";
import type { RowStatus } from "@/lib/gridRowStatus";
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme } from "@/lib/dataGridPaintTheme";
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
export interface CanvasDataGridRow {
id: number;
displayIndex: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: RowStatus;
}
export interface CanvasHoverCell {
rowIndex: number;
visibleColIdx: number;
}
export interface CanvasEditingCell {
rowId: number;
col: number;
}
export interface CanvasSingleSelectedCell {
rowIndex: number;
visibleColIdx: number;
}
export interface CanvasSearchMatch {
displayRow: number;
col: number;
}
export interface DrawCanvasDataGridOptions {
canvas: HTMLCanvasElement;
scroller: HTMLElement;
width: number;
height: number;
isDark: boolean;
rows: CanvasDataGridRow[];
renderedColumnWidths: number[];
visibleColumnIndexes: number[];
rowNumberWidth: number;
hoverCell: CanvasHoverCell | null;
isScrolling: boolean;
editingCell: CanvasEditingCell | null;
singleSelectedCell: CanvasSingleSelectedCell | null;
searchMatchKeys: ReadonlySet<string>;
currentSearchMatch: CanvasSearchMatch | null;
formatCell: (value: CellValue, columnIndex: number) => string;
isRowActive: (rowIndex: number) => boolean;
isRowSelected: (rowId: number) => boolean;
rowCellsUseSelectionVisual: (rowId: number) => boolean;
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
}
type NumericCanvasContext = CanvasRenderingContext2D & {
fontVariantNumeric?: string;
};
function setCanvasNumericVariant(ctx: CanvasRenderingContext2D, value: "normal" | "tabular-nums") {
const numericCtx = ctx as NumericCanvasContext;
if ("fontVariantNumeric" in numericCtx) numericCtx.fontVariantNumeric = value;
}
function canvasTabularFontFamily(fontFamily: string): string {
return `"Geist Variable Tabular", ${fontFamily}`;
}
function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0 || ctx.measureText(text).width <= maxWidth) return text;
const ellipsis = "...";
if (ctx.measureText(ellipsis).width > maxWidth) return "";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(`${text.slice(0, mid)}${ellipsis}`).width <= maxWidth) low = mid;
else high = mid - 1;
}
return `${text.slice(0, low)}${ellipsis}`;
}
function canvasFont(style: {
family: string;
sizePx: number;
style?: string;
weight?: string | number;
lineHeight?: string;
}): string {
const fontStyle = style.style && style.style !== "normal" ? `${style.style} ` : "";
const fontWeight = style.weight && style.weight !== "400" && style.weight !== "normal" ? `${style.weight} ` : "";
const lineHeight = style.lineHeight && style.lineHeight !== "normal" ? `/${style.lineHeight}` : "";
return `${fontStyle}${fontWeight}${style.sizePx}px${lineHeight} ${style.family}`;
}
function columnOffsets(widths: number[]): number[] {
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
offsets[0] = 0;
for (let index = 0; index < widths.length; index++) {
offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
}
return offsets;
}
function firstVisibleColumn(offsets: number[], contentStart: number): number {
let low = 0;
let high = Math.max(0, offsets.length - 2);
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((offsets[mid + 1] ?? 0) < contentStart) low = mid + 1;
else high = mid;
}
return low;
}
export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const {
canvas,
scroller,
width,
height,
isDark,
rows,
renderedColumnWidths,
visibleColumnIndexes,
rowNumberWidth,
hoverCell,
isScrolling,
editingCell,
singleSelectedCell,
searchMatchKeys,
currentSearchMatch,
formatCell,
isRowActive,
isRowSelected,
rowCellsUseSelectionVisual,
cellIsSelected,
cellCanHover,
} = options;
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.floor(width * dpr);
const pixelHeight = Math.floor(height * dpr);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const canvasStyle = getComputedStyle(canvas);
const fontFamily =
canvasStyle.fontFamily || `"Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif`;
const fontSize = Number.parseFloat(canvasStyle.fontSize) || 12;
const lineHeight = canvasStyle.lineHeight;
const normalFont = canvasFont({
family: fontFamily,
sizePx: fontSize,
weight: canvasStyle.fontWeight,
lineHeight,
});
const tabularFont = canvasFont({
family: canvasTabularFontFamily(fontFamily),
sizePx: fontSize,
weight: canvasStyle.fontWeight,
lineHeight,
});
const semiboldFont = canvasFont({ family: fontFamily, sizePx: fontSize, weight: 600, lineHeight });
const italicFont = canvasFont({
family: fontFamily,
sizePx: fontSize,
style: "italic",
weight: canvasStyle.fontWeight,
lineHeight,
});
const theme = resolveDataGridPaintTheme({
getVar: (name) => canvasStyle.getPropertyValue(name),
isDark,
});
const searchFill = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : theme.cellSearch;
const currentSearchFill = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : theme.cellCurrentSearch;
const currentSearchBorder = isDark ? DATA_GRID_DARK_SEARCH_COLORS.currentBorder : theme.cellCurrentSearchBorder;
const scrollTop = scroller.scrollTop;
const scrollLeft = scroller.scrollLeft;
const firstRow = Math.max(0, Math.floor(scrollTop / CANVAS_DATA_GRID_ROW_HEIGHT));
const lastRow = Math.min(rows.length - 1, Math.ceil((scrollTop + height) / CANVAS_DATA_GRID_ROW_HEIGHT));
ctx.fillStyle = theme.background;
ctx.fillRect(0, 0, width, height);
ctx.font = normalFont;
ctx.textBaseline = "middle";
const offsets = columnOffsets(renderedColumnWidths);
const contentStart = Math.max(0, scrollLeft - rowNumberWidth);
const firstCol = firstVisibleColumn(offsets, contentStart);
const columnOffset = offsets[firstCol] ?? 0;
const paintSearchMatches = !isScrolling && searchMatchKeys.size > 0;
for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
const item = rows[rowIndex];
if (!item) continue;
const y = rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop;
const rowIsActive = isRowActive(item.displayIndex);
const rowBase = item.isDeleted
? theme.rowDeleted
: item.isNew && !rowIsActive
? theme.rowNew
: item.displayIndex % 2 === 1 && !rowIsActive
? theme.rowMuted
: theme.background;
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
ctx.fillStyle = rowBase;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
let rowNumberFill =
item.status === "new"
? theme.rowNumberNew
: item.status === "edited"
? theme.rowNumberEdited
: item.status === "deleted"
? theme.rowNumberDeleted
: theme.rowNumberDefault;
if (rowIsActive && !item.isDeleted) rowNumberFill = theme.rowNumberActive;
if (isRowSelected(item.id) && item.status === "clean") rowNumberFill = theme.rowNumberSelected;
ctx.fillStyle = rowNumberFill;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
if (
hoverCell?.rowIndex === item.displayIndex &&
hoverCell.visibleColIdx < 0 &&
!isScrolling &&
item.status === "clean" &&
!rowIsActive &&
!isRowSelected(item.id)
) {
ctx.fillStyle = theme.cellHover;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(rowNumberWidth + 0.5, y);
ctx.lineTo(rowNumberWidth + 0.5, y + CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.stroke();
const rowNumberText =
item.status === "new"
? theme.rowNumberTextNew
: item.status === "edited"
? theme.rowNumberTextEdited
: item.status === "deleted"
? theme.rowNumberTextDeleted
: isRowSelected(item.id)
? theme.primary
: theme.rowNumberTextClean;
ctx.fillStyle = rowNumberText;
ctx.font = item.status === "new" || item.status === "edited" || isRowSelected(item.id) ? semiboldFont : normalFont;
ctx.textAlign = "center";
ctx.fillText(String(item.displayIndex + 1), rowNumberWidth / 2, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.font = normalFont;
let x = rowNumberWidth + columnOffset - scrollLeft;
for (let visibleColIdx = firstCol; visibleColIdx < renderedColumnWidths.length && x < width; visibleColIdx++) {
const colWidth = renderedColumnWidths[visibleColIdx] ?? 0;
const actualColIdx = visibleColumnIndexes[visibleColIdx];
if (actualColIdx === undefined) {
x += colWidth;
continue;
}
if (x + colWidth >= rowNumberWidth) {
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
const rowSelectionVisual = rowCellsUseSelectionVisual(item.id);
const isSingleSelectedCell =
singleSelectedCell?.rowIndex === item.displayIndex && singleSelectedCell.visibleColIdx === visibleColIdx;
const isDirtyCell = item.isDirtyCol[actualColIdx];
const selectedFillVisual =
rowSelectionVisual || (selectedCell && !isSingleSelectedCell && (!rowIsActive || isDirtyCell));
const selectedBorderVisual = rowSelectionVisual || selectedCell;
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(`${item.displayIndex}:${actualColIdx}`);
const isCurrentSearchMatch =
paintSearchMatches &&
currentSearchMatch?.displayRow === item.displayIndex &&
currentSearchMatch.col === actualColIdx;
const clippedX = Math.max(x, rowNumberWidth);
const cellPaintWidth = colWidth - Math.max(0, clippedX - x);
if (isDirtyCell) {
ctx.fillStyle = theme.cellDirty;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (
hoverCell?.rowIndex === item.displayIndex &&
hoverCell.visibleColIdx === visibleColIdx &&
!isScrolling &&
!isSearchMatch &&
!isCurrentSearchMatch &&
cellCanHover(item, actualColIdx)
) {
ctx.fillStyle = theme.cellHover;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (selectedFillVisual) {
ctx.fillStyle = isDirtyCell ? theme.cellSelectedDirty : theme.cellSelected;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (rowIsActive && !item.isDeleted && !isDirtyCell) {
ctx.fillStyle = theme.cellActive;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (isSearchMatch) {
ctx.fillStyle = searchFill;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (isCurrentSearchMatch) {
ctx.fillStyle = currentSearchFill;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
ctx.save();
ctx.beginPath();
ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.clip();
const value = item.data[actualColIdx];
ctx.textAlign = "left";
ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground;
ctx.font = value === null ? italicFont : typeof value === "number" ? tabularFont : normalFont;
setCanvasNumericVariant(ctx, typeof value === "number" ? "tabular-nums" : "normal");
const textLeft = x + 12;
const textMaxWidth = Math.max(0, x + colWidth - textLeft - 12);
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
const displayText = isEditingThisCell ? "" : formatCell(value, actualColIdx);
const text = isEditingThisCell || isScrolling ? displayText : fitCanvasText(ctx, displayText, textMaxWidth);
ctx.fillText(text, textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
if (item.isDeleted && text) {
const textWidth = Math.min(ctx.measureText(text).width, textMaxWidth);
ctx.strokeStyle = theme.foreground;
ctx.beginPath();
ctx.moveTo(textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.lineTo(textLeft + textWidth, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.stroke();
}
ctx.restore();
setCanvasNumericVariant(ctx, "normal");
ctx.font = normalFont;
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(x + colWidth - 0.5, y);
ctx.lineTo(x + colWidth - 0.5, y + CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.stroke();
if (selectedBorderVisual && cellPaintWidth >= 2) {
const selectedLeftX = clippedX + 0.5;
const selectedRightX = clippedX + cellPaintWidth - 1.5;
const selectedTopY = y + 0.5;
const selectedBottomY = y + CANVAS_DATA_GRID_ROW_HEIGHT - 1.5;
ctx.strokeStyle = theme.cellSelectedBorder;
ctx.beginPath();
ctx.moveTo(selectedLeftX, selectedTopY);
ctx.lineTo(selectedRightX, selectedTopY);
ctx.moveTo(selectedLeftX, selectedBottomY);
ctx.lineTo(selectedRightX, selectedBottomY);
ctx.moveTo(selectedLeftX, selectedTopY);
ctx.lineTo(selectedLeftX, selectedBottomY);
ctx.moveTo(selectedRightX, selectedTopY);
ctx.lineTo(selectedRightX, selectedBottomY);
ctx.stroke();
}
if (isCurrentSearchMatch) {
ctx.strokeStyle = currentSearchBorder;
ctx.lineWidth = 2;
ctx.strokeRect(clippedX + 1, y + 1, Math.max(0, cellPaintWidth - 2), CANVAS_DATA_GRID_ROW_HEIGHT - 2);
ctx.lineWidth = 1;
}
}
x += colWidth;
}
if (!isRowSelected(item.id)) {
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(0, y + CANVAS_DATA_GRID_ROW_HEIGHT - 0.5);
ctx.lineTo(width, y + CANVAS_DATA_GRID_ROW_HEIGHT - 0.5);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
}
+123
View File
@@ -0,0 +1,123 @@
export interface DataGridPaintTheme {
background: string;
border: string;
foreground: string;
mutedForeground: string;
primary: string;
rowMuted: string;
rowNew: string;
rowDeleted: string;
cellActive: string;
cellDirty: string;
cellSelected: string;
cellSelectedDirty: string;
cellSelectedBorder: string;
cellHover: string;
cellSearch: string;
cellCurrentSearch: string;
cellCurrentSearchBorder: string;
rowNumberDefault: string;
rowNumberNew: string;
rowNumberEdited: string;
rowNumberDeleted: string;
rowNumberActive: string;
rowNumberSelected: string;
rowNumberTextClean: string;
rowNumberTextNew: string;
rowNumberTextEdited: string;
rowNumberTextDeleted: string;
}
export const DATA_GRID_DARK_SEARCH_COLORS = {
match: "rgb(72 57 8)",
current: "rgb(116 87 0)",
currentBorder: "rgb(239 177 0)",
} as const;
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})`;
}
function resolveCssVarReferences(value: string, getVar: (name: string) => string, depth = 0): string {
if (depth > 6 || !value.includes("var(")) return value;
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);
});
}
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);
}
export function resolveDataGridPaintTheme(options: {
getVar: (name: string) => string;
isDark: boolean;
}): DataGridPaintTheme {
const { getVar, isDark } = options;
const background = cssVarColor(getVar, "--background", isDark ? "oklch(0.145 0 0)" : "oklch(1 0 0)");
const foreground = cssVarColor(getVar, "--foreground", isDark ? "oklch(0.985 0 0)" : "oklch(0.145 0 0)");
const mutedForeground = cssVarColor(getVar, "--muted-foreground", isDark ? "oklch(0.708 0 0)" : "oklch(0.556 0 0)");
const primary = cssVarColor(getVar, "--primary", isDark ? "oklch(0.922 0 0)" : "oklch(0.205 0 0)");
const muted = cssVarColor(getVar, "--muted", isDark ? "oklch(0.269 0 0)" : "oklch(0.97 0 0)");
const destructive = cssVarColor(getVar, "--destructive", "oklch(0.6 0.22 25)");
const accent = cssVarColor(getVar, "--accent", isDark ? "oklch(0.269 0 0)" : "oklch(0.97 0 0)");
const yellow500 = "oklch(0.795 0.184 86.047)";
const activeSurface = isDark ? "rgb(64 64 64)" : `color-mix(in oklab, ${primary} 10%, ${background})`;
const rowMuted = `color-mix(in oklab, ${muted} 30%, transparent)`;
const rowNew = `color-mix(in oklab, ${primary} 5%, transparent)`;
const rowDeleted = `color-mix(in oklab, ${destructive} 5%, transparent)`;
const cellActive = activeSurface;
const cellDirty = `color-mix(in oklab, ${yellow500} 10%, transparent)`;
const cellSelected = `color-mix(in oklab, ${primary} 25%, transparent)`;
const cellSelectedDirty = `color-mix(in oklab, oklch(0.8 0.15 85) 30%, color-mix(in oklab, ${primary} 18%, transparent))`;
const cellSelectedBorder = `color-mix(in oklab, ${primary} 70%, transparent)`;
const cellHover = `color-mix(in oklab, ${accent} 50%, transparent)`;
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 rowNumberDefault = isDark ? "rgb(15 15 15)" : "rgb(255 255 255)";
const rowNumberNew = `color-mix(in oklab, rgb(16 185 129) 15%, ${background})`;
const rowNumberEdited = `color-mix(in oklab, rgb(245 158 11) 15%, ${background})`;
const rowNumberDeleted = `color-mix(in oklab, ${destructive} 15%, ${background})`;
const rowNumberActive = activeSurface;
const rowNumberSelected = `color-mix(in oklab, ${primary} 25%, ${background})`;
return {
background,
border: cssVarColor(getVar, "--border", isDark ? "rgb(63 63 70)" : "rgb(229 231 235)"),
foreground,
mutedForeground,
primary,
rowMuted: paintToken(getVar, "--data-grid-row-muted-bg", rowMuted),
rowNew: paintToken(getVar, "--data-grid-row-new-bg", rowNew),
rowDeleted: paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
cellActive: paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
cellSelected: paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
cellSelectedBorder: paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
cellHover: paintToken(getVar, "--data-grid-cell-hover-bg", cellHover),
cellSearch: paintToken(getVar, "--data-grid-cell-search-bg", cellSearch),
cellCurrentSearch: paintToken(getVar, "--data-grid-cell-current-search-bg", cellCurrentSearch),
cellCurrentSearchBorder: paintToken(getVar, "--data-grid-cell-current-search-border", cellCurrentSearchBorder),
rowNumberDefault,
rowNumberNew: paintToken(getVar, "--data-grid-row-number-new-bg", rowNumberNew),
rowNumberEdited: paintToken(getVar, "--data-grid-row-number-edited-bg", rowNumberEdited),
rowNumberDeleted: paintToken(getVar, "--data-grid-row-number-deleted-bg", rowNumberDeleted),
rowNumberActive: paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive),
rowNumberSelected: paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
rowNumberTextClean: mutedForeground,
rowNumberTextNew: isDark ? "oklch(0.845 0.143 164.978)" : "oklch(0.508 0.118 165.612)",
rowNumberTextEdited: isDark ? "oklch(0.879 0.169 91.605)" : "oklch(0.555 0.163 48.998)",
rowNumberTextDeleted: destructive,
};
}