Image drag-and-drop, clipboard paste, and paste-at-cursor (#92)

* Image drag-and-drop and clipboard paste onto canvas

* Paste images at cursor position, track canvas cursor in state

* Paste nodes at cursor position (Figma and internal clipboard)

* Fix review: center images at cursor, fix hasImageFiles, clean up hash/undo

* Paste at viewport center when cursor is outside canvas

* Fix Figma paste positioning: center nodes at cursor after import

* Image support: clipboard, export, drag-and-drop, paste, renderer, tools, UI

- Clipboard: embed image bytes (base64) in OpenPencil clipboard payload so
  copy/paste between documents preserves image fills
- Fig export: write images/ folder to .fig zip (both fflate and Tauri paths)
- Tauri: extend build_fig_file to accept image entries
- Renderer: implement CROP (with imageTransform) and TILE (TileMode.Repeat)
  scale modes, fix FIT to center the fitted image
- FigmaAPI: add createImage(bytes) with sync FNV-1a hash
- Tools: add set_image_fill tool for AI/MCP
- FillPicker: replace placeholder with file picker, preview, scale mode selector
- Drag-and-drop: new use-image-drop composable creates image nodes from dropped files
- Paste: keyboard paste handler detects image clipboard items
- Shared utils: extract hashImageBytes and getImageDimensions to src/utils/image.ts

* Add image tests and Yjs image sync for collaboration

Tests (18 new):
- FigmaAPI.createImage: deterministic hash, storage, format
- set_image_fill tool: all scale modes, error handling, storage
- Clipboard roundtrip: image bytes preserved, multiple images, children
- Fig export/import: zip contains images/, full round-trip

Collab:
- Add yimages Y.Map to sync graph.images via Yjs
- Observer applies remote image adds/deletes to local graph
- syncNodeToYjs pushes referenced image data alongside node props
- syncAllNodesToYjs bulk-syncs all images on room share

* Fix FillPicker: remove deleted utils/image import, use SHA-1 inline

* Extract storeImage() on editor store, use in FillPicker and placeImageNode

* Unify image hash: use sync FNV-1a everywhere, export computeImageHash

Editor store's hashBytes (async SHA-1) produced different hashes than
FigmaAPI.createImage (sync FNV-1a) for the same bytes. This meant
drag-and-drop images couldn't be deduplicated against AI tool images.

Replace hashBytes with computeImageHash from core. storeImage() is
now sync.

* Skip drawing IMAGE fills when image data is missing

When pasting from Figma, image fills reference a CDN hash but no pixel
data is included in the clipboard. Previously this rendered as a solid
black rectangle because applyImageFill bailed without setting a shader,
leaving stale paint state.

applyFill now returns false when the fill can't be applied, and callers
skip the draw call. The node still exists with the correct imageHash —
if the image data is later provided (e.g. via file re-open), it will
render correctly.

* Warn when Figma paste has missing image data

Show amber warning toast when pasted nodes reference image fills
without available bytes (Figma clipboard limitation).

Add 'warning' toast variant with tailwind-variants, extract toast
styles to src/components/ui/toast.ts.

* Use useFileDialog and useObjectUrl in FillPicker

Replace manual file input ref, click(), createObjectURL/revokeObjectURL
with vueuse composables. Remove hidden <input type=file> from template.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
Anton Soldatov 2026-03-11 07:52:04 +03:00 committed by GitHub
parent be845f5eeb
commit 37da83aec3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 917 additions and 69 deletions

1
components.d.ts vendored
View file

@ -67,6 +67,7 @@ declare module 'vue' {
IconLucideFlipVertical: typeof import('~icons/lucide/flip-vertical')['default']
IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default']
IconLucideImage: typeof import('~icons/lucide/image')['default']
IconLucideImageOff: typeof import('~icons/lucide/image-off')['default']
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']

View file

@ -132,12 +132,19 @@ async fn load_system_font(family: String, style: String) -> Result<Vec<u8>, Stri
.map_err(|e| format!("Font load task failed: {e}"))?
}
#[derive(serde::Deserialize)]
struct ImageEntry {
name: String,
data: Vec<u8>,
}
#[tauri::command]
fn build_fig_file(
schema_deflated: Vec<u8>,
kiwi_data: Vec<u8>,
thumbnail_png: Vec<u8>,
meta_json: String,
images: Option<Vec<ImageEntry>>,
) -> Result<Vec<u8>, String> {
use std::io::{Cursor, Write};
@ -185,6 +192,15 @@ fn build_fig_file(
zip.write_all(meta_json.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(image_entries) = images {
for entry in image_entries {
zip.start_file(&entry.name, options)
.map_err(|e| e.to_string())?;
zip.write_all(&entry.data)
.map_err(|e| e.to_string())?;
}
}
let result = zip.finish().map_err(|e| e.to_string())?;
Ok(result.into_inner())
}

View file

@ -342,9 +342,14 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph):
// --- Internal copy/paste (OpenPencil ↔ OpenPencil) ---
export interface OpenPencilClipboardData {
nodes: Array<SceneNode & { children?: SceneNode[] }>
images: Map<string, Uint8Array>
}
export function parseOpenPencilClipboard(
html: string
): Array<SceneNode & { children?: SceneNode[] }> | null {
): OpenPencilClipboardData | null {
const match = html.match(/<!--\(openpencil\)(.*?)\(\/openpencil\)-->/s)
if (!match) return null
@ -352,7 +357,15 @@ export function parseOpenPencilClipboard(
const decoded = JSON.parse(new TextDecoder().decode(Uint8Array.fromBase64(match[1])))
if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) {
restoreTextPictures(decoded.nodes)
return decoded.nodes
const images = new Map<string, Uint8Array>()
if (decoded.images && typeof decoded.images === 'object') {
for (const [hash, b64] of Object.entries(decoded.images)) {
if (typeof b64 === 'string') {
images.set(hash, Uint8Array.fromBase64(b64))
}
}
}
return { nodes: decoded.nodes, images }
}
} catch {
// Not our format
@ -373,14 +386,36 @@ function restoreTextPictures(nodes: Array<Record<string, unknown>>): void {
export type TextPictureBuilder = (node: SceneNode) => Uint8Array | null
function collectImageHashes(nodes: SceneNode[], graph: SceneGraph): Set<string> {
const hashes = new Set<string>()
function walk(nodeList: SceneNode[]) {
for (const node of nodeList) {
for (const fill of node.fills) {
if (fill.imageHash) hashes.add(fill.imageHash)
}
walk(graph.getChildren(node.id))
}
}
walk(nodes)
return hashes
}
export function buildOpenPencilClipboardHTML(
nodes: SceneNode[],
graph: SceneGraph,
textPictureBuilder?: TextPictureBuilder
): string {
const nodeTree = collectNodeTree(nodes, graph, textPictureBuilder)
const hashes = collectImageHashes(nodes, graph)
const images: Record<string, string> = {}
for (const hash of hashes) {
const bytes = graph.images.get(hash)
if (bytes) images[hash] = bytes.toBase64()
}
const data = {
format: 'openpencil/v1',
nodes: collectNodeTree(nodes, graph, textPictureBuilder)
nodes: nodeTree,
images
}
return `<!--(openpencil)${new TextEncoder().encode(JSON.stringify(data)).toBase64()}(/openpencil)-->`
}

View file

@ -1,4 +1,4 @@
import { zipSync, deflateSync } from 'fflate'
import { zipSync, deflateSync, type Zippable } from 'fflate'
import { CANVAS_BG_COLOR, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap } from './kiwi-serialize'
@ -48,6 +48,14 @@ function variableValueToKiwi(
return { value: { floatValue: Number(value) }, dataType: 'FLOAT', resolvedDataType: 'FLOAT' }
}
function collectImageEntries(graph: SceneGraph): Array<{ name: string; data: Uint8Array }> {
const entries: Array<{ name: string; data: Uint8Array }> = []
for (const [hash, data] of graph.images) {
entries.push({ name: `images/${hash}`, data })
}
return entries
}
const THUMBNAIL_WIDTH = 400
const THUMBNAIL_HEIGHT = 225
@ -216,6 +224,8 @@ export async function exportFigFile(
createdAt: new Date().toISOString()
})
const imageEntries = collectImageEntries(graph)
if (IS_TAURI) {
const { invoke } = await import('@tauri-apps/api/core')
return new Uint8Array(
@ -223,15 +233,20 @@ export async function exportFigFile(
schemaDeflated: Array.from(schemaDeflated),
kiwiData: Array.from(kiwiData),
thumbnailPng: Array.from(thumbnailPng),
metaJson
metaJson,
images: imageEntries.map(e => ({ name: e.name, data: Array.from(e.data) }))
})
)
}
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
return zipSync({
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
})
}
for (const entry of imageEntries) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
return zipSync(zipEntries)
}

View file

@ -57,6 +57,25 @@ function styleNameToWeight(style: string): { weight: number; italic: boolean } {
return { weight: map[clean] ?? 400, italic }
}
export function computeImageHash(data: Uint8Array): string {
let h1 = 0x811c9dc5 >>> 0
let h2 = 0x811c9dc5 >>> 0
let h3 = 0x811c9dc5 >>> 0
let h4 = 0x811c9dc5 >>> 0
let h5 = 0x811c9dc5 >>> 0
for (let i = 0; i < data.length; i++) {
const b = data[i]
switch (i % 5) {
case 0: h1 ^= b; h1 = Math.imul(h1, 0x01000193) >>> 0; break
case 1: h2 ^= b; h2 = Math.imul(h2, 0x01000193) >>> 0; break
case 2: h3 ^= b; h3 = Math.imul(h3, 0x01000193) >>> 0; break
case 3: h4 ^= b; h4 = Math.imul(h4, 0x01000193) >>> 0; break
case 4: h5 ^= b; h5 = Math.imul(h5, 0x01000193) >>> 0; break
}
}
return [h1, h2, h3, h4, h5].map(h => h.toString(16).padStart(8, '0')).join('')
}
const INTERNAL_ID = Symbol('id')
const INTERNAL_GRAPH = Symbol('graph')
const INTERNAL_API = Symbol('api')
@ -1285,6 +1304,12 @@ export class FigmaAPI {
this._viewport = { x: v.center.x, y: v.center.y, zoom: v.zoom }
}
createImage(data: Uint8Array): { hash: string } {
const hash = computeImageHash(data)
this.graph.images.set(hash, data)
return { hash }
}
// --- Stubs ---
async loadFontAsync(_fontName: FigmaFontName): Promise<void> {

View file

@ -51,7 +51,7 @@ export {
type SceneGraphEvents
} from './scene-graph'
export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { FigmaAPI, FigmaNodeProxy, computeImageHash, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { executeRpcCommand, ALL_RPC_COMMANDS } from './rpc'
@ -192,7 +192,8 @@ export {
buildFigmaClipboardHTML,
buildOpenPencilClipboardHTML,
prefetchFigmaSchema,
type TextPictureBuilder
type TextPictureBuilder,
type OpenPencilClipboardData
} from './clipboard'
export { readFigFile, parseFigFile } from './kiwi/fig-file'

View file

@ -57,24 +57,25 @@ export function applyFill(
node: SceneNode,
graph: SceneGraph,
fillIndex = 0
): void {
): boolean {
r.fillPaint.setShader(null)
if (fill.type === 'SOLID') {
const c = r.resolveFillColor(fill, fillIndex, node, graph)
r.fillPaint.setColor(r.ck.Color4f(c.r, c.g, c.b, c.a))
return
return true
}
if (fill.type.startsWith('GRADIENT') && fill.gradientStops && fill.gradientTransform) {
r.applyGradientFill(fill, node)
return
return true
}
if (fill.type === 'IMAGE' && fill.imageHash) {
r.applyImageFill(fill, node, graph)
return
return r.applyImageFill(fill, node, graph)
}
return false
}
export function applyGradientFill(r: SkiaRenderer, fill: Fill, node: SceneNode): void {
@ -144,39 +145,52 @@ export function applyImageFill(
fill: Fill,
node: SceneNode,
graph: SceneGraph
): void {
): boolean {
const hash = fill.imageHash
if (!hash) return
if (!hash) return false
let img = r.imageCache.get(hash)
if (!img) {
const data = graph.images.get(hash)
if (!data) return
if (!data) return false
img = r.ck.MakeImageFromEncoded(data) ?? undefined
if (img) r.imageCache.set(hash, img)
else return
else return false
}
const imgW = img.width()
const imgH = img.height()
const scaleMode = fill.imageScaleMode ?? 'FILL'
if (scaleMode === 'TILE') {
const shader = img.makeShaderCubic(
r.ck.TileMode.Repeat,
r.ck.TileMode.Repeat,
1 / 3,
1 / 3
)
r.fillPaint.setShader(shader)
return true
}
let sx: number, sy: number, sw: number, sh: number
if (scaleMode === 'FILL') {
if (scaleMode === 'CROP' && fill.imageTransform) {
const t = fill.imageTransform
sx = t.m02 * imgW
sy = t.m12 * imgH
sw = t.m00 * imgW
sh = t.m11 * imgH
} else if (scaleMode === 'FIT') {
const scale = Math.min(node.width / imgW, node.height / imgH)
sw = imgW
sh = imgH
sx = -(node.width / scale - imgW) / 2
sy = -(node.height / scale - imgH) / 2
} else {
const scale = Math.max(node.width / imgW, node.height / imgH)
sw = node.width / scale
sh = node.height / scale
sx = (imgW - sw) / 2
sy = (imgH - sh) / 2
} else if (scaleMode === 'FIT') {
sw = imgW
sh = imgH
sx = 0
sy = 0
} else {
sx = 0
sy = 0
sw = imgW
sh = imgH
}
const shader = img.makeShaderCubic(
@ -190,6 +204,7 @@ export function applyImageFill(
)
)
r.fillPaint.setShader(shader)
return true
}
export function drawArc(r: SkiaRenderer, canvas: Canvas, node: SceneNode, paint: Paint): void {

View file

@ -1119,16 +1119,16 @@ export class SkiaRenderer {
drawNodeFillFn(this, canvas, node, rect, hasRadius)
}
applyFill(fill: Fill, node: SceneNode, graph: SceneGraph, fillIndex = 0): void {
applyFillFn(this, fill, node, graph, fillIndex)
applyFill(fill: Fill, node: SceneNode, graph: SceneGraph, fillIndex = 0): boolean {
return applyFillFn(this, fill, node, graph, fillIndex)
}
applyGradientFill(fill: Fill, node: SceneNode): void {
applyGradientFillFn(this, fill, node)
}
applyImageFill(fill: Fill, node: SceneNode, graph: SceneGraph): void {
applyImageFillFn(this, fill, node, graph)
applyImageFill(fill: Fill, node: SceneNode, graph: SceneGraph): boolean {
return applyImageFillFn(this, fill, node, graph)
}
drawArc(canvas: Canvas, node: SceneNode, paint: Paint): void {

View file

@ -172,7 +172,7 @@ export function renderSection(
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
canvas.drawRRect(rrect, r.fillPaint)
r.fillPaint.setShader(null)
@ -206,7 +206,7 @@ export function renderComponentSet(
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
canvas.drawRRect(rrect, r.fillPaint)
r.fillPaint.setShader(null)
@ -364,7 +364,7 @@ export function renderShapeUncached(
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
r.drawNodeFill(canvas, node, rect, hasRadius)
r.fillPaint.setShader(null)

View file

@ -617,3 +617,35 @@ export const setLayoutChild = defineTool({
return { id: args.id, updated }
}
})
export const setImageFill = defineTool({
name: 'set_image_fill',
mutates: true,
description: 'Set an image fill on a node from base64-encoded image data.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
image_data: { type: 'string', description: 'Base64-encoded image bytes (PNG, JPEG, or WEBP)', required: true },
scale_mode: {
type: 'string',
description: 'Image scale mode',
default: 'FILL',
enum: ['FILL', 'FIT', 'CROP', 'TILE']
}
},
execute: (figma, { id, image_data, scale_mode }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const bytes = Uint8Array.fromBase64(image_data)
const image = figma.createImage(bytes)
const mode = (scale_mode ?? 'FILL') as 'FILL' | 'FIT' | 'CROP' | 'TILE'
node.fills = [{
type: 'IMAGE',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
imageHash: image.hash,
imageScaleMode: mode
}]
return { id, imageHash: image.hash, scaleMode: mode }
}
})

View file

@ -13,7 +13,7 @@ import {
setFill, setStroke, setEffects, updateNode, setLayout, setConstraints,
setRotation, setOpacity, setRadius, setMinMax, setText, setFont, setFontRange,
setTextResize, setVisible, setBlend, setLocked, setStrokeAlign,
setTextProperties, setLayoutChild
setTextProperties, setLayoutChild, setImageFill
} from './modify'
import {
deleteNode, cloneNode, renameNode, reparentNode, groupNodes, ungroupNode,
@ -82,6 +82,7 @@ export const ALL_TOOLS: ToolDef[] = [
setStrokeAlign,
setTextProperties,
setLayoutChild,
setImageFill,
// Structure
deleteNode,
cloneNode,

View file

@ -4,6 +4,7 @@ import { ToastProvider, ToastRoot, ToastDescription, ToastViewport, ToastClose }
import { useClipboard } from '@vueuse/core'
import { toast } from '@/composables/use-toast'
import { toastRoot } from '@/components/ui/toast'
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
</script>
@ -15,8 +16,7 @@ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
:key="t.id"
data-test-id="toast-item"
:duration="t.variant === 'error' ? 0 : toast.TOAST_DURATION"
class="flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)]"
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
:class="toastRoot({ tone: t.variant })"
@update:open="
(open) => {
if (!open) toast.remove(t.id)
@ -24,7 +24,7 @@ const { copy, copied } = useClipboard({ copiedDuring: 1500 })
"
>
<icon-lucide-check v-if="t.variant === 'default'" class="mt-0.5 size-3 shrink-0" />
<icon-lucide-alert-triangle v-else class="mt-0.5 size-3 shrink-0" />
<icon-lucide-triangle-alert v-else class="mt-0.5 size-3 shrink-0" />
<ToastDescription class="min-w-0 flex-1 select-text">{{ t.message }}</ToastDescription>
<button
v-if="t.variant === 'error'"

View file

@ -2,6 +2,7 @@
import { ref, computed, watch } from 'vue'
import { useCanvas } from '@/composables/use-canvas'
import { useCanvasDrop } from '@/composables/use-canvas-drop'
import { useCanvasInput } from '@/composables/use-canvas-input'
import { useCollabInjected } from '@/composables/use-collab'
import { useTextEdit } from '@/composables/use-text-edit'
@ -26,6 +27,7 @@ const { cursorOverride } = useCanvasInput(
)
useTextEdit(canvasRef, store)
const { isDraggingOver } = useCanvasDrop(canvasRef, store)
watch(
() => [...store.state.selectedIds],
@ -54,6 +56,17 @@ const cursor = computed(() => {
:style="{ cursor }"
class="block size-full touch-none"
/>
<Transition
enter-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-150"
leave-to-class="opacity-0"
>
<div
v-if="isDraggingOver"
class="pointer-events-none absolute inset-0 z-40 border-2 border-dashed border-accent/60 bg-accent/5"
/>
</Transition>
<Transition leave-active-class="transition-opacity duration-300" leave-to-class="opacity-0">
<div
v-if="store.state.loading"

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, shallowRef, watch } from 'vue'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import {
PopoverRoot,
PopoverTrigger,
@ -18,9 +19,16 @@ import {
import HsvColorArea from './HsvColorArea.vue'
import ScrubInput from './ScrubInput.vue'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS, colorToHexRaw, parseColor } from '@open-pencil/core'
import type { Color, Fill, GradientStop, GradientTransform } from '@open-pencil/core'
import type {
Color,
Fill,
GradientStop,
GradientTransform,
ImageScaleMode
} from '@open-pencil/core'
type FillCategory = 'SOLID' | 'GRADIENT' | 'IMAGE'
type GradientSubtype =
@ -212,6 +220,50 @@ function onStopBarPointerUp() {
function stopSwatchColor(stop: GradientStop) {
return colorToCSS(stop.color)
}
const IMAGE_SCALE_MODES: { value: ImageScaleMode; label: string }[] = [
{ value: 'FILL', label: 'Fill' },
{ value: 'FIT', label: 'Fit' },
{ value: 'CROP', label: 'Crop' },
{ value: 'TILE', label: 'Tile' }
]
const store = useEditorStore()
const imageBlob = shallowRef<Blob | null>(null)
const imagePreviewUrl = useObjectUrl(imageBlob)
watch(
() => fill.imageHash,
(hash) => {
if (!hash) { imageBlob.value = null; return }
const data = store.graph.images.get(hash)
imageBlob.value = data ? new Blob([data]) : null
},
{ immediate: true }
)
const { open: pickImage, onChange: onFileChange } = useFileDialog({
accept: 'image/png,image/jpeg,image/webp',
multiple: false,
})
onFileChange(async (files) => {
const file = files?.[0]
if (!file) return
const bytes = new Uint8Array(await file.arrayBuffer())
const hash = store.storeImage(bytes)
emit('update', {
...fill,
type: 'IMAGE',
imageHash: hash,
imageScaleMode: fill.imageScaleMode ?? 'FILL'
})
})
function setScaleMode(mode: string) {
emit('update', { ...fill, imageScaleMode: mode as ImageScaleMode })
}
</script>
<template>
@ -389,13 +441,56 @@ function stopSwatchColor(stop: GradientStop) {
</div>
</div>
<!-- Image placeholder -->
<div
v-if="fill.type === 'IMAGE'"
data-test-id="fill-picker-image-placeholder"
class="flex h-24 items-center justify-center rounded border border-dashed border-border text-xs text-muted"
>
Image fill (coming soon)
<!-- Image fill -->
<div v-if="fill.type === 'IMAGE'" class="space-y-2">
<div
v-if="imagePreviewUrl"
class="flex h-24 items-center justify-center overflow-hidden rounded border border-border"
>
<img :src="imagePreviewUrl" class="max-h-full max-w-full object-contain" />
</div>
<button
class="flex h-7 w-full cursor-pointer items-center justify-center gap-1 rounded border border-border bg-input text-xs text-surface hover:bg-hover"
data-test-id="fill-picker-choose-image"
@click="pickImage"
>
<icon-lucide-image class="size-3" />
{{ fill.imageHash ? 'Replace' : 'Choose image' }}
</button>
<SelectRoot
:model-value="fill.imageScaleMode ?? 'FILL'"
@update:model-value="setScaleMode"
>
<SelectTrigger
class="flex h-7 w-full cursor-pointer items-center justify-between rounded border border-border bg-input px-2 text-xs text-surface"
>
<SelectValue />
<icon-lucide-chevron-down class="size-3 text-muted" />
</SelectTrigger>
<SelectPortal>
<SelectContent
class="z-[200] min-w-[112px] rounded-md border border-border bg-panel py-1 shadow-xl"
position="popper"
side="bottom"
:side-offset="4"
:align="'start'"
>
<SelectViewport>
<SelectItem
v-for="mode in IMAGE_SCALE_MODES"
:key="mode.value"
:value="mode.value"
class="relative flex cursor-pointer items-center rounded py-1 pr-2 pl-6 text-xs text-surface outline-none data-[highlighted]:bg-accent data-[highlighted]:text-white"
>
<SelectItemIndicator class="absolute left-1.5">
<icon-lucide-check class="size-3" />
</SelectItemIndicator>
<SelectItemText>{{ mode.label }}</SelectItemText>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- HSV color area (solid mode, or editing active gradient stop) -->

View file

@ -0,0 +1,20 @@
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
import type { ToastVariant } from '@/composables/use-toast'
const toast = tv({
base: 'flex max-w-sm items-start gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)]',
variants: {
tone: {
default: 'bg-blue-600',
warning: 'bg-amber-600',
error: 'bg-red-600'
}
},
defaultVariants: { tone: 'default' }
})
export function toastRoot(options?: { tone?: ToastVariant; class?: string }) {
return twMerge(toast(options), options?.class)
}

View file

@ -0,0 +1,68 @@
import { useEventListener } from '@vueuse/core'
import { ref, type Ref } from 'vue'
import type { EditorStore } from '@/stores/editor'
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif'])
export function useCanvasDrop(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
const isDraggingOver = ref(false)
useEventListener(canvasRef, 'dragover', (e: DragEvent) => {
if (!hasImageFiles(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
isDraggingOver.value = true
})
useEventListener(canvasRef, 'dragenter', (e: DragEvent) => {
if (!hasImageFiles(e)) return
e.preventDefault()
isDraggingOver.value = true
})
useEventListener(canvasRef, 'dragleave', () => {
isDraggingOver.value = false
})
useEventListener(canvasRef, 'drop', (e: DragEvent) => {
e.preventDefault()
isDraggingOver.value = false
const files = filterImageFiles(e.dataTransfer?.files ?? null)
if (!files.length) return
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
void store.placeImageFiles(files, cx, cy)
})
return { isDraggingOver }
}
function hasImageFiles(e: DragEvent): boolean {
if (!e.dataTransfer?.types.includes('Files')) return false
for (const item of e.dataTransfer.items) {
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) return true
}
return false
}
function filterImageFiles(files: FileList | null): File[] {
if (!files) return []
const result: File[] = []
for (const file of files) {
if (ACCEPTED_TYPES.has(file.type)) result.push(file)
}
return result
}
export function extractImageFilesFromClipboard(e: ClipboardEvent): File[] {
return filterImageFiles(e.clipboardData?.files ?? null)
}

View file

@ -747,9 +747,11 @@ export function useCanvasInput(
}
function onMouseMove(e: MouseEvent) {
if (onCursorMove) {
{
const { cx, cy } = getCoords(e)
onCursorMove(cx, cy)
store.state.cursorCanvasX = cx
store.state.cursorCanvasY = cy
if (onCursorMove) onCursorMove(cx, cy)
}
if (store.state.activeTool === 'PEN' && store.state.penState && !drag.value) {

View file

@ -46,6 +46,7 @@ export function useCollab(store: EditorStore) {
let ydoc: Y.Doc | null = null
let awareness: awarenessProtocol.Awareness | null = null
let ynodes: Y.Map<Y.Map<unknown>> | null = null
let yimages: Y.Map<Uint8Array> | null = null
let room: Room | null = null
let persistence: IndexeddbPersistence | null = null
let suppressGraphSync = false
@ -64,6 +65,7 @@ export function useCollab(store: EditorStore) {
ydoc = new Y.Doc()
awareness = new awarenessProtocol.Awareness(ydoc)
ynodes = ydoc.getMap('nodes')
yimages = ydoc.getMap('images')
persistence = new IndexeddbPersistence(`op-room-${roomId}`, ydoc)
@ -83,6 +85,19 @@ export function useCollab(store: EditorStore) {
store.requestRender()
})
yimages.observe((event) => {
if (suppressYjsEvents) return
for (const [key, change] of event.changes.keys) {
if (change.action === 'add' || change.action === 'update') {
const data = yimages?.get(key)
if (data) store.graph.images.set(key, new Uint8Array(data))
} else {
store.graph.images.delete(key)
}
}
store.requestRender()
})
room = joinTrysteroRoom(
{
appId: TRYSTERO_APP_ID,
@ -247,6 +262,7 @@ export function useCollab(store: EditorStore) {
ydoc = null
}
ynodes = null
yimages = null
state.value.connected = false
state.value.roomId = null
state.value.peers = []
@ -260,6 +276,7 @@ export function useCollab(store: EditorStore) {
if (!node) return
const localYnodes = ynodes
const localYimages = yimages
suppressYjsEvents = true
ydoc.transact(() => {
let ynode = localYnodes.get(nodeId)
@ -268,6 +285,15 @@ export function useCollab(store: EditorStore) {
localYnodes.set(nodeId, ynode)
}
syncNodePropsToYMap(node, ynode)
if (localYimages) {
for (const fill of node.fills) {
if (fill.imageHash && !localYimages.has(fill.imageHash)) {
const data = store.graph.images.get(fill.imageHash)
if (data) localYimages.set(fill.imageHash, data)
}
}
}
})
suppressYjsEvents = false
}
@ -285,6 +311,7 @@ export function useCollab(store: EditorStore) {
function syncAllNodesToYjs() {
if (!ydoc || !ynodes) return
const localYnodes = ynodes
const localYimages = yimages
suppressYjsEvents = true
ydoc.transact(() => {
for (const node of store.graph.getAllNodes()) {
@ -296,6 +323,15 @@ export function useCollab(store: EditorStore) {
syncNodePropsToYMap(node, ynode)
}
})
if (localYimages) {
ydoc.transact(() => {
for (const [hash, data] of store.graph.images) {
if (!localYimages.has(hash)) {
localYimages.set(hash, data)
}
}
})
}
suppressYjsEvents = false
}

View file

@ -1,6 +1,7 @@
import { useBreakpoints, useEventListener, useMagicKeys, whenever } from '@vueuse/core'
import { computed } from 'vue'
import { extractImageFilesFromClipboard } from '@/composables/use-canvas-drop'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS, useEditorStore } from '@/stores/editor'
import { closeTab, createTab, activeTab as activeTabRef } from '@/stores/tabs'
@ -73,8 +74,20 @@ export function useKeyboard() {
useEventListener(window, 'paste', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
const { cursorCanvasX: ccx, cursorCanvasY: ccy } = store.state
const cursorPos = ccx != null && ccy != null ? { x: ccx, y: ccy } : undefined
const imageFiles = extractImageFilesFromClipboard(e)
if (imageFiles.length) {
const cx = cursorPos?.x ?? (-store.state.panX + window.innerWidth / 2) / store.state.zoom
const cy = cursorPos?.y ?? (-store.state.panY + window.innerHeight / 2) / store.state.zoom
void store.placeImageFiles(imageFiles, cx, cy)
return
}
const html = e.clipboardData?.getData('text/html') ?? ''
if (html) store.pasteFromHTML(html)
if (html) store.pasteFromHTML(html, cursorPos)
})
const keys = useMagicKeys({

View file

@ -1,7 +1,7 @@
import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
export type ToastVariant = 'default' | 'error'
export type ToastVariant = 'default' | 'warning' | 'error'
export interface Toast {
id: number

View file

@ -1,5 +1,6 @@
import { shallowReactive, shallowRef, computed, watch } from 'vue'
import { toast } from '@/composables/use-toast'
import {
IS_TAURI,
DEFAULT_SHAPE_FILL,
@ -19,13 +20,13 @@ import {
computeVectorBounds,
exportFigFile,
importClipboardNodes,
figmaNodesBounds,
parseFigmaClipboard,
parseOpenPencilClipboard,
buildFigmaClipboardHTML,
buildOpenPencilClipboardHTML,
prefetchFigmaSchema,
readFigFile,
computeImageHash,
renderNodesToImage,
renderNodesToSVG,
SceneGraph,
@ -182,6 +183,8 @@ export function createEditorStore() {
} | null,
penCursorX: null as number | null,
penCursorY: null as number | null,
cursorCanvasX: null as number | null,
cursorCanvasY: null as number | null,
remoteCursors: [] as Array<{
name: string
color: Color
@ -1702,6 +1705,107 @@ export function createEditorStore() {
return id
}
const IMAGE_MAX_DIMENSION = 4096
const IMAGE_GAP = 20
async function placeImageFiles(files: File[], cx: number, cy: number) {
if (!_ck) return
const prepared: Array<{ bytes: Uint8Array; name: string; w: number; h: number }> = []
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer())
const dims = decodeImageDimensions(bytes)
if (dims) prepared.push({ bytes, name: file.name, ...dims })
}
if (!prepared.length) return
let totalW = 0
for (const p of prepared) totalW += p.w
totalW += IMAGE_GAP * (prepared.length - 1)
const maxH = Math.max(...prepared.map((p) => p.h))
let curX = cx - totalW / 2
const topY = cy - maxH / 2
const ids: string[] = []
for (const p of prepared) {
const id = await placeImageNode(p.bytes, curX, topY, p.w, p.h, p.name)
if (id) ids.push(id)
curX += p.w + IMAGE_GAP
}
if (ids.length) {
select(ids)
requestRender()
}
}
function decodeImageDimensions(bytes: Uint8Array): { w: number; h: number } | null {
if (!_ck) return null
const skImg = _ck.MakeImageFromEncoded(bytes)
if (!skImg) return null
let w = skImg.width()
let h = skImg.height()
skImg.delete()
if (w > IMAGE_MAX_DIMENSION || h > IMAGE_MAX_DIMENSION) {
const ratio = Math.min(IMAGE_MAX_DIMENSION / w, IMAGE_MAX_DIMENSION / h)
w = Math.round(w * ratio)
h = Math.round(h * ratio)
}
return { w, h }
}
function storeImage(bytes: Uint8Array): string {
const hash = computeImageHash(bytes)
graph.images.set(hash, bytes)
return hash
}
async function placeImageNode(
bytes: Uint8Array,
x: number,
y: number,
w: number,
h: number,
name = 'Image'
): Promise<string | null> {
const hash = storeImage(bytes)
const displayName = name.replace(/\.[^.]+$/, '')
const pid = state.currentPageId
const fill: Fill = {
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FILL',
color: { r: 0, g: 0, b: 0, a: 0 },
opacity: 1,
visible: true
}
const node = graph.createNode('RECTANGLE', pid, {
name: displayName,
x,
y,
width: w,
height: h,
fills: [fill]
})
const id = node.id
const snapshot = { ...node }
undo.push({
label: 'Place image',
forward: () => {
graph.images.set(hash, bytes)
graph.createNode(snapshot.type, pid, snapshot)
},
inverse: () => {
graph.deleteNode(id)
graph.images.delete(hash)
const next = new Set(state.selectedIds)
next.delete(id)
state.selectedIds = next
}
})
return id
}
function adoptNodesIntoSection(sectionId: string) {
const section = graph.getNode(sectionId)
if (section?.type !== 'SECTION') return
@ -1823,6 +1927,28 @@ export function createEditorStore() {
clipboardData.setData('text/plain', names)
}
function centerNodesAt(nodeIds: string[], cx: number, cy: number) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const id of nodeIds) {
const n = graph.getNode(id)
if (!n) continue
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
if (minX === Infinity) return
const dx = cx - (minX + maxX) / 2
const dy = cy - (minY + maxY) / 2
for (const id of nodeIds) {
const n = graph.getNode(id)
if (n) graph.updateNode(id, { x: n.x + dx, y: n.y + dy })
}
}
function collectSubtrees(g: SceneGraph, rootIds: string[]): SceneNode[] {
const result: SceneNode[] = []
function walk(id: string) {
@ -1844,31 +1970,29 @@ export function createEditorStore() {
requestRender()
}
function pasteFromHTML(html: string) {
const ownNodes = parseOpenPencilClipboard(html)
if (ownNodes) {
pasteOpenPencilNodes(ownNodes)
function pasteFromHTML(html: string, cursorPos?: Vector) {
const own = parseOpenPencilClipboard(html)
if (own) {
for (const [hash, data] of own.images) graph.images.set(hash, data)
pasteOpenPencilNodes(own.nodes, undefined, cursorPos)
return
}
void parseFigmaClipboard(html).then((figma) => {
if (figma) {
const bounds = figmaNodesBounds(figma.nodes)
const viewCenterX = (-state.panX + window.innerWidth / 2) / state.zoom
const viewCenterY = (-state.panY + window.innerHeight / 2) / state.zoom
const offsetX = bounds ? viewCenterX - (bounds.x + bounds.w / 2) : 0
const offsetY = bounds ? viewCenterY - (bounds.y + bounds.h / 2) : 0
const prevSelection = new Set(state.selectedIds)
const created = importClipboardNodes(
figma.nodes,
graph,
state.currentPageId,
offsetX,
offsetY,
0,
0,
figma.blobs
)
if (created.length > 0) {
const cx = cursorPos?.x ?? (-state.panX + window.innerWidth / 2) / state.zoom
const cy = cursorPos?.y ?? (-state.panY + window.innerHeight / 2) / state.zoom
centerNodesAt(created, cx, cy)
computeAllLayouts(graph, state.currentPageId)
state.selectedIds = new Set(created)
@ -1893,26 +2017,58 @@ export function createEditorStore() {
}
})
void loadFontsForNodes(created)
warnMissingImages(created)
}
}
})
}
function warnMissingImages(nodeIds: string[]) {
const allNodes = collectSubtrees(graph, nodeIds)
const hasMissing = allNodes.some((n) =>
n.fills.some((f) => f.type === 'IMAGE' && f.imageHash && !graph.images.has(f.imageHash))
)
if (hasMissing) {
toast.show(
"Some images couldn't be pasted — Figma doesn't include image data in clipboard",
'warning'
)
}
}
function pasteOpenPencilNodes(
nodes: Array<SceneNode & { children?: SceneNode[] }>,
parentId?: string
parentId?: string,
cursorPos?: Vector
) {
const target = parentId ?? state.currentPageId
const prevSelection = new Set(state.selectedIds)
const newIds: string[] = []
const created: Array<{ id: string; parentId: string; snapshot: SceneNode }> = []
let offsetX = 20
let offsetY = 20
if (cursorPos && nodes.length > 0) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
offsetX = cursorPos.x - (minX + maxX) / 2
offsetY = cursorPos.y - (minY + maxY) / 2
}
function createTree(src: SceneNode & { children?: SceneNode[] }, pid: string, isTop: boolean) {
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...rest } = src
const node = graph.createNode(src.type, pid, {
...rest,
x: src.x + (isTop ? 20 : 0),
y: src.y + (isTop ? 20 : 0)
x: src.x + (isTop ? offsetX : 0),
y: src.y + (isTop ? offsetY : 0)
})
created.push({ id: node.id, parentId: pid, snapshot: { ...node } })
if (isTop) newIds.push(node.id)
@ -2297,6 +2453,8 @@ export function createEditorStore() {
moveToPage,
renameNode,
createShape,
storeImage,
placeImageFiles,
adoptNodesIntoSection,
duplicateSelected,
writeCopyData,

302
tests/engine/image.test.ts Normal file
View file

@ -0,0 +1,302 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { unzipSync } from 'fflate'
import {
ALL_TOOLS,
buildOpenPencilClipboardHTML,
exportFigFile,
FigmaAPI,
initCodec,
parseOpenPencilClipboard,
parseFigFile,
SceneGraph,
type SceneNode,
} from '@open-pencil/core'
const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const JPEG_MAGIC = new Uint8Array([0xff, 0xd8, 0xff, 0xe0])
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
return { graph, figma }
}
describe('FigmaAPI.createImage', () => {
test('returns deterministic hash for same bytes', () => {
const { figma } = setup()
const a = figma.createImage(PNG_MAGIC)
const b = figma.createImage(PNG_MAGIC)
expect(a.hash).toBe(b.hash)
})
test('different bytes produce different hashes', () => {
const { figma } = setup()
const a = figma.createImage(PNG_MAGIC)
const b = figma.createImage(JPEG_MAGIC)
expect(a.hash).not.toBe(b.hash)
})
test('stores bytes in graph.images', () => {
const { graph, figma } = setup()
const { hash } = figma.createImage(PNG_MAGIC)
expect(graph.images.get(hash)).toEqual(PNG_MAGIC)
})
test('hash is a 40-char hex string', () => {
const { figma } = setup()
const { hash } = figma.createImage(PNG_MAGIC)
expect(hash).toHaveLength(40)
expect(hash).toMatch(/^[0-9a-f]{40}$/)
})
test('empty data produces a valid hash', () => {
const { figma } = setup()
const { hash } = figma.createImage(new Uint8Array([]))
expect(hash).toHaveLength(40)
expect(hash).toMatch(/^[0-9a-f]{40}$/)
})
})
describe('set_image_fill tool', () => {
const tool = ALL_TOOLS.find((t) => t.name === 'set_image_fill')!
test('sets an IMAGE fill with correct imageHash and scaleMode', () => {
const { figma } = setup()
const shape = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const node = shape.execute(figma, { type: 'RECTANGLE', x: 0, y: 0, width: 100, height: 100 }) as { id: string }
const b64 = PNG_MAGIC.toBase64()
const result = tool.execute(figma, { id: node.id, image_data: b64 }) as { id: string; imageHash: string; scaleMode: string }
expect(result.imageHash).toBeTruthy()
expect(result.scaleMode).toBe('FILL')
const fills = figma.getNodeById(node.id)!.fills as Array<{ type: string; imageHash: string; imageScaleMode: string }>
expect(fills).toHaveLength(1)
expect(fills[0].type).toBe('IMAGE')
expect(fills[0].imageHash).toBe(result.imageHash)
expect(fills[0].imageScaleMode).toBe('FILL')
})
test('returns error for non-existent node', () => {
const { figma } = setup()
const result = tool.execute(figma, { id: 'nonexistent', image_data: PNG_MAGIC.toBase64() }) as { error: string }
expect(result.error).toContain('not found')
})
test('default scale mode is FILL', () => {
const { figma } = setup()
const shape = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const node = shape.execute(figma, { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }) as { id: string }
const result = tool.execute(figma, { id: node.id, image_data: PNG_MAGIC.toBase64() }) as { scaleMode: string }
expect(result.scaleMode).toBe('FILL')
})
test('all scale modes work', () => {
const modes = ['FILL', 'FIT', 'CROP', 'TILE'] as const
for (const mode of modes) {
const { figma } = setup()
const shape = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const node = shape.execute(figma, { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }) as { id: string }
const result = tool.execute(figma, { id: node.id, image_data: PNG_MAGIC.toBase64(), scale_mode: mode }) as { scaleMode: string }
expect(result.scaleMode).toBe(mode)
const fills = figma.getNodeById(node.id)!.fills as Array<{ imageScaleMode: string }>
expect(fills[0].imageScaleMode).toBe(mode)
}
})
test('image data is stored in graph.images', () => {
const { graph, figma } = setup()
const shape = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const node = shape.execute(figma, { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }) as { id: string }
const result = tool.execute(figma, { id: node.id, image_data: PNG_MAGIC.toBase64() }) as { imageHash: string }
expect(graph.images.get(result.imageHash)).toEqual(PNG_MAGIC)
})
})
describe('clipboard roundtrip with images', () => {
function graphWithImageNode(): { graph: SceneGraph; node: SceneNode; imageHash: string; imageBytes: Uint8Array } {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const imageBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
const figma = new FigmaAPI(graph)
const { hash } = figma.createImage(imageBytes)
const node = graph.createNode('RECTANGLE', page.id, {
name: 'ImageRect',
width: 100,
height: 100,
fills: [{
type: 'IMAGE',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
imageHash: hash,
imageScaleMode: 'FILL',
}],
})
return { graph, node, imageHash: hash, imageBytes }
}
test('round-trips image bytes through clipboard', () => {
const { graph, node, imageHash, imageBytes } = graphWithImageNode()
const html = buildOpenPencilClipboardHTML([node], graph)
const parsed = parseOpenPencilClipboard(html)
expect(parsed).not.toBeNull()
expect(parsed!.images.size).toBe(1)
expect(parsed!.images.get(imageHash)).toEqual(imageBytes)
})
test('preserves imageHash on the fill', () => {
const { graph, node, imageHash } = graphWithImageNode()
const html = buildOpenPencilClipboardHTML([node], graph)
const parsed = parseOpenPencilClipboard(html)
const fill = parsed!.nodes[0].fills[0]
expect(fill.type).toBe('IMAGE')
expect(fill.imageHash).toBe(imageHash)
})
test('multiple image hashes in different nodes are all included', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const figma = new FigmaAPI(graph)
const bytes1 = new Uint8Array([10, 20, 30])
const bytes2 = new Uint8Array([40, 50, 60])
const { hash: hash1 } = figma.createImage(bytes1)
const { hash: hash2 } = figma.createImage(bytes2)
const node1 = graph.createNode('RECTANGLE', page.id, {
name: 'Img1',
width: 50, height: 50,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash1, imageScaleMode: 'FILL' }],
})
const node2 = graph.createNode('RECTANGLE', page.id, {
name: 'Img2',
width: 50, height: 50,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash2, imageScaleMode: 'FIT' }],
})
const html = buildOpenPencilClipboardHTML([node1, node2], graph)
const parsed = parseOpenPencilClipboard(html)
expect(parsed!.images.size).toBe(2)
expect(parsed!.images.get(hash1)).toEqual(bytes1)
expect(parsed!.images.get(hash2)).toEqual(bytes2)
})
test('nodes without image fills produce empty images map', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('RECTANGLE', page.id, {
name: 'Plain',
width: 50, height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }],
})
const html = buildOpenPencilClipboardHTML([node], graph)
const parsed = parseOpenPencilClipboard(html)
expect(parsed!.images.size).toBe(0)
})
test('child node image hashes are collected', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const figma = new FigmaAPI(graph)
const bytes = new Uint8Array([99, 88, 77])
const { hash } = figma.createImage(bytes)
const frame = graph.createNode('FRAME', page.id, { name: 'Parent', width: 200, height: 200 })
graph.createNode('RECTANGLE', frame.id, {
name: 'ChildImg',
width: 50, height: 50,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash, imageScaleMode: 'TILE' }],
})
const html = buildOpenPencilClipboardHTML([frame], graph)
const parsed = parseOpenPencilClipboard(html)
expect(parsed!.images.size).toBe(1)
expect(parsed!.images.get(hash)).toEqual(bytes)
})
})
describe('fig export/import with images', () => {
beforeAll(async () => {
await initCodec()
})
test('exported zip contains images entries', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const figma = new FigmaAPI(graph)
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03])
const { hash } = figma.createImage(bytes)
graph.createNode('RECTANGLE', page.id, {
name: 'ImageNode',
width: 100, height: 100,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash, imageScaleMode: 'FILL' }],
})
const zip = await exportFigFile(graph)
const entries = unzipSync(zip)
expect(entries[`images/${hash}`]).toBeDefined()
expect(new Uint8Array(entries[`images/${hash}`])).toEqual(bytes)
})
test('graph without images has no images entries', async () => {
const graph = new SceneGraph()
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
name: 'Plain',
width: 50, height: 50,
})
const zip = await exportFigFile(graph)
const entries = unzipSync(zip)
const imageKeys = Object.keys(entries).filter((k) => k.startsWith('images/'))
expect(imageKeys).toHaveLength(0)
})
test('round-trip preserves images', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const figma = new FigmaAPI(graph)
const bytes1 = new Uint8Array([11, 22, 33, 44, 55])
const bytes2 = new Uint8Array([66, 77, 88, 99])
const { hash: hash1 } = figma.createImage(bytes1)
const { hash: hash2 } = figma.createImage(bytes2)
graph.createNode('RECTANGLE', page.id, {
name: 'Img1', width: 100, height: 100,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash1, imageScaleMode: 'FILL' }],
})
graph.createNode('ELLIPSE', page.id, {
name: 'Img2', width: 80, height: 80,
fills: [{ type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash: hash2, imageScaleMode: 'FIT' }],
})
const zip = await exportFigFile(graph)
const restored = await parseFigFile(zip.buffer as ArrayBuffer)
expect(restored.images.size).toBe(2)
expect(new Uint8Array(restored.images.get(hash1)!)).toEqual(bytes1)
expect(new Uint8Array(restored.images.get(hash2)!)).toEqual(bytes2)
})
})