Merge branch 'dev/hybrid-canvas-grid'
# Conflicts: # apps/desktop/src/components/grid/DataGrid.vue # apps/desktop/src/stores/settingsStore.ts
This commit is contained in:
commit
df32a46080
10 files changed
+1180
-39
No files matched your search
@@ -4,7 +4,7 @@ const globalDdlOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, useSlots, watch, type Component } from "vue";
|
||||
import { computed, nextTick, onMounted, onUnmounted, useSlots, watch, type Component } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -143,6 +143,7 @@ import {
|
||||
isToggleTransposeShortcut,
|
||||
} from "@/lib/keyboardShortcuts";
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/canvasDataGridRenderer";
|
||||
import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGridSaveUi";
|
||||
import {
|
||||
appendColumnValueFilterCondition,
|
||||
@@ -317,6 +318,7 @@ const columnCommentMap = computed(() => {
|
||||
});
|
||||
const showColumnCommentsInHeader = computed(() => settingsStore.editorSettings.showColumnCommentsInHeader);
|
||||
const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.compactColumnHeaderActions);
|
||||
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
|
||||
|
||||
function headerColumnComment(column: string): string {
|
||||
if (!showColumnCommentsInHeader.value) return "";
|
||||
@@ -1508,7 +1510,7 @@ function scrollToTableInfoColumn(columnName: string) {
|
||||
}
|
||||
|
||||
// --- Column resize composable ---
|
||||
const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, columnVars, getIsResizing } =
|
||||
const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, totalWidth, columnVars, getIsResizing } =
|
||||
useDataGridColumnResize({
|
||||
columns: visibleColumns,
|
||||
sourceRows: computed(() => props.result.rows),
|
||||
@@ -1522,6 +1524,15 @@ const gridStyle = computed(() => ({
|
||||
}));
|
||||
const gridHorizontalScrollLeft = ref(0);
|
||||
const gridViewportWidth = ref(0);
|
||||
const renderedColumnOffsets = computed(() => {
|
||||
const widths = renderedColumnWidths.value;
|
||||
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
|
||||
offsets[0] = 0;
|
||||
for (let index = 0; index < widths.length; index++) {
|
||||
offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
|
||||
}
|
||||
return offsets;
|
||||
});
|
||||
|
||||
function updateGridHorizontalViewport(element: HTMLElement) {
|
||||
gridHorizontalScrollLeft.value = element.scrollLeft;
|
||||
@@ -1561,6 +1572,7 @@ interface RenderedGridColumn {
|
||||
|
||||
const horizontalColumnWindow = computed(() => {
|
||||
const widths = renderedColumnWidths.value;
|
||||
const offsets = renderedColumnOffsets.value;
|
||||
const totalColumns = visibleColumnIndexes.value.length;
|
||||
if (totalColumns === 0 || widths.length === 0) {
|
||||
return { start: 0, end: 0, beforeWidth: 0, afterWidth: 0 };
|
||||
@@ -1574,22 +1586,23 @@ const horizontalColumnWindow = computed(() => {
|
||||
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 low = 0;
|
||||
let high = totalColumns - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((offsets[mid + 1] ?? 0) < viewportStart) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const start = low;
|
||||
const offset = offsets[start] ?? 0;
|
||||
|
||||
let end = start;
|
||||
let visibleWidth = offset;
|
||||
while (end < totalColumns && visibleWidth < viewportEnd) {
|
||||
visibleWidth += widths[end] ?? 0;
|
||||
while (end < totalColumns && (offsets[end] ?? 0) < viewportEnd) {
|
||||
end++;
|
||||
}
|
||||
const visibleWidth = offsets[end] ?? offset;
|
||||
|
||||
const columnsWidth = widths.reduce((sum, width) => sum + width, 0);
|
||||
const columnsWidth = offsets[totalColumns] ?? 0;
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
@@ -1618,12 +1631,7 @@ function renderedColumnStyle(visibleColIdx: number) {
|
||||
}
|
||||
|
||||
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;
|
||||
return DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
|
||||
}
|
||||
|
||||
let scrollingTimer = 0;
|
||||
@@ -2125,8 +2133,15 @@ const searchMatchSet = computed(() => {
|
||||
return set;
|
||||
});
|
||||
|
||||
const currentSearchMatch = computed(() => {
|
||||
const idx = currentMatchIndex.value;
|
||||
if (idx < 0 || idx >= searchMatches.value.length) return null;
|
||||
return searchMatches.value[idx] ?? null;
|
||||
});
|
||||
|
||||
watch(searchMatches, (matches) => {
|
||||
currentMatchIndex.value = matches.length > 0 ? 0 : -1;
|
||||
if (matches.length > 0) nextTick(scrollToCurrentMatch);
|
||||
});
|
||||
|
||||
function cellIsSearchMatch(displayRow: number, col: number): boolean {
|
||||
@@ -2136,9 +2151,8 @@ function cellIsSearchMatch(displayRow: number, col: number): boolean {
|
||||
|
||||
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];
|
||||
const m = currentSearchMatch.value;
|
||||
if (!m) return false;
|
||||
return m.displayRow === displayRow && m.col === col;
|
||||
}
|
||||
|
||||
@@ -2157,6 +2171,17 @@ function scrollToCurrentMatch() {
|
||||
if (visibleColIdx >= 0) scrollGridColumnIntoView(visibleColIdx);
|
||||
const scrollEl = gridRef.value;
|
||||
if (!scrollEl) return;
|
||||
if (useCanvasGridRows.value) {
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!scroller) return;
|
||||
const targetTop = Math.max(
|
||||
0,
|
||||
match.displayRow * CANVAS_DATA_GRID_ROW_HEIGHT - (scroller.clientHeight - CANVAS_DATA_GRID_ROW_HEIGHT) / 2,
|
||||
);
|
||||
scroller.scrollTop = targetTop;
|
||||
syncCanvasViewport();
|
||||
return;
|
||||
}
|
||||
const rowEl = scrollEl.querySelector(`[data-row-index="${match.displayRow}"]`) as HTMLElement | null;
|
||||
if (rowEl) rowEl.scrollIntoView({ block: "center" });
|
||||
}
|
||||
@@ -2944,6 +2969,382 @@ function rowCellsUseSelectionVisual(rowId: number): boolean {
|
||||
return hasRowSelection.value && isRowSelected(rowId) && !hasCellSelection.value;
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
const canvasOverlayRef = ref<HTMLElement>();
|
||||
const canvasViewportWidth = ref(0);
|
||||
const canvasViewportHeight = ref(0);
|
||||
const canvasScrollTop = ref(0);
|
||||
const canvasHoverCell = ref<{ rowIndex: number; visibleColIdx: number } | null>(null);
|
||||
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
|
||||
const canvasContentHeight = computed(() => Math.max(1, displayItems.value.length * CANVAS_DATA_GRID_ROW_HEIGHT));
|
||||
let canvasResizeObserver: ResizeObserver | null = null;
|
||||
let canvasDrawFrame = 0;
|
||||
|
||||
function toggleDataGridRenderMode() {
|
||||
settingsStore.updateEditorSettings({
|
||||
dataGridRenderMode: dataGridRenderMode.value === "canvas" ? "dom" : "canvas",
|
||||
});
|
||||
}
|
||||
|
||||
function canvasScrollerElement(): HTMLElement | null {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller) return null;
|
||||
if (scroller instanceof HTMLElement) return scroller;
|
||||
if (scroller.$el instanceof HTMLElement) return scroller.$el;
|
||||
if (scroller.el instanceof HTMLElement) return scroller.el;
|
||||
if (scroller.el?.value instanceof HTMLElement) return scroller.el.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncCanvasViewport() {
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!scroller) return;
|
||||
canvasViewportWidth.value = scroller.clientWidth;
|
||||
canvasViewportHeight.value = scroller.clientHeight;
|
||||
canvasScrollTop.value = scroller.scrollTop;
|
||||
updateGridScrollbarGutter(scroller);
|
||||
updateGridHorizontalViewport(scroller);
|
||||
drawCanvasGridNow();
|
||||
}
|
||||
|
||||
function attachCanvasResizeObserver() {
|
||||
canvasResizeObserver?.disconnect();
|
||||
canvasResizeObserver = null;
|
||||
if (!useCanvasGridRows.value) return;
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!scroller) return;
|
||||
syncCanvasViewport();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
canvasResizeObserver = new ResizeObserver(syncCanvasViewport);
|
||||
canvasResizeObserver.observe(scroller);
|
||||
}
|
||||
|
||||
function scheduleCanvasDraw() {
|
||||
if (!useCanvasGridRows.value || canvasDrawFrame) return;
|
||||
canvasDrawFrame = requestAnimationFrame(() => {
|
||||
canvasDrawFrame = 0;
|
||||
drawCanvasGrid();
|
||||
});
|
||||
}
|
||||
|
||||
function drawCanvasGridNow() {
|
||||
if (!useCanvasGridRows.value) return;
|
||||
if (canvasDrawFrame) {
|
||||
cancelAnimationFrame(canvasDrawFrame);
|
||||
canvasDrawFrame = 0;
|
||||
}
|
||||
drawCanvasGrid();
|
||||
}
|
||||
|
||||
function canvasColumnAt(contentX: number): number {
|
||||
const offsets = renderedColumnOffsets.value;
|
||||
const totalColumns = renderedColumnWidths.value.length;
|
||||
if (contentX < 0 || totalColumns === 0 || contentX >= (offsets[totalColumns] ?? 0)) return -1;
|
||||
let low = 0;
|
||||
let high = totalColumns - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if ((offsets[mid + 1] ?? 0) <= contentX) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: number; rowNumber: boolean } | null {
|
||||
const canvas = canvasRef.value;
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!canvas || !scroller) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return null;
|
||||
if (x < DATA_GRID_ROW_NUM_WIDTH) return { rowIndex, visibleColIdx: -1, rowNumber: true };
|
||||
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH);
|
||||
if (visibleColIdx < 0) return null;
|
||||
return { rowIndex, visibleColIdx, rowNumber: false };
|
||||
}
|
||||
|
||||
function onCanvasScroll(event: Event) {
|
||||
const target = event.target;
|
||||
const scroller = target instanceof HTMLElement ? target : canvasScrollerElement();
|
||||
if (!scroller) return;
|
||||
|
||||
const scrollTop = scroller.scrollTop;
|
||||
const scrollLeft = scroller.scrollLeft;
|
||||
const viewportWidth = scroller.clientWidth;
|
||||
const viewportHeight = scroller.clientHeight;
|
||||
if (canvasScrollTop.value !== scrollTop) canvasScrollTop.value = scrollTop;
|
||||
if (canvasViewportWidth.value !== viewportWidth) canvasViewportWidth.value = viewportWidth;
|
||||
if (canvasViewportHeight.value !== viewportHeight) canvasViewportHeight.value = viewportHeight;
|
||||
if (gridHorizontalScrollLeft.value !== scrollLeft || gridViewportWidth.value !== viewportWidth) {
|
||||
updateGridHorizontalViewport(scroller);
|
||||
}
|
||||
const gutter = scrollbarGutterWidth(scroller);
|
||||
if (gridScrollbarGutter.value !== gutter) gridScrollbarGutter.value = gutter;
|
||||
if (headerRef.value && headerRef.value.scrollLeft !== scrollLeft) headerRef.value.scrollLeft = scrollLeft;
|
||||
markGridScrolling();
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(event: MouseEvent) {
|
||||
const hit = canvasHitTest(event);
|
||||
const hitItem = hit ? displayItems.value[hit.rowIndex] : undefined;
|
||||
const next =
|
||||
hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null;
|
||||
const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined;
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.style.cursor = hit?.rowNumber
|
||||
? "default"
|
||||
: hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx)
|
||||
? "text"
|
||||
: "cell";
|
||||
}
|
||||
if (
|
||||
next?.rowIndex === canvasHoverCell.value?.rowIndex &&
|
||||
next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previous = canvasHoverCell.value;
|
||||
if (previous) {
|
||||
const previousActualColIdx = visibleColumnIndexes.value[previous.visibleColIdx];
|
||||
if (previousActualColIdx !== undefined) onCellMouseleave(previous.rowIndex, previousActualColIdx);
|
||||
}
|
||||
canvasHoverCell.value = next;
|
||||
if (next && actualColIdx !== undefined) onCellMouseenter(next.rowIndex, next.visibleColIdx, actualColIdx);
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function onCanvasMouseLeave(event?: MouseEvent) {
|
||||
const relatedTarget = event?.relatedTarget;
|
||||
if (relatedTarget instanceof Node && canvasOverlayRef.value?.contains(relatedTarget)) return;
|
||||
const previous = canvasHoverCell.value;
|
||||
if (previous) {
|
||||
const previousActualColIdx = visibleColumnIndexes.value[previous.visibleColIdx];
|
||||
if (previousActualColIdx !== undefined) onCellMouseleave(previous.rowIndex, previousActualColIdx);
|
||||
}
|
||||
canvasHoverCell.value = null;
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function keepCanvasDetailHover() {
|
||||
const cell = canvasDetailButtonCell.value;
|
||||
if (!cell) return;
|
||||
canvasHoverCell.value = { rowIndex: cell.rowIndex, visibleColIdx: cell.visibleColIdx };
|
||||
hoveredDetailCell.value = { rowIndex: cell.rowIndex, col: cell.actualColIdx };
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function clearCanvasDetailHover(event?: MouseEvent) {
|
||||
const relatedTarget = event?.relatedTarget;
|
||||
if (
|
||||
relatedTarget instanceof Node &&
|
||||
(canvasOverlayRef.value?.contains(relatedTarget) || canvasRef.value?.contains(relatedTarget))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onCanvasMouseLeave();
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(event: MouseEvent) {
|
||||
if (event.button !== 0) return;
|
||||
commitHiddenCanvasEditBeforeCellInteraction();
|
||||
const hit = canvasHitTest(event);
|
||||
if (!hit) return;
|
||||
const item = displayItems.value[hit.rowIndex];
|
||||
if (!item) return;
|
||||
if (hit.rowNumber) {
|
||||
handleRowClick(item.displayIndex, item.id, event);
|
||||
} else {
|
||||
handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event);
|
||||
}
|
||||
gridRef.value?.focus({ preventScroll: true });
|
||||
scheduleCanvasDraw();
|
||||
}
|
||||
|
||||
function onCanvasContext(event: MouseEvent) {
|
||||
commitHiddenCanvasEditBeforeCellInteraction();
|
||||
const hit = canvasHitTest(event);
|
||||
if (!hit) return;
|
||||
const item = displayItems.value[hit.rowIndex];
|
||||
if (!item) return;
|
||||
if (hit.rowNumber) {
|
||||
onRowContext(item.id, item.displayIndex);
|
||||
return;
|
||||
}
|
||||
const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx];
|
||||
if (actualColIdx === undefined) return;
|
||||
onCellContext(item.id, item.displayIndex, actualColIdx, hit.visibleColIdx);
|
||||
}
|
||||
|
||||
function onCanvasDblClick(event: MouseEvent) {
|
||||
const hit = canvasHitTest(event);
|
||||
if (!hit) return;
|
||||
if (hit.rowNumber) {
|
||||
const item = displayItems.value[hit.rowIndex];
|
||||
if (item) toggleTranspose(item.displayIndex);
|
||||
return;
|
||||
}
|
||||
const item = displayItems.value[hit.rowIndex];
|
||||
const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx];
|
||||
if (!item || actualColIdx === undefined || !canEditCellItem(item, actualColIdx)) return;
|
||||
startEdit(item.id, actualColIdx);
|
||||
}
|
||||
|
||||
function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) {
|
||||
const widths = renderedColumnWidths.value;
|
||||
const colWidth = widths[visibleColIdx];
|
||||
if (colWidth === undefined) return null;
|
||||
const left =
|
||||
DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - gridHorizontalScrollLeft.value;
|
||||
return {
|
||||
left,
|
||||
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
|
||||
width: colWidth,
|
||||
height: CANVAS_DATA_GRID_ROW_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasEditingCellViewportRect() {
|
||||
const editing = editingCell.value;
|
||||
if (!editing || !useCanvasGridRows.value) return null;
|
||||
const rowIndex = displayItems.value.findIndex((item) => item.id === editing.rowId);
|
||||
const visibleColIdx = visibleColumnIndexes.value.indexOf(editing.col);
|
||||
if (rowIndex < 0 || visibleColIdx < 0) return null;
|
||||
return canvasCellViewportRect(rowIndex, visibleColIdx);
|
||||
}
|
||||
|
||||
function canvasEditingCellIsVisible() {
|
||||
const rect = canvasEditingCellViewportRect();
|
||||
if (!rect) return false;
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const clippedRight = Math.min(canvasViewportWidth.value, rect.left + rect.width);
|
||||
return rect.top + rect.height > 0 && rect.top < canvasViewportHeight.value && clippedRight - clippedLeft > 0;
|
||||
}
|
||||
|
||||
function commitHiddenCanvasEditBeforeCellInteraction() {
|
||||
if (!editingCell.value || !useCanvasGridRows.value) return;
|
||||
if (canvasEditingCellIsVisible()) return;
|
||||
commitEdit();
|
||||
}
|
||||
|
||||
const canvasEditingCell = computed(() => {
|
||||
const editing = editingCell.value;
|
||||
if (!editing || !useCanvasGridRows.value) return null;
|
||||
const rowIndex = displayItems.value.findIndex((item) => item.id === editing.rowId);
|
||||
const visibleColIdx = visibleColumnIndexes.value.indexOf(editing.col);
|
||||
if (rowIndex < 0 || visibleColIdx < 0) return null;
|
||||
const rect = canvasCellViewportRect(rowIndex, visibleColIdx);
|
||||
if (!rect) return null;
|
||||
return { rowIndex, visibleColIdx, actualColIdx: editing.col, rect };
|
||||
});
|
||||
|
||||
const canvasEditingCellIsNumeric = computed(() => {
|
||||
const cell = canvasEditingCell.value;
|
||||
if (!cell) return false;
|
||||
return typeof displayItems.value[cell.rowIndex]?.data[cell.actualColIdx] === "number";
|
||||
});
|
||||
|
||||
const canvasSingleSelectedCell = computed(() => {
|
||||
const range = selectedRange.value;
|
||||
if (!range || range.startRow !== range.endRow || range.startCol !== range.endCol) return null;
|
||||
return { rowIndex: range.startRow, visibleColIdx: range.startCol };
|
||||
});
|
||||
|
||||
const canvasEditingCellStyle = computed(() => {
|
||||
const cell = canvasEditingCell.value;
|
||||
if (!cell) return {};
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left);
|
||||
const clippedRight = Math.min(canvasViewportWidth.value, cell.rect.left + cell.rect.width);
|
||||
return {
|
||||
left: `${clippedLeft}px`,
|
||||
top: `${cell.rect.top}px`,
|
||||
width: `${Math.max(0, clippedRight - clippedLeft)}px`,
|
||||
height: `${cell.rect.height}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const canvasDetailButtonCell = computed(() => {
|
||||
if (!useCanvasGridRows.value || isScrolling.value) return null;
|
||||
const target = hoveredDetailCell.value ?? (showCellDetail.value ? detailCell.value : null);
|
||||
if (!target || !cellDetailButtonVisible(target.rowIndex, target.col)) return null;
|
||||
const visibleColIdx = visibleColumnIndexes.value.indexOf(target.col);
|
||||
if (visibleColIdx < 0) return null;
|
||||
const rect = canvasCellViewportRect(target.rowIndex, visibleColIdx);
|
||||
if (!rect) return null;
|
||||
const visibleLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const visibleRight = Math.min(canvasViewportWidth.value, rect.left + rect.width);
|
||||
if (rect.top < 0 || rect.top > canvasViewportHeight.value - 1 || visibleRight - visibleLeft < 24) return null;
|
||||
return { rowIndex: target.rowIndex, visibleColIdx, actualColIdx: target.col, rect };
|
||||
});
|
||||
|
||||
const canvasDetailButtonStyle = computed(() => {
|
||||
const cell = canvasDetailButtonCell.value;
|
||||
if (!cell) return {};
|
||||
return {
|
||||
left: `${cell.rect.left + cell.rect.width - 22}px`,
|
||||
top: `${cell.rect.top + 2}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function drawCanvasGrid() {
|
||||
const canvas = canvasRef.value;
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!canvas || !scroller || !useCanvasGridRows.value) return;
|
||||
|
||||
drawCanvasDataGrid({
|
||||
canvas,
|
||||
scroller,
|
||||
width: Math.max(1, canvasViewportWidth.value || scroller.clientWidth),
|
||||
height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight),
|
||||
isDark: isDark.value,
|
||||
rows: displayItems.value,
|
||||
renderedColumnWidths: renderedColumnWidths.value,
|
||||
visibleColumnIndexes: visibleColumnIndexes.value,
|
||||
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
|
||||
hoverCell: canvasHoverCell.value,
|
||||
isScrolling: isScrolling.value,
|
||||
editingCell: editingCell.value,
|
||||
singleSelectedCell: canvasSingleSelectedCell.value,
|
||||
searchMatchKeys: searchMatchSet.value,
|
||||
currentSearchMatch: currentSearchMatch.value,
|
||||
formatCell: formatCellCached,
|
||||
isRowActive,
|
||||
isRowSelected,
|
||||
rowCellsUseSelectionVisual,
|
||||
cellIsSelected,
|
||||
cellCanHover: canEditCellItem,
|
||||
});
|
||||
}
|
||||
|
||||
watch(useCanvasGridRows, () => nextTick(attachCanvasResizeObserver), { immediate: true });
|
||||
watch(
|
||||
[
|
||||
displayItems,
|
||||
renderedColumnWidths,
|
||||
visibleColumnIndexes,
|
||||
selectedRange,
|
||||
selectedRowIds,
|
||||
hasCellSelection,
|
||||
hasRowSelection,
|
||||
searchMatchSet,
|
||||
currentSearchMatch,
|
||||
isDark,
|
||||
isScrolling,
|
||||
hoveredDetailCell,
|
||||
detailCell,
|
||||
showCellDetail,
|
||||
editingCell,
|
||||
],
|
||||
scheduleCanvasDraw,
|
||||
);
|
||||
onMounted(() => nextTick(attachCanvasResizeObserver));
|
||||
onUnmounted(() => {
|
||||
canvasResizeObserver?.disconnect();
|
||||
if (canvasDrawFrame) cancelAnimationFrame(canvasDrawFrame);
|
||||
});
|
||||
|
||||
function setRowStatusFilter(value: string) {
|
||||
rowStatusFilter.value = value as RowStatusFilter;
|
||||
}
|
||||
@@ -3235,6 +3636,10 @@ function currentSelectedCellPosition() {
|
||||
function scrollCellIntoView(rowIndex: number, colIndex: number) {
|
||||
nextTick(() => {
|
||||
scrollGridColumnIntoView(colIndex);
|
||||
if (useCanvasGridRows.value) {
|
||||
scrollCanvasRowIntoView(rowIndex, "nearest");
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"]`);
|
||||
const cellEl = rowEl?.querySelector<HTMLElement>(`[data-visible-col-index="${colIndex}"]`);
|
||||
@@ -3259,11 +3664,30 @@ function scrollGridColumnIntoView(visibleColIdx: number) {
|
||||
|
||||
updateGridHorizontalViewport(scroller);
|
||||
if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
|
||||
if (useCanvasGridRows.value) syncCanvasViewport();
|
||||
}
|
||||
|
||||
function scrollCanvasRowIntoView(rowIndex: number, block: "nearest" | "start") {
|
||||
const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
|
||||
const scroller = canvasScrollerElement();
|
||||
if (!scroller) return;
|
||||
const rowTop = target * CANVAS_DATA_GRID_ROW_HEIGHT;
|
||||
const rowBottom = rowTop + CANVAS_DATA_GRID_ROW_HEIGHT;
|
||||
if (block === "start" || rowTop < scroller.scrollTop) {
|
||||
scroller.scrollTop = rowTop;
|
||||
} else if (rowBottom > scroller.scrollTop + scroller.clientHeight) {
|
||||
scroller.scrollTop = Math.max(0, rowBottom - scroller.clientHeight);
|
||||
}
|
||||
syncCanvasViewport();
|
||||
}
|
||||
|
||||
function scrollGridRowIntoView(rowIndex: number) {
|
||||
const target = Math.max(0, Math.min(displayItems.value.length - 1, rowIndex));
|
||||
nextTick(() => {
|
||||
if (useCanvasGridRows.value) {
|
||||
scrollCanvasRowIntoView(target, "start");
|
||||
return;
|
||||
}
|
||||
const scroller = scrollerRef.value;
|
||||
if (scroller && !(scroller instanceof HTMLElement)) {
|
||||
scroller.scrollToItem?.(target);
|
||||
@@ -4023,6 +4447,10 @@ const searchQuery = ref("");
|
||||
const cellDetailPanelLayout = computed(() => settingsStore.editorSettings.cellDetailPanelLayout);
|
||||
const cellDetailPanelIsBottom = computed(() => cellDetailPanelLayout.value === "bottom");
|
||||
|
||||
watch([showCellDetail, showTableInfo], () => {
|
||||
if (useCanvasGridRows.value) nextTick(syncCanvasViewport);
|
||||
});
|
||||
|
||||
watch(activeTableInfoTab, () => {
|
||||
searchQuery.value = "";
|
||||
});
|
||||
@@ -4985,6 +5413,23 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
<RefreshCcw v-else class="w-3 h-3 mr-1" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 text-xs px-1.5 shrink-0"
|
||||
:class="dataGridRenderMode === 'canvas' ? 'text-primary bg-primary/10' : ''"
|
||||
@click="toggleDataGridRenderMode"
|
||||
>
|
||||
<SquareDashed class="w-3 h-3 mr-1" />
|
||||
{{ dataGridRenderMode === "canvas" ? t("grid.canvasRenderMode") : t("grid.domRenderMode") }}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-sm">
|
||||
{{ t("grid.renderModeHint") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-if="editable && (tableMeta || customSave)"
|
||||
variant="ghost"
|
||||
@@ -5838,9 +6283,85 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="useCanvasGridRows"
|
||||
ref="scrollerRef"
|
||||
class="data-grid-scroller canvas-grid-scroller flex-1 overflow-auto overscroll-none bg-background"
|
||||
:class="{ 'is-scrolling': isScrolling }"
|
||||
@scroll="onCanvasScroll"
|
||||
>
|
||||
<div class="relative" :style="{ width: `${totalWidth}px`, height: `${canvasContentHeight}px` }">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="canvas-grid-surface sticky left-0 top-0 z-0 block text-xs font-sans font-normal"
|
||||
:style="{ width: `${canvasViewportWidth}px`, height: `${canvasViewportHeight}px` }"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseleave="onCanvasMouseLeave"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@contextmenu="onCanvasContext"
|
||||
@dblclick="onCanvasDblClick"
|
||||
/>
|
||||
<div
|
||||
ref="canvasOverlayRef"
|
||||
class="canvas-grid-overlay sticky left-0 top-0 z-10 overflow-hidden"
|
||||
:style="{
|
||||
width: `${canvasViewportWidth}px`,
|
||||
height: `${canvasViewportHeight}px`,
|
||||
marginTop: `-${canvasViewportHeight}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="canvasEditingCell"
|
||||
class="absolute pointer-events-auto z-20"
|
||||
:class="{ 'tabular-nums': canvasEditingCellIsNumeric }"
|
||||
:style="canvasEditingCellStyle"
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
>
|
||||
<TemporalCellEditor
|
||||
v-if="temporalEditorKindForColumn(canvasEditingCell.actualColIdx)"
|
||||
v-model="editValue"
|
||||
:kind="temporalEditorKindForColumn(canvasEditingCell.actualColIdx)!"
|
||||
@cancel="cancelEdit"
|
||||
@commit="commitGridEdit"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="editValue"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 text-xs leading-[22px] outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@keydown.stop="onEditKeydown"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="canvasDetailButtonCell"
|
||||
class="absolute pointer-events-auto z-20 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"
|
||||
:style="canvasDetailButtonStyle"
|
||||
:title="t('grid.cellDetails')"
|
||||
@mouseenter="keepCanvasDetailHover"
|
||||
@mouseleave="clearCanvasDetailHover"
|
||||
@mousedown.stop
|
||||
@click.stop="
|
||||
showCellDetailsForVisibleCell(
|
||||
canvasDetailButtonCell.rowIndex,
|
||||
canvasDetailButtonCell.visibleColIdx,
|
||||
canvasDetailButtonCell.actualColIdx,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Info class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scrolled rows -->
|
||||
<RecycleScroller
|
||||
v-else
|
||||
v-else-if="hasVisibleRows"
|
||||
ref="scrollerRef"
|
||||
class="data-grid-scroller flex-1 overflow-x-auto overscroll-none"
|
||||
:class="{ 'is-scrolling': isScrolling }"
|
||||
@@ -7078,6 +7599,38 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
[data-grid-root] {
|
||||
--data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 30%, transparent);
|
||||
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
|
||||
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
|
||||
--data-grid-cell-active-bg: color-mix(in oklab, var(--primary) 15%, transparent);
|
||||
--data-grid-cell-dirty-bg: color-mix(in oklab, oklch(79.5% 0.184 86.047) 10%, transparent);
|
||||
--data-grid-cell-selected-bg: color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
--data-grid-cell-selected-dirty-bg: color-mix(
|
||||
in oklab,
|
||||
oklch(0.8 0.15 85) 30%,
|
||||
color-mix(in oklab, var(--primary) 18%, transparent)
|
||||
);
|
||||
--data-grid-cell-selected-border: color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
--data-grid-cell-hover-bg: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
--data-grid-cell-search-bg: rgb(253 245 184);
|
||||
--data-grid-cell-current-search-bg: rgb(253 224 71 / 52%);
|
||||
--data-grid-cell-current-search-border: rgb(234 179 8 / 82%);
|
||||
--data-grid-row-number-default-bg: rgb(255 255 255);
|
||||
--data-grid-row-number-new-bg: color-mix(in oklab, rgb(16 185 129) 15%, var(--background));
|
||||
--data-grid-row-number-edited-bg: color-mix(in oklab, rgb(245 158 11) 15%, var(--background));
|
||||
--data-grid-row-number-deleted-bg: color-mix(in oklab, var(--destructive) 15%, var(--background));
|
||||
--data-grid-row-number-active-bg: color-mix(in oklab, var(--primary) 15%, var(--background));
|
||||
--data-grid-row-number-selected-bg: color-mix(in oklab, var(--primary) 25%, var(--background));
|
||||
}
|
||||
|
||||
:global(.dark) [data-grid-root] {
|
||||
--data-grid-cell-search-bg: rgb(72 57 8);
|
||||
--data-grid-cell-current-search-bg: rgb(116 87 0);
|
||||
--data-grid-cell-current-search-border: rgb(239 177 0);
|
||||
--data-grid-row-number-default-bg: rgb(15 15 15);
|
||||
}
|
||||
|
||||
.data-grid-topbar {
|
||||
min-width: 760px;
|
||||
}
|
||||
@@ -7105,6 +7658,19 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-grid-surface {
|
||||
cursor: cell;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.canvas-grid-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.transpose-grid-scroller {
|
||||
overflow-anchor: none;
|
||||
will-change: scroll-position;
|
||||
@@ -7132,56 +7698,56 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
}
|
||||
|
||||
.cell-selected {
|
||||
background-color: color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
background-color: var(--data-grid-cell-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--data-grid-cell-selected-border);
|
||||
}
|
||||
|
||||
.row-cell-selected {
|
||||
background-color: color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
background-color: var(--data-grid-cell-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--data-grid-cell-selected-border);
|
||||
}
|
||||
|
||||
.transpose-record-header-selected {
|
||||
background-color: color-mix(in oklab, var(--primary) 25%, var(--background));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
background-color: var(--data-grid-row-number-selected-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--data-grid-cell-selected-border);
|
||||
}
|
||||
|
||||
.transpose-record-header-active {
|
||||
background-color: color-mix(in oklab, var(--primary) 25%, var(--background));
|
||||
background-color: var(--data-grid-row-number-selected-bg);
|
||||
}
|
||||
|
||||
.cell-selected-dirty {
|
||||
background-color: color-mix(in oklab, oklch(0.8 0.15 85) 30%, color-mix(in oklab, var(--primary) 18%, transparent));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
background-color: var(--data-grid-cell-selected-dirty-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--data-grid-cell-selected-border);
|
||||
}
|
||||
|
||||
.row-cell-selected-dirty {
|
||||
background-color: color-mix(in oklab, oklch(0.8 0.15 85) 30%, color-mix(in oklab, var(--primary) 18%, transparent));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 70%, transparent);
|
||||
background-color: var(--data-grid-cell-selected-dirty-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--data-grid-cell-selected-border);
|
||||
}
|
||||
|
||||
.data-grid-row-number.bg-emerald-500\/15 {
|
||||
background-color: color-mix(in oklab, rgb(16 185 129) 15%, var(--background));
|
||||
background-color: var(--data-grid-row-number-new-bg);
|
||||
}
|
||||
|
||||
.data-grid-row-number.bg-amber-500\/15 {
|
||||
background-color: color-mix(in oklab, rgb(245 158 11) 15%, var(--background));
|
||||
background-color: var(--data-grid-row-number-edited-bg);
|
||||
}
|
||||
|
||||
.data-grid-row-number.bg-destructive\/15 {
|
||||
background-color: color-mix(in oklab, var(--destructive) 15%, var(--background));
|
||||
background-color: var(--data-grid-row-number-deleted-bg);
|
||||
}
|
||||
|
||||
.active-row > div:not(.cell-dirty) {
|
||||
background-color: color-mix(in oklab, var(--primary) 15%, transparent);
|
||||
background-color: var(--data-grid-cell-active-bg);
|
||||
}
|
||||
|
||||
.active-row > .data-grid-row-number:not(.cell-dirty) {
|
||||
background-color: color-mix(in oklab, var(--primary) 15%, var(--background));
|
||||
background-color: var(--data-grid-row-number-active-bg);
|
||||
}
|
||||
|
||||
.data-grid-row-number.\!bg-primary\/25 {
|
||||
background-color: color-mix(in oklab, var(--primary) 25%, var(--background)) !important;
|
||||
background-color: var(--data-grid-row-number-selected-bg) !important;
|
||||
}
|
||||
|
||||
.ddl-code :deep(.ddl-kw) {
|
||||
|
||||
Reference in new issue
Block a user