diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 907803a2f..6aa6a3126 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -525,6 +525,7 @@ function typeColorClass(t: string): string { const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null); const contextHeaderColumn = ref(null); const contextHeaderColumnIndex = ref(null); +const contextHeaderVisibleColIdx = ref(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(".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(() => { 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(() => { 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(() => { > # -
+
(() => { :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(() => { > {{ rowNumberText(item) }}
-
+
diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts index dbdeca36e..579fcce30 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnLayout.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/desktop/src/composables/useDataGridColumnLayout.ts b/apps/desktop/src/composables/useDataGridColumnLayout.ts index 4a7a38d7e..dcb876e17 100644 --- a/apps/desktop/src/composables/useDataGridColumnLayout.ts +++ b/apps/desktop/src/composables/useDataGridColumnLayout.ts @@ -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>(new Set()); const persistedColumnOrderKeys = ref([]); + const frozenColumnCount = ref(0); + // 冻结选中列前的列序快照,取消冻结时还原 + const columnOrderSnapshotBeforeFreeze = ref(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; }) { 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 = { 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).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, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b01012323..70ffbbe67 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d9dbc50b4..abcc3d1d2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b67d0d238..bd38ff2cf 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 172e90220..69e067872 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -968,6 +968,9 @@ export default withEnglishFallback({ sortDatabaseAscending: "データベースで昇順に並び替え", sortDatabaseDescending: "データベースで降順に並び替え", clearSort: "並び替えをクリア", + freezeToColumn: "この列まで固定", + freezeSelectedColumns: "選択列を固定", + unfreezeColumns: "列の固定を解除", pasted: "貼り付けました!", search: "検索...", searchOrWhere: "検索、またはWHERE句を入力...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bbf307323..ad36ee428 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ca0115075..2d24f3274 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1032,6 +1032,9 @@ export default withEnglishFallback({ sortDatabaseAscending: "数据库升序排序", sortDatabaseDescending: "数据库降序排序", clearSort: "清除排序", + freezeToColumn: "冻结到此列", + freezeSelectedColumns: "冻结选中列", + unfreezeColumns: "取消冻结", pasted: "已粘贴!", search: "搜索...", searchOrWhere: "搜索,或输入 WHERE 条件...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 93b079e1f..68a1fcf43 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -973,6 +973,9 @@ export default withEnglishFallback({ sortDatabaseAscending: "資料庫遞增排序", sortDatabaseDescending: "資料庫遞減排序", clearSort: "清除排序", + freezeToColumn: "凍結到此欄", + freezeSelectedColumns: "凍結選取欄", + unfreezeColumns: "取消凍結", pasted: "已貼上!", search: "搜尋……", searchOrWhere: "搜尋,或輸入 WHERE 條件……", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts new file mode 100644 index 000000000..7c490d6b3 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/canvasDataGridRendererFrozen.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFrozenStorage.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFrozenStorage.spec.ts new file mode 100644 index 000000000..721eb90aa --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnFrozenStorage.spec.ts @@ -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(); + 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"], + }); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts index 142114ee9..37d4d5152 100644 --- a/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts +++ b/apps/desktop/src/lib/dataGrid/canvasDataGridRenderer.ts @@ -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(); + } } diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts index 438522e5c..74dae2857 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnLayoutStorage.ts @@ -109,3 +109,38 @@ export function notifyTableDataGridColumnOrderChanged(scopeKey: string) { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(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}`); +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts index 92ff8120c..21dcfc06b 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts @@ -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; - 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; }