diff --git a/.markdownlint.json b/.markdownlint.json --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,1 +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 --- 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 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ 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 @@ --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 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 @@ ## 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 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,6 +31,11 @@ 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 --- a/TODO.md +++ b/TODO.md @@ -67,11 +67,11 @@ #### 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/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -117,6 +117,50 @@ } #[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"); let document_path = temporary.path.join("filters.inkfinite"); diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -10,6 +10,7 @@ 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 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -22,6 +22,8 @@ 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, @@ -97,6 +99,21 @@ pub commit: CommitResult, /// Session state after the commit. 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. @@ -304,6 +321,9 @@ /// 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 { @@ -545,6 +565,76 @@ let commit = session.file.commit(transaction)?; let status = session.status(session_id)?; 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. diff --git a/crates/inkfinite-core/src/svg_transaction.rs b/crates/inkfinite-core/src/svg_transaction.rs new file mode 100644 --- /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 --- 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 = { @@ -27,6 +35,9 @@ export function importInterchange(contents: string, fileName: string): InterchangeImport { 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(')?; + 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 @@ 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 @@ 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 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -9,7 +9,7 @@ }; 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}; @@ -184,6 +184,48 @@ ) -> Result { lock_service(&state)? .commit(&SessionId(session_id), transaction) + .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) } diff --git a/apps/desktop/src/lib/fileops.ts b/apps/desktop/src/lib/fileops.ts --- 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'; @@ -30,6 +31,15 @@ async function showSaveDialog(defaultName?: string): Promise { 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 { @@ -98,6 +108,7 @@ return { showOpenDialog, showSaveDialog, + showSvgDialog, getRecentFiles, addRecentFile, removeRecentFile, diff --git a/crates/inkfinite-cli/src/cli/apply.rs b/crates/inkfinite-cli/src/cli/apply.rs --- a/crates/inkfinite-cli/src/cli/apply.rs +++ b/crates/inkfinite-cli/src/cli/apply.rs @@ -5,7 +5,15 @@ 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 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -11,6 +11,7 @@ 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 @@ 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), @@ -159,6 +163,35 @@ /// Validate and report the result without saving the document. #[arg(long)] 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)] diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -36,7 +36,7 @@ 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 @@ }, "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 @@ .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 --- a/crates/inkfinite-cli/src/cli/mod.rs +++ b/crates/inkfinite-cli/src/cli/mod.rs @@ -113,11 +113,12 @@ 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 @@ 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 --- a/crates/inkfinite-cli/src/cli/mutation.rs +++ b/crates/inkfinite-cli/src/cli/mutation.rs @@ -42,6 +42,14 @@ 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 { @@ -83,12 +91,21 @@ 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( &mut file, transaction, options.dry_run, options.transaction_out.as_deref(), + warnings, json_output, stdout, ), @@ -115,7 +132,7 @@ 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 @@ 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 @@ 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 --- /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-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -699,6 +699,7 @@ 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/packages/core/src/interchange/shared.ts b/packages/core/src/interchange/shared.ts --- a/packages/core/src/interchange/shared.ts +++ b/packages/core/src/interchange/shared.ts @@ -16,7 +16,7 @@ const snapshot: BoardExport = { board: { id: boardId, - name: fileName.replace(/\.(?:excalidraw|canvas)$/i, '').trim() || 'Imported Board', + name: fileName.replace(/\.(?:excalidraw|canvas|svg)$/i, '').trim() || 'Imported Board', createdAt: timestamp, updatedAt: timestamp }, diff --git a/packages/core/src/interchange/svg.ts b/packages/core/src/interchange/svg.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/interchange/svg.ts @@ -0,0 +1,529 @@ +import { ShapeRecord, type Document, type PathSegment, type PathSubpath, type ShapeRecord as Shape } from '../model'; +import type { InterchangeImport } from '../interchange'; +import { addShape, blankSnapshot, WarningCollector } from './shared'; + +type Matrix = { a: number; b: number; c: number; d: number; e: number; f: number }; +type Point = { x: number; y: number }; +type SvgStyle = { + fill: string; + stroke: string; + strokeWidth: number; + fillRule: 'nonzero' | 'evenodd'; + opacity: number; + fillOpacity: number; + strokeOpacity: number; + fontSize: number; + fontFamily: string; +}; + +type SvgContext = { + pageId: string; + layerId: string; + document: Document; + warnings: WarningCollector; + ids: Set; + 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 --- a/packages/core/src/persistence/desktop.ts +++ b/packages/core/src/persistence/desktop.ts @@ -27,6 +27,11 @@ showSaveDialog(defaultName?: string): Promise; /** + * Show the native SVG file picker and return the selected path. + */ + showSvgDialog(): Promise; + + /** * Get recent files list */ getRecentFiles(): Promise; diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts --- 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 @@ 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 @@ 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 --- 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[] }; @@ -158,6 +158,10 @@ next.heads = [`head:${++headNumber}`]; session.status = { ...session.status, snapshot: next, dirty: true, can_undo: true, can_redo: false }; 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 { @@ -323,6 +327,9 @@ async showSaveDialog() { 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 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -80,21 +80,19 @@ 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 }; /** 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 @@ 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 @@ }), 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 @@ 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 @@ 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 @@ }; /** - * 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(); @@ -519,6 +533,22 @@ setCurrentState(opened.status, boardName); if (!workspace && currentFile) await fileOps.addRecentFile(currentFile); 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 { @@ -823,6 +853,7 @@ 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 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -11,6 +11,9 @@ async showSaveDialog() { return '/workspace/new.inkfinite'; }, + async showSvgDialog() { + return null; + }, async getRecentFiles() { return []; }, @@ -62,6 +65,9 @@ throw new Error('not used'); }, commit: async () => { + throw new Error('not used'); + }, + importSvg: async () => { throw new Error('not used'); }, propose: async () => { diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts --- 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 @@ /** 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) { - 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(`Failed to read the selected file: ${String(error)}`)); - } + 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.') ); - }, - { once: true } - ); - document.body.appendChild(input); - input.click(); - }); - }, + return; + } + 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(); + }); + } + + 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/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -15,15 +15,8 @@ export type LiveProposal = { id: string; transaction: { operations: readonly unknown[] }; - preview: { - created: readonly unknown[]; - changed: readonly unknown[]; - deleted: readonly unknown[]; - }; - affected_regions: Array<{ - page_id: string; - bounds: { x: number; y: number; width: number; height: number }; - }>; + preview: { created: readonly unknown[]; changed: readonly unknown[]; deleted: readonly unknown[] }; + affected_regions: Array<{ page_id: string; bounds: { x: number; y: number; width: number; height: number } }>; operation_previews?: Array<{ position: number; label: string; @@ -61,6 +54,7 @@ | 'open' | 'save-as' | 'import' + | 'import-svg' | 'export-excalidraw' | 'export-json-canvas'; @@ -70,6 +64,8 @@ /** Platform file operations used by shared editable-format import and export. */ export interface InterchangeFileAccess { pickImport(): Promise; + /** Picks an SVG source for the browser import path. */ + pickSvg?(): Promise; saveExport(file: InterchangeExport, defaultStem: string): Promise; } @@ -83,13 +79,17 @@ openDraft(): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; isDraft(): boolean; getCurrentFile(): FileHandle | null; + importSvg(): Promise<{ + doc: import('@inkfinite/core').LoadedDoc; + warnings: string[]; + omitted_image_count: number; + shape_ids: string[]; + } | null>; openFromDialog( prepareToOpen?: () => Promise ): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; /** Opens the native dialog, then waits for pending editor writes before saving the selected path. */ - saveAs( - prepareToSave?: () => Promise - ): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; + saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; getWorkspaceDir(): Promise; setWorkspaceDir(path: string | null): Promise; pickWorkspaceDir(): Promise; @@ -98,19 +98,12 @@ getAgentAccess(): 'review' | 'direct'; subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; /** Receives document snapshots committed by the live CLI or trusted sync peers. */ - subscribeLiveDocument( - listener: (doc: import('@inkfinite/core').LoadedDoc) => void - ): () => void; + subscribeLiveDocument(listener: (doc: import('@inkfinite/core').LoadedDoc) => void): () => void; /** Receives authenticated live CLI navigation without changing document history. */ subscribeAgentUi(listener: (control: AgentUiControl) => void): () => void; - acceptProposal( - proposalId: string, - operationPositions?: number[] - ): Promise; + acceptProposal(proposalId: string, operationPositions?: number[]): Promise; rejectProposal(proposalId: string): Promise; - setAgentAccess( - agentAccess: 'review' | 'direct' - ): Promise<{ agent_access: 'review' | 'direct' }>; + setAgentAccess(agentAccess: 'review' | 'direct'): Promise<{ agent_access: 'review' | 'direct' }>; /** Publishes the current page, selection, and visible world-space rectangle. */ updateAgentContext(context: AgentEditorContext): Promise; } @@ -136,9 +129,5 @@ /** Creates the initial status shown while an application adapter connects. */ export function initialPersistenceStatus(platform: EditorPlatform): PersistenceStatus { - return { - backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', - state: 'saved', - pendingWrites: 0 - }; + return { backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', state: 'saved', pendingWrites: 0 }; } diff --git a/packages/ui/src/lib/editor/svg-import.svelte.test.ts b/packages/ui/src/lib/editor/svg-import.svelte.test.ts new file mode 100644 --- /dev/null +++ b/packages/ui/src/lib/editor/svg-import.svelte.test.ts @@ -0,0 +1,21 @@ +import { importInterchange } from '@inkfinite/core'; +import { describe, expect, it } from 'vitest'; + +describe('browser SVG import', () => { + it('maps nested groups and native primitives into one imported board', () => { + const imported = importInterchange( + '', + 'icon.svg' + ); + + expect(imported.format).toBe('svg'); + expect(Object.values(imported.snapshot.doc.shapes)).toHaveLength(1); + expect(Object.values(imported.snapshot.doc.shapes)[0]).toMatchObject({ + type: 'rect', + x: 14, + y: 25, + props: { w: 20, h: 30, fill: '#123456' } + }); + expect(imported.warnings.some((warning) => warning.code === 'svg-group-flattened')).toBe(true); + }); +}); diff --git a/apps/web/src/content/docs/internals/svg-import.md b/apps/web/src/content/docs/internals/svg-import.md --- a/apps/web/src/content/docs/internals/svg-import.md +++ b/apps/web/src/content/docs/internals/svg-import.md @@ -8,8 +8,9 @@ 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 @@ -133,6 +134,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 diff --git a/apps/web/src/content/docs/reference/cli.md b/apps/web/src/content/docs/reference/cli.md --- a/apps/web/src/content/docs/reference/cli.md +++ b/apps/web/src/content/docs/reference/cli.md @@ -21,6 +21,7 @@ | `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,7 +56,14 @@ 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/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte --- 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,13 +178,21 @@ 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"> @@ -198,10 +202,7 @@ oncontextmenu={handleCanvasContextMenu} onpointerleave={c.handlePointerLeave}> {#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} @@ -366,6 +362,27 @@ flex: 1; min-height: 0; 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 { @@ -474,5 +491,11 @@ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2) inset; 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 --- 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 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 @@ 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 @@ 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 @@ } 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 @@ 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 @@ ]); 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 @@ } 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 @@ } 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 @@ } } + 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 @@ 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 @@ 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 () => { @@ -669,6 +689,9 @@ break; case 'import': void importEditableCanvas(); + break; + case 'import-svg': + void importSvg(); break; case 'export-excalidraw': void exportEditableCanvas('excalidraw'); @@ -775,6 +798,8 @@ insertStencil, commitLayerState, importEditableCanvas, + importSvg, + importSvgFile, exportEditableCanvas, interchangeBusy: () => interchangeBusy, interchangeNotice: () => interchangeNotice, @@ -795,12 +820,7 @@ 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 --- 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 @@ Inkfinite - Stormlight Labs + Stormlight Labs @@ -553,10 +506,7 @@ {/each} {#if showContextControls} - + {#if showColorControls} {#if getSelectedShapes(editorState).some(shapeSupportsFill)} @@ -617,10 +567,7 @@ {/if} - + Agent editable @@ -634,6 +581,13 @@ onclick={() => onImportEditable?.()} aria-label="Import Excalidraw or Obsidian Canvas document"> {interchangeBusy ? 'Working…' : 'Import'} + + onImportSvg?.()} + aria-label="Import SVG file"> + SVG @@ -649,11 +603,7 @@ {#if exportMenuOpen} - + { @@ -45,15 +43,14 @@ const toolbar = screen.getByRole('toolbar', { name: 'Drawing tools' }).element(); const handle = screen.getByRole('button', { name: 'Drag toolbar' }).element(); - handle.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true }) - ); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true })); await vi.waitFor(() => expect(toolbar.style.left).toBe('8px')); }); it('offers editable canvas import and export actions', async () => { const onImportEditable = vi.fn(); + const onImportSvg = vi.fn(); const onExportEditable = vi.fn(); const screen = render(Toolbar, { currentTool: 'select', @@ -61,6 +58,7 @@ store: new Store(), brushStore: createBrushStore(), onImportEditable, + onImportSvg, onExportEditable }); @@ -70,14 +68,12 @@ .element() as HTMLButtonElement ).click(); expect(onImportEditable).toHaveBeenCalledOnce(); + (screen.getByRole('button', { name: 'Import SVG file' }).element() as HTMLButtonElement).click(); + expect(onImportSvg).toHaveBeenCalledOnce(); - ( - screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement - ).click(); + (screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement).click(); await expect - .element( - screen.getByRole('menuitem', { name: 'Export as Excalidraw editable document' }) - ) + .element(screen.getByRole('menuitem', { name: 'Export as Excalidraw editable document' })) .toBeInTheDocument(); ( screen @@ -86,15 +82,9 @@ ).click(); expect(onExportEditable).toHaveBeenCalledWith('excalidraw'); - ( - screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement - ).click(); + (screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement).click(); await expect - .element( - screen.getByRole('menuitem', { - name: 'Export as Obsidian Canvas editable document' - }) - ) + .element(screen.getByRole('menuitem', { name: 'Export as Obsidian Canvas editable document' })) .toBeInTheDocument(); ( screen @@ -112,14 +102,8 @@ brushStore: createBrushStore() }); - const penBounds = screen - .getByRole('button', { name: 'Pen' }) - .element() - .getBoundingClientRect(); - const brushBounds = screen - .getByRole('button', { name: 'Brush settings' }) - .element() - .getBoundingClientRect(); + const penBounds = screen.getByRole('button', { name: 'Pen' }).element().getBoundingClientRect(); + const brushBounds = screen.getByRole('button', { name: 'Brush settings' }).element().getBoundingClientRect(); expect(brushBounds.top).toBeGreaterThan(penBounds.bottom); expect(brushBounds.right).toBeCloseTo(penBounds.right, 0); @@ -151,14 +135,10 @@ brushStore: createBrushStore() }); - const fill = screen - .getByRole('slider', { name: 'Fill opacity' }) - .element() as HTMLInputElement; + const fill = screen.getByRole('slider', { name: 'Fill opacity' }).element() as HTMLInputElement; fill.value = '0.4'; fill.dispatchEvent(new Event('change', { bubbles: true })); - const stroke = screen - .getByRole('slider', { name: 'Stroke opacity' }) - .element() as HTMLInputElement; + const stroke = screen.getByRole('slider', { name: 'Stroke opacity' }).element() as HTMLInputElement; stroke.value = '0.65'; stroke.dispatchEvent(new Event('change', { bubbles: true }));