feat(grid): add hybrid canvas data grid renderer

This commit is contained in:
t8y2 2026-06-02 15:56:35 +08:00
parent 0e7262eaa3
commit 396d2ff281
10 changed files with 1129 additions and 39 deletions

View File

@ -4,7 +4,7 @@ const globalDdlOpen = ref(false);
</script>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, watch, type Component } from "vue";
import { computed, nextTick, onMounted, onUnmounted, watch, type Component } from "vue";
import { useI18n } from "vue-i18n";
import {
ArrowUp,
@ -142,6 +142,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,
@ -311,6 +312,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 "";
@ -1503,7 +1505,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),
@ -1517,6 +1519,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;
@ -1545,6 +1556,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 };
@ -1558,22 +1570,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,
@ -1602,12 +1615,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;
@ -2106,8 +2114,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 {
@ -2117,9 +2132,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;
}
@ -2138,6 +2152,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" });
}
@ -2918,6 +2943,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;
}
@ -3987,6 +4388,10 @@ const triggersLoading = ref(false);
const triggersError = ref("");
const searchQuery = ref("");
watch([showCellDetail, showTableInfo], () => {
if (useCanvasGridRows.value) nextTick(syncCanvasViewport);
});
watch(activeTableInfoTab, () => {
searchQuery.value = "";
});
@ -4945,6 +5350,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"
@ -5779,9 +6201,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 }"
@ -6943,6 +7441,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;
}
@ -6970,6 +7500,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;
@ -6997,56 +7540,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) {

View File

@ -439,6 +439,9 @@ export default {
exportFailed: "Export failed: {message}",
copied: "Copied",
copyFailed: "Copy failed: {message}",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "Switch between the stable DOM grid and experimental Canvas rendering.",
filter: "Filter",
filterBuilder: "Filters",
filterBuilderTitle: "Filters",

View File

@ -432,6 +432,9 @@ export default {
exportFailed: "Error al exportar: {message}",
copied: "Copiado",
copyFailed: "Error al copiar: {message}",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "Alterna entre la cuadrícula DOM estable y el renderizado Canvas experimental.",
filter: "Filtrar",
filterBuilder: "Filtros",
filterBuilderTitle: "Filtros",

View File

@ -435,6 +435,9 @@ export default {
exportFailed: "导出失败:{message}",
copied: "已复制",
copyFailed: "复制失败:{message}",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "在稳定 DOM 表格和实验性 Canvas 渲染之间切换。",
filter: "筛选",
filterBuilder: "筛选器",
filterBuilderTitle: "筛选器",

View File

@ -435,6 +435,9 @@ export default {
exportFailed: "匯出失敗:{message}",
copied: "已複製",
copyFailed: "複製失敗:{message}",
domRenderMode: "DOM",
canvasRenderMode: "Canvas",
renderModeHint: "在穩定 DOM 表格和實驗性 Canvas 渲染之間切換。",
filter: "篩選",
filterBuilder: "篩選器",
filterBuilderTitle: "篩選器",

View File

@ -0,0 +1,381 @@
import type { CellValue } from "@/lib/cellValue";
import type { RowStatus } from "@/lib/gridRowStatus";
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme } from "@/lib/dataGridPaintTheme";
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
export interface CanvasDataGridRow {
id: number;
displayIndex: number;
data: CellValue[];
isNew: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: RowStatus;
}
export interface CanvasHoverCell {
rowIndex: number;
visibleColIdx: number;
}
export interface CanvasEditingCell {
rowId: number;
col: number;
}
export interface CanvasSingleSelectedCell {
rowIndex: number;
visibleColIdx: number;
}
export interface CanvasSearchMatch {
displayRow: number;
col: number;
}
export interface DrawCanvasDataGridOptions {
canvas: HTMLCanvasElement;
scroller: HTMLElement;
width: number;
height: number;
isDark: boolean;
rows: CanvasDataGridRow[];
renderedColumnWidths: number[];
visibleColumnIndexes: number[];
rowNumberWidth: number;
hoverCell: CanvasHoverCell | null;
isScrolling: boolean;
editingCell: CanvasEditingCell | null;
singleSelectedCell: CanvasSingleSelectedCell | null;
searchMatchKeys: ReadonlySet<string>;
currentSearchMatch: CanvasSearchMatch | null;
formatCell: (value: CellValue, columnIndex: number) => string;
isRowActive: (rowIndex: number) => boolean;
isRowSelected: (rowId: number) => boolean;
rowCellsUseSelectionVisual: (rowId: number) => boolean;
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
}
type NumericCanvasContext = CanvasRenderingContext2D & {
fontVariantNumeric?: string;
};
function setCanvasNumericVariant(ctx: CanvasRenderingContext2D, value: "normal" | "tabular-nums") {
const numericCtx = ctx as NumericCanvasContext;
if ("fontVariantNumeric" in numericCtx) numericCtx.fontVariantNumeric = value;
}
function canvasTabularFontFamily(fontFamily: string): string {
return `"Geist Variable Tabular", ${fontFamily}`;
}
function fitCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0 || ctx.measureText(text).width <= maxWidth) return text;
const ellipsis = "...";
if (ctx.measureText(ellipsis).width > maxWidth) return "";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(`${text.slice(0, mid)}${ellipsis}`).width <= maxWidth) low = mid;
else high = mid - 1;
}
return `${text.slice(0, low)}${ellipsis}`;
}
function canvasFont(style: {
family: string;
sizePx: number;
style?: string;
weight?: string | number;
lineHeight?: string;
}): string {
const fontStyle = style.style && style.style !== "normal" ? `${style.style} ` : "";
const fontWeight = style.weight && style.weight !== "400" && style.weight !== "normal" ? `${style.weight} ` : "";
const lineHeight = style.lineHeight && style.lineHeight !== "normal" ? `/${style.lineHeight}` : "";
return `${fontStyle}${fontWeight}${style.sizePx}px${lineHeight} ${style.family}`;
}
function columnOffsets(widths: number[]): number[] {
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 firstVisibleColumn(offsets: number[], contentStart: number): number {
let low = 0;
let high = Math.max(0, offsets.length - 2);
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((offsets[mid + 1] ?? 0) < contentStart) low = mid + 1;
else high = mid;
}
return low;
}
export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const {
canvas,
scroller,
width,
height,
isDark,
rows,
renderedColumnWidths,
visibleColumnIndexes,
rowNumberWidth,
hoverCell,
isScrolling,
editingCell,
singleSelectedCell,
searchMatchKeys,
currentSearchMatch,
formatCell,
isRowActive,
isRowSelected,
rowCellsUseSelectionVisual,
cellIsSelected,
cellCanHover,
} = options;
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.floor(width * dpr);
const pixelHeight = Math.floor(height * dpr);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const canvasStyle = getComputedStyle(canvas);
const fontFamily =
canvasStyle.fontFamily || `"Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif`;
const fontSize = Number.parseFloat(canvasStyle.fontSize) || 12;
const lineHeight = canvasStyle.lineHeight;
const normalFont = canvasFont({
family: fontFamily,
sizePx: fontSize,
weight: canvasStyle.fontWeight,
lineHeight,
});
const tabularFont = canvasFont({
family: canvasTabularFontFamily(fontFamily),
sizePx: fontSize,
weight: canvasStyle.fontWeight,
lineHeight,
});
const semiboldFont = canvasFont({ family: fontFamily, sizePx: fontSize, weight: 600, lineHeight });
const italicFont = canvasFont({
family: fontFamily,
sizePx: fontSize,
style: "italic",
weight: canvasStyle.fontWeight,
lineHeight,
});
const theme = resolveDataGridPaintTheme({
getVar: (name) => canvasStyle.getPropertyValue(name),
isDark,
});
const searchFill = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : theme.cellSearch;
const currentSearchFill = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : theme.cellCurrentSearch;
const currentSearchBorder = isDark ? DATA_GRID_DARK_SEARCH_COLORS.currentBorder : theme.cellCurrentSearchBorder;
const scrollTop = scroller.scrollTop;
const scrollLeft = scroller.scrollLeft;
const firstRow = Math.max(0, Math.floor(scrollTop / CANVAS_DATA_GRID_ROW_HEIGHT));
const lastRow = Math.min(rows.length - 1, Math.ceil((scrollTop + height) / CANVAS_DATA_GRID_ROW_HEIGHT));
ctx.fillStyle = theme.background;
ctx.fillRect(0, 0, width, height);
ctx.font = normalFont;
ctx.textBaseline = "middle";
const offsets = columnOffsets(renderedColumnWidths);
const contentStart = Math.max(0, scrollLeft - rowNumberWidth);
const firstCol = firstVisibleColumn(offsets, contentStart);
const columnOffset = offsets[firstCol] ?? 0;
const paintSearchMatches = !isScrolling && searchMatchKeys.size > 0;
for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
const item = rows[rowIndex];
if (!item) continue;
const y = rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop;
const rowIsActive = isRowActive(item.displayIndex);
const rowBase = item.isDeleted
? theme.rowDeleted
: item.isNew && !rowIsActive
? theme.rowNew
: item.displayIndex % 2 === 1 && !rowIsActive
? theme.rowMuted
: theme.background;
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
ctx.fillStyle = rowBase;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
let rowNumberFill =
item.status === "new"
? theme.rowNumberNew
: item.status === "edited"
? theme.rowNumberEdited
: item.status === "deleted"
? theme.rowNumberDeleted
: theme.rowNumberDefault;
if (rowIsActive && !item.isDeleted) rowNumberFill = theme.rowNumberActive;
if (isRowSelected(item.id) && item.status === "clean") rowNumberFill = theme.rowNumberSelected;
ctx.fillStyle = rowNumberFill;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
if (
hoverCell?.rowIndex === item.displayIndex &&
hoverCell.visibleColIdx < 0 &&
!isScrolling &&
item.status === "clean" &&
!rowIsActive &&
!isRowSelected(item.id)
) {
ctx.fillStyle = theme.cellHover;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(rowNumberWidth + 0.5, y);
ctx.lineTo(rowNumberWidth + 0.5, y + CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.moveTo(0, y + CANVAS_DATA_GRID_ROW_HEIGHT - 0.5);
ctx.lineTo(width, y + CANVAS_DATA_GRID_ROW_HEIGHT - 0.5);
ctx.stroke();
const rowNumberText =
item.status === "new"
? theme.rowNumberTextNew
: item.status === "edited"
? theme.rowNumberTextEdited
: item.status === "deleted"
? theme.rowNumberTextDeleted
: isRowSelected(item.id)
? theme.primary
: theme.rowNumberTextClean;
ctx.fillStyle = rowNumberText;
ctx.font = item.status === "new" || item.status === "edited" || isRowSelected(item.id) ? semiboldFont : normalFont;
ctx.textAlign = "center";
ctx.fillText(String(item.displayIndex + 1), rowNumberWidth / 2, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.font = normalFont;
let x = rowNumberWidth + columnOffset - scrollLeft;
for (let visibleColIdx = firstCol; visibleColIdx < renderedColumnWidths.length && x < width; visibleColIdx++) {
const colWidth = renderedColumnWidths[visibleColIdx] ?? 0;
const actualColIdx = visibleColumnIndexes[visibleColIdx];
if (actualColIdx === undefined) {
x += colWidth;
continue;
}
if (x + colWidth >= rowNumberWidth) {
const selectedCell = cellIsSelected(item.displayIndex, visibleColIdx);
const rowSelectionVisual = rowCellsUseSelectionVisual(item.id);
const isSingleSelectedCell =
singleSelectedCell?.rowIndex === item.displayIndex && singleSelectedCell.visibleColIdx === visibleColIdx;
const selectedFillVisual = rowSelectionVisual || (selectedCell && !isSingleSelectedCell);
const selectedBorderVisual = rowSelectionVisual || selectedCell;
const isDirtyCell = item.isDirtyCol[actualColIdx];
const isSearchMatch = paintSearchMatches && searchMatchKeys.has(`${item.displayIndex}:${actualColIdx}`);
const isCurrentSearchMatch =
paintSearchMatches &&
currentSearchMatch?.displayRow === item.displayIndex &&
currentSearchMatch.col === actualColIdx;
const clippedX = Math.max(x, rowNumberWidth);
const cellPaintWidth = colWidth - Math.max(0, clippedX - x);
if (isDirtyCell) {
ctx.fillStyle = theme.cellDirty;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (
hoverCell?.rowIndex === item.displayIndex &&
hoverCell.visibleColIdx === visibleColIdx &&
!isScrolling &&
!isSearchMatch &&
!isCurrentSearchMatch &&
cellCanHover(item, actualColIdx)
) {
ctx.fillStyle = theme.cellHover;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (selectedFillVisual) {
ctx.fillStyle = isDirtyCell ? theme.cellSelectedDirty : theme.cellSelected;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (rowIsActive && !item.isDeleted && !isDirtyCell) {
ctx.fillStyle = theme.cellActive;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
}
if (isSearchMatch) {
ctx.globalAlpha = 1;
ctx.fillStyle = searchFill;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
}
if (isCurrentSearchMatch) {
ctx.globalAlpha = 1;
ctx.fillStyle = currentSearchFill;
ctx.fillRect(clippedX, y, cellPaintWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
}
ctx.save();
ctx.beginPath();
ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.clip();
const value = item.data[actualColIdx];
ctx.textAlign = "left";
ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground;
ctx.font = value === null ? italicFont : typeof value === "number" ? tabularFont : normalFont;
setCanvasNumericVariant(ctx, typeof value === "number" ? "tabular-nums" : "normal");
const textLeft = x + 12;
const textMaxWidth = Math.max(0, x + colWidth - textLeft - 12);
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
const displayText = isEditingThisCell ? "" : formatCell(value, actualColIdx);
const text = isEditingThisCell || isScrolling ? displayText : fitCanvasText(ctx, displayText, textMaxWidth);
ctx.fillText(text, textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
if (item.isDeleted && text) {
const textWidth = Math.min(ctx.measureText(text).width, textMaxWidth);
ctx.strokeStyle = theme.foreground;
ctx.beginPath();
ctx.moveTo(textLeft, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.lineTo(textLeft + textWidth, y + CANVAS_DATA_GRID_ROW_HEIGHT / 2);
ctx.stroke();
}
ctx.restore();
setCanvasNumericVariant(ctx, "normal");
ctx.font = normalFont;
if (selectedBorderVisual) {
ctx.strokeStyle = theme.cellSelectedBorder;
ctx.strokeRect(clippedX + 0.5, y + 0.5, Math.max(0, cellPaintWidth - 1), CANVAS_DATA_GRID_ROW_HEIGHT - 1);
}
if (isCurrentSearchMatch) {
ctx.strokeStyle = currentSearchBorder;
ctx.lineWidth = 2;
ctx.strokeRect(clippedX + 1, y + 1, Math.max(0, cellPaintWidth - 2), CANVAS_DATA_GRID_ROW_HEIGHT - 2);
ctx.lineWidth = 1;
}
ctx.strokeStyle = theme.border;
ctx.beginPath();
ctx.moveTo(x + colWidth - 0.5, y);
ctx.lineTo(x + colWidth - 0.5, y + CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.stroke();
}
x += colWidth;
}
ctx.globalAlpha = 1;
}
}

View File

@ -0,0 +1,110 @@
export interface DataGridPaintTheme {
background: string;
border: string;
foreground: string;
mutedForeground: string;
primary: string;
rowMuted: string;
rowNew: string;
rowDeleted: string;
cellActive: string;
cellDirty: string;
cellSelected: string;
cellSelectedDirty: string;
cellSelectedBorder: string;
cellHover: string;
cellSearch: string;
cellCurrentSearch: string;
cellCurrentSearchBorder: string;
rowNumberDefault: string;
rowNumberNew: string;
rowNumberEdited: string;
rowNumberDeleted: string;
rowNumberActive: string;
rowNumberSelected: string;
rowNumberTextClean: string;
rowNumberTextNew: string;
rowNumberTextEdited: string;
rowNumberTextDeleted: string;
}
export const DATA_GRID_DARK_SEARCH_COLORS = {
match: "rgb(72 57 8)",
current: "rgb(116 87 0)",
currentBorder: "rgb(239 177 0)",
} as const;
export function cssVarColor(getVar: (name: string) => string, name: string, fallback: string): string {
const value = getVar(name).trim();
if (!value) return fallback;
return value.startsWith("#") || /^(rgb|hsl|oklch|oklab|lab|lch|color-mix)\(/.test(value) ? value : `hsl(${value})`;
}
function paintToken(getVar: (name: string) => string, name: string, fallback: string): string {
const value = getVar(name).trim();
if (!value || value.includes("var(")) return fallback;
return cssVarColor(getVar, name, fallback);
}
export function resolveDataGridPaintTheme(options: {
getVar: (name: string) => string;
isDark: boolean;
}): DataGridPaintTheme {
const { getVar, isDark } = options;
const background = cssVarColor(getVar, "--background", isDark ? "oklch(0.145 0 0)" : "oklch(1 0 0)");
const foreground = cssVarColor(getVar, "--foreground", isDark ? "oklch(0.985 0 0)" : "oklch(0.145 0 0)");
const mutedForeground = cssVarColor(getVar, "--muted-foreground", isDark ? "oklch(0.708 0 0)" : "oklch(0.556 0 0)");
const primary = cssVarColor(getVar, "--primary", isDark ? "oklch(0.922 0 0)" : "oklch(0.205 0 0)");
const muted = cssVarColor(getVar, "--muted", isDark ? "oklch(0.269 0 0)" : "oklch(0.97 0 0)");
const destructive = cssVarColor(getVar, "--destructive", "oklch(0.6 0.22 25)");
const accent = cssVarColor(getVar, "--accent", isDark ? "oklch(0.269 0 0)" : "oklch(0.97 0 0)");
const yellow500 = "oklch(0.795 0.184 86.047)";
const rowMuted = `color-mix(in oklab, ${muted} 30%, transparent)`;
const rowNew = `color-mix(in oklab, ${primary} 5%, transparent)`;
const rowDeleted = `color-mix(in oklab, ${destructive} 5%, transparent)`;
const cellActive = `color-mix(in oklab, ${primary} 15%, transparent)`;
const cellDirty = `color-mix(in oklab, ${yellow500} 10%, transparent)`;
const cellSelected = `color-mix(in oklab, ${primary} 25%, transparent)`;
const cellSelectedDirty = `color-mix(in oklab, oklch(0.8 0.15 85) 30%, color-mix(in oklab, ${primary} 18%, transparent))`;
const cellSelectedBorder = `color-mix(in oklab, ${primary} 70%, transparent)`;
const cellHover = `color-mix(in oklab, ${accent} 50%, transparent)`;
const cellSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.match : "rgb(253 245 184)";
const cellCurrentSearch = isDark ? DATA_GRID_DARK_SEARCH_COLORS.current : "rgb(253 224 71 / 52%)";
const cellCurrentSearchBorder = isDark ? DATA_GRID_DARK_SEARCH_COLORS.currentBorder : "rgb(234 179 8 / 82%)";
const rowNumberDefault = isDark ? "rgb(15 15 15)" : "rgb(255 255 255)";
const rowNumberNew = `color-mix(in oklab, rgb(16 185 129) 15%, ${background})`;
const rowNumberEdited = `color-mix(in oklab, rgb(245 158 11) 15%, ${background})`;
const rowNumberDeleted = `color-mix(in oklab, ${destructive} 15%, ${background})`;
const rowNumberActive = `color-mix(in oklab, ${primary} 15%, ${background})`;
const rowNumberSelected = `color-mix(in oklab, ${primary} 25%, ${background})`;
return {
background,
border: cssVarColor(getVar, "--border", isDark ? "rgb(63 63 70)" : "rgb(229 231 235)"),
foreground,
mutedForeground,
primary,
rowMuted: paintToken(getVar, "--data-grid-row-muted-bg", rowMuted),
rowNew: paintToken(getVar, "--data-grid-row-new-bg", rowNew),
rowDeleted: paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
cellActive: paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
cellSelected: paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
cellSelectedBorder: paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
cellHover: paintToken(getVar, "--data-grid-cell-hover-bg", cellHover),
cellSearch: paintToken(getVar, "--data-grid-cell-search-bg", cellSearch),
cellCurrentSearch: paintToken(getVar, "--data-grid-cell-current-search-bg", cellCurrentSearch),
cellCurrentSearchBorder: paintToken(getVar, "--data-grid-cell-current-search-border", cellCurrentSearchBorder),
rowNumberDefault,
rowNumberNew: paintToken(getVar, "--data-grid-row-number-new-bg", rowNumberNew),
rowNumberEdited: paintToken(getVar, "--data-grid-row-number-edited-bg", rowNumberEdited),
rowNumberDeleted: paintToken(getVar, "--data-grid-row-number-deleted-bg", rowNumberDeleted),
rowNumberActive: paintToken(getVar, "--data-grid-row-number-active-bg", rowNumberActive),
rowNumberSelected: paintToken(getVar, "--data-grid-row-number-selected-bg", rowNumberSelected),
rowNumberTextClean: mutedForeground,
rowNumberTextNew: isDark ? "oklch(0.845 0.143 164.978)" : "oklch(0.508 0.118 165.612)",
rowNumberTextEdited: isDark ? "oklch(0.879 0.169 91.605)" : "oklch(0.555 0.163 48.998)",
rowNumberTextDeleted: destructive,
};
}

View File

@ -183,6 +183,8 @@ export type EditorTheme =
const STRUCTURE_EDITOR_DENSITIES = ["compact", "standard", "comfortable"] as const;
export type StructureEditorDensity = (typeof STRUCTURE_EDITOR_DENSITIES)[number];
const DATA_GRID_RENDER_MODES = ["dom", "canvas"] as const;
export type DataGridRenderMode = (typeof DATA_GRID_RENDER_MODES)[number];
export interface EditorSettings {
fontFamily: string;
@ -198,6 +200,7 @@ export interface EditorSettings {
mongoViewMode: "document" | "table";
showColumnCommentsInHeader: boolean;
compactColumnHeaderActions: boolean;
dataGridRenderMode: DataGridRenderMode;
structureEditorDensity: StructureEditorDensity;
tableInfoDrawerWidth: number;
cellDetailDrawerWidth: number;
@ -252,6 +255,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
mongoViewMode: "document",
showColumnCommentsInHeader: false,
compactColumnHeaderActions: true,
dataGridRenderMode: "dom",
structureEditorDensity: "compact",
tableInfoDrawerWidth: 320,
cellDetailDrawerWidth: 320,
@ -288,6 +292,12 @@ function normalizeStructureEditorDensity(value: unknown): StructureEditorDensity
: DEFAULT_EDITOR_SETTINGS.structureEditorDensity;
}
function normalizeDataGridRenderMode(value: unknown): DataGridRenderMode {
return DATA_GRID_RENDER_MODES.includes(value as DataGridRenderMode)
? (value as DataGridRenderMode)
: DEFAULT_EDITOR_SETTINGS.dataGridRenderMode;
}
function normalizeColumnFormatters(value: unknown): Record<string, ColumnFormatterConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const formatters: Record<string, ColumnFormatterConfig> = {};
@ -351,6 +361,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader,
compactColumnHeaderActions:
settings.compactColumnHeaderActions ?? DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions,
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
structureEditorDensity: normalizeStructureEditorDensity(settings.structureEditorDensity),
tableInfoDrawerWidth: normalizeDrawerWidth(
settings.tableInfoDrawerWidth,
@ -494,6 +505,8 @@ export const useSettingsStore = defineStore("settings", () => {
editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader;
if (partial.compactColumnHeaderActions !== undefined)
editorSettings.value.compactColumnHeaderActions = partial.compactColumnHeaderActions;
if (partial.dataGridRenderMode !== undefined)
editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode);
if (partial.structureEditorDensity !== undefined)
editorSettings.value.structureEditorDensity = normalizeStructureEditorDensity(partial.structureEditorDensity);
if (partial.tableInfoDrawerWidth !== undefined)

View File

@ -20,6 +20,30 @@
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist Variable Tabular";
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-feature-settings: "tnum";
src: url("/fonts/geist-latin-ext-wght-normal.woff2") format("woff2-variations");
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF,
U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Geist Variable Tabular";
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-feature-settings: "tnum";
src: url("/fonts/geist-latin-wght-normal.woff2") format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@import "tailwindcss";
@source "../**/*.{vue,ts,tsx,js,jsx,html}";

View File

@ -98,6 +98,13 @@ test("keeps saved data grid header display settings", () => {
assert.equal(settings.compactColumnHeaderActions, false);
});
test("normalizes data grid render mode", () => {
assert.equal(DEFAULT_EDITOR_SETTINGS.dataGridRenderMode, "dom");
assert.equal(normalizeEditorSettings({}).dataGridRenderMode, "dom");
assert.equal(normalizeEditorSettings({ dataGridRenderMode: "canvas" as any }).dataGridRenderMode, "canvas");
assert.equal(normalizeEditorSettings({ dataGridRenderMode: "unknown" as any }).dataGridRenderMode, "dom");
});
test("normalizes table structure editor density", () => {
assert.equal(DEFAULT_EDITOR_SETTINGS.structureEditorDensity, "compact");
assert.equal(normalizeEditorSettings({}).structureEditorDensity, "compact");