feat(grid): add frozen columns

This commit is contained in:
gggaiitx 2026-07-22 15:07:47 +08:00 committed by GitHub
parent 6e5f582320
commit dfab2d09ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 809 additions and 28 deletions

View File

@ -525,6 +525,7 @@ function typeColorClass(t: string): string {
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const contextHeaderColumn = ref<string | null>(null);
const contextHeaderColumnIndex = ref<number | null>(null);
const contextHeaderVisibleColIdx = ref<number | null>(null);
const bulkEditDialogOpen = ref(false);
const bulkEditValue = ref("");
const generateIncrementDialogOpen = ref(false);
@ -1670,6 +1671,10 @@ const {
toggleAllNullColumns,
resetColumnVisibility,
onTableDataGridColumnOrderChanged,
frozenColumnCount,
freezeToColumn,
freezeSelectedColumns,
unfreezeAllColumns,
} = useDataGridColumnLayoutState({
columns: computed(() => props.result.columns),
sourceColumns: computed(() => props.sourceColumns),
@ -1831,6 +1836,7 @@ const {
suppressHeaderClickIfNeeded,
columnHeaderDragClass,
columnHeaderStyle,
horizontalColumnWindowBeforeWidth,
} = useDataGridColumnLayout({
columnNames: computed(() => props.result.columns),
visibleColumnIndexes,
@ -1847,6 +1853,7 @@ const {
onCanvasDrawSchedule: scheduleCanvasDraw,
onRefreshMetrics: () => nextTick(refreshGridScrollerMetrics),
onPersistColumnOrder: persistColumnOrder,
frozenColumnCount,
});
function onHeaderClickCapture(event: MouseEvent) {
@ -4528,7 +4535,15 @@ function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: nu
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
if (rowIndex < 0 || rowIndex >= displayRowCount.value) return null;
if (x < DATA_GRID_ROW_NUM_WIDTH) return { rowIndex, visibleColIdx: -1, rowNumber: true };
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH);
// scrollLeft
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
let contentX: number;
if (frozenWidth > 0 && x - DATA_GRID_ROW_NUM_WIDTH < frozenWidth) {
contentX = x - DATA_GRID_ROW_NUM_WIDTH;
} else {
contentX = scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH;
}
const visibleColIdx = canvasColumnAt(contentX);
if (visibleColIdx < 0) return null;
return { rowIndex, visibleColIdx, rowNumber: false };
}
@ -4744,7 +4759,9 @@ function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) {
const widths = renderedColumnWidths.value;
const colWidth = widths[visibleColIdx];
if (colWidth === undefined) return null;
const left = DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - gridHorizontalScrollLeft.value;
// scrollLeft
const isFrozen = visibleColIdx < frozenColumnCount.value;
const left = DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value);
return {
left,
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
@ -4886,6 +4903,7 @@ function drawCanvasGrid() {
infiniteScrollEnabled: infiniteScrollEnabled.value,
pageSize: pageSize.value,
currentPage: currentPage.value,
frozenColumnCount: frozenColumnCount.value,
});
}
@ -4920,6 +4938,7 @@ watch(
detailCell,
showCellDetail,
editingCell,
frozenColumnCount,
// Pending edit structures can contain large nested cell maps; the editor
// version ref gives the canvas a cheap invalidation signal without a deep watch.
pendingChangesVersion,
@ -5270,6 +5289,7 @@ function selectTransposeCell(rowIndex: number, actualColIdx: number, event: Mous
if (visibleColIdx < 0) return;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
clearRowSelection();
if (event.shiftKey || event.metaKey || event.ctrlKey) {
extendCellSelectionTo(rowIndex, visibleColIdx);
@ -5285,6 +5305,7 @@ function showTransposeCellDetails(rowIndex: number, actualColIdx: number) {
if (visibleColIdx < 0) return;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
clearRowSelection();
selectSingleCell(rowIndex, visibleColIdx);
transposeRowIndex.value = rowIndex;
@ -5604,13 +5625,16 @@ function scrollCellIntoView(rowIndex: number, colIndex: number) {
function scrollGridColumnIntoView(visibleColIdx: number) {
const scroller = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (!scroller) return;
//
if (visibleColIdx < frozenColumnCount.value) return;
const colLeft = columnContentOffsetLeft(visibleColIdx);
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH;
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH + frozenWidth;
const viewportRight = scroller.scrollLeft + scroller.clientWidth;
if (colLeft < viewportLeft) {
scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH);
scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH - frozenWidth);
} else if (colRight > viewportRight) {
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
}
@ -6321,6 +6345,7 @@ function selectTransposeRecord(rowIndex: number, event?: MouseEvent) {
transposeRowIndex.value = rowIndex;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
const item = displayItemAt(rowIndex);
if (item) {
if (event) {
@ -6413,6 +6438,7 @@ function onHeaderContext(col: string, columnIndex: number) {
}
contextHeaderColumn.value = col;
contextHeaderColumnIndex.value = columnIndex;
contextHeaderVisibleColIdx.value = visibleColIdx >= 0 ? visibleColIdx : null;
}
async function copyHeaderColumn() {
if (!contextHeaderColumn.value) return;
@ -6476,6 +6502,7 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC
clearNativeTextSelection();
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
contextCell.value = { rowId, rowIndex, col: colIdx };
if (hasRowSelection.value && isRowSelected(rowId)) {
return;
@ -6613,6 +6640,7 @@ watch(editValue, scheduleActiveCellEditTextareaResize);
function onRowContext(rowId: number, rowIndex: number) {
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextHeaderVisibleColIdx.value = null;
contextCell.value = { rowId, rowIndex, col: -1 };
if (!isRowSelected(rowId)) {
clearCellSelection();
@ -7376,6 +7404,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
canFilter: canUseWhereSearch.value,
hasSort: !!sortCol.value,
sortMode: sortMode.value,
frozenColumnCount: frozenColumnCount.value,
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
hasColumnSelection: hasColumnSelection.value,
labels: {
copyName: t("grid.copyColumnName"),
copyNames: t("grid.copyColumnNames"),
@ -7386,9 +7417,33 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
localAscending: t("grid.sortCurrentPageAscending"),
localDescending: t("grid.sortCurrentPageDescending"),
clearSort: t("grid.clearSort"),
freezeToColumn: t("grid.freezeToColumn"),
freezeSelectedColumns: t("grid.freezeSelectedColumns"),
unfreezeColumns: t("grid.unfreezeColumns"),
},
icons: { copy: Copy, columnDetails: TableProperties, database: Database, ascending: ArrowUp, descending: ArrowDown, clearSort: Eraser },
actions: { copyName: copyHeaderColumn, copyNames: copyColumnNames, details: openContextColumnDetailDialog, copyAlterSql: copyAlterColumnSql, sort: applyContextSort },
actions: {
copyName: copyHeaderColumn,
copyNames: copyColumnNames,
details: openContextColumnDetailDialog,
copyAlterSql: copyAlterColumnSql,
sort: applyContextSort,
freezeToColumn: () => {
const idx = contextHeaderVisibleColIdx.value;
if (idx !== null && idx >= 0) {
freezeToColumn(idx);
clearCellSelection();
}
},
freezeSelectedColumns: () => {
freezeSelectedColumns(selectedVisibleColumnIndexes());
clearCellSelection();
},
unfreezeColumns: () => {
unfreezeAllColumns();
clearCellSelection();
},
},
filterSubmenu: filterSubmenu(),
}),
createDataGridCellContextMenuItems({
@ -7831,7 +7886,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
#
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<div class="shrink-0" :style="{ width: `${horizontalColumnWindowBeforeWidth}px` }" />
<DataGridColumnHeader
v-for="col in renderedGridColumns"
:key="`${col.name}-${col.actualColIdx}`"
@ -7841,6 +7896,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:selected="highlightedColumnIndex === col.actualColIdx || columnIsSelected(col.visibleColIdx)"
:search-match="currentSearchMatch?.kind === 'column' && currentSearchMatch.col === col.actualColIdx"
:dark="isDark"
:frozen="col.visibleColIdx < frozenColumnCount"
:frozen-separator="frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1"
:tooltip-disabled="columnHeaderTooltipsDisabled"
:column-type="headerColumnType(col.name, col.actualColIdx)"
:column-comment="headerColumnComment(col.name)"
@ -8353,13 +8410,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
{{ rowNumberText(item) }}
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<div class="shrink-0" :style="{ width: `${horizontalColumnWindowBeforeWidth}px` }" />
<div
v-for="col in renderedGridColumns"
:key="col.actualColIdx"
class="data-grid-cell group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none inline-block items-center tabular-nums"
:style="renderedColumnStyle(col.visibleColIdx)"
:class="{
'data-grid-cell--frozen': col.visibleColIdx < frozenColumnCount,
'data-grid-cell--frozen-separator': frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1,
'text-muted-foreground italic': isNull(item.data[col.actualColIdx]),
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx],
'cell-selected': cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
@ -9123,6 +9182,16 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: var(--data-grid-cell-bg);
}
/* 冻结列:不透明背景遮挡滚动的非冻结列;状态 class 的 !important 会覆盖此项 */
.data-grid-cell--frozen {
background-color: var(--data-grid-cell-bg, rgb(255, 255, 255)) !important;
}
/* 冻结列分隔线:与 Canvas 模式和列头一致2px 深色右边框) */
.data-grid-cell--frozen-separator {
border-right: 2px solid rgb(100, 116, 139) !important;
}
.data-grid-row-number {
background-color: var(--data-grid-row-number-bg);
}

View File

@ -10,6 +10,8 @@ defineProps<{
selected?: boolean;
searchMatch?: boolean;
dark?: boolean;
frozen?: boolean;
frozenSeparator?: boolean;
tooltipDisabled?: boolean;
columnType?: string;
columnComment?: string;
@ -41,7 +43,14 @@ const emit = defineEmits<{
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
<div
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-gray-200 dark:hover:bg-gray-800 select-none relative overflow-hidden"
:class="[dark && 'data-grid-header-cell--dark', selected && 'data-grid-header-cell--selected outline outline-primary -outline-offset-1', searchMatch && 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40', dragClass]"
:class="[
dark && 'data-grid-header-cell--dark',
selected && 'data-grid-header-cell--selected outline outline-primary -outline-offset-1',
searchMatch && 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40',
frozen && 'data-grid-header-cell--frozen',
frozenSeparator && 'data-grid-header-cell--frozen-separator',
dragClass,
]"
:style="columnStyle"
:data-grid-column-index="actualColumnIndex"
:data-visible-col-index="visibleColumnIndex"
@ -105,4 +114,20 @@ const emit = defineEmits<{
background-color: rgb(66, 67, 70) !important;
color: rgb(244, 244, 245) !important;
}
.data-grid-header-cell--frozen {
background-color: rgb(220, 225, 232) !important;
}
.data-grid-header-cell--frozen-separator {
border-right: 2px solid rgb(100, 116, 139) !important;
}
.data-grid-header-cell--dark.data-grid-header-cell--frozen {
background-color: rgb(40, 42, 48) !important;
}
.data-grid-header-cell--dark.data-grid-header-cell--frozen-separator {
border-right: 2px solid rgb(100, 116, 139) !important;
}
</style>

View File

@ -187,4 +187,223 @@ describe("useDataGridColumnLayout", () => {
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledTimes(1);
});
describe("frozen columns", () => {
it("starts with frozenColumnCount of 0", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout"),
tableScopeKey: ref(""),
}),
)!;
expect(state.frozenColumnCount.value).toBe(0);
scope.stop();
});
it("freezeToColumn sets frozenColumnCount to visibleColIdx + 1", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout-2"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(0);
expect(state.frozenColumnCount.value).toBe(1);
state.freezeToColumn(2);
expect(state.frozenColumnCount.value).toBe(3);
scope.stop();
});
it("unfreezeAllColumns resets frozenColumnCount to 0", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-test-layout-3"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
expect(state.frozenColumnCount.value).toBe(2);
state.unfreezeAllColumns();
expect(state.frozenColumnCount.value).toBe(0);
scope.stop();
});
it("persists frozenColumnCount to localStorage", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-persist-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
const raw = localStorage.getItem("dbx-data-grid-frozen-columns:frozen-persist-layout");
expect(raw).not.toBeNull();
expect(JSON.parse(raw!)).toEqual({ version: 1, frozenCount: 2 });
scope.stop();
});
it("removes localStorage key when unfreezing all columns", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-remove-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(0);
expect(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-remove-layout")).not.toBeNull();
state.unfreezeAllColumns();
expect(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-remove-layout")).toBeNull();
scope.stop();
});
it("restores frozenColumnCount from localStorage on load", async () => {
localStorage.setItem("dbx-data-grid-frozen-columns:frozen-restore-layout", JSON.stringify({ version: 1, frozenCount: 2 }));
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-restore-layout"),
tableScopeKey: ref(""),
}),
)!;
await nextTick();
expect(state.frozenColumnCount.value).toBe(2);
scope.stop();
});
it("restores the pre-freeze order after reload before unfreezing selected columns", async () => {
const options = {
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-reload-layout"),
tableScopeKey: ref(""),
};
const firstScope = effectScope();
const firstState = firstScope.run(() => useDataGridColumnLayoutState(options))!;
firstState.freezeSelectedColumns([2]);
expect(firstState.orderedDisplayableColumnIndexes.value).toEqual([2, 0, 1]);
firstScope.stop();
const reloadedScope = effectScope();
const reloadedState = reloadedScope.run(() => useDataGridColumnLayoutState(options))!;
await nextTick();
reloadedState.unfreezeAllColumns();
expect(reloadedState.frozenColumnCount.value).toBe(0);
expect(reloadedState.orderedDisplayableColumnIndexes.value).toEqual([0, 1, 2]);
reloadedScope.stop();
});
it("shrinks the persisted frozen count when visible columns are hidden", async () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("frozen-hidden-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(2);
state.toggleColumnVisibility(1);
await nextTick();
expect(state.frozenColumnCount.value).toBe(2);
expect(JSON.parse(localStorage.getItem("dbx-data-grid-frozen-columns:frozen-hidden-layout")!)).toMatchObject({ frozenCount: 2 });
scope.stop();
});
it("allows changing frozen count from one value to another", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email", "phone"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2, 3]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0", "phone\0\0"]),
layoutScopeKey: ref("frozen-change-layout"),
tableScopeKey: ref(""),
}),
)!;
state.freezeToColumn(1);
expect(state.frozenColumnCount.value).toBe(2);
// 增加冻结列数
state.freezeToColumn(3);
expect(state.frozenColumnCount.value).toBe(4);
// 减少冻结列数
state.freezeToColumn(0);
expect(state.frozenColumnCount.value).toBe(1);
scope.stop();
});
});
});

View File

@ -3,11 +3,14 @@ import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import {
loadDataGridColumnOrder,
loadDataGridColumnFrozenState,
loadTableDataGridColumnOrder,
notifyTableDataGridColumnOrderChanged,
removeDataGridColumnOrder,
removeDataGridColumnFrozenCount,
removeTableDataGridColumnOrder,
saveDataGridColumnOrder,
saveDataGridColumnFrozenCount,
saveTableDataGridColumnOrder,
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
@ -82,6 +85,9 @@ export function useDataGridColumnLayoutState(options: {
const nullColumnsHidden = computed(() => (options.hideNullColumns === undefined ? localNullColumnsHidden.value : toValue(options.hideNullColumns)));
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const persistedColumnOrderKeys = ref<string[]>([]);
const frozenColumnCount = ref(0);
// 冻结选中列前的列序快照,取消冻结时还原
const columnOrderSnapshotBeforeFreeze = ref<string[] | null>(null);
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
@ -111,6 +117,61 @@ export function useDataGridColumnLayoutState(options: {
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
}
function loadFrozenColumnCount() {
const state = loadDataGridColumnFrozenState(toValue(options.layoutScopeKey));
frozenColumnCount.value = Math.min(state.frozenCount, visibleColumnIndexes.value.length);
columnOrderSnapshotBeforeFreeze.value = state.orderBeforeFreeze;
}
function setFrozenColumnCount(count: number) {
const clampedCount = Math.max(0, Math.min(count, visibleColumnIndexes.value.length));
frozenColumnCount.value = clampedCount;
if (clampedCount > 0) {
saveDataGridColumnFrozenCount(toValue(options.layoutScopeKey), clampedCount, columnOrderSnapshotBeforeFreeze.value);
} else {
removeDataGridColumnFrozenCount(toValue(options.layoutScopeKey));
}
}
function freezeToColumn(visibleColIdx: number) {
setFrozenColumnCount(visibleColIdx + 1);
}
function freezeSelectedColumns(selectedVisibleColIdxs: number[]) {
if (selectedVisibleColIdxs.length === 0) return;
const sorted = [...selectedVisibleColIdxs].sort((a, b) => a - b);
const visibleIdxs = visibleColumnIndexes.value;
// 获取选中列的 actualColIdx
const selectedActualIdxs = sorted.map((vIdx) => visibleIdxs[vIdx]).filter((idx): idx is number => idx !== undefined);
if (selectedActualIdxs.length === 0) return;
const selectedSet = new Set(selectedActualIdxs);
// 非选中列保持原序
const currentOrder = orderedDisplayableColumnIndexes.value;
const nonSelectedActualIdxs = currentOrder.filter((idx) => !selectedSet.has(idx));
// 保存冻结前的列序快照(仅在首次冻结时保存,避免连续冻结覆盖快照)
if (columnOrderSnapshotBeforeFreeze.value === null) {
columnOrderSnapshotBeforeFreeze.value = [...persistedColumnOrderKeys.value];
}
// 新列序:选中列在前 + 非选中列在后
persistColumnOrder([...selectedActualIdxs, ...nonSelectedActualIdxs]);
setFrozenColumnCount(selectedActualIdxs.length);
}
function unfreezeAllColumns() {
setFrozenColumnCount(0);
// 还原冻结前的列序
if (columnOrderSnapshotBeforeFreeze.value !== null) {
const snapshot = columnOrderSnapshotBeforeFreeze.value;
columnOrderSnapshotBeforeFreeze.value = null;
if (snapshot.length === 0) {
resetColumnOrder();
} else {
persistedColumnOrderKeys.value = snapshot;
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), snapshot);
const tableScopeKey = toValue(options.tableScopeKey);
if (tableScopeKey) {
saveTableDataGridColumnOrder(tableScopeKey, snapshot);
notifyTableDataGridColumnOrderChanged(tableScopeKey);
}
}
}
}
function persistColumnOrder(indexes: number[]) {
const tableScopeKey = toValue(options.tableScopeKey);
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
@ -179,7 +240,21 @@ export function useDataGridColumnLayoutState(options: {
}
watch([() => nullColumnsHidden.value, () => [...toValue(options.allNullColumnIndexes)], () => [...toValue(options.displayableColumnIndexes)]], ([hidden]) => applyNullColumnVisibility(hidden as boolean), { immediate: true });
watch([() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)], loadColumnOrder, { immediate: true });
watch(
() => visibleColumnIndexes.value.length,
(visibleCount) => {
if (frozenColumnCount.value > visibleCount) setFrozenColumnCount(visibleCount);
},
{ flush: "sync" },
);
watch(
[() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)],
() => {
loadColumnOrder();
loadFrozenColumnCount();
},
{ immediate: true },
);
return {
hiddenColumnIndexes,
@ -201,6 +276,10 @@ export function useDataGridColumnLayoutState(options: {
toggleAllNullColumns,
resetColumnVisibility,
onTableDataGridColumnOrderChanged,
frozenColumnCount,
freezeToColumn,
freezeSelectedColumns,
unfreezeAllColumns,
};
}
@ -221,8 +300,10 @@ export function useDataGridColumnLayout(options: {
onCanvasDrawSchedule?: () => void;
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter<number>;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const frozenColumnCount = computed(() => toValue(options.frozenColumnCount ?? 0));
const horizontalColumnWindow = computed(() =>
dataGridHorizontalColumnWindow({
widths: toValue(options.renderedColumnWidths),
@ -238,15 +319,46 @@ export function useDataGridColumnLayout(options: {
const columnNames = toValue(options.columnNames);
const visibleIndexes = toValue(options.visibleColumnIndexes);
const window = horizontalColumnWindow.value;
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
const frozen = frozenColumnCount.value;
// 无冻结列时保持原始行为
if (frozen === 0) {
return visibleIndexes.slice(window.start, window.end).map((actualColIdx, offset) => ({
visibleColIdx: window.start + offset,
actualColIdx,
name: columnNames[actualColIdx] ?? "",
}));
}
const result: RenderedDataGridColumn[] = [];
// 冻结列始终包含在渲染窗口中0 ~ frozen-1
for (let i = 0; i < frozen && i < visibleIndexes.length; i++) {
result.push({ visibleColIdx: i, actualColIdx: visibleIndexes[i], name: columnNames[visibleIndexes[i]] ?? "" });
}
// 非冻结列从 max(window.start, frozen) 到 window.end
const nonFrozenStart = Math.max(window.start, frozen);
for (let i = nonFrozenStart; i < window.end && i < visibleIndexes.length; i++) {
result.push({ visibleColIdx: i, actualColIdx: visibleIndexes[i], name: columnNames[visibleIndexes[i]] ?? "" });
}
return result;
});
// 冻结列占位宽度:非冻结列的前置占位需要排除冻结列
const frozenWidth = computed(() => renderedColumnOffsets.value[frozenColumnCount.value] ?? 0);
const horizontalColumnWindowBeforeWidth = computed(() => {
const window = horizontalColumnWindow.value;
const frozen = frozenColumnCount.value;
if (frozen === 0) return window.beforeWidth ?? 0;
// 非冻结列从 max(window.start, frozen) 开始,前置占位 = 该列偏移 - 冻结列宽度
const nonFrozenStart = Math.max(window.start, frozen);
return Math.max(0, (renderedColumnOffsets.value[nonFrozenStart] ?? 0) - (renderedColumnOffsets.value[frozen] ?? 0));
});
function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
const style: Record<string, string | number> = { width: `var(--col-w-${visibleColIdx})` };
if (visibleColIdx < frozenColumnCount.value) {
style.position = "sticky";
style.left = `${columnContentOffsetLeft(visibleColIdx)}px`;
style.zIndex = 10;
}
return style;
}
function columnContentOffsetLeft(visibleColIdx: number): number {
@ -473,6 +585,10 @@ export function useDataGridColumnLayout(options: {
function columnHeaderStyle(visibleColIdx: number) {
const style = renderedColumnStyle(visibleColIdx);
const offset = columnHeaderPreviewOffset(visibleColIdx);
// 冻结列头需要更高 z-index与列头行号 z-20 一致)以覆盖非冻结列头
if (visibleColIdx < frozenColumnCount.value) {
(style as Record<string, string | number>).zIndex = 20;
}
if (!offset) return style;
return { ...style, transform: `translateX(${offset}px)`, transition: columnHeaderDragState.value?.sourceVisibleIndex === visibleColIdx ? undefined : "transform 120ms ease-out" };
}
@ -500,6 +616,8 @@ export function useDataGridColumnLayout(options: {
renderedGridColumns,
renderedColumnStyle,
columnContentOffsetLeft,
frozenWidth,
horizontalColumnWindowBeforeWidth,
columnHeaderDragState,
columnHeaderResizeActive,
columnHeaderTooltipsDisabled,

View File

@ -1030,6 +1030,9 @@ export default {
sortDatabaseAscending: "Sort Database Ascending",
sortDatabaseDescending: "Sort Database Descending",
clearSort: "Clear Sort",
freezeToColumn: "Freeze to This Column",
freezeSelectedColumns: "Freeze Selected Columns",
unfreezeColumns: "Unfreeze Columns",
pasted: "Pasted!",
search: "Search...",
searchOrWhere: "Search, or enter a WHERE clause...",

View File

@ -973,6 +973,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordenar base de datos ascendente",
sortDatabaseDescending: "Ordenar base de datos descendente",
clearSort: "Limpiar orden",
freezeToColumn: "Inmovilizar hasta esta columna",
freezeSelectedColumns: "Inmovilizar columnas seleccionadas",
unfreezeColumns: "Desinmovilizar columnas",
pasted: "¡Pegado!",
search: "Buscar...",
searchOrWhere: "Buscar, o ingresar una cláusula WHERE...",

View File

@ -971,6 +971,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordina database crescente",
sortDatabaseDescending: "Ordina database decrescente",
clearSort: "Rimuovi Ordinamento",
freezeToColumn: "Blocca fino a questa colonna",
freezeSelectedColumns: "Blocca colonne selezionate",
unfreezeColumns: "Sblocca colonne",
pasted: "Incollato!",
search: "Cerca...",
searchOrWhere: "Cerca o inserisci una clausola WHERE...",

View File

@ -968,6 +968,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "データベースで昇順に並び替え",
sortDatabaseDescending: "データベースで降順に並び替え",
clearSort: "並び替えをクリア",
freezeToColumn: "この列まで固定",
freezeSelectedColumns: "選択列を固定",
unfreezeColumns: "列の固定を解除",
pasted: "貼り付けました!",
search: "検索...",
searchOrWhere: "検索、またはWHERE句を入力...",

View File

@ -973,6 +973,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordenar Banco de Dados Crescente",
sortDatabaseDescending: "Ordenar Banco de Dados Decrescente",
clearSort: "Limpar Ordenação",
freezeToColumn: "Congelar até esta coluna",
freezeSelectedColumns: "Congelar colunas selecionadas",
unfreezeColumns: "Descongelar colunas",
pasted: "Colado!",
search: "Pesquisar...",
searchOrWhere: "Pesquise, ou insira uma cláusula WHERE...",

View File

@ -1032,6 +1032,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "数据库升序排序",
sortDatabaseDescending: "数据库降序排序",
clearSort: "清除排序",
freezeToColumn: "冻结到此列",
freezeSelectedColumns: "冻结选中列",
unfreezeColumns: "取消冻结",
pasted: "已粘贴!",
search: "搜索...",
searchOrWhere: "搜索,或输入 WHERE 条件...",

View File

@ -973,6 +973,9 @@ export default withEnglishFallback({
sortDatabaseAscending: "資料庫遞增排序",
sortDatabaseDescending: "資料庫遞減排序",
clearSort: "清除排序",
freezeToColumn: "凍結到此欄",
freezeSelectedColumns: "凍結選取欄",
unfreezeColumns: "取消凍結",
pasted: "已貼上!",
search: "搜尋……",
searchOrWhere: "搜尋,或輸入 WHERE 條件……",

View File

@ -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();
});
});

View File

@ -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"],
});
});
});

View File

@ -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();
}
}

View File

@ -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}`);
}

View File

@ -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;
}