feat(app): convert image layers to vectors
- Add secure Recraft and fal.ai clients with centralized credentials and Media settings - Replace image rectangles with editable vector frames in one undoable operation - Bound desktop proxy responses, timeouts, and redirects for provider downloads - Cover provider parsing, placement, settings, context-menu visibility, and undo Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
parent
7d8d33e1e5
commit
a6940bcab8
|
|
@ -58,6 +58,8 @@ Tauri stores secrets in the native system credential store through `desktop/src/
|
|||
|
||||
Storage-provider schemas and runtime adapters live under `src/app/integrations/storage/`; non-secret preferences and credential references stay separate, and adapters resolve secrets at operation time. Local-first document caching and outbox synchronization live under `src/app/storage/`. A remote storage binding augments document source state and must not replace local file identity.
|
||||
|
||||
Bitmap-to-vector conversion lives in `packages/core/src/vector/vectorize/`; app provider clients, preferences, and lazy credential resolution live under `src/app/editor/vectorize/`. Keep provider credentials in the centralized credential manager, bound request and response sizes, and validate provider-owned download URLs before importing returned SVG.
|
||||
|
||||
App dialogs compose the Reka-backed components under `src/components/ui/dialog/` and the typed theme in `src/theme/dialog.ts`. Do not repeat portal, overlay, content, header, or footer infrastructure in feature dialogs.
|
||||
|
||||
## Commands
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
- Manage AI, agent, and media credentials from unified Settings, using the system credential store on desktop and optional encrypted storage in the browser.
|
||||
- Assign separate Design, Review, Fast, and Vision models, providers, endpoints, and credentials from AI settings.
|
||||
- Connect an S3-compatible storage workspace with local-first saves, background synchronization, and centrally managed credentials.
|
||||
- Convert image layers into editable vector layers with Recraft or fal.ai from the canvas context menu. (#322)
|
||||
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive property grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
|
||||
- Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338)
|
||||
- Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Or download from the [releases page](https://github.com/open-pencil/open-pencil/
|
|||
- **Fully programmable** — headless CLI, XPath queries, Figma Plugin API via `eval`, MCP server for AI agents, and desktop agent integrations for Claude Code, Codex, and Gemini CLI
|
||||
- **Lint, convert, and extract tokens** — inspect documents, lint naming/layout/accessibility, convert between supported formats, analyze colors/typography/spacing/clusters, and extract design tokens
|
||||
- **Components and variants** — create reusable components, group variants into component sets, insert local assets as instances, and switch variants from the inspector
|
||||
- **Image vectorization** — convert image layers into editable vector layers with Recraft or fal.ai
|
||||
- **Design-to-code export** — export selections as JSX/Tailwind, generate token outputs, and map designs into component-oriented code workflows
|
||||
- **Vue SDK for custom editors** — headless components and composables for embedding OpenPencil into other apps or building workflow-specific editing surfaces. [Read the SDK docs →](https://openpencil.dev/programmable/sdk/)
|
||||
- **Real-time collaboration** — P2P via WebRTC, no server, no account. Cursors, presence, follow mode
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use reqwest::{
|
|||
Method,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct ProxyHttpHeader {
|
||||
|
|
@ -16,6 +17,9 @@ pub struct ProxyHttpRequest {
|
|||
method: Option<String>,
|
||||
headers: Option<Vec<ProxyHttpHeader>>,
|
||||
body: Option<Vec<u8>>,
|
||||
max_response_bytes: Option<usize>,
|
||||
follow_redirects: Option<bool>,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -23,6 +27,7 @@ pub struct ProxyHttpResponse {
|
|||
status: u16,
|
||||
headers: Vec<ProxyHttpHeader>,
|
||||
body: Vec<u8>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn request_headers(headers: Option<Vec<ProxyHttpHeader>>) -> HeaderMap {
|
||||
|
|
@ -51,10 +56,16 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
|
|||
return Err("Only HTTP(S) requests are supported".into());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let redirect_policy = if request.follow_redirects.unwrap_or(true) {
|
||||
reqwest::redirect::Policy::limited(5)
|
||||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
};
|
||||
let mut client_builder = reqwest::Client::builder().redirect(redirect_policy);
|
||||
if let Some(timeout_ms) = request.timeout_ms {
|
||||
client_builder = client_builder.timeout(Duration::from_millis(timeout_ms));
|
||||
}
|
||||
let client = client_builder.build().map_err(|e| e.to_string())?;
|
||||
let mut builder = client
|
||||
.request(request_method(request.method)?, parsed)
|
||||
.headers(request_headers(request.headers));
|
||||
|
|
@ -62,8 +73,9 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
|
|||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let response = builder.send().await.map_err(|e| e.to_string())?;
|
||||
let mut response = builder.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let url = response.url().to_string();
|
||||
let headers = response
|
||||
.headers()
|
||||
.iter()
|
||||
|
|
@ -74,11 +86,28 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
|
|||
})
|
||||
})
|
||||
.collect();
|
||||
let body = response.bytes().await.map_err(|e| e.to_string())?.to_vec();
|
||||
if let (Some(limit), Some(content_length)) =
|
||||
(request.max_response_bytes, response.content_length())
|
||||
{
|
||||
if content_length > limit as u64 {
|
||||
return Err("HTTP response exceeds the configured size limit".into());
|
||||
}
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
if request
|
||||
.max_response_bytes
|
||||
.is_some_and(|limit| body.len() + chunk.len() > limit)
|
||||
{
|
||||
return Err("HTTP response exceeds the configured size limit".into());
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
Ok(ProxyHttpResponse {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type {
|
|||
} from './types'
|
||||
import { createUndoActions } from './undo'
|
||||
import { createVariableActions } from './variables'
|
||||
import { createVectorizeActions } from './vectorize'
|
||||
import { createViewportActions } from './viewport'
|
||||
|
||||
export { createDefaultEditorState } from './state'
|
||||
|
|
@ -165,6 +166,7 @@ export function createEditor(options?: EditorOptions) {
|
|||
const text = createTextActions(ctx)
|
||||
const nodes = createNodeActions(ctx)
|
||||
const variables = createVariableActions(ctx)
|
||||
const vectorize = createVectorizeActions(ctx)
|
||||
const alignment = createAlignmentActions(ctx)
|
||||
const clipboardBridge = createClipboardBridge(clipboard, selection)
|
||||
const componentBridge = createComponentBridge(components, selection, structure, pages)
|
||||
|
|
@ -251,6 +253,9 @@ export function createEditor(options?: EditorOptions) {
|
|||
// Alignment (align, flip, rotate)
|
||||
...alignment,
|
||||
|
||||
// Bitmap-to-vector replacement
|
||||
...vectorize,
|
||||
|
||||
// Variables
|
||||
...variables,
|
||||
|
||||
|
|
|
|||
124
packages/core/src/editor/vectorize.ts
Normal file
124
packages/core/src/editor/vectorize.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import { copyEffects, copyStrokes } from '@open-pencil/scene-graph/copy'
|
||||
|
||||
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
import {
|
||||
createVectorFrameChildren,
|
||||
resolveVectorFramePlacement,
|
||||
type SVGVectorizeResult
|
||||
} from '#core/vector/vectorize'
|
||||
|
||||
function hasRoundedCorners(node: SceneNode): boolean {
|
||||
return node.independentCorners
|
||||
? [node.topLeftRadius, node.topRightRadius, node.bottomRightRadius, node.bottomLeftRadius].some(
|
||||
(radius) => radius > 0
|
||||
)
|
||||
: node.cornerRadius > 0
|
||||
}
|
||||
|
||||
function replacementFrameProps(node: SceneNode) {
|
||||
return {
|
||||
name: node.name,
|
||||
rotation: node.rotation,
|
||||
flipX: node.flipX,
|
||||
flipY: node.flipY,
|
||||
opacity: node.opacity,
|
||||
visible: node.visible,
|
||||
locked: node.locked,
|
||||
blendMode: node.blendMode,
|
||||
effects: copyEffects(node.effects),
|
||||
strokes: copyStrokes(node.strokes),
|
||||
strokeStyleId: node.strokeStyleId,
|
||||
strokeCap: node.strokeCap,
|
||||
strokeJoin: node.strokeJoin,
|
||||
strokeMiterLimit: node.strokeMiterLimit,
|
||||
dashPattern: [...node.dashPattern],
|
||||
cornerRadius: node.cornerRadius,
|
||||
topLeftRadius: node.topLeftRadius,
|
||||
topRightRadius: node.topRightRadius,
|
||||
bottomRightRadius: node.bottomRightRadius,
|
||||
bottomLeftRadius: node.bottomLeftRadius,
|
||||
independentCorners: node.independentCorners,
|
||||
cornerSmoothing: node.cornerSmoothing,
|
||||
clipsContent: node.clipsContent || hasRoundedCorners(node),
|
||||
horizontalConstraint: node.horizontalConstraint,
|
||||
verticalConstraint: node.verticalConstraint,
|
||||
layoutPositioning: node.layoutPositioning,
|
||||
layoutGrow: node.layoutGrow,
|
||||
layoutAlignSelf: node.layoutAlignSelf,
|
||||
minWidth: node.minWidth,
|
||||
maxWidth: node.maxWidth,
|
||||
minHeight: node.minHeight,
|
||||
maxHeight: node.maxHeight,
|
||||
isMask: node.isMask,
|
||||
maskType: node.maskType,
|
||||
maskIsOutline: node.maskIsOutline
|
||||
}
|
||||
}
|
||||
|
||||
export function createVectorizeActions(ctx: EditorContext) {
|
||||
function replaceNodeWithVectorFrame(
|
||||
nodeId: string,
|
||||
vectorized: SVGVectorizeResult
|
||||
): string | null {
|
||||
const node = ctx.graph.getNode(nodeId)
|
||||
const parentId = node?.parentId
|
||||
const parent = parentId ? ctx.graph.getNode(parentId) : null
|
||||
if (!node || !parentId || !parent) return null
|
||||
|
||||
const insertIndex = parent.childIds.indexOf(node.id)
|
||||
if (insertIndex === -1) return null
|
||||
const placement = resolveVectorFramePlacement(node, vectorized.contentBounds)
|
||||
const originalSubtree = snapshotSubtree(ctx.graph, node.id)
|
||||
const previousSelection = new Set(ctx.state.selectedIds)
|
||||
|
||||
const frame = ctx.graph.createNode('FRAME', parentId, {
|
||||
...replacementFrameProps(node),
|
||||
x: placement.x,
|
||||
y: placement.y,
|
||||
width: placement.width,
|
||||
height: placement.height,
|
||||
fills: []
|
||||
})
|
||||
ctx.graph.insertChildAt(frame.id, parentId, insertIndex)
|
||||
createVectorFrameChildren(ctx.graph, frame.id, vectorized, placement)
|
||||
if (frame.childIds.length === 0) {
|
||||
ctx.graph.deleteNode(frame.id)
|
||||
return null
|
||||
}
|
||||
|
||||
const frameSubtree = snapshotSubtree(ctx.graph, frame.id)
|
||||
ctx.graph.deleteNode(node.id)
|
||||
ctx.setSelectedIds(new Set([frame.id]))
|
||||
|
||||
ctx.undo.push({
|
||||
label: 'Vectorize image',
|
||||
forward: () => {
|
||||
if (ctx.graph.getNode(node.id)) ctx.graph.deleteNode(node.id)
|
||||
const frameRoot = frameSubtree.get(frame.id)
|
||||
if (frameRoot && !ctx.graph.getNode(frame.id)) {
|
||||
restoreSubtree(ctx.graph, frameRoot, parentId, frameSubtree)
|
||||
ctx.graph.insertChildAt(frame.id, parentId, insertIndex)
|
||||
}
|
||||
ctx.setSelectedIds(new Set([frame.id]))
|
||||
ctx.requestRender()
|
||||
},
|
||||
inverse: () => {
|
||||
if (ctx.graph.getNode(frame.id)) ctx.graph.deleteNode(frame.id)
|
||||
const originalRoot = originalSubtree.get(node.id)
|
||||
if (originalRoot && !ctx.graph.getNode(node.id)) {
|
||||
restoreSubtree(ctx.graph, originalRoot, parentId, originalSubtree)
|
||||
ctx.graph.insertChildAt(node.id, parentId, insertIndex)
|
||||
}
|
||||
ctx.setSelectedIds(previousSelection)
|
||||
ctx.requestRender()
|
||||
}
|
||||
})
|
||||
|
||||
ctx.requestRender()
|
||||
return frame.id
|
||||
}
|
||||
|
||||
return { replaceNodeWithVectorFrame }
|
||||
}
|
||||
|
|
@ -14,8 +14,12 @@ export interface VectorFramePlacement {
|
|||
offsetY: number
|
||||
}
|
||||
|
||||
function shouldTightenToContent(node: Pick<SceneNode, 'width' | 'height'>, content: Rect): boolean {
|
||||
function shouldTightenToContent(
|
||||
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
|
||||
content: Rect
|
||||
): boolean {
|
||||
return (
|
||||
node.rotation === 0 &&
|
||||
content.width > 0 &&
|
||||
content.height > 0 &&
|
||||
(content.x > 0 ||
|
||||
|
|
@ -26,7 +30,7 @@ function shouldTightenToContent(node: Pick<SceneNode, 'width' | 'height'>, conte
|
|||
}
|
||||
|
||||
export function resolveVectorFramePlacement(
|
||||
node: Pick<SceneNode, 'x' | 'y' | 'width' | 'height'>,
|
||||
node: Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'rotation'>,
|
||||
content: Rect
|
||||
): VectorFramePlacement {
|
||||
const tighten = shouldTightenToContent(node, content)
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Keines",
|
||||
"back": "Zurück",
|
||||
"settingsMedia": "Medien",
|
||||
"vectorization": "Bildvektorisierung",
|
||||
"vectorizationDescription": "Sendet Bildebenen an Recraft oder fal.ai und gibt bearbeitbare Vektoren zurück. Es können Anbietergebühren anfallen.",
|
||||
"vectorizeProvider": "Vektorisierungsdienst",
|
||||
"settingsStorage": "Cloud-Speicher",
|
||||
"storageWorkspace": "Speicher-Arbeitsbereich",
|
||||
"openStorageWorkspace": "Arbeitsbereich öffnen",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "Als JSX kopieren",
|
||||
"copyNodeId": "Knoten-ID kopieren",
|
||||
"copyXPath": "XPath kopieren",
|
||||
"convertToVector": "In Vektor umwandeln",
|
||||
"checkUpdates": "Nach Updates suchen…",
|
||||
"booleanOperations": "Boolesche Operationen",
|
||||
"strikethrough": "Durchstreichen",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Ninguno",
|
||||
"back": "Atrás",
|
||||
"settingsMedia": "Multimedia",
|
||||
"vectorization": "Vectorización de imágenes",
|
||||
"vectorizationDescription": "Envía capas de imagen a Recraft o fal.ai y devuelve vectores editables. Pueden aplicarse cargos del proveedor.",
|
||||
"vectorizeProvider": "Servicio de vectorización",
|
||||
"settingsStorage": "Almacenamiento en la nube",
|
||||
"storageWorkspace": "Espacio de almacenamiento",
|
||||
"openStorageWorkspace": "Abrir espacio",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"copyAsJSX": "Copiar como JSX",
|
||||
"copyNodeId": "Copiar ID del nodo",
|
||||
"copyXPath": "Copiar XPath",
|
||||
"convertToVector": "Convertir a vector",
|
||||
"checkUpdates": "Buscar actualizaciones…",
|
||||
"booleanOperations": "Operaciones booleanas",
|
||||
"closeTab": "Cerrar pestaña",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Aucun",
|
||||
"back": "Retour",
|
||||
"settingsMedia": "Médias",
|
||||
"vectorization": "Vectorisation d’image",
|
||||
"vectorizationDescription": "Envoie les calques d’image à Recraft ou fal.ai et renvoie des vecteurs modifiables. Des frais peuvent s’appliquer.",
|
||||
"vectorizeProvider": "Service de vectorisation",
|
||||
"settingsStorage": "Stockage cloud",
|
||||
"storageWorkspace": "Espace de stockage",
|
||||
"openStorageWorkspace": "Ouvrir l’espace",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "Copier en JSX",
|
||||
"copyNodeId": "Copier l'ID du nœud",
|
||||
"copyXPath": "Copier le XPath",
|
||||
"convertToVector": "Convertir en vecteur",
|
||||
"checkUpdates": "Rechercher des mises à jour…",
|
||||
"booleanOperations": "Opérations booléennes",
|
||||
"strikethrough": "Barré",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Nessuno",
|
||||
"back": "Indietro",
|
||||
"settingsMedia": "Media",
|
||||
"vectorization": "Vettorializzazione immagini",
|
||||
"vectorizationDescription": "Invia i livelli immagine a Recraft o fal.ai e restituisce vettori modificabili. Potrebbero essere applicati costi.",
|
||||
"vectorizeProvider": "Servizio di vettorializzazione",
|
||||
"settingsStorage": "Archiviazione cloud",
|
||||
"storageWorkspace": "Area di archiviazione",
|
||||
"openStorageWorkspace": "Apri area",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "Copia come JSX",
|
||||
"copyNodeId": "Copia ID nodo",
|
||||
"copyXPath": "Copia XPath",
|
||||
"convertToVector": "Converti in vettore",
|
||||
"checkUpdates": "Controlla aggiornamenti…",
|
||||
"booleanOperations": "Operazioni booleane",
|
||||
"strikethrough": "Barrato",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "なし",
|
||||
"back": "戻る",
|
||||
"settingsMedia": "メディア",
|
||||
"vectorization": "画像のベクター化",
|
||||
"vectorizationDescription": "画像レイヤーを Recraft または fal.ai に送信し、編集可能なベクターを返します。プロバイダー料金が発生する場合があります。",
|
||||
"vectorizeProvider": "ベクター化サービス",
|
||||
"settingsStorage": "クラウドストレージ",
|
||||
"storageWorkspace": "ストレージワークスペース",
|
||||
"openStorageWorkspace": "ワークスペースを開く",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"copyAsJSX": "JSXとしてコピー",
|
||||
"copyNodeId": "ノードIDをコピー",
|
||||
"copyXPath": "XPathをコピー",
|
||||
"convertToVector": "ベクターに変換",
|
||||
"checkUpdates": "アップデートを確認…",
|
||||
"booleanOperations": "パスの型抜き (ブーリアン演算)",
|
||||
"closeTab": "タブを閉じる",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Brak",
|
||||
"back": "Wstecz",
|
||||
"settingsMedia": "Media",
|
||||
"vectorization": "Wektoryzacja obrazów",
|
||||
"vectorizationDescription": "Wysyła warstwy obrazów do Recraft lub fal.ai i zwraca edytowalne wektory. Mogą obowiązywać opłaty dostawcy.",
|
||||
"vectorizeProvider": "Usługa wektoryzacji",
|
||||
"settingsStorage": "Przechowywanie w chmurze",
|
||||
"storageWorkspace": "Obszar przechowywania",
|
||||
"openStorageWorkspace": "Otwórz obszar",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "Kopiuj jako JSX",
|
||||
"copyNodeId": "Kopiuj ID węzła",
|
||||
"copyXPath": "Kopiuj XPath",
|
||||
"convertToVector": "Konwertuj na wektor",
|
||||
"checkUpdates": "Sprawdź aktualizacje…",
|
||||
"booleanOperations": "Operacje logiczne",
|
||||
"strikethrough": "Przekreślenie",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "Нет",
|
||||
"back": "Назад",
|
||||
"settingsMedia": "Медиа",
|
||||
"vectorization": "Векторизация изображений",
|
||||
"vectorizationDescription": "Отправляет слои изображений в Recraft или fal.ai и возвращает редактируемые векторы. Может взиматься плата провайдера.",
|
||||
"vectorizeProvider": "Сервис векторизации",
|
||||
"settingsStorage": "Облачное хранилище",
|
||||
"storageWorkspace": "Рабочая область хранилища",
|
||||
"openStorageWorkspace": "Открыть область",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "Копировать как JSX",
|
||||
"copyNodeId": "Копировать ID узла",
|
||||
"copyXPath": "Копировать XPath",
|
||||
"convertToVector": "Преобразовать в вектор",
|
||||
"checkUpdates": "Проверить обновления…",
|
||||
"booleanOperations": "Логические операции",
|
||||
"strikethrough": "Зачёркивание",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@
|
|||
"noModel": "无",
|
||||
"back": "返回",
|
||||
"settingsMedia": "媒体",
|
||||
"vectorization": "图像矢量化",
|
||||
"vectorizationDescription": "将图像图层发送到 Recraft 或 fal.ai,并返回可编辑的矢量。服务商可能收费。",
|
||||
"vectorizeProvider": "矢量化服务",
|
||||
"settingsStorage": "云存储",
|
||||
"storageWorkspace": "存储工作区",
|
||||
"openStorageWorkspace": "打开工作区",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"copyAsJSX": "复制为 JSX",
|
||||
"copyNodeId": "复制节点 ID",
|
||||
"copyXPath": "复制 XPath",
|
||||
"convertToVector": "转换为矢量",
|
||||
"checkUpdates": "检查更新…",
|
||||
"booleanOperations": "布尔运算",
|
||||
"strikethrough": "删除线",
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@ export const dialogMessageDefaults = {
|
|||
noModel: 'None',
|
||||
back: 'Back',
|
||||
settingsMedia: 'Media',
|
||||
vectorization: 'Image vectorization',
|
||||
vectorizationDescription:
|
||||
'Send image layers to Recraft or fal.ai and return editable vectors. Provider charges may apply.',
|
||||
vectorizeProvider: 'Vectorization service',
|
||||
settingsStorage: 'Cloud storage',
|
||||
storageWorkspace: 'Storage workspace',
|
||||
openStorageWorkspace: 'Open workspace',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const menuMessageDefaults = {
|
|||
copyAsJSX: 'Copy as JSX',
|
||||
copyNodeId: 'Copy node ID',
|
||||
copyXPath: 'Copy XPath',
|
||||
convertToVector: 'Convert to vector',
|
||||
booleanOperations: 'Boolean operations',
|
||||
arrangeAlignLeft: 'Align left',
|
||||
arrangeAlignCenter: 'Align center',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { nodeToXPath } from '@open-pencil/core/xpath'
|
|||
import type { EditorStore } from '@/app/editor/active-store'
|
||||
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
|
||||
import { executeClipboardCommand } from '@/app/editor/clipboard/system'
|
||||
import { canVectorizeImageNode, vectorizeImageNode } from '@/app/editor/vectorize'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
import { writeTauriClipboardText } from '@/app/tauri/clipboard'
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
|
@ -75,6 +76,14 @@ export function createCanvasMenuActions(store: EditorStore, selectedIds: Ref<Set
|
|||
clipboardWrite,
|
||||
copyNodeId,
|
||||
copyXPath,
|
||||
copyAsPNG
|
||||
copyAsPNG,
|
||||
canVectorizeImage: () => {
|
||||
void selectedIds.value
|
||||
return canVectorizeImageNode(store)
|
||||
},
|
||||
vectorizeImage: async () => {
|
||||
const nodeId = ids()[0]
|
||||
if (nodeId) await vectorizeImageNode(store, nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { createCanvasMenuActions } from '@/app/editor/canvas/menu/actions'
|
|||
import {
|
||||
CANVAS_COPY_AS_ACTIONS,
|
||||
CANVAS_COPY_AS_GROUP_TEST_ID,
|
||||
CANVAS_VECTORIZE_TEST_ID,
|
||||
type CanvasContextActionId
|
||||
} from '@/app/editor/canvas/menu/registry'
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ type CanvasCopyLabels = {
|
|||
copyAsJSX: string
|
||||
copyNodeId: string
|
||||
copyXPath: string
|
||||
convertToVector: string
|
||||
}
|
||||
|
||||
function withoutStaticSelectionCommands(entries: readonly MenuEntry[]): MenuEntry[] {
|
||||
|
|
@ -90,6 +92,16 @@ export function useCanvasContextMenu(
|
|||
return computed<MenuEntry[]>(() => {
|
||||
const entries = withoutStaticSelectionCommands(baseEntries.value)
|
||||
if (!hasSelection.value) return entries
|
||||
if (actions.canVectorizeImage()) {
|
||||
entries.push(
|
||||
{ separator: true },
|
||||
{
|
||||
label: labels.value.convertToVector,
|
||||
testId: CANVAS_VECTORIZE_TEST_ID,
|
||||
action: runAsync(actions.vectorizeImage)
|
||||
}
|
||||
)
|
||||
}
|
||||
return [...entries, { separator: true }, copyPasteAsEntry(editor, actions, labels.value)]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export type CanvasContextActionMeta = {
|
|||
}
|
||||
|
||||
export const CANVAS_COPY_AS_GROUP_TEST_ID = 'context-copy-paste-as' satisfies TestId
|
||||
export const CANVAS_VECTORIZE_TEST_ID = 'context-vectorize' satisfies TestId
|
||||
export const COPY_AS_PNG_SHORTCUT = 'MOD+SHIFT+C'
|
||||
|
||||
export const CANVAS_COPY_AS_ACTIONS = [
|
||||
|
|
|
|||
89
src/app/editor/vectorize/action.ts
Normal file
89
src/app/editor/vectorize/action.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { preprocessForVectorize, svgToVectorPaths } from '@open-pencil/core/vector'
|
||||
import type { Fill, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { EditorStore } from '@/app/editor/active-store'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
|
||||
import { resolveVectorizeCredential } from './credentials'
|
||||
import { vectorizeProviderID } from './preferences'
|
||||
import { getVectorizeProvider } from './providers'
|
||||
import { VectorizeAuthError } from './types'
|
||||
|
||||
const activeStores = new WeakSet<EditorStore>()
|
||||
const ERROR_MAX_LENGTH = 240
|
||||
|
||||
function imageFill(node: SceneNode): Fill | null {
|
||||
if (node.type !== 'RECTANGLE' || node.childIds.length > 0 || node.fills.length !== 1) return null
|
||||
const fill = node.fills[0]
|
||||
return fill.type === 'IMAGE' && fill.imageHash ? fill : null
|
||||
}
|
||||
|
||||
export function canVectorizeImageNode(store: EditorStore): boolean {
|
||||
if (store.state.selectedIds.size !== 1 || activeStores.has(store)) return false
|
||||
const nodeId = store.state.selectedIds.values().next().value
|
||||
if (!nodeId) return false
|
||||
const node = store.graph.getNode(nodeId)
|
||||
return Boolean(node && imageFill(node))
|
||||
}
|
||||
|
||||
function vectorizeErrorMessage(error: unknown, providerName: string): string {
|
||||
if (error instanceof TypeError) {
|
||||
return `${providerName} could not be reached. Check your connection and try again.`
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Vectorization failed'
|
||||
return message.length <= ERROR_MAX_LENGTH ? message : `${message.slice(0, ERROR_MAX_LENGTH)}…`
|
||||
}
|
||||
|
||||
export async function vectorizeImageNode(store: EditorStore, nodeId: string): Promise<void> {
|
||||
if (activeStores.has(store)) return
|
||||
const node = store.graph.getNode(nodeId)
|
||||
const fill = node ? imageFill(node) : null
|
||||
if (!node || fill?.type !== 'IMAGE' || !fill.imageHash) return
|
||||
|
||||
const provider = getVectorizeProvider(vectorizeProviderID.value)
|
||||
activeStores.add(store)
|
||||
try {
|
||||
const apiKey = await resolveVectorizeCredential(provider.id)
|
||||
if (!apiKey) {
|
||||
toast.error(`Add a ${provider.name} API key in Settings → Media.`)
|
||||
openSettingsDialog('media')
|
||||
return
|
||||
}
|
||||
|
||||
const imageBytes = store.graph.images.get(fill.imageHash)
|
||||
if (!imageBytes) {
|
||||
toast.error('Image data is missing for this layer')
|
||||
return
|
||||
}
|
||||
|
||||
toast.info('Vectorizing image…')
|
||||
const preprocessed = preprocessForVectorize(imageBytes, () => store.renderer?.ck ?? null)
|
||||
if (!preprocessed) throw new Error('Could not prepare this image for vectorization')
|
||||
|
||||
const svg = await provider.vectorize(preprocessed.pngBytes, apiKey)
|
||||
const currentNode = store.graph.getNode(node.id)
|
||||
const currentFill = currentNode ? imageFill(currentNode) : null
|
||||
if (!currentNode || currentFill?.type !== 'IMAGE' || currentFill.imageHash !== fill.imageHash) {
|
||||
throw new Error('The image changed before vectorization finished')
|
||||
}
|
||||
const vectorized = svgToVectorPaths(svg, {
|
||||
width: currentNode.width,
|
||||
height: currentNode.height
|
||||
})
|
||||
if (!vectorized?.paths.length) throw new Error('The vectorizer returned no editable paths')
|
||||
|
||||
const frameId = store.replaceNodeWithVectorFrame(currentNode.id, vectorized)
|
||||
if (!frameId) throw new Error('Could not replace this image with vector layers')
|
||||
toast.info('Image converted to vectors')
|
||||
} catch (error) {
|
||||
if (error instanceof VectorizeAuthError) {
|
||||
toast.error(`${error.message}. Update it in Settings → Media.`)
|
||||
openSettingsDialog('media')
|
||||
} else {
|
||||
toast.error(vectorizeErrorMessage(error, provider.name))
|
||||
}
|
||||
} finally {
|
||||
activeStores.delete(store)
|
||||
}
|
||||
}
|
||||
36
src/app/editor/vectorize/credentials.ts
Normal file
36
src/app/editor/vectorize/credentials.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { appCredentialServices } from '@/app/settings/credentials/app'
|
||||
import { credentialRef } from '@/app/settings/credentials/reference'
|
||||
import type { CredentialRef, CredentialStatus } from '@/app/settings/credentials/types'
|
||||
|
||||
import type { VectorizeProviderID } from './types'
|
||||
|
||||
export function vectorizeCredentialRef(providerID: VectorizeProviderID): CredentialRef {
|
||||
return credentialRef(`vectorize-${providerID}`, 'api-key')
|
||||
}
|
||||
|
||||
export const VECTORIZE_CREDENTIAL_REFS = [
|
||||
vectorizeCredentialRef('recraft'),
|
||||
vectorizeCredentialRef('fal')
|
||||
] as const
|
||||
|
||||
export function vectorizeCredentialStatus(
|
||||
providerID: VectorizeProviderID
|
||||
): Promise<CredentialStatus> {
|
||||
return appCredentialServices.manager.status(vectorizeCredentialRef(providerID))
|
||||
}
|
||||
|
||||
export async function setVectorizeCredential(
|
||||
providerID: VectorizeProviderID,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const reference = vectorizeCredentialRef(providerID)
|
||||
const credential = value.trim()
|
||||
if (credential) await appCredentialServices.manager.set(reference, credential)
|
||||
else await appCredentialServices.manager.clear(reference)
|
||||
}
|
||||
|
||||
export function resolveVectorizeCredential(
|
||||
providerID: VectorizeProviderID
|
||||
): Promise<string | null> {
|
||||
return appCredentialServices.resolver.resolve(vectorizeCredentialRef(providerID))
|
||||
}
|
||||
9
src/app/editor/vectorize/index.ts
Normal file
9
src/app/editor/vectorize/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { canVectorizeImageNode, vectorizeImageNode } from './action'
|
||||
export {
|
||||
setVectorizeCredential,
|
||||
vectorizeCredentialRef,
|
||||
vectorizeCredentialStatus
|
||||
} from './credentials'
|
||||
export { vectorizeProviderID } from './preferences'
|
||||
export { getVectorizeProvider, VECTORIZE_PROVIDER_DEFINITIONS } from './providers'
|
||||
export type { VectorizeProviderID } from './types'
|
||||
17
src/app/editor/vectorize/preferences.ts
Normal file
17
src/app/editor/vectorize/preferences.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { VECTORIZE_PROVIDER_IDS, type VectorizeProviderID } from '@/app/editor/vectorize/types'
|
||||
|
||||
const storedProvider = useLocalStorage<string>('open-pencil:vectorize:provider', 'recraft')
|
||||
|
||||
function isVectorizeProviderID(value: string): value is VectorizeProviderID {
|
||||
return VECTORIZE_PROVIDER_IDS.some((providerID) => providerID === value)
|
||||
}
|
||||
|
||||
export const vectorizeProviderID = computed<VectorizeProviderID>({
|
||||
get: () => (isVectorizeProviderID(storedProvider.value) ? storedProvider.value : 'recraft'),
|
||||
set: (providerID) => {
|
||||
storedProvider.value = providerID
|
||||
}
|
||||
})
|
||||
276
src/app/editor/vectorize/providers.ts
Normal file
276
src/app/editor/vectorize/providers.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { IS_TAURI } from '@open-pencil/core/constants'
|
||||
|
||||
import { tauriFetch } from '@/app/tauri/http'
|
||||
|
||||
import {
|
||||
VectorizeAuthError,
|
||||
type VectorizeProvider,
|
||||
type VectorizeProviderDefinition,
|
||||
type VectorizeProviderID,
|
||||
VectorizeTimeoutError
|
||||
} from './types'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const NATIVE_TIMEOUT_GRACE_MS = 1000
|
||||
const MAX_SVG_BYTES = 20 * 1024 * 1024
|
||||
const MAX_API_RESPONSE_BYTES = 1024 * 1024
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const BASE64_CHUNK_SIZE = 32_768
|
||||
|
||||
const RECRAFT_DEFINITION = {
|
||||
id: 'recraft',
|
||||
name: 'Recraft',
|
||||
keyURL: 'https://www.recraft.ai/profile/api',
|
||||
keyPlaceholder: 'Recraft API key'
|
||||
} satisfies VectorizeProviderDefinition
|
||||
|
||||
const FAL_DEFINITION = {
|
||||
id: 'fal',
|
||||
name: 'fal.ai',
|
||||
keyURL: 'https://fal.ai/dashboard/keys',
|
||||
keyPlaceholder: 'fal API key'
|
||||
} satisfies VectorizeProviderDefinition
|
||||
|
||||
export const VECTORIZE_PROVIDER_DEFINITIONS: readonly VectorizeProviderDefinition[] = [
|
||||
RECRAFT_DEFINITION,
|
||||
FAL_DEFINITION
|
||||
]
|
||||
|
||||
async function vectorizeFetch(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
maxResponseBytes = MAX_API_RESPONSE_BYTES
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
const requestInit = { ...init, signal: controller.signal }
|
||||
return await (IS_TAURI
|
||||
? tauriFetch(
|
||||
input,
|
||||
requestInit,
|
||||
maxResponseBytes,
|
||||
REQUEST_TIMEOUT_MS + NATIVE_TIMEOUT_GRACE_MS
|
||||
)
|
||||
: fetch(input, requestInit))
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new VectorizeTimeoutError()
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
type VectorizeResponseRecord = {
|
||||
image?: unknown
|
||||
data?: unknown
|
||||
images?: unknown
|
||||
url?: unknown
|
||||
}
|
||||
|
||||
function isVectorizeResponseRecord(value: unknown): value is VectorizeResponseRecord {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function readURLField(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object' || !('url' in value)) return null
|
||||
return typeof value.url === 'string' && value.url ? value.url : null
|
||||
}
|
||||
|
||||
export function extractVectorizedSVGURL(payload: unknown): string | null {
|
||||
if (!isVectorizeResponseRecord(payload)) return null
|
||||
const record = payload
|
||||
if ('image' in record) {
|
||||
const imageURL = readURLField(record.image)
|
||||
if (imageURL) return imageURL
|
||||
}
|
||||
for (const field of ['data', 'images'] as const) {
|
||||
if (!(field in record)) continue
|
||||
const value = record[field]
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const itemURL = readURLField(value[0])
|
||||
if (itemURL) return itemURL
|
||||
}
|
||||
const nestedURL = extractVectorizedSVGURL(value)
|
||||
if (nestedURL) return nestedURL
|
||||
}
|
||||
return readURLField(record)
|
||||
}
|
||||
|
||||
function isAllowedAssetHost(providerID: VectorizeProviderID, hostname: string): boolean {
|
||||
const suffixes = providerID === 'recraft' ? ['recraft.ai'] : ['fal.media', 'fal.run', 'fal.ai']
|
||||
return suffixes.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
|
||||
}
|
||||
|
||||
export function validateVectorizedAssetURL(providerID: VectorizeProviderID, rawURL: string): URL {
|
||||
const url = new URL(rawURL)
|
||||
if (url.protocol !== 'https:' || !isAllowedAssetHost(providerID, url.hostname.toLowerCase())) {
|
||||
throw new Error('Vectorization provider returned an untrusted download URL')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function readBoundedResponse(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
sizeError: string
|
||||
): Promise<string> {
|
||||
const contentLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
throw new Error(sizeError)
|
||||
}
|
||||
if (!response.body) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
if (bytes.byteLength > maxBytes) throw new Error(sizeError)
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let totalBytes = 0
|
||||
try {
|
||||
let result = await reader.read()
|
||||
while (!result.done) {
|
||||
totalBytes += result.value.byteLength
|
||||
if (totalBytes > maxBytes) throw new Error(sizeError)
|
||||
chunks.push(result.value)
|
||||
result = await reader.read()
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(totalBytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
async function fetchSVGFromURL(providerID: VectorizeProviderID, rawURL: string): Promise<string> {
|
||||
const url = validateVectorizedAssetURL(providerID, rawURL)
|
||||
let response: Response
|
||||
try {
|
||||
response = await vectorizeFetch(
|
||||
url.href,
|
||||
{ credentials: 'omit', redirect: 'error' },
|
||||
MAX_SVG_BYTES
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof VectorizeTimeoutError) throw error
|
||||
throw new Error('Vectorized SVG could not be downloaded. Try again or use the desktop app.')
|
||||
}
|
||||
if (!response.ok) throw new Error(`Failed to download vectorized SVG (${response.status})`)
|
||||
if (response.url) validateVectorizedAssetURL(providerID, response.url)
|
||||
return readBoundedResponse(response, MAX_SVG_BYTES, 'Vectorized SVG is too large')
|
||||
}
|
||||
|
||||
async function responseErrorDetail(response: Response): Promise<string> {
|
||||
const detail = await readBoundedResponse(
|
||||
response,
|
||||
4096,
|
||||
'Provider error response is too large'
|
||||
).catch(() => '')
|
||||
return detail.trim().slice(0, 500)
|
||||
}
|
||||
|
||||
const recraft: VectorizeProvider = {
|
||||
...RECRAFT_DEFINITION,
|
||||
async vectorize(pngBytes, apiKey) {
|
||||
if (pngBytes.byteLength > MAX_IMAGE_BYTES) throw new Error('Image is too large to vectorize')
|
||||
const form = new FormData()
|
||||
const payload = pngBytes.buffer.slice(
|
||||
pngBytes.byteOffset,
|
||||
pngBytes.byteOffset + pngBytes.byteLength
|
||||
) as ArrayBuffer
|
||||
form.append('file', new Blob([payload], { type: 'image/png' }), 'image.png')
|
||||
|
||||
const response = await vectorizeFetch('https://external.api.recraft.ai/v1/images/vectorize', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
credentials: 'omit',
|
||||
redirect: 'error'
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new VectorizeAuthError(recraft.name)
|
||||
}
|
||||
const detail = await responseErrorDetail(response)
|
||||
throw new Error(
|
||||
detail
|
||||
? `${recraft.name} vectorization failed (${response.status}): ${detail}`
|
||||
: `${recraft.name} vectorization failed (${response.status})`
|
||||
)
|
||||
}
|
||||
const url = extractVectorizedSVGURL(
|
||||
JSON.parse(
|
||||
await readBoundedResponse(
|
||||
response,
|
||||
MAX_API_RESPONSE_BYTES,
|
||||
`${recraft.name} response is too large`
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!url) throw new Error(`${recraft.name} returned no SVG URL`)
|
||||
return fetchSVGFromURL(recraft.id, url)
|
||||
}
|
||||
}
|
||||
|
||||
const fal: VectorizeProvider = {
|
||||
...FAL_DEFINITION,
|
||||
async vectorize(pngBytes, apiKey) {
|
||||
if (pngBytes.byteLength > MAX_IMAGE_BYTES) throw new Error('Image is too large to vectorize')
|
||||
const response = await vectorizeFetch('https://fal.run/fal-ai/recraft/vectorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Key ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ image_url: `data:image/png;base64,${bytesToBase64(pngBytes)}` }),
|
||||
credentials: 'omit',
|
||||
redirect: 'error'
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new VectorizeAuthError(fal.name)
|
||||
}
|
||||
const detail = await responseErrorDetail(response)
|
||||
throw new Error(
|
||||
detail
|
||||
? `${fal.name} vectorization failed (${response.status}): ${detail}`
|
||||
: `${fal.name} vectorization failed (${response.status})`
|
||||
)
|
||||
}
|
||||
const url = extractVectorizedSVGURL(
|
||||
JSON.parse(
|
||||
await readBoundedResponse(
|
||||
response,
|
||||
MAX_API_RESPONSE_BYTES,
|
||||
`${fal.name} response is too large`
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!url) throw new Error(`${fal.name} returned no SVG URL`)
|
||||
return fetchSVGFromURL(fal.id, url)
|
||||
}
|
||||
}
|
||||
|
||||
const providers: Record<VectorizeProviderID, VectorizeProvider> = { recraft, fal }
|
||||
|
||||
export function getVectorizeProvider(providerID: VectorizeProviderID): VectorizeProvider {
|
||||
return providers[providerID]
|
||||
}
|
||||
28
src/app/editor/vectorize/types.ts
Normal file
28
src/app/editor/vectorize/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export const VECTORIZE_PROVIDER_IDS = ['recraft', 'fal'] as const
|
||||
|
||||
export type VectorizeProviderID = (typeof VECTORIZE_PROVIDER_IDS)[number]
|
||||
|
||||
export type VectorizeProviderDefinition = {
|
||||
id: VectorizeProviderID
|
||||
name: string
|
||||
keyURL: string
|
||||
keyPlaceholder: string
|
||||
}
|
||||
|
||||
export type VectorizeProvider = VectorizeProviderDefinition & {
|
||||
vectorize(pngBytes: Uint8Array, apiKey: string): Promise<string>
|
||||
}
|
||||
|
||||
export class VectorizeAuthError extends Error {
|
||||
constructor(providerName: string) {
|
||||
super(`${providerName} API key was rejected`)
|
||||
this.name = 'VectorizeAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
export class VectorizeTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('Vectorization request timed out')
|
||||
this.name = 'VectorizeTimeoutError'
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { AI_PROVIDERS } from '@open-pencil/core/constants'
|
||||
|
||||
import { aiModelSettings, modelConnectionCredentialRef } from '@/app/ai/models'
|
||||
import { VECTORIZE_CREDENTIAL_REFS } from '@/app/editor/vectorize/credentials'
|
||||
import { storageCredentialRefs, storageProviderRegistry } from '@/app/integrations/storage'
|
||||
import {
|
||||
PEXELS_CREDENTIAL,
|
||||
|
|
@ -31,6 +32,7 @@ export function appCredentialRefs(): CredentialRef[] {
|
|||
...modelConnectionRefs,
|
||||
PEXELS_CREDENTIAL,
|
||||
UNSPLASH_CREDENTIAL,
|
||||
...VECTORIZE_CREDENTIAL_REFS,
|
||||
...storageCredentials
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ interface ProxyHttpRequest {
|
|||
method?: string
|
||||
headers?: ProxyHttpHeader[]
|
||||
body?: number[]
|
||||
max_response_bytes?: number
|
||||
follow_redirects?: boolean
|
||||
timeout_ms?: number
|
||||
}
|
||||
|
||||
interface ProxyHttpResponse {
|
||||
status: number
|
||||
headers: ProxyHttpHeader[]
|
||||
body: number[]
|
||||
url: string
|
||||
}
|
||||
|
||||
function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
|
||||
|
|
@ -56,7 +60,12 @@ export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Pr
|
|||
})
|
||||
}
|
||||
|
||||
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
export async function tauriFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
maxResponseBytes?: number,
|
||||
timeoutMs?: number
|
||||
): Promise<Response> {
|
||||
const request = new Request(input, init)
|
||||
request.signal.throwIfAborted()
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
|
|
@ -64,15 +73,20 @@ export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit):
|
|||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headersToProxyHeaders(request.headers),
|
||||
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())]
|
||||
body: request.body == null ? undefined : [...new Uint8Array(await request.arrayBuffer())],
|
||||
max_response_bytes: maxResponseBytes,
|
||||
follow_redirects: request.redirect === 'follow',
|
||||
timeout_ms: timeoutMs
|
||||
}
|
||||
request.signal.throwIfAborted()
|
||||
const response = await withAbortSignal(
|
||||
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
|
||||
request.signal
|
||||
)
|
||||
return new Response(new Uint8Array(response.body), {
|
||||
const proxiedResponse = new Response(new Uint8Array(response.body), {
|
||||
status: response.status,
|
||||
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
|
||||
})
|
||||
Object.defineProperty(proxiedResponse, 'url', { value: response.url })
|
||||
return proxiedResponse
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ function contextCommandIcon(id: EditorCommandId | undefined): Component | undefi
|
|||
</ContextMenuSub>
|
||||
<ContextMenuItem
|
||||
v-else
|
||||
v-test-id="contextCommandTestId(item.id)"
|
||||
v-test-id="item.testId ?? contextCommandTestId(item.id)"
|
||||
:class="canvasMenuItemClass(item.label, cls)"
|
||||
:disabled="item.disabled"
|
||||
@select="item.action?.()"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { settingsDialogOpen, settingsDialogSection } from '@/app/settings/dialog
|
|||
import ModelsPanel from '@/components/settings/models/ModelsPanel.vue'
|
||||
import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeysSection.vue'
|
||||
import StorageSettingsPanel from '@/components/settings/storage/StorageSettingsPanel.vue'
|
||||
import VectorizeSettingsSection from '@/components/settings/vectorize/VectorizeSettingsSection.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import { AppDialogFooter, AppDialogHeader, AppDialogRoot } from '@/components/ui/dialog'
|
||||
|
||||
|
|
@ -104,6 +105,7 @@ const navigationClass =
|
|||
>
|
||||
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsMedia }}</h3>
|
||||
<StockPhotoKeysSection />
|
||||
<VectorizeSettingsSection />
|
||||
</section>
|
||||
|
||||
<StorageSettingsPanel v-else />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
setVectorizeCredential,
|
||||
vectorizeCredentialStatus,
|
||||
vectorizeProviderID,
|
||||
VECTORIZE_PROVIDER_DEFINITIONS
|
||||
} from '@/app/editor/vectorize'
|
||||
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const keyDraft = ref('')
|
||||
const keyStatus = ref<'configured' | 'missing' | 'unavailable' | 'locked'>('missing')
|
||||
const provider = computed(() =>
|
||||
VECTORIZE_PROVIDER_DEFINITIONS.find((definition) => definition.id === vectorizeProviderID.value)
|
||||
)
|
||||
const providerOptions = VECTORIZE_PROVIDER_DEFINITIONS.map((definition) => ({
|
||||
value: definition.id,
|
||||
label: definition.name
|
||||
}))
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
const providerID = vectorizeProviderID.value
|
||||
const status = await vectorizeCredentialStatus(providerID)
|
||||
if (vectorizeProviderID.value === providerID) keyStatus.value = status
|
||||
}
|
||||
|
||||
async function saveCredential(): Promise<void> {
|
||||
if (!keyDraft.value.trim()) return
|
||||
await setVectorizeCredential(vectorizeProviderID.value, keyDraft.value)
|
||||
keyDraft.value = ''
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
async function clearCredential(): Promise<void> {
|
||||
await setVectorizeCredential(vectorizeProviderID.value, '')
|
||||
keyDraft.value = ''
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
watch(vectorizeProviderID, () => {
|
||||
keyDraft.value = ''
|
||||
void refreshStatus()
|
||||
})
|
||||
|
||||
onMounted(() => void refreshStatus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-2.5 border-t border-border pt-3" data-vectorize-settings>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-surface">{{ dialogs.vectorization }}</h3>
|
||||
<p class="mt-0.5 text-[10px] text-muted">{{ dialogs.vectorizationDescription }}</p>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-[10px] text-muted">
|
||||
{{ dialogs.vectorizeProvider }}
|
||||
<AppSelect
|
||||
v-model="vectorizeProviderID"
|
||||
:label="dialogs.vectorizeProvider"
|
||||
:options="providerOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<ProviderSettingsKeyField
|
||||
v-if="provider"
|
||||
v-model="keyDraft"
|
||||
:label="dialogs.apiKey"
|
||||
:saved="keyStatus === 'configured'"
|
||||
kind="api"
|
||||
:placeholder="keyStatus === 'configured' ? dialogs.keySavedReplace : provider.keyPlaceholder"
|
||||
:key-url="provider.keyURL"
|
||||
:key-url-label="dialogs.getAPIKeyGeneric"
|
||||
@change="saveCredential"
|
||||
@clear="clearCredential"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
159
tests/e2e/vectorize/basic.spec.ts
Normal file
159
tests/e2e/vectorize/basic.spec.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
const editor = useEditorSetupWithClear('/?test')
|
||||
const MOCK_SVG = readFileSync(
|
||||
join(process.cwd(), 'tests/fixtures/vectorize/euro_shield.recraft.svg'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
async function rightClickSelected(): Promise<void> {
|
||||
const box = expectDefined(await editor.canvas.canvas.boundingBox(), 'canvas bounds')
|
||||
const point = await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store || store.state.selectedIds.size !== 1) return null
|
||||
const node = store.graph.getNode([...store.state.selectedIds][0])
|
||||
if (!node) return null
|
||||
return {
|
||||
x: (node.x + node.width / 2) * store.state.zoom + store.state.panX,
|
||||
y: (node.y + node.height / 2) * store.state.zoom + store.state.panY
|
||||
}
|
||||
})
|
||||
const selectedPoint = expectDefined(point, 'selected node screen point')
|
||||
await editor.page.mouse.click(box.x + selectedPoint.x, box.y + selectedPoint.y, {
|
||||
button: 'right'
|
||||
})
|
||||
}
|
||||
|
||||
async function createImageNode(): Promise<string> {
|
||||
return editor.page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
|
||||
const imageCanvas = document.createElement('canvas')
|
||||
imageCanvas.width = 120
|
||||
imageCanvas.height = 80
|
||||
const context = imageCanvas.getContext('2d')
|
||||
if (!context) throw new Error('Cannot create image fixture canvas')
|
||||
context.fillStyle = '#4488cc'
|
||||
context.fillRect(0, 0, 120, 80)
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
imageCanvas.toBlob((result) => {
|
||||
if (result) resolve(result)
|
||||
else reject(new Error('toBlob failed'))
|
||||
}, 'image/png')
|
||||
})
|
||||
const hash = store.storeImage(new Uint8Array(await blob.arrayBuffer()))
|
||||
const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
|
||||
name: 'Vectorize Target',
|
||||
x: 180,
|
||||
y: 180,
|
||||
width: 120,
|
||||
height: 80,
|
||||
fills: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
imageHash: hash,
|
||||
imageScaleMode: 'FILL'
|
||||
}
|
||||
]
|
||||
})
|
||||
store.select([node.id])
|
||||
store.zoomToSelection()
|
||||
store.requestRender()
|
||||
return node.id
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await editor.page.route('**/external.api.recraft.ai/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ image: { url: 'https://cdn.recraft.ai/mock.svg' } })
|
||||
})
|
||||
})
|
||||
await editor.page.route('https://cdn.recraft.ai/mock.svg', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: MOCK_SVG })
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to vector appears only for a single image node', async () => {
|
||||
await createImageNode()
|
||||
await editor.canvas.waitForRender()
|
||||
await rightClickSelected()
|
||||
await expect(editor.page.getByTestId('context-vectorize')).toBeVisible()
|
||||
await editor.page.keyboard.press('Escape')
|
||||
|
||||
await editor.canvas.drawRect(400, 400, 60, 60)
|
||||
await rightClickSelected()
|
||||
await expect(editor.page.getByTestId('context-vectorize')).toHaveCount(0)
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
test('missing key opens Media settings and saves through the credential manager', async () => {
|
||||
const nodeId = await createImageNode()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
await rightClickSelected()
|
||||
await editor.page.getByTestId('context-vectorize').click()
|
||||
const section = editor.page.locator('[data-vectorize-settings]')
|
||||
await expect(section).toBeVisible()
|
||||
|
||||
await section.getByTestId('provider-settings-api-key').fill('test-recraft-key')
|
||||
await editor.page.getByTestId('app-settings-done').click()
|
||||
await expect(editor.page.getByTestId('app-settings-dialog')).toHaveCount(0)
|
||||
expect(
|
||||
await editor.page.evaluate(
|
||||
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type,
|
||||
nodeId
|
||||
)
|
||||
).toBe('RECTANGLE')
|
||||
})
|
||||
|
||||
test('vectorize replaces an image with editable vectors and undo restores it', async () => {
|
||||
const nodeId = await createImageNode()
|
||||
await editor.canvas.waitForRender()
|
||||
|
||||
await rightClickSelected()
|
||||
await editor.page.getByTestId('context-vectorize').click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
editor.page.evaluate(
|
||||
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type ?? null,
|
||||
nodeId
|
||||
)
|
||||
)
|
||||
.toBeNull()
|
||||
|
||||
const replacement = await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store || store.state.selectedIds.size !== 1) return null
|
||||
const frameId = [...store.state.selectedIds][0]
|
||||
const frame = store.graph.getNode(frameId)
|
||||
if (!frame) return null
|
||||
return {
|
||||
frameId,
|
||||
type: frame.type,
|
||||
childTypes: frame.childIds.map((id) => store.graph.getNode(id)?.type)
|
||||
}
|
||||
})
|
||||
expect(replacement?.type).toBe('FRAME')
|
||||
expect(replacement?.childTypes.filter((type) => type === 'VECTOR').length).toBeGreaterThan(1)
|
||||
|
||||
await editor.canvas.undo()
|
||||
await editor.canvas.waitForRender()
|
||||
expect(
|
||||
await editor.page.evaluate(
|
||||
(id) => window.openPencil?.getStore?.().graph.getNode(id)?.type,
|
||||
nodeId
|
||||
)
|
||||
).toBe('RECTANGLE')
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
66
tests/engine/app/editor/vectorize/providers.test.ts
Normal file
66
tests/engine/app/editor/vectorize/providers.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, spyOn, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
extractVectorizedSVGURL,
|
||||
getVectorizeProvider,
|
||||
validateVectorizedAssetURL
|
||||
} from '@/app/editor/vectorize/providers'
|
||||
import { appCredentialRefs } from '@/app/settings/credentials/persistence'
|
||||
import { credentialKey } from '@/app/settings/credentials/reference'
|
||||
|
||||
describe('vectorization providers', () => {
|
||||
test('extracts supported nested SVG response URLs', () => {
|
||||
expect(extractVectorizedSVGURL({ image: { url: 'https://cdn.recraft.ai/a.svg' } })).toBe(
|
||||
'https://cdn.recraft.ai/a.svg'
|
||||
)
|
||||
expect(extractVectorizedSVGURL({ data: [{ url: 'https://v3.fal.media/a.svg' }] })).toBe(
|
||||
'https://v3.fal.media/a.svg'
|
||||
)
|
||||
expect(extractVectorizedSVGURL({ data: { image: { url: 'https://fal.media/a.svg' } } })).toBe(
|
||||
'https://fal.media/a.svg'
|
||||
)
|
||||
})
|
||||
|
||||
test('allows only provider-owned HTTPS asset hosts', () => {
|
||||
expect(validateVectorizedAssetURL('recraft', 'https://cdn.recraft.ai/a.svg').hostname).toBe(
|
||||
'cdn.recraft.ai'
|
||||
)
|
||||
expect(validateVectorizedAssetURL('fal', 'https://v3.fal.media/a.svg').hostname).toBe(
|
||||
'v3.fal.media'
|
||||
)
|
||||
expect(() => validateVectorizedAssetURL('recraft', 'https://example.com/a.svg')).toThrow(
|
||||
'untrusted'
|
||||
)
|
||||
expect(() => validateVectorizedAssetURL('fal', 'http://fal.media/a.svg')).toThrow('untrusted')
|
||||
})
|
||||
|
||||
test('rejects redirects from trusted asset hosts to untrusted hosts', async () => {
|
||||
let requestCount = 0
|
||||
const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
requestCount += 1
|
||||
if (requestCount === 1) {
|
||||
return Response.json({ image: { url: 'https://cdn.recraft.ai/result.svg' } })
|
||||
}
|
||||
const response = new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
|
||||
headers: { 'content-type': 'image/svg+xml' }
|
||||
})
|
||||
Object.defineProperty(response, 'url', { value: 'https://example.com/result.svg' })
|
||||
return response
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getVectorizeProvider('recraft').vectorize(new Uint8Array([1, 2, 3]), 'secret')
|
||||
).rejects.toThrow('untrusted')
|
||||
expect(requestCount).toBe(2)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('includes vectorization keys in browser persistence changes', () => {
|
||||
const keys = appCredentialRefs().map(credentialKey)
|
||||
expect(keys).toContain('v1:vectorize-recraft:default:api-key')
|
||||
expect(keys).toContain('v1:vectorize-fal:default:api-key')
|
||||
})
|
||||
})
|
||||
96
tests/engine/editor/vectorize.test.ts
Normal file
96
tests/engine/editor/vectorize.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
import { svgToVectorPaths } from '@open-pencil/core/vector'
|
||||
|
||||
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
||||
|
||||
function vectorizedInset() {
|
||||
return expectDefined(
|
||||
svgToVectorPaths(
|
||||
`<svg viewBox="0 0 100 100"><path d="M25 10 H75 V90 H25 Z" fill="#336699"/></svg>`,
|
||||
{ width: 200, height: 100 }
|
||||
),
|
||||
'vectorized SVG'
|
||||
)
|
||||
}
|
||||
|
||||
describe('replaceNodeWithVectorFrame', () => {
|
||||
test('replaces an image node in place and restores it with undo', () => {
|
||||
const editor = createEditor()
|
||||
const page = expectDefined(editor.graph.getPages()[0], 'page')
|
||||
const source = editor.graph.createNode('RECTANGLE', page.id, {
|
||||
name: 'Photo',
|
||||
x: 40,
|
||||
y: 60,
|
||||
width: 200,
|
||||
height: 100,
|
||||
opacity: 0.75,
|
||||
cornerRadius: 12,
|
||||
effects: [
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: { r: 0, g: 0, b: 0, a: 0.3 },
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
spread: 0,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
]
|
||||
})
|
||||
editor.select([source.id])
|
||||
|
||||
const frameId = expectDefined(
|
||||
editor.replaceNodeWithVectorFrame(source.id, vectorizedInset()),
|
||||
'replacement frame'
|
||||
)
|
||||
const frame = getNodeOrThrow(editor.graph, frameId)
|
||||
expect(frame.type).toBe('FRAME')
|
||||
expect(frame).toMatchObject({
|
||||
x: 90,
|
||||
y: 70,
|
||||
width: 100,
|
||||
height: 80,
|
||||
opacity: 0.75,
|
||||
cornerRadius: 12,
|
||||
clipsContent: true
|
||||
})
|
||||
expect(frame.effects).toHaveLength(1)
|
||||
expect(frame.childIds).toHaveLength(1)
|
||||
expect(editor.graph.getNode(source.id)).toBeUndefined()
|
||||
|
||||
editor.undo.undo()
|
||||
expect(getNodeOrThrow(editor.graph, source.id).type).toBe('RECTANGLE')
|
||||
expect(editor.graph.getNode(frameId)).toBeUndefined()
|
||||
expect([...editor.state.selectedIds]).toEqual([source.id])
|
||||
|
||||
editor.undo.redo()
|
||||
expect(getNodeOrThrow(editor.graph, frameId).type).toBe('FRAME')
|
||||
expect(editor.graph.getNode(source.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('preserves the coordinate box and rotation of rotated images', () => {
|
||||
const editor = createEditor()
|
||||
const page = expectDefined(editor.graph.getPages()[0], 'page')
|
||||
const source = editor.graph.createNode('RECTANGLE', page.id, {
|
||||
x: 40,
|
||||
y: 60,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 30
|
||||
})
|
||||
|
||||
const frameId = expectDefined(
|
||||
editor.replaceNodeWithVectorFrame(source.id, vectorizedInset()),
|
||||
'replacement frame'
|
||||
)
|
||||
expect(getNodeOrThrow(editor.graph, frameId)).toMatchObject({
|
||||
x: 40,
|
||||
y: 60,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 30
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -164,11 +164,20 @@ describe('resolveVectorFramePlacement', () => {
|
|||
test('tightens the replacement frame around inset vector content', () => {
|
||||
expect(
|
||||
resolveVectorFramePlacement(
|
||||
{ x: 10, y: 20, width: 200, height: 100 },
|
||||
{ x: 10, y: 20, width: 200, height: 100, rotation: 0 },
|
||||
{ x: 25, y: 10, width: 150, height: 80 }
|
||||
)
|
||||
).toEqual({ x: 35, y: 30, width: 150, height: 80, offsetX: 25, offsetY: 10 })
|
||||
})
|
||||
|
||||
test('keeps rotated replacements in the original coordinate box', () => {
|
||||
expect(
|
||||
resolveVectorFramePlacement(
|
||||
{ x: 10, y: 20, width: 200, height: 100, rotation: 30 },
|
||||
{ x: 25, y: 10, width: 150, height: 80 }
|
||||
)
|
||||
).toEqual({ x: 10, y: 20, width: 200, height: 100, offsetX: 0, offsetY: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('svgToVectorPaths', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue