diff --git a/components.d.ts b/components.d.ts index 359775345..9a0a68f51 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 3e2984c4b..19abbd8b5 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -132,12 +132,19 @@ async fn load_system_font(family: String, style: String) -> Result, Stri .map_err(|e| format!("Font load task failed: {e}"))? } +#[derive(serde::Deserialize)] +struct ImageEntry { + name: String, + data: Vec, +} + #[tauri::command] fn build_fig_file( schema_deflated: Vec, kiwi_data: Vec, thumbnail_png: Vec, meta_json: String, + images: Option>, ) -> Result, 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()) } diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 73c57ee81..34a6de196 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -342,9 +342,14 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): // --- Internal copy/paste (OpenPencil ↔ OpenPencil) --- +export interface OpenPencilClipboardData { + nodes: Array + images: Map +} + export function parseOpenPencilClipboard( html: string -): Array | null { +): OpenPencilClipboardData | null { const match = html.match(//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() + 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>): void { export type TextPictureBuilder = (node: SceneNode) => Uint8Array | null +function collectImageHashes(nodes: SceneNode[], graph: SceneGraph): Set { + const hashes = new Set() + 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 = {} + 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 `` } diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index db3eab9c0..d9e8d4c60 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -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) } diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index c12914f28..976ace15b 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa6138d08..1c5e1942e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/renderer/fills.ts b/packages/core/src/renderer/fills.ts index 1bbbfb4d7..d91975448 100644 --- a/packages/core/src/renderer/fills.ts +++ b/packages/core/src/renderer/fills.ts @@ -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 { diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index ac0f903ba..748daac65 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -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 { diff --git a/packages/core/src/renderer/scene.ts b/packages/core/src/renderer/scene.ts index 4d1393e80..c1300c6cc 100644 --- a/packages/core/src/renderer/scene.ts +++ b/packages/core/src/renderer/scene.ts @@ -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) diff --git a/packages/core/src/tools/modify.ts b/packages/core/src/tools/modify.ts index 06b0f7c91..af054b20e 100644 --- a/packages/core/src/tools/modify.ts +++ b/packages/core/src/tools/modify.ts @@ -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 } + } +}) diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 6623ae401..d690553d9 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -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, diff --git a/src/components/AppToast.vue b/src/components/AppToast.vue index 099ce040f..2501b768b 100644 --- a/src/components/AppToast.vue +++ b/src/components/AppToast.vue @@ -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 }) @@ -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 }) " > - + {{ t.message }}