feat(grid): add hybrid canvas data grid renderer
This commit is contained in:
parent
0e7262eaa3
commit
396d2ff281
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -435,6 +435,9 @@ export default {
|
|||
exportFailed: "导出失败:{message}",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败:{message}",
|
||||
domRenderMode: "DOM",
|
||||
canvasRenderMode: "Canvas",
|
||||
renderModeHint: "在稳定 DOM 表格和实验性 Canvas 渲染之间切换。",
|
||||
filter: "筛选",
|
||||
filterBuilder: "筛选器",
|
||||
filterBuilderTitle: "筛选器",
|
||||
|
|
|
|||
|
|
@ -435,6 +435,9 @@ export default {
|
|||
exportFailed: "匯出失敗:{message}",
|
||||
copied: "已複製",
|
||||
copyFailed: "複製失敗:{message}",
|
||||
domRenderMode: "DOM",
|
||||
canvasRenderMode: "Canvas",
|
||||
renderModeHint: "在穩定 DOM 表格和實驗性 Canvas 渲染之間切換。",
|
||||
filter: "篩選",
|
||||
filterBuilder: "篩選器",
|
||||
filterBuilderTitle: "篩選器",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue