feat(grid): add snapshots for selected data
This commit is contained in:
1 parent
e6d25b016e
commit
704f817b4d
14 files changed
+817
No files matched your search
@@ -46,6 +46,7 @@ import {
|
||||
PencilRuler,
|
||||
Settings2,
|
||||
WandSparkles,
|
||||
Camera,
|
||||
} from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -75,6 +76,7 @@ import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWor
|
||||
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
|
||||
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
|
||||
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
|
||||
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
|
||||
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
|
||||
@@ -307,6 +309,7 @@ import { useDataGridSelection } from "@/composables/useDataGridSelection";
|
||||
import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation";
|
||||
import { dataGridInlineBulkEditValue } from "@/lib/dataGrid/dataGridInlineBulkEdit";
|
||||
import type { CellPosition } from "@/lib/dataGrid/gridSelection";
|
||||
import type { GridSnapshotSource } from "@/lib/gridSnapshot/gridSnapshot";
|
||||
import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
|
||||
import { useDataGridEditor } from "@/composables/useDataGridEditor";
|
||||
import { useDataGridSort } from "@/composables/useDataGridSort";
|
||||
@@ -924,6 +927,8 @@ const insertPosition = ref<GridInsertRowPosition>("below");
|
||||
const generateIncrementDialogOpen = ref(false);
|
||||
const generateIncrementStartValue = ref("1");
|
||||
const generateIncrementTarget = ref<"selection" | "detail">("selection");
|
||||
const gridSnapshotOpen = ref(false);
|
||||
const gridSnapshotSource = ref<GridSnapshotSource | null>(null);
|
||||
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
|
||||
const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null);
|
||||
const quickDownloadMenuCell = ref<{ rowIndex: number; col: number } | null>(null);
|
||||
@@ -11684,6 +11689,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const row = contextRowItem.value;
|
||||
const rowLabels = rowActionLabels();
|
||||
const hasEditableSelection = selectionHasEditableCells();
|
||||
const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null;
|
||||
const previewItems: ContextMenuItem[] = [];
|
||||
if (!contextHeaderColumn.value && contextCell.value) {
|
||||
const colType = props.result.column_types?.[contextCell.value.col];
|
||||
@@ -11812,6 +11818,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
children: generateSelectionMenuItems(!hasEditableSelection),
|
||||
},
|
||||
}),
|
||||
[
|
||||
{ label: "", separator: true, visible: gridSnapshotContext !== null },
|
||||
{
|
||||
label: t(gridSnapshotContext === "rows" ? "gridSnapshot.openRows" : gridSnapshotContext === "columns" ? "gridSnapshot.openColumns" : "gridSnapshot.open"),
|
||||
action: openGridSnapshot,
|
||||
icon: Camera,
|
||||
visible: gridSnapshotContext !== null,
|
||||
},
|
||||
],
|
||||
createDataGridRowContextMenuItems({
|
||||
editable: props.editable,
|
||||
hasRow: !!row,
|
||||
@@ -11846,6 +11861,40 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
return gridContextMenuItems.value;
|
||||
}
|
||||
|
||||
function gridSnapshotVisibleColumnIndexes(): number[] {
|
||||
if (selectedCellMatrix.value) return selectedCellMatrix.value.columnIndexes.slice();
|
||||
if (selectedCellKeys.value.size > 0) {
|
||||
return [...new Set([...selectedCellKeys.value].map((key) => Number(key.split(":")[1])).filter((index) => Number.isInteger(index) && index >= 0))].sort((a, b) => a - b);
|
||||
}
|
||||
if (hasColumnSelection.value) return [...selectedColumnIndexes.value].sort((a, b) => a - b);
|
||||
const range = selectedRange.value;
|
||||
return range ? Array.from({ length: range.endCol - range.startCol + 1 }, (_, index) => range.startCol + index) : [];
|
||||
}
|
||||
|
||||
function openGridSnapshot() {
|
||||
const rowSelection = contextCell.value?.col === -1 && hasRowSelection.value;
|
||||
const selectedRows = rowSelection ? new Set(affectedRowIds()) : null;
|
||||
const selection = rowSelection
|
||||
? {
|
||||
columns: visibleColumns.value,
|
||||
rows: visibleDisplayItems.value.filter((item) => selectedRows!.has(item.id)).map((item) => item.data),
|
||||
}
|
||||
: (selectedCellMatrix.value ?? selectedCells.value);
|
||||
if (!selection.columns.length || !selection.rows.length) return;
|
||||
const columnIndexes = rowSelection ? visibleColumns.value.map((_, index) => index) : gridSnapshotVisibleColumnIndexes();
|
||||
gridSnapshotSource.value = {
|
||||
columns: selection.columns.slice(),
|
||||
columnTypes: columnIndexes.map((index) => {
|
||||
const type = visibleColumnTypes.value[index];
|
||||
return type ? shortTypeName(compactHeaderColumnType(type)) : undefined;
|
||||
}),
|
||||
columnDetails: columnIndexes.map((index) => visibleColumnComments.value[index]),
|
||||
rows: selection.rows.map((row) => row.slice()),
|
||||
title: props.exportFileBaseName || props.result.sourceLabel || undefined,
|
||||
};
|
||||
gridSnapshotOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13935,6 +13984,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
||||
|
||||
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
|
||||
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
|
||||
<GridSnapshotDialog v-model:open="gridSnapshotOpen" :source="gridSnapshotSource" />
|
||||
|
||||
<Dialog v-model:open="generateIncrementDialogOpen">
|
||||
<DialogContent class="sm:max-w-[380px]">
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Camera, Check, ClipboardCopy, Download, Moon, RotateCcw, Sun } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { copyPngDataUrlToClipboard, gridSnapshotMetadataControlState, renderGridSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng, type GridSnapshotSource } from "@/lib/gridSnapshot/gridSnapshot";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { isDark } = useTheme();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const props = defineProps<{ source: GridSnapshotSource | null }>();
|
||||
|
||||
const appearance = ref<"light" | "dark">(isDark.value ? "dark" : "light");
|
||||
const showTrafficLights = ref(true);
|
||||
const showFieldNames = ref(true);
|
||||
const showColumnTypes = ref(false);
|
||||
const showColumnDetails = ref(false);
|
||||
const showRowNumbers = ref(true);
|
||||
const wrapCells = ref(false);
|
||||
const transpose = ref(false);
|
||||
const compact = ref(false);
|
||||
const title = ref("");
|
||||
const snapshotHtml = ref("");
|
||||
const previewWrapRef = ref<HTMLDivElement>();
|
||||
const exporting = ref(false);
|
||||
const copied = ref(false);
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cellCount = computed(() => props.source?.rows.reduce((sum, row) => sum + row.length, 0) ?? 0);
|
||||
const hasColumnTypes = computed(() => props.source?.columnTypes?.some(Boolean) === true);
|
||||
const hasColumnDetails = computed(() => props.source?.columnDetails?.some(Boolean) === true);
|
||||
const metadataControls = computed(() =>
|
||||
gridSnapshotMetadataControlState({
|
||||
showFieldNames: showFieldNames.value,
|
||||
hasColumnTypes: hasColumnTypes.value,
|
||||
hasColumnDetails: hasColumnDetails.value,
|
||||
}),
|
||||
);
|
||||
|
||||
watch(
|
||||
metadataControls,
|
||||
(state) => {
|
||||
if (state.columnTypesDisabled) showColumnTypes.value = false;
|
||||
if (state.columnDetailsDisabled) showColumnDetails.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function snapshotRoot(): HTMLElement | null {
|
||||
return previewWrapRef.value?.querySelector<HTMLElement>(".dbx-grid-snapshot") ?? null;
|
||||
}
|
||||
|
||||
function renderSnapshot() {
|
||||
if (!open.value || !props.source) return;
|
||||
snapshotHtml.value = renderGridSnapshotHtml(
|
||||
{ ...props.source, title: title.value.trim() || props.source.title },
|
||||
{
|
||||
appearance: appearance.value,
|
||||
showTrafficLights: showTrafficLights.value,
|
||||
showFieldNames: showFieldNames.value,
|
||||
showColumnTypes: showColumnTypes.value,
|
||||
showColumnDetails: showColumnDetails.value,
|
||||
showRowNumbers: showRowNumbers.value,
|
||||
wrapCells: wrapCells.value,
|
||||
transpose: transpose.value,
|
||||
fieldNameLabel: t("gridSnapshot.fieldNames"),
|
||||
compact: compact.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resetSnapshotOptions() {
|
||||
if (titleDebounceTimer) {
|
||||
clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = null;
|
||||
}
|
||||
appearance.value = isDark.value ? "dark" : "light";
|
||||
showTrafficLights.value = true;
|
||||
showFieldNames.value = true;
|
||||
showColumnTypes.value = false;
|
||||
showColumnDetails.value = false;
|
||||
showRowNumbers.value = true;
|
||||
wrapCells.value = false;
|
||||
transpose.value = false;
|
||||
compact.value = false;
|
||||
title.value = props.source?.title ?? "";
|
||||
copied.value = false;
|
||||
if (copyResetTimer) {
|
||||
clearTimeout(copyResetTimer);
|
||||
copyResetTimer = null;
|
||||
}
|
||||
renderSnapshot();
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (isOpen) resetSnapshotOptions();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch([open, () => props.source, appearance, showTrafficLights, showFieldNames, showColumnTypes, showColumnDetails, showRowNumbers, wrapCells, transpose, compact], renderSnapshot, { immediate: true });
|
||||
watch(title, () => {
|
||||
if (titleDebounceTimer) clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = setTimeout(renderSnapshot, 250);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
if (titleDebounceTimer) clearTimeout(titleDebounceTimer);
|
||||
});
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "-").trim() || "grid-snapshot";
|
||||
}
|
||||
|
||||
async function exportSnapshot(kind: "clipboard" | "file") {
|
||||
const root = snapshotRoot();
|
||||
if (!root || exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
const dataUrl = await snapshotElementToPng(root);
|
||||
if (kind === "clipboard") {
|
||||
await copyPngDataUrlToClipboard(dataUrl);
|
||||
copied.value = true;
|
||||
toast(t("gridSnapshot.copied"));
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyResetTimer = setTimeout(() => (copied.value = false), 2000);
|
||||
} else {
|
||||
const base = sanitizeFileName(title.value || props.source?.title || "grid-snapshot");
|
||||
if (await savePngDataUrlToFile(dataUrl, `${base}-${Date.now()}.png`)) toast(t("gridSnapshot.saved"));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(t("gridSnapshot.failed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[980px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2"><Camera class="h-5 w-5 text-primary" />{{ t("gridSnapshot.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-if="open && props.source" class="flex min-h-0 flex-1 flex-col gap-4 md:flex-row">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
|
||||
<div ref="previewWrapRef" class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3"><div v-html="snapshotHtml" class="flex w-max min-w-[320px] flex-none" /></div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("gridSnapshot.rows", { count: props.source.rows.length }) }}</span
|
||||
><span>{{ t("gridSnapshot.columns", { count: props.source.columns.length }) }}</span
|
||||
><span>{{ t("gridSnapshot.cells", { count: cellCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full shrink-0 space-y-3 md:w-56">
|
||||
<div class="space-y-2">
|
||||
<Label class="block text-[11px] font-medium text-muted-foreground">{{ t("gridSnapshot.appearance") }}</Label>
|
||||
<div class="grid gap-1.5">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.theme") }}</Label>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" class="h-7 flex-1 gap-1 text-[11px]" :class="{ 'border-primary text-primary': appearance === 'light' }" @click="appearance = 'light'"><Sun class="h-3 w-3" />{{ t("codeSnapshot.themeLight") }}</Button
|
||||
><Button size="sm" variant="outline" class="h-7 flex-1 gap-1 text-[11px]" :class="{ 'border-primary text-primary': appearance === 'dark' }" @click="appearance = 'dark'"><Moon class="h-3 w-3" />{{ t("codeSnapshot.themeDark") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.windowControls") }}</Label
|
||||
><Switch v-model="showTrafficLights" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 border-t pt-3">
|
||||
<Label class="block text-[11px] font-medium text-muted-foreground">{{ t("gridSnapshot.layout") }}</Label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.transpose") }}</Label
|
||||
><Switch v-model="transpose" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.fieldNames") }}</Label
|
||||
><Switch v-model="showFieldNames" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.rowNumbers") }}</Label
|
||||
><Switch v-model="showRowNumbers" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.wrapCells") }}</Label
|
||||
><Switch v-model="wrapCells" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.compact") }}</Label
|
||||
><Switch v-model="compact" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 border-t pt-3">
|
||||
<Label class="block text-[11px] font-medium text-muted-foreground">{{ t("gridSnapshot.fieldMetadata") }}</Label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.columnTypes") }}</Label
|
||||
><Switch v-model="showColumnTypes" :disabled="metadataControls.columnTypesDisabled" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.columnDetails") }}</Label
|
||||
><Switch v-model="showColumnDetails" :disabled="metadataControls.columnDetailsDisabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1.5">
|
||||
<Label class="text-xs">{{ t("gridSnapshot.titleLabel") }}</Label
|
||||
><Input v-model="title" class="h-8 text-xs" :placeholder="t('gridSnapshot.titlePlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="sm:justify-between">
|
||||
<Button variant="outline" :disabled="exporting" @click="resetSnapshotOptions"><RotateCcw class="mr-1.5 h-4 w-4" />{{ t("gridSnapshot.reset") }}</Button>
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button variant="outline" @click="open = false">{{ t("codeSnapshot.close") }}</Button>
|
||||
<Button variant="outline" :disabled="exporting" @click="exportSnapshot('clipboard')"><Check v-if="copied" class="mr-1.5 h-4 w-4 text-green-500" /><ClipboardCopy v-else class="mr-1.5 h-4 w-4" />{{ copied ? t("gridSnapshot.copied") : t("codeSnapshot.copy") }}</Button>
|
||||
<Button :disabled="exporting" @click="exportSnapshot('file')"><Download class="mr-1.5 h-4 w-4" />{{ t("codeSnapshot.save") }}</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -226,6 +226,12 @@ function selectAllHeader(host: HTMLElement): HTMLElement {
|
||||
return header;
|
||||
}
|
||||
|
||||
function rowNumber(host: HTMLElement, rowIndex = 0): HTMLElement {
|
||||
const cell = host.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"] .data-grid-row-number`);
|
||||
if (!cell) throw new Error(`Row number not found: ${rowIndex}`);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function openContextMenu(target: HTMLElement) {
|
||||
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 12, clientY: 12 }));
|
||||
}
|
||||
@@ -296,6 +302,32 @@ describe("DataGrid context menu target lifecycle", () => {
|
||||
expect(contextMenuLabels()).toContain("Freeze Selected Columns");
|
||||
});
|
||||
|
||||
it("offers snapshots for full-column selections from the column header", async () => {
|
||||
const { host } = mountGrid(hydratedResult(1, "value"));
|
||||
await settle();
|
||||
|
||||
openContextMenu(columnHeader(host, 1));
|
||||
await settle();
|
||||
|
||||
expect(contextMenuLabels()).toContain("Screenshot selected columns");
|
||||
contextMenuButton("Screenshot selected columns").click();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("Grid Snapshot");
|
||||
});
|
||||
|
||||
it("offers snapshots for full-row selections from the row number", async () => {
|
||||
const { host } = mountGrid(hydratedResult(1, "value"));
|
||||
await settle();
|
||||
|
||||
openContextMenu(rowNumber(host));
|
||||
await settle();
|
||||
|
||||
expect(contextMenuLabels()).toContain("Screenshot selected rows");
|
||||
contextMenuButton("Screenshot selected rows").click();
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain("Grid Snapshot");
|
||||
});
|
||||
|
||||
it.each(["WHERE", "ORDER BY"] as const)("keeps the native context menu for the %s condition editor", async (placeholder) => {
|
||||
const { host } = mountGrid(hydratedResult(1, "value"));
|
||||
await settle();
|
||||
|
||||
@@ -13,4 +13,8 @@ describe("DataGrid selection gesture ownership", () => {
|
||||
const canvasMove = source.slice(source.indexOf("function onCanvasMouseMove"), source.indexOf("function onCanvasMouseLeave"));
|
||||
expect(canvasMove).toContain("stopReleasedSelectionGesture(event);");
|
||||
});
|
||||
|
||||
it("only exposes cell snapshots for selections with a rectangular matrix", () => {
|
||||
expect(source).toMatch(/contextCell\.value && hasCellSelection\.value && selectedCellMatrix\.value\s*\n?\s*\?\s*"cells"/);
|
||||
});
|
||||
});
|
||||
@@ -8890,4 +8890,31 @@ export default {
|
||||
titleLabel: "Title (optional)",
|
||||
titlePlaceholder: "e.g. Query example",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "Screenshot selected cells",
|
||||
openRows: "Screenshot selected rows",
|
||||
openColumns: "Screenshot selected columns",
|
||||
title: "Grid Snapshot",
|
||||
copied: "Copied",
|
||||
saved: "Saved",
|
||||
failed: "Failed: {message}",
|
||||
reset: "Reset",
|
||||
rows: "{count} rows",
|
||||
columns: "{count} columns",
|
||||
cells: "{count} cells",
|
||||
appearance: "Appearance",
|
||||
layout: "Layout",
|
||||
fieldMetadata: "Field Metadata",
|
||||
theme: "Theme",
|
||||
windowControls: "Window Controls",
|
||||
fieldNames: "Field Names",
|
||||
columnTypes: "Column Types",
|
||||
columnDetails: "Field Descriptions",
|
||||
rowNumbers: "Row Numbers",
|
||||
wrapCells: "Wrap Cell Content",
|
||||
transpose: "Transpose",
|
||||
compact: "Compact Cells",
|
||||
titleLabel: "Title (optional)",
|
||||
titlePlaceholder: "e.g. Query result",
|
||||
},
|
||||
};
|
||||
@@ -8632,4 +8632,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "Título (opcional)",
|
||||
titlePlaceholder: "p. ej. Ejemplo de consulta",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "Capturar celdas seleccionadas",
|
||||
openRows: "Capturar filas seleccionadas",
|
||||
openColumns: "Capturar columnas seleccionadas",
|
||||
title: "Captura de cuadrícula",
|
||||
copied: "Copiado",
|
||||
saved: "Guardado",
|
||||
failed: "Error: {message}",
|
||||
reset: "Restablecer",
|
||||
rows: "{count} filas",
|
||||
columns: "{count} columnas",
|
||||
cells: "{count} celdas",
|
||||
appearance: "Apariencia",
|
||||
layout: "Diseño",
|
||||
fieldMetadata: "Metadatos de campos",
|
||||
theme: "Tema",
|
||||
windowControls: "Botones de ventana",
|
||||
fieldNames: "Nombres de campo",
|
||||
columnTypes: "Tipos de campo",
|
||||
columnDetails: "Descripciones de campo",
|
||||
rowNumbers: "Números de fila",
|
||||
wrapCells: "Ajustar contenido de las celdas",
|
||||
transpose: "Transponer",
|
||||
compact: "Celdas compactas",
|
||||
titleLabel: "Título (opcional)",
|
||||
titlePlaceholder: "p. ej. Resultado de consulta",
|
||||
},
|
||||
});
|
||||
@@ -8633,4 +8633,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "Titolo (facoltativo)",
|
||||
titlePlaceholder: "es. Esempio di query",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "Cattura celle selezionate",
|
||||
openRows: "Cattura righe selezionate",
|
||||
openColumns: "Cattura colonne selezionate",
|
||||
title: "Cattura griglia",
|
||||
copied: "Copiato",
|
||||
saved: "Salvato",
|
||||
failed: "Errore: {message}",
|
||||
reset: "Reimposta",
|
||||
rows: "{count} righe",
|
||||
columns: "{count} colonne",
|
||||
cells: "{count} celle",
|
||||
appearance: "Aspetto",
|
||||
layout: "Layout",
|
||||
fieldMetadata: "Metadati dei campi",
|
||||
theme: "Tema",
|
||||
windowControls: "Pulsanti finestra",
|
||||
fieldNames: "Nomi dei campi",
|
||||
columnTypes: "Tipi di campo",
|
||||
columnDetails: "Descrizioni dei campi",
|
||||
rowNumbers: "Numeri di riga",
|
||||
wrapCells: "Testo delle celle a capo",
|
||||
transpose: "Trasponi",
|
||||
compact: "Celle compatte",
|
||||
titleLabel: "Titolo (facoltativo)",
|
||||
titlePlaceholder: "es. Risultato della query",
|
||||
},
|
||||
});
|
||||
@@ -8684,4 +8684,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "タイトル(任意)",
|
||||
titlePlaceholder: "例:クエリの例",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "選択したセルをスナップショット",
|
||||
openRows: "選択した行をスナップショット",
|
||||
openColumns: "選択した列をスナップショット",
|
||||
title: "グリッドスナップショット",
|
||||
copied: "コピーしました",
|
||||
saved: "保存しました",
|
||||
failed: "失敗: {message}",
|
||||
reset: "リセット",
|
||||
rows: "{count} 行",
|
||||
columns: "{count} 列",
|
||||
cells: "{count} セル",
|
||||
appearance: "外観",
|
||||
layout: "レイアウト",
|
||||
fieldMetadata: "フィールドメタデータ",
|
||||
theme: "テーマ",
|
||||
windowControls: "ウィンドウボタン",
|
||||
fieldNames: "フィールド名",
|
||||
columnTypes: "フィールド型",
|
||||
columnDetails: "フィールドの説明",
|
||||
rowNumbers: "行番号",
|
||||
wrapCells: "セル内容を折り返す",
|
||||
transpose: "転置",
|
||||
compact: "コンパクトセル",
|
||||
titleLabel: "タイトル(任意)",
|
||||
titlePlaceholder: "例:クエリ結果",
|
||||
},
|
||||
});
|
||||
@@ -8074,4 +8074,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "제목 (선택)",
|
||||
titlePlaceholder: "예: 쿼리 예시",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "선택한 셀 스냅샷",
|
||||
openRows: "선택한 행 스냅샷",
|
||||
openColumns: "선택한 열 스냅샷",
|
||||
title: "그리드 스냅샷",
|
||||
copied: "복사됨",
|
||||
saved: "저장됨",
|
||||
failed: "실패: {message}",
|
||||
reset: "재설정",
|
||||
rows: "{count}개 행",
|
||||
columns: "{count}개 열",
|
||||
cells: "{count}개 셀",
|
||||
appearance: "모양",
|
||||
layout: "레이아웃",
|
||||
fieldMetadata: "필드 메타데이터",
|
||||
theme: "테마",
|
||||
windowControls: "창 컨트롤",
|
||||
fieldNames: "필드 이름",
|
||||
columnTypes: "필드 유형",
|
||||
columnDetails: "필드 설명",
|
||||
rowNumbers: "행 번호",
|
||||
wrapCells: "셀 내용 줄 바꿈",
|
||||
transpose: "전치",
|
||||
compact: "셀 간격 좁게",
|
||||
titleLabel: "제목 (선택)",
|
||||
titlePlaceholder: "예: 쿼리 결과",
|
||||
},
|
||||
});
|
||||
@@ -8634,4 +8634,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "Título (opcional)",
|
||||
titlePlaceholder: "ex.: Exemplo de consulta",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "Capturar células selecionadas",
|
||||
openRows: "Capturar linhas selecionadas",
|
||||
openColumns: "Capturar colunas selecionadas",
|
||||
title: "Captura da grade",
|
||||
copied: "Copiado",
|
||||
saved: "Salvo",
|
||||
failed: "Falha: {message}",
|
||||
reset: "Redefinir",
|
||||
rows: "{count} linhas",
|
||||
columns: "{count} colunas",
|
||||
cells: "{count} células",
|
||||
appearance: "Aparência",
|
||||
layout: "Layout",
|
||||
fieldMetadata: "Metadados dos campos",
|
||||
theme: "Tema",
|
||||
windowControls: "Botões da janela",
|
||||
fieldNames: "Nomes dos campos",
|
||||
columnTypes: "Tipos de campo",
|
||||
columnDetails: "Descrições dos campos",
|
||||
rowNumbers: "Números das linhas",
|
||||
wrapCells: "Quebrar conteúdo das células",
|
||||
transpose: "Transpor",
|
||||
compact: "Células compactas",
|
||||
titleLabel: "Título (opcional)",
|
||||
titlePlaceholder: "ex.: Resultado da consulta",
|
||||
},
|
||||
});
|
||||
@@ -8871,4 +8871,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "标题(可选)",
|
||||
titlePlaceholder: "例如:查询示例",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "截图选中区域",
|
||||
openRows: "截图选中行",
|
||||
openColumns: "截图选中列",
|
||||
title: "网格截图",
|
||||
copied: "已复制",
|
||||
saved: "已保存",
|
||||
failed: "操作失败:{message}",
|
||||
reset: "重置",
|
||||
rows: "{count} 行",
|
||||
columns: "{count} 列",
|
||||
cells: "{count} 个单元格",
|
||||
appearance: "外观",
|
||||
layout: "布局",
|
||||
fieldMetadata: "字段元数据",
|
||||
theme: "主题",
|
||||
windowControls: "窗口按钮",
|
||||
fieldNames: "字段名",
|
||||
columnTypes: "字段类型",
|
||||
columnDetails: "字段描述",
|
||||
rowNumbers: "行号",
|
||||
wrapCells: "单元格自动换行",
|
||||
transpose: "转置",
|
||||
compact: "紧凑单元格",
|
||||
titleLabel: "标题(可选)",
|
||||
titlePlaceholder: "例如:查询结果",
|
||||
},
|
||||
});
|
||||
@@ -8623,4 +8623,31 @@ export default withEnglishFallback({
|
||||
titleLabel: "標題(可選)",
|
||||
titlePlaceholder: "例如:查詢範例",
|
||||
},
|
||||
gridSnapshot: {
|
||||
open: "截圖選取區域",
|
||||
openRows: "截圖選取列",
|
||||
openColumns: "截圖選取欄",
|
||||
title: "網格截圖",
|
||||
copied: "已複製",
|
||||
saved: "已儲存",
|
||||
failed: "操作失敗:{message}",
|
||||
reset: "重設",
|
||||
rows: "{count} 列",
|
||||
columns: "{count} 欄",
|
||||
cells: "{count} 個儲存格",
|
||||
appearance: "外觀",
|
||||
layout: "版面配置",
|
||||
fieldMetadata: "欄位中繼資料",
|
||||
theme: "主題",
|
||||
windowControls: "視窗按鈕",
|
||||
fieldNames: "欄位名稱",
|
||||
columnTypes: "欄位類型",
|
||||
columnDetails: "欄位說明",
|
||||
rowNumbers: "列號",
|
||||
wrapCells: "儲存格內容自動換行",
|
||||
transpose: "轉置",
|
||||
compact: "緊湊儲存格",
|
||||
titleLabel: "標題(可選)",
|
||||
titlePlaceholder: "例如:查詢結果",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatGridSnapshotCell, gridSnapshotMetadataControlState, renderGridSnapshotHtml } from "@/lib/gridSnapshot/gridSnapshot";
|
||||
|
||||
describe("grid snapshot rendering", () => {
|
||||
it("enables field metadata only when field names are visible", () => {
|
||||
expect(gridSnapshotMetadataControlState({ showFieldNames: true, hasColumnTypes: true, hasColumnDetails: true })).toEqual({ columnTypesDisabled: false, columnDetailsDisabled: false });
|
||||
expect(gridSnapshotMetadataControlState({ showFieldNames: false, hasColumnTypes: true, hasColumnDetails: true })).toEqual({ columnTypesDisabled: true, columnDetailsDisabled: true });
|
||||
expect(gridSnapshotMetadataControlState({ showFieldNames: true, hasColumnTypes: false, hasColumnDetails: false })).toEqual({ columnTypesDisabled: true, columnDetailsDisabled: true });
|
||||
});
|
||||
|
||||
it("formats primitive values and NULL consistently", () => {
|
||||
expect(formatGridSnapshotCell(null)).toBe("NULL");
|
||||
expect(formatGridSnapshotCell(true)).toBe("true");
|
||||
expect(formatGridSnapshotCell(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("renders an escaped, self-contained table with optional headers and row numbers", () => {
|
||||
const html = renderGridSnapshotHtml(
|
||||
{
|
||||
columns: ["id", "name"],
|
||||
columnTypes: ["integer", "varchar"],
|
||||
columnDetails: ["Primary key", "Display name"],
|
||||
rows: [
|
||||
[1, "<Ada>"],
|
||||
[2, null],
|
||||
],
|
||||
title: "Users",
|
||||
},
|
||||
{ appearance: "light", showTrafficLights: false, showFieldNames: true, showRowNumbers: true },
|
||||
);
|
||||
expect(html).toContain("dbx-grid-snapshot");
|
||||
expect(html).toContain("<Ada>");
|
||||
expect(html).toContain(">NULL</td>");
|
||||
expect(html).toContain(">#</th>");
|
||||
expect(html).toContain(">1</td>");
|
||||
expect(html).not.toContain("#ff5f57");
|
||||
expect(html).not.toContain("integer");
|
||||
expect(html).not.toContain("Primary key");
|
||||
expect(html).toContain("width: max-content");
|
||||
expect(html).toContain("border-collapse: separate");
|
||||
expect(html).not.toMatch(/<td[^>]*dbx-grid-snapshot__cell--wrapped/);
|
||||
});
|
||||
|
||||
it("optionally renders column types and details below field names", () => {
|
||||
const html = renderGridSnapshotHtml(
|
||||
{
|
||||
columns: ["id"],
|
||||
columnTypes: ["integer"],
|
||||
columnDetails: ["Primary key"],
|
||||
rows: [[1]],
|
||||
},
|
||||
{ appearance: "dark", showColumnTypes: true, showColumnDetails: true, wrapCells: true },
|
||||
);
|
||||
expect(html).toContain("dbx-grid-snapshot__column-meta");
|
||||
expect(html).toContain(">integer</span>");
|
||||
expect(html).toContain(">Primary key</span>");
|
||||
expect(html).toMatch(/<td[^>]*dbx-grid-snapshot__cell--wrapped/);
|
||||
});
|
||||
|
||||
it("can render a compact data-only view", () => {
|
||||
const html = renderGridSnapshotHtml({ columns: ["value"], rows: [["x"]] }, { appearance: "dark", showFieldNames: false, showRowNumbers: false, compact: true });
|
||||
expect(html).not.toContain("<thead>");
|
||||
expect(html).not.toMatch(/<td[^>]*dbx-grid-snapshot__cell--row-number/);
|
||||
expect(html).toContain("font-size:12px");
|
||||
});
|
||||
|
||||
it("transposes fields into rows while keeping field metadata attached", () => {
|
||||
const html = renderGridSnapshotHtml(
|
||||
{
|
||||
columns: ["id", "name"],
|
||||
columnTypes: ["integer", "varchar"],
|
||||
columnDetails: ["Primary key", "Display name"],
|
||||
rows: [
|
||||
[1, "Ada"],
|
||||
[2, "Bob"],
|
||||
],
|
||||
},
|
||||
{ appearance: "light", transpose: true, fieldNameLabel: "Field Names", showColumnTypes: true, showColumnDetails: true },
|
||||
);
|
||||
expect(html).toContain(">Field Names</span>");
|
||||
expect(html).toContain(">#1</span>");
|
||||
expect(html).toContain(">#2</span>");
|
||||
expect(html).toContain(">id</span>");
|
||||
expect(html).toContain(">integer</span>");
|
||||
expect(html).toContain(">Primary key</span>");
|
||||
expect(html).toContain(">Ada</td>");
|
||||
expect(html).toContain(">Bob</td>");
|
||||
});
|
||||
|
||||
it("hides the field-name placement while preserving transposed row identifiers", () => {
|
||||
const source = {
|
||||
columns: ["id", "name"],
|
||||
columnTypes: ["integer", "varchar"],
|
||||
columnDetails: ["Primary key", "Display name"],
|
||||
rows: [
|
||||
[1, "Ada"],
|
||||
[2, "Bob"],
|
||||
],
|
||||
};
|
||||
const transposed = renderGridSnapshotHtml(source, { appearance: "light", transpose: true, fieldNameLabel: "Field Names", showFieldNames: false, showColumnTypes: true, showColumnDetails: true, showRowNumbers: false });
|
||||
expect(transposed).toContain("<thead>");
|
||||
expect(transposed).toContain(">#1</span>");
|
||||
expect(transposed).toContain(">#2</span>");
|
||||
expect(transposed).not.toContain("Field Names");
|
||||
expect(transposed).not.toContain(">id</span>");
|
||||
expect(transposed).not.toContain(">name</span>");
|
||||
expect(transposed).not.toContain("integer");
|
||||
expect(transposed).not.toContain("Primary key");
|
||||
expect(transposed).toContain(">Ada</td>");
|
||||
expect(transposed).toContain(">Bob</td>");
|
||||
|
||||
const regular = renderGridSnapshotHtml(source, { appearance: "light", showFieldNames: false, showColumnTypes: true, showColumnDetails: true });
|
||||
expect(regular).not.toContain("<thead>");
|
||||
expect(regular).not.toContain("integer");
|
||||
expect(regular).not.toContain("Primary key");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { AppThemeAppearance } from "@/lib/app/appTheme";
|
||||
import { snapshotElementToPng, copyPngDataUrlToClipboard, savePngDataUrlToFile } from "@/lib/codeSnapshot/codeSnapshot";
|
||||
|
||||
export type GridSnapshotCellValue = string | number | boolean | null;
|
||||
|
||||
export interface GridSnapshotSource {
|
||||
columns: string[];
|
||||
columnTypes?: Array<string | undefined>;
|
||||
columnDetails?: Array<string | undefined>;
|
||||
rows: GridSnapshotCellValue[][];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface GridSnapshotStyleOptions {
|
||||
appearance: AppThemeAppearance;
|
||||
showTrafficLights?: boolean;
|
||||
showFieldNames?: boolean;
|
||||
showColumnTypes?: boolean;
|
||||
showColumnDetails?: boolean;
|
||||
showRowNumbers?: boolean;
|
||||
wrapCells?: boolean;
|
||||
transpose?: boolean;
|
||||
fieldNameLabel?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export interface GridSnapshotMetadataControlState {
|
||||
columnTypesDisabled: boolean;
|
||||
columnDetailsDisabled: boolean;
|
||||
}
|
||||
|
||||
export function gridSnapshotMetadataControlState(options: { showFieldNames: boolean; hasColumnTypes: boolean; hasColumnDetails: boolean }): GridSnapshotMetadataControlState {
|
||||
return {
|
||||
columnTypesDisabled: !options.showFieldNames || !options.hasColumnTypes,
|
||||
columnDetailsDisabled: !options.showFieldNames || !options.hasColumnDetails,
|
||||
};
|
||||
}
|
||||
|
||||
const GRID_SNAPSHOT_BACKGROUND: Record<AppThemeAppearance, string> = {
|
||||
light: "#ffffff",
|
||||
dark: "#0d1117",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_BAR_BACKGROUND: Record<AppThemeAppearance, string> = {
|
||||
light: "#f6f8fa",
|
||||
dark: "#161b22",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_TEXT: Record<AppThemeAppearance, string> = {
|
||||
light: "#24292f",
|
||||
dark: "#e6edf3",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_MUTED: Record<AppThemeAppearance, string> = {
|
||||
light: "#57606a",
|
||||
dark: "#8b949e",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_BORDER: Record<AppThemeAppearance, string> = {
|
||||
light: "#d0d7de",
|
||||
dark: "#30363d",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_HEADER: Record<AppThemeAppearance, string> = {
|
||||
light: "rgb(239, 239, 239)",
|
||||
dark: "rgb(32, 32, 34)",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_STRIPED_ROW: Record<AppThemeAppearance, string> = {
|
||||
light: "rgb(240, 240, 240)",
|
||||
dark: "rgb(40, 40, 43)",
|
||||
};
|
||||
|
||||
const GRID_SNAPSHOT_ROW_NUMBER: Record<AppThemeAppearance, string> = {
|
||||
light: "rgb(255, 255, 255)",
|
||||
dark: "rgb(35, 37, 42)",
|
||||
};
|
||||
|
||||
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
|
||||
|
||||
export const GRID_SNAPSHOT_CSS = `
|
||||
.dbx-grid-snapshot,
|
||||
.dbx-grid-snapshot * { box-sizing: border-box; }
|
||||
.dbx-grid-snapshot {
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border: 1px solid var(--dbx-grid-snapshot-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
text-align: left;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
|
||||
}
|
||||
.dbx-grid-snapshot__bar { display: flex; width: 100%; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--dbx-grid-snapshot-border); }
|
||||
.dbx-grid-snapshot__dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
|
||||
.dbx-grid-snapshot__title { margin-left: 4px; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dbx-grid-snapshot__table { border-collapse: separate; border-spacing: 0; table-layout: auto; width: 100%; }
|
||||
.dbx-grid-snapshot__cell { border-right: 1px solid var(--dbx-grid-snapshot-border); border-bottom: 1px solid var(--dbx-grid-snapshot-border); padding: 7px 10px; white-space: nowrap; vertical-align: top; }
|
||||
.dbx-grid-snapshot__cell--wrapped { max-width: 420px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.dbx-grid-snapshot__cell:last-child { border-right: 0; }
|
||||
.dbx-grid-snapshot__table tbody tr:last-child .dbx-grid-snapshot__cell { border-bottom: 0; }
|
||||
.dbx-grid-snapshot__cell--header { font-weight: 600; white-space: nowrap; }
|
||||
.dbx-grid-snapshot__column { display: flex; min-width: 0; flex-direction: column; }
|
||||
.dbx-grid-snapshot__column-name { line-height: 16px; }
|
||||
.dbx-grid-snapshot__column-meta { min-height: 12px; font-size: 10px; font-weight: 400; line-height: 12px; }
|
||||
.dbx-grid-snapshot__cell--row-number { width: 1%; min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; user-select: none; }
|
||||
.dbx-grid-snapshot__cell--null { font-style: italic; }
|
||||
`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function formatGridSnapshotCell(value: GridSnapshotCellValue): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppearance, showTrafficLights: boolean): string {
|
||||
const dots = showTrafficLights ? TRAFFIC_LIGHT_COLORS.map((color) => `<span class="dbx-grid-snapshot__dot" style="background:${color}"></span>`).join("") : "";
|
||||
const titleHtml = title ? `<span class="dbx-grid-snapshot__title" style="color:${GRID_SNAPSHOT_MUTED[appearance]}">${escapeHtml(title)}</span>` : "";
|
||||
return `<div class="dbx-grid-snapshot__bar" style="background:${GRID_SNAPSHOT_BAR_BACKGROUND[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]}">${dots}${titleHtml}</div>`;
|
||||
}
|
||||
|
||||
/** Render a self-contained snapshot table for the selected grid cells. */
|
||||
export function renderGridSnapshotHtml(source: GridSnapshotSource, options: GridSnapshotStyleOptions): string {
|
||||
const appearance = options.appearance;
|
||||
const showFieldNames = options.showFieldNames !== false;
|
||||
const transpose = options.transpose === true;
|
||||
const showColumnTypes = options.showColumnTypes === true && showFieldNames;
|
||||
const showColumnDetails = options.showColumnDetails === true && showFieldNames;
|
||||
const showRowNumbers = options.showRowNumbers !== false;
|
||||
const wrapCells = options.wrapCells === true;
|
||||
const compact = options.compact === true;
|
||||
const cellPadding = compact ? "3px 8px" : "4px 12px";
|
||||
const headerPadding = compact ? "4px 8px" : "6px 8px";
|
||||
const fontSize = compact ? "12px" : "13px";
|
||||
const rowNumberHeader = showRowNumbers ? `<th class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--header dbx-grid-snapshot__cell--row-number" style="background:${GRID_SNAPSHOT_HEADER[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]};padding:${headerPadding}">#</th>` : "";
|
||||
const renderFieldCell = (column: string, columnIndex: number, tag: "th" | "td") => {
|
||||
const type = showColumnTypes ? source.columnTypes?.[columnIndex] : undefined;
|
||||
const detail = showColumnDetails ? source.columnDetails?.[columnIndex] : undefined;
|
||||
return `<${tag} class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--header" style="background:${GRID_SNAPSHOT_HEADER[appearance]};color:${GRID_SNAPSHOT_TEXT[appearance]};padding:${headerPadding}"><span class="dbx-grid-snapshot__column"><span class="dbx-grid-snapshot__column-name">${escapeHtml(column)}</span>${type ? `<span class="dbx-grid-snapshot__column-meta" style="color:${GRID_SNAPSHOT_MUTED[appearance]}">${escapeHtml(type)}</span>` : ""}${detail ? `<span class="dbx-grid-snapshot__column-meta" style="color:${GRID_SNAPSHOT_MUTED[appearance]}">${escapeHtml(detail)}</span>` : ""}</span></${tag}>`;
|
||||
};
|
||||
const header = transpose
|
||||
? `<thead><tr>${rowNumberHeader}${showFieldNames ? `<th class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--header" style="background:${GRID_SNAPSHOT_HEADER[appearance]};color:${GRID_SNAPSHOT_TEXT[appearance]};padding:${headerPadding}"><span class="dbx-grid-snapshot__column"><span class="dbx-grid-snapshot__column-name">${escapeHtml(options.fieldNameLabel ?? "Field Names")}</span></span></th>` : ""}${source.rows.map((_, rowIndex) => `<th class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--header" style="background:${GRID_SNAPSHOT_HEADER[appearance]};color:${GRID_SNAPSHOT_TEXT[appearance]};padding:${headerPadding}"><span class="dbx-grid-snapshot__column"><span class="dbx-grid-snapshot__column-name">#${rowIndex + 1}</span></span></th>`).join("")}</tr></thead>`
|
||||
: showFieldNames
|
||||
? `<thead><tr>${rowNumberHeader}${source.columns.map((column, columnIndex) => renderFieldCell(column, columnIndex, "th")).join("")}</tr></thead>`
|
||||
: "";
|
||||
const displayRows: GridSnapshotCellValue[][] = transpose ? source.columns.map((_, columnIndex) => source.rows.map((row) => row[columnIndex] ?? null)) : source.rows;
|
||||
const body = displayRows
|
||||
.map(
|
||||
(row, rowIndex) =>
|
||||
`<tr style="background:${rowIndex % 2 === 1 ? GRID_SNAPSHOT_STRIPED_ROW[appearance] : GRID_SNAPSHOT_BACKGROUND[appearance]}">${showRowNumbers ? `<td class="dbx-grid-snapshot__cell dbx-grid-snapshot__cell--row-number" style="background:${GRID_SNAPSHOT_ROW_NUMBER[appearance]};color:${GRID_SNAPSHOT_MUTED[appearance]};padding:${cellPadding}">${rowIndex + 1}</td>` : ""}${transpose && showFieldNames ? renderFieldCell(source.columns[rowIndex] ?? "", rowIndex, "td") : ""}${row
|
||||
.map((value) => {
|
||||
const nullClass = value === null ? " dbx-grid-snapshot__cell--null" : "";
|
||||
return `<td class="dbx-grid-snapshot__cell${wrapCells ? " dbx-grid-snapshot__cell--wrapped" : ""}${nullClass}" style="color:${value === null ? GRID_SNAPSHOT_MUTED[appearance] : GRID_SNAPSHOT_TEXT[appearance]};padding:${cellPadding}">${escapeHtml(formatGridSnapshotCell(value))}</td>`;
|
||||
})
|
||||
.join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const bar = options.showTrafficLights !== false || source.title ? renderSnapshotBar(source.title, appearance, options.showTrafficLights !== false) : "";
|
||||
return `<style>${GRID_SNAPSHOT_CSS}</style><div class="dbx-grid-snapshot" data-snapshot-appearance="${appearance}" style="background:${GRID_SNAPSHOT_BACKGROUND[appearance]};font-size:${fontSize};color:${GRID_SNAPSHOT_TEXT[appearance]};--dbx-grid-snapshot-border:${GRID_SNAPSHOT_BORDER[appearance]}">${bar}<table class="dbx-grid-snapshot__table">${header}<tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
export { snapshotElementToPng, copyPngDataUrlToClipboard, savePngDataUrlToFile };
|
||||
Reference in new issue
Block a user