mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(web): support image clipboard copy
This commit is contained in:
1 parent
3c8b83d571
commit
142f2f03b1
2 files changed
+110
-2
No files matched your search
@@ -2,6 +2,15 @@ import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { WebPlatformAdapter } from './web'
|
||||
|
||||
function replaceGlobal(name: string, value: unknown): () => void {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name)
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value })
|
||||
return () => {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
||||
else delete (globalThis as Record<string, unknown>)[name]
|
||||
}
|
||||
}
|
||||
|
||||
describe('WebPlatformAdapter', () => {
|
||||
it('uses the bundled web version for display without querying the CLI server', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
@@ -72,6 +81,77 @@ describe('WebPlatformAdapter', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('writes a PNG data URL to the browser image clipboard', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let clipboardItems: unknown[] = []
|
||||
|
||||
class MockClipboardItem {
|
||||
constructor(readonly data: Record<string, Blob | Promise<Blob>>) {}
|
||||
}
|
||||
|
||||
const restoreNavigator = replaceGlobal('navigator', {
|
||||
clipboard: {
|
||||
write: async (items: unknown[]) => {
|
||||
clipboardItems = items
|
||||
},
|
||||
},
|
||||
})
|
||||
const restoreClipboardItem = replaceGlobal('ClipboardItem', MockClipboardItem)
|
||||
const restoreSecureContext = replaceGlobal('isSecureContext', true)
|
||||
globalThis.fetch = ((input: RequestInfo | URL) => {
|
||||
assert.equal(String(input), 'data:image/png;base64,aGVsbG8=')
|
||||
return Promise.resolve(new Response(new Blob(['hello'], { type: 'image/png' })))
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=')
|
||||
|
||||
assert.deepEqual(result, { success: true })
|
||||
assert.equal(clipboardItems.length, 1)
|
||||
const item = clipboardItems[0] as MockClipboardItem
|
||||
const png = await item.data['image/png']
|
||||
assert.equal(png.type, 'image/png')
|
||||
assert.equal(await png.text(), 'hello')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
restoreSecureContext()
|
||||
restoreClipboardItem()
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports unsupported image clipboard APIs without attempting a write', async () => {
|
||||
const restoreNavigator = replaceGlobal('navigator', { clipboard: {} })
|
||||
const restoreClipboardItem = replaceGlobal('ClipboardItem', undefined)
|
||||
const restoreSecureContext = replaceGlobal('isSecureContext', true)
|
||||
|
||||
try {
|
||||
const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=')
|
||||
assert.deepEqual(result, {
|
||||
success: false,
|
||||
error: 'Image clipboard is not supported by this browser',
|
||||
})
|
||||
} finally {
|
||||
restoreSecureContext()
|
||||
restoreClipboardItem()
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects image clipboard writes outside a secure context', async () => {
|
||||
const restoreSecureContext = replaceGlobal('isSecureContext', false)
|
||||
|
||||
try {
|
||||
const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=')
|
||||
assert.deepEqual(result, {
|
||||
success: false,
|
||||
error: 'Image clipboard requires HTTPS or localhost',
|
||||
})
|
||||
} finally {
|
||||
restoreSecureContext()
|
||||
}
|
||||
})
|
||||
|
||||
it('parses markdown skill bodies as text instead of JSON', async () => {
|
||||
// Regression: importing a skill from the cloud market failed with
|
||||
// "SyntaxError: No number after minus sign in JSON..." because
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PerformUpdateResult,
|
||||
} from './types'
|
||||
import { fetchWithAuth } from '../utils/http'
|
||||
import { reportError } from '../log-report'
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
@@ -81,8 +82,35 @@ export class WebPlatformAdapter implements PlatformAdapter {
|
||||
return { canceled: true, filePaths: [] }
|
||||
}
|
||||
|
||||
async copyImageToClipboard(_dataUrl: string): Promise<{ success: boolean; error?: string }> {
|
||||
return { success: false, error: 'Not available in web mode' }
|
||||
async copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (globalThis.isSecureContext === false) {
|
||||
return { success: false, error: 'Image clipboard requires HTTPS or localhost' }
|
||||
}
|
||||
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
typeof navigator.clipboard?.write !== 'function' ||
|
||||
typeof ClipboardItem === 'undefined'
|
||||
) {
|
||||
return { success: false, error: 'Image clipboard is not supported by this browser' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Keep the Blob conversion inside ClipboardItem so the write starts within the user's click activation.
|
||||
const pngBlob = fetch(dataUrl).then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Failed to read screenshot data: HTTP ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
if (blob.type !== 'image/png') throw new Error(`Unsupported screenshot MIME type: ${blob.type || 'unknown'}`)
|
||||
return blob
|
||||
})
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
||||
reportError(`Image clipboard write failed: ${detail}`, error instanceof Error ? error.stack : undefined)
|
||||
return { success: false, error: detail }
|
||||
}
|
||||
}
|
||||
|
||||
async checkUpdate(): Promise<CheckUpdateResult> {
|
||||
|
||||
Reference in new issue
Block a user