fix(grid): unify and refine data grid selection visuals
This commit is contained in:
1 parent
19401112d2
commit
b10d276d96
7 files changed
+763
-188
No files matched your search
@@ -170,6 +170,7 @@ import { buildDataGridColumnLookupItems, dataGridColumnCommentFor, filterDataGri
|
|||||||
import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
|
import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||||
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||||
import { summarizeSelection } from "@/lib/dataGrid/gridSelection";
|
import { summarizeSelection } from "@/lib/dataGrid/gridSelection";
|
||||||
|
import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames";
|
||||||
import {
|
import {
|
||||||
createDataGridCellContextMenuItems,
|
createDataGridCellContextMenuItems,
|
||||||
createDataGridColumnContextMenuItems,
|
createDataGridColumnContextMenuItems,
|
||||||
@@ -2383,6 +2384,7 @@ function onScrollerScroll(e: Event) {
|
|||||||
clampGridScrollerBounds(target);
|
clampGridScrollerBounds(target);
|
||||||
updateDomGridVisibleItemsDuringScroll(target);
|
updateDomGridVisibleItemsDuringScroll(target);
|
||||||
syncHeaderScroll(e);
|
syncHeaderScroll(e);
|
||||||
|
if (domGridScrollTop.value !== target.scrollTop) domGridScrollTop.value = target.scrollTop;
|
||||||
recordScrollPosition({ top: target.scrollTop, left: target.scrollLeft });
|
recordScrollPosition({ top: target.scrollTop, left: target.scrollLeft });
|
||||||
maybeCheckInfiniteScroll(target);
|
maybeCheckInfiniteScroll(target);
|
||||||
} else {
|
} else {
|
||||||
@@ -3820,8 +3822,10 @@ const selection = useDataGridSelection({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isSelectingAll,
|
isSelectingAll,
|
||||||
|
isSelectingCells,
|
||||||
selectedRange,
|
selectedRange,
|
||||||
selectedCells,
|
selectedCells,
|
||||||
|
selectedCellKeys,
|
||||||
selectedCellMatrix,
|
selectedCellMatrix,
|
||||||
selectedCellCount,
|
selectedCellCount,
|
||||||
hasCellSelection,
|
hasCellSelection,
|
||||||
@@ -3856,8 +3860,21 @@ const multiRowCount = computed(() => {
|
|||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionSummary = computed(() => (hasCellSelection.value ? summarizeSelection(selectedCells.value) : null));
|
// 框选拖拽中不物化 selectedCells / 不做数值汇总(大选区下这是 DOM 卡顿主因)
|
||||||
|
const selectionSummary = computed(() => {
|
||||||
|
if (!hasCellSelection.value) return null;
|
||||||
|
if (isSelectingCells.value) {
|
||||||
|
return {
|
||||||
|
cellCount: selectedCellCount.value,
|
||||||
|
rowCount: multiRowCount.value,
|
||||||
|
numericCount: 0,
|
||||||
|
sum: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return summarizeSelection(selectedCells.value);
|
||||||
|
});
|
||||||
const selectionSummarySumText = computed(() => {
|
const selectionSummarySumText = computed(() => {
|
||||||
|
if (isSelectingCells.value) return "…";
|
||||||
const summary = selectionSummary.value;
|
const summary = selectionSummary.value;
|
||||||
if (!summary) return "0";
|
if (!summary) return "0";
|
||||||
const sum = Object.is(summary.sum, -0) ? 0 : summary.sum;
|
const sum = Object.is(summary.sum, -0) ? 0 : summary.sum;
|
||||||
@@ -3866,9 +3883,90 @@ const selectionSummarySumText = computed(() => {
|
|||||||
|
|
||||||
const isMultiRow = computed(() => multiRowCount.value > 1);
|
const isMultiRow = computed(() => multiRowCount.value > 1);
|
||||||
|
|
||||||
|
// Navicat 风格选区形态:单个单元格细边框,多格范围浅色填充 + 一圈细外框;
|
||||||
|
// 离散点选(Ctrl)退回逐格描边
|
||||||
|
const selectionFramesData = computed(() =>
|
||||||
|
resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: selectedCellKeys.value.size,
|
||||||
|
hasColumnSelection: hasColumnSelection.value,
|
||||||
|
selectedColumnIndexes: selectedColumnIndexes.value,
|
||||||
|
selectedRange: selectedRange.value,
|
||||||
|
rowCount: displayRowCount.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const selectionUsesOuterFrame = computed(() => dataGridSelectionUsesOuterFrame(selectionFramesData.value.frames));
|
||||||
|
// DOM 框选拖拽:overlay 只画外框(与 canvas strokeRect 一致),填充仍走格子 cell-selected
|
||||||
|
const domSelectionDragOverlayActive = computed(() => {
|
||||||
|
if (!isSelectingCells.value || dataGridRenderMode.value === "canvas") return false;
|
||||||
|
const range = selectedRange.value;
|
||||||
|
if (!range) return false;
|
||||||
|
return range.startRow !== range.endRow || range.startCol !== range.endCol;
|
||||||
|
});
|
||||||
|
const domGridScrollTop = ref(0);
|
||||||
|
|
||||||
|
function selectionFrameKindForCell(rowIndex: number, visibleColIdx: number): "single" | "range" | null {
|
||||||
|
const frames = selectionFramesData.value.frames;
|
||||||
|
if (frames.length === 0) return null;
|
||||||
|
// 框选热路径:已确定是多格外框时,选中格必为 range,避免再扫 frames
|
||||||
|
if (selectionUsesOuterFrame.value && frames.length === 1) {
|
||||||
|
const frame = frames[0]!;
|
||||||
|
if (rowIndex < frame.startRow || rowIndex > frame.endRow || visibleColIdx < frame.startCol || visibleColIdx > frame.endCol) return null;
|
||||||
|
return "range";
|
||||||
|
}
|
||||||
|
return dataGridSelectionFrameKindAtCell(frames, rowIndex, visibleColIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行号的选区覆盖指示(Navicat 风格),但行选中(--selected)和行状态色(新增/编辑/删除)优先,
|
||||||
|
// 与 canvas 渲染器的 rowNumberFill 优先级顺序保持一致
|
||||||
|
function rowNumberShowsSelectionTint(item: RowItem): boolean {
|
||||||
|
if (isRowSelected(item.id)) return false;
|
||||||
|
if (item.status !== "clean") return false;
|
||||||
|
const frames = selectionFramesData.value.frames;
|
||||||
|
return frames.length > 0 && dataGridFrameCoversRow(frames, item.displayIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多格范围的外框:松手后用单元格 bitmask class;拖拽中改由 overlay 画一圈外框(与 canvas 一致,文字仍在格子里)
|
||||||
|
function selectionFrameEdgeClass(rowIndex: number, visibleColIdx: number): string {
|
||||||
|
if (domSelectionDragOverlayActive.value || !selectionUsesOuterFrame.value) return "";
|
||||||
|
const mask = dataGridSelectionEdgeMask(selectionFramesData.value.frames, rowIndex, visibleColIdx);
|
||||||
|
return mask === 0 ? "" : `cell-sel-frame-${mask}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domSelectionDragOverlayStyle = computed((): CSSProperties | undefined => {
|
||||||
|
if (!domSelectionDragOverlayActive.value) return undefined;
|
||||||
|
const range = selectedRange.value;
|
||||||
|
if (!range) return undefined;
|
||||||
|
const startCol = range.startCol;
|
||||||
|
const endCol = range.endCol;
|
||||||
|
const startFrozen = startCol < frozenColumnCount.value;
|
||||||
|
const endFrozen = endCol < frozenColumnCount.value;
|
||||||
|
const left = rowNumberWidth.value + (renderedColumnOffsets.value[startCol] ?? 0) - (startFrozen ? 0 : gridHorizontalScrollLeft.value);
|
||||||
|
const right = rowNumberWidth.value + (renderedColumnOffsets.value[endCol] ?? 0) + (renderedColumnWidths.value[endCol] ?? 0) - (endFrozen ? 0 : gridHorizontalScrollLeft.value);
|
||||||
|
const top = range.startRow * CANVAS_DATA_GRID_ROW_HEIGHT - domGridScrollTop.value;
|
||||||
|
const bottom = (range.endRow + 1) * CANVAS_DATA_GRID_ROW_HEIGHT - domGridScrollTop.value;
|
||||||
|
const viewportWidth = gridViewportWidth.value;
|
||||||
|
const scroller = gridScrollerElement();
|
||||||
|
const viewportHeight = scroller?.clientHeight ?? 0;
|
||||||
|
const clippedLeft = Math.max(rowNumberWidth.value, left);
|
||||||
|
const clippedTop = Math.max(0, top);
|
||||||
|
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, right) : right;
|
||||||
|
const clippedBottom = viewportHeight > 0 ? Math.min(viewportHeight, bottom) : bottom;
|
||||||
|
const width = clippedRight - clippedLeft;
|
||||||
|
const height = clippedBottom - clippedTop;
|
||||||
|
if (width < 1 || height < 1) return undefined;
|
||||||
|
return {
|
||||||
|
left: `${clippedLeft}px`,
|
||||||
|
top: `${clippedTop}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
|
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
|
||||||
quickDownloadMenuCell.value = retainBinaryCellDownloadMenuForHover(quickDownloadMenuCell.value, { rowIndex, col: actualColIdx });
|
if (!isSelectingCells.value) {
|
||||||
if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx };
|
quickDownloadMenuCell.value = retainBinaryCellDownloadMenuForHover(quickDownloadMenuCell.value, { rowIndex, col: actualColIdx });
|
||||||
|
if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx };
|
||||||
|
}
|
||||||
extendCellSelection(rowIndex, visibleColIdx);
|
extendCellSelection(rowIndex, visibleColIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5461,6 +5559,7 @@ function drawCanvasGrid() {
|
|||||||
isRowActive,
|
isRowActive,
|
||||||
rowCellsUseSelectionVisual,
|
rowCellsUseSelectionVisual,
|
||||||
cellIsSelected,
|
cellIsSelected,
|
||||||
|
selectionFrames: selectionFramesData.value.frames,
|
||||||
cellCanHover: canEditCellItem,
|
cellCanHover: canEditCellItem,
|
||||||
infiniteScrollEnabled: infiniteScrollEnabled.value,
|
infiniteScrollEnabled: infiniteScrollEnabled.value,
|
||||||
pageOffset: rowNumberPageOffset(),
|
pageOffset: rowNumberPageOffset(),
|
||||||
@@ -6004,6 +6103,7 @@ function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: M
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([selectedRange, showCellDetail, isEditingDetail], () => {
|
watch([selectedRange, showCellDetail, isEditingDetail], () => {
|
||||||
|
if (isSelectingCells.value) return;
|
||||||
const selectedCell = currentSelectedCellPosition();
|
const selectedCell = currentSelectedCellPosition();
|
||||||
const target = linkedCellDetailTarget({
|
const target = linkedCellDetailTarget({
|
||||||
isOpen: showCellDetail.value,
|
isOpen: showCellDetail.value,
|
||||||
@@ -9033,6 +9133,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
'text-muted-foreground italic': cell.isNull,
|
'text-muted-foreground italic': cell.isNull,
|
||||||
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
'cell-selected': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
'cell-selected-dirty': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
|
'cell-selected--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
|
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
|
||||||
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
|
||||||
@@ -9158,7 +9260,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
<div class="data-grid-header-row flex w-(--header-total-w) font-semibold text-foreground">
|
<div class="data-grid-header-row flex w-(--header-total-w) font-semibold text-foreground">
|
||||||
<div
|
<div
|
||||||
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r w-(--row-num-w) border-border text-center text-muted-foreground select-none cursor-default hover:bg-gray-200 dark:hover:bg-gray-800 sticky left-0 z-20"
|
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r w-(--row-num-w) border-border text-center text-muted-foreground select-none cursor-default hover:bg-gray-200 dark:hover:bg-gray-800 sticky left-0 z-20"
|
||||||
:class="{ 'data-grid-header-cell--selected outline outline-primary -outline-offset-1': isSelectingAll }"
|
:class="{ 'data-grid-header-cell--selected': isSelectingAll }"
|
||||||
@click="selectAllCells"
|
@click="selectAllCells"
|
||||||
>
|
>
|
||||||
#
|
#
|
||||||
@@ -9674,141 +9776,143 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Virtual scrolled rows -->
|
<!-- Virtual scrolled rows -->
|
||||||
<RecycleScroller
|
<div v-else-if="hasVisibleRows" class="relative min-h-0 flex-1">
|
||||||
v-else-if="hasVisibleRows"
|
<RecycleScroller
|
||||||
ref="scrollerRef"
|
ref="scrollerRef"
|
||||||
class="data-grid-scroller dbx-data-grid-font-family flex-1 overflow-x-auto overscroll-none"
|
class="data-grid-scroller dbx-data-grid-font-family h-full overflow-x-auto overscroll-none"
|
||||||
:class="{ 'is-scrolling': isScrolling, 'has-horizontal-scrollbar': hasGridHorizontalOverflow }"
|
:class="{ 'is-scrolling': isScrolling, 'has-horizontal-scrollbar': hasGridHorizontalOverflow }"
|
||||||
:items="displayItems"
|
:items="displayItems"
|
||||||
:item-size="DOM_DATA_GRID_ROW_HEIGHT"
|
:item-size="DOM_DATA_GRID_ROW_HEIGHT"
|
||||||
:buffer="600"
|
:buffer="600"
|
||||||
:skip-hover="true"
|
:skip-hover="true"
|
||||||
key-field="id"
|
key-field="id"
|
||||||
@scroll="onScrollerScroll"
|
@scroll="onScrollerScroll"
|
||||||
@wheel="onDomGridWheel"
|
@wheel="onDomGridWheel"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<div
|
|
||||||
class="data-grid-row flex border-b border-border h-6.5 w-(--total-w)"
|
|
||||||
:class="{
|
|
||||||
'data-grid-row--deleted opacity-70': item.isDeleted,
|
|
||||||
'data-grid-row--new': item.isNew && !isRowActive(item.displayIndex),
|
|
||||||
'data-grid-row--draft': item.isDraft && !isRowActive(item.displayIndex),
|
|
||||||
'data-grid-row--striped': !item.isNew && !item.isDraft && !item.isDeleted && !isRowActive(item.displayIndex) && item.displayIndex % 2 === 1,
|
|
||||||
'active-row': isRowActive(item.displayIndex) && !item.isDeleted,
|
|
||||||
'relative z-20 overflow-visible': editingCell?.rowId === item.id,
|
|
||||||
}"
|
|
||||||
:style="dataGridRowStyle(item)"
|
|
||||||
:data-row-index="item.displayIndex"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="data-grid-row-number w-(--row-num-w) shrink-0 px-2 py-1 border-r text-center select-none cursor-default sticky left-0 z-10"
|
class="data-grid-row flex border-b border-border h-6.5 w-(--total-w)"
|
||||||
:class="[rowNumberStatusClass(item), { 'data-grid-row-number--selected': isRowSelected(item.id) }]"
|
|
||||||
@mousedown="onRowNumberMouseDown(item, $event)"
|
|
||||||
@dblclick.stop="toggleTranspose(item.displayIndex)"
|
|
||||||
@contextmenu="onRowContext(item.id, item.displayIndex)"
|
|
||||||
>
|
|
||||||
{{ rowNumberText(item) }}
|
|
||||||
</div>
|
|
||||||
<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="{
|
:class="{
|
||||||
'data-grid-cell--frozen': col.visibleColIdx < frozenColumnCount,
|
'data-grid-row--deleted opacity-70': item.isDeleted,
|
||||||
'data-grid-cell--frozen-separator': frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1,
|
'data-grid-row--new': item.isNew && !isRowActive(item.displayIndex),
|
||||||
'text-right': columnAligns[col.visibleColIdx] === 'right',
|
'data-grid-row--draft': item.isDraft && !isRowActive(item.displayIndex),
|
||||||
'text-muted-foreground italic': isNull(item.data[col.actualColIdx]),
|
'data-grid-row--striped': !item.isNew && !item.isDraft && !item.isDeleted && !isRowActive(item.displayIndex) && item.displayIndex % 2 === 1,
|
||||||
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx],
|
'active-row': isRowActive(item.displayIndex) && !item.isDeleted,
|
||||||
'cell-selected': cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
'relative z-20 overflow-visible': editingCell?.rowId === item.id,
|
||||||
'cell-selected-dirty': cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
|
|
||||||
'row-cell-selected': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
|
||||||
'row-cell-selected-dirty': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
|
|
||||||
'cell-search-match': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
|
|
||||||
'cell-current-search-match': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
|
|
||||||
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
|
|
||||||
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
|
|
||||||
'tabular-nums': typeof item.data[col.actualColIdx] === 'number',
|
|
||||||
'cursor-text hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx) && !(booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null),
|
|
||||||
'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx),
|
|
||||||
'line-through': item.isDeleted,
|
|
||||||
'overflow-visible z-20 border-r-transparent': editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx,
|
|
||||||
'overflow-hidden': !(editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx),
|
|
||||||
}"
|
}"
|
||||||
@mousedown="
|
:style="dataGridRowStyle(item)"
|
||||||
prepareDataCellMouseDown(item, col.actualColIdx);
|
:data-row-index="item.displayIndex"
|
||||||
handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);
|
|
||||||
"
|
|
||||||
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
|
|
||||||
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
|
|
||||||
@dblclick="onDomCellDblClick(item, item.displayIndex, col.visibleColIdx, col.actualColIdx, $event)"
|
|
||||||
:data-visible-col-index="col.visibleColIdx"
|
|
||||||
@contextmenu="onCellContext(item.id, item.displayIndex, col.actualColIdx, col.visibleColIdx, $event)"
|
|
||||||
>
|
>
|
||||||
<template v-if="editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx">
|
<div
|
||||||
<TemporalCellEditor
|
class="data-grid-row-number w-(--row-num-w) shrink-0 px-2 py-1 border-r text-center select-none cursor-default sticky left-0 z-10"
|
||||||
v-if="temporalEditorConfigForColumn(col.actualColIdx)"
|
:class="[rowNumberStatusClass(item), { 'data-grid-row-number--selected': isRowSelected(item.id), 'data-grid-row-number--in-selection': rowNumberShowsSelectionTint(item) }]"
|
||||||
v-model="editValue"
|
@mousedown="onRowNumberMouseDown(item, $event)"
|
||||||
:kind="temporalEditorConfigForColumn(col.actualColIdx)!.kind"
|
@dblclick.stop="toggleTranspose(item.displayIndex)"
|
||||||
:fraction-precision="temporalEditorConfigForColumn(col.actualColIdx)!.fractionPrecision"
|
@contextmenu="onRowContext(item.id, item.displayIndex)"
|
||||||
@cancel="cancelEdit"
|
>
|
||||||
@commit="commitGridEdit"
|
{{ rowNumberText(item) }}
|
||||||
/>
|
</div>
|
||||||
<EnumCellEditor
|
<div class="shrink-0" :style="{ width: `${horizontalColumnWindowBeforeWidth}px` }" />
|
||||||
v-else-if="isBooleanGridCell(item, col.actualColIdx)"
|
<div
|
||||||
v-model="booleanEditorModelValue"
|
v-for="col in renderedGridColumns"
|
||||||
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
:key="col.actualColIdx"
|
||||||
:nullable="isBooleanGridColumnNullable(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"
|
||||||
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
:style="renderedColumnStyle(col.visibleColIdx)"
|
||||||
@cancel="cancelEdit"
|
:class="[
|
||||||
@commit="commitBooleanGridEdit"
|
selectionFrameEdgeClass(item.displayIndex, col.visibleColIdx),
|
||||||
/>
|
{
|
||||||
<EnumCellEditor
|
'data-grid-cell--frozen': col.visibleColIdx < frozenColumnCount,
|
||||||
v-else-if="isEnumGridColumn(col.actualColIdx)"
|
'data-grid-cell--frozen-separator': frozenColumnCount > 0 && col.visibleColIdx === frozenColumnCount - 1,
|
||||||
v-model="editValue"
|
'text-right': columnAligns[col.visibleColIdx] === 'right',
|
||||||
:values="enumValuesForGridColumn(col.actualColIdx)"
|
'text-muted-foreground italic': isNull(item.data[col.actualColIdx]),
|
||||||
:nullable="isEnumGridColumnNullable(col.actualColIdx)"
|
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx],
|
||||||
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
'cell-selected': cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
||||||
@cancel="cancelEdit"
|
'cell-selected-dirty': cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
|
||||||
@commit="commitGridEdit"
|
'cell-selected--single': !selectionUsesOuterFrame && selectionFrameKindForCell(item.displayIndex, col.visibleColIdx) === 'single' && cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
||||||
/>
|
'cell-selected--sparse': selectionFramesData.sparse && cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
||||||
<textarea
|
'cell-selected-dirty--sparse': selectionFramesData.sparse && cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
|
||||||
v-else-if="cellUsesExpandedEditor(item.id, col.actualColIdx)"
|
'row-cell-selected': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
|
||||||
v-model="editValue"
|
'row-cell-selected-dirty': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
|
||||||
data-expanded-cell-editor="true"
|
'cell-search-match': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
|
||||||
rows="1"
|
'cell-current-search-match': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
|
||||||
:inputmode="cellEditInputModeForColumn(col.actualColIdx)"
|
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
|
||||||
autocapitalize="off"
|
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
|
||||||
autocorrect="off"
|
'tabular-nums': typeof item.data[col.actualColIdx] === 'number',
|
||||||
spellcheck="false"
|
'cursor-text hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && canEditCellItem(item, col.actualColIdx) && !(booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null),
|
||||||
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-2.5 py-1 leading-[18px] outline-none z-10"
|
'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800': !isScrolling && booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx),
|
||||||
@blur="commitEditFromCellBlur"
|
'line-through': item.isDeleted,
|
||||||
@click.stop
|
'overflow-visible z-20 border-r-transparent': editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx,
|
||||||
@focus="onCellEditTextareaInput"
|
'overflow-hidden': !(editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx),
|
||||||
@input="onCellEditTextareaInput"
|
},
|
||||||
@keydown.stop="onCellEditKeydown"
|
]"
|
||||||
@paste.stop="onCellEditTextareaPaste"
|
@mousedown="
|
||||||
/>
|
prepareDataCellMouseDown(item, col.actualColIdx);
|
||||||
<input
|
handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);
|
||||||
v-else
|
"
|
||||||
v-model="editValue"
|
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
|
||||||
:inputmode="cellEditInputModeForColumn(col.actualColIdx)"
|
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
|
||||||
autocapitalize="off"
|
@dblclick="onDomCellDblClick(item, item.displayIndex, col.visibleColIdx, col.actualColIdx, $event)"
|
||||||
autocorrect="off"
|
:data-visible-col-index="col.visibleColIdx"
|
||||||
spellcheck="false"
|
@contextmenu="onCellContext(item.id, item.displayIndex, col.actualColIdx, col.visibleColIdx, $event)"
|
||||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 leading-[22px] outline-none z-10"
|
>
|
||||||
@blur="commitEditFromCellBlur"
|
<template v-if="editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx">
|
||||||
@click.stop
|
<TemporalCellEditor
|
||||||
@input="onCellEditTextareaInput"
|
v-if="temporalEditorConfigForColumn(col.actualColIdx)"
|
||||||
@keydown.stop="onCellEditKeydown"
|
v-model="editValue"
|
||||||
@paste.stop="onCellEditTextareaPaste"
|
:kind="temporalEditorConfigForColumn(col.actualColIdx)!.kind"
|
||||||
/>
|
:fraction-precision="temporalEditorConfigForColumn(col.actualColIdx)!.fractionPrecision"
|
||||||
</template>
|
@cancel="cancelEdit"
|
||||||
<template v-else>
|
@commit="commitGridEdit"
|
||||||
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
|
/>
|
||||||
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
|
<EnumCellEditor
|
||||||
|
v-else-if="isBooleanGridCell(item, col.actualColIdx)"
|
||||||
|
v-model="booleanEditorModelValue"
|
||||||
|
:values="BOOLEAN_CELL_EDITOR_VALUES"
|
||||||
|
:nullable="isBooleanGridColumnNullable(col.actualColIdx)"
|
||||||
|
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
||||||
|
@cancel="cancelEdit"
|
||||||
|
@commit="commitBooleanGridEdit"
|
||||||
|
/>
|
||||||
|
<EnumCellEditor
|
||||||
|
v-else-if="isEnumGridColumn(col.actualColIdx)"
|
||||||
|
v-model="editValue"
|
||||||
|
:values="enumValuesForGridColumn(col.actualColIdx)"
|
||||||
|
:nullable="isEnumGridColumnNullable(col.actualColIdx)"
|
||||||
|
:initial-null="isGridCellInitialNull(item.id, col.actualColIdx)"
|
||||||
|
@cancel="cancelEdit"
|
||||||
|
@commit="commitGridEdit"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-else-if="cellUsesExpandedEditor(item.id, col.actualColIdx)"
|
||||||
|
v-model="editValue"
|
||||||
|
data-expanded-cell-editor="true"
|
||||||
|
rows="1"
|
||||||
|
:inputmode="cellEditInputModeForColumn(col.actualColIdx)"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-2.5 py-1 leading-[18px] outline-none z-10"
|
||||||
|
@blur="commitEditFromCellBlur"
|
||||||
|
@click.stop
|
||||||
|
@focus="onCellEditTextareaInput"
|
||||||
|
@input="onCellEditTextareaInput"
|
||||||
|
@keydown.stop="onCellEditKeydown"
|
||||||
|
@paste.stop="onCellEditTextareaPaste"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="editValue"
|
||||||
|
:inputmode="cellEditInputModeForColumn(col.actualColIdx)"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 leading-[22px] outline-none z-10"
|
||||||
|
@blur="commitEditFromCellBlur"
|
||||||
|
@click.stop
|
||||||
|
@input="onCellEditTextareaInput"
|
||||||
|
@keydown.stop="onCellEditKeydown"
|
||||||
|
@paste.stop="onCellEditTextareaPaste"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx)">
|
<template v-else-if="booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] !== null && canEditCellItem(item, col.actualColIdx)">
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
@@ -9825,39 +9929,45 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
<template v-else-if="booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] === null && canEditCellItem(item, col.actualColIdx)">
|
<template v-else-if="booleanCellsUseCheckbox && isBooleanGridCell(item, col.actualColIdx) && item.data[col.actualColIdx] === null && canEditCellItem(item, col.actualColIdx)">
|
||||||
<span class="italic text-muted-foreground cursor-pointer select-none" @click.stop="cycleBooleanGridCell(item, col.actualColIdx, $event)">{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</span>
|
<span class="italic text-muted-foreground cursor-pointer select-none" @click.stop="cycleBooleanGridCell(item, col.actualColIdx, $event)">{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</template>
|
<template v-else>
|
||||||
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
|
||||||
<LightDropdownMenu
|
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
|
||||||
v-if="canQuickDownloadCellValue(item.displayIndex, col.actualColIdx)"
|
</template>
|
||||||
:items="binaryCellDownloadMenuItems"
|
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</template>
|
||||||
:open="quickDownloadMenuOpenFor(item.displayIndex, col.actualColIdx)"
|
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||||
align="end"
|
<LightDropdownMenu
|
||||||
content-class="w-44"
|
v-if="canQuickDownloadCellValue(item.displayIndex, col.actualColIdx)"
|
||||||
:match-trigger-width="false"
|
:items="binaryCellDownloadMenuItems"
|
||||||
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, item.displayIndex, col.actualColIdx)"
|
:open="quickDownloadMenuOpenFor(item.displayIndex, col.actualColIdx)"
|
||||||
@select="(mode: string) => downloadCellBinaryValue(item.displayIndex, col.actualColIdx, mode as BinaryCellDownloadMode)"
|
align="end"
|
||||||
>
|
content-class="w-44"
|
||||||
<template #trigger="{ open, toggle }">
|
:match-trigger-width="false"
|
||||||
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
@update:open="(value: boolean) => handleQuickDownloadMenuOpenChange(value, item.displayIndex, col.actualColIdx)"
|
||||||
<Upload class="h-3 w-3" />
|
@select="(mode: string) => downloadCellBinaryValue(item.displayIndex, col.actualColIdx, mode as BinaryCellDownloadMode)"
|
||||||
</button>
|
>
|
||||||
</template>
|
<template #trigger="{ open, toggle }">
|
||||||
</LightDropdownMenu>
|
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||||
<button
|
<Upload class="h-3 w-3" />
|
||||||
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
</button>
|
||||||
:title="t('grid.cellDetails')"
|
</template>
|
||||||
@mousedown.stop
|
</LightDropdownMenu>
|
||||||
@click.stop="showCellDetailsForVisibleCell(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
|
<button
|
||||||
>
|
class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
||||||
<Info class="h-3 w-3" />
|
:title="t('grid.cellDetails')"
|
||||||
</button>
|
@mousedown.stop
|
||||||
</div>
|
@click.stop="showCellDetailsForVisibleCell(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
|
||||||
</template>
|
>
|
||||||
|
<Info class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.afterWidth}px` }" />
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.afterWidth}px` }" />
|
</template>
|
||||||
</div>
|
</RecycleScroller>
|
||||||
</template>
|
<div v-if="domSelectionDragOverlayStyle" class="data-grid-selection-drag-overlay pointer-events-none absolute z-20" :style="domSelectionDragOverlayStyle" />
|
||||||
</RecycleScroller>
|
</div>
|
||||||
<!-- Infinite scroll loading indicator for RecycleScroller -->
|
<!-- Infinite scroll loading indicator for RecycleScroller -->
|
||||||
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !gridSurfaceBusy" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
|
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !gridSurfaceBusy" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
|
||||||
<Loader2 class="w-3 h-3 animate-spin mr-1" />
|
<Loader2 class="w-3 h-3 animate-spin mr-1" />
|
||||||
@@ -11021,8 +11131,6 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
|
|
||||||
.cell-selected-dirty {
|
.cell-selected-dirty {
|
||||||
background-color: var(--data-grid-cell-selected-dirty-bg) !important;
|
background-color: var(--data-grid-cell-selected-dirty-bg) !important;
|
||||||
outline: 1px solid var(--data-grid-cell-selected-border);
|
|
||||||
outline-offset: -1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-cell-selected-dirty {
|
.row-cell-selected-dirty {
|
||||||
@@ -11045,19 +11153,123 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
background-color: var(--data-grid-row-number-active-bg) !important;
|
background-color: var(--data-grid-row-number-active-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选区覆盖指示:选中单元格/范围时,覆盖到的行号淡色高亮(Navicat 风格)。
|
||||||
|
* 不用 !important:行选中(--selected)和活动行规则天然优先 */
|
||||||
|
.data-grid-row-number--in-selection {
|
||||||
|
background-color: var(--data-grid-cell-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.data-grid-row-number--selected {
|
.data-grid-row-number--selected {
|
||||||
background-color: var(--data-grid-row-number-selected-bg) !important;
|
background-color: var(--data-grid-row-number-selected-bg) !important;
|
||||||
color: var(--foreground) !important;
|
color: var(--foreground) !important;
|
||||||
box-shadow: inset 3px 0 0 var(--data-grid-cell-selected-border);
|
box-shadow: inset 3px 0 0 var(--data-grid-cell-selected-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中优先级高于活动行(与 canvas 的 resolveCanvasDataGridRowFill 一致),
|
||||||
|
* 否则选中且活动的行号在 DOM 里显示为更浅的活动行底色 */
|
||||||
|
.active-row > .data-grid-row-number--selected {
|
||||||
|
background-color: var(--data-grid-row-number-selected-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.cell-selected {
|
.cell-selected {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background-color: var(--data-grid-cell-selected-single-bg) !important;
|
background-color: var(--data-grid-cell-selected-single-bg) !important;
|
||||||
outline: 1px solid var(--data-grid-cell-selected-border);
|
}
|
||||||
|
|
||||||
|
/* 单个单元格:细边框(跟随主题色,与 canvas 一致) */
|
||||||
|
.cell-selected--single {
|
||||||
|
outline: 1px solid var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 离散点选(Ctrl 多选、transpose 视图)无法矩形化,退回逐格描边 */
|
||||||
|
.cell-selected--sparse,
|
||||||
|
.cell-selected-dirty--sparse {
|
||||||
|
outline: 1px solid var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DOM 框选拖拽外框:与 canvas 一样只描 1.5px 外框;填充仍由格子上的 cell-selected 负责(文字在底色之上) */
|
||||||
|
.data-grid-selection-drag-overlay {
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 多格选区外框:bitmask class(top=1,right=2,bottom=4,left=8),避免框选时写 inline box-shadow */
|
||||||
|
.cell-sel-frame-1 {
|
||||||
|
box-shadow: inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-2 {
|
||||||
|
box-shadow: inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-3 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-4 {
|
||||||
|
box-shadow: inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-5 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-6 {
|
||||||
|
box-shadow:
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-7 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-8 {
|
||||||
|
box-shadow: inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-9 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-10 {
|
||||||
|
box-shadow:
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-11 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-12 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-13 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-14 {
|
||||||
|
box-shadow:
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
.cell-sel-frame-15 {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset -1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 0 -1.5px 0 0 var(--data-grid-cell-selected-single-border, var(--primary)),
|
||||||
|
inset 1.5px 0 0 0 var(--data-grid-cell-selected-single-border, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
.ddl-code :deep(.ddl-kw) {
|
.ddl-code :deep(.ddl-kw) {
|
||||||
color: rgb(39 132 213);
|
color: rgb(39 132 213);
|
||||||
color: oklch(0.6 0.15 250);
|
color: oklch(0.6 0.15 250);
|
||||||
|
|||||||
@@ -57,14 +57,7 @@ const emit = defineEmits<{
|
|||||||
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
|
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
|
||||||
<div
|
<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="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="[
|
:class="[dark && 'data-grid-header-cell--dark', selected && 'data-grid-header-cell--selected', 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]"
|
||||||
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"
|
:style="columnStyle"
|
||||||
:data-grid-column-index="actualColumnIndex"
|
:data-grid-column-index="actualColumnIndex"
|
||||||
:data-visible-col-index="visibleColumnIndex"
|
:data-visible-col-index="visibleColumnIndex"
|
||||||
|
|||||||
@@ -141,7 +141,36 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCellCount = computed(() => selectedCells.value.rows.reduce((count, row) => count + row.length, 0));
|
// 选区计数/是否有选区不走 selectedCells 物化:框选拖拽时每帧都会读这些值
|
||||||
|
const selectedCellCount = computed(() => {
|
||||||
|
if (hasColumnSelection.value) {
|
||||||
|
const colCount = selectedColumnIndexes.value.size;
|
||||||
|
if (colCount === 0) return 0;
|
||||||
|
let rows = 0;
|
||||||
|
for (const item of displayItems.value) {
|
||||||
|
if (!item.isDraft) rows += 1;
|
||||||
|
}
|
||||||
|
return rows * colCount;
|
||||||
|
}
|
||||||
|
if (selectedCellKeys.value.size > 0) {
|
||||||
|
let count = 0;
|
||||||
|
for (const key of selectedCellKeys.value) {
|
||||||
|
const position = parseCellKey(key);
|
||||||
|
if (position && !displayItems.value[position.rowIndex]?.isDraft) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
const range = selectedRange.value;
|
||||||
|
if (!range) return 0;
|
||||||
|
const colCount = range.endCol - range.startCol + 1;
|
||||||
|
let rows = 0;
|
||||||
|
for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) {
|
||||||
|
if (!displayItems.value[rowIndex]?.isDraft) rows += 1;
|
||||||
|
}
|
||||||
|
return rows * colCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
// hasCellSelection 跟随计数语义:仅选中草稿行/草稿单元格时视为无选区
|
||||||
const hasCellSelection = computed(() => selectedCellCount.value > 0);
|
const hasCellSelection = computed(() => selectedCellCount.value > 0);
|
||||||
|
|
||||||
function clearCellSelection() {
|
function clearCellSelection() {
|
||||||
@@ -461,6 +490,8 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
|
|||||||
|
|
||||||
function extendCellSelection(rowIndex: number, colIndex: number) {
|
function extendCellSelection(rowIndex: number, colIndex: number) {
|
||||||
if (!isSelectingCells.value || !selectionAnchor.value) return;
|
if (!isSelectingCells.value || !selectionAnchor.value) return;
|
||||||
|
const current = selectionFocus.value;
|
||||||
|
if (current?.rowIndex === rowIndex && current?.colIndex === colIndex) return;
|
||||||
selectionFocus.value = { rowIndex, colIndex };
|
selectionFocus.value = { rowIndex, colIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||||
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCellValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCellValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||||
|
import { dataGridFrameCoversRow, dataGridFrameIsMultiCell, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame } from "@/lib/dataGrid/dataGridSelectionFrames";
|
||||||
|
import type { CellSelectionRange } from "@/lib/dataGrid/gridSelection";
|
||||||
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
|
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
|
||||||
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
|
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||||
|
|
||||||
@@ -79,6 +81,9 @@ export interface DrawCanvasDataGridOptions {
|
|||||||
isRowActive: (rowIndex: number) => boolean;
|
isRowActive: (rowIndex: number) => boolean;
|
||||||
rowCellsUseSelectionVisual: (rowId: number) => boolean;
|
rowCellsUseSelectionVisual: (rowId: number) => boolean;
|
||||||
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
|
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
|
||||||
|
/** 连续选区矩形(displayRow/visibleCol 坐标)。用于 Navicat 风格的形态区分:
|
||||||
|
* 多格范围浅色填充 + 一圈细外框、内部零描边;1×1 画细边框;离散点选传空数组退回逐格描边 */
|
||||||
|
selectionFrames?: readonly CellSelectionRange[];
|
||||||
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
|
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
|
||||||
infiniteScrollEnabled: boolean;
|
infiniteScrollEnabled: boolean;
|
||||||
pageOffset: number;
|
pageOffset: number;
|
||||||
@@ -333,6 +338,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
isRowActive,
|
isRowActive,
|
||||||
rowCellsUseSelectionVisual,
|
rowCellsUseSelectionVisual,
|
||||||
cellIsSelected,
|
cellIsSelected,
|
||||||
|
selectionFrames = [],
|
||||||
cellCanHover,
|
cellCanHover,
|
||||||
infiniteScrollEnabled,
|
infiniteScrollEnabled,
|
||||||
pageOffset,
|
pageOffset,
|
||||||
@@ -342,6 +348,10 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
columnIsBoolean,
|
columnIsBoolean,
|
||||||
booleanDisplayMode = "dropdown",
|
booleanDisplayMode = "dropdown",
|
||||||
} = options;
|
} = options;
|
||||||
|
// 框选热路径:整次绘制只判断一次。常见情况(单矩形 / 多列且每段都是多格)可跳过逐格 kind 查询
|
||||||
|
const paintSelectionOuterFrame = dataGridSelectionUsesOuterFrame(selectionFrames);
|
||||||
|
const suppressAllSelectedCellBorders = selectionFrames.length > 0 && selectionFrames.every(dataGridFrameIsMultiCell);
|
||||||
|
const selectionRowCoverage = selectionFrames.length > 0;
|
||||||
const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
|
const fallbackRatio = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1);
|
||||||
const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({
|
const { pixelWidth, pixelHeight, scaleX, scaleY } = resolveCanvasBackingStoreMetrics({
|
||||||
width,
|
width,
|
||||||
@@ -409,6 +419,9 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
ctx.fillStyle = rowFill;
|
ctx.fillStyle = rowFill;
|
||||||
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
|
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||||
|
|
||||||
|
// 选区覆盖指示(Navicat 风格):行落在选区范围内时行号淡色高亮;
|
||||||
|
// 优先级低于行选中/状态色/活动行,与 DOM 的级联顺序一致
|
||||||
|
const rowInSelection = !rowSelectionVisual && selectionRowCoverage && dataGridFrameCoversRow(selectionFrames, item.displayIndex);
|
||||||
const rowNumberFill = rowSelectionVisual
|
const rowNumberFill = rowSelectionVisual
|
||||||
? theme.rowNumberSelected
|
? theme.rowNumberSelected
|
||||||
: item.status === "draft"
|
: item.status === "draft"
|
||||||
@@ -421,16 +434,24 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
? theme.rowNumberDeleted
|
? theme.rowNumberDeleted
|
||||||
: rowIsActive
|
: rowIsActive
|
||||||
? theme.rowNumberActive
|
? theme.rowNumberActive
|
||||||
: theme.rowNumberDefault;
|
: rowInSelection
|
||||||
|
? theme.cellSelected
|
||||||
|
: theme.rowNumberDefault;
|
||||||
ctx.fillStyle = rowNumberFill;
|
ctx.fillStyle = rowNumberFill;
|
||||||
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
|
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||||
|
// 与 DOM 的 .data-grid-row-number--selected 对齐:选中行号左侧 3px 蓝色条
|
||||||
|
if (rowSelectionVisual) {
|
||||||
|
ctx.fillStyle = theme.cellSelectedBorder;
|
||||||
|
ctx.fillRect(0, y, 3, CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||||
|
}
|
||||||
ctx.strokeStyle = theme.border;
|
ctx.strokeStyle = theme.border;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(rowNumberBorderX, y);
|
ctx.moveTo(rowNumberBorderX, y);
|
||||||
ctx.lineTo(rowNumberBorderX, y + CANVAS_DATA_GRID_ROW_HEIGHT);
|
ctx.lineTo(rowNumberBorderX, y + CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const rowNumberText = item.status === "new" ? theme.rowNumberTextNew : item.status === "edited" ? theme.rowNumberTextEdited : item.status === "deleted" ? theme.rowNumberTextDeleted : theme.rowNumberTextClean;
|
// 与 DOM 对齐:选中行号文字用前景色
|
||||||
|
const rowNumberText = item.status === "new" ? theme.rowNumberTextNew : item.status === "edited" ? theme.rowNumberTextEdited : item.status === "deleted" ? theme.rowNumberTextDeleted : rowSelectionVisual ? theme.foreground : theme.rowNumberTextClean;
|
||||||
ctx.fillStyle = rowNumberText;
|
ctx.fillStyle = rowNumberText;
|
||||||
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
|
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
@@ -464,7 +485,8 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
|
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
|
||||||
const isDirtyCell = item.isDirtyCol[actualColIdx];
|
const isDirtyCell = item.isDirtyCol[actualColIdx];
|
||||||
const selectedFillVisual = rowSelectionVisual || selectedCell;
|
const selectedFillVisual = rowSelectionVisual || selectedCell;
|
||||||
const selectedBorderVisual = selectedCell;
|
// Navicat 风格:多格范围内部零描边(末尾统一画外框);单格 / Ctrl 点选保留细边框
|
||||||
|
const selectedBorderVisual = !selectedCell ? false : suppressAllSelectedCellBorders ? false : paintSelectionOuterFrame ? dataGridSelectionFrameKindAtCell(selectionFrames, item.displayIndex, visibleColIdx) !== "range" : true;
|
||||||
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx));
|
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(dataGridSearchMatchKey(item.displayIndex, actualColIdx));
|
||||||
const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
|
const isCurrentSearchMatch = paintSearchMatches && currentSearchMatch?.displayRow === item.displayIndex && currentSearchMatch.col === actualColIdx;
|
||||||
const clippedX = Math.max(drawX, rowNumberWidth);
|
const clippedX = Math.max(drawX, rowNumberWidth);
|
||||||
@@ -562,7 +584,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
const selectedRightX = clippedX + cellPaintWidth - 1.5;
|
const selectedRightX = clippedX + cellPaintWidth - 1.5;
|
||||||
const selectedTopY = Math.max(y + 0.5, 1);
|
const selectedTopY = Math.max(y + 0.5, 1);
|
||||||
const drawSelectedLeftBorder = selectedLeftX >= rowNumberWidth + 0.5;
|
const drawSelectedLeftBorder = selectedLeftX >= rowNumberWidth + 0.5;
|
||||||
ctx.strokeStyle = selectedCell && !isDirtyCell ? theme.cellSelectedSingleBorder : theme.cellSelectedBorder;
|
ctx.strokeStyle = theme.cellSelectedSingleBorder;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(selectedLeftX, selectedTopY);
|
ctx.moveTo(selectedLeftX, selectedTopY);
|
||||||
ctx.lineTo(selectedRightX, selectedTopY);
|
ctx.lineTo(selectedRightX, selectedTopY);
|
||||||
@@ -641,4 +663,31 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 选区外框:多格范围选区画一圈主题色细外框(1.5px,比单格的 1px 略粗),
|
||||||
|
// 内部零描边(Navicat 风格);1×1 单格外框已由逐格细边框覆盖,这里跳过
|
||||||
|
if (paintSelectionOuterFrame) {
|
||||||
|
const frozenWidth = frozenColumnCount > 0 && frozenColumnCount <= renderedColumnWidths.length ? (offsets[frozenColumnCount] ?? 0) : 0;
|
||||||
|
const frozenRight = rowNumberWidth + frozenWidth;
|
||||||
|
// 列边缘的屏幕 x:冻结区内的边缘不随水平滚动,其余减去 scrollLeft
|
||||||
|
const columnEdgeX = (edgeCol: number): number => {
|
||||||
|
const offset = offsets[edgeCol] ?? 0;
|
||||||
|
return edgeCol <= frozenColumnCount ? rowNumberWidth + offset : rowNumberWidth + offset - scrollLeft;
|
||||||
|
};
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = theme.cellSelectedSingleBorder;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
for (const frame of selectionFrames) {
|
||||||
|
if (!dataGridFrameIsMultiCell(frame)) continue;
|
||||||
|
const minX = frame.startCol >= frozenColumnCount ? frozenRight : rowNumberWidth;
|
||||||
|
const minRightX = frame.endCol + 1 > frozenColumnCount ? frozenRight : rowNumberWidth;
|
||||||
|
const left = Math.min(Math.max(columnEdgeX(frame.startCol), minX), width);
|
||||||
|
const right = Math.min(Math.max(columnEdgeX(frame.endCol + 1), minRightX), width);
|
||||||
|
const top = Math.min(Math.max(frame.startRow * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop, 0), height);
|
||||||
|
const bottom = Math.min(Math.max((frame.endRow + 1) * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop, 0), height);
|
||||||
|
if (right - left < 2 || bottom - top < 2) continue;
|
||||||
|
ctx.strokeRect(left + 1, top + 1, right - left - 2, bottom - top - 2);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -278,11 +278,11 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
|
|||||||
rowDeleted: isDark ? rowDeleted : paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
|
rowDeleted: isDark ? rowDeleted : paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
|
||||||
cellActive: isDark ? cellActive : paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
|
cellActive: isDark ? cellActive : paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
|
||||||
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
|
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
|
||||||
cellSelected: isDark ? cellSelected : paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
|
cellSelected: paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
|
||||||
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
|
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
|
||||||
cellSelectedBorder: isDark ? cellSelectedBorder : paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
|
cellSelectedBorder: paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
|
||||||
cellSelectedSingle: isDark ? cellSelectedSingle : paintToken(getVar, "--data-grid-cell-selected-single-bg", cellSelectedSingle),
|
cellSelectedSingle: paintToken(getVar, "--data-grid-cell-selected-single-bg", cellSelectedSingle),
|
||||||
cellSelectedSingleBorder: isDark ? primary : paintToken(getVar, "--data-grid-cell-selected-single-border", primary),
|
cellSelectedSingleBorder: paintToken(getVar, "--data-grid-cell-selected-single-border", primary),
|
||||||
cellHover: isDark ? cellHover : paintToken(getVar, "--data-grid-cell-hover-bg", cellHover),
|
cellHover: isDark ? cellHover : paintToken(getVar, "--data-grid-cell-hover-bg", cellHover),
|
||||||
cellSearch: isDark ? cellSearch : paintToken(getVar, "--data-grid-cell-search-bg", cellSearch),
|
cellSearch: isDark ? cellSearch : paintToken(getVar, "--data-grid-cell-search-bg", cellSearch),
|
||||||
cellCurrentSearch: isDark ? cellCurrentSearch : paintToken(getVar, "--data-grid-cell-current-search-bg", cellCurrentSearch),
|
cellCurrentSearch: isDark ? cellCurrentSearch : paintToken(getVar, "--data-grid-cell-current-search-bg", cellCurrentSearch),
|
||||||
@@ -292,7 +292,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
|
|||||||
rowNumberEdited: isDark ? rowNumberEdited : paintToken(getVar, "--data-grid-row-number-edited-bg", rowNumberEdited),
|
rowNumberEdited: isDark ? rowNumberEdited : paintToken(getVar, "--data-grid-row-number-edited-bg", rowNumberEdited),
|
||||||
rowNumberDeleted: isDark ? rowNumberDeleted : paintToken(getVar, "--data-grid-row-number-deleted-bg", rowNumberDeleted),
|
rowNumberDeleted: isDark ? rowNumberDeleted : paintToken(getVar, "--data-grid-row-number-deleted-bg", rowNumberDeleted),
|
||||||
rowNumberActive: isDark ? rowNumberActive : paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive),
|
rowNumberActive: isDark ? rowNumberActive : paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive),
|
||||||
rowNumberSelected: isDark ? rowNumberSelected : paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
|
rowNumberSelected: paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
|
||||||
rowNumberTextClean: mutedForeground,
|
rowNumberTextClean: mutedForeground,
|
||||||
rowNumberTextNew: isDark ? "rgb(94, 233, 181)" : "rgb(0, 122, 85)",
|
rowNumberTextNew: isDark ? "rgb(94, 233, 181)" : "rgb(0, 122, 85)",
|
||||||
rowNumberTextEdited: isDark ? "rgb(255, 210, 48)" : "rgb(187, 77, 0)",
|
rowNumberTextEdited: isDark ? "rgb(255, 210, 48)" : "rgb(187, 77, 0)",
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { CellSelectionRange } from "@/lib/dataGrid/gridSelection";
|
||||||
|
|
||||||
|
/** 选区外框数据:把当前选区解析为一组连续矩形(frames)。
|
||||||
|
* 只有能表示为连续矩形的选区才画外框(Navicat 风格:淡色填充 + 一圈外框);
|
||||||
|
* Ctrl 点选的离散单元格无法矩形化,返回 sparse,由调用方退回逐格描边。 */
|
||||||
|
export interface DataGridSelectionFrames {
|
||||||
|
frames: CellSelectionRange[];
|
||||||
|
sparse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDataGridSelectionFrames(options: { sparseCellCount: number; hasColumnSelection: boolean; selectedColumnIndexes: ReadonlySet<number>; selectedRange: CellSelectionRange | null; rowCount: number }): DataGridSelectionFrames {
|
||||||
|
if (options.sparseCellCount > 0) return { frames: [], sparse: true };
|
||||||
|
|
||||||
|
if (options.hasColumnSelection) {
|
||||||
|
const lastRow = Math.max(0, options.rowCount - 1);
|
||||||
|
const columns = [...options.selectedColumnIndexes].sort((a, b) => a - b);
|
||||||
|
const frames: CellSelectionRange[] = [];
|
||||||
|
let runStart = -1;
|
||||||
|
let runEnd = -1;
|
||||||
|
for (const col of columns) {
|
||||||
|
if (runStart < 0) {
|
||||||
|
runStart = col;
|
||||||
|
runEnd = col;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (col === runEnd + 1) {
|
||||||
|
runEnd = col;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frames.push({ startRow: 0, endRow: lastRow, startCol: runStart, endCol: runEnd });
|
||||||
|
runStart = col;
|
||||||
|
runEnd = col;
|
||||||
|
}
|
||||||
|
if (runStart >= 0) frames.push({ startRow: 0, endRow: lastRow, startCol: runStart, endCol: runEnd });
|
||||||
|
return { frames, sparse: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.selectedRange) return { frames: [options.selectedRange], sparse: false };
|
||||||
|
return { frames: [], sparse: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridFrameContainsCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): boolean {
|
||||||
|
return dataGridFrameAtCell(frames, rowIndex, colIndex) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridFrameAtCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): CellSelectionRange | null {
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (rowIndex >= frame.startRow && rowIndex <= frame.endRow && colIndex >= frame.startCol && colIndex <= frame.endCol) return frame;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataGridFrameIsMultiCell(frame: CellSelectionRange): boolean {
|
||||||
|
return frame.startRow !== frame.endRow || frame.startCol !== frame.endCol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行号的"选区覆盖"指示:行落在任一 frame 范围内即为被覆盖 */
|
||||||
|
export function dataGridFrameCoversRow(frames: readonly CellSelectionRange[], rowIndex: number): boolean {
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (rowIndex >= frame.startRow && rowIndex <= frame.endRow) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navicat 风格的选区形态:单个单元格用细边框(single),
|
||||||
|
* 多格范围用实心填充 + 文字反白(range),不在选区内返回 null。 */
|
||||||
|
export function dataGridSelectionFrameKindAtCell(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): "single" | "range" | null {
|
||||||
|
const frame = dataGridFrameAtCell(frames, rowIndex, colIndex);
|
||||||
|
if (!frame) return null;
|
||||||
|
return dataGridFrameIsMultiCell(frame) ? "range" : "single";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否用「一圈外框」代替逐格描边。框选热路径下只算一次,避免每个可见格再查 frame kind。
|
||||||
|
* sparse(frames 为空)与 1×1 单格均为 false。
|
||||||
|
*/
|
||||||
|
export function dataGridSelectionUsesOuterFrame(frames: readonly CellSelectionRange[]): boolean {
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (dataGridFrameIsMultiCell(frame)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridSelectionEdgeFlags {
|
||||||
|
top: boolean;
|
||||||
|
right: boolean;
|
||||||
|
bottom: boolean;
|
||||||
|
left: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bitmask: top=1, right=2, bottom=4, left=8。不在外框上时返回 0。 */
|
||||||
|
export type DataGridSelectionEdgeMask = number;
|
||||||
|
|
||||||
|
export function dataGridSelectionEdgeMaskFromFlags(flags: DataGridSelectionEdgeFlags): DataGridSelectionEdgeMask {
|
||||||
|
return (flags.top ? 1 : 0) | (flags.right ? 2 : 0) | (flags.bottom ? 4 : 0) | (flags.left ? 8 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeFlagsForFrame(frame: CellSelectionRange, rowIndex: number, colIndex: number): DataGridSelectionEdgeFlags | null {
|
||||||
|
if (rowIndex < frame.startRow || rowIndex > frame.endRow || colIndex < frame.startCol || colIndex > frame.endCol) return null;
|
||||||
|
return {
|
||||||
|
top: rowIndex === frame.startRow,
|
||||||
|
right: colIndex === frame.endCol,
|
||||||
|
bottom: rowIndex === frame.endRow,
|
||||||
|
left: colIndex === frame.startCol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单元格在选区外框上的边:相邻格不在任何 frame 内的那一侧即为外框边。
|
||||||
|
* 不在任何 frame 内时返回 null。单 frame(框选)走边界比较,避免四次邻格探测。 */
|
||||||
|
export function dataGridSelectionEdgeFlags(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): DataGridSelectionEdgeFlags | null {
|
||||||
|
if (frames.length === 0) return null;
|
||||||
|
if (frames.length === 1) return edgeFlagsForFrame(frames[0]!, rowIndex, colIndex);
|
||||||
|
if (!dataGridFrameContainsCell(frames, rowIndex, colIndex)) return null;
|
||||||
|
return {
|
||||||
|
top: !dataGridFrameContainsCell(frames, rowIndex - 1, colIndex),
|
||||||
|
right: !dataGridFrameContainsCell(frames, rowIndex, colIndex + 1),
|
||||||
|
bottom: !dataGridFrameContainsCell(frames, rowIndex + 1, colIndex),
|
||||||
|
left: !dataGridFrameContainsCell(frames, rowIndex, colIndex - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DOM 用:返回外框边 bitmask(0 = 不在外框上,或不需要外框)。仅多格 frame 的边界格非 0。 */
|
||||||
|
export function dataGridSelectionEdgeMask(frames: readonly CellSelectionRange[], rowIndex: number, colIndex: number): DataGridSelectionEdgeMask {
|
||||||
|
if (frames.length === 0) return 0;
|
||||||
|
if (frames.length === 1) {
|
||||||
|
const frame = frames[0]!;
|
||||||
|
if (!dataGridFrameIsMultiCell(frame)) return 0;
|
||||||
|
const flags = edgeFlagsForFrame(frame, rowIndex, colIndex);
|
||||||
|
return flags ? dataGridSelectionEdgeMaskFromFlags(flags) : 0;
|
||||||
|
}
|
||||||
|
const frame = dataGridFrameAtCell(frames, rowIndex, colIndex);
|
||||||
|
if (!frame || !dataGridFrameIsMultiCell(frame)) return 0;
|
||||||
|
return dataGridSelectionEdgeMaskFromFlags({
|
||||||
|
top: !dataGridFrameContainsCell(frames, rowIndex - 1, colIndex),
|
||||||
|
right: !dataGridFrameContainsCell(frames, rowIndex, colIndex + 1),
|
||||||
|
bottom: !dataGridFrameContainsCell(frames, rowIndex + 1, colIndex),
|
||||||
|
left: !dataGridFrameContainsCell(frames, rowIndex, colIndex - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "vitest";
|
||||||
|
import {
|
||||||
|
dataGridFrameContainsCell,
|
||||||
|
dataGridFrameCoversRow,
|
||||||
|
dataGridSelectionEdgeFlags,
|
||||||
|
dataGridSelectionEdgeMask,
|
||||||
|
dataGridSelectionFrameKindAtCell,
|
||||||
|
dataGridSelectionUsesOuterFrame,
|
||||||
|
resolveDataGridSelectionFrames,
|
||||||
|
} from "../../apps/desktop/src/lib/dataGrid/dataGridSelectionFrames.ts";
|
||||||
|
|
||||||
|
test("rectangular range selection resolves to a single frame", () => {
|
||||||
|
const result = resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: 0,
|
||||||
|
hasColumnSelection: false,
|
||||||
|
selectedColumnIndexes: new Set(),
|
||||||
|
selectedRange: { startRow: 2, endRow: 5, startCol: 1, endCol: 3 },
|
||||||
|
rowCount: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.sparse, false);
|
||||||
|
assert.deepEqual(result.frames, [{ startRow: 2, endRow: 5, startCol: 1, endCol: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select-all resolves to one full-grid frame", () => {
|
||||||
|
const result = resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: 0,
|
||||||
|
hasColumnSelection: false,
|
||||||
|
selectedColumnIndexes: new Set(),
|
||||||
|
selectedRange: { startRow: 0, endRow: 99, startCol: 0, endCol: 7 },
|
||||||
|
rowCount: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.sparse, false);
|
||||||
|
assert.deepEqual(result.frames, [{ startRow: 0, endRow: 99, startCol: 0, endCol: 7 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sparse ctrl-click cells stay in per-cell border mode", () => {
|
||||||
|
const result = resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: 3,
|
||||||
|
hasColumnSelection: false,
|
||||||
|
selectedColumnIndexes: new Set(),
|
||||||
|
selectedRange: { startRow: 0, endRow: 2, startCol: 0, endCol: 2 },
|
||||||
|
rowCount: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.sparse, true);
|
||||||
|
assert.deepEqual(result.frames, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("column selection groups contiguous columns into one frame per run", () => {
|
||||||
|
const result = resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: 0,
|
||||||
|
hasColumnSelection: true,
|
||||||
|
selectedColumnIndexes: new Set([4, 1, 2, 3, 6]),
|
||||||
|
selectedRange: null,
|
||||||
|
rowCount: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.sparse, false);
|
||||||
|
assert.deepEqual(result.frames, [
|
||||||
|
{ startRow: 0, endRow: 49, startCol: 1, endCol: 4 },
|
||||||
|
{ startRow: 0, endRow: 49, startCol: 6, endCol: 6 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no selection resolves to no frames and not sparse", () => {
|
||||||
|
const result = resolveDataGridSelectionFrames({
|
||||||
|
sparseCellCount: 0,
|
||||||
|
hasColumnSelection: false,
|
||||||
|
selectedColumnIndexes: new Set(),
|
||||||
|
selectedRange: null,
|
||||||
|
rowCount: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.sparse, false);
|
||||||
|
assert.deepEqual(result.frames, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edge flags mark only the outer boundary of a frame", () => {
|
||||||
|
const frames = [{ startRow: 2, endRow: 4, startCol: 1, endCol: 3 }];
|
||||||
|
|
||||||
|
// 内部格:四边都不是外框
|
||||||
|
assert.deepEqual(dataGridSelectionEdgeFlags(frames, 3, 2), { top: false, right: false, bottom: false, left: false });
|
||||||
|
// 左上角
|
||||||
|
assert.deepEqual(dataGridSelectionEdgeFlags(frames, 2, 1), { top: true, right: false, bottom: false, left: true });
|
||||||
|
// 右下角
|
||||||
|
assert.deepEqual(dataGridSelectionEdgeFlags(frames, 4, 3), { top: false, right: true, bottom: true, left: false });
|
||||||
|
// 上边缘中间格
|
||||||
|
assert.deepEqual(dataGridSelectionEdgeFlags(frames, 2, 2), { top: true, right: false, bottom: false, left: false });
|
||||||
|
// 选区外
|
||||||
|
assert.equal(dataGridSelectionEdgeFlags(frames, 0, 0), null);
|
||||||
|
assert.equal(dataGridSelectionEdgeFlags(frames, 5, 2), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single cell frame is its own boundary on all sides", () => {
|
||||||
|
const frames = [{ startRow: 7, endRow: 7, startCol: 2, endCol: 2 }];
|
||||||
|
|
||||||
|
assert.deepEqual(dataGridSelectionEdgeFlags(frames, 7, 2), { top: true, right: true, bottom: true, left: true });
|
||||||
|
assert.equal(dataGridFrameContainsCell(frames, 7, 2), true);
|
||||||
|
assert.equal(dataGridFrameContainsCell(frames, 7, 3), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("frame kind distinguishes single-cell borders from range inversion", () => {
|
||||||
|
const single = [{ startRow: 7, endRow: 7, startCol: 2, endCol: 2 }];
|
||||||
|
const range = [{ startRow: 0, endRow: 9, startCol: 1, endCol: 3 }];
|
||||||
|
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell(single, 7, 2), "single");
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell(single, 7, 3), null);
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell(range, 0, 1), "range");
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell(range, 5, 3), "range");
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell(range, 5, 4), null);
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell([], 5, 3), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one-row-wide or one-column-wide frames still count as range", () => {
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell([{ startRow: 3, endRow: 3, startCol: 0, endCol: 2 }], 3, 1), "range");
|
||||||
|
assert.equal(dataGridSelectionFrameKindAtCell([{ startRow: 0, endRow: 5, startCol: 4, endCol: 4 }], 2, 4), "range");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("frame coverage drives row-number highlight", () => {
|
||||||
|
const frames = [
|
||||||
|
{ startRow: 2, endRow: 4, startCol: 1, endCol: 3 },
|
||||||
|
{ startRow: 7, endRow: 9, startCol: 6, endCol: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(dataGridFrameCoversRow(frames, 2), true);
|
||||||
|
assert.equal(dataGridFrameCoversRow(frames, 9), true);
|
||||||
|
assert.equal(dataGridFrameCoversRow(frames, 5), false);
|
||||||
|
assert.equal(dataGridFrameCoversRow([], 2), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outer-frame mode is true only when a multi-cell frame exists", () => {
|
||||||
|
assert.equal(dataGridSelectionUsesOuterFrame([{ startRow: 0, endRow: 2, startCol: 1, endCol: 3 }]), true);
|
||||||
|
assert.equal(dataGridSelectionUsesOuterFrame([{ startRow: 7, endRow: 7, startCol: 2, endCol: 2 }]), false);
|
||||||
|
assert.equal(dataGridSelectionUsesOuterFrame([]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edge mask encodes outer borders for CSS class selection", () => {
|
||||||
|
const frames = [{ startRow: 2, endRow: 4, startCol: 1, endCol: 3 }];
|
||||||
|
|
||||||
|
// interior → 0
|
||||||
|
assert.equal(dataGridSelectionEdgeMask(frames, 3, 2), 0);
|
||||||
|
// top-left: top|left = 1|8 = 9
|
||||||
|
assert.equal(dataGridSelectionEdgeMask(frames, 2, 1), 9);
|
||||||
|
// bottom-right: right|bottom = 2|4 = 6
|
||||||
|
assert.equal(dataGridSelectionEdgeMask(frames, 4, 3), 6);
|
||||||
|
// single-cell frame never paints outer-frame classes
|
||||||
|
assert.equal(dataGridSelectionEdgeMask([{ startRow: 7, endRow: 7, startCol: 2, endCol: 2 }], 7, 2), 0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user