From b7b37a39dc094e263a4d7ad51c7fff049f5d376b Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 20 Aug 2026 03:09:28 -0500 Subject: [PATCH] feat: atomic SVG importing across adapters/entry points --- .markdownlint.json | 2 +- CHANGELOG.md | 2 + README.md | 16 +- ROADMAP.md | 5 + TODO.md | 10 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/menu.rs | 6 +- apps/desktop/src-tauri/src/session.rs | 44 +- apps/desktop/src/lib/fileops.ts | 11 + .../desktop-session.invoke.test.ts | 10 +- .../lib/persistence/desktop-session.test.ts | 21 +- .../src/lib/persistence/desktop-session.ts | 57 +- .../lib/persistence/desktop-workspace.test.ts | 6 + .../src/content/docs/internals/svg-import.md | 12 +- apps/web/src/content/docs/reference/cli.md | 8 + apps/web/src/lib/persistence/dexie.ts | 91 +-- crates/inkfinite-cli/src/cli/apply.rs | 10 +- crates/inkfinite-cli/src/cli/args.rs | 33 ++ crates/inkfinite-cli/src/cli/contract.rs | 5 +- crates/inkfinite-cli/src/cli/mod.rs | 8 +- crates/inkfinite-cli/src/cli/mutation.rs | 24 +- crates/inkfinite-cli/src/cli/svg.rs | 96 ++++ crates/inkfinite-cli/tests/cli.rs | 44 ++ crates/inkfinite-core/src/ipc/mod.rs | 1 + crates/inkfinite-core/src/lib.rs | 1 + crates/inkfinite-core/src/session.rs | 90 +++ crates/inkfinite-core/src/svg_transaction.rs | 340 +++++++++++ packages/core/src/interchange.ts | 13 +- packages/core/src/interchange/shared.ts | 2 +- packages/core/src/interchange/svg.ts | 529 ++++++++++++++++++ packages/core/src/persistence/desktop.ts | 5 + .../ui/src/lib/editor/canvas/Canvas.svelte | 75 ++- .../lib/editor/canvas/canvas-store.svelte.ts | 184 +++--- .../src/lib/editor/components/Toolbar.svelte | 112 ++-- .../editor/components/Toolbar.svelte.test.ts | 48 +- packages/ui/src/lib/editor/platform.ts | 43 +- .../src/lib/editor/svg-import.svelte.test.ts | 21 + 37 files changed, 1650 insertions(+), 336 deletions(-) create mode 100644 crates/inkfinite-cli/src/cli/svg.rs create mode 100644 crates/inkfinite-core/src/svg_transaction.rs create mode 100644 packages/core/src/interchange/svg.ts create mode 100644 packages/ui/src/lib/editor/svg-import.svelte.test.ts diff --git a/.markdownlint.json b/.markdownlint.json index 7738a33..cd4c187 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1 +1 @@ -{ "MD033": false, "MD013": false, "MD024": false, "MD010": false, "MD041": false } +{ "MD033": false, "MD013": false, "MD024": false, "MD010": false, "MD041": false, "MD007": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 66635bb..d892a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ - Native path bounds with Bézier extrema, Canvas rendering, fill and stroke hit testing, parent-relative transforms, deterministic SVG output, and shared valid/invalid geometry fixtures. +- SVG imports committed as one validated transaction from the desktop file menu, + browser file and drop entry points, and the CLI. ### Changed diff --git a/README.md b/README.md index 6f48433..3cde834 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ inkfinite apply architecture.inkfinite --transaction transaction.json --dry-run cat transaction.json | inkfinite apply architecture.inkfinite --transaction - --json ``` +Import static SVG content through the same transaction engine. Use `--dry-run` +to validate the source and target without saving it: + +```sh +inkfinite import svg architecture.inkfinite --input icon.svg --dry-run +inkfinite import svg architecture.inkfinite --input icon.svg +``` + The structured mutation commands build ordinary transactions. They generate shape and binding IDs when you omit them. Shapes can be selected by exact ID, name, or semantic role: @@ -81,10 +89,13 @@ inkfinite shape create architecture.inkfinite \ --kind rect --layer layer:architecture:1 \ --x 80 --y 120 --properties '{"width":240,"height":120}' \ --role architecture.service + inkfinite shape patch architecture.inkfinite --role architecture.service \ --patch '{"properties":{"width":280,"height":120}}' + inkfinite connect architecture.inkfinite --binding-id binding:api-db \ --source shape:arrow --target-role architecture.database + inkfinite layout align architecture.inkfinite \ --role architecture.service --alignment top ``` @@ -147,13 +158,16 @@ inkfinite app query --role architecture.service --detail --limit 20 --json inkfinite app focus inkfinite shape patch --app --role architecture.service \ --patch '@service-patch.json' --json + inkfinite app propose --transaction transaction.json --json inkfinite app proposal wait --proposal-id proposal:1 --json inkfinite app proposal renew --proposal-id proposal:1 --json inkfinite app render --output current.svg --transaction transaction.json \ --proposed-output proposed.png --json + inkfinite app ui --page page:1 --layer layer:1 --select shape:service \ --camera 640,360,1.25 --json + inkfinite app apply --transaction transaction.json --json ``` @@ -227,4 +241,4 @@ pnpm dev:ui ## License -Inkfinite is licensed under the [Apache-2.0](LICENSE). +Inkfinite is licensed under [Apache-2.0](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 9ca53e7..b163092 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,6 +31,11 @@ SVG import should map supported elements onto native Inkfinite concepts: The original SVG source is retained as an asset for provenance, future re-import, and fallback handling. +Desktop file-menu imports, browser file selection and drop, and the CLI use the +same SVG transaction builder. Desktop imports into the active layer; browser +imports create a new local board using the browser persistence adapter; CLI +imports can target a file or a live desktop session. + Unsupported SVG features should be reported explicitly and preserve a path to opaque fallback rather than silently disappearing. diff --git a/TODO.md b/TODO.md index 8a4a59b..ad5fc21 100644 --- a/TODO.md +++ b/TODO.md @@ -67,11 +67,11 @@ Completed work is in [CHANGELOG.md](CHANGELOG.md). #### Transactions and entry points -- [ ] Commit imports through one validated transaction -- [ ] Add desktop SVG file import -- [ ] Add web-app SVG file import - - [ ] Add drag-and-drop SVG import -- [ ] Add CLI SVG import +- [x] Commit imports through one validated transaction +- [x] Add desktop SVG file import +- [x] Add web-app SVG file import + - [x] Add drag-and-drop SVG import +- [x] Add CLI SVG import #### Import fixtures diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3124088..86cff44 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ pub fn run() { session::snapshot, session::update_context, session::commit, + session::import_svg, session::propose, session::accept_proposal, session::reject_proposal, diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs index 5e53f03..ee278bd 100644 --- a/apps/desktop/src-tauri/src/menu.rs +++ b/apps/desktop/src-tauri/src/menu.rs @@ -9,6 +9,7 @@ const NEW_BOARD: &str = "file.new-board"; const OPEN_BOARD: &str = "file.open-board"; const SAVE_BOARD_AS: &str = "file.save-board-as"; const IMPORT_CANVAS: &str = "file.import-canvas"; +const IMPORT_SVG: &str = "file.import-svg"; const EXPORT_EXCALIDRAW: &str = "file.export-excalidraw"; const EXPORT_JSON_CANVAS: &str = "file.export-json-canvas"; @@ -24,6 +25,7 @@ pub fn build(app: &AppHandle) -> tauri::Result> { let new_board = MenuItem::with_id(app, NEW_BOARD, "New Board", true, Some("CmdOrCtrl+N"))?; let open_board = MenuItem::with_id(app, OPEN_BOARD, "Open…", true, Some("CmdOrCtrl+O"))?; let import_canvas = MenuItem::with_id(app, IMPORT_CANVAS, "Import Editable Canvas…", true, None::<&str>)?; + let import_svg = MenuItem::with_id(app, IMPORT_SVG, "Import SVG…", true, None::<&str>)?; let save_board_as = MenuItem::with_id(app, SAVE_BOARD_AS, "Save As…", true, Some("CmdOrCtrl+Shift+S"))?; let export_excalidraw = MenuItem::with_id(app, EXPORT_EXCALIDRAW, "Export as Excalidraw…", true, None::<&str>)?; let export_json_canvas = MenuItem::with_id( @@ -34,10 +36,11 @@ pub fn build(app: &AppHandle) -> tauri::Result> { None::<&str>, )?; let separator = PredefinedMenuItem::separator(app)?; - let items: [&dyn IsMenuItem; 7] = [ + let items: [&dyn IsMenuItem; 8] = [ &new_board, &open_board, &import_canvas, + &import_svg, &save_board_as, &export_excalidraw, &export_json_canvas, @@ -60,6 +63,7 @@ pub fn handle_event(app: &AppHandle, event: tauri::menu::MenuEven OPEN_BOARD => "open", SAVE_BOARD_AS => "save-as", IMPORT_CANVAS => "import", + IMPORT_SVG => "import-svg", EXPORT_EXCALIDRAW => "export-excalidraw", EXPORT_JSON_CANVAS => "export-json-canvas", _ => return, diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index e7eb967..aece0f7 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -9,7 +9,7 @@ use inkfinite_core::proto::{ }; use inkfinite_core::session::{ EditorContextUpdate, SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, - SessionSync, + SessionSync, SvgImportCommit, }; use inkfinite_core::sync::SyncMessage; use inkfinite_core::{ActorId, ChangeHash, DocumentId}; @@ -187,6 +187,48 @@ pub fn commit( .map_err(to_protocol_error) } +/// Imports an SVG file into the active desktop layer through one transaction. +#[tauri::command] +pub async fn import_svg(state: State<'_, DesktopState>, session_id: String, path: String) -> Result { + let source_path = Path::new(&path); + if source_path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + != Some("svg".into()) + { + return Err(ProtocolError { + code: "svg_import_failed".into(), + message: "SVG import requires a .svg file".into(), + details: None, + }); + } + let metadata = fs::metadata(source_path).map_err(|error| ProtocolError { + code: "svg_import_failed".into(), + message: format!("could not inspect SVG file {path}: {error}"), + details: None, + })?; + if metadata.len() > inkfinite_core::svg_import::SVG_IMPORT_MAX_BYTES as u64 { + return Err(ProtocolError { + code: "svg_import_failed".into(), + message: format!( + "SVG input exceeds the {}-byte limit", + inkfinite_core::svg_import::SVG_IMPORT_MAX_BYTES + ), + details: None, + }); + } + let source = fs::read(source_path).map_err(|error| ProtocolError { + code: "svg_import_failed".into(), + message: format!("could not read SVG file {path}: {error}"), + details: None, + })?; + let source_name = source_path.file_name().and_then(|name| name.to_str()); + lock_service(&state)? + .import_svg(&SessionId(session_id), &source, source_name) + .map_err(to_protocol_error) +} + /// Validates and stores an agent transaction for desktop proposal review. #[tauri::command] pub fn propose(state: State<'_, DesktopState>, session_id: String, transaction: TransactionDraft) -> Result { diff --git a/apps/desktop/src/lib/fileops.ts b/apps/desktop/src/lib/fileops.ts index 01b8d59..ce3e73c 100644 --- a/apps/desktop/src/lib/fileops.ts +++ b/apps/desktop/src/lib/fileops.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import { open } from '@tauri-apps/plugin-dialog'; import { load } from '@tauri-apps/plugin-store'; import type { DesktopFileOps, DirectoryEntry, FileHandle } from '@inkfinite/core'; @@ -32,6 +33,15 @@ export function createDesktopFileOps(): DesktopFileOps { return invoke('pick_save_document', { defaultName: defaultName || 'Untitled.inkfinite' }); } + async function showSvgDialog(): Promise { + const selected = await open({ + multiple: false, + directory: false, + filters: [{ name: 'SVG files', extensions: ['svg'] }] + }); + return typeof selected === 'string' ? selected : null; + } + async function getRecentFiles(): Promise { const store = await getStore(); const recent = (await store.get(RECENT_FILES_KEY)) || []; @@ -98,6 +108,7 @@ export function createDesktopFileOps(): DesktopFileOps { return { showOpenDialog, showSaveDialog, + showSvgDialog, getRecentFiles, addRecentFile, removeRecentFile, diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts index 6cbc94e..4273e44 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts @@ -7,13 +7,8 @@ const tauri = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn(async () => () vi.mock('@tauri-apps/api/core', () => ({ invoke: tauri.invoke })); vi.mock('@tauri-apps/api/event', () => ({ listen: tauri.listen })); -import { - createDesktopSessionRepo, - type SessionCommit, - type SessionOpened, - type SessionSaved, - type SessionStatus -} from './desktop-session'; +import { createDesktopSessionRepo } from './desktop-session'; +import type { SessionCommit, SessionOpened, SessionSaved, SessionStatus } from './desktop-session'; function snapshot(documentId: string): DocumentSnapshot { const pageId = `page:${documentId}:1`; @@ -50,6 +45,7 @@ function fileOps() { const ops: DesktopFileOps = { showOpenDialog: async () => null, showSaveDialog: async () => savePath, + showSvgDialog: async () => null, getRecentFiles: async () => [], addRecentFile: async () => undefined, removeRecentFile: async () => undefined, diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 03eb671..aa33656 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -1,13 +1,13 @@ import { PageRecord, ShapeRecord, type BoardExport, type DesktopFileOps, type FileHandle } from '@inkfinite/core'; import type { ChangeHash, DocumentSnapshot, Proposal, TransactionDraft } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it } from 'vitest'; -import { - createDesktopSessionRepo, - type SessionApi, - type SessionCommit, - type SessionOpened, - type SessionSaved, - type SessionStatus +import { createDesktopSessionRepo } from '$lib/persistence/desktop-session'; +import type { + SessionApi, + SessionCommit, + SessionOpened, + SessionSaved, + SessionStatus } from '$lib/persistence/desktop-session'; type FakeSession = { status: SessionStatus; undo: DocumentSnapshot[]; redo: DocumentSnapshot[] }; @@ -160,6 +160,10 @@ function createFakeSessionApi() { return { commit: commitResult(args.transaction, next), status: statusFor(args.session_id, session) }; }, + async importSvg(_args: Parameters[0]) { + throw new Error('SVG import is not part of this fake session'); + }, + async propose(_args: Parameters[0]): Promise { throw new Error('Proposals are not part of this fake session'); }, @@ -324,6 +328,9 @@ function createFakeFileOps() { saveDialogCount += 1; return savePath; }, + async showSvgDialog() { + return null; + }, async getRecentFiles() { return [...recent]; }, diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 7cc5613..3aa2297 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -80,15 +80,10 @@ export type SyncMessage = { export type SyncDisposition = 'applied' | 'duplicate' | 'deferred' | 'quarantined'; /** Document patch and validation result returned by a peer merge. */ -export type SyncApplyResult = { - disposition: SyncDisposition; - adopted_messages: number; - heads: ChangeHash[]; - patch: CommitResult['patch']; - affected_ids: CommitResult['affected_ids']; - affected_regions: CommitResult['affected_regions']; - warnings: CommitResult['warnings']; -}; +export type SyncApplyResult = { disposition: SyncDisposition; adopted_messages: number; heads: ChangeHash[] } & Pick< + CommitResult, + 'patch' | 'affected_ids' | 'affected_regions' | 'warnings' +>; /** Result returned after creating or opening a desktop session. */ export type SessionOpened = { session_id: string; status: SessionStatus }; @@ -96,6 +91,9 @@ export type SessionOpened = { session_id: string; status: SessionStatus }; /** Result returned after committing, undoing, or redoing a transaction. */ export type SessionCommit = { commit: CommitResult; status: SessionStatus }; +/** Result returned after importing an SVG through the Rust session service. */ +export type SvgImportResult = { doc: LoadedDoc; warnings: string[]; omitted_image_count: number; shape_ids: string[] }; + /** Result returned after persisting a session. */ export type SessionSaved = { save: { path: string; heads: ChangeHash[] }; status: SessionStatus }; @@ -134,6 +132,16 @@ export interface SessionApi { occluded_regions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; + importSvg(args: { + session_id: string; + path: string; + }): Promise<{ + session: SessionCommit; + warnings: string[]; + omitted_image_count: number; + shape_ids: string[]; + source_asset_id: string; + }>; propose(args: { session_id: string; transaction: TransactionDraft }): Promise; acceptProposal(args: { session_id: string; @@ -187,6 +195,11 @@ function createSessionApi(): SessionApi { }), commit: (args) => invokeSession('commit', { sessionId: args.session_id, transaction: args.transaction }), + importSvg: (args) => + invokeSession>>('import_svg', { + sessionId: args.session_id, + path: args.path + }), propose: (args) => invokeSession('propose', { sessionId: args.session_id, transaction: args.transaction }), acceptProposal: (args) => @@ -248,7 +261,7 @@ function describeError(error: unknown): string { return String(error); } -function invokeSession(command: string, args: Record): Promise { +async function invokeSession(command: string, args: Record): Promise { return invoke(command, args).catch((error: unknown) => { const detail = describeError(error); const message = `${command} failed: ${detail}`; @@ -263,6 +276,7 @@ export type DesktopSessionRepo = PersistentDocRepo & { openDraft(): Promise<{ boardId: string; doc: LoadedDoc }>; isDraft(): boolean; getCurrentFile(): FileHandle | null; + importSvg(): Promise; openFromDialog(prepareToOpen?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }>; saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }>; getWorkspaceDir(): Promise; @@ -298,9 +312,9 @@ export type DesktopSessionRepo = PersistentDocRepo & { }; /** - * Creates the desktop repository adapter. Document bytes cross the Tauri - * command boundary only; this adapter keeps the editor projection in memory - * until the backend returns a committed snapshot. + * Creates the desktop repository adapter. Document bytes cross the Tauri command boundary only. + * + * This adapter keeps the editor projection in memory until the backend returns a committed snapshot. */ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: SessionApi } = {}): DesktopSessionRepo { const api = opts.api ?? createSessionApi(); @@ -521,6 +535,22 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return opened.status.snapshot.document_id; } + async function importSvg(): Promise { + if (!currentStatus || !currentDoc) throw new Error('No board loaded'); + const path = await fileOps.showSvgDialog(); + if (!path) return null; + const result = await api.importSvg({ session_id: currentStatus.session_id, path }); + updateStatus(result.session.status); + await saveCurrentSession(); + if (!currentDoc) throw new Error('SVG import did not return a document'); + return { + doc: currentDoc, + warnings: result.warnings, + omitted_image_count: result.omitted_image_count, + shape_ids: result.shape_ids + }; + } + async function renameBoard(boardId: string, name: string): Promise { await ensureBoardLoaded(boardId); if (!currentStatus || !currentFile || !currentBoard) throw new Error('No board loaded'); @@ -823,6 +853,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: exportBoard, importBoard, getCurrentFile: () => (currentIsDraft ? null : currentFile), + importSvg, openFromDialog, saveAs, getWorkspaceDir: () => fileOps.getWorkspaceDir(), diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index e5bd707..1e7d687 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -11,6 +11,9 @@ function createWorkspaceOps() { async showSaveDialog() { return '/workspace/new.inkfinite'; }, + async showSvgDialog() { + return null; + }, async getRecentFiles() { return []; }, @@ -64,6 +67,9 @@ describe('desktop workspace adapter', () => { commit: async () => { throw new Error('not used'); }, + importSvg: async () => { + throw new Error('not used'); + }, propose: async () => { throw new Error('not used'); }, diff --git a/apps/web/src/content/docs/internals/svg-import.md b/apps/web/src/content/docs/internals/svg-import.md index 444a9f7..cf06f18 100644 --- a/apps/web/src/content/docs/internals/svg-import.md +++ b/apps/web/src/content/docs/internals/svg-import.md @@ -8,8 +8,9 @@ order: 12 Inkfinite parses the supported static SVG subset in Rust and maps it to native shape properties. The importer does not retain an SVG-specific document model. -Its output is a normalized tree that can later be turned into one validated -Inkfinite transaction. +Its output is a normalized tree. `build_svg_import_transaction` turns that +result into one ordered transaction containing the source asset, extracted +assets, a root container, nested groups, and native shapes. ## Import boundary @@ -134,6 +135,13 @@ native document model does not yet have an SVG-backed shape. A future fallback must render only a sanitized, static projection of the retained source and keep the imported subtree movable as one object. +Desktop imports use the native Tauri dialog plugin to select a path, then Rust +reads, parses, and commits the file through the active session. The browser +adapter uses the supported element mapping for file selection and SVG drop, +then persists the imported board in one IndexedDB operation. The CLI accepts +`inkfinite import svg FILE --input ARTWORK.svg` and can validate the transaction +with `--dry-run` before saving. + Gradients, patterns, clip paths, masks, and filters are not evaluated. Stylesheet blocks, event-handler attributes, scripts, and SVG animation elements are ignored with warnings. External image URLs and other resource references are diff --git a/apps/web/src/content/docs/reference/cli.md b/apps/web/src/content/docs/reference/cli.md index bbd212b..a13686f 100644 --- a/apps/web/src/content/docs/reference/cli.md +++ b/apps/web/src/content/docs/reference/cli.md @@ -21,6 +21,7 @@ commands are: | `validate` | Load and validate a canonical document | | `shape`, `connect`, `layout` | Build a structured file or live desktop edit | | `apply` | Validate and apply a transaction draft from JSON | +| `import svg` | Import static SVG content into native shapes | | `render` | Write an SVG or PNG of a document or filtered view | | `app` | Inspect or work with a running desktop session | | `schema`, `capabilities` | Print machine-readable contracts for integrations | @@ -55,8 +56,15 @@ inkfinite shape patch architecture.inkfinite \ inkfinite apply architecture.inkfinite \ --transaction transaction.json \ --dry-run --json + +inkfinite import svg architecture.inkfinite \ + --input icon.svg --dry-run --json ``` +`import svg` creates the retained source asset, native group containers, and +supported shapes in one validated transaction. Use `--page` or `--layer` to +choose a target; otherwise the first page and layer receive the import. + File commands never prompt. Close the desktop editor before changing its file; a lock or stale-head error is a signal to inspect current state, not a reason to overwrite the file. diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index 873811c..fd67006 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,8 +1,8 @@ -import { - type DocPatch, - type InterchangeExport, - type PersistenceSink, - type PersistentDocRepo +import type { + DocPatch, + InterchangeExport, + PersistenceSink, + PersistentDocRepo } from '@inkfinite/core'; import { createStatusStore } from '@inkfinite/ui/editor'; import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui/editor'; @@ -113,45 +113,52 @@ export function createDexieSession( /** Creates browser-backed file selection and download operations. */ export function createBrowserInterchangeFiles() { - return { - pickImport(): Promise<{ name: string; contents: string } | null> { - return new Promise((resolve, reject) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.excalidraw,.canvas,application/json'; - input.hidden = true; - const finish = (value: { name: string; contents: string } | null) => { - input.remove(); - resolve(value); - }; - input.addEventListener('cancel', () => finish(null), { once: true }); - input.addEventListener( - 'change', - () => { - const file = input.files?.[0]; - if (!file) { - finish(null); - return; - } - if (file.size > 16 * 1024 * 1024) { + function pickTextFile(accept: string): Promise<{ name: string; contents: string } | null> { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.hidden = true; + const finish = (value: { name: string; contents: string } | null) => { + input.remove(); + resolve(value); + }; + input.addEventListener('cancel', () => finish(null), { once: true }); + input.addEventListener( + 'change', + () => { + const file = input.files?.[0]; + if (!file) { + finish(null); + return; + } + if (file.size > 16 * 1024 * 1024) { + input.remove(); + reject( + new Error('The selected file is larger than the 16 MB import limit.') + ); + return; + } + void file.text().then( + (contents) => finish({ name: file.name, contents }), + (error) => { input.remove(); - reject(new Error('The selected file is larger than the 16 MB import limit.')); - return; + reject( + new Error(`Failed to read the selected file: ${String(error)}`) + ); } - void file.text().then( - (contents) => finish({ name: file.name, contents }), - (error) => { - input.remove(); - reject(new Error(`Failed to read the selected file: ${String(error)}`)); - } - ); - }, - { once: true } - ); - document.body.appendChild(input); - input.click(); - }); - }, + ); + }, + { once: true } + ); + document.body.appendChild(input); + input.click(); + }); + } + + return { + pickImport: () => pickTextFile('.excalidraw,.canvas,application/json'), + pickSvg: () => pickTextFile('.svg,image/svg+xml'), async saveExport(file: InterchangeExport, defaultStem: string): Promise { const blob = new Blob([file.contents], { type: file.mimeType }); const url = URL.createObjectURL(blob); diff --git a/crates/inkfinite-cli/src/cli/apply.rs b/crates/inkfinite-cli/src/cli/apply.rs index ab1d972..6db110d 100644 --- a/crates/inkfinite-cli/src/cli/apply.rs +++ b/crates/inkfinite-cli/src/cli/apply.rs @@ -5,7 +5,15 @@ use super::{ApplyArgs, CliError, EXIT_INPUT, EXIT_INVALID, Path, Read, Result, T pub fn apply_transaction(args: &ApplyArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { let transaction = read_transaction(&args.transaction)?; let mut file = open_document(&args.path)?; - commit_mutation(&mut file, transaction, args.dry_run, None, json_output, stdout) + commit_mutation( + &mut file, + transaction, + args.dry_run, + None, + Vec::new(), + json_output, + stdout, + ) } /// Reads one transaction from a JSON file or standard input for file and live modes. diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index 0021f0f..6e08713 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -11,6 +11,7 @@ use super::{ArgGroup, Args, Bounds, CameraState, Parser, PathBuf, Subcommand, Va inkfinite new architecture.inkfinite inkfinite inspect architecture.inkfinite --json inkfinite apply architecture.inkfinite --transaction transaction.json --dry-run + inkfinite import svg architecture.inkfinite --input icon.svg inkfinite render architecture.inkfinite --output architecture.svg inkfinite app status --json @@ -78,6 +79,9 @@ pub enum Command { cat transaction.json | inkfinite apply architecture.inkfinite --transaction - --json ")] Apply(ApplyArgs), + /// Import external formats through the transaction engine. + #[command(subcommand)] + Import(ImportCommand), /// Create, patch, or delete a shape through the transaction engine. #[command(subcommand)] Shape(ShapeCommand), @@ -161,6 +165,35 @@ pub struct ApplyArgs { pub dry_run: bool, } +#[derive(Debug, Subcommand)] +pub enum ImportCommand { + /// Import a static SVG into a document or open desktop session. + #[command(after_help = "Examples: + + inkfinite import svg architecture.inkfinite --input icon.svg + inkfinite import svg architecture.inkfinite --input logo.svg --layer layer:architecture:1 --dry-run +")] + Svg(SvgImportArgs), +} + +#[derive(Debug, Args)] +pub struct SvgImportArgs { + /// Canonical .inkfinite document to change. Omit when using --app. + #[arg(value_name = "FILE")] + pub path: Option, + /// SVG file to import. + #[arg(long, value_name = "SVG_FILE")] + pub input: PathBuf, + /// Target page. Defaults to the active page or first page. + #[arg(long, value_name = "PAGE_ID")] + pub page: Option, + /// Target layer. Defaults to the active layer or first layer on the page. + #[arg(long, value_name = "LAYER_ID")] + pub layer: Option, + #[command(flatten)] + pub mutation: MutationOptions, +} + #[derive(Debug, Subcommand)] pub enum ShapeCommand { /// Create a shape in a layer or container. diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index e6c5f9a..b1e3df5 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -36,7 +36,7 @@ pub fn print_schema(kind: SchemaKind, stdout: &mut dyn Write) -> Result<(), CliE pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<(), CliError> { let capabilities = json!({ - "commands": ["new", "inspect", "query", "app", "validate", "apply", "shape", "connect", "layout", "render", "schema", "capabilities"], + "commands": ["new", "inspect", "query", "app", "validate", "apply", "import", "shape", "connect", "layout", "render", "schema", "capabilities"], "exit_codes": { "conflict": EXIT_CONFLICT, "input": EXIT_INPUT, @@ -64,6 +64,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( }, "mutation_commands": { "apply": ["--transaction", "--dry-run"], + "import svg": ["--input", "--page", "--layer", "--dry-run", "--transaction-out", "--app"], "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--dry-run", "--transaction-out", "--app"], "layout": ["align", "distribute"], "shape": ["create", "patch", "delete", "kinds", "describe"], @@ -90,7 +91,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( .map_err(map_output_error)?; writeln!( stdout, - "Commands: new, inspect, query, app, validate, apply, shape, connect, layout, render, schema, capabilities" + "Commands: new, inspect, query, app, validate, apply, import, shape, connect, layout, render, schema, capabilities" ) .map_err(map_output_error)?; writeln!( diff --git a/crates/inkfinite-cli/src/cli/mod.rs b/crates/inkfinite-cli/src/cli/mod.rs index fa48f10..6b43254 100644 --- a/crates/inkfinite-cli/src/cli/mod.rs +++ b/crates/inkfinite-cli/src/cli/mod.rs @@ -113,11 +113,12 @@ mod mutation; mod render; mod shape; mod support; +mod svg; use args::{ - AlignmentArg, ApplyArgs, AxisArg, ConnectArgs, FileOutputArgs, InspectArgs, LayoutCommand, LayoutSelectionArgs, - MutationOptions, NewArgs, PlacementArg, QueryArgs, RenderArgs, SchemaKind, ShapeCommand, ShapeCreateArgs, - ShapeDeleteArgs, ShapeDescribeArgs, ShapePatchArgs, + AlignmentArg, ApplyArgs, AxisArg, ConnectArgs, FileOutputArgs, ImportCommand, InspectArgs, LayoutCommand, + LayoutSelectionArgs, MutationOptions, NewArgs, PlacementArg, QueryArgs, RenderArgs, SchemaKind, ShapeCommand, + ShapeCreateArgs, ShapeDeleteArgs, ShapeDescribeArgs, ShapePatchArgs, SvgImportArgs, }; use support::parse_bounds; @@ -131,6 +132,7 @@ pub fn run(command: Command, json_output: bool, stdout: &mut dyn Write) -> Resul Command::App(command) => app::run_app_command(command, json_output, stdout), Command::Validate(args) => document::validate_file(&args, json_output, stdout), Command::Apply(args) => apply::apply_transaction(&args, json_output, stdout), + Command::Import(command) => svg::run_import_command(command, json_output, stdout), Command::Shape(command) => shape::run_shape_command(command, json_output, stdout), Command::Connect(args) => connect::connect_shapes(args, json_output, stdout), Command::Layout(command) => layout::run_layout_command(command, json_output, stdout), diff --git a/crates/inkfinite-cli/src/cli/mutation.rs b/crates/inkfinite-cli/src/cli/mutation.rs index 1c7f176..dffa857 100644 --- a/crates/inkfinite-cli/src/cli/mutation.rs +++ b/crates/inkfinite-cli/src/cli/mutation.rs @@ -42,6 +42,14 @@ impl StructuredMutationTarget { Ok(Self::File(Box::new(super::support::open_document(path)?))) } + /// Returns the actor that owns the target mutation stream. + pub fn actor_id(&self) -> inkfinite_core::ActorId { + match self { + Self::File(file) => file.actor_id().clone(), + Self::App { status, .. } => status.actor_id.clone(), + } + } + /// Returns the document state used for selectors and generated IDs. pub fn snapshot(&mut self) -> Result { match self { @@ -82,6 +90,14 @@ impl StructuredMutationTarget { /// Applies a file mutation or submits a live edit using the desktop access mode. pub fn finish( self, transaction: TransactionDraft, options: &MutationOptions, json_output: bool, stdout: &mut dyn Write, + ) -> Result<()> { + self.finish_with_warnings(transaction, options, json_output, stdout, Vec::new()) + } + + /// Applies a mutation while retaining non-fatal import diagnostics in file-mode output. + pub fn finish_with_warnings( + self, transaction: TransactionDraft, options: &MutationOptions, json_output: bool, stdout: &mut dyn Write, + warnings: Vec, ) -> Result<()> { match self { Self::File(mut file) => commit_mutation( @@ -89,6 +105,7 @@ impl StructuredMutationTarget { transaction, options.dry_run, options.transaction_out.as_deref(), + warnings, json_output, stdout, ), @@ -115,7 +132,7 @@ struct MutationResult { pub fn commit_mutation( file: &mut DocumentFile, transaction: TransactionDraft, dry_run: bool, transaction_output: Option<&Path>, - json_output: bool, stdout: &mut dyn Write, + warnings: Vec, json_output: bool, stdout: &mut dyn Write, ) -> Result<()> { let transaction_json = transaction_output .map(|_| serde_json::to_vec_pretty(&transaction)) @@ -173,7 +190,7 @@ pub fn commit_mutation( updated: commit.patch.changed, deleted: commit.patch.deleted, repairs: commit.warnings, - warnings: Vec::new(), + warnings, dry_run: effective_dry_run, transaction_output: transaction_output.map(portable_path), }; @@ -188,6 +205,9 @@ pub fn commit_mutation( writeln!(stdout, "Created: {}", result.created.len()).map_err(map_output_error)?; writeln!(stdout, "Updated: {}", result.updated.len()).map_err(map_output_error)?; writeln!(stdout, "Deleted: {}", result.deleted.len()).map_err(map_output_error)?; + for warning in &result.warnings { + writeln!(stdout, "Warning: {warning}").map_err(map_output_error)?; + } write_heads(stdout, &result.current_heads) } } diff --git a/crates/inkfinite-cli/src/cli/svg.rs b/crates/inkfinite-cli/src/cli/svg.rs new file mode 100644 index 0000000..bd17d05 --- /dev/null +++ b/crates/inkfinite-cli/src/cli/svg.rs @@ -0,0 +1,96 @@ +use super::mutation::StructuredMutationTarget; +use super::support::map_output_error; +use super::{ + CliError, EXIT_INPUT, EXIT_INVALID, ImportCommand, Origin, PageId, Result, SvgImportArgs, Timestamp, TransactionId, + Write, anyhow, fs, +}; +use inkfinite_core::svg_import::import_svg; +use inkfinite_core::svg_transaction::{SvgImportTransactionOptions, build_svg_import_transaction}; + +/// Runs external SVG import commands. +pub fn run_import_command(command: ImportCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + match command { + ImportCommand::Svg(args) => import_svg_file(args, json_output, stdout), + } +} + +fn import_svg_file(args: SvgImportArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let source_path = &args.input; + if source_path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + != Some("svg".into()) + { + return Err( + CliError::new(EXIT_INPUT, anyhow!("SVG input must use a .svg extension")).with_code("invalid_svg_input"), + ); + } + let source = fs::read(source_path).map_err(|error| { + CliError::new(EXIT_INPUT, error).context(format!("could not read SVG input {}", source_path.display())) + })?; + let import = + import_svg(&source).map_err(|error| CliError::new(EXIT_INVALID, error).context("could not parse SVG input"))?; + + let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; + let page_id = args + .page + .map(PageId::from) + .or_else(|| snapshot.document.page_ids.first().cloned()) + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("document has no page for SVG import")))?; + let page = snapshot + .document + .pages + .get(&page_id) + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("page {page_id} does not exist")))?; + let layer_id = args + .layer + .map(inkfinite_core::LayerId::from) + .or_else(|| page.layer_ids.first().cloned()) + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("page {page_id} has no layer for SVG import")))?; + let actor_id = target.actor_id(); + let source_name = source_path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned); + let transaction = build_svg_import_transaction( + &snapshot, + &import, + SvgImportTransactionOptions { + actor_id, + origin: Origin::Agent, + page_id, + layer_id, + transaction_id: TransactionId(format!( + "transaction:svg-import:{}", + import.source_asset.digest.replace(':', "-") + )), + description: source_name + .as_deref() + .map(|name| format!("Import SVG {name}")) + .unwrap_or_else(|| "Import SVG".into()), + source_name, + timestamp: Timestamp(0), + }, + ) + .map_err(|error| CliError::new(EXIT_INVALID, error).context("could not build SVG import transaction"))?; + let warnings = import.warnings.iter().map(ToString::to_string).collect::>(); + let omitted_image_count = transaction.omitted_image_count; + let shape_count = transaction.shape_ids.len(); + let asset_count = transaction.asset_ids.len(); + target.finish_with_warnings(transaction.transaction, &args.mutation, json_output, stdout, warnings)?; + + if !json_output { + if omitted_image_count > 0 { + writeln!( + stdout, + "Warning: omitted {omitted_image_count} embedded image node(s); native image support is pending" + ) + .map_err(map_output_error)?; + } + writeln!(stdout, "Imported shapes: {shape_count}").map_err(map_output_error)?; + writeln!(stdout, "Imported assets: {asset_count}").map_err(map_output_error)?; + } + Ok(()) +} diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index 0415ffc..2924656 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -116,6 +116,50 @@ fn closed_file_workflow_has_stable_human_and_json_output() { assert!(stdout.contains("Pages: 1")); } +#[test] +fn svg_import_is_one_atomic_transaction_with_dry_run_support() { + let temporary = TestDirectory::new("svg-import"); + let document_path = temporary.path.join("import.inkfinite"); + let svg_path = temporary.path.join("icon.svg"); + fs::write( + &svg_path, + r#""#, + ) + .unwrap(); + assert_success(&run(["new", path(&document_path), "--json"])); + let before = fs::read(&document_path).unwrap(); + + let dry_run = run([ + "import", + "svg", + path(&document_path), + "--input", + path(&svg_path), + "--dry-run", + "--json", + ]); + assert_success(&dry_run); + let dry_run_json = parse_stdout(&dry_run); + assert_eq!(dry_run_json["dry_run"], true); + assert_eq!(dry_run_json["created"].as_array().unwrap().len(), 5); + assert_eq!(fs::read(&document_path).unwrap(), before); + + let imported = run([ + "import", + "svg", + path(&document_path), + "--input", + path(&svg_path), + "--json", + ]); + assert_success(&imported); + let imported_json = parse_stdout(&imported); + assert_eq!(imported_json["warnings"].as_array().unwrap().len(), 0); + let summary = parse_stdout(&run(["inspect", path(&document_path), "--summary", "--json"])); + assert_eq!(summary["counts"]["assets"], 1); + assert_eq!(summary["counts"]["shapes"], 4); +} + #[test] fn query_forwards_semantic_hierarchy_kind_and_bounds_filters() { let temporary = TestDirectory::new("filters"); diff --git a/crates/inkfinite-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs index 7aa1301..dcf5612 100644 --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -699,6 +699,7 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { SessionError::Engine(EngineError::Precondition(_)) => "precondition_failed", SessionError::Engine(EngineError::Permission(_)) => "permission_denied", SessionError::Engine(_) => "document_engine_error", + SessionError::SvgImport(_) => "svg_import_failed", }; let details = match error { SessionError::ProposalStale { proposal, .. } => serde_json::to_value(proposal).ok(), diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 506e401..30838a0 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod proto; pub mod render; pub mod session; pub mod svg_import; +pub mod svg_transaction; pub mod sync; use std::collections::BTreeMap; diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index b25cc0d..acb056e 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -22,6 +22,8 @@ use crate::proto::{ Warning, }; use crate::render::{SvgRenderError, SvgRenderOptions, render_svg}; +use crate::svg_import::import_svg; +use crate::svg_transaction::{SvgImportTransactionOptions, build_svg_import_transaction}; use crate::sync::{PeerSyncStatus, SyncMessage}; use crate::{ ActorId, ChangeHash, Document, DocumentId, DocumentSnapshot, LayerId, Origin, PageId, ShapeId, Timestamp, @@ -99,6 +101,21 @@ pub struct SessionCommit { pub status: SessionStatus, } +/// Result returned after importing one SVG through the desktop session. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SvgImportCommit { + /// Commit and session state returned by the shared transaction engine. + pub session: SessionCommit, + /// Warnings emitted while unsupported SVG content was omitted. + pub warnings: Vec, + /// Number of embedded image nodes omitted until a native image kind exists. + pub omitted_image_count: usize, + /// Native shape IDs created by the import. + pub shape_ids: Vec, + /// Retained source asset ID. + pub source_asset_id: crate::AssetId, +} + /// Review state exposed to an agent without granting review authority. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -304,6 +321,9 @@ pub enum SessionError { /// The transaction engine rejected a history or validation operation. #[error(transparent)] Engine(#[from] EngineError), + /// SVG parsing or transaction construction rejected the import. + #[error("SVG import failed: {0}")] + SvgImport(String), } struct DocumentSession { @@ -547,6 +567,76 @@ impl SessionService { Ok(SessionCommit { commit, status }) } + /// Parses and commits one SVG into the session's active layer. + /// + /// The source is parsed before the session changes. The resulting assets, + /// root container, groups, and native shapes then enter the document as one + /// actor-owned transaction. + /// + /// # Errors + /// + /// Returns a typed session, SVG parser, target, transaction, or persistence + /// error. No document state changes when parsing or transaction construction + /// fails. + pub fn import_svg( + &mut self, session_id: &SessionId, source: &[u8], source_name: Option<&str>, + ) -> Result { + let import = import_svg(source).map_err(|error| SessionError::SvgImport(error.to_string()))?; + let session = self.session_mut(session_id)?; + let snapshot = session.file.snapshot()?; + let page_id = session + .page_id + .clone() + .or_else(|| snapshot.document.page_ids.first().cloned()) + .ok_or_else(|| SessionError::SvgImport("document has no page for SVG import".into()))?; + let page = snapshot + .document + .pages + .get(&page_id) + .ok_or_else(|| SessionError::SvgImport(format!("page {page_id} does not exist")))?; + let layer_id = session + .active_layer_id + .clone() + .filter(|layer_id| page.layer_ids.contains(layer_id)) + .or_else(|| page.layer_ids.first().cloned()) + .ok_or_else(|| SessionError::SvgImport(format!("page {page_id} has no import layer")))?; + let source_label = source_name.map(str::to_owned); + let transaction = build_svg_import_transaction( + &snapshot, + &import, + SvgImportTransactionOptions { + actor_id: session.file.actor_id().clone(), + origin: Origin::Human, + page_id, + layer_id, + transaction_id: crate::proto::TransactionId(format!( + "transaction:svg-import:{}", + import.source_asset.digest.replace(':', "-") + )), + description: source_label + .as_deref() + .map(|name| format!("Import SVG {name}")) + .unwrap_or_else(|| "Import SVG".into()), + source_name: source_label, + timestamp: timestamp_now(), + }, + ) + .map_err(|error| SessionError::SvgImport(error.to_string()))?; + let shape_ids = transaction.shape_ids; + let omitted_image_count = transaction.omitted_image_count; + let source_asset_id = import.source_asset.id; + let warnings = import.warnings.iter().map(ToString::to_string).collect(); + let commit = session.file.commit(transaction.transaction)?; + let status = session.status(session_id)?; + Ok(SvgImportCommit { + session: SessionCommit { commit, status }, + warnings, + omitted_image_count, + shape_ids, + source_asset_id, + }) + } + /// Validates and stores one agent transaction for explicit desktop review. /// /// The document, CRDT heads, and history remain unchanged. diff --git a/crates/inkfinite-core/src/svg_transaction.rs b/crates/inkfinite-core/src/svg_transaction.rs new file mode 100644 index 0000000..e27570f --- /dev/null +++ b/crates/inkfinite-core/src/svg_transaction.rs @@ -0,0 +1,340 @@ +//! Transaction construction for importing normalized SVG content. +//! +//! The parser deliberately stops before document mutation. This module turns +//! its normalized tree into one ordered transaction so file, desktop, and CLI +//! callers share the same validation and history path. + +use std::collections::BTreeSet; + +use thiserror::Error; + +use crate::proto::{Operation, TransactionDraft, TransactionId}; +use crate::svg_import::{SvgAsset, SvgGroup, SvgImport, SvgImportNode}; +use crate::{ + ActorId, AssetId, AssetRecord, AssetSource, DocumentSnapshot, LayerId, Origin, PageId, Provenance, RecordVersion, + SemanticMetadata, ShapeId, ShapeParent, ShapeRecord, SiblingAnchor, Timestamp, +}; + +/// Inputs that identify the document location and history metadata for an SVG import. +#[derive(Clone, Debug)] +pub struct SvgImportTransactionOptions { + /// Actor that owns the transaction. + pub actor_id: ActorId, + /// Provenance origin for the created records. + pub origin: Origin, + /// Page that owns the target layer. + pub page_id: PageId, + /// Layer that receives the imported root container. + pub layer_id: LayerId, + /// Stable transaction identifier. + pub transaction_id: TransactionId, + /// Human-readable transaction description. + pub description: String, + /// Source filename retained in provenance and used for the root name. + pub source_name: Option, + /// Client-recorded transaction time. + pub timestamp: Timestamp, +} + +/// One validated-ready SVG import transaction and its import diagnostics. +#[derive(Clone, Debug, PartialEq)] +pub struct SvgImportTransaction { + /// The single transaction containing source assets and native shapes. + pub transaction: TransactionDraft, + /// Native shape IDs created by the transaction, in creation order. + pub shape_ids: Vec, + /// Asset IDs included by the transaction, including retained source data. + pub asset_ids: Vec, + /// Number of image nodes omitted because the native registry has no image kind. + pub omitted_image_count: usize, +} + +/// A target or asset conflict found while building an SVG transaction. +#[derive(Clone, Debug, Error, PartialEq)] +pub enum SvgImportTransactionError { + /// The selected page does not exist. + #[error("SVG import target page {0} does not exist")] + MissingPage(PageId), + /// The selected layer does not exist. + #[error("SVG import target layer {0} does not exist")] + MissingLayer(LayerId), + /// The selected layer belongs to another page. + #[error("SVG import target layer {layer} does not belong to page {page}")] + LayerPageMismatch { page: PageId, layer: LayerId }, + /// The document already contains a different asset at the imported ID. + #[error("document already contains a different asset at {0}")] + AssetConflict(AssetId), +} + +/// Builds one transaction from a normalized SVG import and a current snapshot. +/// +/// The root SVG becomes a container on `layer_id`. Nested groups become child +/// containers, while supported elements become ordinary native shapes. Assets +/// are created before the shape tree and existing identical content-addressed +/// assets are reused. +/// +/// # Errors +/// +/// Returns [`SvgImportTransactionError`] when the target page or layer is +/// missing, or an existing asset ID has different contents. +pub fn build_svg_import_transaction( + snapshot: &DocumentSnapshot, import: &SvgImport, options: SvgImportTransactionOptions, +) -> Result { + let page = snapshot + .document + .pages + .get(&options.page_id) + .ok_or_else(|| SvgImportTransactionError::MissingPage(options.page_id.clone()))?; + let layer = snapshot + .document + .layers + .get(&options.layer_id) + .ok_or_else(|| SvgImportTransactionError::MissingLayer(options.layer_id.clone()))?; + if layer.page_id != page.id { + return Err(SvgImportTransactionError::LayerPageMismatch { page: page.id.clone(), layer: layer.id.clone() }); + } + + let mut operations = Vec::new(); + let mut asset_ids = Vec::new(); + for asset in std::iter::once(&import.source_asset).chain(import.assets.iter()) { + if let Some(existing) = snapshot.document.assets.get(&asset.id) { + if !same_asset(existing, asset) { + return Err(SvgImportTransactionError::AssetConflict(asset.id.clone())); + } + asset_ids.push(asset.id.clone()); + continue; + } + operations.push(Operation::CreateAsset { asset: asset_record(asset, &options) }); + asset_ids.push(asset.id.clone()); + } + + let mut ids = ShapeIdAllocator::new(&snapshot.document.shapes, &import.source_asset.id); + let root_id = ids.next(); + let root_name = options + .source_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + .or_else(|| import.source_asset.name.strip_prefix("source-").map(str::to_owned)) + .unwrap_or_else(|| "Imported SVG".into()); + operations.push(Operation::CreateShape { + shape: group_record( + root_id.clone(), + ShapeParent::Layer(options.layer_id.clone()), + &import.root, + root_name, + &options, + ), + anchor: SiblingAnchor::Last, + }); + + let mut shape_ids = vec![root_id.clone()]; + let mut omitted_image_count = 0; + append_group( + &mut operations, + &mut shape_ids, + &mut omitted_image_count, + &mut ids, + &import.root, + &root_id, + &options, + ); + + Ok(SvgImportTransaction { + transaction: TransactionDraft { + id: options.transaction_id, + actor_id: options.actor_id, + origin: options.origin, + base_heads: snapshot.heads.clone(), + description: options.description, + operations, + timestamp: options.timestamp, + }, + shape_ids, + asset_ids, + omitted_image_count, + }) +} + +fn append_group( + operations: &mut Vec, shape_ids: &mut Vec, omitted_image_count: &mut usize, + ids: &mut ShapeIdAllocator, group: &SvgGroup, parent_id: &ShapeId, options: &SvgImportTransactionOptions, +) { + for node in &group.children { + match node { + SvgImportNode::Group(child) => { + let id = ids.next(); + let name = child + .source_id + .clone() + .unwrap_or_else(|| format!("Imported group {}", shape_ids.len())); + operations.push(Operation::CreateShape { + shape: group_record(id.clone(), ShapeParent::Shape(parent_id.clone()), child, name, options), + anchor: SiblingAnchor::Last, + }); + shape_ids.push(id.clone()); + append_group(operations, shape_ids, omitted_image_count, ids, child, &id, options); + } + SvgImportNode::Shape(shape) => { + let id = ids.next(); + operations.push(Operation::CreateShape { + shape: ShapeRecord { + id: id.clone(), + kind: shape.kind.clone(), + parent: ShapeParent::Shape(parent_id.clone()), + transform: shape.transform, + child_ids: Vec::new(), + layout: None, + properties: shape.properties.clone(), + metadata: metadata( + shape.source_id.clone().unwrap_or_else(|| "Imported SVG shape".into()), + options, + ), + style: shape.style, + version: RecordVersion(1), + }, + anchor: SiblingAnchor::Last, + }); + shape_ids.push(id); + } + SvgImportNode::Image(_) => *omitted_image_count += 1, + } + } +} + +fn group_record( + id: ShapeId, parent: ShapeParent, group: &SvgGroup, name: String, options: &SvgImportTransactionOptions, +) -> ShapeRecord { + ShapeRecord { + id, + kind: crate::ShapeKind::from(crate::CONTAINER_KIND), + parent, + transform: group.transform, + child_ids: Vec::new(), + layout: None, + properties: group.properties.clone(), + metadata: metadata(name, options), + style: group.style, + version: RecordVersion(1), + } +} + +fn metadata(name: String, options: &SvgImportTransactionOptions) -> SemanticMetadata { + SemanticMetadata { + name: Some(name), + role: None, + description: Some("Imported from SVG".into()), + tags: vec!["svg-import".into()], + locked: false, + agent_editable: true, + provenance: Provenance { + actor_id: options.actor_id.clone(), + origin: options.origin.clone(), + timestamp: options.timestamp, + source: options.source_name.clone(), + }, + } +} + +fn asset_record(asset: &SvgAsset, options: &SvgImportTransactionOptions) -> AssetRecord { + AssetRecord { + id: asset.id.clone(), + name: asset.name.clone(), + media_type: asset.media_type.clone(), + digest: asset.digest.clone(), + source: AssetSource::Embedded { bytes: asset.bytes.clone() }, + provenance: Provenance { + actor_id: options.actor_id.clone(), + origin: options.origin.clone(), + timestamp: options.timestamp, + source: options.source_name.clone(), + }, + version: RecordVersion(1), + } +} + +fn same_asset(existing: &AssetRecord, imported: &SvgAsset) -> bool { + existing.media_type == imported.media_type + && existing.digest == imported.digest + && matches!(&existing.source, AssetSource::Embedded { bytes } if bytes == &imported.bytes) +} + +struct ShapeIdAllocator { + existing: BTreeSet, + prefix: String, + next_index: usize, +} + +impl ShapeIdAllocator { + fn new(existing: &std::collections::BTreeMap, source_id: &AssetId) -> Self { + Self { + existing: existing.keys().cloned().collect(), + prefix: format!("shape:svg:{}", source_id.as_str().trim_start_matches("asset:")), + next_index: 0, + } + } + + fn next(&mut self) -> ShapeId { + loop { + self.next_index += 1; + let candidate = ShapeId::from(format!("{}:{}", self.prefix, self.next_index)); + if self.existing.insert(candidate.clone()) { + return candidate; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::svg_import::import_svg; + use crate::{DocumentId, Origin, Timestamp, blank_document}; + + fn snapshot() -> DocumentSnapshot { + let document_id = DocumentId::from("document:svg"); + let document = blank_document(&document_id, None); + DocumentSnapshot { + format: crate::FormatId::from(crate::INKFINITE_FORMAT_ID), + format_version: crate::INKFINITE_FORMAT_VERSION, + document_id, + heads: vec![crate::ChangeHash::from("hash:one")], + document, + } + } + + #[test] + fn builds_one_transaction_for_nested_svg_content_and_source_asset() { + let source = r#""#; + let import = import_svg(source).expect("SVG should import"); + let current = snapshot(); + let page_id = current.document.page_ids[0].clone(); + let layer_id = current.document.pages[&page_id].layer_ids[0].clone(); + let transaction = build_svg_import_transaction( + ¤t, + &import, + SvgImportTransactionOptions { + actor_id: ActorId::from("actor:test"), + origin: Origin::Human, + page_id, + layer_id, + transaction_id: TransactionId("transaction:svg".into()), + description: "Import SVG".into(), + source_name: Some("icon.svg".into()), + timestamp: Timestamp(1), + }, + ) + .expect("transaction should build"); + + assert_eq!(transaction.transaction.operations.len(), 4); + assert_eq!(transaction.shape_ids.len(), 3); + assert_eq!(transaction.asset_ids.len(), 1); + assert!( + transaction + .transaction + .operations + .iter() + .all(|operation| matches!(operation, Operation::CreateAsset { .. } | Operation::CreateShape { .. })) + ); + } +} diff --git a/packages/core/src/interchange.ts b/packages/core/src/interchange.ts index 94c417c..4c62c2e 100644 --- a/packages/core/src/interchange.ts +++ b/packages/core/src/interchange.ts @@ -1,16 +1,24 @@ import type { BoardExport } from './persistence/document'; import { exportExcalidraw, importExcalidraw } from './interchange/excalidraw'; import { exportJsonCanvas, importJsonCanvas } from './interchange/json-canvas'; +import { importSvg } from './interchange/svg'; import { object } from './interchange/shared'; /** External editable document formats supported by Inkfinite. */ export type InterchangeFormat = 'excalidraw' | 'json-canvas'; +/** Formats accepted by the import boundary. */ +export type InterchangeImportFormat = InterchangeFormat | 'svg'; + /** A non-fatal loss or compatibility decision made during conversion. */ export type InterchangeWarning = { code: string; message: string; count: number }; /** A converted document and the losses encountered while reading it. */ -export type InterchangeImport = { format: InterchangeFormat; snapshot: BoardExport; warnings: InterchangeWarning[] }; +export type InterchangeImport = { + format: InterchangeImportFormat; + snapshot: BoardExport; + warnings: InterchangeWarning[]; +}; /** Serialized external content and the losses encountered while writing it. */ export type InterchangeExport = { @@ -28,6 +36,9 @@ export function importInterchange(contents: string, fileName: string): Interchan if (new TextEncoder().encode(contents).byteLength > MAX_IMPORT_BYTES) { throw new Error('The selected file is larger than the 16 MB import limit.'); } + if (fileName.toLowerCase().endsWith('.svg') || contents.trimStart().startsWith('; + shapeIndex: number; +}; + +const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; +const DEFAULT_STYLE: SvgStyle = { + fill: '#000000', + stroke: 'none', + strokeWidth: 1, + fillRule: 'nonzero', + opacity: 1, + fillOpacity: 1, + strokeOpacity: 1, + fontSize: 16, + fontFamily: 'sans-serif' +}; + +/** Imports the supported static SVG subset into the browser document model. */ +export function importSvg(root: string, fileName: string): InterchangeImport { + if (new TextEncoder().encode(root).byteLength > 16 * 1024 * 1024) { + throw new Error('The selected file is larger than the 16 MB import limit.'); + } + if (typeof DOMParser === 'undefined') throw new Error('SVG import is only available in a browser.'); + const xml = new DOMParser().parseFromString(root, 'image/svg+xml'); + if (xml.querySelector('parsererror')) throw new Error('The selected SVG is malformed.'); + const svg = xml.documentElement; + if (svg.localName !== 'svg') throw new Error('The selected file does not have an root.'); + + const { snapshot, pageId, layerId } = blankSnapshot(fileName); + const warnings = new WarningCollector(); + warnings.add('svg-source-asset', 'The browser document model does not retain the original SVG source asset.'); + const context: SvgContext = { pageId, layerId, document: snapshot.doc, warnings, ids: new Set(), shapeIndex: 0 }; + walk(svg, IDENTITY, DEFAULT_STYLE, context, true); + return { format: 'svg', snapshot, warnings: warnings.values() }; +} + +function walk(element: Element, parentMatrix: Matrix, parentStyle: SvgStyle, context: SvgContext, isRoot = false) { + const tag = element.localName; + const style = readStyle(element, parentStyle); + let matrix: Matrix; + try { + matrix = multiply(parentMatrix, parseTransform(element.getAttribute('transform'))); + } catch (error) { + context.warnings.add('svg-transform', `A transform on <${tag}> was skipped: ${String(error)}.`); + matrix = parentMatrix; + } + + if (!isRoot && ['script', 'style', 'animate', 'animateMotion', 'animateTransform', 'set'].includes(tag)) { + context.warnings.add('svg-active-content', `The <${tag}> element was omitted.`); + return; + } + if ( + !isRoot && + ['defs', 'linearGradient', 'radialGradient', 'pattern', 'clipPath', 'mask', 'filter'].includes(tag) + ) { + context.warnings.add('svg-unsupported-feature', `The <${tag}> definition was omitted.`); + return; + } + if (!isRoot && ['g', 'svg'].includes(tag)) { + if (tag === 'g') + context.warnings.add('svg-group-flattened', 'SVG groups were flattened into native shape coordinates.'); + for (const child of Array.from(element.children)) walk(child, matrix, style, context); + return; + } + if (!isRoot) { + try { + const shape = shapeFromElement(element, matrix, style, context); + if (shape) addShape(context.document, context.pageId, context.layerId, shape); + } catch (error) { + context.warnings.add('svg-element', `The <${tag}> element was omitted: ${String(error)}.`); + } + } + if (tag === 'svg' || tag === 'g') { + for (const child of Array.from(element.children)) walk(child, matrix, style, context); + } +} + +function shapeFromElement(element: Element, matrix: Matrix, style: SvgStyle, context: SvgContext): Shape | null { + const tag = element.localName; + const id = nextId(element.getAttribute('id'), context); + const transform = decompose(matrix); + const opacity = style.opacity; + const fillOpacity = style.fillOpacity * opacity; + const strokeOpacity = style.strokeOpacity * opacity; + + switch (tag) { + case 'rect': { + const width = nonNegativeNumber(element, 'width', 0); + const height = nonNegativeNumber(element, 'height', 0); + const rectTransform = decompose( + multiply(matrix, translation(number(element, 'x', 0), number(element, 'y', 0))) + ); + const props = { + w: width * Math.abs(rectTransform.scaleX), + h: height * Math.abs(rectTransform.scaleY), + fill: style.fill, + stroke: style.stroke, + radius: Math.max(0, number(element, 'rx', number(element, 'ry', 0))) + }; + return withStyle( + ShapeRecord.createRect(context.pageId, rectTransform.x, rectTransform.y, props, id), + opacity, + fillOpacity, + strokeOpacity, + rectTransform.rotation + ); + } + case 'circle': + case 'ellipse': { + const rx = tag === 'circle' ? nonNegativeNumber(element, 'r', 0) : nonNegativeNumber(element, 'rx', 0); + const ry = tag === 'circle' ? rx : nonNegativeNumber(element, 'ry', 0); + const ellipseTransform = decompose( + multiply(matrix, translation(number(element, 'cx', 0) - rx, number(element, 'cy', 0) - ry)) + ); + const props = { + w: rx * 2 * Math.abs(ellipseTransform.scaleX), + h: ry * 2 * Math.abs(ellipseTransform.scaleY), + fill: style.fill, + stroke: style.stroke + }; + return withStyle( + ShapeRecord.createEllipse(context.pageId, ellipseTransform.x, ellipseTransform.y, props, id), + opacity, + fillOpacity, + strokeOpacity, + ellipseTransform.rotation + ); + } + case 'line': { + const start = apply(matrix, { x: number(element, 'x1', 0), y: number(element, 'y1', 0) }); + const end = apply(matrix, { x: number(element, 'x2', 0), y: number(element, 'y2', 0) }); + const props = { + a: { x: 0, y: 0 }, + b: { x: end.x - start.x, y: end.y - start.y }, + stroke: style.stroke, + width: style.strokeWidth + }; + return withStyle( + ShapeRecord.createLine(context.pageId, start.x, start.y, props, id), + opacity, + 1, + strokeOpacity, + 0 + ); + } + case 'polygon': + case 'polyline': { + const points = parsePoints(element.getAttribute('points') ?? '').map((point) => apply(matrix, point)); + if (points.length < (tag === 'polygon' ? 3 : 2)) throw new Error('the points attribute has too few points'); + const segments: PathSegment[] = [ + { type: 'move', to: points[0] }, + ...points.slice(1).map((to) => ({ type: 'line', to }) satisfies PathSegment) + ]; + return withStyle( + ShapeRecord.createPath( + context.pageId, + 0, + 0, + { + subpaths: [{ segments, closed: tag === 'polygon' }], + fill_rule: style.fillRule, + fill: style.fill, + stroke: style.stroke, + stroke_width: style.strokeWidth + }, + id + ), + opacity, + fillOpacity, + strokeOpacity, + 0 + ); + } + case 'path': { + const geometry = parsePath(element.getAttribute('d') ?? '').map((subpath) => ({ + ...subpath, + segments: subpath.segments.map((segment) => transformSegment(segment, matrix)) + })); + if (!geometry.length) throw new Error('the path has no segments'); + return withStyle( + ShapeRecord.createPath( + context.pageId, + 0, + 0, + { + subpaths: geometry, + fill_rule: style.fillRule, + fill: style.fill, + stroke: style.stroke, + stroke_width: style.strokeWidth + }, + id + ), + opacity, + fillOpacity, + strokeOpacity, + 0 + ); + } + case 'text': { + const point = apply(matrix, { x: number(element, 'x', 0), y: number(element, 'y', 0) }); + return withStyle( + ShapeRecord.createText( + context.pageId, + point.x, + point.y, + { + text: element.textContent ?? '', + fontSize: style.fontSize, + fontFamily: style.fontFamily, + color: style.fill + }, + id + ), + opacity, + fillOpacity, + strokeOpacity, + transform.rotation + ); + } + case 'image': + context.warnings.add('svg-image', 'Embedded image nodes are not available in the browser document model.'); + return null; + default: + context.warnings.add('svg-element', `The <${tag}> element is not supported.`); + return null; + } +} + +function withStyle( + shape: T, + opacity: number, + fillOpacity: number, + strokeOpacity: number, + rotation: number +): T { + return { ...shape, rot: rotation, opacity, fillOpacity, strokeOpacity }; +} + +function readStyle(element: Element, parent: SvgStyle): SvgStyle { + const style = { ...parent }; + const declarations = new Map(); + for (const name of [ + 'fill', + 'stroke', + 'stroke-width', + 'fill-rule', + 'opacity', + 'fill-opacity', + 'stroke-opacity', + 'font-size', + 'font-family' + ]) { + const value = element.getAttribute(name); + if (value !== null) declarations.set(name, value); + } + for (const declaration of (element.getAttribute('style') ?? '').split(';')) { + const [name, value] = declaration.split(':', 2).map((part) => part?.trim()); + if (name && value) declarations.set(name, value); + } + if (declarations.has('fill')) style.fill = paint(declarations.get('fill')!); + if (declarations.has('stroke')) style.stroke = paint(declarations.get('stroke')!); + if (declarations.has('stroke-width')) + style.strokeWidth = finite(declarations.get('stroke-width')!.replace(/px$/, ''), 'stroke-width'); + if (declarations.has('fill-rule')) + style.fillRule = declarations.get('fill-rule') === 'evenodd' ? 'evenodd' : 'nonzero'; + if (declarations.has('opacity')) style.opacity *= clampOpacity(declarations.get('opacity')!); + if (declarations.has('fill-opacity')) style.fillOpacity *= clampOpacity(declarations.get('fill-opacity')!); + if (declarations.has('stroke-opacity')) style.strokeOpacity *= clampOpacity(declarations.get('stroke-opacity')!); + if (declarations.has('font-size')) + style.fontSize = finite(declarations.get('font-size')!.replace(/px$/, ''), 'font-size'); + if (declarations.has('font-family')) style.fontFamily = declarations.get('font-family')!.split(',')[0].trim(); + return style; +} + +function paint(value: string) { + const normalized = value.trim(); + return normalized === 'none' || normalized === 'transparent' || normalized.startsWith('url(') ? 'none' : normalized; +} + +function number(element: Element, attribute: string, fallback: number): number { + const value = element.getAttribute(attribute); + return value === null || value.trim() === '' ? fallback : finite(value.replace(/px$/, ''), attribute); +} + +function nonNegativeNumber(element: Element, attribute: string, fallback: number): number { + const value = number(element, attribute, fallback); + if (value < 0) throw new Error(`${attribute} must not be negative`); + return value; +} + +function finite(value: string, name: string) { + const parsed = Number(value.trim()); + if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number`); + return parsed; +} + +function clampOpacity(value: string) { + return Math.min(1, Math.max(0, finite(value, 'opacity'))); +} + +function nextId(sourceId: string | null, context: SvgContext) { + const base = sourceId?.trim() ? `svg:${sourceId.trim()}` : `svg:shape:${context.shapeIndex}`; + context.shapeIndex += 1; + let id = base; + let suffix = 2; + while (context.ids.has(id)) id = `${base}:${suffix++}`; + context.ids.add(id); + return id; +} + +function parsePoints(value: string): Point[] { + const values = value + .trim() + .split(/[\s,]+/) + .filter(Boolean) + .map((item) => finite(item, 'points')); + if (values.length % 2 !== 0) throw new Error('points must contain x/y pairs'); + const points: Point[] = []; + for (let index = 0; index < values.length; index += 2) points.push({ x: values[index], y: values[index + 1] }); + return points; +} + +function parseTransform(value: string | null): Matrix { + if (!value?.trim()) return IDENTITY; + let result = IDENTITY; + const pattern = /([a-z]+)\s*\(([^)]*)\)/gi; + let match: RegExpExecArray | null; + while ((match = pattern.exec(value))) { + const values = match[2] + .split(/[\s,]+/) + .filter(Boolean) + .map((item) => finite(item, 'transform')); + let local: Matrix; + switch (match[1].toLowerCase()) { + case 'translate': + local = { ...IDENTITY, e: values[0] ?? 0, f: values[1] ?? 0 }; + break; + case 'scale': + local = { a: values[0] ?? 1, b: 0, c: 0, d: values[1] ?? values[0] ?? 1, e: 0, f: 0 }; + break; + case 'rotate': { + const angle = ((values[0] ?? 0) * Math.PI) / 180; + local = { a: Math.cos(angle), b: Math.sin(angle), c: -Math.sin(angle), d: Math.cos(angle), e: 0, f: 0 }; + break; + } + case 'matrix': + if (values.length !== 6) throw new Error('matrix requires six values'); + local = { a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5] }; + break; + default: + throw new Error(`${match[1]} transforms are not supported`); + } + result = multiply(result, local); + } + return result; +} + +function multiply(left: Matrix, right: Matrix): Matrix { + return { + a: left.a * right.a + left.c * right.b, + b: left.b * right.a + left.d * right.b, + c: left.a * right.c + left.c * right.d, + d: left.b * right.c + left.d * right.d, + e: left.a * right.e + left.c * right.f + left.e, + f: left.b * right.e + left.d * right.f + left.f + }; +} + +function translation(x: number, y: number): Matrix { + return { ...IDENTITY, e: x, f: y }; +} + +function apply(matrix: Matrix, point: Point): Point { + return { + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f + }; +} + +function decompose(matrix: Matrix) { + const scaleX = Math.hypot(matrix.a, matrix.b) || 1; + const determinant = matrix.a * matrix.d - matrix.b * matrix.c; + const scaleY = determinant / scaleX || 1; + return { x: matrix.e, y: matrix.f, rotation: Math.atan2(matrix.b, matrix.a), scaleX, scaleY }; +} + +function transformSegment(segment: PathSegment, matrix: Matrix): PathSegment { + if (segment.type === 'move' || segment.type === 'line') return { ...segment, to: apply(matrix, segment.to) }; + if (segment.type === 'quadratic') + return { ...segment, control: apply(matrix, segment.control), to: apply(matrix, segment.to) }; + return { + ...segment, + control_1: apply(matrix, segment.control_1), + control_2: apply(matrix, segment.control_2), + to: apply(matrix, segment.to) + }; +} + +function parsePath(value: string): PathSubpath[] { + const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? []; + let index = 0; + let command = ''; + let current: Point = { x: 0, y: 0 }; + let start: Point = { x: 0, y: 0 }; + let lastCubic: Point | null = null; + let lastQuadratic: Point | null = null; + const subpaths: PathSubpath[] = []; + let active: PathSubpath | null = null; + const isCommand = (token: string) => /^[a-zA-Z]$/.test(token); + const read = () => { + if (index >= tokens.length || isCommand(tokens[index])) + throw new Error('path command has incomplete parameters'); + return finite(tokens[index++], 'path'); + }; + while (index < tokens.length) { + if (isCommand(tokens[index])) command = tokens[index++]; + if (!command) throw new Error('path data must begin with a command'); + const relative = command === command.toLowerCase(); + const type = command.toUpperCase(); + if (type === 'Z') { + if (!active) throw new Error('close command has no subpath'); + active.closed = true; + current = start; + lastCubic = null; + lastQuadratic = null; + command = ''; + continue; + } + const point = (x: number, y: number): Point => (relative ? { x: current.x + x, y: current.y + y } : { x, y }); + if (type === 'M') { + const next = point(read(), read()); + active = { segments: [{ type: 'move', to: next }], closed: false }; + subpaths.push(active); + current = start = next; + lastCubic = lastQuadratic = null; + command = relative ? 'l' : 'L'; + continue; + } + if (!active) throw new Error('path segment has no move command'); + switch (type) { + case 'L': + current = point(read(), read()); + active.segments.push({ type: 'line', to: current }); + lastCubic = lastQuadratic = null; + break; + case 'H': { + const value = read(); + current = { x: relative ? current.x + value : value, y: current.y }; + active.segments.push({ type: 'line', to: current }); + lastCubic = lastQuadratic = null; + break; + } + case 'V': { + const value = read(); + current = { x: current.x, y: relative ? current.y + value : value }; + active.segments.push({ type: 'line', to: current }); + lastCubic = lastQuadratic = null; + break; + } + case 'C': { + const control1 = point(read(), read()); + const control2 = point(read(), read()); + current = point(read(), read()); + active.segments.push({ type: 'cubic', control_1: control1, control_2: control2, to: current }); + lastCubic = control2; + lastQuadratic = null; + break; + } + case 'S': { + const control1 = lastCubic + ? { x: current.x * 2 - lastCubic.x, y: current.y * 2 - lastCubic.y } + : current; + const control2 = point(read(), read()); + current = point(read(), read()); + active.segments.push({ type: 'cubic', control_1: control1, control_2: control2, to: current }); + lastCubic = control2; + lastQuadratic = null; + break; + } + case 'Q': { + const control: Point = point(read(), read()); + current = point(read(), read()); + active.segments.push({ type: 'quadratic', control, to: current }); + lastQuadratic = control; + lastCubic = null; + break; + } + case 'T': { + const control: Point = lastQuadratic + ? { x: current.x * 2 - lastQuadratic.x, y: current.y * 2 - lastQuadratic.y } + : current; + current = point(read(), read()); + active.segments.push({ type: 'quadratic', control, to: current }); + lastQuadratic = control; + lastCubic = null; + break; + } + case 'A': + throw new Error('arc commands are not supported in the browser importer'); + default: + throw new Error(`the ${type} command is not supported`); + } + } + return subpaths; +} diff --git a/packages/core/src/persistence/desktop.ts b/packages/core/src/persistence/desktop.ts index de10ecf..0d3f7c7 100644 --- a/packages/core/src/persistence/desktop.ts +++ b/packages/core/src/persistence/desktop.ts @@ -26,6 +26,11 @@ export interface DesktopFileOps { */ showSaveDialog(defaultName?: string): Promise; + /** + * Show the native SVG file picker and return the selected path. + */ + showSvgDialog(): Promise; + /** * Get recent files list */ diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index 80ef004..84461e1 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -24,6 +24,7 @@ let contextMenuOpen = $state(false); let contextMenuPoint = $state({ x: 0, y: 0 }); let contextMenuItems = $state([]); + let svgDragActive = $state(false); // The composition root fixes the platform adapter for this component's lifetime. // svelte-ignore state_referenced_locally @@ -65,6 +66,13 @@ function handleDrop(e: DragEvent) { e.preventDefault(); + svgDragActive = false; + + const droppedFile = e.dataTransfer?.files?.[0]; + if (!draggingStencil.current && droppedFile?.name.toLowerCase().endsWith('.svg')) { + void c.importSvgFile(droppedFile); + return; + } let stencil = draggingStencil.current; @@ -116,9 +124,7 @@ ui: { ...state.ui, activeLayerId: shape?.layerId ?? state.ui.activeLayerId, - selectionIds: state.ui.selectionIds.includes(shapeId) - ? state.ui.selectionIds - : [shapeId], + selectionIds: state.ui.selectionIds.includes(shapeId) ? state.ui.selectionIds : [shapeId], toolId: 'select' } }; @@ -126,12 +132,7 @@ contextMenuItems = [ { id: 'duplicate', label: 'Duplicate', icon: 'add', shortcut: '⌘/Ctrl D' }, { id: 'forward', label: 'Bring forward', icon: 'arrow-up', shortcut: '⌘/Ctrl ]' }, - { - id: 'backward', - label: 'Send backward', - icon: 'arrow-down', - shortcut: '⌘/Ctrl [' - }, + { id: 'backward', label: 'Send backward', icon: 'arrow-down', shortcut: '⌘/Ctrl [' }, { type: 'separator' }, { id: 'delete', label: 'Delete', icon: 'delete', shortcut: '⌫', danger: true } ]; @@ -158,12 +159,7 @@ break; case 'delete': c.handleAction( - Action.keyDown('Delete', 'Delete', { - ctrl: false, - shift: false, - alt: false, - meta: false - }) + Action.keyDown('Delete', 'Delete', { ctrl: false, shift: false, alt: false, meta: false }) ); break; case 'stencils': @@ -182,14 +178,22 @@ canvas={canvasEl ?? undefined} brushStore={c.brushStore} onImportEditable={c.importEditableCanvas} + onImportSvg={c.importSvg} onExportEditable={c.exportEditableCanvas} interchangeBusy={c.interchangeBusy()} />
{ + if (e.dataTransfer?.types.includes('Files')) svgDragActive = true; + }} ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }} + ondragleave={(e) => { + if (e.currentTarget === e.target) svgDragActive = false; + }} ondrop={handleDrop} role="application"> {#if liveProposal} - + {/if} @@ -317,19 +318,14 @@ bind:open={c.fileBrowser.open} onUpdate={c.fileBrowser.handleUpdate} onClose={c.fileBrowser.handleClose} - fetchInspectorData={platformKind === 'web' - ? c.fileBrowser.fetchInspectorData - : undefined} + fetchInspectorData={platformKind === 'web' ? c.fileBrowser.fetchInspectorData : undefined} desktopRepo={c.desktop.repo} /> {/if} (c.stencilPaletteOpen = false)} onStencilClick={handleInsertStencilAtCenter} /> - + {#if interchangeNotice}

{interchangeNotice.title}

@@ -368,6 +364,27 @@ position: relative; } + .canvas-container::after { + content: 'Drop an SVG to import'; + position: absolute; + inset: var(--ink-space-5); + display: grid; + place-items: center; + border: 1px dashed var(--ink-accent); + border-radius: var(--ink-radius-panel-small); + background: color-mix(in srgb, var(--ink-accent) 10%, var(--ink-canvas)); + color: var(--ink-text); + font: 600 var(--ink-type-sm) / 1.3 var(--ink-font-body); + pointer-events: none; + opacity: 0; + transition: opacity var(--ink-duration-fast) var(--ink-ease-out); + z-index: 3; + } + + .canvas-container[data-svg-drag-active='true']::after { + opacity: 1; + } + .canvas-container canvas { width: 100%; height: 100%; @@ -475,4 +492,10 @@ pointer-events: none; z-index: 1; } + + @media (prefers-reduced-motion: reduce) { + .canvas-container::after { + transition: none; + } + } diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 7002997..c23e35d 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -1,11 +1,6 @@ import { createInputAdapter, type InputAdapter } from '../input'; import { initialPersistenceStatus } from '../platform'; -import type { - DesktopDocumentRepo, - EditorPlatformAdapter, - EditorPlatformSession, - LiveProposal -} from '../platform'; +import type { DesktopDocumentRepo, EditorPlatformAdapter, EditorPlatformSession, LiveProposal } from '../platform'; import { createBrushStore, createSnapStore, createStatusStore } from '../status'; import type { BrushStore, SnapStore, StatusStore } from '../status'; import { themeStore } from '../theme.svelte'; @@ -62,10 +57,7 @@ export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): v export type CanvasController = ReturnType; -export function createCanvasController( - platformAdapter: EditorPlatformAdapter, - bindings: CanvasControllerBindings -) { +export function createCanvasController(platformAdapter: EditorPlatformAdapter, bindings: CanvasControllerBindings) { let repo: PersistentDocRepo | null = null; let sink: PersistenceSink | null = null; let platformSession: EditorPlatformSession | null = null; @@ -123,12 +115,7 @@ export function createCanvasController( shapes: {}, bindings: {} }, - ui: { - currentPageId: initialPage.id, - activeLayerId: initialLayer.id, - selectionIds: [], - toolId: 'select' - }, + ui: { currentPageId: initialPage.id, activeLayerId: initialLayer.id, selectionIds: [], toolId: 'select' }, camera: Camera.create() }, { @@ -201,37 +188,21 @@ export function createCanvasController( const rect = element.getBoundingClientRect(); const min = Camera.screenToWorld( state.camera, - { - x: rect.left - canvasRect.left, - y: rect.top - canvasRect.top - }, + { x: rect.left - canvasRect.left, y: rect.top - canvasRect.top }, viewport ); const max = Camera.screenToWorld( state.camera, - { - x: rect.right - canvasRect.left, - y: rect.bottom - canvasRect.top - }, + { x: rect.right - canvasRect.left, y: rect.bottom - canvasRect.top }, viewport ); - return { - x: min.x, - y: min.y, - width: max.x - min.x, - height: max.y - min.y - }; + return { x: min.x, y: min.y, width: max.x - min.x, height: max.y - min.y }; }); const context = { pageId: state.ui.currentPageId, activeLayerId: state.ui.activeLayerId ?? null, selectionIds: [...state.ui.selectionIds], - viewport: { - x: state.camera.x - width / 2, - y: state.camera.y - height / 2, - width, - height - }, + viewport: { x: state.camera.x - width / 2, y: state.camera.y - height / 2, width, height }, camera: { ...state.camera }, occludedRegions }; @@ -256,10 +227,7 @@ export function createCanvasController( } const cursor = computeCursor( textEditor.isEditing || arrowLabelEditor.isEditing || markdownEditor.isEditing, - { - isPanning: runtime.getInteractionState().panning, - spaceHeld: runtime.getInteractionState().spaceHeld - }, + { isPanning: runtime.getInteractionState().panning, spaceHeld: runtime.getInteractionState().spaceHeld }, { hover: handleState.hover, active: handleState.active }, runtime.getInteractionState().pointerDown ); @@ -292,12 +260,7 @@ export function createCanvasController( const selectTool = new SelectTool(handleMarqueeChange, (point) => { const snap = snapStore.get(); - if ( - !snap.snapEnabled || - !snap.gridEnabled || - !Number.isFinite(snap.gridSize) || - snap.gridSize <= 0 - ) { + if (!snap.snapEnabled || !snap.gridEnabled || !Number.isFinite(snap.gridSize) || snap.gridSize <= 0) { return point; } return { @@ -332,11 +295,7 @@ export function createCanvasController( ]); const textEditor = new TextEditorController(store, getOverlayViewport, refreshCursor); - const arrowLabelEditor = new ArrowLabelEditorController( - store, - getOverlayViewport, - refreshCursor - ); + const arrowLabelEditor = new ArrowLabelEditorController(store, getOverlayViewport, refreshCursor); const markdownEditor = new MarkdownEditorController(store, getOverlayViewport, refreshCursor); const toolController = new ToolController(store, tools); const unsubscribeMarqueeCamera = store.subscribe((state) => { @@ -398,17 +357,11 @@ export function createCanvasController( } function handleAction(action: import('@inkfinite/core').Action) { - if ( - textEditor.isEditing && - (action.type === 'pointer-down' || action.type === 'pointer-up') - ) { + if (textEditor.isEditing && (action.type === 'pointer-down' || action.type === 'pointer-up')) { textEditor.commit(); } - if ( - markdownEditor.isEditing && - (action.type === 'pointer-down' || action.type === 'pointer-up') - ) { + if (markdownEditor.isEditing && (action.type === 'pointer-down' || action.type === 'pointer-up')) { markdownEditor.commit(); } @@ -417,8 +370,7 @@ export function createCanvasController( } if ( action.type === 'pointer-down' && - (action.button === 1 || - (action.button === 0 && runtime.getInteractionState().spaceHeld)) + (action.button === 1 || (action.button === 0 && runtime.getInteractionState().spaceHeld)) ) { camera.cancelFit(); } @@ -467,21 +419,93 @@ export function createCanvasController( } } + async function importBrowserSvgSource(source: { name: string; contents: string }) { + if (!repo || !sink) return; + const imported = importInterchange(source.contents, source.name); + await sink.flush(); + const boardId = await repo.importBoard(imported.snapshot); + const doc = await repo.loadDoc(boardId); + setActiveBoardId(boardId); + applyLoadedDoc(doc, true); + interchangeNotice = { + title: 'SVG import complete', + message: `${source.name} is now an Inkfinite document.`, + warnings: imported.warnings, + error: false + }; + } + + async function importSvg() { + if (!repo || !sink || !platformSession?.interchange) return; + interchangeBusy = true; + try { + if (desktopRepo) { + const imported = await desktopRepo.importSvg(); + if (!imported) return; + applyLoadedDoc(imported.doc, true); + interchangeNotice = { + title: 'SVG import complete', + message: 'The SVG was added to the current document as native shapes.', + warnings: [ + ...imported.warnings.map((message, index) => ({ + code: `svg-warning-${index}`, + message, + count: 1 + })), + ...(imported.omitted_image_count > 0 + ? [ + { + code: 'svg-images-omitted', + message: + 'Embedded image nodes were omitted because image shapes are not available yet.', + count: imported.omitted_image_count + } + ] + : []) + ], + error: false + }; + return; + } + const source = await platformSession.interchange.pickSvg?.(); + if (source) await importBrowserSvgSource(source); + } catch (error) { + interchangeNotice = { + title: 'SVG import failed', + message: error instanceof Error ? error.message : String(error), + warnings: [], + error: true + }; + } finally { + interchangeBusy = false; + } + } + + async function importSvgFile(file: File) { + if (platform !== 'web' || !platformSession?.interchange) return; + interchangeBusy = true; + try { + await importBrowserSvgSource({ name: file.name, contents: await file.text() }); + } catch (error) { + interchangeNotice = { + title: 'SVG import failed', + message: error instanceof Error ? error.message : String(error), + warnings: [], + error: true + }; + } finally { + interchangeBusy = false; + } + } + async function exportEditableCanvas(format: InterchangeFormat) { if (!activeBoardId || !repo || !sink || !platformSession?.interchange) return; interchangeBusy = true; try { await sink.flush(); const snapshot = await repo.exportBoard(activeBoardId); - const exported = exportInterchange( - snapshot, - format, - store.getState().ui.currentPageId ?? undefined - ); - const saved = await platformSession.interchange.saveExport( - exported, - snapshot.board.name - ); + const exported = exportInterchange(snapshot, format, store.getState().ui.currentPageId ?? undefined); + const saved = await platformSession.interchange.saveExport(exported, snapshot.board.name); if (!saved) return; interchangeNotice = { title: 'Export complete', @@ -553,10 +577,7 @@ export function createCanvasController( const clickedShape = shapes.some((shape) => { const bounds = shapeBounds(shape); return ( - world.x >= bounds.min.x && - world.x <= bounds.max.x && - world.y >= bounds.min.y && - world.y <= bounds.max.y + world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y ); }); if (!clickedShape) { @@ -609,8 +630,7 @@ export function createCanvasController( onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) }); - const resizeObserver = - typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(handleResize); + const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(handleResize); resizeObserver?.observe(canvas); return () => { @@ -670,6 +690,9 @@ export function createCanvasController( case 'import': void importEditableCanvas(); break; + case 'import-svg': + void importSvg(); + break; case 'export-excalidraw': void exportEditableCanvas('excalidraw'); break; @@ -775,6 +798,8 @@ export function createCanvasController( insertStencil, commitLayerState, importEditableCanvas, + importSvg, + importSvgFile, exportEditableCanvas, interchangeBusy: () => interchangeBusy, interchangeNotice: () => interchangeNotice, @@ -795,12 +820,7 @@ export function createCanvasController( state, nextState, 'Insert Stencil', - Action.keyDown('InsertStencil', 'InsertStencil', { - ctrl: false, - shift: false, - alt: false, - meta: false - }) + Action.keyDown('InsertStencil', 'InsertStencil', { ctrl: false, shift: false, alt: false, meta: false }) ); } } diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index a446c8c..aac6796 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -16,13 +16,7 @@ TextShape, ToolId } from '@inkfinite/core'; - import { - EditorState, - exportToSVG, - exportViewportToPNG, - getSelectedShapes, - SnapshotCommand - } from '@inkfinite/core'; + import { EditorState, exportToSVG, exportViewportToPNG, getSelectedShapes, SnapshotCommand } from '@inkfinite/core'; import { fade } from 'svelte/transition'; import ArrowPopover from './ArrowPopover.svelte'; @@ -34,6 +28,7 @@ brushStore: BrushStore; onStencilsClick?: () => void; onImportEditable?: () => void; + onImportSvg?: () => void; onExportEditable?: (format: InterchangeFormat) => void; interchangeBusy?: boolean; }; @@ -46,6 +41,7 @@ brushStore, onStencilsClick, onImportEditable, + onImportSvg, onExportEditable, interchangeBusy = false }: Props = $props(); @@ -62,9 +58,7 @@ let strokeDisabled = $state(true); let agentEditableValue = $state(true); let brush = $derived(brushStore.get()); - let hasArrowSelection = $derived( - getSelectedShapes(editorState).some((s) => s.type === 'arrow') - ); + let hasArrowSelection = $derived(getSelectedShapes(editorState).some((s) => s.type === 'arrow')); $effect(() => { editorState = store.getState(); @@ -91,11 +85,7 @@ strokeDisabled = strokable.length === 0; if (fillable.length > 0) { const shared = getSharedColor(fillable, (shape) => - shape.type === 'text' - ? shape.props.color - : 'fill' in shape.props - ? shape.props.fill - : null + shape.type === 'text' ? shape.props.color : 'fill' in shape.props ? shape.props.fill : null ); if (shared) { fillColorValue = shared; @@ -112,9 +102,7 @@ fillOpacityValue = getSharedOpacity(fillOpacityTargets, (shape) => shape.fillOpacity) ?? 1; strokeOpacityValue = getSharedOpacity(strokeOpacityTargets, (shape) => - shape.type === 'stroke' - ? (shape.strokeOpacity ?? shape.props.style.opacity) - : shape.strokeOpacity + shape.type === 'stroke' ? (shape.strokeOpacity ?? shape.props.style.opacity) : shape.strokeOpacity ) ?? 1; agentEditableValue = selection.every((shape) => shape.agentEditable !== false); }); @@ -184,8 +172,7 @@ isDragging = false; if (typeof document !== 'undefined') document.body.style.userSelect = ''; const handle = event.currentTarget as HTMLElement; - if (handle.hasPointerCapture(event.pointerId)) - handle.releasePointerCapture(event.pointerId); + if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId); } function handleDragKeyDown(event: KeyboardEvent) { @@ -261,26 +248,14 @@ return shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'text'; } - function shapeSupportsStroke( - shape: ShapeRecord - ): shape is RectShape | EllipseShape | LineShape | ArrowShape { - return ( - shape.type === 'rect' || - shape.type === 'ellipse' || - shape.type === 'line' || - shape.type === 'arrow' - ); + function shapeSupportsStroke(shape: ShapeRecord): shape is RectShape | EllipseShape | LineShape | ArrowShape { + return shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'line' || shape.type === 'arrow'; } function shapeSupportsFillOpacity( shape: ShapeRecord ): shape is RectShape | EllipseShape | TextShape | MarkdownShape { - return ( - shape.type === 'rect' || - shape.type === 'ellipse' || - shape.type === 'text' || - shape.type === 'markdown' - ); + return shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'text' || shape.type === 'markdown'; } function shapeSupportsStrokeOpacity( @@ -345,12 +320,7 @@ } } const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; - const command = new SnapshotCommand( - 'Set fill color', - 'doc', - before, - EditorState.clone(after) - ); + const command = new SnapshotCommand('Set fill color', 'doc', before, EditorState.clone(after)); store.executeCommand(command); } @@ -365,26 +335,17 @@ for (const shape of targets) { switch (shape.type) { case 'rect': { - const updated: RectShape = { - ...shape, - props: { ...shape.props, stroke: color } - }; + const updated: RectShape = { ...shape, props: { ...shape.props, stroke: color } }; newShapes[shape.id] = updated; break; } case 'ellipse': { - const updated: EllipseShape = { - ...shape, - props: { ...shape.props, stroke: color } - }; + const updated: EllipseShape = { ...shape, props: { ...shape.props, stroke: color } }; newShapes[shape.id] = updated; break; } case 'line': { - const updated: LineShape = { - ...shape, - props: { ...shape.props, stroke: color } - }; + const updated: LineShape = { ...shape, props: { ...shape.props, stroke: color } }; newShapes[shape.id] = updated; break; } @@ -399,12 +360,7 @@ } } const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; - const command = new SnapshotCommand( - 'Set stroke color', - 'doc', - before, - EditorState.clone(after) - ); + const command = new SnapshotCommand('Set stroke color', 'doc', before, EditorState.clone(after)); store.executeCommand(command); } @@ -510,11 +466,8 @@
@@ -553,10 +506,7 @@ {/each} {#if showContextControls} -
+
{#if showColorControls}
{#if getSelectedShapes(editorState).some(shapeSupportsFill)} @@ -617,10 +567,7 @@ {/if}
@@ -635,6 +582,13 @@ aria-label="Import Excalidraw or Obsidian Canvas document"> {interchangeBusy ? 'Working…' : 'Import'} +