Merge pull request #411 from open-pencil/figma-paste-images

fix(clipboard): fetch pasted Figma images on desktop
This commit is contained in:
Danila Poyarkov 2026-07-18 05:04:49 +03:00 committed by GitHub
commit e0d8efb2a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 606 additions and 35 deletions

View file

@ -59,6 +59,7 @@
- Preserve imported Figma text sizing more accurately, especially auto-sized text inside auto-layout frames.
- Match Figma auto-layout reflow when deleting children, hiding optional instance slots, or syncing component changes.
- Fix desktop clipboard copy, cut, and paste when browser clipboard events are unavailable.
- Fetch image fills when pasting from Figma in the desktop app and warn when referenced images remain unavailable.
- Fix desktop "Share This File" links so they use the public app URL.
- Fix collaborators joining a room without receiving the current document contents.
- Fix `.fig` round-trips that could corrupt files because of duplicate generated IDs.

View file

@ -98,32 +98,31 @@ export function createClipboardActions(ctx: EditorContext) {
const replacementTargets = options.replaceSelection ? selectedReplacementTargets(ctx) : []
const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx)
const created = importClipboardNodes(figma.nodes, ctx.graph, pasteTarget, 0, 0, figma.blobs)
if (created.length > 0) {
if (replacementTargets.length > 0) {
replaceTargetsWithCreated(
ctx,
placementActions.centerNodesAt,
created,
replacementTargets,
prevSelection
)
await fontActions.loadFontsForNodes(created)
warnMissingImages(created)
ctx.requestRender()
return
}
if (created.length === 0) return
if (replacementTargets.length > 0) {
replaceTargetsWithCreated(
ctx,
placementActions.centerNodesAt,
created,
replacementTargets,
prevSelection
)
} else {
const { width: viewW, height: viewH } = ctx.getViewportSize()
const cx = cursorPos?.x ?? (-ctx.state.panX + viewW / 2) / ctx.state.zoom
const cy = cursorPos?.y ?? (-ctx.state.panY + viewH / 2) / ctx.state.zoom
placementActions.centerNodesAt(created, cx, cy)
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
ctx.setSelectedIds(new Set(created))
pushPasteUndo(created, prevSelection)
await fontActions.loadFontsForNodes(created)
warnMissingImages(created)
ctx.requestRender()
}
await Promise.all([
hydrateFigmaClipboardImages(figma.meta.fileKey, created),
fontActions.loadFontsForNodes(created)
])
ctx.requestRender()
}
}
@ -173,11 +172,47 @@ export function createClipboardActions(ctx: EditorContext) {
return created
}
function missingImageHashes(nodeIds: string[]) {
const hashes = new Set<string>()
for (const node of collectSubtrees(ctx.graph, nodeIds)) {
for (const fill of node.fills) {
if (fill.type === 'IMAGE' && fill.imageHash && !ctx.graph.images.has(fill.imageHash)) {
hashes.add(fill.imageHash)
}
}
}
return [...hashes]
}
async function hydrateFigmaClipboardImages(fileKey: string, nodeIds: string[]) {
const hashes = missingImageHashes(nodeIds)
if (hashes.length === 0) return
const resolver = ctx.resolveFigmaClipboardImages
if (resolver) {
try {
const images = await resolver(fileKey, hashes)
for (const hash of hashes) {
const bytes = images.get(hash)
if (bytes) ctx.graph.images.set(hash, bytes)
}
} catch (error) {
console.warn('Failed to fetch Figma clipboard images', error)
}
}
const missing = missingImageHashes(nodeIds).length
if (missing > 0) {
ctx.emitEditorEvent('clipboard:images-missing', {
total: hashes.length,
missing,
fetchAttempted: resolver !== null
})
}
}
function warnMissingImages(nodeIds: string[]) {
const allNodes = collectSubtrees(ctx.graph, nodeIds)
return allNodes.some((n) =>
n.fills.some((f) => f.type === 'IMAGE' && f.imageHash && !ctx.graph.images.has(f.imageHash))
)
return missingImageHashes(nodeIds).length > 0
}
function deleteSelected() {

View file

@ -138,6 +138,7 @@ export function createEditor(options?: EditorOptions) {
undo,
state,
loadFont: _loadFont,
resolveFigmaClipboardImages: options?.resolveFigmaClipboardImages ?? null,
getViewportSize: _getViewportSize,
getCk: () => _ck,
getRenderer: () => _renderer,

View file

@ -5,10 +5,12 @@ export { opacityFromBuffer } from './nodes'
export { EDITOR_TOOLS, TOOL_SHORTCUTS } from './tool-registry'
export type { EditorToolDef } from './tool-registry'
export type {
ClipboardImageResolution,
EditorContext,
EditorEventName,
EditorEvents,
EditorOptions,
EditorState,
FigmaClipboardImageResolver,
Tool
} from './types'

View file

@ -87,6 +87,17 @@ export interface EditorState {
cursorCanvasY?: number | null
}
export interface ClipboardImageResolution {
total: number
missing: number
fetchAttempted: boolean
}
export type FigmaClipboardImageResolver = (
fileKey: string,
hashes: string[]
) => Promise<ReadonlyMap<string, Uint8Array>>
export interface EditorEvents extends SceneGraphEvents {
'render:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
'repaint:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
@ -94,6 +105,7 @@ export interface EditorEvents extends SceneGraphEvents {
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
'tool:changed': (tool: Tool, previousTool: Tool) => void
'page:changed': (pageId: string, previousPageId: string) => void
'clipboard:images-missing': (resolution: ClipboardImageResolution) => void
'viewport:changed': (
viewport: { panX: number; panY: number; zoom: number },
previous: { panX: number; panY: number; zoom: number }
@ -106,6 +118,7 @@ export interface EditorOptions {
graph?: SceneGraph
state?: EditorState
loadFont?: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
resolveFigmaClipboardImages?: FigmaClipboardImageResolver
getViewportSize?: () => { width: number; height: number }
skipInitialGraphSetup?: boolean
}
@ -116,6 +129,7 @@ export interface EditorContext {
undo: UndoManager
state: EditorState
loadFont: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
resolveFigmaClipboardImages: FigmaClipboardImageResolver | null
getViewportSize: () => { width: number; height: number }
getCk: () => CanvasKit | null
getRenderer: () => SkiaRenderer | null

View file

@ -7,11 +7,13 @@ export * from './constants'
export { createDefaultEditorState, createEditor, EDITOR_TOOLS, TOOL_SHORTCUTS } from './editor'
export type {
ClipboardImageResolution,
Editor,
EditorContext,
EditorOptions,
EditorState,
EditorToolDef,
FigmaClipboardImageResolver,
Tool
} from './editor'

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Online-Schriftanbieter",
"downloadMissingWebFonts": "Fehlende Web-Schriften über aktivierte Anbieter herunterladen.",
"webFontProvidersRequireDesktopApp": "Online-Schriftanbieter-Kataloge sind in der Web-App nicht verfügbar. Lade die Desktop-App herunter, um Anbieter-Schriften zu durchsuchen und zu laden.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"fallbackPacks": "Fallback-Pakete",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Proveedores de fuentes en línea",
"downloadMissingWebFonts": "Descarga fuentes web faltantes mediante los proveedores activados.",
"webFontProvidersRequireDesktopApp": "Los catálogos de proveedores de fuentes en línea no están disponibles en la app web. Descarga la app de escritorio para explorar y cargar fuentes de proveedores.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Activar",
"disable": "Desactivar",
"fallbackPacks": "Paquetes de respaldo",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Fournisseurs de polices en ligne",
"downloadMissingWebFonts": "Télécharger les polices web manquantes via les fournisseurs activés.",
"webFontProvidersRequireDesktopApp": "Les catalogues de fournisseurs de polices en ligne ne sont pas disponibles dans lapp web. Téléchargez lapp de bureau pour parcourir et charger les polices des fournisseurs.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Activer",
"disable": "Désactiver",
"fallbackPacks": "Packs de secours",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Provider di font online",
"downloadMissingWebFonts": "Scarica i font web mancanti tramite i provider abilitati.",
"webFontProvidersRequireDesktopApp": "I cataloghi dei provider di font online non sono disponibili nellapp web. Scarica lapp desktop per sfogliare e caricare i font dei provider.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Abilita",
"disable": "Disabilita",
"fallbackPacks": "Pacchetti di fallback",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "オンラインフォントプロバイダー",
"downloadMissingWebFonts": "有効なプロバイダーから不足しているWebフォントをダウンロードします。",
"webFontProvidersRequireDesktopApp": "オンラインフォントプロバイダーのカタログはWebアプリでは利用できません。プロバイダーのフォントを参照して読み込むにはデスクトップアプリをダウンロードしてください。",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "有効にする",
"disable": "無効にする",
"fallbackPacks": "フォールバックパック",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Dostawcy czcionek online",
"downloadMissingWebFonts": "Pobieraj brakujące czcionki webowe przez włączonych dostawców.",
"webFontProvidersRequireDesktopApp": "Katalogi dostawców czcionek online nie są dostępne w aplikacji webowej. Pobierz aplikację desktopową, aby przeglądać i ładować czcionki dostawców.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Włącz",
"disable": "Wyłącz",
"fallbackPacks": "Pakiety zapasowe",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "Онлайн-провайдеры шрифтов",
"downloadMissingWebFonts": "Загружайте отсутствующие веб-шрифты через включённых провайдеров.",
"webFontProvidersRequireDesktopApp": "Каталоги провайдеров онлайн-шрифтов недоступны в веб-приложении. Скачайте настольное приложение, чтобы просматривать и загружать шрифты провайдеров.",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "Включить",
"disable": "Отключить",
"fallbackPacks": "Резервные наборы",

View file

@ -108,6 +108,10 @@
"onlineFontProviders": "在线字体提供商",
"downloadMissingWebFonts": "通过已启用的提供商下载缺失的网页字体。",
"webFontProvidersRequireDesktopApp": "网页版暂不支持在线字体提供商目录。请下载桌面应用来浏览和加载提供商字体。",
"clipboardImageUnavailableWeb": "Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.",
"clipboardImagesUnavailableWeb": "Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.",
"clipboardImageFetchFailed": "Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.",
"clipboardImagesFetchFailed": "Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.",
"enable": "启用",
"disable": "停用",
"fallbackPacks": "后备字体包",

View file

@ -51,6 +51,16 @@ export const dialogMessageDefaults = {
downloadMissingWebFonts: 'Download missing web fonts through enabled providers.',
webFontProvidersRequireDesktopApp:
'Online font provider catalogs are unavailable in the web app. Download the desktop app to browse and load provider fonts.',
clipboardImageUnavailableWeb:
'Pasted design includes 1 image that cannot be loaded in the web app. Use the desktop app to include it.',
clipboardImagesUnavailableWeb: params(
'Pasted design includes {count} images that cannot be loaded in the web app. Use the desktop app to include them.'
),
clipboardImageFetchFailed:
'Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.',
clipboardImagesFetchFailed: params(
'Failed to fetch {count} images from Figma. Check that the source file is accessible and try again.'
),
enable: 'Enable',
disable: 'Disable',
fallbackPacks: 'Fallback packs',

View file

@ -0,0 +1,86 @@
import { tauriFetch } from '@/app/tauri/http'
type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
type FigmaImageURLs = Record<string, string>
const IMAGE_FETCH_CONCURRENCY = 6
const IMAGE_FETCH_TIMEOUT_MS = 15_000
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function imageURLsFromBatchResponse(value: unknown): FigmaImageURLs {
if (
!isRecord(value) ||
value.error === true ||
(typeof value.status === 'number' && value.status !== 200) ||
!isRecord(value.meta)
) {
throw new Error('Figma returned an invalid image response')
}
const urls = value.meta.s3_urls
if (!isRecord(urls)) throw new Error('Figma returned an invalid image URL map')
const result: FigmaImageURLs = {}
for (const [hash, url] of Object.entries(urls)) {
if (typeof url === 'string') result[hash] = url
}
return result
}
async function sha1Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-1', Uint8Array.from(bytes))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
export async function resolveFigmaClipboardImages(
fileKey: string,
hashes: string[],
fetcher: ClipboardImageFetch = tauriFetch,
timeoutMs = IMAGE_FETCH_TIMEOUT_MS
): Promise<ReadonlyMap<string, Uint8Array>> {
const uniqueHashes = [...new Set(hashes)]
if (uniqueHashes.length === 0) return new Map()
const batchResponse = await fetcher(
`https://www.figma.com/file/${encodeURIComponent(fileKey)}/image/batch`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false }),
signal: AbortSignal.timeout(timeoutMs)
}
)
if (!batchResponse.ok) {
throw new Error(`Figma image request failed with status ${batchResponse.status}`)
}
const payload: unknown = await batchResponse.json()
const urls = imageURLsFromBatchResponse(payload)
const images = new Map<string, Uint8Array>()
for (let offset = 0; offset < uniqueHashes.length; offset += IMAGE_FETCH_CONCURRENCY) {
const batch = uniqueHashes.slice(offset, offset + IMAGE_FETCH_CONCURRENCY)
await Promise.all(
batch.map(async (hash) => {
const url = urls[hash]
if (!url) return
try {
const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) })
if (!response.ok) throw new Error(`status ${response.status}`)
const bytes = new Uint8Array(await response.arrayBuffer())
if ((await sha1Hex(bytes)) !== hash.toLowerCase()) {
throw new Error('SHA-1 mismatch')
}
images.set(hash, bytes)
} catch (error) {
console.warn(`Failed to fetch pasted Figma image ${hash}`, error)
}
})
)
}
return images
}

View file

@ -0,0 +1,30 @@
import type { ClipboardImageResolution, Editor } from '@open-pencil/core/editor'
import { dialogMessages } from '@open-pencil/vue'
import { toast } from '@/app/shell/ui'
export function notifyClipboardImageResolution({
total,
missing,
fetchAttempted
}: ClipboardImageResolution) {
const messages = dialogMessages.get()
if (!fetchAttempted) {
toast.warning(
total === 1
? messages.clipboardImageUnavailableWeb
: messages.clipboardImagesUnavailableWeb({ count: total })
)
return
}
toast.error(
missing === 1
? messages.clipboardImageFetchFailed
: messages.clipboardImagesFetchFailed({ count: missing })
)
}
export function bindClipboardNotifications(editor: Editor) {
return editor.onEditorEvent('clipboard:images-missing', notifyClipboardImageResolution)
}

View file

@ -9,6 +9,8 @@ import {
setActiveEditorStore,
useEditorStore
} from '@/app/editor/active-store'
import { resolveFigmaClipboardImages } from '@/app/editor/clipboard/figma-images'
import { bindClipboardNotifications } from '@/app/editor/clipboard/notifications'
import { loadFont } from '@/app/editor/fonts'
import {
createEditorComputedRefs,
@ -16,6 +18,7 @@ import {
defineEditorStoreAccessors
} from '@/app/editor/session/modules'
import { createInitialAppEditorState, type AppEditorState } from '@/app/editor/session/types'
import { IS_TAURI } from '@/constants'
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core/editor'
export type { EditorToolDef as ToolDef, Tool } from '@open-pencil/core/editor'
@ -30,6 +33,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
graph,
state,
loadFont,
resolveFigmaClipboardImages: IS_TAURI ? resolveFigmaClipboardImages : undefined,
skipInitialGraphSetup: !!initialGraph,
getViewportSize: () =>
viewportSize.width > 0 && viewportSize.height > 0
@ -37,6 +41,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
: { width: window.innerWidth, height: window.innerHeight }
})
const io = new IORegistry(BUILTIN_IO_FORMATS)
bindClipboardNotifications(editor)
if (initialGraph) {
editor.subscribeToGraph()

View file

@ -20,30 +20,57 @@ function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
return [...headers.entries()].map(([name, value]) => ({ name, value }))
}
async function bodyToBytes(body: BodyInit | null | undefined): Promise<number[] | undefined> {
if (body == null) return undefined
if (typeof body === 'string') return [...new TextEncoder().encode(body)]
if (body instanceof ArrayBuffer) return [...new Uint8Array(body)]
if (ArrayBuffer.isView(body))
return [...new Uint8Array(body.buffer, body.byteOffset, body.byteLength)]
if (body instanceof Blob) return [...new Uint8Array(await body.arrayBuffer())]
if (body instanceof URLSearchParams) return [...new TextEncoder().encode(body.toString())]
if (body instanceof FormData) {
return [...new Uint8Array(await new Response(body).arrayBuffer())]
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError')
}
export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void promise.catch(() => undefined)
return Promise.reject(abortReason(signal))
}
throw new TypeError('Streaming request bodies are not supported by the desktop HTTP bridge yet')
return new Promise<T>((resolve, reject) => {
const cleanup = () => signal.removeEventListener('abort', onAbort)
const onAbort = () => {
cleanup()
reject(abortReason(signal))
}
signal.addEventListener('abort', onAbort, { once: true })
void (async () => {
try {
const value = await promise
cleanup()
resolve(value)
} catch (error) {
cleanup()
reject(
error instanceof Error
? error
: new Error('Desktop HTTP request failed', { cause: error })
)
}
})()
})
}
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = new Request(input, init)
request.signal.throwIfAborted()
const { invoke } = await import('@tauri-apps/api/core')
const payload: ProxyHttpRequest = {
url: request.url,
method: request.method,
headers: headersToProxyHeaders(request.headers),
body: await bodyToBytes(init?.body)
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())]
}
const response = await invoke<ProxyHttpResponse>('proxy_http_request', { request: payload })
request.signal.throwIfAborted()
const response = await withAbortSignal(
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
request.signal
)
return new Response(new Uint8Array(response.body), {
status: response.status,
headers: response.headers.map(({ name, value }): [string, string] => [name, value])

View file

@ -0,0 +1,133 @@
import { describe, expect, spyOn, test } from 'bun:test'
import { resolveFigmaClipboardImages } from '@/app/editor/clipboard/figma-images'
import { expectDefined } from '#tests/helpers/assert'
async function sha1Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest('SHA-1', Uint8Array.from(bytes))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
function pendingResponseUntilAbort(_input: RequestInfo | URL, init?: RequestInit) {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal
if (!signal) {
reject(new Error('Expected request signal'))
return
}
if (signal.aborted) {
reject(signal.reason)
return
}
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
describe('resolveFigmaClipboardImages', () => {
test('resolves signed URLs, verifies bytes, and deduplicates hashes', async () => {
const bytes = new Uint8Array([1, 2, 3])
const hash = await sha1Hex(bytes)
const requests: Array<{ url: string; init?: RequestInit }> = []
const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
requests.push({ url, init })
if (url.includes('/image/batch')) {
return Response.json({
error: false,
status: 200,
meta: { s3_urls: { [hash]: 'https://s3-alpha-sig.figma.com/image' } }
})
}
return new Response(Uint8Array.from(bytes))
}
const images = await resolveFigmaClipboardImages('file/key', [hash, hash], fetcher)
expect(images.get(hash)).toEqual(bytes)
expect(requests.map(({ url }) => url)).toEqual([
'https://www.figma.com/file/file%2Fkey/image/batch',
'https://s3-alpha-sig.figma.com/image'
])
const body = expectDefined(requests[0].init?.body, 'batch request body')
expect(JSON.parse(String(body))).toEqual({
sha1s: [hash],
needs_compressed_textures: false
})
})
test('drops a response whose bytes do not match the Figma hash', async () => {
const warn = spyOn(console, 'warn').mockImplementation(() => undefined)
const fetcher = async (input: RequestInfo | URL) => {
if (String(input).includes('/image/batch')) {
return Response.json({
error: false,
status: 200,
meta: {
s3_urls: {
'1111111111111111111111111111111111111111': 'https://s3-alpha-sig.figma.com/image'
}
}
})
}
return new Response(new Uint8Array([9, 9, 9]))
}
const images = await resolveFigmaClipboardImages(
'file-key',
['1111111111111111111111111111111111111111'],
fetcher
)
expect(images.size).toBe(0)
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
test('times out stalled batch requests', async () => {
await expect(
resolveFigmaClipboardImages('file-key', ['hash'], pendingResponseUntilAbort, 5)
).rejects.toMatchObject({ name: 'TimeoutError' })
})
test('drops images whose signed URL request times out', async () => {
const warn = spyOn(console, 'warn').mockImplementation(() => undefined)
const images = await resolveFigmaClipboardImages(
'file-key',
['1111111111111111111111111111111111111111'],
(input, init) => {
if (String(input).includes('/image/batch')) {
return Promise.resolve(
Response.json({
error: false,
status: 200,
meta: {
s3_urls: {
'1111111111111111111111111111111111111111': 'https://s3-alpha-sig.figma.com/image'
}
}
})
)
}
return pendingResponseUntilAbort(input, init)
},
5
)
expect(images.size).toBe(0)
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
test('rejects failed and malformed batch responses', async () => {
await expect(
resolveFigmaClipboardImages('file-key', ['hash'], async () =>
Response.json({}, { status: 503 })
)
).rejects.toThrow('status 503')
await expect(
resolveFigmaClipboardImages('file-key', ['hash'], async () => Response.json({ error: true }))
).rejects.toThrow('invalid image response')
})
})

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { notifyClipboardImageResolution } from '@/app/editor/clipboard/notifications'
import { toast } from '@/app/shell/ui'
afterEach(() => {
toast.toasts.value = []
})
describe('clipboard image notifications', () => {
test('warns web users when pasted images cannot be fetched', () => {
notifyClipboardImageResolution({ total: 2, missing: 2, fetchAttempted: false })
expect(toast.toasts.value).toHaveLength(1)
expect(toast.toasts.value[0]).toMatchObject({
variant: 'warning',
message:
'Pasted design includes 2 images that cannot be loaded in the web app. Use the desktop app to include them.'
})
})
test('shows an actionable desktop error for partial failures', () => {
notifyClipboardImageResolution({ total: 3, missing: 1, fetchAttempted: true })
expect(toast.toasts.value).toHaveLength(1)
expect(toast.toasts.value[0]).toMatchObject({
variant: 'error',
message:
'Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.'
})
})
})

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { withAbortSignal } from '@/app/tauri/http'
describe('withAbortSignal', () => {
test('resolves with the wrapped promise', async () => {
const controller = new AbortController()
await expect(withAbortSignal(Promise.resolve('ok'), controller.signal)).resolves.toBe('ok')
})
test('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController()
const reason = new Error('cancelled')
controller.abort(reason)
const pending = Promise.withResolvers<string>()
const result = withAbortSignal(pending.promise, controller.signal)
await expect(result).rejects.toBe(reason)
pending.reject(new Error('late request failure'))
await Promise.resolve()
})
test('rejects a pending promise when the signal aborts', async () => {
const controller = new AbortController()
const pending = Promise.withResolvers<string>()
const result = withAbortSignal(pending.promise, controller.signal)
const reason = new Error('cancelled')
controller.abort(reason)
await expect(result).rejects.toBe(reason)
pending.resolve('late result')
})
})

View file

@ -0,0 +1,125 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { initCodec } from '@open-pencil/core'
import { buildFigmaClipboardHTML } from '@open-pencil/core/clipboard'
import { createEditor } from '@open-pencil/core/editor'
import type { ClipboardImageResolution } from '@open-pencil/core/editor'
import { expectDefined } from '#tests/helpers/assert'
const IMAGE_HASH_A = '1111111111111111111111111111111111111111'
const IMAGE_HASH_B = '2222222222222222222222222222222222222222'
async function imageClipboardHtml(hashes: string[]) {
const source = createEditor()
const frame = source.graph.createNode('FRAME', source.state.currentPageId, {
name: 'Images',
width: 200,
height: 200
})
for (const [index, hash] of hashes.entries()) {
source.graph.createNode('RECTANGLE', frame.id, {
name: `Image ${index}`,
x: index * 20,
width: 20,
height: 20,
fills: [
{
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FILL',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
}
]
})
}
return expectDefined(await buildFigmaClipboardHTML([frame], source.graph), 'Figma clipboard HTML')
}
describe('Figma clipboard images', () => {
beforeAll(async () => {
await initCodec()
})
test('finalizes structural paste before image resolution completes', async () => {
const html = await imageClipboardHtml([IMAGE_HASH_A])
let startResolution: (() => void) | undefined
const resolutionStarted = new Promise<void>((resolve) => {
startResolution = resolve
})
let finishResolution: ((images: ReadonlyMap<string, Uint8Array>) => void) | undefined
const pendingResolution = new Promise<ReadonlyMap<string, Uint8Array>>((resolve) => {
finishResolution = resolve
})
const editor = createEditor({
resolveFigmaClipboardImages: () => {
startResolution?.()
return pendingResolution
}
})
const paste = editor.pasteFromHTML(html)
await resolutionStarted
expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(1)
expect(editor.state.selectedIds.size).toBe(1)
expect(editor.undo.undoLabel).toBe('Paste')
finishResolution?.(new Map([[IMAGE_HASH_A, new Uint8Array([1, 2, 3])]]))
await paste
})
test('resolves and stores missing images before completing paste', async () => {
const html = await imageClipboardHtml([IMAGE_HASH_A, IMAGE_HASH_A])
const imageBytes = new Uint8Array([1, 2, 3])
const calls: Array<{ fileKey: string; hashes: string[] }> = []
const resolutions: ClipboardImageResolution[] = []
const editor = createEditor({
resolveFigmaClipboardImages: async (fileKey, hashes) => {
calls.push({ fileKey, hashes })
return new Map([[IMAGE_HASH_A, imageBytes]])
}
})
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
resolutions.push(resolution)
})
await editor.pasteFromHTML(html)
expect(calls).toEqual([{ fileKey: 'openpencil', hashes: [IMAGE_HASH_A] }])
expect(editor.graph.images.get(IMAGE_HASH_A)).toEqual(imageBytes)
expect(resolutions).toEqual([])
})
test('reports images that cannot be fetched without a resolver', async () => {
const html = await imageClipboardHtml([IMAGE_HASH_A])
const resolutions: ClipboardImageResolution[] = []
const editor = createEditor()
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
resolutions.push(resolution)
})
await editor.pasteFromHTML(html)
expect(resolutions).toEqual([{ total: 1, missing: 1, fetchAttempted: false }])
})
test('stores partial results and reports remaining images', async () => {
const html = await imageClipboardHtml([IMAGE_HASH_A, IMAGE_HASH_B])
const editor = createEditor({
resolveFigmaClipboardImages: async () => new Map([[IMAGE_HASH_A, new Uint8Array([4, 5, 6])]])
})
const resolutions: ClipboardImageResolution[] = []
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
resolutions.push(resolution)
})
await editor.pasteFromHTML(html)
expect(editor.graph.images.has(IMAGE_HASH_A)).toBe(true)
expect(editor.graph.images.has(IMAGE_HASH_B)).toBe(false)
expect(resolutions).toEqual([{ total: 2, missing: 1, fetchAttempted: true }])
})
})