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:
parent
c8bbdbbb46
commit
b08e199c85
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "フォールバックパック",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Резервные наборы",
|
||||
|
|
|
|||
|
|
@ -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": "后备字体包",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () =>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in a new issue