diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b61a6155..df04a8c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Find overlapping layers and overflowing children from the CLI, AI tools, and MCP. - Use Figma-style number-key opacity shortcuts: `1`–`9` set 10%–90%, `0` sets 100%, and two-digit sequences set exact values. - Drag image files directly into the desktop app and paste Figma layers with their remote image fills. +- Drop SVG files onto the canvas to import them as editable vector layers alongside raster images. (#392) - Drag with the Text tool to create a fixed-size text box, or click to create auto-width text. - Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents. - Test OpenAI-compatible provider connections from AI settings with clearer setup errors. diff --git a/packages/core/src/editor/bridges/clipboard.ts b/packages/core/src/editor/bridges/clipboard.ts index 71c24bc0b..8ef5eef94 100644 --- a/packages/core/src/editor/bridges/clipboard.ts +++ b/packages/core/src/editor/bridges/clipboard.ts @@ -12,6 +12,7 @@ export function createClipboardBridge(clipboard: ClipboardActions, selection: Se pasteFromHTML: clipboard.pasteFromHTML, deleteSelected: clipboard.deleteSelected, storeImage: clipboard.storeImage, + placeFiles: clipboard.placeFiles, placeImageFiles: clipboard.placeImageFiles, loadFontsForNodes: clipboard.loadFontsForNodes, copySelectionAsText: clipboard.copySelectionAsText, diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index d10d6a502..73987be6d 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -8,11 +8,11 @@ import { } from '#core/clipboard' import { computeAllLayouts } from '#core/layout' +import { createClipboardAssetActions } from './clipboard/assets' import { createClipboardCopyActions } from './clipboard/copy' import { createClipboardExportActions } from './clipboard/export' import { createClipboardFontActions } from './clipboard/fonts' import { deleteIds, recreateSnapshots, restoreDeletedEntries } from './clipboard/history' -import { createClipboardImageActions } from './clipboard/images' import { replaceTargetsWithCreated, selectedReplacementTargets } from './clipboard/paste-replace' import { resolvePasteTarget } from './clipboard/paste-target' import { createClipboardPlacementActions } from './clipboard/placement' @@ -66,11 +66,11 @@ export function createClipboardActions(ctx: EditorContext) { } } - function pushPasteUndo(created: string[], prevSelection: Set) { + function pushCreatedNodesUndo(created: string[], prevSelection: Set, label = 'Paste') { const allNodes = collectSubtrees(ctx.graph, created) const pageId = ctx.state.currentPageId ctx.undo.push({ - label: 'Paste', + label, forward: () => { recreateSnapshots(ctx, allNodes, pageId) computeAllLayouts(ctx.graph, pageId) @@ -115,7 +115,7 @@ export function createClipboardActions(ctx: EditorContext) { placementActions.centerNodesAt(created, cx, cy) computeAllLayouts(ctx.graph, ctx.state.currentPageId) ctx.setSelectedIds(new Set(created)) - pushPasteUndo(created, prevSelection) + pushCreatedNodesUndo(created, prevSelection) } await Promise.all([ @@ -168,7 +168,7 @@ export function createClipboardActions(ctx: EditorContext) { computeAllLayouts(ctx.graph, ctx.state.currentPageId) ctx.setSelectedIds(new Set(created)) - pushPasteUndo(created, prevSelection) + pushCreatedNodesUndo(created, prevSelection) return created } @@ -261,7 +261,7 @@ export function createClipboardActions(ctx: EditorContext) { const copyActions = createClipboardCopyActions(ctx) const exportActions = createClipboardExportActions(ctx) const fontActions = createClipboardFontActions(ctx) - const imageActions = createClipboardImageActions(ctx) + const assetActions = createClipboardAssetActions(ctx, pushCreatedNodesUndo) const placementActions = createClipboardPlacementActions(ctx) return { @@ -273,7 +273,7 @@ export function createClipboardActions(ctx: EditorContext) { pasteFromHTML, warnMissingImages, deleteSelected, - ...imageActions, + ...assetActions, ...exportActions } } diff --git a/packages/core/src/editor/clipboard/assets.ts b/packages/core/src/editor/clipboard/assets.ts new file mode 100644 index 000000000..8ffb796fa --- /dev/null +++ b/packages/core/src/editor/clipboard/assets.ts @@ -0,0 +1,184 @@ +import type { Fill } from '@open-pencil/scene-graph' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import Matrix from '@open-pencil/scene-graph/matrix' + +import { TRANSPARENT } from '#core/constants' +import { resolvePasteTarget } from '#core/editor/clipboard/paste-target' +import type { EditorContext } from '#core/editor/types' +import { computeImageHash } from '#core/figma-api' +import { + createSVGNodesFromImport, + prepareSVGImport, + type SVGImportData +} from '#core/io/formats/svg' +import { computeAllLayouts } from '#core/layout' + +const IMAGE_MAX_DIMENSION = 4096 +const ASSET_GAP = 20 + +const RASTER_IMAGE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif' +]) + +interface PreparedRasterAsset { + kind: 'raster' + bytes: Uint8Array + name: string + width: number + height: number +} + +interface PreparedSVGAsset { + kind: 'svg' + data: SVGImportData + name: string + width: number + height: number +} + +type PreparedAsset = PreparedRasterAsset | PreparedSVGAsset + +type PushCreatedNodesUndo = ( + created: string[], + previousSelection: Set, + label?: string +) => void + +function isSVGFile(file: Pick): boolean { + return ( + file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg')) + ) +} + +export function createClipboardAssetActions( + ctx: EditorContext, + pushCreatedNodesUndo: PushCreatedNodesUndo +) { + function storeImage(bytes: Uint8Array): string { + const hash = computeImageHash(bytes) + ctx.graph.images.set(hash, bytes) + return hash + } + + function decodeImageDimensions(bytes: Uint8Array): { width: number; height: number } | null { + const ck = ctx.getCk() + if (!ck) return null + const skImg = ck.MakeImageFromEncoded(bytes) + if (!skImg) return null + let width = skImg.width() + let height = skImg.height() + skImg.delete() + if (width > IMAGE_MAX_DIMENSION || height > IMAGE_MAX_DIMENSION) { + const ratio = Math.min(IMAGE_MAX_DIMENSION / width, IMAGE_MAX_DIMENSION / height) + width = Math.round(width * ratio) + height = Math.round(height * ratio) + } + return { width, height } + } + + async function prepareAsset(file: File): Promise { + if (isSVGFile(file)) { + const data = prepareSVGImport(await file.text()) + return data + ? { + kind: 'svg', + data, + name: file.name.replace(/\.svg$/i, '') || 'SVG', + width: data.width, + height: data.height + } + : null + } + if (!RASTER_IMAGE_TYPES.has(file.type)) return null + const bytes = new Uint8Array(await file.arrayBuffer()) + const dimensions = decodeImageDimensions(bytes) + return dimensions ? { kind: 'raster', bytes, name: file.name, ...dimensions } : null + } + + function parentLocalPoint(parentId: string, x: number, y: number) { + const parent = ctx.graph.getNode(parentId) + if (!parent) return { x, y } + const inverse = Matrix.invert(getWorldMatrix(parent, ctx.graph)) + return inverse ? Matrix.mapPoint(inverse, { x, y }) : { x, y } + } + + function createRasterNode( + asset: PreparedRasterAsset, + parentId: string, + x: number, + y: number + ): string { + const hash = storeImage(asset.bytes) + const fill: Fill = { + type: 'IMAGE', + imageHash: hash, + imageScaleMode: 'FILL', + color: TRANSPARENT, + opacity: 1, + visible: true + } + return ctx.graph.createNode('RECTANGLE', parentId, { + name: asset.name.replace(/\.[^.]+$/, ''), + x, + y, + width: asset.width, + height: asset.height, + fills: [fill] + }).id + } + + async function placeFiles(files: File[], cx: number, cy: number) { + const prepared = (await Promise.all(files.map(prepareAsset))).filter( + (asset): asset is PreparedAsset => asset !== null + ) + if (prepared.length === 0) return + + const previousSelection = new Set(ctx.state.selectedIds) + const parentId = resolvePasteTarget(ctx) + const center = parentLocalPoint(parentId, cx, cy) + const totalWidth = + prepared.reduce((total, asset) => total + asset.width, 0) + ASSET_GAP * (prepared.length - 1) + const maxHeight = Math.max(...prepared.map((asset) => asset.height)) + let x = center.x - totalWidth / 2 + const y = center.y - maxHeight / 2 + const created: string[] = [] + + try { + for (const asset of prepared) { + const id = + asset.kind === 'raster' + ? createRasterNode(asset, parentId, x, y) + : createSVGNodesFromImport(ctx.graph, parentId, asset.data, { + name: asset.name, + x, + y + })?.id + if (id) created.push(id) + x += asset.width + ASSET_GAP + } + } catch (error) { + for (const id of created.reverse()) ctx.graph.deleteNode(id) + throw error + } + + if (created.length === 0) return + computeAllLayouts(ctx.graph, ctx.state.currentPageId) + ctx.setSelectedIds(new Set(created)) + pushCreatedNodesUndo(created, previousSelection, 'Place files') + ctx.requestRender() + } + + function placeImageFiles(files: File[], cx: number, cy: number) { + return placeFiles( + files.filter((file) => RASTER_IMAGE_TYPES.has(file.type)), + cx, + cy + ) + } + + return { storeImage, placeFiles, placeImageFiles } +} diff --git a/packages/core/src/editor/clipboard/images.ts b/packages/core/src/editor/clipboard/images.ts deleted file mode 100644 index e00ba3a9e..000000000 --- a/packages/core/src/editor/clipboard/images.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Fill } from '@open-pencil/scene-graph' - -import { TRANSPARENT } from '#core/constants' -import { resolvePasteTarget } from '#core/editor/clipboard/paste-target' -import type { EditorContext } from '#core/editor/types' -import { computeImageHash } from '#core/figma-api' - -const IMAGE_MAX_DIMENSION = 4096 -const IMAGE_GAP = 20 - -export function createClipboardImageActions(ctx: EditorContext) { - function storeImage(bytes: Uint8Array): string { - const hash = computeImageHash(bytes) - ctx.graph.images.set(hash, bytes) - return hash - } - - function decodeImageDimensions(bytes: Uint8Array): { w: number; h: number } | null { - const ck = ctx.getCk() - 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 placeImageNode( - bytes: Uint8Array, - x: number, - y: number, - w: number, - h: number, - name = 'Image' - ): string | null { - const hash = storeImage(bytes) - const displayName = name.replace(/\.[^.]+$/, '') - const pid = resolvePasteTarget(ctx) - const fill: Fill = { - type: 'IMAGE', - imageHash: hash, - imageScaleMode: 'FILL', - color: TRANSPARENT, - opacity: 1, - visible: true - } - const node = ctx.graph.createNode('RECTANGLE', pid, { - name: displayName, - x, - y, - width: w, - height: h, - fills: [fill] - }) - const id = node.id - const snapshot = { ...node } - ctx.undo.push({ - label: 'Place image', - forward: () => { - ctx.graph.images.set(hash, bytes) - ctx.graph.createNode(snapshot.type, pid, snapshot) - }, - inverse: () => { - ctx.graph.deleteNode(id) - ctx.graph.images.delete(hash) - const next = new Set(ctx.state.selectedIds) - next.delete(id) - ctx.setSelectedIds(next) - } - }) - return id - } - - async function placeImageFiles(files: File[], cx: number, cy: number) { - 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 = placeImageNode(p.bytes, curX, topY, p.w, p.h, p.name) - if (id) ids.push(id) - curX += p.w + IMAGE_GAP - } - if (ids.length) { - ctx.setSelectedIds(new Set(ids)) - ctx.requestRender() - } - } - - return { storeImage, placeImageFiles } -} diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 5b9c37c1a..316c0bb27 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -33,21 +33,42 @@ function isElement(node: Node): node is Element { return node.nodeType === node.ELEMENT_NODE } -function inheritedAttribute(element: Element, name: string, inherited: string): string { - return element.hasAttribute(name) ? (element.getAttribute(name) ?? inherited) : inherited +function inlineStyles(element: Element): ReadonlyMap { + const styles = new Map() + for (const declaration of (element.getAttribute('style') ?? '').split(';')) { + const separator = declaration.indexOf(':') + if (separator <= 0) continue + const name = declaration.slice(0, separator).trim() + const value = declaration.slice(separator + 1).trim() + if (name && value) styles.set(name, value) + } + return styles +} + +function inheritedAttribute( + element: Element, + styles: ReadonlyMap, + name: string, + inherited: string +): string { + return ( + styles.get(name) ?? + (element.hasAttribute(name) ? (element.getAttribute(name) ?? inherited) : inherited) + ) } function presentationFor( element: Element, inherited: PresentationAttributes ): PresentationAttributes { + const styles = inlineStyles(element) return { - fill: inheritedAttribute(element, 'fill', inherited.fill), - stroke: inheritedAttribute(element, 'stroke', inherited.stroke), - strokeWidth: inheritedAttribute(element, 'stroke-width', inherited.strokeWidth), - strokeCap: inheritedAttribute(element, 'stroke-linecap', inherited.strokeCap), - strokeJoin: inheritedAttribute(element, 'stroke-linejoin', inherited.strokeJoin), - fillRule: inheritedAttribute(element, 'fill-rule', inherited.fillRule) + fill: inheritedAttribute(element, styles, 'fill', inherited.fill), + stroke: inheritedAttribute(element, styles, 'stroke', inherited.stroke), + strokeWidth: inheritedAttribute(element, styles, 'stroke-width', inherited.strokeWidth), + strokeCap: inheritedAttribute(element, styles, 'stroke-linecap', inherited.strokeCap), + strokeJoin: inheritedAttribute(element, styles, 'stroke-linejoin', inherited.strokeJoin), + fillRule: inheritedAttribute(element, styles, 'fill-rule', inherited.fillRule) } } @@ -133,44 +154,93 @@ function combinedTransform(parent: string | null, element: Element): string | nu return current ?? parent } +function appendShapePath( + tagName: string, + element: Element, + presentation: PresentationAttributes, + transform: string | null, + result: IconPathInfo[] +): void { + if (!SHAPE_NAMES.has(tagName)) return + const pathData = tagName === 'path' ? element.getAttribute('d') : shapeToD(tagName, element) + if (!pathData) return + const strokeWidth = Number.parseFloat(presentation.strokeWidth) + result.push({ + d: pathData, + fill: presentation.fill === 'none' ? null : presentation.fill, + stroke: presentation.stroke === 'none' ? null : presentation.stroke, + strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1, + strokeCap: presentation.strokeCap, + strokeJoin: presentation.strokeJoin, + fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO', + transform + }) +} + +function collectUsePaths( + element: Element, + presentation: PresentationAttributes, + transform: string | null, + result: IconPathInfo[], + elementsById: ReadonlyMap, + useStack: ReadonlySet +): boolean { + const tagName = element.localName || element.tagName + if (tagName !== 'use') return false + const x = num(element, 'x') + const y = num(element, 'y') + const useTransform = + x !== 0 || y !== 0 ? `${transform ?? ''} translate(${x} ${y})`.trim() : transform + const href = element.getAttribute('href') ?? element.getAttribute('xlink:href') + const target = href?.startsWith('#') ? elementsById.get(href.slice(1)) : null + if (target && !useStack.has(target)) { + collectPaths( + target, + presentation, + useTransform, + result, + elementsById, + new Set([...useStack, target]), + true + ) + } + return true +} + function collectPaths( element: Element, inherited: PresentationAttributes, parentTransform: string | null, - result: IconPathInfo[] + result: IconPathInfo[], + elementsById: ReadonlyMap, + useStack: ReadonlySet = new Set(), + referenced = false ): void { const tagName = element.localName || element.tagName - if (NON_RENDERED_CONTAINERS.has(tagName)) return + if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return const presentation = presentationFor(element, inherited) const transform = combinedTransform(parentTransform, element) - if (SHAPE_NAMES.has(tagName)) { - const pathData = tagName === 'path' ? element.getAttribute('d') : shapeToD(tagName, element) - if (pathData) { - const strokeWidth = Number.parseFloat(presentation.strokeWidth) - result.push({ - d: pathData, - fill: presentation.fill === 'none' ? null : presentation.fill, - stroke: presentation.stroke === 'none' ? null : presentation.stroke, - strokeWidth: Number.isFinite(strokeWidth) ? strokeWidth : 1, - strokeCap: presentation.strokeCap, - strokeJoin: presentation.strokeJoin, - fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO', - transform - }) - } - } + if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return + appendShapePath(tagName, element, presentation, transform, result) for (const child of Array.from(element.childNodes)) { - if (isElement(child)) collectPaths(child, presentation, transform, result) + if (isElement(child)) { + collectPaths(child, presentation, transform, result, elementsById, useStack, referenced) + } } } export function extractPaths(svgBody: string): IconPathInfo[] { const root = parseSVGFragment(svgBody)?.documentElement if (!root) return [] + const elementsById = new Map() + for (const element of Array.from(root.getElementsByTagName('*'))) { + const id = element.getAttribute('id') + if (id) elementsById.set(id, element) + } const result: IconPathInfo[] = [] - collectPaths(root, DEFAULT_PRESENTATION, null, result) + collectPaths(root, DEFAULT_PRESENTATION, null, result, elementsById) return result } diff --git a/packages/core/src/io/formats/svg/import.ts b/packages/core/src/io/formats/svg/import.ts new file mode 100644 index 000000000..619ab6b55 --- /dev/null +++ b/packages/core/src/io/formats/svg/import.ts @@ -0,0 +1,75 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import type { Size } from '@open-pencil/scene-graph/primitives' + +import { createVectorFrameChildren } from '#core/vector/vectorize/placement' +import { svgToVectorPaths, type SVGVectorizeResult } from '#core/vector/vectorize/svg/to-vectors' + +import { parseSVGSize } from './metadata' + +export type SVGImportData = SVGVectorizeResult & Size + +export interface SVGImportOptions { + name?: string + defaultColor?: string + x?: number + y?: number +} + +export function prepareSVGImport( + source: string, + options: Pick = {} +): SVGImportData | null { + const { width, height } = parseSVGSize(source) + const vectorized = svgToVectorPaths( + source, + { width, height }, + { + defaultColor: options.defaultColor, + preserveAspectRatio: true + } + ) + return vectorized ? { width, height, ...vectorized } : null +} + +export function createSVGNodesFromImport( + graph: SceneGraph, + parentId: string, + data: SVGImportData, + options: SVGImportOptions = {} +): SceneNode | null { + const frame = graph.createNode('FRAME', parentId, { + name: options.name ?? 'SVG', + x: options.x ?? 0, + y: options.y ?? 0, + width: data.width, + height: data.height, + fills: [] + }) + + try { + createVectorFrameChildren(graph, frame.id, data, { + x: frame.x, + y: frame.y, + width: frame.width, + height: frame.height, + offsetX: 0, + offsetY: 0 + }) + if (graph.getChildren(frame.id).length > 0) return frame + graph.deleteNode(frame.id) + return null + } catch (error) { + graph.deleteNode(frame.id) + throw error + } +} + +export function createSVGNodes( + graph: SceneGraph, + parentId: string, + source: string, + options: SVGImportOptions = {} +): SceneNode | null { + const data = prepareSVGImport(source, options) + return data ? createSVGNodesFromImport(graph, parentId, data, options) : null +} diff --git a/packages/core/src/io/formats/svg/index.ts b/packages/core/src/io/formats/svg/index.ts index 4ffd93bdd..2af266144 100644 --- a/packages/core/src/io/formats/svg/index.ts +++ b/packages/core/src/io/formats/svg/index.ts @@ -1 +1,8 @@ export { renderNodesToSVG, geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './export' +export { + createSVGNodes, + createSVGNodesFromImport, + prepareSVGImport, + type SVGImportData, + type SVGImportOptions +} from './import' diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index e74dc8a6d..38e15bdc0 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -23,7 +23,16 @@ export { type RasterExportFormat, type ExportFormat } from './formats/raster' -export { renderNodesToSVG, geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './formats/svg' +export { + createSVGNodes, + createSVGNodesFromImport, + prepareSVGImport, + renderNodesToSVG, + geometryBlobToSVGPath, + vectorNetworkToSVGPaths, + type SVGImportData, + type SVGImportOptions +} from './formats/svg' export { renderNodesToPPTX, type PPTXExportOptions, diff --git a/packages/core/src/tools/create/svg.ts b/packages/core/src/tools/create/svg.ts index 7a2faa7a9..b739a2718 100644 --- a/packages/core/src/tools/create/svg.ts +++ b/packages/core/src/tools/create/svg.ts @@ -1,61 +1,11 @@ -import { parseSVGPath } from '@open-pencil/scene-graph/parse-path' - -import { parseColor } from '#core/color' -import { createPathStroke } from '#core/icons/path-style' -import { extractPaths } from '#core/icons/svg' -import type { IconPathInfo } from '#core/icons/types' -import { parseSVGSize } from '#core/io/formats/svg/metadata' +import { createSVGNodes } from '#core/io/formats/svg' import { defineTool } from '#core/tools/schema' -function createVectorFromPath( - figma: Parameters[0]['execute']>[0], - path: IconPathInfo, - width: number, - height: number, - parentId: string, - defaultColor: string -) { - const vectorNetwork = parseSVGPath(path.d, path.fillRule) - const vector = figma.graph.createNode('VECTOR', parentId, { - name: 'path', - width, - height, - vectorNetwork - }) - vector.x = 0 - vector.y = 0 - - if (path.fill && path.fill !== 'none') { - const fillColor = - path.fill === 'currentColor' ? parseColor(defaultColor) : parseColor(path.fill) - figma.graph.updateNode(vector.id, { - fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }] - }) - } else if (path.fill === null && !path.stroke) { - const fillColor = parseColor(defaultColor) - figma.graph.updateNode(vector.id, { - fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }] - }) - } else { - figma.graph.updateNode(vector.id, { fills: [] }) - } - - if (path.stroke && path.stroke !== 'none') { - const strokeColor = - path.stroke === 'currentColor' ? parseColor(defaultColor) : parseColor(path.stroke) - figma.graph.updateNode(vector.id, { - strokes: [createPathStroke(strokeColor, path.strokeWidth, path.strokeCap, path.strokeJoin)] - }) - } - - return vector -} - export const importSvg = defineTool({ name: 'import_svg', mutates: true, description: - 'Import raw SVG markup onto the canvas. Parses , , , , , , elements and creates vector nodes. Supports fill, stroke, stroke-width, viewBox sizing.', + 'Import raw SVG markup onto the canvas as editable vector nodes. Supports common SVG shapes, inherited presentation attributes, transforms, gradients, and internal references.', params: { svg: { type: 'string', @@ -72,30 +22,15 @@ export const importSvg = defineTool({ y: { type: 'number', description: 'Y position' } }, execute: async (figma, args) => { - const svg = args.svg - if (!svg || typeof svg !== 'string') return { error: 'svg parameter is required' } + if (!args.svg || typeof args.svg !== 'string') return { error: 'svg parameter is required' } - const paths = extractPaths(svg) - if (paths.length === 0) return { error: 'No supported SVG elements found in the markup' } - - const { width, height } = parseSVGSize(svg) - const defaultColor = args.color ?? '#000000' - const parentId = args.parent_id ?? figma.currentPage.id - - const frame = figma.graph.createNode('FRAME', parentId, { - name: args.name ?? 'SVG', - width, - height, - fills: [] + const frame = createSVGNodes(figma.graph, args.parent_id ?? figma.currentPage.id, args.svg, { + name: args.name, + defaultColor: args.color, + x: args.x, + y: args.y }) - - if (args.x !== undefined) frame.x = args.x - if (args.y !== undefined) frame.y = args.y - - for (const path of paths) { - createVectorFromPath(figma, path, width, height, frame.id, defaultColor) - } - + if (!frame) return { error: 'No supported SVG elements found in the markup' } return { id: frame.id, name: frame.name, type: frame.type } } }) diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 145584f10..651bf05d9 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -68,8 +68,8 @@ function normalizeVectorToNodeBounds(network: VectorNetwork): { network: VectorNetwork bounds: Rect } | null { + if (network.vertices.length === 0) return null const bounds = computeAccurateBounds(network) - if (bounds.width <= 0 || bounds.height <= 0) return null return { bounds, diff --git a/packages/core/src/vector/vectorize/svg/gradients.ts b/packages/core/src/vector/vectorize/svg/gradients.ts index 0519b0818..b7bf36e88 100644 --- a/packages/core/src/vector/vectorize/svg/gradients.ts +++ b/packages/core/src/vector/vectorize/svg/gradients.ts @@ -9,14 +9,14 @@ * normalize into each node's bounding box (objectBoundingBox) space, matching the * gradientTransform convention used by the SVG exporter (see io/formats/svg/defs). */ -import svgpath from 'svgpath' - import type { Fill, GradientStop } from '@open-pencil/scene-graph' -import type { Color, Matrix, Rect, Size, Vector } from '@open-pencil/scene-graph/primitives' +import type { Color, Matrix, Rect, Vector } from '@open-pencil/scene-graph/primitives' import { parseColor } from '#core/color' import { parseSVGDocument } from '#core/io/formats/svg/document' +import { mapSVGPointToViewport, type SVGViewportMapping } from './transform' + interface RawStop { offset: number color: Color @@ -119,33 +119,6 @@ function gradientIdFromFill(fill: string | null): string | null { return id && !id.includes(' ') ? id : null } -/** - * Map a userSpaceOnUse point through the same element-transform + viewBox→bounds - * pipeline applied to the path data, yielding bounds-pixel-space coordinates. - */ -function mapUserPoint( - x: number, - y: number, - elementTransform: string | null, - gradientTransform: string | null, - space: Rect, - bounds: Size -): Vector { - const sx = bounds.width / space.width - const sy = bounds.height / space.height - let sp = svgpath(`M${x} ${y}`) - if (gradientTransform) sp = sp.transform(gradientTransform) - if (elementTransform) sp = sp.transform(elementTransform) - sp = sp.translate(-space.x, -space.y).scale(sx, sy) - const points: Vector[] = [] - sp.abs().iterate((segment) => { - if (points.length === 0 && segment[0] === 'M') { - points.push({ x: segment[1], y: segment[2] }) - } - }) - return points[0] ?? { x, y } -} - function gradientStops(stops: RawStop[]): GradientStop[] { return stops.map((s) => ({ color: s.color, position: s.offset })) } @@ -159,8 +132,7 @@ export function resolveGradientFill( fillRef: string | null, gradients: Map, elementTransform: string | null, - space: Rect, - bounds: Size, + viewport: SVGViewportMapping, nodeBounds: Rect ): Fill | null { const id = gradientIdFromFill(fillRef) @@ -173,7 +145,7 @@ export function resolveGradientFill( const mapped = grad.units === 'objectBoundingBox' ? { x: nodeBounds.x + px * nodeBounds.width, y: nodeBounds.y + py * nodeBounds.height } - : mapUserPoint(px, py, elementTransform, grad.transform, space, bounds) + : mapSVGPointToViewport(px, py, elementTransform, grad.transform, viewport) return { x: (mapped.x - nodeBounds.x) / nodeBounds.width, y: (mapped.y - nodeBounds.y) / nodeBounds.height diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index ca171d202..ae8a18969 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -5,8 +5,6 @@ * reflect the input pixel size. Scale path data from the SVG coordinate space * (viewBox, else width/height) into the target node bounds before parsing. */ -import svgpath from 'svgpath' - import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph' import { computeBounds } from '@open-pencil/scene-graph/geometry' import { parseSVGPath } from '@open-pencil/scene-graph/parse-path' @@ -20,14 +18,11 @@ import { parseSVGSize, parseSVGViewBox } from '#core/io/formats/svg/metadata' import { computeAccurateBounds } from '#core/vector/curve-math' import { parseSVGGradients, resolveGradientFill } from './gradients' -import { applySVGTransformToPath } from './transform' - -/** Map path data from SVG user space (viewBox) into target pixel bounds. */ -function mapPathDataToBounds(d: string, space: Rect, target: Size): string { - const sx = target.width / space.width - const sy = target.height / space.height - return svgpath(d).translate(-space.x, -space.y).scale(sx, sy).toString() -} +import { + applySVGTransformToPath, + mapSVGPathToViewport, + resolveSVGViewportMapping +} from './transform' function parseSVGCoordinateSpace(svg: string): Rect { const viewBox = parseSVGViewBox(svg) @@ -75,7 +70,7 @@ export interface SVGVectorizeResult { export function svgToVectorPaths( svgText: string, bounds: Size, - options?: { defaultColor?: string } + options?: { defaultColor?: string; preserveAspectRatio?: boolean } ): SVGVectorizeResult | null { const paths = extractPaths(svgText) if (paths.length === 0) return null @@ -85,16 +80,20 @@ export function svgToVectorPaths( const defaultColor = options?.defaultColor ?? '#000000' const gradients = parseSVGGradients(svgText) - // Path coordinates are scaled from SVG space into the target bounds; scale stroke - // width by the same (uniform) factor so thickness matches the transformed geometry. - const strokeScale = Math.min(bounds.width / space.width, bounds.height / space.height) + const viewport = resolveSVGViewportMapping( + svgText, + space, + bounds, + options?.preserveAspectRatio ?? false + ) + const strokeScale = Math.min(viewport.scaleX, viewport.scaleY) const vectorized: VectorizedPath[] = [] for (const path of paths) { const fillRule: WindingRule = path.fillRule const transform = path.transform ?? null const pathData = applySVGTransformToPath(path.d, transform) - const scaledD = mapPathDataToBounds(pathData, space, bounds) + const scaledD = mapSVGPathToViewport(pathData, viewport) const network = parseSVGPath(scaledD, fillRule) const gradientFill = gradients.size > 0 @@ -102,8 +101,7 @@ export function svgToVectorPaths( path.fill, gradients, transform, - space, - bounds, + viewport, computeAccurateBounds(network) ) : null diff --git a/packages/core/src/vector/vectorize/svg/transform.ts b/packages/core/src/vector/vectorize/svg/transform.ts index 8a4dbb0f7..c7c25e13a 100644 --- a/packages/core/src/vector/vectorize/svg/transform.ts +++ b/packages/core/src/vector/vectorize/svg/transform.ts @@ -1,17 +1,92 @@ import svgpath from 'svgpath' -/** - * Apply an SVG `transform` attribute to path data. Delegates the full transform - * grammar (translate/scale/rotate/skewX/skewY/matrix and lists) to svgpath rather - * than parsing a subset ourselves, so non-translate/matrix transforms import - * correctly. Returns the original path if the transform string can't be applied. - */ +import type { Rect, Size, Vector } from '@open-pencil/scene-graph/primitives' + +import { parseSVGDocument } from '#core/io/formats/svg/document' + +export interface SVGViewportMapping { + space: Rect + scaleX: number + scaleY: number + offsetX: number + offsetY: number +} + +function alignmentOffset(align: string, remaining: number, axis: 'x' | 'y'): number { + const token = axis === 'x' ? align.slice(0, 4) : align.slice(4) + if (token.endsWith('Mid')) return remaining / 2 + if (token.endsWith('Max')) return remaining + return 0 +} + +export function resolveSVGViewportMapping( + svg: string, + space: Rect, + bounds: Size, + preserveAspectRatio: boolean +): SVGViewportMapping { + const scaleX = bounds.width / space.width + const scaleY = bounds.height / space.height + if (!preserveAspectRatio) return { space, scaleX, scaleY, offsetX: 0, offsetY: 0 } + + const value = + parseSVGDocument(svg)?.documentElement?.getAttribute('preserveAspectRatio')?.trim() ?? '' + const tokens = value.split(/\s+/).filter(Boolean) + if (tokens.includes('none')) return { space, scaleX, scaleY, offsetX: 0, offsetY: 0 } + const align = tokens.find((token) => token.startsWith('x')) ?? 'xMidYMid' + + const uniformScale = tokens.includes('slice') + ? Math.max(scaleX, scaleY) + : Math.min(scaleX, scaleY) + const remainingX = bounds.width - space.width * uniformScale + const remainingY = bounds.height - space.height * uniformScale + return { + space, + scaleX: uniformScale, + scaleY: uniformScale, + offsetX: alignmentOffset(align, remainingX, 'x'), + offsetY: alignmentOffset(align, remainingY, 'y') + } +} + +export function mapSVGPathToViewport(d: string, mapping: SVGViewportMapping): string { + return svgpath(d) + .translate(-mapping.space.x, -mapping.space.y) + .scale(mapping.scaleX, mapping.scaleY) + .translate(mapping.offsetX, mapping.offsetY) + .toString() +} + +export function mapSVGPointToViewport( + x: number, + y: number, + elementTransform: string | null, + gradientTransform: string | null, + mapping: SVGViewportMapping +): Vector { + let path = svgpath(`M${x} ${y}`) + if (gradientTransform) path = path.transform(gradientTransform) + if (elementTransform) path = path.transform(elementTransform) + path = path + .translate(-mapping.space.x, -mapping.space.y) + .scale(mapping.scaleX, mapping.scaleY) + .translate(mapping.offsetX, mapping.offsetY) + const points: Vector[] = [] + path.abs().iterate((segment) => { + if (points.length === 0 && segment[0] === 'M') { + points.push({ x: segment[1], y: segment[2] }) + } + }) + return points[0] ?? { x, y } +} + +/** Apply the complete SVG transform grammar through svgpath. */ export function applySVGTransformToPath(d: string, transform: string | null): string { if (!transform || transform === 'none') return d try { return svgpath(d).transform(transform).toString() - } catch (err) { - console.warn('Ignoring unsupported SVG transform:', transform, err) + } catch (error) { + console.warn('Ignoring unsupported SVG transform:', transform, error) return d } } diff --git a/packages/vue/src/canvas/drop/use.ts b/packages/vue/src/canvas/drop/use.ts index ec7500fe1..0f2e3a7f9 100644 --- a/packages/vue/src/canvas/drop/use.ts +++ b/packages/vue/src/canvas/drop/use.ts @@ -5,7 +5,13 @@ import type { Editor } from '@open-pencil/core/editor' import { findMoveDropTarget } from '#vue/shared/input/drop-target' -const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']) +const RASTER_IMAGE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'image/avif' +]) const COMPONENT_MIME = 'application/x-openpencil-component' function hasComponentData(e: DragEvent): boolean { @@ -38,14 +44,14 @@ export function useCanvasDrop(canvasRef: Ref, editor: const isDraggingOver = ref(false) useEventListener(canvasRef, 'dragover', (e: DragEvent) => { - if (!hasComponentData(e) && !hasImageFiles(e)) return + if (!hasComponentData(e) && !hasFileData(e)) return e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' isDraggingOver.value = true }) useEventListener(canvasRef, 'dragenter', (e: DragEvent) => { - if (!hasComponentData(e) && !hasImageFiles(e)) return + if (!hasComponentData(e) && !hasFileData(e)) return e.preventDefault() isDraggingOver.value = true }) @@ -71,31 +77,32 @@ export function useCanvasDrop(canvasRef: Ref, editor: return } - const files = filterImageFiles(e.dataTransfer?.files ?? null) + const files = filterCanvasFiles(e.dataTransfer?.files ?? null) if (!files.length) return - void editor.placeImageFiles(files, point.x, point.y) + void editor.placeFiles(files, point.x, point.y).catch((error: unknown) => { + console.error('Failed to place dropped files', error) + }) }) 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 hasFileData(e: DragEvent): boolean { + return e.dataTransfer?.types.includes('Files') ?? false } -function filterImageFiles(files: FileList | null): File[] { +function isSVGFile(file: File): boolean { + return ( + file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg')) + ) +} + +export function filterCanvasFiles(files: ArrayLike | Iterable | null): File[] { if (!files) return [] - const result: File[] = [] - for (const file of files) { - if (ACCEPTED_TYPES.has(file.type)) result.push(file) - } - return result + return Array.from(files).filter((file) => RASTER_IMAGE_TYPES.has(file.type) || isSVGFile(file)) } export function extractImageFilesFromClipboard(e: ClipboardEvent): File[] { - return filterImageFiles(e.clipboardData?.files ?? null) + const files = e.clipboardData?.files + return files ? Array.from(files).filter((file) => RASTER_IMAGE_TYPES.has(file.type)) : [] } diff --git a/tests/e2e/canvas/drop-files.spec.ts b/tests/e2e/canvas/drop-files.spec.ts new file mode 100644 index 000000000..2879fe978 --- /dev/null +++ b/tests/e2e/canvas/drop-files.spec.ts @@ -0,0 +1,96 @@ +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' + +const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') + +async function dispatchMixedFileDrop() { + return editor.page.getByTestId('canvas-element').evaluate(async (canvas) => { + const bounds = canvas.getBoundingClientRect() + const svg = new File( + [ + '' + ], + 'mark.svg' + ) + const rasterCanvas = document.createElement('canvas') + rasterCanvas.width = 20 + rasterCanvas.height = 10 + const context = rasterCanvas.getContext('2d') + if (!context) throw new Error('Canvas context unavailable') + context.fillStyle = '#00f' + context.fillRect(0, 0, 20, 10) + const blob = await new Promise((resolve, reject) => { + rasterCanvas.toBlob((value) => { + if (value) resolve(value) + else reject(new Error('PNG failed')) + }) + }) + const image = new File([blob], 'photo.png', { type: 'image/png' }) + const transfer = new DataTransfer() + transfer.items.add(svg) + transfer.items.add(image) + const eventOptions = { + bubbles: true, + cancelable: true, + clientX: bounds.left + 300, + clientY: bounds.top + 200, + dataTransfer: transfer + } + const dragoverAccepted = !canvas.dispatchEvent(new DragEvent('dragover', eventOptions)) + canvas.dispatchEvent(new DragEvent('drop', eventOptions)) + return dragoverAccepted + }) +} + +test('mixed SVG and raster drops share placement, selection, and undo', async () => { + expect(await dispatchMixedFileDrop()).toBe(true) + + await expect + .poll(() => + editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + return store?.graph.getChildren(store.state.currentPageId).length ?? 0 + }) + ) + .toBe(2) + + const placed = await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return { + nodes: store.graph + .getChildren(store.state.currentPageId) + .map((node) => ({ name: node.name, type: node.type })), + selected: store.state.selectedIds.size, + undoLabel: store.undo.undoLabel + } + }) + expect(placed).toEqual({ + nodes: [ + { name: 'mark', type: 'FRAME' }, + { name: 'photo', type: 'RECTANGLE' } + ], + selected: 2, + undoLabel: 'Place files' + }) + + await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction()) + await expect + .poll(() => + editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + return store?.graph.getChildren(store.state.currentPageId).length ?? 0 + }) + ) + .toBe(0) + + await editor.page.evaluate(() => window.openPencil?.getStore?.().redoAction()) + await expect + .poll(() => + editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + return store?.graph.getChildren(store.state.currentPageId).length ?? 0 + }) + ) + .toBe(2) + editor.canvas.assertNoErrors() +}) diff --git a/tests/engine/editor/clipboard/file-placement.test.ts b/tests/engine/editor/clipboard/file-placement.test.ts new file mode 100644 index 000000000..5d34f441f --- /dev/null +++ b/tests/engine/editor/clipboard/file-placement.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' + +import { createEditor } from '@open-pencil/core/editor' + +function svgFile(name = 'mark.svg', width = 40, height = 20) { + return new File( + [ + `` + ], + name, + { type: 'image/svg+xml' } + ) +} + +describe('dropped file placement', () => { + test('places files in order with one selection and undo entry', async () => { + const editor = createEditor() + + await editor.placeFiles([svgFile(), svgFile('symbol.svg', 20, 10)], 200, 100) + + const roots = editor.graph.getChildren(editor.state.currentPageId) + expect(roots.map((node) => node.name)).toEqual(['mark', 'symbol']) + expect(roots.map((node) => node.x)).toEqual([160, 220]) + expect([...editor.state.selectedIds]).toEqual(roots.map((node) => node.id)) + expect(editor.undo.undoLabel).toBe('Place files') + + editor.undoAction() + expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(0) + expect(editor.state.selectedIds.size).toBe(0) + + editor.redoAction() + expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(2) + expect(editor.state.selectedIds.size).toBe(2) + }) + + test('reads every file before mutating the graph', async () => { + const editor = createEditor() + const unreadable = { + name: 'broken.svg', + type: 'image/svg+xml', + text: () => Promise.reject(new Error('read failed')) + } as File + + await expect(editor.placeFiles([svgFile(), unreadable], 200, 100)).rejects.toThrow( + 'read failed' + ) + expect(editor.graph.getChildren(editor.state.currentPageId)).toHaveLength(0) + expect(editor.undo.canUndo).toBe(false) + }) + + test('accepts an SVG filename when the drag source omits its MIME type', async () => { + const editor = createEditor() + const file = new File( + [''], + 'fallback.SVG' + ) + + await editor.placeFiles([file], 50, 50) + + const frame = editor.graph.getChildren(editor.state.currentPageId)[0] + expect(frame.name).toBe('fallback') + expect(frame.type).toBe('FRAME') + }) +}) diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 434348378..6cdef9543 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -148,4 +148,59 @@ describe('import_svg', () => { const children = graph.getChildren(result.id) expect(children.length).toBe(2) }) + + test('applies nested transforms through the XML tree', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + const path = getNodeOrThrow(graph, graph.getChildren(result.id)[0].id) + expect(path.x).toBeCloseTo(40) + expect(path.y).toBeCloseTo(30) + expect(path.width).toBeCloseTo(10) + expect(path.height).toBeCloseTo(20) + }) + + test('honors preserveAspectRatio when mapping the viewBox', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + const path = graph.getChildren(result.id)[0] + expect(path.x).toBeCloseTo(0) + expect(path.y).toBeCloseTo(50) + expect(path.width).toBeCloseTo(200) + expect(path.height).toBeCloseTo(100) + }) + + test('supports preserveAspectRatio none', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + const path = graph.getChildren(result.id)[0] + expect(path.x).toBeCloseTo(0) + expect(path.y).toBeCloseTo(0) + expect(path.width).toBeCloseTo(200) + expect(path.height).toBeCloseTo(200) + }) + + test('resolves internal use references and inline presentation styles', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + const path = graph.getChildren(result.id)[0] + expect(path.x).toBeCloseTo(20) + expect(path.y).toBeCloseTo(30) + expect(path.fills[0].color.b).toBeCloseTo(1) + }) + + test('imports gradient fills through the shared SVG pipeline', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + expect(graph.getChildren(result.id)[0].fills[0].type).toBe('GRADIENT_LINEAR') + }) }) diff --git a/tests/engine/vue/input/drop-files.test.ts b/tests/engine/vue/input/drop-files.test.ts new file mode 100644 index 000000000..541c60037 --- /dev/null +++ b/tests/engine/vue/input/drop-files.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' + +import { filterCanvasFiles } from '#vue/canvas/drop/use' + +describe('canvas file drops', () => { + test('keeps supported raster and SVG files in source order', () => { + const svg = new File([''], 'mark.svg', { type: 'image/svg+xml' }) + const image = new File(['png'], 'photo.png', { type: 'image/png' }) + const text = new File(['text'], 'notes.txt', { type: 'text/plain' }) + + expect(filterCanvasFiles([svg, text, image])).toEqual([svg, image]) + }) + + test('recognizes SVG filenames without MIME metadata', () => { + const svg = new File([''], 'mark.SVG') + expect(filterCanvasFiles([svg])).toEqual([svg]) + }) +})