perf(desktop): optimize DataGrid wide table rendering

This commit is contained in:
t8y2 2026-05-31 01:46:54 +08:00
parent c24e3a5086
commit c0b231374d
2 changed files with 324 additions and 113 deletions

View File

@ -158,7 +158,7 @@ import {
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
import { useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
@ -468,7 +468,7 @@ function localFilterKey(value: CellValue): string {
}
function localFilterLabel(value: CellValue, columnIndex: number): string {
return value === null ? "NULL" : formatCell(value, columnIndex);
return value === null ? "NULL" : formatCellCached(value, columnIndex);
}
function localFilterActive(colIdx: number): boolean {
@ -1424,11 +1424,16 @@ function scrollToTableInfoColumn(columnName: string) {
nextTick(() => {
const visibleColIdx = visibleColumnIndexes.value.indexOf(columnIndex);
const scroller = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
const headerCell = headerRef.value?.querySelector<HTMLElement>(`[data-grid-column-index="${columnIndex}"]`);
if (visibleColIdx < 0 || !scroller || !headerCell) return;
if (visibleColIdx < 0 || !scroller) return;
const targetLeft = Math.max(0, headerCell.offsetLeft - scroller.clientWidth / 2 + headerCell.offsetWidth / 2);
const targetLeft = Math.max(
0,
columnContentOffsetLeft(visibleColIdx) -
scroller.clientWidth / 2 +
(renderedColumnWidths.value[visibleColIdx] ?? 0) / 2,
);
scroller.scrollLeft = targetLeft;
updateGridHorizontalViewport(scroller);
if (headerRef.value) {
headerRef.value.scrollLeft = scroller.scrollLeft;
}
@ -1436,31 +1441,117 @@ function scrollToTableInfoColumn(columnName: string) {
}
// --- Column resize composable ---
const { initColumnWidths, onResizeStart, autoFitColumn, columnVars, getIsResizing } = useDataGridColumnResize({
columns: visibleColumns,
sourceRows: computed(() => props.result.rows),
columnIndexes: visibleColumnIndexes,
gridRef,
});
const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, columnVars, getIsResizing } =
useDataGridColumnResize({
columns: visibleColumns,
sourceRows: computed(() => props.result.rows),
columnIndexes: visibleColumnIndexes,
gridRef,
});
const gridStyle = computed(() => ({
...columnVars.value,
"--header-total-w": dataGridHeaderContentWidth("var(--total-w)", gridScrollbarGutter.value),
"--grid-scrollbar-gutter": `${gridScrollbarGutter.value}px`,
}));
const gridHorizontalScrollLeft = ref(0);
const gridViewportWidth = ref(0);
function updateGridHorizontalViewport(element: HTMLElement) {
gridHorizontalScrollLeft.value = element.scrollLeft;
gridViewportWidth.value = element.clientWidth;
}
function updateGridScrollbarGutter(element: HTMLElement) {
gridScrollbarGutter.value = scrollbarGutterWidth(element);
}
function syncHeaderScroll(e: Event) {
updateGridScrollbarGutter(e.target as HTMLElement);
const target = e.target as HTMLElement;
updateGridScrollbarGutter(target);
updateGridHorizontalViewport(target);
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
headerRef.value.scrollLeft = target.scrollLeft;
}
}
const HORIZONTAL_COLUMN_BUFFER_PX = 900;
interface RenderedGridColumn {
visibleColIdx: number;
actualColIdx: number;
name: string;
}
const horizontalColumnWindow = computed(() => {
const widths = renderedColumnWidths.value;
const totalColumns = visibleColumnIndexes.value.length;
if (totalColumns === 0 || widths.length === 0) {
return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 };
}
const viewportStart = Math.max(
0,
gridHorizontalScrollLeft.value - DATA_GRID_ROW_NUM_WIDTH - HORIZONTAL_COLUMN_BUFFER_PX,
);
const viewportEnd =
Math.max(gridViewportWidth.value, 1) +
Math.max(0, gridHorizontalScrollLeft.value - DATA_GRID_ROW_NUM_WIDTH) +
HORIZONTAL_COLUMN_BUFFER_PX;
let start = 0;
let offset = 0;
while (start < totalColumns && offset + (widths[start] ?? 0) < viewportStart) {
offset += widths[start] ?? 0;
start++;
}
let end = start;
let visibleWidth = offset;
while (end < totalColumns && visibleWidth < viewportEnd) {
visibleWidth += widths[end] ?? 0;
end++;
}
const columnsWidth = widths.reduce((sum, width) => sum + width, 0);
return {
start,
end,
beforeWidth: offset,
afterWidth: Math.max(0, columnsWidth - visibleWidth),
};
});
const renderedGridColumns = computed<RenderedGridColumn[]>(() => {
const window = horizontalColumnWindow.value;
const columns: RenderedGridColumn[] = [];
for (let visibleColIdx = window.start; visibleColIdx < window.end; visibleColIdx++) {
const actualColIdx = visibleColumnIndexes.value[visibleColIdx];
if (actualColIdx === undefined) continue;
columns.push({
visibleColIdx,
actualColIdx,
name: props.result.columns[actualColIdx] ?? "",
});
}
return columns;
});
function renderedColumnStyle(visibleColIdx: number) {
return { width: `var(--col-w-${visibleColIdx})` };
}
function columnContentOffsetLeft(visibleColIdx: number): number {
const widths = renderedColumnWidths.value;
let offset = DATA_GRID_ROW_NUM_WIDTH;
for (let i = 0; i < visibleColIdx; i++) {
offset += widths[i] ?? 0;
}
return offset;
}
let scrollingTimer = 0;
const isScrolling = ref(false);
function onScrollerScroll(e: Event) {
syncHeaderScroll(e);
function markGridScrolling() {
if (!isScrolling.value) isScrolling.value = true;
clearTimeout(scrollingTimer);
scrollingTimer = window.setTimeout(() => {
@ -1468,8 +1559,26 @@ function onScrollerScroll(e: Event) {
}, 120);
}
function onScrollerScroll(e: Event) {
syncHeaderScroll(e);
markGridScrolling();
}
watch(isScrolling, (scrolling) => {
if (scrolling) hoveredDetailCell.value = null;
});
initColumnWidths();
watch(() => visibleColumns.value.length, initColumnWidths);
watch(
() => [visibleColumnCount.value, renderedColumnWidths.value.length],
() => {
nextTick(() => {
const scrollerEl = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (scrollerEl) updateGridHorizontalViewport(scrollerEl);
});
},
);
const localFilterScopeKey = computed(() =>
[
props.connectionId ?? "",
@ -1815,7 +1924,9 @@ const sortedRows = computed(() => {
const rows = props.result.rows;
indices = indices.filter((sourceIndex) => {
const data = rows[sourceIndex];
return data.some((cell, columnIndex) => cell !== null && formatCell(cell, columnIndex).toLowerCase().includes(q));
return data.some(
(cell, columnIndex) => cell !== null && formatCellCached(cell, columnIndex).toLowerCase().includes(q),
);
});
}
return indices;
@ -1855,7 +1966,10 @@ watch(
() => {
nextTick(() => {
const scrollerEl = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (scrollerEl) updateGridScrollbarGutter(scrollerEl);
if (scrollerEl) {
updateGridScrollbarGutter(scrollerEl);
updateGridHorizontalViewport(scrollerEl);
}
});
},
);
@ -1873,7 +1987,7 @@ const searchMatches = computed<SearchMatch[]>(() => {
for (let r = 0; r < items.length; r++) {
const data = items[r].data;
for (let c = 0; c < data.length; c++) {
if (data[c] !== null && formatCell(data[c], c).toLowerCase().includes(q)) {
if (data[c] !== null && formatCellCached(data[c], c).toLowerCase().includes(q)) {
matches.push({ displayRow: r, col: c });
}
}
@ -1894,10 +2008,12 @@ watch(searchMatches, (matches) => {
});
function cellIsSearchMatch(displayRow: number, col: number): boolean {
if (isScrolling.value) return false;
return searchMatchSet.value.has(`${displayRow}:${col}`);
}
function cellIsCurrentMatch(displayRow: number, col: number): boolean {
if (isScrolling.value) return false;
const idx = currentMatchIndex.value;
if (idx < 0 || idx >= searchMatches.value.length) return false;
const m = searchMatches.value[idx];
@ -1915,6 +2031,8 @@ function scrollToCurrentMatch() {
const idx = currentMatchIndex.value;
if (idx < 0 || idx >= searchMatches.value.length) return;
const match = searchMatches.value[idx];
const visibleColIdx = visibleColumnIndexes.value.indexOf(match.col);
if (visibleColIdx >= 0) scrollGridColumnIntoView(visibleColIdx);
const scrollEl = gridRef.value;
if (!scrollEl) return;
const rowEl = scrollEl.querySelector(`[data-row-index="${match.displayRow}"]`) as HTMLElement | null;
@ -2014,17 +2132,19 @@ const multiRowCount = computed(() => {
const isMultiRow = computed(() => multiRowCount.value > 1);
function onCellMouseenter(rowIndex: number, visibleColIdx: number, actualColIdx: number) {
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
if (!isScrolling.value) hoveredDetailCell.value = { rowIndex, col: actualColIdx };
extendCellSelection(rowIndex, visibleColIdx);
}
function onCellMouseleave(rowIndex: number, actualColIdx: number) {
if (isScrolling.value) return;
if (hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) {
hoveredDetailCell.value = null;
}
}
function cellDetailButtonVisible(rowIndex: number, actualColIdx: number) {
if (isScrolling.value) return false;
return (
(hoveredDetailCell.value?.rowIndex === rowIndex && hoveredDetailCell.value.col === actualColIdx) ||
(showCellDetail.value && detailCell.value?.rowIndex === rowIndex && detailCell.value.col === actualColIdx)
@ -2089,7 +2209,7 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai
columnIndex,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
displayValue: (value, index) => formatCellCached(value, index),
isEditable: canEditCellItem(item, columnIndex),
});
}
@ -2116,7 +2236,7 @@ const rowDetail = computed(() => {
columnIndexes: displayableColumnIndexes.value,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
displayValue: (value, index) => formatCellCached(value, index),
isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex),
});
});
@ -2135,7 +2255,7 @@ const columnDetail = computed(() => {
columnIndex,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
displayValue: (value, index) => formatCellCached(value, index),
});
});
@ -2594,13 +2714,76 @@ async function applyWhereFilter() {
}
const CELL_DISPLAY_MAX_LENGTH = 256;
const CELL_FORMAT_CACHE_LIMIT = 20_000;
const CELL_FORMAT_CACHE_PRUNE_COUNT = 5_000;
const resolvedColumnFormatters = computed(() =>
props.result.columns.map((_, columnIndex) => columnFormatter(columnIndex)),
);
const columnFormatterSignatures = computed(() => resolvedColumnFormatters.value.map(formatterSignature));
const primitiveCellFormatCache = new Map<string, string>();
let objectCellFormatCache = new WeakMap<object, Map<number, string>>();
function formatterSignature(formatter: ColumnFormatterConfig | undefined): string {
return formatter ? JSON.stringify(formatter) : "";
}
function clearCellFormatCache() {
primitiveCellFormatCache.clear();
objectCellFormatCache = new WeakMap<object, Map<number, string>>();
}
function rememberPrimitiveCellFormat(key: string, display: string): string {
primitiveCellFormatCache.set(key, display);
if (primitiveCellFormatCache.size > CELL_FORMAT_CACHE_LIMIT) {
let removed = 0;
for (const cacheKey of primitiveCellFormatCache.keys()) {
primitiveCellFormatCache.delete(cacheKey);
removed++;
if (removed >= CELL_FORMAT_CACHE_PRUNE_COUNT) break;
}
}
return display;
}
function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string {
return `${columnIndex ?? -1}\u0000${typeof value}\u0000${String(value)}`;
}
function formatCell(value: CellValue, columnIndex?: number): string {
const formatter = columnIndex === undefined ? undefined : columnFormatter(columnIndex);
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
const s = applyColumnFormatter(value, formatter);
return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s;
}
function formatCellCached(value: CellValue, columnIndex?: number): string {
if (value !== null && typeof (value as unknown) === "object") {
const objectValue = value as unknown as object;
const cacheColumn = columnIndex ?? -1;
const columnCache = objectCellFormatCache.get(objectValue);
const cached = columnCache?.get(cacheColumn);
if (cached !== undefined) return cached;
const display = formatCell(value, columnIndex);
if (columnCache) {
columnCache.set(cacheColumn, display);
} else {
objectCellFormatCache.set(objectValue, new Map([[cacheColumn, display]]));
}
return display;
}
const key = primitiveCellFormatKey(value, columnIndex);
const cached = primitiveCellFormatCache.get(key);
if (cached !== undefined) return cached;
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex));
}
watch(
() => [props.result.columns.join("\u0000"), columnFormatterSignatures.value.join("\u0000")],
clearCellFormatCache,
);
function quoteIdent(name: string): string {
return quoteTableIdentifier(props.databaseType, name);
}
@ -2801,6 +2984,7 @@ function transposeCellIsSelected(rowIndex: number, actualColIdx: number) {
}
function onTransposeCellMouseenter(rowIndex: number, actualColIdx: number) {
if (isScrolling.value) return;
hoveredDetailCell.value = { rowIndex, col: actualColIdx };
}
@ -2914,12 +3098,33 @@ function currentSelectedCellPosition() {
function scrollCellIntoView(rowIndex: number, colIndex: number) {
nextTick(() => {
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"]`);
const cellEl = rowEl?.querySelector<HTMLElement>(`[data-visible-col-index="${colIndex}"]`);
(cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" });
scrollGridColumnIntoView(colIndex);
nextTick(() => {
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"]`);
const cellEl = rowEl?.querySelector<HTMLElement>(`[data-visible-col-index="${colIndex}"]`);
(cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
});
}
function scrollGridColumnIntoView(visibleColIdx: number) {
const scroller = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (!scroller) return;
const colLeft = columnContentOffsetLeft(visibleColIdx);
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH;
const viewportRight = scroller.scrollLeft + scroller.clientWidth;
if (colLeft < viewportLeft) {
scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH);
} else if (colRight > viewportRight) {
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
}
updateGridHorizontalViewport(scroller);
if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
}
function scrollGridRowIntoView(rowIndex: number) {
const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
nextTick(() => {
@ -3278,7 +3483,7 @@ const transposeRows = computed(() => {
recordIndexes: visibleTransposeRecordIndexes.value,
valueIndexes: visibleColumnIndexes.value,
typeByColumn: columnTypeMap.value,
displayValue: (value, _column, index) => formatCell(value, visibleColumnIndexes.value[index]),
displayValue: (value, _column, index) => formatCellCached(value, visibleColumnIndexes.value[index]),
});
});
const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0);
@ -3301,6 +3506,7 @@ function updateTransposeViewport() {
function onTransposeScroll() {
updateTransposeViewport();
markGridScrolling();
}
function scrollTransposeRecordIntoView(rowIndex: number) {
@ -4706,6 +4912,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<RecycleScroller
ref="transposeScrollRef"
class="transpose-grid-scroller flex-1 min-h-0 overflow-auto overscroll-none bg-background"
:class="{ 'is-scrolling': isScrolling }"
:style="{
'--transpose-total-w': `${transposeTotalWidth}px`,
'--transpose-field-w': `${transposePinnedWidth}px`,
@ -4799,8 +5006,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
!displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] &&
!transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'cursor-text': canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex),
'cursor-text': !isScrolling && canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex),
'hover:bg-accent/50':
!isScrolling &&
canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) &&
!transposeRecordUsesSelectionVisual(cell.recordIndex) &&
!transposeRecordUsesActiveHighlight(cell.recordIndex) &&
@ -4874,50 +5082,51 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
#
</div>
<Tooltip v-for="(col, colIdx) in visibleColumns" :key="`${col}-${actualColumnIndex(colIdx)}`">
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<Tooltip v-for="col in renderedGridColumns" :key="`${col.name}-${col.actualColIdx}`">
<TooltipTrigger as-child>
<div
class="shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-accent/60 select-none relative overflow-hidden"
:class="{
'bg-primary/15 ring-1 ring-inset ring-primary/40':
highlightedColumnIndex === actualColumnIndex(colIdx) || columnIsSelected(colIdx),
highlightedColumnIndex === col.actualColIdx || columnIsSelected(col.visibleColIdx),
}"
:style="{ width: `var(--col-w-${colIdx})` }"
:data-grid-column-index="actualColumnIndex(colIdx)"
@click="selectColumn(colIdx, $event)"
@contextmenu="onHeaderContext(col, actualColumnIndex(colIdx))"
:style="renderedColumnStyle(col.visibleColIdx)"
:data-grid-column-index="col.actualColIdx"
@click="selectColumn(col.visibleColIdx, $event)"
@contextmenu="onHeaderContext(col.name, col.actualColIdx)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
<span class="min-w-0 truncate leading-4">
{{ col }}
{{ col.name }}
</span>
<span
v-if="headerColumnComment(col)"
v-if="headerColumnComment(col.name)"
class="min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground"
:title="headerColumnComment(col)"
:title="headerColumnComment(col.name)"
>
{{ headerColumnComment(col) }}
{{ headerColumnComment(col.name) }}
</span>
</span>
<button
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
sortCol === col && sortColIndex === actualColumnIndex(colIdx)
sortCol === col.name && sortColIndex === col.actualColIdx
? 'text-primary opacity-100'
: 'opacity-80'
"
:title="t('grid.sort')"
@click.stop="toggleSort(col, actualColumnIndex(colIdx))"
@click.stop="toggleSort(col.name, col.actualColIdx)"
>
<ArrowUp
v-if="sortCol === col && sortColIndex === actualColumnIndex(colIdx) && sortDir === 'asc'"
v-if="sortCol === col.name && sortColIndex === col.actualColIdx && sortDir === 'asc'"
class="h-3 w-3 shrink-0"
/>
<ArrowDown
v-else-if="
sortCol === col && sortColIndex === actualColumnIndex(colIdx) && sortDir === 'desc'
sortCol === col.name && sortColIndex === col.actualColIdx && sortDir === 'desc'
"
class="h-3 w-3 shrink-0"
/>
@ -4925,10 +5134,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</button>
<DropdownMenu
v-if="compactColumnHeaderActions"
:open="headerActionMenuOpenColumn === actualColumnIndex(colIdx)"
:open="headerActionMenuOpenColumn === col.actualColIdx"
@update:open="
(value: boolean) =>
(headerActionMenuOpenColumn = value ? actualColumnIndex(colIdx) : null)
(value: boolean) => (headerActionMenuOpenColumn = value ? col.actualColIdx : null)
"
>
<DropdownMenuTrigger as-child>
@ -4936,8 +5144,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
columnHasFormatter(actualColumnIndex(colIdx)) ||
localFilterActive(actualColumnIndex(colIdx))
columnHasFormatter(col.actualColIdx) || localFilterActive(col.actualColIdx)
? 'text-primary opacity-90'
: 'opacity-80'
"
@ -4955,15 +5162,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<DropdownMenuItem
class="gap-1 px-1.5 py-0.5 text-xs"
:disabled="!formatterKeyForColumn(col)"
@select.prevent="openCompactColumnFormatter(actualColumnIndex(colIdx))"
:disabled="!formatterKeyForColumn(col.name)"
@select.prevent="openCompactColumnFormatter(col.actualColIdx)"
>
<Code2 class="h-3 w-3" />
{{ t("grid.columnFormatter") }}
</DropdownMenuItem>
<DropdownMenuItem
class="gap-1 px-1.5 py-0.5 text-xs"
@select.prevent="openCompactLocalFilter(actualColumnIndex(colIdx))"
@select.prevent="openCompactLocalFilter(col.actualColIdx)"
>
<Filter class="h-3 w-3" />
{{ t("grid.localFilter") }}
@ -4971,10 +5178,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</DropdownMenuContent>
</DropdownMenu>
<Popover
:open="formatterOpenColumn === actualColumnIndex(colIdx)"
@update:open="
(value: boolean) => handleColumnFormatterOpenChange(value, actualColumnIndex(colIdx))
"
:open="formatterOpenColumn === col.actualColIdx"
@update:open="(value: boolean) => handleColumnFormatterOpenChange(value, col.actualColIdx)"
>
<PopoverAnchor v-if="compactColumnHeaderActions" as-child>
<span class="pointer-events-none absolute right-3 top-1/2 h-px w-px -translate-y-1/2" />
@ -4984,11 +5189,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
columnHasFormatter(actualColumnIndex(colIdx))
? 'text-primary opacity-100'
: 'opacity-80'
columnHasFormatter(col.actualColIdx) ? 'text-primary opacity-100' : 'opacity-80'
"
:disabled="!formatterKeyForColumn(col)"
:disabled="!formatterKeyForColumn(col.name)"
:title="t('grid.columnFormatter')"
@click.stop
>
@ -5004,7 +5207,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">
{{ t("grid.columnFormatterFor", { column: col }) }}
{{ t("grid.columnFormatterFor", { column: col.name }) }}
</div>
<div class="mt-0.5 text-[11px] text-muted-foreground">
{{ t("grid.columnFormatterHint") }}
@ -5153,7 +5356,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<div class="max-h-40 overflow-auto rounded border bg-muted/20">
<div
v-for="row in formatterPreviewRows(actualColumnIndex(colIdx))"
v-for="row in formatterPreviewRows(col.actualColIdx)"
:key="row.index"
class="grid grid-cols-[2rem_minmax(0,1fr)_minmax(0,1fr)] gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0"
>
@ -5170,8 +5373,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="!columnHasFormatter(actualColumnIndex(colIdx))"
@click="clearColumnFormatter(actualColumnIndex(colIdx))"
:disabled="!columnHasFormatter(col.actualColIdx)"
@click="clearColumnFormatter(col.actualColIdx)"
>
{{ t("grid.clearFormatter") }}
</Button>
@ -5188,7 +5391,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
size="sm"
class="h-7 px-2 text-xs"
:disabled="!formatterDraftIsSavable()"
@click="saveColumnFormatter(actualColumnIndex(colIdx))"
@click="saveColumnFormatter(col.actualColIdx)"
>
{{ t("grid.saveFormatter") }}
</Button>
@ -5197,10 +5400,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</PopoverContent>
</Popover>
<Popover
:open="localFilterOpenColumn === actualColumnIndex(colIdx)"
@update:open="
(value: boolean) => handleLocalFilterOpenChange(value, actualColumnIndex(colIdx))
"
:open="localFilterOpenColumn === col.actualColIdx"
@update:open="(value: boolean) => handleLocalFilterOpenChange(value, col.actualColIdx)"
>
<PopoverAnchor v-if="compactColumnHeaderActions" as-child>
<span class="pointer-events-none absolute right-3 top-1/2 h-px w-px -translate-y-1/2" />
@ -5209,11 +5410,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<button
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
localFilterActive(actualColumnIndex(colIdx))
? 'text-primary opacity-100'
: 'opacity-80'
"
:class="localFilterActive(col.actualColIdx) ? 'text-primary opacity-100' : 'opacity-80'"
:title="t('grid.localFilter')"
@click.stop
>
@ -5228,7 +5425,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@keydown.stop
>
<div class="border-b bg-muted/40 px-2 py-1.5 text-center text-xs font-semibold">
{{ t("grid.localFilterFor", { column: col }) }}
{{ t("grid.localFilterFor", { column: col.name }) }}
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@ -5320,7 +5517,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
@click="clearLocalFilter(actualColumnIndex(colIdx))"
@click="clearLocalFilter(col.actualColIdx)"
>
{{ t("grid.clearFilter") }}
</Button>
@ -5343,8 +5540,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</span>
<div
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
@mousedown.stop="onResizeStart(colIdx, $event)"
@dblclick.stop="autoFitColumn(colIdx)"
@mousedown.stop="onResizeStart(col.visibleColIdx, $event)"
@dblclick.stop="autoFitColumn(col.visibleColIdx)"
/>
</div>
</TooltipTrigger>
@ -5354,26 +5551,29 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<span class="text-background/70">{{ t("grid.columnName") }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ col }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ col.name }}</span>
<button
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10"
:title="t('grid.copyColumnName')"
@click.stop="copyText(col)"
@click.stop="copyText(col.name)"
>
<Copy class="h-3 w-3" />
</button>
</span>
<template v-if="columnTypeMap.get(col)">
<template v-if="columnTypeMap.get(col.name)">
<span class="text-background/70">{{ t("grid.columnType") }}</span>
<span :class="typeColorClass(columnTypeMap.get(col)!)">{{ columnTypeMap.get(col) }}</span>
<span :class="typeColorClass(columnTypeMap.get(col.name)!)">{{
columnTypeMap.get(col.name)
}}</span>
</template>
<template v-if="columnCommentMap.get(col)">
<template v-if="columnCommentMap.get(col.name)">
<span class="text-background/70">{{ t("grid.columnComment") }}</span>
<span>{{ columnCommentMap.get(col) }}</span>
<span>{{ columnCommentMap.get(col.name) }}</span>
</template>
</TooltipContent>
</Tooltip>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.afterWidth}px` }" />
<div
v-if="gridScrollbarGutter > 0"
class="shrink-0 border-l border-border"
@ -5456,47 +5656,51 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
{{ item.displayIndex + 1 }}
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<div
v-for="(actualColIdx, visibleColIdx) in visibleColumnIndexes"
:key="actualColIdx"
v-for="col in renderedGridColumns"
:key="col.actualColIdx"
class="group/cell shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative select-none"
:style="{ width: `var(--col-w-${visibleColIdx})` }"
:style="renderedColumnStyle(col.visibleColIdx)"
:class="{
'text-muted-foreground italic': isNull(item.data[actualColIdx]),
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[actualColIdx],
'text-muted-foreground italic': isNull(item.data[col.actualColIdx]),
'bg-yellow-500/10 cell-dirty': item.isDirtyCol[col.actualColIdx],
'cell-selected':
cellIsSelected(item.displayIndex, visibleColIdx) && !item.isDirtyCol[actualColIdx],
cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
'cell-selected-dirty':
cellIsSelected(item.displayIndex, visibleColIdx) && item.isDirtyCol[actualColIdx],
cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
'row-cell-selected':
rowCellsUseSelectionVisual(item.id) &&
!cellIsSelected(item.displayIndex, visibleColIdx) &&
!item.isDirtyCol[actualColIdx],
!cellIsSelected(item.displayIndex, col.visibleColIdx) &&
!item.isDirtyCol[col.actualColIdx],
'row-cell-selected-dirty':
rowCellsUseSelectionVisual(item.id) &&
!cellIsSelected(item.displayIndex, visibleColIdx) &&
item.isDirtyCol[actualColIdx],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, actualColIdx),
!cellIsSelected(item.displayIndex, col.visibleColIdx) &&
item.isDirtyCol[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,
actualColIdx,
col.actualColIdx,
),
'tabular-nums': typeof item.data[actualColIdx] === 'number',
'cursor-text hover:bg-accent/50': canEditCellItem(item, actualColIdx),
'tabular-nums': typeof item.data[col.actualColIdx] === 'number',
'cursor-text hover:bg-accent/50': !isScrolling && canEditCellItem(item, col.actualColIdx),
'line-through': item.isDeleted,
}"
@mousedown="handleDataCellMousedown(item.displayIndex, visibleColIdx, item.id, $event)"
@mouseenter="onCellMouseenter(item.displayIndex, visibleColIdx, actualColIdx)"
@mouseleave="onCellMouseleave(item.displayIndex, actualColIdx)"
@dblclick="canEditCellItem(item, actualColIdx) && startEdit(item.id, actualColIdx)"
:data-visible-col-index="visibleColIdx"
@contextmenu="onCellContext(item.id, item.displayIndex, actualColIdx, visibleColIdx)"
@mousedown="handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event)"
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
@dblclick="canEditCellItem(item, col.actualColIdx) && startEdit(item.id, col.actualColIdx)"
:data-visible-col-index="col.visibleColIdx"
@contextmenu="onCellContext(item.id, item.displayIndex, col.actualColIdx, col.visibleColIdx)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === actualColIdx">
<template v-if="editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx">
<TemporalCellEditor
v-if="temporalEditorKindForColumn(actualColIdx)"
v-if="temporalEditorKindForColumn(col.actualColIdx)"
v-model="editValue"
:kind="temporalEditorKindForColumn(actualColIdx)!"
:kind="temporalEditorKindForColumn(col.actualColIdx)!"
@cancel="cancelEdit"
@commit="commitGridEdit"
/>
@ -5513,18 +5717,21 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
/>
</template>
<template v-else>
{{ formatCell(item.data[actualColIdx], actualColIdx) }}
{{ formatCellCached(item.data[col.actualColIdx], col.actualColIdx) }}
<button
v-if="cellDetailButtonVisible(item.displayIndex, actualColIdx)"
v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)"
class="absolute right-0.5 top-0.5 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.cellDetails')"
@mousedown.stop
@click.stop="showCellDetailsForVisibleCell(item.displayIndex, visibleColIdx, actualColIdx)"
@click.stop="
showCellDetailsForVisibleCell(item.displayIndex, col.visibleColIdx, col.actualColIdx)
"
>
<Info class="h-3 w-3" />
</button>
</template>
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.afterWidth}px` }" />
</div>
</template>
</RecycleScroller>
@ -6523,6 +6730,10 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
contain: layout style paint;
}
.transpose-grid-scroller.is-scrolling :deep(.vue-recycle-scroller__item-view) {
pointer-events: none;
}
.ddl-drawer-resizing {
transition: none;
}

View File

@ -8,7 +8,7 @@ import {
type CellValue = string | number | boolean | null;
const ROW_NUM_WIDTH = 48;
export const DATA_GRID_ROW_NUM_WIDTH = 48;
export interface UseDataGridColumnResizeOptions {
columns: ComputedRef<string[]>;
@ -80,21 +80,21 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
const widths = columnWidths.value;
if (widths.length === 0) return widths;
const extraWidth = Math.max(0, gridWidth.value - ROW_NUM_WIDTH - baseTotalWidth.value);
const extraWidth = Math.max(0, gridWidth.value - DATA_GRID_ROW_NUM_WIDTH - baseTotalWidth.value);
if (extraWidth === 0) return widths;
const extraPerColumn = extraWidth / widths.length;
return widths.map((width) => width + extraPerColumn);
});
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + ROW_NUM_WIDTH);
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + DATA_GRID_ROW_NUM_WIDTH);
const columnVars = computed(() => {
const vars: Record<string, string> = {};
renderedColumnWidths.value.forEach((w, i) => {
vars[`--col-w-${i}`] = `${w}px`;
});
vars["--row-num-w"] = `${ROW_NUM_WIDTH}px`;
vars["--row-num-w"] = `${DATA_GRID_ROW_NUM_WIDTH}px`;
vars["--total-w"] = `${totalWidth.value}px`;
return vars;
});