feat(web-admin): add copy-to-clipboard for attachment images

Let users copy images next to download on shared attachment cards, preview toolbars, and list cells so they can paste into chat or docs without saving files first.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-14 23:18:34 +08:00
1 parent 53987bdf22
commit 43e240ad6a
11 files changed
+699 -14

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { afterEach, before, describe, it } from 'node:test'
import {
copyAttachmentImageToClipboard,
copyImageBlobToClipboard,
ensurePngBlob
} from './clipboard'
class FakeClipboardItem {
readonly types: string[]
private readonly data: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.data = items
this.types = Object.keys(items)
}
async getType(type: string) {
return this.data[type]
}
}
before(() => {
if (typeof globalThis.ClipboardItem === 'undefined') {
;(globalThis as unknown as { ClipboardItem: typeof FakeClipboardItem }).ClipboardItem =
FakeClipboardItem
}
if (typeof globalThis.navigator === 'undefined') {
;(globalThis as unknown as { navigator: Navigator }).navigator = {} as Navigator
}
})
const originalClipboard = globalThis.navigator?.clipboard
afterEach(() => {
if (typeof globalThis.navigator === 'undefined') return
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: originalClipboard
})
})
describe('ensurePngBlob', () => {
it('returns the same blob when type is already image/png', async () => {
const png = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
const out = await ensurePngBlob(png)
assert.equal(out, png)
assert.equal(out.type, 'image/png')
})
})
describe('copyImageBlobToClipboard', () => {
it('writes a ClipboardItem with image/png and returns true', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const png = new Blob([new Uint8Array([9])], { type: 'image/png' })
const ok = await copyImageBlobToClipboard(png)
assert.equal(ok, true)
assert.equal(written.length, 1)
const types = written[0].types
assert.ok(types.includes('image/png'))
})
it('returns false when clipboard.write is unavailable', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {}
})
const png = new Blob([new Uint8Array([1])], { type: 'image/png' })
assert.equal(await copyImageBlobToClipboard(png), false)
})
})
describe('copyAttachmentImageToClipboard', () => {
it('returns false for non-image attachments without fetching', async () => {
let fetched = false
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.pdf', fileName: 'a.pdf' },
{
fetchBlob: async () => {
fetched = true
return new Blob(['x'], { type: 'application/pdf' })
}
}
)
assert.equal(ok, false)
assert.equal(fetched, false)
})
it('fetches image blob and copies via clipboard', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.jpg', fileName: 'a.jpg' },
{
fetchBlob: async () => new Blob([new Uint8Array([7])], { type: 'image/png' })
}
)
assert.equal(ok, true)
assert.equal(written.length, 1)
})
})
+85
View File
@@ -2,9 +2,15 @@
* 复制文本到剪贴板。
* 小程序 web-view / H5 iframe 常被 Permissions Policy 拦截 Clipboard API,
* 需回退到 execCommand,必要时再交给壳页复制。
* 另提供图片 Blob / 附件图片写入系统剪贴板(ClipboardItem)。
*/
import { MP_WEBVIEW_COPY_TEXT } from '@/utils/mp-webview'
export type AttachmentClipboardItem = {
fileUrl?: string
fileName?: string
}
function fallbackExecCommandCopy(text: string): boolean {
try {
const textarea = document.createElement('textarea')
@@ -64,3 +70,82 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
return tryCopyViaMpWebviewHost(value)
}
/** 与 download-attachment 图片判定对齐,避免测试静态拉入整条 API 依赖链 */
function isImageAttachment(file: AttachmentClipboardItem): boolean {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
)
}
/** 已是 PNG 则原样返回;否则经 canvas 转为 image/png。 */
export async function ensurePngBlob(blob: Blob): Promise<Blob> {
if (blob.type === 'image/png') return blob
if (typeof createImageBitmap === 'function' && typeof document !== 'undefined') {
const bitmap = await createImageBitmap(blob)
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d unavailable')
ctx.drawImage(bitmap, 0, 0)
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
})
return png
} finally {
bitmap.close?.()
}
}
// 无 canvas 环境时仍尝试按原类型写入(部分浏览器接受 image/jpeg)
return blob
}
/** 将图片 Blob 写入系统剪贴板;失败返回 false。 */
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
if (!blob || blob.size <= 0) return false
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) return false
if (typeof ClipboardItem === 'undefined') return false
const png = await ensurePngBlob(blob)
const type = png.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: png })])
return true
} catch {
return false
}
}
export type CopyAttachmentImageOptions = {
fullFileUrl?: (url: string) => string
/** 测试可注入;默认走预签名下载 Blob */
fetchBlob?: (
file: AttachmentClipboardItem,
options?: { fullFileUrl?: (url: string) => string }
) => Promise<Blob>
}
/** 仅图片附件:拉取 Blob 后写入剪贴板;非图片返回 false。 */
export async function copyAttachmentImageToClipboard(
file: AttachmentClipboardItem,
options?: CopyAttachmentImageOptions
): Promise<boolean> {
if (!isImageAttachment(file)) return false
try {
const fetchBlob =
options?.fetchBlob ||
(await import('@/utils/download-attachment')).fetchAttachmentBlob
const blob = await fetchBlob(file, { fullFileUrl: options?.fullFileUrl })
return await copyImageBlobToClipboard(blob)
} catch {
return false
}
}