diff --git a/CHANGELOG.md b/CHANGELOG.md index 393b3d236..0517d60de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index 78d321b96..d10d6a502 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -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() + 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() { diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index f36dbc5b6..8575cf8b4 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -138,6 +138,7 @@ export function createEditor(options?: EditorOptions) { undo, state, loadFont: _loadFont, + resolveFigmaClipboardImages: options?.resolveFigmaClipboardImages ?? null, getViewportSize: _getViewportSize, getCk: () => _ck, getRenderer: () => _renderer, diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index d7d955d74..916acbc02 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -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' diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index 313ebf131..0abf0cf86 100644 --- a/packages/core/src/editor/types.ts +++ b/packages/core/src/editor/types.ts @@ -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> + 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 + 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 + resolveFigmaClipboardImages: FigmaClipboardImageResolver | null getViewportSize: () => { width: number; height: number } getCk: () => CanvasKit | null getRenderer: () => SkiaRenderer | null diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73c4ba93c..5257b92d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index cf5e82de0..a4b312b2b 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 7d2091acd..de2e05550 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 26365e895..1883ec5a6 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -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 l’app web. Téléchargez l’app 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", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 26936b592..cd34ddcbd 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -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 nell’app web. Scarica l’app 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", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index c550a3ea9..eb8ef8916 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -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": "フォールバックパック", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index c75d82f40..d9cf34e15 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 5634cbbed..95fa1fd38 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -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": "Резервные наборы", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index a0e26c42f..1f154445f 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -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": "后备字体包", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 70be5d902..7e0862978 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -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', diff --git a/src/app/editor/clipboard/figma-images.ts b/src/app/editor/clipboard/figma-images.ts new file mode 100644 index 000000000..4f5416494 --- /dev/null +++ b/src/app/editor/clipboard/figma-images.ts @@ -0,0 +1,86 @@ +import { tauriFetch } from '@/app/tauri/http' + +type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise + +type FigmaImageURLs = Record + +const IMAGE_FETCH_CONCURRENCY = 6 +const IMAGE_FETCH_TIMEOUT_MS = 15_000 + +function isRecord(value: unknown): value is Record { + 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 { + 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> { + 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() + + 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 +} diff --git a/src/app/editor/clipboard/notifications.ts b/src/app/editor/clipboard/notifications.ts new file mode 100644 index 000000000..f77e21b36 --- /dev/null +++ b/src/app/editor/clipboard/notifications.ts @@ -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) +} diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index 90177381b..73d1a3bc8 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -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() diff --git a/src/app/tauri/http.ts b/src/app/tauri/http.ts index 922f33340..f3b15df11 100644 --- a/src/app/tauri/http.ts +++ b/src/app/tauri/http.ts @@ -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 { - 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(promise: Promise, signal: AbortSignal): Promise { + 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((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 { 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('proxy_http_request', { request: payload }) + request.signal.throwIfAborted() + const response = await withAbortSignal( + invoke('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]) diff --git a/tests/engine/app/clipboard/figma-images.test.ts b/tests/engine/app/clipboard/figma-images.test.ts new file mode 100644 index 000000000..93b57c759 --- /dev/null +++ b/tests/engine/app/clipboard/figma-images.test.ts @@ -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((_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') + }) +}) diff --git a/tests/engine/app/clipboard/notifications.test.ts b/tests/engine/app/clipboard/notifications.test.ts new file mode 100644 index 000000000..bc16a61b4 --- /dev/null +++ b/tests/engine/app/clipboard/notifications.test.ts @@ -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.' + }) + }) +}) diff --git a/tests/engine/app/tauri/http.test.ts b/tests/engine/app/tauri/http.test.ts new file mode 100644 index 000000000..29906c3a7 --- /dev/null +++ b/tests/engine/app/tauri/http.test.ts @@ -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() + 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() + 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') + }) +}) diff --git a/tests/engine/editor/clipboard/figma-images.test.ts b/tests/engine/editor/clipboard/figma-images.test.ts new file mode 100644 index 000000000..f8a4d8935 --- /dev/null +++ b/tests/engine/editor/clipboard/figma-images.test.ts @@ -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((resolve) => { + startResolution = resolve + }) + let finishResolution: ((images: ReadonlyMap) => void) | undefined + const pendingResolution = new Promise>((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 }]) + }) +})