Merge pull request #411 from open-pencil/figma-paste-images
fix(clipboard): fetch pasted Figma images on desktop
This commit is contained in:
commit
e0d8efb2a4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -98,32 +98,31 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
const replacementTargets = options.replaceSelection ? selectedReplacementTargets(ctx) : []
|
||||
const pasteTarget = replacementTargets[0]?.parentId ?? resolvePasteTarget(ctx)
|
||||
const created = importClipboardNodes(figma.nodes, ctx.graph, pasteTarget, 0, 0, figma.blobs)
|
||||
if (created.length > 0) {
|
||||
if (replacementTargets.length > 0) {
|
||||
replaceTargetsWithCreated(
|
||||
ctx,
|
||||
placementActions.centerNodesAt,
|
||||
created,
|
||||
replacementTargets,
|
||||
prevSelection
|
||||
)
|
||||
await fontActions.loadFontsForNodes(created)
|
||||
warnMissingImages(created)
|
||||
ctx.requestRender()
|
||||
return
|
||||
}
|
||||
if (created.length === 0) return
|
||||
|
||||
if (replacementTargets.length > 0) {
|
||||
replaceTargetsWithCreated(
|
||||
ctx,
|
||||
placementActions.centerNodesAt,
|
||||
created,
|
||||
replacementTargets,
|
||||
prevSelection
|
||||
)
|
||||
} else {
|
||||
const { width: viewW, height: viewH } = ctx.getViewportSize()
|
||||
const cx = cursorPos?.x ?? (-ctx.state.panX + viewW / 2) / ctx.state.zoom
|
||||
const cy = cursorPos?.y ?? (-ctx.state.panY + viewH / 2) / ctx.state.zoom
|
||||
placementActions.centerNodesAt(created, cx, cy)
|
||||
computeAllLayouts(ctx.graph, ctx.state.currentPageId)
|
||||
ctx.setSelectedIds(new Set(created))
|
||||
|
||||
pushPasteUndo(created, prevSelection)
|
||||
await fontActions.loadFontsForNodes(created)
|
||||
warnMissingImages(created)
|
||||
ctx.requestRender()
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
hydrateFigmaClipboardImages(figma.meta.fileKey, created),
|
||||
fontActions.loadFontsForNodes(created)
|
||||
])
|
||||
ctx.requestRender()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,11 +172,47 @@ export function createClipboardActions(ctx: EditorContext) {
|
|||
return created
|
||||
}
|
||||
|
||||
function missingImageHashes(nodeIds: string[]) {
|
||||
const hashes = new Set<string>()
|
||||
for (const node of collectSubtrees(ctx.graph, nodeIds)) {
|
||||
for (const fill of node.fills) {
|
||||
if (fill.type === 'IMAGE' && fill.imageHash && !ctx.graph.images.has(fill.imageHash)) {
|
||||
hashes.add(fill.imageHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...hashes]
|
||||
}
|
||||
|
||||
async function hydrateFigmaClipboardImages(fileKey: string, nodeIds: string[]) {
|
||||
const hashes = missingImageHashes(nodeIds)
|
||||
if (hashes.length === 0) return
|
||||
|
||||
const resolver = ctx.resolveFigmaClipboardImages
|
||||
if (resolver) {
|
||||
try {
|
||||
const images = await resolver(fileKey, hashes)
|
||||
for (const hash of hashes) {
|
||||
const bytes = images.get(hash)
|
||||
if (bytes) ctx.graph.images.set(hash, bytes)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch Figma clipboard images', error)
|
||||
}
|
||||
}
|
||||
|
||||
const missing = missingImageHashes(nodeIds).length
|
||||
if (missing > 0) {
|
||||
ctx.emitEditorEvent('clipboard:images-missing', {
|
||||
total: hashes.length,
|
||||
missing,
|
||||
fetchAttempted: resolver !== null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function warnMissingImages(nodeIds: string[]) {
|
||||
const allNodes = collectSubtrees(ctx.graph, nodeIds)
|
||||
return allNodes.some((n) =>
|
||||
n.fills.some((f) => f.type === 'IMAGE' && f.imageHash && !ctx.graph.images.has(f.imageHash))
|
||||
)
|
||||
return missingImageHashes(nodeIds).length > 0
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export function createEditor(options?: EditorOptions) {
|
|||
undo,
|
||||
state,
|
||||
loadFont: _loadFont,
|
||||
resolveFigmaClipboardImages: options?.resolveFigmaClipboardImages ?? null,
|
||||
getViewportSize: _getViewportSize,
|
||||
getCk: () => _ck,
|
||||
getRenderer: () => _renderer,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -87,6 +87,17 @@ export interface EditorState {
|
|||
cursorCanvasY?: number | null
|
||||
}
|
||||
|
||||
export interface ClipboardImageResolution {
|
||||
total: number
|
||||
missing: number
|
||||
fetchAttempted: boolean
|
||||
}
|
||||
|
||||
export type FigmaClipboardImageResolver = (
|
||||
fileKey: string,
|
||||
hashes: string[]
|
||||
) => Promise<ReadonlyMap<string, Uint8Array>>
|
||||
|
||||
export interface EditorEvents extends SceneGraphEvents {
|
||||
'render:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
|
||||
'repaint:requested': (versions: { renderVersion: number; sceneVersion: number }) => void
|
||||
|
|
@ -94,6 +105,7 @@ export interface EditorEvents extends SceneGraphEvents {
|
|||
'selection:changed': (selectedIds: string[], previousIds: string[]) => void
|
||||
'tool:changed': (tool: Tool, previousTool: Tool) => void
|
||||
'page:changed': (pageId: string, previousPageId: string) => void
|
||||
'clipboard:images-missing': (resolution: ClipboardImageResolution) => void
|
||||
'viewport:changed': (
|
||||
viewport: { panX: number; panY: number; zoom: number },
|
||||
previous: { panX: number; panY: number; zoom: number }
|
||||
|
|
@ -106,6 +118,7 @@ export interface EditorOptions {
|
|||
graph?: SceneGraph
|
||||
state?: EditorState
|
||||
loadFont?: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
|
||||
resolveFigmaClipboardImages?: FigmaClipboardImageResolver
|
||||
getViewportSize?: () => { width: number; height: number }
|
||||
skipInitialGraphSetup?: boolean
|
||||
}
|
||||
|
|
@ -116,6 +129,7 @@ export interface EditorContext {
|
|||
undo: UndoManager
|
||||
state: EditorState
|
||||
loadFont: (family: string, style: string, characters?: string) => Promise<ArrayBuffer | null>
|
||||
resolveFigmaClipboardImages: FigmaClipboardImageResolver | null
|
||||
getViewportSize: () => { width: number; height: number }
|
||||
getCk: () => CanvasKit | null
|
||||
getRenderer: () => SkiaRenderer | null
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
86
src/app/editor/clipboard/figma-images.ts
Normal file
86
src/app/editor/clipboard/figma-images.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { tauriFetch } from '@/app/tauri/http'
|
||||
|
||||
type ClipboardImageFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
type FigmaImageURLs = Record<string, string>
|
||||
|
||||
const IMAGE_FETCH_CONCURRENCY = 6
|
||||
const IMAGE_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function imageURLsFromBatchResponse(value: unknown): FigmaImageURLs {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.error === true ||
|
||||
(typeof value.status === 'number' && value.status !== 200) ||
|
||||
!isRecord(value.meta)
|
||||
) {
|
||||
throw new Error('Figma returned an invalid image response')
|
||||
}
|
||||
const urls = value.meta.s3_urls
|
||||
if (!isRecord(urls)) throw new Error('Figma returned an invalid image URL map')
|
||||
|
||||
const result: FigmaImageURLs = {}
|
||||
for (const [hash, url] of Object.entries(urls)) {
|
||||
if (typeof url === 'string') result[hash] = url
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function sha1Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-1', Uint8Array.from(bytes))
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export async function resolveFigmaClipboardImages(
|
||||
fileKey: string,
|
||||
hashes: string[],
|
||||
fetcher: ClipboardImageFetch = tauriFetch,
|
||||
timeoutMs = IMAGE_FETCH_TIMEOUT_MS
|
||||
): Promise<ReadonlyMap<string, Uint8Array>> {
|
||||
const uniqueHashes = [...new Set(hashes)]
|
||||
if (uniqueHashes.length === 0) return new Map()
|
||||
|
||||
const batchResponse = await fetcher(
|
||||
`https://www.figma.com/file/${encodeURIComponent(fileKey)}/image/batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false }),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
}
|
||||
)
|
||||
if (!batchResponse.ok) {
|
||||
throw new Error(`Figma image request failed with status ${batchResponse.status}`)
|
||||
}
|
||||
|
||||
const payload: unknown = await batchResponse.json()
|
||||
const urls = imageURLsFromBatchResponse(payload)
|
||||
const images = new Map<string, Uint8Array>()
|
||||
|
||||
for (let offset = 0; offset < uniqueHashes.length; offset += IMAGE_FETCH_CONCURRENCY) {
|
||||
const batch = uniqueHashes.slice(offset, offset + IMAGE_FETCH_CONCURRENCY)
|
||||
await Promise.all(
|
||||
batch.map(async (hash) => {
|
||||
const url = urls[hash]
|
||||
if (!url) return
|
||||
try {
|
||||
const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (!response.ok) throw new Error(`status ${response.status}`)
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
if ((await sha1Hex(bytes)) !== hash.toLowerCase()) {
|
||||
throw new Error('SHA-1 mismatch')
|
||||
}
|
||||
images.set(hash, bytes)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch pasted Figma image ${hash}`, error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
30
src/app/editor/clipboard/notifications.ts
Normal file
30
src/app/editor/clipboard/notifications.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ClipboardImageResolution, Editor } from '@open-pencil/core/editor'
|
||||
import { dialogMessages } from '@open-pencil/vue'
|
||||
|
||||
import { toast } from '@/app/shell/ui'
|
||||
|
||||
export function notifyClipboardImageResolution({
|
||||
total,
|
||||
missing,
|
||||
fetchAttempted
|
||||
}: ClipboardImageResolution) {
|
||||
const messages = dialogMessages.get()
|
||||
if (!fetchAttempted) {
|
||||
toast.warning(
|
||||
total === 1
|
||||
? messages.clipboardImageUnavailableWeb
|
||||
: messages.clipboardImagesUnavailableWeb({ count: total })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(
|
||||
missing === 1
|
||||
? messages.clipboardImageFetchFailed
|
||||
: messages.clipboardImagesFetchFailed({ count: missing })
|
||||
)
|
||||
}
|
||||
|
||||
export function bindClipboardNotifications(editor: Editor) {
|
||||
return editor.onEditorEvent('clipboard:images-missing', notifyClipboardImageResolution)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -20,30 +20,57 @@ function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
|
|||
return [...headers.entries()].map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
|
||||
async function bodyToBytes(body: BodyInit | null | undefined): Promise<number[] | undefined> {
|
||||
if (body == null) return undefined
|
||||
if (typeof body === 'string') return [...new TextEncoder().encode(body)]
|
||||
if (body instanceof ArrayBuffer) return [...new Uint8Array(body)]
|
||||
if (ArrayBuffer.isView(body))
|
||||
return [...new Uint8Array(body.buffer, body.byteOffset, body.byteLength)]
|
||||
if (body instanceof Blob) return [...new Uint8Array(await body.arrayBuffer())]
|
||||
if (body instanceof URLSearchParams) return [...new TextEncoder().encode(body.toString())]
|
||||
if (body instanceof FormData) {
|
||||
return [...new Uint8Array(await new Response(body).arrayBuffer())]
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
void promise.catch(() => undefined)
|
||||
return Promise.reject(abortReason(signal))
|
||||
}
|
||||
throw new TypeError('Streaming request bodies are not supported by the desktop HTTP bridge yet')
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener('abort', onAbort)
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
reject(abortReason(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await promise
|
||||
cleanup()
|
||||
resolve(value)
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Desktop HTTP request failed', { cause: error })
|
||||
)
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init)
|
||||
request.signal.throwIfAborted()
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const payload: ProxyHttpRequest = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headersToProxyHeaders(request.headers),
|
||||
body: await bodyToBytes(init?.body)
|
||||
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())]
|
||||
}
|
||||
const response = await invoke<ProxyHttpResponse>('proxy_http_request', { request: payload })
|
||||
request.signal.throwIfAborted()
|
||||
const response = await withAbortSignal(
|
||||
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
|
||||
request.signal
|
||||
)
|
||||
return new Response(new Uint8Array(response.body), {
|
||||
status: response.status,
|
||||
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
|
||||
|
|
|
|||
133
tests/engine/app/clipboard/figma-images.test.ts
Normal file
133
tests/engine/app/clipboard/figma-images.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, spyOn, test } from 'bun:test'
|
||||
|
||||
import { resolveFigmaClipboardImages } from '@/app/editor/clipboard/figma-images'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
async function sha1Hex(bytes: Uint8Array) {
|
||||
const digest = await crypto.subtle.digest('SHA-1', Uint8Array.from(bytes))
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function pendingResponseUntilAbort(_input: RequestInfo | URL, init?: RequestInit) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
reject(new Error('Expected request signal'))
|
||||
return
|
||||
}
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveFigmaClipboardImages', () => {
|
||||
test('resolves signed URLs, verifies bytes, and deduplicates hashes', async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3])
|
||||
const hash = await sha1Hex(bytes)
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
requests.push({ url, init })
|
||||
if (url.includes('/image/batch')) {
|
||||
return Response.json({
|
||||
error: false,
|
||||
status: 200,
|
||||
meta: { s3_urls: { [hash]: 'https://s3-alpha-sig.figma.com/image' } }
|
||||
})
|
||||
}
|
||||
return new Response(Uint8Array.from(bytes))
|
||||
}
|
||||
|
||||
const images = await resolveFigmaClipboardImages('file/key', [hash, hash], fetcher)
|
||||
|
||||
expect(images.get(hash)).toEqual(bytes)
|
||||
expect(requests.map(({ url }) => url)).toEqual([
|
||||
'https://www.figma.com/file/file%2Fkey/image/batch',
|
||||
'https://s3-alpha-sig.figma.com/image'
|
||||
])
|
||||
const body = expectDefined(requests[0].init?.body, 'batch request body')
|
||||
expect(JSON.parse(String(body))).toEqual({
|
||||
sha1s: [hash],
|
||||
needs_compressed_textures: false
|
||||
})
|
||||
})
|
||||
|
||||
test('drops a response whose bytes do not match the Figma hash', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const fetcher = async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes('/image/batch')) {
|
||||
return Response.json({
|
||||
error: false,
|
||||
status: 200,
|
||||
meta: {
|
||||
s3_urls: {
|
||||
'1111111111111111111111111111111111111111': 'https://s3-alpha-sig.figma.com/image'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return new Response(new Uint8Array([9, 9, 9]))
|
||||
}
|
||||
|
||||
const images = await resolveFigmaClipboardImages(
|
||||
'file-key',
|
||||
['1111111111111111111111111111111111111111'],
|
||||
fetcher
|
||||
)
|
||||
|
||||
expect(images.size).toBe(0)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
test('times out stalled batch requests', async () => {
|
||||
await expect(
|
||||
resolveFigmaClipboardImages('file-key', ['hash'], pendingResponseUntilAbort, 5)
|
||||
).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
})
|
||||
|
||||
test('drops images whose signed URL request times out', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const images = await resolveFigmaClipboardImages(
|
||||
'file-key',
|
||||
['1111111111111111111111111111111111111111'],
|
||||
(input, init) => {
|
||||
if (String(input).includes('/image/batch')) {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
error: false,
|
||||
status: 200,
|
||||
meta: {
|
||||
s3_urls: {
|
||||
'1111111111111111111111111111111111111111': 'https://s3-alpha-sig.figma.com/image'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return pendingResponseUntilAbort(input, init)
|
||||
},
|
||||
5
|
||||
)
|
||||
|
||||
expect(images.size).toBe(0)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
test('rejects failed and malformed batch responses', async () => {
|
||||
await expect(
|
||||
resolveFigmaClipboardImages('file-key', ['hash'], async () =>
|
||||
Response.json({}, { status: 503 })
|
||||
)
|
||||
).rejects.toThrow('status 503')
|
||||
|
||||
await expect(
|
||||
resolveFigmaClipboardImages('file-key', ['hash'], async () => Response.json({ error: true }))
|
||||
).rejects.toThrow('invalid image response')
|
||||
})
|
||||
})
|
||||
32
tests/engine/app/clipboard/notifications.test.ts
Normal file
32
tests/engine/app/clipboard/notifications.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { notifyClipboardImageResolution } from '@/app/editor/clipboard/notifications'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
|
||||
afterEach(() => {
|
||||
toast.toasts.value = []
|
||||
})
|
||||
|
||||
describe('clipboard image notifications', () => {
|
||||
test('warns web users when pasted images cannot be fetched', () => {
|
||||
notifyClipboardImageResolution({ total: 2, missing: 2, fetchAttempted: false })
|
||||
|
||||
expect(toast.toasts.value).toHaveLength(1)
|
||||
expect(toast.toasts.value[0]).toMatchObject({
|
||||
variant: 'warning',
|
||||
message:
|
||||
'Pasted design includes 2 images that cannot be loaded in the web app. Use the desktop app to include them.'
|
||||
})
|
||||
})
|
||||
|
||||
test('shows an actionable desktop error for partial failures', () => {
|
||||
notifyClipboardImageResolution({ total: 3, missing: 1, fetchAttempted: true })
|
||||
|
||||
expect(toast.toasts.value).toHaveLength(1)
|
||||
expect(toast.toasts.value[0]).toMatchObject({
|
||||
variant: 'error',
|
||||
message:
|
||||
'Failed to fetch 1 image from Figma. Check that the source file is accessible and try again.'
|
||||
})
|
||||
})
|
||||
})
|
||||
36
tests/engine/app/tauri/http.test.ts
Normal file
36
tests/engine/app/tauri/http.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { withAbortSignal } from '@/app/tauri/http'
|
||||
|
||||
describe('withAbortSignal', () => {
|
||||
test('resolves with the wrapped promise', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(withAbortSignal(Promise.resolve('ok'), controller.signal)).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
test('rejects immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled')
|
||||
controller.abort(reason)
|
||||
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const result = withAbortSignal(pending.promise, controller.signal)
|
||||
|
||||
await expect(result).rejects.toBe(reason)
|
||||
pending.reject(new Error('late request failure'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
test('rejects a pending promise when the signal aborts', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const result = withAbortSignal(pending.promise, controller.signal)
|
||||
const reason = new Error('cancelled')
|
||||
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(result).rejects.toBe(reason)
|
||||
pending.resolve('late result')
|
||||
})
|
||||
})
|
||||
125
tests/engine/editor/clipboard/figma-images.test.ts
Normal file
125
tests/engine/editor/clipboard/figma-images.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { initCodec } from '@open-pencil/core'
|
||||
import { buildFigmaClipboardHTML } from '@open-pencil/core/clipboard'
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
import type { ClipboardImageResolution } from '@open-pencil/core/editor'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
const IMAGE_HASH_A = '1111111111111111111111111111111111111111'
|
||||
const IMAGE_HASH_B = '2222222222222222222222222222222222222222'
|
||||
|
||||
async function imageClipboardHtml(hashes: string[]) {
|
||||
const source = createEditor()
|
||||
const frame = source.graph.createNode('FRAME', source.state.currentPageId, {
|
||||
name: 'Images',
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
for (const [index, hash] of hashes.entries()) {
|
||||
source.graph.createNode('RECTANGLE', frame.id, {
|
||||
name: `Image ${index}`,
|
||||
x: index * 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fills: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
imageHash: hash,
|
||||
imageScaleMode: 'FILL',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
return expectDefined(await buildFigmaClipboardHTML([frame], source.graph), 'Figma clipboard HTML')
|
||||
}
|
||||
|
||||
describe('Figma clipboard images', () => {
|
||||
beforeAll(async () => {
|
||||
await initCodec()
|
||||
})
|
||||
|
||||
test('finalizes structural paste before image resolution completes', async () => {
|
||||
const html = await imageClipboardHtml([IMAGE_HASH_A])
|
||||
let startResolution: (() => void) | undefined
|
||||
const resolutionStarted = new Promise<void>((resolve) => {
|
||||
startResolution = resolve
|
||||
})
|
||||
let finishResolution: ((images: ReadonlyMap<string, Uint8Array>) => void) | undefined
|
||||
const pendingResolution = new Promise<ReadonlyMap<string, Uint8Array>>((resolve) => {
|
||||
finishResolution = resolve
|
||||
})
|
||||
const editor = createEditor({
|
||||
resolveFigmaClipboardImages: () => {
|
||||
startResolution?.()
|
||||
return pendingResolution
|
||||
}
|
||||
})
|
||||
|
||||
const paste = editor.pasteFromHTML(html)
|
||||
await resolutionStarted
|
||||
|
||||
expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(1)
|
||||
expect(editor.state.selectedIds.size).toBe(1)
|
||||
expect(editor.undo.undoLabel).toBe('Paste')
|
||||
|
||||
finishResolution?.(new Map([[IMAGE_HASH_A, new Uint8Array([1, 2, 3])]]))
|
||||
await paste
|
||||
})
|
||||
|
||||
test('resolves and stores missing images before completing paste', async () => {
|
||||
const html = await imageClipboardHtml([IMAGE_HASH_A, IMAGE_HASH_A])
|
||||
const imageBytes = new Uint8Array([1, 2, 3])
|
||||
const calls: Array<{ fileKey: string; hashes: string[] }> = []
|
||||
const resolutions: ClipboardImageResolution[] = []
|
||||
const editor = createEditor({
|
||||
resolveFigmaClipboardImages: async (fileKey, hashes) => {
|
||||
calls.push({ fileKey, hashes })
|
||||
return new Map([[IMAGE_HASH_A, imageBytes]])
|
||||
}
|
||||
})
|
||||
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
|
||||
resolutions.push(resolution)
|
||||
})
|
||||
|
||||
await editor.pasteFromHTML(html)
|
||||
|
||||
expect(calls).toEqual([{ fileKey: 'openpencil', hashes: [IMAGE_HASH_A] }])
|
||||
expect(editor.graph.images.get(IMAGE_HASH_A)).toEqual(imageBytes)
|
||||
expect(resolutions).toEqual([])
|
||||
})
|
||||
|
||||
test('reports images that cannot be fetched without a resolver', async () => {
|
||||
const html = await imageClipboardHtml([IMAGE_HASH_A])
|
||||
const resolutions: ClipboardImageResolution[] = []
|
||||
const editor = createEditor()
|
||||
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
|
||||
resolutions.push(resolution)
|
||||
})
|
||||
|
||||
await editor.pasteFromHTML(html)
|
||||
|
||||
expect(resolutions).toEqual([{ total: 1, missing: 1, fetchAttempted: false }])
|
||||
})
|
||||
|
||||
test('stores partial results and reports remaining images', async () => {
|
||||
const html = await imageClipboardHtml([IMAGE_HASH_A, IMAGE_HASH_B])
|
||||
const editor = createEditor({
|
||||
resolveFigmaClipboardImages: async () => new Map([[IMAGE_HASH_A, new Uint8Array([4, 5, 6])]])
|
||||
})
|
||||
const resolutions: ClipboardImageResolution[] = []
|
||||
editor.onEditorEvent('clipboard:images-missing', (resolution) => {
|
||||
resolutions.push(resolution)
|
||||
})
|
||||
|
||||
await editor.pasteFromHTML(html)
|
||||
|
||||
expect(editor.graph.images.has(IMAGE_HASH_A)).toBe(true)
|
||||
expect(editor.graph.images.has(IMAGE_HASH_B)).toBe(false)
|
||||
expect(resolutions).toEqual([{ total: 2, missing: 1, fetchAttempted: true }])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue