From 6391d06d8bbbe5886446b718c725e08531b6413e Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 2 Aug 2026 12:21:07 -0500 Subject: [PATCH] feat: file format interchange (JSON canvas & excalidraw) --- .markdownlint.json | 2 +- README.md | 5 + apps/desktop/src-tauri/src/files.rs | 66 ++ apps/desktop/src-tauri/src/lib.rs | 2 + apps/desktop/src-tauri/src/menu.rs | 25 +- .../lib/persistence/desktop-session.test.ts | 47 +- .../src/lib/persistence/desktop-session.ts | 79 ++- apps/desktop/src/lib/platform.ts | 9 + apps/web/src/lib/persistence/dexie.ts | 73 +- .../lib/tests/Toolbar.accessibility.test.ts | 6 +- .../docs/reference/file-format/+page.svx | 63 +- packages/core/src/camera.ts | 4 +- packages/core/src/geom.ts | 20 +- packages/core/src/index.ts | 1 + packages/core/src/interchange.ts | 54 ++ packages/core/src/interchange/excalidraw.ts | 624 ++++++++++++++++++ packages/core/src/interchange/json-canvas.ts | 365 ++++++++++ packages/core/src/interchange/shared.ts | 105 +++ packages/core/src/math.ts | 14 + packages/core/src/tools/select.ts | 29 +- packages/core/tests/interchange.test.ts | 270 ++++++++ packages/core/tests/math.test.ts | 19 +- .../ui/src/lib/editor/canvas/Canvas.svelte | 67 +- .../lib/editor/canvas/canvas-store.svelte.ts | 90 +++ .../desktop-file-controller.svelte.ts | 5 + .../src/lib/editor/components/Toolbar.svelte | 63 +- .../editor/components/Toolbar.svelte.test.ts | 52 ++ packages/ui/src/lib/editor/index.ts | 2 + packages/ui/src/lib/editor/platform.ts | 19 +- 29 files changed, 2117 insertions(+), 63 deletions(-) create mode 100644 packages/core/src/interchange.ts create mode 100644 packages/core/src/interchange/excalidraw.ts create mode 100644 packages/core/src/interchange/json-canvas.ts create mode 100644 packages/core/src/interchange/shared.ts create mode 100644 packages/core/tests/interchange.test.ts diff --git a/.markdownlint.json b/.markdownlint.json index 67d257c..7738a33 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1 +1 @@ -{ "MD033": false, "MD013": false, "MD024": false } +{ "MD033": false, "MD013": false, "MD024": false, "MD010": false, "MD041": false } diff --git a/README.md b/README.md index 157b8c2..24a9522 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ diagramming. It runs in the browser or as a desktop app. - Pan, zoom, select, resize, reorder, undo, and redo directly on the canvas. - Store documents in your browser (`idb`) or work with local `.inkfinite` files in the desktop app. +- Import and export editable Excalidraw and Obsidian Canvas documents in either app. - Inspect, edit, validate, and render `.inkfinite` files from scripts with the CLI. Inkfinite is under active development. [TODO.md](TODO.md) shows what is being @@ -156,6 +157,10 @@ The desktop app and CLI protect canonical writes with file locks, atomic replacement, and recovery data. A rejected or interrupted CLI mutation leaves the original canonical file unchanged. +Excalidraw and Obsidian Canvas import and export are intentionally lossy. See the +[file format guide](apps/web/src/routes/docs/reference/file-format/+page.svx) for +the supported mappings and omitted features. + ## Repository layout Inkfinite is a pnpm monorepo and Cargo workspace: diff --git a/apps/desktop/src-tauri/src/files.rs b/apps/desktop/src-tauri/src/files.rs index 8dde5e2..ce5c0bd 100644 --- a/apps/desktop/src-tauri/src/files.rs +++ b/apps/desktop/src-tauri/src/files.rs @@ -3,6 +3,8 @@ use std::path::Path; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; +const MAX_INTERCHANGE_BYTES: usize = 16 * 1024 * 1024; + #[derive(serde::Serialize, serde::Deserialize)] pub struct FileEntry { pub path: String, @@ -10,6 +12,15 @@ pub struct FileEntry { pub is_dir: bool, } +/// UTF-8 contents selected from an external editable canvas file. +#[derive(serde::Serialize)] +pub struct InterchangeFile { + /// File name shown to the user. + pub name: String, + /// Complete JSON source. + pub contents: String, +} + /// Read directory contents and return matching files #[tauri::command] pub fn read_directory(directory: String, pattern: Option) -> Result, String> { @@ -137,6 +148,61 @@ pub async fn pick_save_document(app: AppHandle, default_name: Option) -> Ok(path) } +/// Opens an external editable canvas document and returns its UTF-8 contents. +#[tauri::command] +pub async fn pick_interchange_document(app: AppHandle) -> Result, String> { + let Some(selected) = app + .dialog() + .file() + .add_filter("Editable Canvas Files", &["excalidraw", "canvas"]) + .blocking_pick_file() + else { + return Ok(None); + }; + let path = selected + .into_path() + .map_err(|error| format!("Failed to resolve selected import path: {error}"))?; + let metadata = fs::metadata(&path).map_err(|error| format!("Failed to inspect selected import: {error}"))?; + if metadata.len() > MAX_INTERCHANGE_BYTES as u64 { + return Err("The selected file is larger than the 16 MB import limit.".into()); + } + let contents = + fs::read_to_string(&path).map_err(|error| format!("Failed to read selected import as UTF-8 JSON: {error}"))?; + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Imported canvas".into()); + Ok(Some(InterchangeFile { name, contents })) +} + +/// Saves an external editable canvas export without changing the canonical document path. +#[tauri::command] +pub async fn save_interchange_document( + app: AppHandle, default_name: String, extension: String, contents: String, +) -> Result { + let allowed = matches!(extension.as_str(), "excalidraw" | "canvas"); + if !allowed { + return Err(format!("Unsupported export extension: {extension}")); + } + let Some(selected) = app + .dialog() + .file() + .add_filter("Editable Canvas File", &[extension.as_str()]) + .set_file_name(default_name) + .blocking_save_file() + else { + return Ok(false); + }; + let mut path = selected + .into_path() + .map_err(|error| format!("Failed to resolve selected export path: {error}"))?; + if path.extension().is_none() { + path.set_extension(&extension); + } + fs::write(&path, contents).map_err(|error| format!("Failed to save editable canvas export: {error}"))?; + Ok(true) +} + /// Pick a workspace directory using the system folder picker #[tauri::command] pub async fn pick_workspace_directory(app: AppHandle) -> Result, String> { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7021a18..4effd15 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -47,6 +47,8 @@ pub fn run() { files::read_directory, files::pick_open_document, files::pick_save_document, + files::pick_interchange_document, + files::save_interchange_document, files::rename_file, files::delete_file, files::pick_workspace_directory, diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs index eca04c8..5e53f03 100644 --- a/apps/desktop/src-tauri/src/menu.rs +++ b/apps/desktop/src-tauri/src/menu.rs @@ -8,6 +8,9 @@ pub const FILE_MENU_EVENT: &str = "inkfinite-file-menu"; const NEW_BOARD: &str = "file.new-board"; const OPEN_BOARD: &str = "file.open-board"; const SAVE_BOARD_AS: &str = "file.save-board-as"; +const IMPORT_CANVAS: &str = "file.import-canvas"; +const EXPORT_EXCALIDRAW: &str = "file.export-excalidraw"; +const EXPORT_JSON_CANVAS: &str = "file.export-json-canvas"; /// Builds the platform-standard menu with Inkfinite document commands added to File. pub fn build(app: &AppHandle) -> tauri::Result> { @@ -20,9 +23,26 @@ pub fn build(app: &AppHandle) -> tauri::Result> { if let Some(file_menu) = file_menu { let new_board = MenuItem::with_id(app, NEW_BOARD, "New Board", true, Some("CmdOrCtrl+N"))?; let open_board = MenuItem::with_id(app, OPEN_BOARD, "Open…", true, Some("CmdOrCtrl+O"))?; + let import_canvas = MenuItem::with_id(app, IMPORT_CANVAS, "Import Editable Canvas…", true, None::<&str>)?; let 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( + app, + EXPORT_JSON_CANVAS, + "Export as Obsidian Canvas…", + true, + None::<&str>, + )?; let separator = PredefinedMenuItem::separator(app)?; - let items: [&dyn IsMenuItem; 4] = [&new_board, &open_board, &save_board_as, &separator]; + let items: [&dyn IsMenuItem; 7] = [ + &new_board, + &open_board, + &import_canvas, + &save_board_as, + &export_excalidraw, + &export_json_canvas, + &separator, + ]; file_menu.insert_items(&items, 0)?; log::info!("installed native File menu commands"); } else { @@ -39,6 +59,9 @@ pub fn handle_event(app: &AppHandle, event: tauri::menu::MenuEven NEW_BOARD => "new", OPEN_BOARD => "open", SAVE_BOARD_AS => "save-as", + IMPORT_CANVAS => "import", + EXPORT_EXCALIDRAW => "export-excalidraw", + EXPORT_JSON_CANVAS => "export-json-canvas", _ => return, }; log::info!("native File menu selected: id={menu_id}, action={action}"); diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 073cfda..77a9f7c 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -1,4 +1,4 @@ -import type { DesktopFileOps, FileHandle } from '@inkfinite/core'; +import { PageRecord, ShapeRecord, type BoardExport, type DesktopFileOps, type FileHandle } from '@inkfinite/core'; import type { ApplyAuthorization, ChangeHash, DocumentSnapshot, Proposal, TransactionDraft } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it } from 'vitest'; import { @@ -135,6 +135,15 @@ function createFakeSessionApi() { page.name = operation.name; page.version += 1; } + } else if (operation.type === 'create_shape') { + next.document.shapes[operation.shape.id] = structuredClone(operation.shape); + if (operation.shape.parent.kind === 'layer') { + next.document.layers[operation.shape.parent.id]?.shape_ids.push(operation.shape.id); + } else { + next.document.shapes[operation.shape.parent.id]?.child_ids.push(operation.shape.id); + } + } else if (operation.type === 'create_binding') { + next.document.bindings[operation.binding.id] = structuredClone(operation.binding); } } session.undo.push(structuredClone(session.status.snapshot)); @@ -506,4 +515,40 @@ describe('Rust-backed desktop session repository', () => { expect(committed.pages[pageId].name).toBe('Accepted proposal'); expect((await repo.loadDoc(opened.boardId)).pages[pageId].name).toBe('Accepted proposal'); }); + + it('imports editable canvas content into the Rust-backed canonical document', async () => { + fileOps.setSavePath('/tmp/imported.inkfinite'); + const page = PageRecord.create('Imported canvas', 'page:imported'); + const shape = ShapeRecord.createRect( + page.id, + 12, + 24, + { w: 160, h: 90, fill: '#ffffff', stroke: '#111827', radius: 8 }, + 'shape:imported' + ); + const snapshot: BoardExport = { + board: { id: 'board:source', name: 'Imported board', createdAt: 1, updatedAt: 1 }, + doc: { + pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, + shapes: { [shape.id]: shape }, + bindings: {} + }, + order: { pageIds: [page.id], shapeOrder: { [page.id]: [shape.id] } } + }; + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + + const boardId = await repo.importBoard(snapshot); + const imported = await repo.loadDoc(boardId); + const importedPageId = imported.order.pageIds[0]; + + expect(imported.pages[importedPageId].name).toBe('Imported canvas'); + expect(imported.shapes[shape.id]).toMatchObject({ + id: shape.id, + pageId: importedPageId, + x: 12, + y: 24, + type: 'rect' + }); + expect(session.files.get('/tmp/imported.inkfinite')?.document.shapes[shape.id]).toBeDefined(); + }); }); diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index d5ceafc..a1cb945 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -541,17 +541,29 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: } async function importBoard(snapshot: BoardExport): Promise { - if (!currentStatus || !currentBoard) throw new Error('No board loaded'); const path = await fileOps.showSaveDialog(`${safeFileStem(snapshot.board.name)}.inkfinite`); if (!path) throw new Error('Save cancelled'); - const saveAs = currentIsDraft ? api.saveDraftAs : api.saveAs; - const saved = await saveAs({ - session_id: currentStatus.session_id, + if (currentStatus) { + if (currentStatus.dirty) await saveCurrentSession(); + await closeCurrentSession(); + } + const opened = await api.createDocument({ path, - expected_heads: currentStatus.snapshot.heads + document_id: createId('board'), + actor_id: ACTOR_ID, + page_name: snapshot.doc.pages[snapshot.order.pageIds[0]]?.name ?? 'Page 1' + }); + setCurrentState(opened.status, snapshot.board.name); + if (!currentBoard || !currentDoc) throw new Error('Failed to create the imported Inkfinite document'); + const imported = rebaseImportedDocument(snapshot, currentDoc); + await applyDocPatch(currentBoard.id, { + upserts: { + pages: Object.values(imported.pages), + shapes: Object.values(imported.shapes), + bindings: Object.values(imported.bindings) + }, + order: imported.order }); - updateStatus(saved.status); - currentIsDraft = false; currentBoard = { ...currentBoard, name: snapshot.board.name, updatedAt: Date.now() }; if (currentFile) await fileOps.addRecentFile(currentFile); return currentBoard.id; @@ -940,6 +952,59 @@ function applyPatch(doc: LoadedDoc, patch: DocPatch): LoadedDoc { return next; } +function rebaseImportedDocument(snapshot: BoardExport, destination: LoadedDoc): LoadedDoc { + const sourcePageId = snapshot.order.pageIds[0] ?? Object.keys(snapshot.doc.pages)[0]; + const destinationPageId = destination.order.pageIds[0] ?? Object.keys(destination.pages)[0]; + const destinationPage = destination.pages[destinationPageId]; + const destinationLayerId = destinationPage?.layerIds?.[0] ?? Object.keys(destination.layers ?? {})[0]; + if (!sourcePageId || !destinationPageId || !destinationPage || !destinationLayerId) { + throw new Error('Imported and destination documents must each contain a page and layer'); + } + const sourcePage = snapshot.doc.pages[sourcePageId]; + const sourceShapeIds = snapshot.order.shapeOrder?.[sourcePageId] ?? sourcePage?.shapeIds ?? []; + const shapes = Object.fromEntries( + sourceShapeIds.flatMap((id) => { + const shape = snapshot.doc.shapes[id]; + return shape + ? [[id, { ...structuredClone(shape), pageId: destinationPageId, layerId: destinationLayerId }]] + : []; + }) + ) as LoadedDoc['shapes']; + const bindings = Object.fromEntries( + Object.entries(snapshot.doc.bindings).filter( + ([, binding]) => shapes[binding.fromShapeId] && shapes[binding.toShapeId] + ) + ); + const layer: EditorLayerRecord = { + ...(destination.layers?.[destinationLayerId] ?? { + id: destinationLayerId, + pageId: destinationPageId, + name: 'Imported', + visible: true, + locked: false, + opacity: 1 + }), + shapeIds: sourceShapeIds.filter((id) => Boolean(shapes[id])) + }; + const page: EditorPageRecord = { + ...destinationPage, + name: sourcePage?.name ?? destinationPage.name, + shapeIds: [...layer.shapeIds], + layerIds: [destinationLayerId] + }; + return { + pages: { [destinationPageId]: page }, + layers: { [destinationLayerId]: layer }, + shapes, + bindings, + order: { + pageIds: [destinationPageId], + shapeOrder: { [destinationPageId]: [...layer.shapeIds] }, + layers: { [destinationLayerId]: layer } + } + }; +} + function documentFromLoadedDoc(doc: LoadedDoc, current: DocumentSnapshot, actor: string): DocumentSnapshot { const pages = structuredClone(current.document.pages); const layers = structuredClone(current.document.layers); diff --git a/apps/desktop/src/lib/platform.ts b/apps/desktop/src/lib/platform.ts index a9deb81..cbb1874 100644 --- a/apps/desktop/src/lib/platform.ts +++ b/apps/desktop/src/lib/platform.ts @@ -16,6 +16,15 @@ export function createDesktopPlatformAdapter(): EditorPlatformAdapter { desktop: repo, sink: createDesktopPersistenceSink(repo, status), status, + interchange: { + pickImport: () => invoke<{ name: string; contents: string } | null>('pick_interchange_document'), + saveExport: (file, defaultStem) => + invoke('save_interchange_document', { + defaultName: `${defaultStem}.${file.extension}`, + extension: file.extension, + contents: file.contents + }) + }, subscribeFileMenu(listener) { let active = true; let stop: (() => void) | undefined; diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index a77438b..873811c 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,4 +1,9 @@ -import { type DocPatch, type PersistenceSink, type PersistentDocRepo } from '@inkfinite/core'; +import { + type DocPatch, + type InterchangeExport, + type PersistenceSink, + type PersistentDocRepo +} from '@inkfinite/core'; import { createStatusStore } from '@inkfinite/ui/editor'; import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui/editor'; import { liveQuery } from 'dexie'; @@ -83,6 +88,7 @@ export function createDexieSession( repo, sink: trackedSink, status, + interchange: createBrowserInterchangeFiles(), inspectBoard: (boardId) => getBoardInspectorData(database, boardId), setActiveBoard(boardId) { if (activeBoardId === boardId) return; @@ -105,6 +111,71 @@ export function createDexieSession( }; } +/** Creates browser-backed file selection and download operations. */ +export function createBrowserInterchangeFiles() { + return { + pickImport(): Promise<{ name: string; contents: string } | null> { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.excalidraw,.canvas,application/json'; + input.hidden = true; + const finish = (value: { name: string; contents: string } | null) => { + input.remove(); + resolve(value); + }; + input.addEventListener('cancel', () => finish(null), { once: true }); + input.addEventListener( + 'change', + () => { + const file = input.files?.[0]; + if (!file) { + finish(null); + return; + } + if (file.size > 16 * 1024 * 1024) { + 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)}`)); + } + ); + }, + { once: true } + ); + document.body.appendChild(input); + input.click(); + }); + }, + async saveExport(file: InterchangeExport, defaultStem: string): Promise { + const blob = new Blob([file.contents], { type: file.mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${safeFileStem(defaultStem)}.${file.extension}`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + return true; + } + }; +} + +function safeFileStem(value: string) { + return ( + value + .trim() + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/^\.+|\.+$/g, '') || 'Untitled' + ); +} + function hasPatchChanges(patch: DocPatch): boolean { const upserts = patch.upserts; if (upserts?.pages?.length || upserts?.shapes?.length || upserts?.bindings?.length) diff --git a/apps/web/src/lib/tests/Toolbar.accessibility.test.ts b/apps/web/src/lib/tests/Toolbar.accessibility.test.ts index 54527c3..fbf1c6d 100644 --- a/apps/web/src/lib/tests/Toolbar.accessibility.test.ts +++ b/apps/web/src/lib/tests/Toolbar.accessibility.test.ts @@ -41,6 +41,10 @@ describe('Toolbar accessibility', () => { expect(exportButton?.getAttribute('aria-label')).toBe('Export drawing'); expect(exportButton?.getAttribute('aria-haspopup')).toBe('true'); expect(exportButton?.getAttribute('aria-expanded')).toBe('false'); + const importButton = container.querySelector('.toolbar__import-button'); + expect(importButton?.getAttribute('aria-label')).toBe( + 'Import Excalidraw or Obsidian Canvas document' + ); }); it('should have proper menu roles when export menu is open', async () => { @@ -59,7 +63,7 @@ describe('Toolbar accessibility', () => { expect(exportMenu?.getAttribute('aria-label')).toBe('Export options'); const menuItems = container.querySelectorAll('.toolbar__export-menu .toolbar__menu-item'); - expect(menuItems.length).toBe(3); + expect(menuItems.length).toBe(5); menuItems.forEach((item) => { expect(item.getAttribute('role')).toBe('menuitem'); expect(item.getAttribute('aria-label')).toBeTruthy(); diff --git a/apps/web/src/routes/docs/reference/file-format/+page.svx b/apps/web/src/routes/docs/reference/file-format/+page.svx index 47599c4..b852c5e 100644 --- a/apps/web/src/routes/docs/reference/file-format/+page.svx +++ b/apps/web/src/routes/docs/reference/file-format/+page.svx @@ -1,32 +1,67 @@ File Format · Inkfinite documentation - + -# File Format +# File format -Understand native `.inkfinite` files and the formats Inkfinite can export. +Inkfinite uses `.inkfinite` as its native document format. The editor can also import and export Excalidraw and Obsidian Canvas files when you need to move editable content between applications.

Native files

-*todo* +A canonical `.inkfinite` file contains the document and its Automerge change history. Keep this file as the source of truth when you expect to continue editing in Inkfinite. -

Versions and compatibility

+The desktop app protects native writes with a file lock, atomic replacement, and recovery data. The web app stores the same editor projection in IndexedDB. -*todo* +Opening and saving native files is separate from editable-format import and export. Importing an external file creates a new Inkfinite document. Exporting does not change the current document's native path or saved state. -

exports

+

Editable interchange

-*todo* +Use **Import** in the editor toolbar to select an Excalidraw `.excalidraw` file or an Obsidian Canvas `.canvas` file. Use **Export** to write the current page in either format. The desktop app offers the same commands in its File menu. -

JSON

+Imports are limited to 16 MB of UTF-8 JSON. Inkfinite rejects malformed geometry and duplicate identifiers instead of guessing at corrupt data. It omits edges and bindings that refer to missing or unsupported shapes and includes them in the conversion notes. -*todo* +Both formats describe one canvas, so Inkfinite imports them as a one-page document and exports only the selected page. An export reports this when the source document contains more than one page. -

SVG

+

Excalidraw

-*todo* +Inkfinite reads and writes Excalidraw v2 scene JSON. The converter handles rectangles, ellipses, lines, arrows, text, freehand strokes, groups, frames, arrow bindings, and arrow labels. -

Recovery

+| Excalidraw content | Inkfinite result | +| -------------------------------- | ---------------------------------------------------- | +| Rectangle, ellipse, text | Matching editable shape | +| Line or arrow | Matching line or arrow; extra line vertices are lost | +| Bound arrow and label | Arrow bindings and label | +| Freedraw | Freehand stroke | +| Group or frame | Flat Inkfinite group | +| Embeddable URL | Markdown link card | +| Markdown exported from Inkfinite | Literal Excalidraw text | +| Image, iframe, or diamond | Omitted with a conversion note | -*todo* +Excalidraw's rough rendering, fill patterns, canvas settings, nested group hierarchy, some arrowhead styles, and exact font metrics do not have Inkfinite equivalents. Inkfinite converts rotation around Excalidraw's center-based origin into its own top-left transform so rotated supported shapes keep their placement. + +Embedded Excalidraw images remain unsupported until Inkfinite has an editable image shape. The importer reports both omitted image elements and embedded file data. + +

Obsidian Canvas

+ +Inkfinite implements the JSON Canvas 1.0 structure used by Obsidian `.canvas` files. + +| JSON Canvas content | Inkfinite result | +| ------------------- | --------------------------------------------------- | +| Text node | Markdown card | +| File node | Markdown wiki link | +| Link node | Markdown link | +| Group node | Flat Inkfinite group; label and background are lost | +| Edge between nodes | Bound Inkfinite arrow | + +JSON Canvas does not represent general drawing primitives, freehand strokes, layers, rotation, or free-floating arrows. When exporting, Inkfinite writes text and Markdown as cards, bound arrows as edges, and groups around supported cards. Other drawing shapes are omitted and listed in the conversion notes. Rotated cards use their axis-aligned bounds. + +File nodes refer to paths in an Obsidian vault. Inkfinite preserves those references as Markdown links but does not copy or resolve the target attachments. + +

Image and SVG exports

+ +PNG and SVG are presentation exports rather than editable interchange formats. PNG captures the current viewport. SVG can export the selected shapes or all shapes on the current page. + +Use `.inkfinite`, `.excalidraw`, or `.canvas` when another editor needs to continue working with individual objects. diff --git a/packages/core/src/camera.ts b/packages/core/src/camera.ts index d06ffac..9c94e41 100644 --- a/packages/core/src/camera.ts +++ b/packages/core/src/camera.ts @@ -1,4 +1,4 @@ -import { Vec2 } from "./math"; +import { clamp, Vec2 } from "./math"; /** * Camera represents the viewport into the infinite canvas @@ -108,7 +108,7 @@ export const Camera = { * @returns Camera with clamped zoom */ clampZoom(camera: Camera, minZoom = 0.1, maxZoom = 10): Camera { - const clampedZoom = Math.max(minZoom, Math.min(maxZoom, camera.zoom)); + const clampedZoom = clamp(camera.zoom, minZoom, maxZoom); if (clampedZoom === camera.zoom) { return camera; diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 15223c2..783ca24 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -70,7 +70,7 @@ function rectBounds(shape: RectShape): Box2 { { x: w, y: h }, { x: 0, y: h } ]; - const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); + const rotatedCorners = corners.map((corner) => Vec2Ops.rotate(corner, rot)); const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); return Box2Ops.fromPoints(translatedCorners); } @@ -92,7 +92,7 @@ function ellipseBounds(shape: EllipseShape): Box2 { { x: w, y: h }, { x: 0, y: h } ]; - const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); + const rotatedCorners = corners.map((corner) => Vec2Ops.rotate(corner, rot)); const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); return Box2Ops.fromPoints(translatedCorners); } @@ -111,7 +111,7 @@ function lineBounds(shape: LineShape): Box2 { return Box2Ops.fromPoints(translatedPoints); } - const rotatedPoints = points.map((p) => rotatePoint(p, rot)); + const rotatedPoints = points.map((p) => Vec2Ops.rotate(p, rot)); const translatedPoints = rotatedPoints.map((p) => ({ x: p.x + x, y: p.y + y })); return Box2Ops.fromPoints(translatedPoints); } @@ -129,7 +129,7 @@ function arrowBounds(shape: ArrowShape): Box2 { return Box2Ops.fromPoints(translatedPoints); } - const rotatedPoints = points.map((p) => rotatePoint(p, rot)); + const rotatedPoints = points.map((p) => Vec2Ops.rotate(p, rot)); const translatedPoints = rotatedPoints.map((p) => ({ x: p.x + x, y: p.y + y })); return Box2Ops.fromPoints(translatedPoints); } @@ -155,7 +155,7 @@ function textBounds(shape: TextShape): Box2 { { x: 0, y: height } ]; - const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); + const rotatedCorners = corners.map((corner) => Vec2Ops.rotate(corner, rot)); const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); return Box2Ops.fromPoints(translatedCorners); } @@ -180,7 +180,7 @@ function markdownBounds(shape: MarkdownShape): Box2 { { x: width, y: height }, { x: 0, y: height } ]; - const rotatedCorners = corners.map((corner) => rotatePoint(corner, rot)); + const rotatedCorners = corners.map((corner) => Vec2Ops.rotate(corner, rot)); const translatedCorners = rotatedCorners.map((corner) => ({ x: corner.x + x, y: corner.y + y })); return Box2Ops.fromPoints(translatedCorners); } @@ -271,12 +271,6 @@ function strokeBounds(shape: StrokeShape): Box2 { * @param theta - Rotation angle in radians * @returns Rotated point */ -function rotatePoint(p: Vec2, theta: number): Vec2 { - const cos = Math.cos(theta); - const sin = Math.sin(theta); - return { x: p.x * cos - p.y * sin, y: p.x * sin + p.y * cos }; -} - /** * Check if a point is inside a rectangle shape * @@ -468,7 +462,7 @@ function worldToLocal(p: Vec2, shapeX: number, shapeY: number, shapeRot: number) return translated; } - return rotatePoint(translated, -shapeRot); + return Vec2Ops.rotate(translated, -shapeRot); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c29b58..20bfdce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from './cursor'; export * from './export'; export * from './geom'; export * from './history'; +export * from './interchange'; export * from './layers'; export * from './math'; export * from './model'; diff --git a/packages/core/src/interchange.ts b/packages/core/src/interchange.ts new file mode 100644 index 0000000..94c417c --- /dev/null +++ b/packages/core/src/interchange.ts @@ -0,0 +1,54 @@ +import type { BoardExport } from './persistence/document'; +import { exportExcalidraw, importExcalidraw } from './interchange/excalidraw'; +import { exportJsonCanvas, importJsonCanvas } from './interchange/json-canvas'; +import { object } from './interchange/shared'; + +/** External editable document formats supported by Inkfinite. */ +export type InterchangeFormat = 'excalidraw' | 'json-canvas'; + +/** 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[] }; + +/** Serialized external content and the losses encountered while writing it. */ +export type InterchangeExport = { + format: InterchangeFormat; + contents: string; + extension: 'canvas' | 'excalidraw'; + mimeType: 'application/json'; + warnings: InterchangeWarning[]; +}; + +const MAX_IMPORT_BYTES = 16 * 1024 * 1024; + +/** Detects and imports a supported editable canvas document. */ +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.'); + } + let value: unknown; + try { + value = JSON.parse(contents); + } catch { + throw new Error('The selected file does not contain valid JSON.'); + } + const root = object(value, 'document'); + if (root.type === 'excalidraw' || fileName.toLowerCase().endsWith('.excalidraw')) { + return importExcalidraw(root, fileName); + } + if (Array.isArray(root.nodes) || Array.isArray(root.edges) || fileName.toLowerCase().endsWith('.canvas')) { + return importJsonCanvas(root, fileName); + } + throw new Error('Choose an Excalidraw (.excalidraw) or Obsidian Canvas (.canvas) file.'); +} + +/** Exports one Inkfinite page to a supported editable canvas format. */ +export function exportInterchange( + snapshot: BoardExport, + format: InterchangeFormat, + pageId?: string +): InterchangeExport { + return format === 'excalidraw' ? exportExcalidraw(snapshot, pageId) : exportJsonCanvas(snapshot, pageId); +} diff --git a/packages/core/src/interchange/excalidraw.ts b/packages/core/src/interchange/excalidraw.ts new file mode 100644 index 0000000..f8c539e --- /dev/null +++ b/packages/core/src/interchange/excalidraw.ts @@ -0,0 +1,624 @@ +import { shapeBounds } from '../geom'; +import { clamp, Vec2 } from '../math'; +import { BindingRecord, ensureDocumentLayers, ShapeRecord, type ArrowShape, type ShapeRecord as Shape } from '../model'; +import type { BoardExport } from '../persistence/document'; +import type { InterchangeExport, InterchangeImport } from '../interchange'; +import { + addShape, + bindingFor, + blankSnapshot, + finiteNumber, + inkId, + isObject, + object, + optionalArray, + requiredString, + selectPage, + WarningCollector, + type JsonObject +} from './shared'; + +type Point = { x: number; y: number }; + +const DEFAULT_FONT = 'Arial'; +const DEFAULT_FONT_SIZE = 20; +const EXCALIDRAW_FONTS: Record = { 2: 'Helvetica', 3: 'Cascadia Code', 5: 'Excalifont' }; + +/** Converts a parsed Excalidraw scene into an Inkfinite snapshot. */ +export function importExcalidraw(root: JsonObject, fileName: string): InterchangeImport { + if (root.type !== 'excalidraw') throw new Error('The selected file is not an Excalidraw scene.'); + const elements = optionalArray(root.elements, 'elements'); + const warnings = new WarningCollector(); + const { snapshot, pageId, layerId } = blankSnapshot(fileName); + const imported = new Map(); + const sourceById = new Map(); + const pendingLabels = new Map(); + + for (const [index, value] of elements.entries()) { + const element = object(value, `elements[${index}]`); + const id = requiredString(element.id, `elements[${index}].id`); + if (sourceById.has(id)) throw new Error(`Excalidraw element ID ${JSON.stringify(id)} is duplicated.`); + sourceById.set(id, element); + if (element.isDeleted === true) continue; + if (element.type === 'text' && typeof element.containerId === 'string') { + pendingLabels.set(element.containerId, typeof element.text === 'string' ? element.text : ''); + } + } + + for (const [id, element] of sourceById) { + if (element.isDeleted === true || (element.type === 'text' && typeof element.containerId === 'string')) + continue; + const type = requiredString(element.type, `element ${id}.type`); + const x = finiteNumber(element.x, `element ${id}.x`); + const y = finiteNumber(element.y, `element ${id}.y`); + const width = nonNegativeNumber(element.width, `element ${id}.width`); + const height = nonNegativeNumber(element.height, `element ${id}.height`); + const angle = optionalFiniteNumber(element.angle, 0, `element ${id}.angle`); + const rotationOffset = centerRotationOffset(width, height, angle); + const origin = { x: x + rotationOffset.x, y: y + rotationOffset.y }; + const shapeId = inkId('excalidraw', id); + const stroke = stringOr(element.strokeColor, '#1e1e1e'); + const background = stringOr(element.backgroundColor, 'transparent'); + const fill = background === 'transparent' ? '' : background; + let shape: Shape | null = null; + + switch (type) { + case 'rectangle': + shape = ShapeRecord.createRect( + pageId, + origin.x, + origin.y, + { + w: width, + h: height, + fill, + stroke, + radius: element.roundness ? Math.min(width, height) * 0.1 : 0 + }, + shapeId + ); + break; + case 'ellipse': + shape = ShapeRecord.createEllipse( + pageId, + origin.x, + origin.y, + { w: width, h: height, fill, stroke }, + shapeId + ); + break; + case 'line': { + const points = excalidrawPoints(element.points, `element ${id}.points`); + shape = ShapeRecord.createLine( + pageId, + origin.x, + origin.y, + { + a: points[0] ?? { x: 0, y: 0 }, + b: points.at(-1) ?? { x: width, y: height }, + stroke, + width: optionalFiniteNumber(element.strokeWidth, 2, `element ${id}.strokeWidth`) + }, + shapeId + ); + if (points.length > 2) + warnings.add( + 'excalidraw-line-points', + 'Multi-point Excalidraw lines were reduced to their endpoints.' + ); + break; + } + case 'arrow': { + const points = excalidrawPoints(element.points, `element ${id}.points`); + shape = ShapeRecord.createArrow( + pageId, + origin.x, + origin.y, + { + points: + points.length >= 2 + ? points + : [ + { x: 0, y: 0 }, + { x: width, y: height } + ], + start: { kind: 'free' }, + end: { kind: 'free' }, + style: { + stroke, + width: optionalFiniteNumber(element.strokeWidth, 2, `element ${id}.strokeWidth`), + headStart: element.startArrowhead !== null && element.startArrowhead !== undefined, + headEnd: element.endArrowhead !== null, + ...(element.strokeStyle === 'dashed' ? { dash: [8, 6] } : {}) + }, + routing: { kind: element.elbowed === true ? 'orthogonal' : 'straight' }, + ...(pendingLabels.get(id) + ? { label: { text: pendingLabels.get(id)!, align: 'center', offset: 0 } as const } + : {}) + }, + shapeId + ); + break; + } + case 'text': + shape = ShapeRecord.createText( + pageId, + origin.x, + origin.y, + { + text: stringOr(element.text, ''), + fontSize: optionalFiniteNumber(element.fontSize, DEFAULT_FONT_SIZE, `element ${id}.fontSize`), + fontFamily: + typeof element.fontFamily === 'number' + ? (EXCALIDRAW_FONTS[element.fontFamily] ?? DEFAULT_FONT) + : DEFAULT_FONT, + color: stroke, + w: width || undefined + }, + shapeId + ); + break; + case 'freedraw': { + const points = excalidrawPointTuples(element.points, `element ${id}.points`); + const pressures = Array.isArray(element.pressures) ? element.pressures : []; + shape = ShapeRecord.createStroke( + pageId, + origin.x, + origin.y, + { + points: points.map((point, pointIndex) => [ + point[0], + point[1], + typeof pressures[pointIndex] === 'number' && Number.isFinite(pressures[pointIndex]) + ? pressures[pointIndex] + : undefined + ]), + style: { color: stroke, opacity: 1 }, + brush: { + size: optionalFiniteNumber(element.strokeWidth, 2, `element ${id}.strokeWidth`) * 2, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: element.simulatePressure !== false + } + }, + shapeId + ); + break; + } + case 'embeddable': { + const url = typeof element.link === 'string' ? element.link : ''; + shape = ShapeRecord.createMarkdown( + pageId, + origin.x, + origin.y, + { + md: url ? `[${url}](${url})` : 'Unsupported embedded content', + w: width, + h: height, + fontSize: 16, + fontFamily: DEFAULT_FONT, + color: stroke, + bg: fill || undefined, + border: stroke + }, + shapeId + ); + warnings.add('excalidraw-embeddable', 'Embedded web content was imported as a Markdown link.'); + break; + } + case 'frame': + case 'magicframe': + warnings.add('excalidraw-frame', 'Excalidraw frames were retained only as element groups.'); + continue; + case 'diamond': + case 'image': + case 'iframe': + warnings.add(`excalidraw-${type}`, `Excalidraw ${type} elements are not supported and were omitted.`); + continue; + default: + warnings.add('excalidraw-element-type', `Unsupported Excalidraw ${type} elements were omitted.`); + continue; + } + + shape.rot = angle; + shape.opacity = clamp(optionalFiniteNumber(element.opacity, 100, `element ${id}.opacity`) / 100, 0, 1); + shape.layerId = layerId; + shape.agentEditable = element.locked !== true; + const groupIds = Array.isArray(element.groupIds) + ? element.groupIds.filter((value): value is string => typeof value === 'string') + : []; + const group = typeof element.frameId === 'string' ? element.frameId : groupIds.at(-1); + if (group) shape.groupId = inkId('excalidraw-group', group); + if (groupIds.length > 1) + warnings.add('excalidraw-nested-group', 'Nested Excalidraw groups were flattened to one Inkfinite group.'); + imported.set(id, shape); + addShape(snapshot.doc, pageId, layerId, shape); + } + + for (const [id, element] of sourceById) { + const shape = imported.get(id); + if (!shape || shape.type !== 'arrow') continue; + for (const handle of ['start', 'end'] as const) { + const bindingValue = element[`${handle}Binding`]; + if (!isObject(bindingValue) || typeof bindingValue.elementId !== 'string') continue; + const target = imported.get(bindingValue.elementId); + if (!target) { + warnings.add('excalidraw-dangling-binding', 'Bindings to omitted or missing elements were removed.'); + continue; + } + const binding = BindingRecord.create( + shape.id, + target.id, + handle, + { kind: 'center' }, + inkId(`excalidraw-binding-${handle}`, id) + ); + snapshot.doc.bindings[binding.id] = binding; + shape.props[handle] = { kind: 'bound', bindingId: binding.id }; + } + } + + if (isObject(root.files) && Object.keys(root.files).length > 0) { + warnings.add( + 'excalidraw-files', + 'Embedded Excalidraw files were omitted because Inkfinite has no image shape yet.', + Object.keys(root.files).length + ); + } + if (isObject(root.appState) && root.appState.viewBackgroundColor !== undefined) { + warnings.add('excalidraw-app-state', 'Excalidraw canvas background and editor settings were not imported.'); + } + return { format: 'excalidraw', snapshot, warnings: warnings.values() }; +} + +/** Converts one Inkfinite page into an Excalidraw v2 scene. */ +export function exportExcalidraw(snapshot: BoardExport, requestedPageId?: string): InterchangeExport { + const warnings = new WarningCollector(); + const document = ensureDocumentLayers(snapshot.doc); + const page = selectPage(document, snapshot.order.pageIds, requestedPageId, warnings); + const elements: JsonObject[] = []; + const exportedIds = new Set(); + const bindingsByTarget = new Map>(); + + for (const layerId of page.layerIds ?? []) { + const layer = document.layers?.[layerId]; + if (!layer) continue; + if (!layer.visible) { + warnings.add('hidden-layer', 'Shapes on hidden layers were omitted.'); + continue; + } + for (const shapeId of layer.shapeIds) { + const shape = document.shapes[shapeId]; + if (!shape) continue; + const opacity = clamp((shape.opacity ?? 1) * layer.opacity, 0, 1); + const element = excalidrawElement(shape, opacity, layer.locked, warnings); + if (!element) continue; + elements.push(element); + exportedIds.add(shape.id); + if (shape.type === 'arrow') { + for (const handle of ['start', 'end'] as const) { + const binding = bindingFor(document, shape, handle); + if (!binding) continue; + element[`${handle}Binding`] = { elementId: binding.toShapeId, focus: 0, gap: 1 }; + const targetBindings = bindingsByTarget.get(binding.toShapeId) ?? []; + targetBindings.push({ id: shape.id, type: 'arrow' }); + bindingsByTarget.set(binding.toShapeId, targetBindings); + } + } + } + } + + const labels: JsonObject[] = []; + for (const element of elements) { + const id = element.id as string; + element.boundElements = bindingsByTarget.get(id) ?? []; + if (element.type === 'arrow') { + const shape = document.shapes[id] as ArrowShape; + if (shape.props.label?.text) { + const textId = `${id}:label`; + element.boundElements = [...((element.boundElements as unknown[]) ?? []), { id: textId, type: 'text' }]; + labels.push(excalidrawArrowLabel(shape, textId, element)); + } + } + } + elements.push(...labels); + for (const element of elements) { + for (const handle of ['start', 'end'] as const) { + const value = element[`${handle}Binding`]; + if (isObject(value) && typeof value.elementId === 'string' && !exportedIds.has(value.elementId)) { + element[`${handle}Binding`] = null; + warnings.add( + 'excalidraw-missing-binding-target', + 'Bindings to omitted shapes were exported as free arrow endpoints.' + ); + } + } + } + + const scene = { + type: 'excalidraw', + version: 2, + source: 'https://inkfinite.app', + elements, + appState: { gridSize: null, viewBackgroundColor: '#ffffff' }, + files: {} + }; + return { + format: 'excalidraw', + contents: JSON.stringify(scene, null, 2) + '\n', + extension: 'excalidraw', + mimeType: 'application/json', + warnings: warnings.values() + }; +} + +function excalidrawElement( + shape: Shape, + opacity: number, + layerLocked: boolean, + warnings: WarningCollector +): JsonObject | null { + const groupIds = shape.groupId ? [shape.groupId] : []; + const locked = layerLocked || shape.agentEditable === false; + let width = 0; + let height = 0; + let localOrigin = { x: shape.x, y: shape.y }; + let specific: JsonObject; + + switch (shape.type) { + case 'rect': + width = shape.props.w; + height = shape.props.h; + specific = { + type: 'rectangle', + strokeColor: shape.props.stroke || 'transparent', + backgroundColor: shape.props.fill || 'transparent', + roundness: shape.props.radius > 0 ? { type: 3 } : null + }; + break; + case 'ellipse': + width = shape.props.w; + height = shape.props.h; + specific = { + type: 'ellipse', + strokeColor: shape.props.stroke || 'transparent', + backgroundColor: shape.props.fill || 'transparent', + roundness: null + }; + break; + case 'line': { + const normalized = normalizePoints([shape.props.a, shape.props.b], shape.x, shape.y, shape.rot); + width = normalized.width; + height = normalized.height; + localOrigin = normalized.origin; + specific = { + type: 'line', + points: normalized.points.map((point) => [point.x, point.y]), + strokeColor: shape.props.stroke, + backgroundColor: 'transparent', + strokeWidth: shape.props.width, + roundness: null, + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: null, + endArrowhead: null + }; + break; + } + case 'arrow': { + const normalized = normalizePoints(shape.props.points, shape.x, shape.y, shape.rot); + width = normalized.width; + height = normalized.height; + localOrigin = normalized.origin; + specific = { + type: 'arrow', + points: normalized.points.map((point) => [point.x, point.y]), + strokeColor: shape.props.style.stroke, + backgroundColor: 'transparent', + strokeWidth: shape.props.style.width, + strokeStyle: shape.props.style.dash ? 'dashed' : 'solid', + roundness: shape.props.routing?.kind === 'orthogonal' ? null : { type: 2 }, + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: shape.props.style.headStart ? 'arrow' : null, + endArrowhead: shape.props.style.headEnd === false ? null : 'arrow', + elbowed: shape.props.routing?.kind === 'orthogonal' + }; + break; + } + case 'text': + width = + shape.props.w ?? Math.max(shape.props.fontSize, shape.props.text.length * shape.props.fontSize * 0.6); + height = shape.props.fontSize * 1.2; + specific = { + type: 'text', + strokeColor: shape.props.color, + backgroundColor: 'transparent', + fontSize: shape.props.fontSize, + fontFamily: 2, + text: shape.props.text, + originalText: shape.props.text, + textAlign: 'left', + verticalAlign: 'top', + lineHeight: 1.25, + containerId: null, + boundElements: null, + autoResize: shape.props.w === undefined + }; + if (!/^(Arial|Helvetica|sans-serif)$/i.test(shape.props.fontFamily)) { + warnings.add('excalidraw-font', 'Fonts were replaced with Excalidraw Helvetica.'); + } + break; + case 'markdown': + width = shape.props.w; + height = shape.props.h ?? shape.props.fontSize * 10; + specific = { + type: 'text', + strokeColor: shape.props.color, + backgroundColor: 'transparent', + fontSize: shape.props.fontSize, + fontFamily: 2, + text: shape.props.md, + originalText: shape.props.md, + textAlign: 'left', + verticalAlign: 'top', + lineHeight: 1.25, + containerId: null, + boundElements: null, + autoResize: false + }; + warnings.add('excalidraw-markdown', 'Markdown blocks were exported as literal text.'); + break; + case 'stroke': { + const points = shape.props.points.map(([x, y]) => ({ x, y })); + const normalized = normalizePoints(points, shape.x, shape.y, shape.rot); + width = normalized.width; + height = normalized.height; + localOrigin = normalized.origin; + specific = { + type: 'freedraw', + points: normalized.points.map((point) => [point.x, point.y]), + pressures: shape.props.points.map((point) => point[2] ?? 0.5), + simulatePressure: shape.props.brush.simulatePressure, + strokeColor: shape.props.style.color, + backgroundColor: 'transparent', + strokeWidth: shape.props.brush.size / 2, + lastCommittedPoint: null + }; + break; + } + } + + const rotationOffset = centerRotationOffset(width, height, shape.rot); + const position = { x: localOrigin.x - rotationOffset.x, y: localOrigin.y - rotationOffset.y }; + return { + id: shape.id, + x: position.x, + y: position.y, + width, + height, + angle: shape.rot, + strokeColor: '#1e1e1e', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 2, + strokeStyle: 'solid', + roughness: 0, + opacity: Math.round(opacity * 100), + groupIds, + frameId: null, + roundness: null, + seed: stableSeed(shape.id), + version: 1, + versionNonce: stableSeed(`${shape.id}:version`), + isDeleted: false, + boundElements: [], + updated: 0, + link: null, + locked, + ...specific + }; +} + +function excalidrawArrowLabel(shape: ArrowShape, id: string, arrow: JsonObject): JsonObject { + const bounds = shapeBounds(shape); + const fontSize = 20; + const text = shape.props.label?.text ?? ''; + const width = Math.max(fontSize, text.length * fontSize * 0.6); + const height = fontSize * 1.25; + return { + id, + type: 'text', + x: (bounds.min.x + bounds.max.x - width) / 2, + y: (bounds.min.y + bounds.max.y - height) / 2, + width, + height, + angle: 0, + strokeColor: shape.props.style.stroke, + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 0, + opacity: arrow.opacity, + groupIds: arrow.groupIds, + frameId: null, + roundness: null, + seed: stableSeed(id), + version: 1, + versionNonce: stableSeed(`${id}:version`), + isDeleted: false, + boundElements: null, + updated: 0, + link: null, + locked: arrow.locked, + fontSize, + fontFamily: 2, + text, + originalText: text, + textAlign: 'center', + verticalAlign: 'middle', + lineHeight: 1.25, + containerId: shape.id, + autoResize: true + }; +} + +function centerRotationOffset(width: number, height: number, angle: number): Point { + const center = { x: width / 2, y: height / 2 }; + const rotated = Vec2.rotate(center, angle); + return { x: center.x - rotated.x, y: center.y - rotated.y }; +} + +function normalizePoints(points: Point[], x: number, y: number, angle: number) { + if (points.length === 0) return { points: [{ x: 0, y: 0 }], width: 0, height: 0, origin: { x, y } }; + const minX = Math.min(...points.map((point) => point.x)); + const minY = Math.min(...points.map((point) => point.y)); + const maxX = Math.max(...points.map((point) => point.x)); + const maxY = Math.max(...points.map((point) => point.y)); + const offset = Vec2.rotate({ x: minX, y: minY }, angle); + return { + points: points.map((point) => ({ x: point.x - minX, y: point.y - minY })), + width: maxX - minX, + height: maxY - minY, + origin: { x: x + offset.x, y: y + offset.y } + }; +} + +function excalidrawPoints(value: unknown, name: string): Point[] { + return excalidrawPointTuples(value, name).map(([x, y]) => ({ x, y })); +} + +function excalidrawPointTuples(value: unknown, name: string): Array<[number, number]> { + if (!Array.isArray(value)) throw new Error(`${name} must be an array.`); + return value.map((point, index) => { + if (!Array.isArray(point) || point.length < 2) throw new Error(`${name}[${index}] must contain x and y.`); + return [finiteNumber(point[0], `${name}[${index}][0]`), finiteNumber(point[1], `${name}[${index}][1]`)]; + }); +} + +function stableSeed(value: string) { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function stringOr(value: unknown, fallback: string) { + return typeof value === 'string' ? value : fallback; +} + +function optionalFiniteNumber(value: unknown, fallback: number, name: string) { + return value === undefined || value === null ? fallback : finiteNumber(value, name); +} + +function nonNegativeNumber(value: unknown, name: string) { + const number = finiteNumber(value, name); + if (number < 0) throw new Error(`${name} must not be negative.`); + return number; +} diff --git a/packages/core/src/interchange/json-canvas.ts b/packages/core/src/interchange/json-canvas.ts new file mode 100644 index 0000000..6f81353 --- /dev/null +++ b/packages/core/src/interchange/json-canvas.ts @@ -0,0 +1,365 @@ +import { shapeBounds } from '../geom'; +import { + BindingRecord, + ensureDocumentLayers, + ShapeRecord, + type BindingAnchor, + type ShapeRecord as Shape +} from '../model'; +import type { BoardExport } from '../persistence/document'; +import type { InterchangeExport, InterchangeImport } from '../interchange'; +import { + addShape, + bindingFor, + blankSnapshot, + finiteNumber, + inkId, + object, + optionalArray, + requiredString, + selectPage, + WarningCollector, + type JsonObject +} from './shared'; + +type Point = { x: number; y: number }; + +const DEFAULT_FONT = 'Arial'; + +/** Converts a parsed JSON Canvas document into an Inkfinite snapshot. */ +export function importJsonCanvas(root: JsonObject, fileName: string): InterchangeImport { + const warnings = new WarningCollector(); + const nodes = optionalArray(root.nodes, 'nodes'); + const edges = optionalArray(root.edges, 'edges'); + const { snapshot, pageId, layerId } = blankSnapshot(fileName); + const nodeShapes = new Map(); + const groupNodes: Array<{ id: string; x: number; y: number; width: number; height: number }> = []; + const sourceIds = new Set(); + + for (const [index, value] of nodes.entries()) { + const node = object(value, `nodes[${index}]`); + const id = requiredString(node.id, `nodes[${index}].id`); + if (sourceIds.has(id)) { + throw new Error(`JSON Canvas ID ${JSON.stringify(id)} is duplicated.`); + } + sourceIds.add(id); + const type = requiredString(node.type, `nodes[${index}].type`); + const x = finiteNumber(node.x, `nodes[${index}].x`); + const y = finiteNumber(node.y, `nodes[${index}].y`); + const width = positiveNumber(node.width, `nodes[${index}].width`); + const height = positiveNumber(node.height, `nodes[${index}].height`); + if (type === 'group') { + groupNodes.push({ id, x, y, width, height }); + if (typeof node.label === 'string' && node.label.trim()) { + warnings.add('json-canvas-group-label', 'Group labels are not editable in Inkfinite and were omitted.'); + } + if (node.background !== undefined) { + warnings.add('json-canvas-group-background', 'Group backgrounds are not supported and were omitted.'); + } + continue; + } + + let markdown: string; + switch (type) { + case 'text': + if (typeof node.text !== 'string') { + throw new Error(`nodes[${index}].text must be a string.`); + } + markdown = node.text; + break; + case 'file': { + const path = requiredString(node.file, `nodes[${index}].file`); + const subpath = typeof node.subpath === 'string' ? node.subpath : ''; + markdown = `[[${path}${subpath}]]`; + warnings.add('json-canvas-file-card', 'File cards were imported as Markdown links.'); + break; + } + case 'link': { + const url = requiredString(node.url, `nodes[${index}].url`); + markdown = `[${url}](${url})`; + warnings.add('json-canvas-link-card', 'Link cards were imported as Markdown links.'); + break; + } + default: + warnings.add('json-canvas-node-type', `Unsupported JSON Canvas ${type} nodes were omitted.`); + continue; + } + const shape = ShapeRecord.createMarkdown( + pageId, + x, + y, + { + md: markdown, + w: width, + h: height, + fontSize: 16, + fontFamily: DEFAULT_FONT, + color: '#1e1e1e', + bg: canvasColor(node.color), + border: canvasColor(node.color) + }, + inkId('json-canvas', id) + ); + shape.layerId = layerId; + nodeShapes.set(id, shape); + } + + for (const shape of nodeShapes.values()) { + const bounds = shapeBounds(shape); + const containing = groupNodes + .filter( + (group) => + bounds.min.x >= group.x && + bounds.min.y >= group.y && + bounds.max.x <= group.x + group.width && + bounds.max.y <= group.y + group.height + ) + .sort((a, b) => a.width * a.height - b.width * b.height); + if (containing[0]) shape.groupId = inkId('json-canvas-group', containing[0].id); + if (containing.length > 1) { + warnings.add( + 'json-canvas-nested-group', + 'Nested JSON Canvas groups were flattened to their innermost group.' + ); + } + } + + for (const shape of nodeShapes.values()) addShape(snapshot.doc, pageId, layerId, shape); + + for (const [index, value] of edges.entries()) { + const edge = object(value, `edges[${index}]`); + const id = requiredString(edge.id, `edges[${index}].id`); + if (sourceIds.has(id)) { + throw new Error(`JSON Canvas ID ${JSON.stringify(id)} is duplicated.`); + } + sourceIds.add(id); + const fromId = requiredString(edge.fromNode, `edges[${index}].fromNode`); + const toId = requiredString(edge.toNode, `edges[${index}].toNode`); + const from = nodeShapes.get(fromId); + const to = nodeShapes.get(toId); + if (!from || !to) { + warnings.add('json-canvas-dangling-edge', 'Edges connected to omitted or missing nodes were omitted.'); + continue; + } + const start = sidePoint(from, optionalSide(edge.fromSide)); + const end = sidePoint(to, optionalSide(edge.toSide)); + const startBinding = BindingRecord.create( + inkId('json-canvas-edge', id), + from.id, + 'start', + anchorForSide(optionalSide(edge.fromSide)), + inkId('json-canvas-binding-start', id) + ); + const endBinding = BindingRecord.create( + inkId('json-canvas-edge', id), + to.id, + 'end', + anchorForSide(optionalSide(edge.toSide)), + inkId('json-canvas-binding-end', id) + ); + const arrow = ShapeRecord.createArrow( + pageId, + start.x, + start.y, + { + points: [ + { x: 0, y: 0 }, + { x: end.x - start.x, y: end.y - start.y } + ], + start: { kind: 'bound', bindingId: startBinding.id }, + end: { kind: 'bound', bindingId: endBinding.id }, + style: { + stroke: canvasColor(edge.color) ?? '#1e1e1e', + width: 2, + headStart: edge.fromEnd === 'arrow', + headEnd: edge.toEnd !== 'none' + }, + routing: { kind: 'straight' }, + ...(typeof edge.label === 'string' && edge.label + ? { label: { text: edge.label, align: 'center', offset: 0 } as const } + : {}) + }, + inkId('json-canvas-edge', id) + ); + arrow.layerId = layerId; + addShape(snapshot.doc, pageId, layerId, arrow); + snapshot.doc.bindings[startBinding.id] = startBinding; + snapshot.doc.bindings[endBinding.id] = endBinding; + } + + return { format: 'json-canvas', snapshot, warnings: warnings.values() }; +} + +/** Converts one Inkfinite page into a JSON Canvas document. */ +export function exportJsonCanvas(snapshot: BoardExport, requestedPageId?: string): InterchangeExport { + const warnings = new WarningCollector(); + const document = ensureDocumentLayers(snapshot.doc); + const page = selectPage(document, snapshot.order.pageIds, requestedPageId, warnings); + const shapeIds = page.layerIds?.flatMap((id) => document.layers?.[id]?.shapeIds ?? []) ?? page.shapeIds; + const exportable = new Map(); + const groups = new Map(); + + for (const id of shapeIds) { + const shape = document.shapes[id]; + if (!shape) continue; + const layer = shape.layerId ? document.layers?.[shape.layerId] : undefined; + if (layer && !layer.visible) { + warnings.add('hidden-layer', 'Shapes on hidden layers were omitted.'); + continue; + } + if (shape.type !== 'text' && shape.type !== 'markdown') { + if (shape.type !== 'arrow') { + warnings.add( + 'json-canvas-drawing-shape', + 'Drawing shapes that JSON Canvas cannot represent were omitted.' + ); + } + continue; + } + const bounds = shapeBounds(shape); + if (shape.rot !== 0) + warnings.add('json-canvas-rotation', 'Rotated cards were exported using axis-aligned bounds.'); + exportable.set(shape.id, { + id: shape.id, + type: 'text', + x: Math.round(bounds.min.x), + y: Math.round(bounds.min.y), + width: Math.max(1, Math.round(bounds.max.x - bounds.min.x)), + height: Math.max(1, Math.round(bounds.max.y - bounds.min.y)), + text: shape.type === 'text' ? shape.props.text : shape.props.md, + ...(nodeColor(shape) ? { color: nodeColor(shape) } : {}) + }); + if (shape.groupId) { + const members = groups.get(shape.groupId) ?? []; + members.push(shape); + groups.set(shape.groupId, members); + } + } + + const nodes: JsonObject[] = []; + for (const [id, members] of groups) { + const memberBounds = members.map(shapeBounds); + const bounds = { + min: { + x: Math.min(...memberBounds.map((member) => member.min.x)), + y: Math.min(...memberBounds.map((member) => member.min.y)) + }, + max: { + x: Math.max(...memberBounds.map((member) => member.max.x)), + y: Math.max(...memberBounds.map((member) => member.max.y)) + } + }; + nodes.push({ + id, + type: 'group', + x: Math.floor(bounds.min.x - 20), + y: Math.floor(bounds.min.y - 20), + width: Math.max(1, Math.ceil(bounds.max.x - bounds.min.x + 40)), + height: Math.max(1, Math.ceil(bounds.max.y - bounds.min.y + 40)) + }); + } + for (const id of shapeIds) { + const node = exportable.get(id); + if (node) nodes.push(node); + } + + const edges: JsonObject[] = []; + for (const id of shapeIds) { + const shape = document.shapes[id]; + if (!shape || shape.type !== 'arrow') continue; + const start = bindingFor(document, shape, 'start'); + const end = bindingFor(document, shape, 'end'); + if (!start || !end || !exportable.has(start.toShapeId) || !exportable.has(end.toShapeId)) { + warnings.add('json-canvas-free-arrow', 'Arrows without two exportable card endpoints were omitted.'); + continue; + } + edges.push({ + id: shape.id, + fromNode: start.toShapeId, + fromSide: sideForAnchor(start.anchor), + fromEnd: shape.props.style.headStart ? 'arrow' : 'none', + toNode: end.toShapeId, + toSide: sideForAnchor(end.anchor), + toEnd: shape.props.style.headEnd === false ? 'none' : 'arrow', + color: shape.props.style.stroke, + ...(shape.props.label?.text ? { label: shape.props.label.text } : {}) + }); + } + + return { + format: 'json-canvas', + contents: JSON.stringify({ nodes, edges }, null, 2) + '\n', + extension: 'canvas', + mimeType: 'application/json', + warnings: warnings.values() + }; +} + +function sidePoint(shape: Shape, side?: CanvasSide): Point { + const bounds = shapeBounds(shape); + const center = { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; + switch (side) { + case 'top': + return { x: center.x, y: bounds.min.y }; + case 'right': + return { x: bounds.max.x, y: center.y }; + case 'bottom': + return { x: center.x, y: bounds.max.y }; + case 'left': + return { x: bounds.min.x, y: center.y }; + default: + return center; + } +} + +type CanvasSide = 'top' | 'right' | 'bottom' | 'left'; + +function optionalSide(value: unknown): CanvasSide | undefined { + return value === 'top' || value === 'right' || value === 'bottom' || value === 'left' ? value : undefined; +} + +function anchorForSide(side?: CanvasSide) { + switch (side) { + case 'top': + return { kind: 'edge', nx: 0, ny: -1 } as const; + case 'right': + return { kind: 'edge', nx: 1, ny: 0 } as const; + case 'bottom': + return { kind: 'edge', nx: 0, ny: 1 } as const; + case 'left': + return { kind: 'edge', nx: -1, ny: 0 } as const; + default: + return { kind: 'center' } as const; + } +} + +function sideForAnchor(anchor: BindingAnchor): CanvasSide { + if (anchor.kind === 'center') return 'right'; + if (Math.abs(anchor.nx) >= Math.abs(anchor.ny)) return anchor.nx < 0 ? 'left' : 'right'; + return anchor.ny < 0 ? 'top' : 'bottom'; +} + +function nodeColor(shape: Shape) { + if (shape.type === 'markdown') return shape.props.bg || shape.props.border; + if (shape.type === 'text') return shape.props.color; + return undefined; +} + +function canvasColor(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const presets: Record = { + '1': '#fb464c', + '2': '#e9973f', + '3': '#e0de71', + '4': '#44cf6e', + '5': '#53dfdd', + '6': '#a882ff' + }; + return presets[value] ?? (/^#[\da-f]{6}$/i.test(value) ? value : undefined); +} + +function positiveNumber(value: unknown, name: string) { + const number = finiteNumber(value, name); + if (number <= 0) throw new Error(`${name} must be greater than zero.`); + return number; +} diff --git a/packages/core/src/interchange/shared.ts b/packages/core/src/interchange/shared.ts new file mode 100644 index 0000000..67aee1e --- /dev/null +++ b/packages/core/src/interchange/shared.ts @@ -0,0 +1,105 @@ +import { createId, LayerRecord, PageRecord, type ArrowShape, type Document, type ShapeRecord as Shape } from '../model'; +import type { BoardExport } from '../persistence/document'; +import type { InterchangeWarning } from '../interchange'; + +/** Parsed JSON object consumed by an interchange codec. */ +export type JsonObject = Record; + +/** Creates the single-page document that receives imported shapes. */ +export function blankSnapshot(fileName: string) { + const boardId = createId('board'); + const page = PageRecord.create('Page 1'); + const layer = LayerRecord.create(page.id, 'Imported'); + page.layerIds = [layer.id]; + const timestamp = Date.now(); + const doc: Document = { pages: { [page.id]: page }, layers: { [layer.id]: layer }, shapes: {}, bindings: {} }; + const snapshot: BoardExport = { + board: { + id: boardId, + name: fileName.replace(/\.(?:excalidraw|canvas)$/i, '').trim() || 'Imported Board', + createdAt: timestamp, + updatedAt: timestamp + }, + doc, + order: { pageIds: [page.id], shapeOrder: { [page.id]: [] }, layers: doc.layers } + }; + return { snapshot, pageId: page.id, layerId: layer.id }; +} + +/** Adds an imported shape to its document, page, and layer indexes. */ +export function addShape(document: Document, pageId: string, layerId: string, shape: Shape) { + document.shapes[shape.id] = shape; + document.pages[pageId].shapeIds.push(shape.id); + document.layers![layerId].shapeIds.push(shape.id); +} + +/** Resolves the requested export page and records multi-page loss. */ +export function selectPage( + document: Document, + pageOrder: string[], + requestedPageId: string | undefined, + warnings: WarningCollector +) { + const pageId = requestedPageId ?? pageOrder[0] ?? Object.keys(document.pages)[0]; + const page = pageId ? document.pages[pageId] : undefined; + if (!page) throw new Error('The Inkfinite document has no page to export.'); + if (Object.keys(document.pages).length > 1) + warnings.add('multiple-pages', 'Only the selected Inkfinite page was exported.'); + return page; +} + +/** Resolves one bound endpoint from an arrow. */ +export function bindingFor(document: Document, arrow: ArrowShape, handle: 'start' | 'end') { + const endpoint = arrow.props[handle]; + if (endpoint.kind !== 'bound' || !endpoint.bindingId) return undefined; + return document.bindings[endpoint.bindingId]; +} + +/** Namespaces a source identifier without making it unstable. */ +export function inkId(namespace: string, id: string) { + return `${namespace}:${id}`; +} + +/** Requires a JSON object at a named input location. */ +export function object(value: unknown, name: string): JsonObject { + if (!isObject(value)) throw new Error(`${name} must be a JSON object.`); + return value; +} + +/** Checks whether a value is a non-array JSON object. */ +export function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Reads an optional JSON array, defaulting an absent value to empty. */ +export function optionalArray(value: unknown, name: string): unknown[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${name} must be an array.`); + return value; +} + +/** Requires a non-empty string at a named input location. */ +export function requiredString(value: unknown, name: string) { + if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string.`); + return value; +} + +/** Requires a finite number at a named input location. */ +export function finiteNumber(value: unknown, name: string) { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${name} must be a finite number.`); + return value; +} + +/** Aggregates repeated lossy-conversion decisions by stable warning code. */ +export class WarningCollector { + private warnings = new Map(); + + add(code: string, message: string, count = 1) { + const current = this.warnings.get(code); + this.warnings.set(code, { code, message, count: (current?.count ?? 0) + count }); + } + + values() { + return [...this.warnings.values()]; + } +} diff --git a/packages/core/src/math.ts b/packages/core/src/math.ts index 0c1e754..bd4a70a 100644 --- a/packages/core/src/math.ts +++ b/packages/core/src/math.ts @@ -1,5 +1,10 @@ export type Vec2 = { x: number; y: number }; +/** Constrain a number to an inclusive range. */ +export function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + export const Vec2 = { /** * Add two vectors @@ -22,6 +27,15 @@ export const Vec2 = { return { x: v.x * s, y: v.y * s }; }, + /** + * Rotate a vector around the origin using the standard 2D rotation matrix. + */ + rotate(v: Vec2, radians: number): Vec2 { + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos }; + }, + /** * Calculate length (magnitude) of vector */ diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 5223c31..bb7dad8 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -7,7 +7,7 @@ import { resolveArrowEndpoints, shapeBounds } from '../geom'; -import { Box2, type Vec2, Vec2 as Vec2Ops } from '../math'; +import { Box2, clamp, type Vec2, Vec2 as Vec2Ops } from '../math'; import { BindingRecord, ShapeRecord } from '../model'; import { EditorState, getCurrentPage, getInteractiveShapesOnCurrentPage, type ToolId } from '../reactivity'; import type { Tool } from './base'; @@ -652,44 +652,43 @@ export class SelectTool implements Tool { let minY = bounds.min.y; let maxY = bounds.max.y; - const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); - const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); + const clampCoordinate = (value: number) => clamp(value, -1e6, 1e6); switch (handle) { case 'nw': { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + minX = Math.min(clampCoordinate(pointer.x), maxX - MIN_RESIZE_SIZE); + minY = Math.min(clampCoordinate(pointer.y), maxY - MIN_RESIZE_SIZE); break; } case 'n': { - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + minY = Math.min(clampCoordinate(pointer.y), maxY - MIN_RESIZE_SIZE); break; } case 'ne': { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + maxX = Math.max(clampCoordinate(pointer.x), minX + MIN_RESIZE_SIZE); + minY = Math.min(clampCoordinate(pointer.y), maxY - MIN_RESIZE_SIZE); break; } case 'e': { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); + maxX = Math.max(clampCoordinate(pointer.x), minX + MIN_RESIZE_SIZE); break; } case 'se': { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + maxX = Math.max(clampCoordinate(pointer.x), minX + MIN_RESIZE_SIZE); + maxY = Math.max(clampCoordinate(pointer.y), minY + MIN_RESIZE_SIZE); break; } case 's': { - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + maxY = Math.max(clampCoordinate(pointer.y), minY + MIN_RESIZE_SIZE); break; } case 'sw': { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + minX = Math.min(clampCoordinate(pointer.x), maxX - MIN_RESIZE_SIZE); + maxY = Math.max(clampCoordinate(pointer.y), minY + MIN_RESIZE_SIZE); break; } case 'w': { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); + minX = Math.min(clampCoordinate(pointer.x), maxX - MIN_RESIZE_SIZE); break; } } diff --git a/packages/core/tests/interchange.test.ts b/packages/core/tests/interchange.test.ts new file mode 100644 index 0000000..3ffabef --- /dev/null +++ b/packages/core/tests/interchange.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest'; +import { + BindingRecord, + exportInterchange, + importInterchange, + LayerRecord, + PageRecord, + ShapeRecord, + type BoardExport +} from '../src'; + +function board(): BoardExport { + const page = PageRecord.create('First', 'page:1'); + const layer = LayerRecord.create(page.id, 'Default', 'layer:1'); + page.layerIds = [layer.id]; + const markdown = ShapeRecord.createMarkdown( + page.id, + 10, + 20, + { md: '# Card', w: 240, h: 120, fontSize: 16, fontFamily: 'Arial', color: '#111111', bg: '#ffeeaa' }, + 'shape:card' + ); + markdown.layerId = layer.id; + const text = ShapeRecord.createText( + page.id, + 400, + 40, + { text: 'Target', fontSize: 20, fontFamily: 'Arial', color: '#222222', w: 100 }, + 'shape:target' + ); + text.layerId = layer.id; + const start = BindingRecord.create( + 'shape:arrow', + markdown.id, + 'start', + { kind: 'edge', nx: 1, ny: 0 }, + 'binding:start' + ); + const end = BindingRecord.create('shape:arrow', text.id, 'end', { kind: 'edge', nx: -1, ny: 0 }, 'binding:end'); + const arrow = ShapeRecord.createArrow( + page.id, + 250, + 80, + { + points: [ + { x: 0, y: 0 }, + { x: 150, y: 0 } + ], + start: { kind: 'bound', bindingId: start.id }, + end: { kind: 'bound', bindingId: end.id }, + style: { stroke: '#335577', width: 2, headEnd: true }, + routing: { kind: 'straight' }, + label: { text: 'connects', align: 'center', offset: 0 } + }, + 'shape:arrow' + ); + arrow.layerId = layer.id; + page.shapeIds = [markdown.id, text.id, arrow.id]; + layer.shapeIds = [...page.shapeIds]; + return { + board: { id: 'board:1', name: 'Example', createdAt: 1, updatedAt: 1 }, + doc: { + pages: { [page.id]: page }, + layers: { [layer.id]: layer }, + shapes: { [markdown.id]: markdown, [text.id]: text, [arrow.id]: arrow }, + bindings: { [start.id]: start, [end.id]: end } + }, + order: { pageIds: [page.id], shapeOrder: { [page.id]: [...page.shapeIds] }, layers: { [layer.id]: layer } } + }; +} + +describe('JSON Canvas interchange', () => { + it('imports every JSON Canvas node kind and bound edges', () => { + const result = importInterchange( + JSON.stringify({ + nodes: [ + { id: 'group', type: 'group', x: 0, y: 0, width: 600, height: 400, label: 'Group' }, + { id: 'text', type: 'text', x: 20, y: 20, width: 200, height: 100, text: '# Hello', color: '4' }, + { + id: 'file', + type: 'file', + x: 300, + y: 20, + width: 200, + height: 100, + file: 'Note.md', + subpath: '#Heading' + }, + { id: 'link', type: 'link', x: 300, y: 200, width: 200, height: 100, url: 'https://example.com' } + ], + edges: [ + { id: 'edge', fromNode: 'text', fromSide: 'right', toNode: 'file', toSide: 'left', label: 'reads' } + ] + }), + 'Notes.canvas' + ); + + expect(result.format).toBe('json-canvas'); + expect(result.snapshot.board.name).toBe('Notes'); + expect(Object.values(result.snapshot.doc.shapes).filter((shape) => shape.type === 'markdown')).toHaveLength(3); + expect(Object.values(result.snapshot.doc.shapes).filter((shape) => shape.type === 'arrow')).toHaveLength(1); + expect(Object.values(result.snapshot.doc.bindings)).toHaveLength(2); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['json-canvas-group-label', 'json-canvas-file-card', 'json-canvas-link-card']) + ); + }); + + it('accepts empty text cards', () => { + const result = importInterchange( + JSON.stringify({ + nodes: [{ id: 'empty', type: 'text', x: 0, y: 0, width: 200, height: 100, text: '' }], + edges: [] + }), + 'Empty.canvas' + ); + + expect(Object.values(result.snapshot.doc.shapes)[0]).toMatchObject({ type: 'markdown', props: { md: '' } }); + }); + + it('exports cards, groups, and bound arrows', () => { + const snapshot = board(); + snapshot.doc.shapes['shape:card'].groupId = 'group:one'; + snapshot.doc.shapes['shape:target'].groupId = 'group:one'; + const result = exportInterchange(snapshot, 'json-canvas'); + const value = JSON.parse(result.contents); + + expect(value.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'group:one', type: 'group' }), + expect.objectContaining({ id: 'shape:card', type: 'text', text: '# Card' }) + ]) + ); + expect(value.edges).toEqual([ + expect.objectContaining({ + id: 'shape:arrow', + fromNode: 'shape:card', + toNode: 'shape:target', + label: 'connects' + }) + ]); + }); +}); + +describe('Excalidraw interchange', () => { + it('imports supported elements, bindings, labels, groups, and styles', () => { + const result = importInterchange( + JSON.stringify({ + type: 'excalidraw', + version: 2, + elements: [ + { + id: 'box', + type: 'rectangle', + x: 10, + y: 20, + width: 100, + height: 60, + angle: Math.PI / 4, + strokeColor: '#111111', + backgroundColor: '#eeeeee', + opacity: 80, + groupIds: ['g'], + isDeleted: false + }, + { + id: 'arrow', + type: 'arrow', + x: 110, + y: 50, + width: 100, + height: 0, + angle: 0, + points: [ + [0, 0], + [100, 0] + ], + strokeColor: '#222222', + strokeWidth: 2, + endArrowhead: 'arrow', + startBinding: { elementId: 'box', focus: 0, gap: 1 }, + endBinding: null, + isDeleted: false + }, + { + id: 'label', + type: 'text', + x: 130, + y: 30, + width: 50, + height: 20, + angle: 0, + text: 'Flow', + fontSize: 20, + fontFamily: 2, + strokeColor: '#222222', + containerId: 'arrow', + isDeleted: false + }, + { + id: 'stroke', + type: 'freedraw', + x: 0, + y: 0, + width: 20, + height: 20, + angle: 0, + points: [ + [0, 0], + [20, 20] + ], + pressures: [0.2, 0.8], + strokeColor: '#333333', + strokeWidth: 3, + isDeleted: false + }, + { id: 'image', type: 'image', x: 0, y: 0, width: 10, height: 10, angle: 0, isDeleted: false } + ], + appState: { viewBackgroundColor: '#ffffff' }, + files: { image: { dataURL: 'data:image/png;base64,AA==' } } + }), + 'Drawing.excalidraw' + ); + + const shapes = Object.values(result.snapshot.doc.shapes); + const rectangle = shapes.find((shape) => shape.type === 'rect'); + const arrow = shapes.find((shape) => shape.type === 'arrow'); + expect(rectangle).toMatchObject({ rot: Math.PI / 4, opacity: 0.8, groupId: 'excalidraw-group:g' }); + expect(arrow?.type === 'arrow' && arrow.props.label?.text).toBe('Flow'); + expect(Object.values(result.snapshot.doc.bindings)).toHaveLength(1); + expect(shapes.some((shape) => shape.type === 'stroke')).toBe(true); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['excalidraw-image', 'excalidraw-files', 'excalidraw-app-state']) + ); + }); + + it('exports an editable Excalidraw v2 scene', () => { + const result = exportInterchange(board(), 'excalidraw'); + const scene = JSON.parse(result.contents); + const arrow = scene.elements.find((element: { id: string }) => element.id === 'shape:arrow'); + const label = scene.elements.find((element: { containerId?: string }) => element.containerId === 'shape:arrow'); + + expect(scene).toMatchObject({ type: 'excalidraw', version: 2, files: {} }); + expect(arrow).toMatchObject({ + type: 'arrow', + startBinding: { elementId: 'shape:card' }, + endBinding: { elementId: 'shape:target' } + }); + expect(label).toMatchObject({ type: 'text', text: 'connects', containerId: 'shape:arrow' }); + expect(result.warnings.map((warning) => warning.code)).toContain('excalidraw-markdown'); + }); +}); + +describe('interchange validation', () => { + it('rejects malformed and oversized input', () => { + expect(() => importInterchange('{', 'bad.canvas')).toThrow('valid JSON'); + expect(() => importInterchange(JSON.stringify({ nodes: [{ id: 'a' }] }), 'bad.canvas')).toThrow( + 'nodes[0].type' + ); + expect(() => importInterchange('x'.repeat(16 * 1024 * 1024 + 1), 'large.canvas')).toThrow('16 MB'); + expect(() => + importInterchange( + JSON.stringify({ + nodes: [{ id: 'same', type: 'text', x: 0, y: 0, width: 10, height: 10, text: '' }], + edges: [{ id: 'same', fromNode: 'same', toNode: 'same' }] + }), + 'duplicate.canvas' + ) + ).toThrow('duplicated'); + }); +}); diff --git a/packages/core/tests/math.test.ts b/packages/core/tests/math.test.ts index 5b6cb9c..af5e713 100644 --- a/packages/core/tests/math.test.ts +++ b/packages/core/tests/math.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { Box2, Mat3, Vec2 } from "../src/math"; +import { Box2, clamp, Mat3, Vec2 } from "../src/math"; + +describe("clamp", () => { + it("constrains values to an inclusive range", () => { + expect(clamp(-1, 0, 10)).toBe(0); + expect(clamp(4, 0, 10)).toBe(4); + expect(clamp(11, 0, 10)).toBe(10); + }); +}); describe("Vec2", () => { describe("add", () => { @@ -67,6 +75,15 @@ describe("Vec2", () => { }); }); + describe("rotate", () => { + it("rotates a vector counterclockwise around the origin", () => { + const rotated = Vec2.rotate({ x: 3, y: 0 }, Math.PI / 2); + + expect(rotated.x).toBeCloseTo(0); + expect(rotated.y).toBeCloseTo(3); + }); + }); + describe("len", () => { it.each([ { description: "should calculate length of 3-4-5 triangle", v: { x: 3, y: 4 }, expected: 5 }, diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index b365b68..383fd21 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -8,7 +8,7 @@ import ProposalReview from '../components/ProposalReview.svelte'; import ProposalGhostLayer from '../components/ProposalGhostLayer.svelte'; import NavigationControls from './NavigationControls.svelte'; - import { ContextMenu, type ContextMenuEntry } from '../../index'; + import { Button, ContextMenu, Dialog, type ContextMenuEntry } from '../../index'; import { createCanvasController } from './canvas-store.svelte'; import { draggingStencil, endDrag } from '../dnd.svelte'; import type { EditorPlatformAdapter } from '../platform'; @@ -41,6 +41,7 @@ let marqueeRect = $derived(c.marqueeRect()); let liveProposal = $derived(c.proposal()); let proposalMessage = $derived(c.proposalMessage()); + let interchangeNotice = $derived(c.interchangeNotice()); $effect(() => { c.setCanvasRef(canvasEl); @@ -190,7 +191,10 @@ onStencilsClick={handleStencilsClick} store={c.store} canvas={canvasEl ?? undefined} - brushStore={c.brushStore} /> + brushStore={c.brushStore} + onImportEditable={c.importEditableCanvas} + onExportEditable={c.exportEditableCanvas} + interchangeBusy={c.interchangeBusy()} />
{ @@ -329,6 +333,30 @@ bind:open={c.stencilPaletteOpen} onClose={() => (c.stencilPaletteOpen = false)} onStencilClick={handleInsertStencilAtCenter} /> + + {#if interchangeNotice} +
+

{interchangeNotice.title}

+

{interchangeNotice.message}

+ {#if interchangeNotice.warnings.length > 0} +

Conversion notes

+
    + {#each interchangeNotice.warnings as warning (warning.code)} +
  • + {warning.message}{warning.count > 1 ? ` (${warning.count})` : ''} +
  • + {/each} +
+ {/if} +
+ +
+
+ {/if} +