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:
1 parent
53987bdf22
commit
43e240ad6a
11 files changed
+699
-14
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user