feat(grid): preview image URLs in cell details

This commit is contained in:
t8y2
2026-05-18 12:27:44 +08:00
parent c5fc31c218
commit cc96e2c38b
6 changed files with 120 additions and 1 deletions
+58 -1
View File
@@ -91,6 +91,7 @@ import {
} from "@/lib/dataGridTranspose";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { cellImagePreviewUrl } from "@/lib/cellImageUrl";
import {
applyColumnFormatter,
buildColumnFormatterKey,
@@ -253,6 +254,8 @@ function typeColorClass(t: string): string {
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const showCellDetail = ref(false);
const detailWidth = ref(320);
const isResizingDetail = ref(false);
const transposeRowIndex = ref<number | null>(null);
const showTranspose = ref(false);
const transposeScrollRef = ref<HTMLElement | { $el?: HTMLElement }>();
@@ -1596,6 +1599,7 @@ const activeCellDetail = computed(() => {
value,
rawValue,
displayValue,
imagePreviewUrl: cellImagePreviewUrl(value),
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: canEditCellItem(item, cell.col),
@@ -2143,6 +2147,8 @@ const ddlWrap = ref(true);
const isResizingDdl = ref(false);
let ddlResizeStartX = 0;
let ddlResizeStartWidth = 0;
let detailResizeStartX = 0;
let detailResizeStartWidth = 0;
const indexes = ref<IndexInfo[]>([]);
const indexesLoaded = ref(false);
const indexesLoading = ref(false);
@@ -2160,6 +2166,10 @@ const ddlDrawerStyle = computed(() => ({
width: `${ddlWidth.value}px`,
}));
const detailDrawerStyle = computed(() => ({
width: `${detailWidth.value}px`,
}));
const tableInfoTabs = computed(
() =>
[
@@ -2325,6 +2335,28 @@ function onDdlResizeEnd() {
window.removeEventListener("mouseup", onDdlResizeEnd);
}
function onDetailResizeStart(event: MouseEvent) {
isResizingDetail.value = true;
detailResizeStartX = event.clientX;
detailResizeStartWidth = detailWidth.value;
document.body.classList.add("select-none", "cursor-col-resize");
window.addEventListener("mousemove", onDetailResizeMove);
window.addEventListener("mouseup", onDetailResizeEnd);
}
function onDetailResizeMove(event: MouseEvent) {
if (!isResizingDetail.value) return;
const nextWidth = detailResizeStartWidth + detailResizeStartX - event.clientX;
detailWidth.value = Math.min(Math.max(nextWidth, 260), 900);
}
function onDetailResizeEnd() {
isResizingDetail.value = false;
document.body.classList.remove("select-none", "cursor-col-resize");
window.removeEventListener("mousemove", onDetailResizeMove);
window.removeEventListener("mouseup", onDetailResizeEnd);
}
const loadingElapsed = ref(0);
let _loadingTimer: ReturnType<typeof setInterval> | undefined;
let _loadingStart = 0;
@@ -2346,6 +2378,7 @@ watch(
onUnmounted(() => {
cleanupFrames();
onDdlResizeEnd();
onDetailResizeEnd();
finishCellSelection();
clearTimeout(_searchTimer);
clearInterval(_loadingTimer);
@@ -3537,8 +3570,14 @@ defineExpose({
<!-- Cell Detail Drawer -->
<div
v-if="showCellDetail && activeCellDetail"
class="relative w-80 shrink-0 border-l flex flex-col bg-background min-w-0"
class="relative shrink-0 border-l flex flex-col bg-background min-w-0"
:class="{ 'detail-drawer-resizing': isResizingDetail }"
:style="detailDrawerStyle"
>
<div
class="absolute left-0 top-0 bottom-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30"
@mousedown.prevent="onDetailResizeStart"
/>
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
<Info class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
@@ -3582,6 +3621,24 @@ defineExpose({
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div v-if="activeCellDetail.imagePreviewUrl && !isEditingDetail" class="space-y-1.5">
<div class="text-muted-foreground">{{ t("grid.imagePreview") }}</div>
<a
:href="activeCellDetail.imagePreviewUrl"
target="_blank"
rel="noreferrer"
class="block overflow-hidden rounded border bg-muted/20"
>
<img
:src="activeCellDetail.imagePreviewUrl"
:alt="activeCellDetail.column"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="max-h-72 w-full object-contain"
/>
</a>
</div>
<template v-if="isEditingDetail">
<textarea
v-model="detailEditValue"
+1
View File
@@ -342,6 +342,7 @@ export default {
selectedCells: "{count} selected",
cellDetails: "Cell Details",
cellValue: "Value",
imagePreview: "Image Preview",
columnName: "Column",
columnType: "Type",
columnComment: "Comment",
+1
View File
@@ -318,6 +318,7 @@ export default {
selectedCells: "{count} seleccionadas",
cellDetails: "Detalles de celda",
cellValue: "Valor",
imagePreview: "Vista previa de imagen",
columnName: "Columna",
columnType: "Tipo",
columnComment: "Comentario",
+1
View File
@@ -338,6 +338,7 @@ export default {
selectedCells: "已选 {count} 个单元格",
cellDetails: "单元格详情",
cellValue: "值",
imagePreview: "图片预览",
columnName: "列名",
columnType: "类型",
columnComment: "注释",
+27
View File
@@ -0,0 +1,27 @@
import type { CellValue } from "@/lib/cellValue";
const IMAGE_PATH_RE = /\.(?:png|jpe?g|webp|gif|avif|bmp|svg)$/i;
const SAFE_DATA_IMAGE_RE = /^data:image\/(?:png|jpe?g|webp|gif|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
function isLocalHttpHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
export function cellImagePreviewUrl(value: CellValue): string | null {
if (typeof value !== "string") return null;
const text = value.trim();
if (!text) return null;
if (SAFE_DATA_IMAGE_RE.test(text)) return text;
let url: URL;
try {
url = new URL(text);
} catch {
return null;
}
if (url.protocol === "http:" && !isLocalHttpHost(url.hostname)) return null;
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
if (!IMAGE_PATH_RE.test(url.pathname)) return null;
return text;
}
+32
View File
@@ -0,0 +1,32 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { cellImagePreviewUrl } from "../../apps/desktop/src/lib/cellImageUrl.ts";
test("detects obvious remote image URLs", () => {
assert.equal(cellImagePreviewUrl("https://cdn.example.com/avatar/user.png"), "https://cdn.example.com/avatar/user.png");
assert.equal(
cellImagePreviewUrl(" https://cdn.example.com/photo.JPG?width=320#preview "),
"https://cdn.example.com/photo.JPG?width=320#preview",
);
assert.equal(cellImagePreviewUrl("https://cdn.example.com/image.webp"), "https://cdn.example.com/image.webp");
});
test("allows localhost HTTP image URLs for development data", () => {
assert.equal(cellImagePreviewUrl("http://localhost:3000/image.gif"), "http://localhost:3000/image.gif");
assert.equal(cellImagePreviewUrl("http://127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg");
});
test("rejects non-image and unsafe URLs", () => {
assert.equal(cellImagePreviewUrl("https://example.com/page"), null);
assert.equal(cellImagePreviewUrl("https://example.com/image.txt"), null);
assert.equal(cellImagePreviewUrl("http://example.com/image.png"), null);
assert.equal(cellImagePreviewUrl("file:///tmp/image.png"), null);
assert.equal(cellImagePreviewUrl("javascript:alert(1)"), null);
assert.equal(cellImagePreviewUrl(42), null);
assert.equal(cellImagePreviewUrl(null), null);
});
test("detects safe data image URLs", () => {
assert.equal(cellImagePreviewUrl("data:image/png;base64,abc123"), "data:image/png;base64,abc123");
assert.equal(cellImagePreviewUrl("data:image/svg+xml;base64,abc123"), null);
});