feat(grid): add frozen columns
This commit is contained in:
parent
6e5f582320
commit
dfab2d09ed
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -968,6 +968,9 @@ export default withEnglishFallback({
|
|||
sortDatabaseAscending: "データベースで昇順に並び替え",
|
||||
sortDatabaseDescending: "データベースで降順に並び替え",
|
||||
clearSort: "並び替えをクリア",
|
||||
freezeToColumn: "この列まで固定",
|
||||
freezeSelectedColumns: "選択列を固定",
|
||||
unfreezeColumns: "列の固定を解除",
|
||||
pasted: "貼り付けました!",
|
||||
search: "検索...",
|
||||
searchOrWhere: "検索、またはWHERE句を入力...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,9 @@ export default withEnglishFallback({
|
|||
sortDatabaseAscending: "数据库升序排序",
|
||||
sortDatabaseDescending: "数据库降序排序",
|
||||
clearSort: "清除排序",
|
||||
freezeToColumn: "冻结到此列",
|
||||
freezeSelectedColumns: "冻结选中列",
|
||||
unfreezeColumns: "取消冻结",
|
||||
pasted: "已粘贴!",
|
||||
search: "搜索...",
|
||||
searchOrWhere: "搜索,或输入 WHERE 条件...",
|
||||
|
|
|
|||
|
|
@ -973,6 +973,9 @@ export default withEnglishFallback({
|
|||
sortDatabaseAscending: "資料庫遞增排序",
|
||||
sortDatabaseDescending: "資料庫遞減排序",
|
||||
clearSort: "清除排序",
|
||||
freezeToColumn: "凍結到此欄",
|
||||
freezeSelectedColumns: "凍結選取欄",
|
||||
unfreezeColumns: "取消凍結",
|
||||
pasted: "已貼上!",
|
||||
search: "搜尋……",
|
||||
searchOrWhere: "搜尋,或輸入 WHERE 條件……",
|
||||
|
|
|
|||
153
apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts
vendored
Normal file
153
apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue