fix(canvas): import dropped SVG files
- Parse dropped SVGs through the shared XML-backed import pipeline - Preserve transforms, gradients, viewport alignment, and reusable shapes - Place mixed image drops in one ordered undoable transaction - Cover file filtering, atomic placement, undo, and browser drops Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
parent
cb2c4aa3d3
commit
7e8feb998a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string>) {
|
||||
function pushCreatedNodesUndo(created: string[], prevSelection: Set<string>, 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
184
packages/core/src/editor/clipboard/assets.ts
Normal file
184
packages/core/src/editor/clipboard/assets.ts
Normal file
|
|
@ -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<string>,
|
||||
label?: string
|
||||
) => void
|
||||
|
||||
function isSVGFile(file: Pick<File, 'name' | 'type'>): 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<PreparedAsset | null> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<string, string> {
|
||||
const styles = new Map<string, string>()
|
||||
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<string, string>,
|
||||
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,20 +154,16 @@ function combinedTransform(parent: string | null, element: Element): string | nu
|
|||
return current ?? parent
|
||||
}
|
||||
|
||||
function collectPaths(
|
||||
function appendShapePath(
|
||||
tagName: string,
|
||||
element: Element,
|
||||
inherited: PresentationAttributes,
|
||||
parentTransform: string | null,
|
||||
presentation: PresentationAttributes,
|
||||
transform: string | null,
|
||||
result: IconPathInfo[]
|
||||
): void {
|
||||
const tagName = element.localName || element.tagName
|
||||
if (NON_RENDERED_CONTAINERS.has(tagName)) return
|
||||
|
||||
const presentation = presentationFor(element, inherited)
|
||||
const transform = combinedTransform(parentTransform, element)
|
||||
if (SHAPE_NAMES.has(tagName)) {
|
||||
if (!SHAPE_NAMES.has(tagName)) return
|
||||
const pathData = tagName === 'path' ? element.getAttribute('d') : shapeToD(tagName, element)
|
||||
if (pathData) {
|
||||
if (!pathData) return
|
||||
const strokeWidth = Number.parseFloat(presentation.strokeWidth)
|
||||
result.push({
|
||||
d: pathData,
|
||||
|
|
@ -158,19 +175,72 @@ function collectPaths(
|
|||
fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO',
|
||||
transform
|
||||
})
|
||||
}
|
||||
|
||||
function collectUsePaths(
|
||||
element: Element,
|
||||
presentation: PresentationAttributes,
|
||||
transform: string | null,
|
||||
result: IconPathInfo[],
|
||||
elementsById: ReadonlyMap<string, Element>,
|
||||
useStack: ReadonlySet<Element>
|
||||
): 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[],
|
||||
elementsById: ReadonlyMap<string, Element>,
|
||||
useStack: ReadonlySet<Element> = new Set(),
|
||||
referenced = false
|
||||
): void {
|
||||
const tagName = element.localName || element.tagName
|
||||
if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return
|
||||
|
||||
const presentation = presentationFor(element, inherited)
|
||||
const transform = combinedTransform(parentTransform, element)
|
||||
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<string, Element>()
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
75
packages/core/src/io/formats/svg/import.ts
Normal file
75
packages/core/src/io/formats/svg/import.ts
Normal file
|
|
@ -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<SVGImportOptions, 'defaultColor'> = {}
|
||||
): 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
|
||||
}
|
||||
|
|
@ -1 +1,8 @@
|
|||
export { renderNodesToSVG, geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './export'
|
||||
export {
|
||||
createSVGNodes,
|
||||
createSVGNodesFromImport,
|
||||
prepareSVGImport,
|
||||
type SVGImportData,
|
||||
type SVGImportOptions
|
||||
} from './import'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Parameters<typeof defineTool>[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 <path>, <circle>, <ellipse>, <rect>, <line>, <polygon>, <polyline> 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 <use> 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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, ParsedGradient>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLCanvasElement | null>, 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<HTMLCanvasElement | null>, 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<File> | Iterable<File> | 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)) : []
|
||||
}
|
||||
|
|
|
|||
96
tests/e2e/canvas/drop-files.spec.ts
Normal file
96
tests/e2e/canvas/drop-files.spec.ts
Normal file
|
|
@ -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(
|
||||
[
|
||||
'<svg width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#f00"/></svg>'
|
||||
],
|
||||
'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<Blob>((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()
|
||||
})
|
||||
64
tests/engine/editor/clipboard/file-placement.test.ts
Normal file
64
tests/engine/editor/clipboard/file-placement.test.ts
Normal file
|
|
@ -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(
|
||||
[
|
||||
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#f00"/></svg>`
|
||||
],
|
||||
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(
|
||||
['<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>'],
|
||||
'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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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: `<svg viewBox="0 0 100 100"><g transform="translate(40 30)"><rect width="10" height="20"/></g></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: `<svg width="200" height="200" viewBox="0 0 100 50"><rect width="100" height="50"/></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: `<svg width="200" height="200" viewBox="0 0 100 50" preserveAspectRatio="none"><rect width="100" height="50"/></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: `<svg viewBox="0 0 100 100"><defs><path id="tile" d="M0 0H10V10H0Z"/></defs><use href="#tile" x="20" y="30" style="fill: #0000ff"/></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: `<svg viewBox="0 0 10 10"><defs><linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="10" height="10" fill="url(#g)"/></svg>`
|
||||
})) as { id: string }
|
||||
|
||||
expect(graph.getChildren(result.id)[0].fills[0].type).toBe('GRADIENT_LINEAR')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
18
tests/engine/vue/input/drop-files.test.ts
Normal file
18
tests/engine/vue/input/drop-files.test.ts
Normal file
|
|
@ -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(['<svg/>'], '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(['<svg/>'], 'mark.SVG')
|
||||
expect(filterCanvasFiles([svg])).toEqual([svg])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue