mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(grid): preview image URLs in cell details
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -342,6 +342,7 @@ export default {
|
||||
selectedCells: "{count} selected",
|
||||
cellDetails: "Cell Details",
|
||||
cellValue: "Value",
|
||||
imagePreview: "Image Preview",
|
||||
columnName: "Column",
|
||||
columnType: "Type",
|
||||
columnComment: "Comment",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -338,6 +338,7 @@ export default {
|
||||
selectedCells: "已选 {count} 个单元格",
|
||||
cellDetails: "单元格详情",
|
||||
cellValue: "值",
|
||||
imagePreview: "图片预览",
|
||||
columnName: "列名",
|
||||
columnType: "类型",
|
||||
columnComment: "注释",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user