feat(grid): add frozen columns
This commit is contained in:
1 parent
6e5f582320
commit
dfab2d09ed
16 files changed
+809
-28
No files matched your search
+153
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user