fix(grid): unify and refine data grid selection visuals

This commit is contained in:
Freedom authored and GitHub committed 2026-08-07 15:57:45 +08:00
1 parent 19401112d2
commit b10d276d96
7 files changed
+763 -188

No files matched your search

@@ -1,5 +1,7 @@
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCellValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
import { dataGridFrameCoversRow, dataGridFrameIsMultiCell, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame } from "@/lib/dataGrid/dataGridSelectionFrames";
import type { CellSelectionRange } from "@/lib/dataGrid/gridSelection";
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
@@ -79,6 +81,9 @@ export interface DrawCanvasDataGridOptions {
isRowActive: (rowIndex: number) => boolean;
rowCellsUseSelectionVisual: (rowId: number) => boolean;
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
/** 连续选区矩形(displayRow/visibleCol 坐标)。用于 Navicat 风格的形态区分:
* 多格范围浅色填充 + 一圈细外框、内部零描边;1×1 画细边框;离散点选传空数组退回逐格描边 */
selectionFrames?: readonly CellSelectionRange[];
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
infiniteScrollEnabled: boolean;
pageOffset: number;
@@ -333,6 +338,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
isRowActive,
rowCellsUseSelectionVisual,
cellIsSelected,
selectionFrames = [],
cellCanHover,
infiniteScrollEnabled,
pageOffset,
@@ -342,6 +348,10 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
columnIsBoolean,
booleanDisplayMode = "dropdown",
} = options;
// 框选热路径:整次绘制只判断一次。常见情况(单矩形 / 多列且每段都是多格)可跳过逐格 kind 查询
const paintSelectionOuterFrame = dataGridSelectionUsesOuterFrame(selectionFrames);
const suppressAllSelectedCellBorders = selectionFrames.length > 0 && selectionFrames.every(dataGridFrameIsMultiCell);
const selectionRowCoverage = selectionFrames.length > 0;
const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({
width,
@@ -409,6 +419,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.fillStyle = rowFill;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
// 选区覆盖指示(Navicat 风格):行落在选区范围内时行号淡色高亮;
// 优先级低于行选中/状态色/活动行,与 DOM 的级联顺序一致
const rowInSelection = !rowSelectionVisual && selectionRowCoverage && dataGridFrameCoversRow(selectionFrames, item.displayIndex);
const rowNumberFill = rowSelectionVisual
? theme.rowNumberSelected
: item.status === "draft"
@@ -421,16 +434,24 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
? theme.rowNumberDeleted
: rowIsActive
? theme.rowNumberActive
: theme.rowNumberDefault;
: rowInSelection
? theme.cellSelected
: theme.rowNumberDefault;
ctx.fillStyle = rowNumberFill;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
// 与 DOM 的 .data-grid-row-number--selected 对齐:选中行号左侧 3px 蓝色条
if (rowSelectionVisual) {
ctx.fillStyle = theme.cellSelectedBorder;
ctx.fillRect(0, y, 3, CANVAS_DATA_GRID_ROW_HEIGHT);
}
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(rowNumberBorderX, y);
ctx.lineTo(rowNumberBorderX, 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 : theme.rowNumberTextClean;
// 与 DOM 对齐:选中行号文字用前景色
const rowNumberText = item.status === "new" ? theme.rowNumberTextNew : item.status === "edited" ? theme.rowNumberTextEdited : item.status === "deleted" ? theme.rowNumberTextDeleted : rowSelectionVisual ? theme.foreground : theme.rowNumberTextClean;
ctx.fillStyle = rowNumberText;
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
ctx.textAlign = "center";
@@ -464,7 +485,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
const isDirtyCell = item.isDirtyCol[actualColIdx];
const selectedFillVisual = rowSelectionVisual || selectedCell;
const selectedBorderVisual = selectedCell;
// Navicat 风格:多格范围内部零描边(末尾统一画外框);单格 / Ctrl 点选保留细边框
const selectedBorderVisual = !selectedCell ? false : suppressAllSelectedCellBorders ? false : paintSelectionOuterFrame ? dataGridSelectionFrameKindAtCell(selectionFrames, item.displayIndex, visibleColIdx) !== "range" : true;
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx));
const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
const clippedX = Math.max(drawX, rowNumberWidth);
@@ -562,7 +584,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const selectedRightX = clippedX + cellPaintWidth - 1.5;
const selectedTopY = Math.max(y + 0.5, 1);
const drawSelectedLeftBorder = selectedLeftX >= rowNumberWidth + 0.5;
ctx.strokeStyle = selectedCell && !isDirtyCell ? theme.cellSelectedSingleBorder : theme.cellSelectedBorder;
ctx.strokeStyle = theme.cellSelectedSingleBorder;
ctx.beginPath();
ctx.moveTo(selectedLeftX, selectedTopY);
ctx.lineTo(selectedRightX, selectedTopY);
@@ -641,4 +663,31 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.stroke();
ctx.restore();
}
// 选区外框:多格范围选区画一圈主题色细外框(1.5px,比单格的 1px 略粗),
// 内部零描边(Navicat 风格);1×1 单格外框已由逐格细边框覆盖,这里跳过
if (paintSelectionOuterFrame) {
const frozenWidth = frozenColumnCount > 0 && frozenColumnCount <= renderedColumnWidths.length ? (offsets[frozenColumnCount] ?? 0) : 0;
const frozenRight = rowNumberWidth + frozenWidth;
// 列边缘的屏幕 x:冻结区内的边缘不随水平滚动,其余减去 scrollLeft
const columnEdgeX = (edgeCol: number): number => {
const offset = offsets[edgeCol] ?? 0;
return edgeCol <= frozenColumnCount ? rowNumberWidth + offset : rowNumberWidth + offset - scrollLeft;
};
ctx.save();
ctx.strokeStyle = theme.cellSelectedSingleBorder;
ctx.lineWidth = 1.5;
for (const frame of selectionFrames) {
if (!dataGridFrameIsMultiCell(frame)) continue;
const minX = frame.startCol >= frozenColumnCount ? frozenRight : rowNumberWidth;
const minRightX = frame.endCol + 1 > frozenColumnCount ? frozenRight : rowNumberWidth;
const left = Math.min(Math.max(columnEdgeX(frame.startCol), minX), width);
const right = Math.min(Math.max(columnEdgeX(frame.endCol + 1), minRightX), width);
const top = Math.min(Math.max(frame.startRow * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop, 0), height);
const bottom = Math.min(Math.max((frame.endRow + 1) * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop, 0), height);
if (right - left < 2 || bottom - top < 2) continue;
ctx.strokeRect(left + 1, top + 1, right - left - 2, bottom - top - 2);
}
ctx.restore();
}
}
@@ -278,11 +278,11 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
rowDeleted: isDark ? rowDeleted : paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
cellActive: isDark ? cellActive : paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
cellSelected: isDark ? cellSelected : paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
cellSelected: paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
cellSelectedBorder: isDark ? cellSelectedBorder : paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
cellSelectedSingle: isDark ? cellSelectedSingle : paintToken(getVar, "--data-grid-cell-selected-single-bg", cellSelectedSingle),
cellSelectedSingleBorder: isDark ? primary : paintToken(getVar, "--data-grid-cell-selected-single-border", primary),
cellSelectedBorder: paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
cellSelectedSingle: paintToken(getVar, "--data-grid-cell-selected-single-bg", cellSelectedSingle),
cellSelectedSingleBorder: paintToken(getVar, "--data-grid-cell-selected-single-border", primary),
cellHover: isDark ? cellHover : paintToken(getVar, "--data-grid-cell-hover-bg", cellHover),
cellSearch: isDark ? cellSearch : paintToken(getVar, "--data-grid-cell-search-bg", cellSearch),
cellCurrentSearch: isDark ? cellCurrentSearch : paintToken(getVar, "--data-grid-cell-current-search-bg", cellCurrentSearch),
@@ -292,7 +292,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
rowNumberEdited: isDark ? rowNumberEdited : paintToken(getVar, "--data-grid-row-number-edited-bg", rowNumberEdited),
rowNumberDeleted: isDark ? rowNumberDeleted : paintToken(getVar, "--data-grid-row-number-deleted-bg", rowNumberDeleted),
rowNumberActive: isDark ? rowNumberActive : paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive),
rowNumberSelected: isDark ? rowNumberSelected : paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
rowNumberSelected: paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
rowNumberTextClean: mutedForeground,
rowNumberTextNew: isDark ? "rgb(94, 233, 181)" : "rgb(0, 122, 85)",
rowNumberTextEdited: isDark ? "rgb(255, 210, 48)" : "rgb(187, 77, 0)",
@@ -0,0 +1,139 @@
import type { CellSelectionRange } from "@/lib/dataGrid/gridSelection";
/** 选区外框数据:把当前选区解析为一组连续矩形(frames)。
* 只有能表示为连续矩形的选区才画外框(Navicat 风格:淡色填充 + 一圈外框);
* Ctrl 点选的离散单元格无法矩形化,返回 sparse,由调用方退回逐格描边。 */
export interface DataGridSelectionFrames {
frames: CellSelectionRange[];
sparse: boolean;
}
export function resolveDataGridSelectionFrames(options: { sparseCellCount: number; hasColumnSelection: boolean; selectedColumnIndexes: ReadonlySet<number>; selectedRange: CellSelectionRange | null; rowCount: number }): DataGridSelectionFrames {
if (options.sparseCellCount > 0) return { frames: [], sparse: true };
if (options.hasColumnSelection) {
const lastRow = Math.max(0, options.rowCount - 1);
const columns = [...options.selectedColumnIndexes].sort((a, b) => a - b);
const frames: CellSelectionRange[] = [];
let runStart = -1;
let runEnd = -1;
for (const col of columns) {
if (runStart < 0) {
runStart = col;
runEnd = col;
continue;
}
if (col === runEnd + 1) {
runEnd = col;
continue;
}
frames.push({ startRow: 0, endRow: lastRow, startCol: runStart, endCol: runEnd });
runStart = col;
runEnd = col;
}
if (runStart >= 0) frames.push({ startRow: 0, endRow: lastRow, startCol: runStart, endCol: runEnd });
return { frames, sparse: false };
}
if (options.selectedRange) return { frames: [options.selectedRange], sparse: false };
return { frames: [], sparse: false };
}
export function dataGridFrameContainsCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): boolean {
return dataGridFrameAtCell(frames, rowIndex, colIndex) !== null;
}
export function dataGridFrameAtCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): CellSelectionRange | null {
for (const frame of frames) {
if (rowIndex >= frame.startRow && rowIndex <= frame.endRow && colIndex >= frame.startCol && colIndex <= frame.endCol) return frame;
}
return null;
}
export function dataGridFrameIsMultiCell(frame: CellSelectionRange): boolean {
return frame.startRow !== frame.endRow || frame.startCol !== frame.endCol;
}
/** 行号的"选区覆盖"指示:行落在任一 frame 范围内即为被覆盖 */
export function dataGridFrameCoversRow(frames: readonly CellSelectionRange[], rowIndex: number): boolean {
for (const frame of frames) {
if (rowIndex >= frame.startRow && rowIndex <= frame.endRow) return true;
}
return false;
}
/** Navicat 风格的选区形态:单个单元格用细边框(single),
* 多格范围用实心填充 + 文字反白(range),不在选区内返回 null。 */
export function dataGridSelectionFrameKindAtCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): "single" | "range" | null {
const frame = dataGridFrameAtCell(frames, rowIndex, colIndex);
if (!frame) return null;
return dataGridFrameIsMultiCell(frame) ? "range" : "single";
}
/**
* 是否用「一圈外框」代替逐格描边。框选热路径下只算一次,避免每个可见格再查 frame kind。
* sparse(frames 为空)与 1×1 单格均为 false。
*/
export function dataGridSelectionUsesOuterFrame(frames: readonly CellSelectionRange[]): boolean {
for (const frame of frames) {
if (dataGridFrameIsMultiCell(frame)) return true;
}
return false;
}
export interface DataGridSelectionEdgeFlags {
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}
/** bitmask: top=1, right=2, bottom=4, left=8。不在外框上时返回 0。 */
export type DataGridSelectionEdgeMask = number;
export function dataGridSelectionEdgeMaskFromFlags(flags: DataGridSelectionEdgeFlags): DataGridSelectionEdgeMask {
return (flags.top ? 1 : 0) | (flags.right ? 2 : 0) | (flags.bottom ? 4 : 0) | (flags.left ? 8 : 0);
}
function edgeFlagsForFrame(frame: CellSelectionRange, rowIndex: number, colIndex: number): DataGridSelectionEdgeFlags | null {
if (rowIndex < frame.startRow || rowIndex > frame.endRow || colIndex < frame.startCol || colIndex > frame.endCol) return null;
return {
top: rowIndex === frame.startRow,
right: colIndex === frame.endCol,
bottom: rowIndex === frame.endRow,
left: colIndex === frame.startCol,
};
}
/** 单元格在选区外框上的边:相邻格不在任何 frame 内的那一侧即为外框边。
* 不在任何 frame 内时返回 null。单 frame(框选)走边界比较,避免四次邻格探测。 */
export function dataGridSelectionEdgeFlags(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): DataGridSelectionEdgeFlags | null {
if (frames.length === 0) return null;
if (frames.length === 1) return edgeFlagsForFrame(frames[0]!, rowIndex, colIndex);
if (!dataGridFrameContainsCell(frames, rowIndex, colIndex)) return null;
return {
top: !dataGridFrameContainsCell(frames, rowIndex - 1, colIndex),
right: !dataGridFrameContainsCell(frames, rowIndex, colIndex + 1),
bottom: !dataGridFrameContainsCell(frames, rowIndex + 1, colIndex),
left: !dataGridFrameContainsCell(frames, rowIndex, colIndex - 1),
};
}
/** DOM 用:返回外框边 bitmask(0 = 不在外框上,或不需要外框)。仅多格 frame 的边界格非 0。 */
export function dataGridSelectionEdgeMask(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): DataGridSelectionEdgeMask {
if (frames.length === 0) return 0;
if (frames.length === 1) {
const frame = frames[0]!;
if (!dataGridFrameIsMultiCell(frame)) return 0;
const flags = edgeFlagsForFrame(frame, rowIndex, colIndex);
return flags ? dataGridSelectionEdgeMaskFromFlags(flags) : 0;
}
const frame = dataGridFrameAtCell(frames, rowIndex, colIndex);
if (!frame || !dataGridFrameIsMultiCell(frame)) return 0;
return dataGridSelectionEdgeMaskFromFlags({
top: !dataGridFrameContainsCell(frames, rowIndex - 1, colIndex),
right: !dataGridFrameContainsCell(frames, rowIndex, colIndex + 1),
bottom: !dataGridFrameContainsCell(frames, rowIndex + 1, colIndex),
left: !dataGridFrameContainsCell(frames, rowIndex, colIndex - 1),
});
}