feat(grid): add frozen columns

This commit is contained in:
gggaiitx authored and GitHub committed 2026-07-22 15:07:47 +08:00
1 parent 6e5f582320
commit dfab2d09ed
16 files changed
+809 -28

No files matched your search

@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid, type DrawCanvasDataGridOptions } from "@/lib/dataGrid/canvasDataGridRenderer";
function createMockCanvas(width = 800, height = 400) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// Mock getComputedStyle
canvas.style.fontFamily = "sans-serif";
canvas.style.fontSize = "13px";
canvas.style.fontWeight = "400";
canvas.style.lineHeight = "normal";
return canvas;
}
function createMockScroller(scrollLeft = 0, scrollTop = 0, clientWidth = 800, clientHeight = 400) {
const scroller = document.createElement("div");
Object.defineProperty(scroller, "scrollLeft", { value: scrollLeft, writable: true, configurable: true });
Object.defineProperty(scroller, "scrollTop", { value: scrollTop, writable: true, configurable: true });
Object.defineProperty(scroller, "clientWidth", { value: clientWidth, writable: true, configurable: true });
Object.defineProperty(scroller, "clientHeight", { value: clientHeight, writable: true, configurable: true });
return scroller;
}
function createMockRow(id: number, displayIndex: number, data: (string | null)[]) {
return {
id,
displayIndex,
data,
isNew: false,
isDraft: false,
isDeleted: false,
isDirtyCol: data.map(() => false),
status: "clean" as const,
};
}
function createBaseOptions(overrides: Partial<DrawCanvasDataGridOptions> = {}): DrawCanvasDataGridOptions {
return {
canvas: createMockCanvas(),
scroller: createMockScroller(),
width: 800,
height: 400,
pixelRatio: 1,
isDark: false,
rowCount: 3,
rowAt: (index: number) => (index < 3 ? createMockRow(index, index, ["val1", "val2", "val3"]) : undefined),
renderedColumnWidths: [120, 120, 120],
visibleColumnIndexes: [0, 1, 2],
rowNumberWidth: 40,
hoverCell: null,
isScrolling: false,
editingCell: null,
searchMatchKeys: new Set(),
currentSearchMatch: null,
formatCell: (value) => String(value ?? ""),
isRowActive: () => false,
rowCellsUseSelectionVisual: () => false,
cellIsSelected: () => false,
cellCanHover: () => true,
infiniteScrollEnabled: false,
pageSize: 100,
currentPage: 1,
...overrides,
};
}
describe("drawCanvasDataGrid with frozen columns", () => {
beforeEach(() => {
// Mock getComputedStyle to return valid font properties
vi.spyOn(window, "getComputedStyle").mockReturnValue({
fontFamily: "sans-serif",
fontSize: "13px",
fontWeight: "400",
lineHeight: "normal",
getPropertyValue: () => "",
} as CSSStyleDeclaration);
});
it("draws without errors when frozenColumnCount is 0", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({ canvas, frozenColumnCount: 0 });
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("draws without errors when frozenColumnCount is set", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({ canvas, frozenColumnCount: 2 });
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("draws without errors when frozenColumnCount equals total columns", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({ canvas, frozenColumnCount: 3 });
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("draws without errors when frozenColumnCount exceeds total columns", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({ canvas, frozenColumnCount: 5 });
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("draws without errors with scroll offset and frozen columns", () => {
const canvas = createMockCanvas();
const scroller = createMockScroller(200, 0);
const options = createBaseOptions({ canvas, scroller, frozenColumnCount: 1 });
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("frozen columns remain at fixed positions regardless of scrollLeft", () => {
// This test verifies the canvas is drawn without errors at different scroll positions
// The actual pixel-level verification would require snapshot testing
const canvas1 = createMockCanvas();
const canvas2 = createMockCanvas();
const scroller1 = createMockScroller(0, 0);
const scroller2 = createMockScroller(500, 0);
const options1 = createBaseOptions({ canvas: canvas1, scroller: scroller1, frozenColumnCount: 2 });
const options2 = createBaseOptions({ canvas: canvas2, scroller: scroller2, frozenColumnCount: 2 });
expect(() => drawCanvasDataGrid(options1)).not.toThrow();
expect(() => drawCanvasDataGrid(options2)).not.toThrow();
});
it("handles empty rows with frozen columns", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({
canvas,
frozenColumnCount: 1,
rowCount: 0,
rowAt: () => undefined,
});
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
it("handles single column with frozen columns", () => {
const canvas = createMockCanvas();
const options = createBaseOptions({
canvas,
frozenColumnCount: 1,
renderedColumnWidths: [120],
visibleColumnIndexes: [0],
rowCount: 1,
rowAt: (index: number) => (index === 0 ? createMockRow(0, 0, ["val"]) : undefined),
});
expect(() => drawCanvasDataGrid(options)).not.toThrow();
});
});
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadDataGridColumnFrozenCount, loadDataGridColumnFrozenState, removeDataGridColumnFrozenCount, saveDataGridColumnFrozenCount } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => data.set(key, value),
removeItem: (key: string) => data.delete(key),
});
}
describe("data grid column frozen count storage", () => {
beforeEach(installLocalStorage);
afterEach(() => vi.unstubAllGlobals());
it("returns 0 when no frozen count is stored", () => {
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(0);
});
it("saves and loads a frozen column count", () => {
saveDataGridColumnFrozenCount("scope-1", 3);
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(3);
});
it("isolates frozen counts between different scope keys", () => {
saveDataGridColumnFrozenCount("scope-1", 2);
saveDataGridColumnFrozenCount("scope-2", 5);
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(2);
expect(loadDataGridColumnFrozenCount("scope-2")).toBe(5);
});
it("removes a stored frozen count", () => {
saveDataGridColumnFrozenCount("scope-1", 3);
removeDataGridColumnFrozenCount("scope-1");
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(0);
});
it("does not affect other scope keys when removing", () => {
saveDataGridColumnFrozenCount("scope-1", 2);
saveDataGridColumnFrozenCount("scope-2", 4);
removeDataGridColumnFrozenCount("scope-1");
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(0);
expect(loadDataGridColumnFrozenCount("scope-2")).toBe(4);
});
it("returns 0 for corrupted stored data", () => {
localStorage.setItem("dbx-data-grid-frozen-columns:scope-bad", "not-json");
expect(loadDataGridColumnFrozenCount("scope-bad")).toBe(0);
});
it("overwrites an existing frozen count", () => {
saveDataGridColumnFrozenCount("scope-1", 2);
saveDataGridColumnFrozenCount("scope-1", 5);
expect(loadDataGridColumnFrozenCount("scope-1")).toBe(5);
});
it("stores frozen count as a structured object with version", () => {
saveDataGridColumnFrozenCount("scope-1", 3);
const raw = localStorage.getItem("dbx-data-grid-frozen-columns:scope-1");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed).toEqual({ version: 1, frozenCount: 3 });
});
it("stores and loads the column order from before selected columns were frozen", () => {
saveDataGridColumnFrozenCount("scope-1", 2, ["id", "name", "email"]);
expect(loadDataGridColumnFrozenState("scope-1")).toEqual({
frozenCount: 2,
orderBeforeFreeze: ["id", "name", "email"],
});
});
});
@@ -68,6 +68,7 @@ export interface DrawCanvasDataGridOptions {
infiniteScrollEnabled: boolean;
pageSize: number;
currentPage: number;
frozenColumnCount?: number;
}
type NumericCanvasContext = CanvasRenderingContext2D & {
@@ -243,6 +244,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
infiniteScrollEnabled,
pageSize,
currentPage,
frozenColumnCount = 0,
} = options;
const dpr = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
const pixelWidth = Math.max(1, Math.ceil(width * dpr));
@@ -441,8 +443,13 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
}
};
// 第一轮:绘制非冻结列(跳过冻结列,冻结列稍后绘制以确保覆盖在上方)
let x = rowNumberWidth + columnOffset - scrollLeft;
for (let visibleColIdx = firstCol; visibleColIdx < renderedColumnWidths.length && x - maxPreviewLeftShift < width; visibleColIdx++) {
if (visibleColIdx < frozenColumnCount) {
x += renderedColumnWidths[visibleColIdx] ?? 0;
continue;
}
const colWidth = renderedColumnWidths[visibleColIdx] ?? 0;
drawCell(visibleColIdx, x);
x += colWidth;
@@ -450,6 +457,51 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
if (columnPreviewSourceVisibleIndex !== null && columnPreviewSourceVisibleIndex !== undefined && (columnPreviewOffsets[columnPreviewSourceVisibleIndex] ?? 0) !== 0) {
drawCell(columnPreviewSourceVisibleIndex, rowNumberWidth + (offsets[columnPreviewSourceVisibleIndex] ?? 0) - scrollLeft);
}
// 第二轮:绘制冻结列(不受水平滚动影响,覆盖在非冻结列之上)
if (frozenColumnCount > 0 && frozenColumnCount <= renderedColumnWidths.length) {
const frozenWidth = offsets[frozenColumnCount] ?? 0;
// 用不透明底色覆盖冻结区域(遮挡第一轮溢入的非冻结列内容)
// 注意:rowBase 在浅色主题下可能是 color-mix(..., transparent) 半透明色,
// 单次半透明填充无法遮挡文字,需先填不透明底再叠加半透明色调
if (rowIsActive && !item.isDeleted) {
ctx.fillStyle = theme.cellSelectedSingle;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
} else {
ctx.fillStyle = theme.background;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
if (rowBase !== theme.background) {
ctx.fillStyle = rowBase;
ctx.fillRect(rowNumberWidth, y, frozenWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
}
// 绘制冻结列的每个单元格(x 坐标不受 scrollLeft 影响)
for (let fcIdx = 0; fcIdx < frozenColumnCount; fcIdx++) {
const cellX = rowNumberWidth + (offsets[fcIdx] ?? 0);
drawCell(fcIdx, cellX);
}
// 重绘行底部边框(冻结区域部分)
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(rowNumberWidth, rowBorderY);
ctx.lineTo(rowNumberWidth + frozenWidth, rowBorderY);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 画冻结列分隔线(与 DOM 模式和列头一致:2px 灰色右边框)
// DOM 的 border-right 右边缘对齐单元格右边缘,中心偏左 1px;
// Canvas 的 stroke 以坐标点为中心,需左移 1px 使两者对齐
if (frozenColumnCount > 0 && frozenColumnCount < renderedColumnWidths.length) {
const frozenWidth = offsets[frozenColumnCount] ?? 0;
const separatorX = rowNumberWidth + frozenWidth - 1;
ctx.save();
ctx.strokeStyle = "rgb(100, 116, 139)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(separatorX, 0);
ctx.lineTo(separatorX, height);
ctx.stroke();
ctx.restore();
}
}
@@ -109,3 +109,38 @@ export function notifyTableDataGridColumnOrderChanged(scopeKey: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<TableDataGridColumnOrderChangedDetail>(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, { detail: { scopeKey } }));
}
const FROZEN_STORAGE_PREFIX = "dbx-data-grid-frozen-columns:";
export interface DataGridColumnFrozenState {
frozenCount: number;
orderBeforeFreeze: string[] | null;
}
export function loadDataGridColumnFrozenState(scopeKey: string): DataGridColumnFrozenState {
const emptyState = { frozenCount: 0, orderBeforeFreeze: null };
const raw = safeLocalStorageGet(`${FROZEN_STORAGE_PREFIX}${scopeKey}`);
if (!raw) return emptyState;
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "number") return { frozenCount: parsed, orderBeforeFreeze: null };
if (typeof parsed !== "object" || parsed === null || !("frozenCount" in parsed)) return emptyState;
const frozenCount = typeof parsed.frozenCount === "number" ? parsed.frozenCount : 0;
const orderBeforeFreeze = Array.isArray(parsed.orderBeforeFreeze) ? parsed.orderBeforeFreeze.filter((key: unknown): key is string => typeof key === "string") : null;
return { frozenCount, orderBeforeFreeze };
} catch {
return emptyState;
}
}
export function loadDataGridColumnFrozenCount(scopeKey: string): number {
return loadDataGridColumnFrozenState(scopeKey).frozenCount;
}
export function saveDataGridColumnFrozenCount(scopeKey: string, frozenCount: number, orderBeforeFreeze: readonly string[] | null = null) {
safeLocalStorageSet(`${FROZEN_STORAGE_PREFIX}${scopeKey}`, JSON.stringify({ version: STORAGE_VERSION, frozenCount, ...(orderBeforeFreeze ? { orderBeforeFreeze: [...orderBeforeFreeze] } : {}) }));
}
export function removeDataGridColumnFrozenCount(scopeKey: string) {
safeLocalStorageRemove(`${FROZEN_STORAGE_PREFIX}${scopeKey}`);
}
@@ -76,9 +76,12 @@ export function createDataGridColumnContextMenuItems(options: {
canFilter: boolean;
hasSort: boolean;
sortMode: "database" | "local";
labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort", string>;
frozenColumnCount?: number;
contextVisibleColIdx?: number;
hasColumnSelection?: boolean;
labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns", string>;
icons: Pick<DataGridContextMenuIcons, "copy" | "columnDetails" | "database" | "ascending" | "descending" | "clearSort">;
actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void };
actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void; freezeToColumn: () => void; freezeSelectedColumns: () => void; unfreezeColumns: () => void };
filterSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
@@ -88,17 +91,28 @@ export function createDataGridColumnContextMenuItems(options: {
items.push({ label: options.labels.details, action: options.actions.details, icon: options.icons.columnDetails });
if (options.canCopyAlterSql) items.push({ label: options.labels.copyAlterSql, action: options.actions.copyAlterSql, icon: options.icons.copy });
}
if (!options.contextColumn) return items;
items.push(
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
{ label: "", separator: true },
{ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending },
{ label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending },
);
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
items.push({ label: "", separator: true });
if (!options.contextColumn && !options.headerColumn) return items;
if (options.contextColumn) {
items.push(
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
{ label: "", separator: true },
{ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending },
{ label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending },
);
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
}
if (options.contextVisibleColIdx !== undefined) {
items.push({ label: "", separator: true });
if ((options.frozenColumnCount ?? 0) > 0) {
items.push({ label: options.labels.unfreezeColumns, action: options.actions.unfreezeColumns });
}
if (options.hasColumnSelection) {
items.push({ label: options.labels.freezeSelectedColumns, action: options.actions.freezeSelectedColumns });
}
items.push({ label: options.labels.freezeToColumn, action: options.actions.freezeToColumn });
}
return items;
}