feat(web): open, copy, and download table image attachments

Clicking an image thumbnail opens the file. Copy and download appear on hover, and copy places the image on the clipboard.
This commit is contained in:
tianma-if committed 2026-09-23 23:35:32 +08:00
1 parent b7622c78a9
commit 0f3aec1679
7 files changed
+233 -11

No files matched your search

+99 -10
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { ChevronLeft, Download, Form, Paperclip, Plus, RefreshCw, TableProperties, Trash2, X } from "lucide-react";
import { Check, ChevronLeft, Copy, Download, Form, Paperclip, Plus, RefreshCw, TableProperties, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
addTableField,
@@ -49,7 +49,8 @@ import {
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { toDesktopResourceUrl } from "@/lib/desktop-resources";
import { copyImageUrlToClipboard } from "@/lib/clipboard";
import { toDesktopResourceDownloadUrl, toDesktopResourceUrl } from "@/lib/desktop-resources";
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
import { createLocalEditSession } from "@/components/editor/editor-pane-helpers";
import { isLocalMemoId } from "@/lib/local-mirror";
@@ -160,6 +161,16 @@ const FieldHeader = ({
const attachmentItems = (value: TableCellValue): TableAttachment[] => Array.isArray(value) ? value : [];
const isImageAttachment = (item: TableAttachment) => item.mimeType.toLowerCase().startsWith("image/");
const downloadTableAttachment = (href: string, filename: string) => {
const anchor = document.createElement("a");
anchor.href = toDesktopResourceDownloadUrl(href, filename);
anchor.download = filename;
anchor.rel = "noreferrer";
anchor.click();
};
const AttachmentCell = ({
field,
record,
@@ -181,11 +192,33 @@ const AttachmentCell = ({
}) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const copyTimer = useRef<number | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [copyError, setCopyError] = useState<string | null>(null);
const items = attachmentItems(record.cells[field.id] ?? null);
const limitReached = items.length >= TABLE_ATTACHMENT_LIMIT;
useEffect(() => () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
}, []);
const copyImage = async (href: string, resourceId: string) => {
setCopyError(null);
const copied = await copyImageUrlToClipboard(href);
if (!copied) {
setCopiedId(null);
setCopyError(t("structuredTable.copyImageFailed"));
return;
}
setCopiedId(resourceId);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => {
setCopiedId((current) => current === resourceId ? null : current);
}, 2000);
};
const uploadFiles = async (files: FileList | null) => {
if (!files?.length || readOnly) return;
setUploading(true);
@@ -214,19 +247,74 @@ const AttachmentCell = ({
};
return (
<div className="flex min-w-48 flex-col gap-1">
<div className="flex min-w-36 flex-col gap-1 px-2">
{items.map((item) => {
const href = toDesktopResourceUrl(tableAttachmentUrl(item.resourceId));
const image = item.mimeType.startsWith("image/");
const image = isImageAttachment(item);
const openLabel = t("structuredTable.openAttachment", { name: item.filename });
const copied = copiedId === item.resourceId;
return (
<div key={item.resourceId} className="flex items-center gap-1">
{image ? <img src={href} alt="" className="h-8 w-8 rounded object-cover" /> : <Paperclip className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden="true" />}
<a href={href} className="min-w-0 flex-1 truncate text-sm text-emerald-700 underline-offset-2 hover:underline" target="_blank" rel="noreferrer">{item.filename}</a>
<div key={item.resourceId} className="group/attachment flex items-center gap-1">
{image ? (
<Tooltip>
<TooltipTrigger asChild>
<a
href={href}
target="_blank"
rel="noreferrer"
className="h-8 w-8 shrink-0 overflow-hidden rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
aria-label={openLabel}
>
<img src={href} alt="" className="h-full w-full object-cover" />
</a>
</TooltipTrigger>
<TooltipContent>{openLabel}</TooltipContent>
</Tooltip>
) : (
<>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden="true" />
<a href={href} className="min-w-0 flex-1 truncate text-sm text-emerald-700 underline-offset-2 hover:underline" target="_blank" rel="noreferrer">{item.filename}</a>
</>
)}
<div className="hidden items-center gap-1 group-hover/attachment:flex group-focus-within/attachment:flex">
{image ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={copied ? t("structuredTable.imageCopied") : t("structuredTable.copyImage")}
onClick={() => { void copyImage(href, item.resourceId); }}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("structuredTable.imageCopied") : t("structuredTable.copyImage")}</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={t("structuredTable.downloadAttachment", { name: item.filename })}
onClick={() => downloadTableAttachment(href, item.filename)}
>
<Download className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("structuredTable.downloadAttachment", { name: item.filename })}</TooltipContent>
</Tooltip>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-8 w-8"
aria-label={t("structuredTable.removeAttachment", { name: item.filename })}
disabled={readOnly}
onClick={() => onRemove(item.resourceId)}
@@ -249,7 +337,7 @@ const AttachmentCell = ({
type="button"
variant="ghost"
size="sm"
className="h-7 justify-start px-1 text-xs"
className="h-8 justify-start px-0 text-xs"
disabled={readOnly || uploading || limitReached}
aria-label={limitReached ? t("structuredTable.attachmentLimit") : t("structuredTable.addAttachment")}
onClick={() => inputRef.current?.click()}
@@ -258,6 +346,7 @@ const AttachmentCell = ({
{uploading ? t("structuredTable.uploading") : limitReached ? t("structuredTable.attachmentLimit") : t("structuredTable.addAttachment")}
</Button>
{uploadError ? <p className="text-xs text-rose-600">{uploadError}</p> : null}
{copyError ? <p className="text-xs text-rose-600" role="alert">{copyError}</p> : null}
</div>
);
};
@@ -882,7 +971,7 @@ export const TableEditorPane = ({
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-2 py-1 align-middle">
<td key={cell.id} className="px-2 py-1 align-top">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
+49 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { copyHtmlToClipboard, copyImageBlobToClipboard, copyTextToClipboard } from "./clipboard.ts";
import { copyHtmlToClipboard, copyImageBlobToClipboard, copyImageUrlToClipboard, copyTextToClipboard } from "./clipboard.ts";
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
@@ -188,6 +188,54 @@ describe("copyImageBlobToClipboard", () => {
await expect(pending).resolves.toBe(true);
});
test("starts the image-url write during the click, before the image response arrives", async () => {
const writes = [];
const jpeg = new Blob([Uint8Array.from([1, 2, 3])], { type: "image/jpeg" });
const png = new Blob([Uint8Array.from([9])], { type: "image/png" });
let resolveFetch = () => {};
globalThis.window = {};
installClipboardItem();
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { clipboard: { write: async (items) => writes.push(items) } },
});
globalThis.fetch = () => new Promise((resolve) => {
resolveFetch = () => resolve({ ok: true, blob: async () => jpeg });
});
globalThis.document = {
createElement: (tag) => {
if (tag === "img") {
const image = {
naturalWidth: 1,
naturalHeight: 1,
set src(_value) {
queueMicrotask(() => image.onload?.());
},
};
return image;
}
return {
width: 0,
height: 0,
getContext: () => ({ drawImage() {} }),
toBlob: (callback) => callback(png),
};
},
};
globalThis.URL = {
createObjectURL: () => "blob:image",
revokeObjectURL() {},
};
const pending = copyImageUrlToClipboard("/api/v1/resources/res_image/blob");
expect(writes).toHaveLength(1);
const payload = writes[0][0].data["image/png"];
expect(payload).toBeInstanceOf(Promise);
resolveFetch();
await expect(payload).resolves.toBe(png);
await expect(pending).resolves.toBe(true);
});
test("returns false when the browser refuses the image write", async () => {
globalThis.window = {};
installClipboardItem();
+41
View File
@@ -91,6 +91,47 @@ const toPngBlob = (blob: Blob): Promise<Blob | null> => {
});
};
export const copyImageUrlToClipboard = async (url: string): Promise<boolean> => {
const copyImage = typeof window !== "undefined" ? window.edgeeverDesktop?.copyImage : undefined;
if (copyImage) {
try {
const response = await fetch(url);
if (!response.ok) return false;
return await copyImageBlobToClipboard(await response.blob());
} catch {
return false;
}
}
if (typeof window === "undefined" || !navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
return false;
}
try {
// Safari keeps the click gesture only when write() runs now and the
// ClipboardItem payload is still a Promise. Fetch and PNG conversion stay inside it.
const pngPromise = fetch(url)
.then((response) => {
if (!response.ok) throw new Error("Image fetch failed");
return response.blob();
})
.then((blob) => toPngBlob(blob))
.then((png) => {
if (!png) throw new Error("PNG conversion failed");
return png;
});
await navigator.clipboard.write([
new ClipboardItem({
"image/png": pngPromise,
}),
]);
return true;
} catch (error) {
console.warn("Failed to copy image URL to clipboard:", error);
return false;
}
};
export const copyImageBlobToClipboard = async (blob: Blob): Promise<boolean> => {
const copyImage = typeof window !== "undefined" ? window.edgeeverDesktop?.copyImage : undefined;
if (copyImage) {
+5
View File
@@ -347,6 +347,11 @@ export const enUS = {
uploadLimit: "Each attachment must be 20MB or smaller",
},
addAttachment: "Add attachment",
openAttachment: "Open {{name}}",
copyImage: "Copy image",
imageCopied: "Copied. You can paste it.",
copyImageFailed: "Couldn't copy the image",
downloadAttachment: "Download {{name}}",
removeAttachment: "Remove {{name}}",
attachmentLimit: "Up to 10 attachments per cell",
uploading: "Uploading",
+5
View File
@@ -347,6 +347,11 @@ export const ja = {
uploadLimit: "添付ファイルは1件20MBまで",
},
addAttachment: "添付ファイルを追加",
openAttachment: "{{name}} を開く",
copyImage: "画像をコピー",
imageCopied: "コピーしました。貼り付けできます",
copyImageFailed: "画像をコピーできませんでした",
downloadAttachment: "{{name}} をダウンロード",
removeAttachment: "{{name}} を削除",
attachmentLimit: "1セルにつき添付は10件まで",
uploading: "アップロード中",
+5
View File
@@ -347,6 +347,11 @@ export const zhCN = {
uploadLimit: "每个附件不超过 20MB",
},
addAttachment: "添加附件",
openAttachment: "打开 {{name}}",
copyImage: "复制图片",
imageCopied: "已复制,可以粘贴",
copyImageFailed: "图片复制失败",
downloadAttachment: "下载 {{name}}",
removeAttachment: "移除 {{name}}",
attachmentLimit: "每格最多 10 个附件",
uploading: "正在上传",
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
describe("table image attachment actions", () => {
test("opens an image from its thumbnail and keeps copy and download on hover", () => {
const source = readSource("../apps/web/src/components/TableEditorPane.tsx");
expect(source).toContain("const isImageAttachment = (item: TableAttachment) => item.mimeType.toLowerCase().startsWith(\"image/\");");
expect(source).toContain('target="_blank"');
expect(source).toContain('t("structuredTable.openAttachment"');
expect(source).toContain("group-hover/attachment:flex group-focus-within/attachment:flex");
expect(source).toContain("copyImageUrlToClipboard");
expect(source).toContain('t("structuredTable.copyImage")');
expect(source).toContain('t("structuredTable.downloadAttachment"');
expect(source).not.toContain("ImageViewer");
expect(source).not.toContain("openInNewWindow");
});
test("names the image actions in every locale", () => {
for (const locale of ["zh-CN", "en-US", "ja"]) {
const source = readSource(`../packages/shared/src/i18n/${locale}.ts`);
expect(source).toContain("openAttachment:");
expect(source).toContain("copyImage:");
expect(source).toContain("imageCopied:");
expect(source).toContain("downloadAttachment:");
}
});
});