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:
parent
be845f5eeb
commit
37da83aec3
1
components.d.ts
vendored
1
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)-->`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
20
src/components/ui/toast.ts
Normal file
20
src/components/ui/toast.ts
Normal 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)
|
||||
}
|
||||
68
src/composables/use-canvas-drop.ts
Normal file
68
src/composables/use-canvas-drop.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
302
tests/engine/image.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue