From 29edc4c00a56adaabf6b0d9d260ef1e9ea4c5d60 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 18 May 2026 12:35:34 +0800 Subject: [PATCH] feat(grid): add image preview viewer --- apps/desktop/src/components/grid/DataGrid.vue | 19 +- .../components/grid/ImagePreviewDialog.vue | 267 ++++++++++++++++++ apps/desktop/src/i18n/locales/en.ts | 7 + apps/desktop/src/i18n/locales/es.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 7 + apps/desktop/src/lib/imagePreviewViewer.ts | 24 ++ packages/app-tests/imagePreviewViewer.test.ts | 26 ++ 7 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/grid/ImagePreviewDialog.vue create mode 100644 apps/desktop/src/lib/imagePreviewViewer.ts create mode 100644 packages/app-tests/imagePreviewViewer.test.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 722b36b718..4c7e1bf97f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -65,6 +65,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; +import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue"; import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database"; import * as api from "@/lib/api"; import { @@ -256,6 +257,9 @@ const detailCell = ref<{ rowIndex: number; col: number } | null>(null); const showCellDetail = ref(false); const detailWidth = ref(320); const isResizingDetail = ref(false); +const imagePreviewOpen = ref(false); +const imagePreviewSrc = ref(""); +const imagePreviewTitle = ref(""); const transposeRowIndex = ref(null); const showTranspose = ref(false); const transposeScrollRef = ref(); @@ -1898,6 +1902,12 @@ function showCellDetails(rowIndex: number, colIndex: number) { showCellDetail.value = true; } +function openImagePreview(src: string, title: string) { + imagePreviewSrc.value = src; + imagePreviewTitle.value = title; + imagePreviewOpen.value = true; +} + function eventTargetAllowsNativeClipboard(event: KeyboardEvent): boolean { const target = event.target as HTMLElement | null; return !!target?.closest("input, textarea, [contenteditable='true'], [role='textbox']"); @@ -3625,9 +3635,9 @@ defineExpose({
{{ t("grid.imagePreview") }}
+ @@ -3974,6 +3985,10 @@ defineExpose({ transition: none; } +.detail-drawer-resizing { + transition: none; +} + .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); diff --git a/apps/desktop/src/components/grid/ImagePreviewDialog.vue b/apps/desktop/src/components/grid/ImagePreviewDialog.vue new file mode 100644 index 0000000000..4449194003 --- /dev/null +++ b/apps/desktop/src/components/grid/ImagePreviewDialog.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3740e1b051..dc328c8c16 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -343,6 +343,13 @@ export default { cellDetails: "Cell Details", cellValue: "Value", imagePreview: "Image Preview", + imageLoadFailed: "Image failed to load", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", + fitImage: "Fit to Window", + actualSize: "Actual Size", + rotateImage: "Rotate", + openImage: "Open Image", columnName: "Column", columnType: "Type", columnComment: "Comment", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 49e3d3e88a..9f1f881d91 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -319,6 +319,13 @@ export default { cellDetails: "Detalles de celda", cellValue: "Valor", imagePreview: "Vista previa de imagen", + imageLoadFailed: "No se pudo cargar la imagen", + zoomIn: "Acercar", + zoomOut: "Alejar", + fitImage: "Ajustar a ventana", + actualSize: "Tamaño real", + rotateImage: "Rotar", + openImage: "Abrir imagen", columnName: "Columna", columnType: "Tipo", columnComment: "Comentario", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 881e676932..074c654c2b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -339,6 +339,13 @@ export default { cellDetails: "单元格详情", cellValue: "值", imagePreview: "图片预览", + imageLoadFailed: "图片加载失败", + zoomIn: "放大", + zoomOut: "缩小", + fitImage: "适应窗口", + actualSize: "原始大小", + rotateImage: "旋转", + openImage: "打开图片", columnName: "列名", columnType: "类型", columnComment: "注释", diff --git a/apps/desktop/src/lib/imagePreviewViewer.ts b/apps/desktop/src/lib/imagePreviewViewer.ts new file mode 100644 index 0000000000..cbe3f73fe6 --- /dev/null +++ b/apps/desktop/src/lib/imagePreviewViewer.ts @@ -0,0 +1,24 @@ +export const IMAGE_PREVIEW_MIN_SCALE = 0.2; +export const IMAGE_PREVIEW_MAX_SCALE = 8; +export const IMAGE_PREVIEW_ZOOM_STEP = 0.2; + +export type ImagePreviewZoomDirection = "in" | "out"; + +export function clampImagePreviewScale(scale: number): number { + if (!Number.isFinite(scale)) return 1; + return Math.min(Math.max(Number(scale.toFixed(2)), IMAGE_PREVIEW_MIN_SCALE), IMAGE_PREVIEW_MAX_SCALE); +} + +export function nextImagePreviewScale(scale: number, direction: ImagePreviewZoomDirection): number { + const delta = direction === "in" ? IMAGE_PREVIEW_ZOOM_STEP : -IMAGE_PREVIEW_ZOOM_STEP; + return clampImagePreviewScale(scale + delta); +} + +export function imagePreviewTransform(options: { + scale: number; + rotation: number; + offsetX: number; + offsetY: number; +}): string { + return `translate(${options.offsetX}px, ${options.offsetY}px) rotate(${options.rotation}deg) scale(${options.scale})`; +} diff --git a/packages/app-tests/imagePreviewViewer.test.ts b/packages/app-tests/imagePreviewViewer.test.ts new file mode 100644 index 0000000000..1a89addbba --- /dev/null +++ b/packages/app-tests/imagePreviewViewer.test.ts @@ -0,0 +1,26 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + clampImagePreviewScale, + imagePreviewTransform, + nextImagePreviewScale, +} from "../../apps/desktop/src/lib/imagePreviewViewer.ts"; + +test("clamps image preview zoom to usable bounds", () => { + assert.equal(clampImagePreviewScale(0.1), 0.2); + assert.equal(clampImagePreviewScale(1.25), 1.25); + assert.equal(clampImagePreviewScale(12), 8); +}); + +test("zooms in fixed steps from wheel direction", () => { + assert.equal(nextImagePreviewScale(1, "in"), 1.2); + assert.equal(nextImagePreviewScale(1, "out"), 0.8); + assert.equal(nextImagePreviewScale(7.95, "in"), 8); +}); + +test("builds a stable CSS transform for image previews", () => { + assert.equal( + imagePreviewTransform({ scale: 1.5, rotation: 90, offsetX: 12, offsetY: -8 }), + "translate(12px, -8px) rotate(90deg) scale(1.5)", + ); +});