From b08e199c85bc1981877d2b8664cf38dc5fc313fb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 18 Jul 2026 04:22:38 +0300 Subject: [PATCH] fix(clipboard): finalize paste before image fetch - Complete placement and undo registration before awaiting image hydration - Bound Figma image requests with timeouts and use shared Tauri detection - Localize missing-image notifications and cover stalled requests --- packages/core/src/editor/clipboard.ts | 36 ++++++++--------- packages/vue/src/i18n/locales/de/dialogs.json | 4 ++ packages/vue/src/i18n/locales/es/dialogs.json | 4 ++ packages/vue/src/i18n/locales/fr/dialogs.json | 4 ++ packages/vue/src/i18n/locales/it/dialogs.json | 4 ++ packages/vue/src/i18n/locales/ja/dialogs.json | 4 ++ packages/vue/src/i18n/locales/pl/dialogs.json | 4 ++ packages/vue/src/i18n/locales/ru/dialogs.json | 4 ++ .../vue/src/i18n/locales/zh-cn/dialogs.json | 4 ++ packages/vue/src/i18n/messages/dialogs.ts | 10 +++++ src/app/editor/clipboard/figma-images.ts | 37 +++++++++++++++-- src/app/editor/clipboard/notifications.ts | 14 ++++--- src/app/editor/session/create.ts | 4 +- .../engine/app/clipboard/figma-images.test.ts | 40 +++++++++++++++++++ .../editor/clipboard/figma-images.test.ts | 28 +++++++++++++ 15 files changed, 170 insertions(+), 31 deletions(-) diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index 5dbc39d73..d10d6a502 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -98,33 +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) { - await hydrateFigmaClipboardImages(figma.meta.fileKey, created) - 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() } } 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 index 4b87b7e37..308a24ff1 100644 --- a/src/app/editor/clipboard/figma-images.ts +++ b/src/app/editor/clipboard/figma-images.ts @@ -5,6 +5,7 @@ type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Pro 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) @@ -34,21 +35,49 @@ async function sha1Hex(bytes: Uint8Array): Promise { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('') } +async function fetchWithTimeout( + fetcher: ClipboardImageFetch, + input: RequestInfo | URL, + init: RequestInit | undefined, + timeoutMs: number +) { + const controller = new AbortController() + let timeout: ReturnType | undefined + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + controller.abort() + reject(new Error('Figma image request timed out')) + }, timeoutMs) + }) + + try { + return await Promise.race([ + fetcher(input, { ...init, signal: controller.signal }), + timeoutPromise + ]) + } finally { + clearTimeout(timeout) + } +} + export async function resolveFigmaClipboardImages( fileKey: string, hashes: string[], - fetcher: ClipboardImageFetch = tauriFetch + 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( + const batchResponse = await fetchWithTimeout( + 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 }) - } + }, + timeoutMs ) if (!batchResponse.ok) { throw new Error(`Figma image request failed with status ${batchResponse.status}`) @@ -65,7 +94,7 @@ export async function resolveFigmaClipboardImages( const url = urls[hash] if (!url) return try { - const response = await fetcher(url) + const response = await fetchWithTimeout(fetcher, url, undefined, timeoutMs) if (!response.ok) throw new Error(`status ${response.status}`) const bytes = new Uint8Array(await response.arrayBuffer()) if ((await sha1Hex(bytes)) !== hash.toLowerCase()) { diff --git a/src/app/editor/clipboard/notifications.ts b/src/app/editor/clipboard/notifications.ts index 20eaedf19..f77e21b36 100644 --- a/src/app/editor/clipboard/notifications.ts +++ b/src/app/editor/clipboard/notifications.ts @@ -1,25 +1,27 @@ import type { ClipboardImageResolution, Editor } from '@open-pencil/core/editor' +import { dialogMessages } from '@open-pencil/vue' import { toast } from '@/app/shell/ui' -function imageCount(count: number) { - return `${count} image${count === 1 ? '' : 's'}` -} - export function notifyClipboardImageResolution({ total, missing, fetchAttempted }: ClipboardImageResolution) { + const messages = dialogMessages.get() if (!fetchAttempted) { toast.warning( - `Pasted design includes ${imageCount(total)} that cannot be loaded in the web app. Use the desktop app to include ${total === 1 ? 'it' : 'them'}.` + total === 1 + ? messages.clipboardImageUnavailableWeb + : messages.clipboardImagesUnavailableWeb({ count: total }) ) return } toast.error( - `Failed to fetch ${imageCount(missing)} from Figma. Check that the source file is accessible and try again.` + missing === 1 + ? messages.clipboardImageFetchFailed + : messages.clipboardImagesFetchFailed({ count: missing }) ) } diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index 9560691f3..73d1a3bc8 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -18,7 +18,7 @@ import { defineEditorStoreAccessors } from '@/app/editor/session/modules' import { createInitialAppEditorState, type AppEditorState } from '@/app/editor/session/types' -import { isTauri } from '@/app/tauri/env' +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' @@ -33,7 +33,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { graph, state, loadFont, - resolveFigmaClipboardImages: isTauri() ? resolveFigmaClipboardImages : undefined, + resolveFigmaClipboardImages: IS_TAURI ? resolveFigmaClipboardImages : undefined, skipInitialGraphSetup: !!initialGraph, getViewportSize: () => viewportSize.width > 0 && viewportSize.height > 0 diff --git a/tests/engine/app/clipboard/figma-images.test.ts b/tests/engine/app/clipboard/figma-images.test.ts index 4a19ab064..ea4ca6014 100644 --- a/tests/engine/app/clipboard/figma-images.test.ts +++ b/tests/engine/app/clipboard/figma-images.test.ts @@ -69,6 +69,46 @@ describe('resolveFigmaClipboardImages', () => { warn.mockRestore() }) + test('times out stalled batch requests', async () => { + await expect( + resolveFigmaClipboardImages( + 'file-key', + ['hash'], + () => Promise.withResolvers().promise, + 5 + ) + ).rejects.toThrow('timed out') + }) + + 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) => { + 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 Promise.withResolvers().promise + }, + 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 () => diff --git a/tests/engine/editor/clipboard/figma-images.test.ts b/tests/engine/editor/clipboard/figma-images.test.ts index d5a2921b5..f8a4d8935 100644 --- a/tests/engine/editor/clipboard/figma-images.test.ts +++ b/tests/engine/editor/clipboard/figma-images.test.ts @@ -43,6 +43,34 @@ describe('Figma clipboard images', () => { 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])