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
This commit is contained in:
Danila Poyarkov 2026-07-18 04:22:38 +03:00
parent c8bbdbbb46
commit b08e199c85
15 changed files with 170 additions and 31 deletions

View file

@ -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()
}
}

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 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",

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 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",

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

@ -5,6 +5,7 @@ type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Pro
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)
@ -34,21 +35,49 @@ async function sha1Hex(bytes: Uint8Array): Promise<string> {
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<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_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<ReadonlyMap<string, Uint8Array>> {
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()) {

View file

@ -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 })
)
}

View file

@ -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

View file

@ -69,6 +69,46 @@ describe('resolveFigmaClipboardImages', () => {
warn.mockRestore()
})
test('times out stalled batch requests', async () => {
await expect(
resolveFigmaClipboardImages(
'file-key',
['hash'],
() => Promise.withResolvers<Response>().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<Response>().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 () =>

View file

@ -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<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])