feat(grid): add image preview viewer

This commit is contained in:
t8y2 2026-05-18 12:35:34 +08:00
parent cc96e2c38b
commit 29edc4c00a
7 changed files with 355 additions and 2 deletions

View File

@ -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<number | null>(null);
const showTranspose = ref(false);
const transposeScrollRef = ref<HTMLElement | { $el?: HTMLElement }>();
@ -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({
<div class="text-muted-foreground">{{ t("grid.imagePreview") }}</div>
<a
:href="activeCellDetail.imagePreviewUrl"
target="_blank"
rel="noreferrer"
role="button"
class="block overflow-hidden rounded border bg-muted/20"
@click.prevent="openImagePreview(activeCellDetail.imagePreviewUrl, activeCellDetail.column)"
>
<img
:src="activeCellDetail.imagePreviewUrl"
@ -3933,6 +3943,7 @@ defineExpose({
"
@confirm="confirmDeleteRow"
/>
<ImagePreviewDialog v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
</div>
</template>
@ -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);

View File

@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ExternalLink, Maximize2, Minimize2, RotateCw, X, ZoomIn, ZoomOut } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { imagePreviewTransform, nextImagePreviewScale } from "@/lib/imagePreviewViewer";
const props = defineProps<{
open: boolean;
src: string;
title?: string;
}>();
const emit = defineEmits<{
"update:open": [open: boolean];
}>();
const { t } = useI18n();
const scale = ref(1);
const rotation = ref(0);
const offsetX = ref(0);
const offsetY = ref(0);
const imageLoaded = ref(false);
const imageError = ref(false);
const dragStart = ref<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const hostLabel = computed(() => {
try {
const url = new URL(props.src);
return url.protocol === "data:" ? "data:image" : url.host;
} catch {
return "";
}
});
const imageTitle = computed(() => props.title || t("grid.imagePreview"));
const zoomLabel = computed(() => `${Math.round(scale.value * 100)}%`);
const imageStyle = computed(() => ({
transform: `translate(-50%, -50%) ${imagePreviewTransform({
scale: scale.value,
rotation: rotation.value,
offsetX: offsetX.value,
offsetY: offsetY.value,
})}`,
}));
function resetViewer() {
scale.value = 1;
rotation.value = 0;
offsetX.value = 0;
offsetY.value = 0;
imageLoaded.value = false;
imageError.value = false;
dragStart.value = null;
}
function close() {
emit("update:open", false);
}
function zoomIn() {
scale.value = nextImagePreviewScale(scale.value, "in");
}
function zoomOut() {
scale.value = nextImagePreviewScale(scale.value, "out");
}
function fitImage() {
scale.value = 1;
offsetX.value = 0;
offsetY.value = 0;
}
function actualSize() {
scale.value = 1;
offsetX.value = 0;
offsetY.value = 0;
}
function rotateImage() {
rotation.value = (rotation.value + 90) % 360;
}
function onWheel(event: WheelEvent) {
event.preventDefault();
scale.value = nextImagePreviewScale(scale.value, event.deltaY < 0 ? "in" : "out");
}
function onImagePointerDown(event: PointerEvent) {
if (event.button !== 0) return;
dragStart.value = { x: event.clientX, y: event.clientY, offsetX: offsetX.value, offsetY: offsetY.value };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onImagePointerMove(event: PointerEvent) {
if (!dragStart.value) return;
offsetX.value = dragStart.value.offsetX + event.clientX - dragStart.value.x;
offsetY.value = dragStart.value.offsetY + event.clientY - dragStart.value.y;
}
function onImagePointerUp(event: PointerEvent) {
dragStart.value = null;
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
}
function onDoubleClick() {
scale.value = scale.value === 1 ? 2 : 1;
offsetX.value = 0;
offsetY.value = 0;
}
function openExternal() {
window.open(props.src, "_blank", "noopener,noreferrer");
}
watch(
() => [props.open, props.src] as const,
([open]) => {
if (open) resetViewer();
},
);
</script>
<template>
<Dialog :open="open" @update:open="(value) => emit('update:open', value)">
<DialogContent
:show-close-button="false"
class="image-preview-dialog h-[min(86vh,920px)] w-[min(92vw,1280px)] max-w-none gap-0 overflow-hidden rounded-xl border-white/10 bg-[#090b0f] p-0 text-white shadow-2xl"
@escape-key-down="close"
>
<div class="flex h-12 shrink-0 items-center gap-3 border-b border-white/10 bg-white/[0.035] px-4">
<div class="min-w-0 flex-1">
<DialogTitle class="truncate text-sm font-semibold text-white">{{ imageTitle }}</DialogTitle>
<div class="truncate text-[11px] text-white/45">{{ hostLabel || src }}</div>
</div>
<div class="flex items-center gap-1 rounded-md border border-white/10 bg-black/20 p-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.zoomOut')"
@click="zoomOut"
>
<ZoomOut class="h-3.5 w-3.5" />
</Button>
<div class="w-12 text-center text-[11px] tabular-nums text-white/65">{{ zoomLabel }}</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.zoomIn')"
@click="zoomIn"
>
<ZoomIn class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.fitImage')"
@click="fitImage"
>
<Maximize2 class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.actualSize')"
@click="actualSize"
>
<Minimize2 class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.rotateImage')"
@click="rotateImage"
>
<RotateCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-white/75 hover:bg-white/10 hover:text-white"
:title="t('grid.openImage')"
@click="openExternal"
>
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-white/70 hover:bg-white/10 hover:text-white"
:title="t('dangerDialog.cancel')"
@click="close"
>
<X class="h-4 w-4" />
</Button>
</div>
<div
class="image-preview-stage relative min-h-0 flex-1 overflow-hidden"
:class="{ 'cursor-grabbing': dragStart, 'cursor-grab': !dragStart && imageLoaded && !imageError }"
@wheel="onWheel"
>
<div v-if="!imageLoaded && !imageError" class="absolute inset-0 flex items-center justify-center">
<div
class="h-16 w-16 animate-pulse rounded-full border border-white/10 bg-white/10 shadow-[0_0_80px_rgba(255,255,255,0.12)]"
/>
</div>
<div
v-if="imageError"
class="absolute inset-0 flex items-center justify-center px-8 text-center text-sm text-white/55"
>
{{ t("grid.imageLoadFailed") }}
</div>
<img
v-show="!imageError"
:src="src"
:alt="imageTitle"
draggable="false"
decoding="async"
referrerpolicy="no-referrer"
class="absolute left-1/2 top-1/2 max-h-[78vh] max-w-[88vw] select-none object-contain transition-opacity duration-150"
:class="{ 'opacity-100': imageLoaded, 'opacity-0': !imageLoaded }"
:style="imageStyle"
@load="imageLoaded = true"
@error="imageError = true"
@pointerdown="onImagePointerDown"
@pointermove="onImagePointerMove"
@pointerup="onImagePointerUp"
@pointercancel="onImagePointerUp"
@dblclick="onDoubleClick"
/>
</div>
</DialogContent>
</Dialog>
</template>
<style scoped>
.image-preview-stage {
background-color: #07090d;
background-image:
linear-gradient(45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.055) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.055) 75%),
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.08), transparent 42%);
background-position:
0 0,
0 12px,
12px -12px,
-12px 0,
center;
background-size:
24px 24px,
24px 24px,
24px 24px,
24px 24px,
100% 100%;
}
</style>

View File

@ -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",

View File

@ -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",

View File

@ -339,6 +339,13 @@ export default {
cellDetails: "单元格详情",
cellValue: "值",
imagePreview: "图片预览",
imageLoadFailed: "图片加载失败",
zoomIn: "放大",
zoomOut: "缩小",
fitImage: "适应窗口",
actualSize: "原始大小",
rotateImage: "旋转",
openImage: "打开图片",
columnName: "列名",
columnType: "类型",
columnComment: "注释",

View File

@ -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})`;
}

View File

@ -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)",
);
});