From ec0d6d954f04d9ebe7ac782728226034e7f9f394 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 25 Aug 2026 02:06:56 -0500 Subject: [PATCH] refactor: headless TypeScript core --- TODO.md | 14 +- apps/desktop/src/lib/fileops.ts | 25 ++- .../desktop-session.invoke.test.ts | 2 +- .../lib/persistence/desktop-session.test.ts | 4 +- .../src/lib/persistence/desktop-session.ts | 14 +- .../lib/persistence/desktop-workspace.test.ts | 2 +- apps/desktop/vite.config.ts | 3 + .../content/docs/development/architecture.md | 23 ++- apps/web/src/dexie-repository.test.ts | 6 +- apps/web/src/lib/persistence/database.ts | 2 +- apps/web/src/lib/persistence/dexie.ts | 15 +- apps/web/src/lib/persistence/repository.ts | 22 ++- .../components/FileBrowser.svelte.test.ts | 4 +- .../lib/tests/document-engine.worker.test.ts | 6 +- apps/web/src/lib/tests/status.test.ts | 3 +- apps/web/vite.config.ts | 12 ++ packages/core/consumer/index.ts | 25 +++ packages/core/consumer/tsconfig.json | 13 ++ packages/core/package.json | 8 +- packages/core/src/actions.ts | 10 +- packages/core/src/base64.ts | 49 ++++++ packages/core/src/commands.ts | 4 + packages/core/src/export.ts | 91 +--------- packages/core/src/geometry.ts | 9 + packages/core/src/index.ts | 10 +- packages/core/src/interchange/excalidraw.ts | 27 +-- packages/core/src/model.ts | 9 + packages/core/src/paint.ts | 49 +----- packages/core/src/persistence.ts | 5 + packages/core/src/persistence/desktop.ts | 88 ---------- packages/core/tests/export.test.ts | 58 ++++++- packages/core/tests/import-boundary.test.ts | 37 ++++ packages/core/tests/index.test.ts | 61 ++++--- .../core/tests/interchange-fixtures.test.ts | 2 +- packages/core/tests/interchange.test.ts | 12 +- packages/core/tests/statusbar.test.ts | 141 --------------- packages/core/tsconfig.json | 36 ++-- packages/core/tsdown.config.ts | 15 +- packages/editor/package.json | 8 + packages/editor/src/export.ts | 82 +++++++++ packages/editor/src/input-dom.ts | 8 +- packages/editor/src/paint.ts | 69 ++++++++ packages/editor/src/renderer.ts | 2 +- .../lib/editor/__tests__/statusbar.test.ts | 162 ++++++++++++++++++ .../lib/editor/canvas/canvas-store.svelte.ts | 10 +- .../desktop-file-controller.svelte.ts | 2 +- .../filebrowser-controller.svelte.ts | 13 +- .../lib/editor/components/StatusBar.svelte | 12 +- .../src/lib/editor/components/Toolbar.svelte | 3 +- .../lib/editor/filebrowser/FileBrowser.svelte | 13 +- .../filebrowser/__tests__/model.test.ts} | 10 +- .../src/lib/editor/filebrowser/model.ts} | 12 +- packages/ui/src/lib/editor/index.ts | 16 ++ packages/ui/src/lib/editor/platform.ts | 45 ++--- packages/ui/src/lib/editor/status.ts | 5 +- .../src/ui => ui/src/lib/editor}/statusbar.ts | 23 ++- .../editor/stories/editor.stories.fixtures.ts | 5 +- packages/ui/src/test/editor-fixtures.ts | 12 +- 58 files changed, 840 insertions(+), 588 deletions(-) create mode 100644 packages/core/consumer/index.ts create mode 100644 packages/core/consumer/tsconfig.json create mode 100644 packages/core/src/base64.ts create mode 100644 packages/core/src/commands.ts create mode 100644 packages/core/src/geometry.ts create mode 100644 packages/core/src/model.ts create mode 100644 packages/core/src/persistence.ts delete mode 100644 packages/core/src/persistence/desktop.ts create mode 100644 packages/core/tests/import-boundary.test.ts delete mode 100644 packages/core/tests/statusbar.test.ts create mode 100644 packages/editor/src/export.ts create mode 100644 packages/editor/src/paint.ts create mode 100644 packages/ui/src/lib/editor/__tests__/statusbar.test.ts rename packages/{core/tests/filebrowser.test.ts => ui/src/lib/editor/filebrowser/__tests__/model.test.ts} (94%) rename packages/{core/src/ui/filebrowser.ts => ui/src/lib/editor/filebrowser/model.ts} (94%) rename packages/{core/src/ui => ui/src/lib/editor}/statusbar.ts (82%) diff --git a/TODO.md b/TODO.md index a705227..f387530 100644 --- a/TODO.md +++ b/TODO.md @@ -70,23 +70,23 @@ ### Make TypeScript core headless -- [ ] Audit `@inkfinite/core` exports and classify them as domain, editor, +- [x] Audit `@inkfinite/core` exports and classify them as domain, editor, browser/platform, persistence, or UI concerns -- [ ] Move DOM and `HTMLCanvasElement`-dependent raster export helpers out of +- [x] Move DOM and `HTMLCanvasElement`-dependent raster export helpers out of `@inkfinite/core`; preserve existing SVG/PNG behavior in browser and desktop integration tests -- [ ] Move UI-specific file-browser and status-bar contracts out of +- [x] Move UI-specific file-browser and status-bar contracts out of `@inkfinite/core` and update consumers without introducing circular package dependencies -- [ ] Keep browser and desktop persistence adapters at application/platform +- [x] Keep browser and desktop persistence adapters at application/platform boundaries rather than exposing them through the core root barrel; verify create, save, reopen, and export workflows in both hosts -- [ ] Add explicit `@inkfinite/core` subpath exports for stable capability +- [x] Add explicit `@inkfinite/core` subpath exports for stable capability groups such as model, geometry, commands, selection, and interchange, with package-consumer typecheck coverage -- [ ] Reduce the root `@inkfinite/core` export surface to the intentionally +- [x] Reduce the root `@inkfinite/core` export surface to the intentionally supported convenience API -- [ ] Add an import-boundary lint rule or test preventing core from depending +- [x] Add an import-boundary lint rule or test preventing core from depending on DOM, Svelte, application, or platform-specific modules ### Decompose the editor implementation diff --git a/apps/desktop/src/lib/fileops.ts b/apps/desktop/src/lib/fileops.ts index 6d00035..09985e4 100644 --- a/apps/desktop/src/lib/fileops.ts +++ b/apps/desktop/src/lib/fileops.ts @@ -1,9 +1,28 @@ 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'; - -export type { DesktopFileOps }; +/** A recent or currently open desktop document. */ +export type FileHandle = { path: string; name: string; modifiedAt?: number }; +/** One filesystem entry returned by the desktop workspace adapter. */ +export type DirectoryEntry = { path: string; name: string; isDir: boolean; modifiedAt?: number }; + +/** Native dialogs, recent files, and workspace operations supplied by Tauri. */ +export interface DesktopFileOps { + showOpenDialog(): Promise; + showSaveDialog(defaultName?: string): Promise; + showSvgDialog(): Promise; + getRecentFiles(): Promise; + addRecentFile(handle: FileHandle): Promise; + removeRecentFile(path: string): Promise; + clearRecentFiles(): Promise; + getWorkspaceDir(): Promise; + setWorkspaceDir(path: string | null): Promise; + pickWorkspaceDir(): Promise; + readDirectory(directory: string, pattern?: string): Promise; + getFileModifiedAt?(path: string): Promise; + renameFile(oldPath: string, newPath: string): Promise; + deleteFile(path: string): Promise; +} const STORE_NAME = 'inkfinite-desktop.json'; const RECENT_FILES_KEY = 'recentFiles'; diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts index 2e42760..e149b7d 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts @@ -1,4 +1,4 @@ -import type { DesktopFileOps } from '@inkfinite/core'; +import type { DesktopFileOps } from '../fileops'; import type { DocumentSnapshot, Proposal } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 5bceb2f..03b400a 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -1,4 +1,6 @@ -import { EditorPageRecord, EditorShapeRecord, type BoardExport, type DesktopFileOps, type FileHandle } from '@inkfinite/core'; +import { EditorPageRecord, EditorShapeRecord } from '@inkfinite/core'; +import type { BoardExport } from '@inkfinite/core/persistence'; +import type { DesktopFileOps, FileHandle } from '../fileops'; import type { ChangeHash, DocumentSnapshot, Proposal, ShapeProperties, TransactionDraft } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it } from 'vitest'; import { createDesktopSessionRepo } from '$lib/persistence/desktop-session'; diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 4881ad4..0ace760 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -1,24 +1,22 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { createId } from '@inkfinite/core'; import { createEditorReconciliationRequest, - createId, fromCanonicalDocumentSnapshot, fromEditorProjection -} from '@inkfinite/core'; +} from '@inkfinite/core/persistence'; +import type { EditorLayerRecord, EditorPageRecord } from '@inkfinite/core'; import type { BoardExport, BoardMeta, - DesktopFileOps, DocPatch, - FileHandle, LoadedDoc, - EditorLayerRecord, - EditorPageRecord, PersistenceSink, - PersistenceStatus, PersistentDocRepo -} from '@inkfinite/core'; +} from '@inkfinite/core/persistence'; +import type { PersistenceStatus } from '@inkfinite/ui/editor'; +import type { DesktopFileOps, FileHandle } from '../fileops'; import type { ChangeHash, CommitResult, diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index 594e6a3..54cf353 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -1,4 +1,4 @@ -import type { DesktopFileOps } from '@inkfinite/core'; +import type { DesktopFileOps } from '../fileops'; import { beforeEach, describe, expect, it } from 'vitest'; import { createDesktopSessionRepo } from '$lib/persistence/desktop-session'; diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7712a9e..69b6bd0 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -8,9 +8,12 @@ export default defineConfig({ resolve: { alias: { '@inkfinite/bindings': new URL('../../packages/bindings/src/index.ts', import.meta.url).pathname, + '@inkfinite/core/persistence': new URL('../../packages/core/src/persistence.ts', import.meta.url).pathname, + '@inkfinite/core/geometry': new URL('../../packages/core/src/geometry.ts', import.meta.url).pathname, '@inkfinite/core': new URL('../../packages/core/src/index.ts', import.meta.url).pathname, '@inkfinite/editor/input-dom': new URL('../../packages/editor/src/input-dom.ts', import.meta.url).pathname, '@inkfinite/editor/renderer': new URL('../../packages/editor/src/renderer.ts', import.meta.url).pathname, + '@inkfinite/editor/export': new URL('../../packages/editor/src/export.ts', import.meta.url).pathname, '@inkfinite/editor/runtime': new URL('../../packages/editor/src/runtime.ts', import.meta.url).pathname, '@inkfinite/ui/editor': new URL('../../packages/ui/src/lib/editor/index.ts', import.meta.url).pathname, '@inkfinite/ui/styles.css': new URL('../../packages/ui/src/lib/styles/index.css', import.meta.url).pathname, diff --git a/apps/web/src/content/docs/development/architecture.md b/apps/web/src/content/docs/development/architecture.md index 202f0b9..a4c1c56 100644 --- a/apps/web/src/content/docs/development/architecture.md +++ b/apps/web/src/content/docs/development/architecture.md @@ -107,10 +107,18 @@ Rust inkfinite-wasm ──> web app persistence adapter Rust Tauri commands ──> desktop app persistence adapter ``` -`@inkfinite/editor` owns normalized input, interaction state, commands, and Canvas rendering. The -UI package owns Svelte presentation and inspector controls. Applications own browser storage, -filesystem access, Tauri or WASM calls, and composition. No UI, editor runtime, or application code -owns canonical records or applies native transactions directly. +`@inkfinite/editor` owns normalized input, interaction state, commands, Canvas rendering, and +browser Canvas export adapters. The UI package owns Svelte presentation, inspector controls, file +browser and status-bar view models. Applications own browser storage, filesystem access, Tauri or +WASM calls, and composition. No UI, editor runtime, or application code owns canonical records or +applies native transactions directly. + +`@inkfinite/core` is headless and has no DOM, Svelte, application, or platform imports. Its root +entry point is a convenience API; capability-oriented consumers can use the explicit `model`, +`geometry`, `commands`, `selection`, `interchange`, and `persistence` entry points. The core root +keeps SVG export and pure paint serialization, while `@inkfinite/editor/export` owns PNG helpers +that require `HTMLCanvasElement`. Browser and desktop persistence implementations stay in their +application roots, and the desktop file-operation contract lives beside the Tauri adapter. For the record structure, see [Document model](/docs/concepts/document-model/). The [native path geometry guide](/docs/development/native-path-geometry/) documents the path representation used by @@ -168,7 +176,7 @@ Causal heads and record versions are used for optimistic concurrency. See | `crates/inkfinite-cli` | `inkfinite` command parsing, human/JSON output, file-mode operations, live desktop control, and binding/schema generation | | `apps/desktop/src-tauri` | Tauri command surface and native application integration around `inkfinite-core` | | `packages/bindings` | Generated TypeScript contracts derived from Rust. Do not edit these by hand | -| `packages/core` | Editor-facing model, geometry, actions, tools, stencils, interchange, and browser-side utilities | +| `packages/core` | Headless editor model, geometry, actions, tools, stencils, interchange, SVG export, and persistence projections | | `packages/editor` | DOM input normalization, interaction state, transaction drafts, and Canvas 2D rendering | | `packages/ui` | Shared Svelte editor, panels, controls, themes, and UI components | | `apps/web` | Browser composition root, documentation site, and IndexedDB-backed editor persistence | @@ -187,8 +195,9 @@ text and Markdown layout. Selection handles, binding previews, and snapping guid editor-only state and are not native document records. Headless rendering is separate. `inkfinite-core` renders the canonical document directly to -deterministic SVG for CLI output, fixtures, and inspection. This keeps headless output independent -of the browser renderer. +deterministic SVG for CLI output, fixtures, and inspection. `@inkfinite/core` also provides pure SVG +serialization for the interactive editor model. PNG export remains in `@inkfinite/editor` because +it requires a browser Canvas. This keeps headless output independent of the browser renderer. ## Desktop sessions, CLI, and MCP diff --git a/apps/web/src/dexie-repository.test.ts b/apps/web/src/dexie-repository.test.ts index 807dd8f..a04811a 100644 --- a/apps/web/src/dexie-repository.test.ts +++ b/apps/web/src/dexie-repository.test.ts @@ -3,14 +3,14 @@ import { InkfiniteDB } from '$lib/persistence/database'; import { createDexieDocRepo, createPersistenceSink } from '$lib/persistence/repository'; import { CreateShapeCommand, - diffDoc, EditorDocument as EditorDocumentOps, EditorPageRecord, SetSelectionCommand, EditorShapeRecord, - Store, - type CanonicalDocumentState + Store } from '@inkfinite/core'; +import { diffDoc } from '@inkfinite/core/persistence'; +import type { CanonicalDocumentState } from '@inkfinite/core/persistence'; import Dexie from 'dexie'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/apps/web/src/lib/persistence/database.ts b/apps/web/src/lib/persistence/database.ts index 86dab4e..d396807 100644 --- a/apps/web/src/lib/persistence/database.ts +++ b/apps/web/src/lib/persistence/database.ts @@ -1,4 +1,4 @@ -import type { BoardMeta } from '@inkfinite/core'; +import type { BoardMeta } from '@inkfinite/core/persistence'; import Dexie from 'dexie'; import type { BindingRow, CanonicalRow, MetaRow, PageRow, ShapeRow } from './repository'; diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index ee35c59..e2f3a4d 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,19 +1,18 @@ import { createEditorReconciliationRequest, - diffDoc, fromEditorProjection, - toCanonicalDocumentSnapshot -} from '@inkfinite/core'; + toCanonicalDocumentSnapshot, + diffDoc +} from '@inkfinite/core/persistence'; +import type { InterchangeExport, SvgExport, SvgExportOptions } from '@inkfinite/core'; import type { BoardExport, DocPatch, EditorDocumentChange, - InterchangeExport, PersistenceSink, PersistentDocRepo, - SvgExport, - SvgExportOptions -} from '@inkfinite/core'; + LoadedDoc +} from '@inkfinite/core/persistence'; import { createStatusStore } from '@inkfinite/ui/editor'; import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui/editor'; import { liveQuery } from 'dexie'; @@ -22,8 +21,6 @@ import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from import { getSharedDocumentEngineWorker, renderSvgInWorker } from './document-engine'; import type { BrowserDocumentState } from './document-engine'; import type { PersistenceSinkOptions } from './repository'; -import type { LoadedDoc } from '@inkfinite/core'; - type LiveQueryFactory = typeof liveQuery; /** Test and tuning hooks for the Dexie persistence adapter. */ diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts index 68a6ebb..405ca58 100644 --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -1,15 +1,24 @@ import { EditorBindingRecord as BindingOps, - BoardStatsOps, createId, - fromCanonicalDocumentSnapshot, - fromEditorProjection, EditorLayerRecord as LayerOps, EditorPageRecord as PageOps, EditorShapeRecord as ShapeOps } from '@inkfinite/core'; +import { + BoardStatsOps, + fromCanonicalDocumentSnapshot, + fromEditorProjection +} from '@inkfinite/core/persistence'; import type { EditorBindingRecord, + EditorDocument, + EditorLayerRecord, + EditorPageRecord, + EditorShapeRecord, + ImportedAsset +} from '@inkfinite/core'; +import type { BoardExport, BoardInspectorData, BoardMeta, @@ -17,17 +26,12 @@ import type { CanonicalDocumentState, DocOrder, DocPatch, - EditorDocument, LoadedDoc, - EditorLayerRecord, - ImportedAsset, - EditorPageRecord, PersistenceSink, PersistentDocRepo, SchemaInfo, - EditorShapeRecord, Timestamp -} from '@inkfinite/core'; +} from '@inkfinite/core/persistence'; import Dexie from 'dexie'; /** IndexedDB row for a page scoped to its board. */ diff --git a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts index 45f4cf8..1defbb6 100644 --- a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts +++ b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts @@ -1,6 +1,6 @@ import FileBrowser from '$editor/filebrowser/FileBrowser.svelte'; -import type { BoardMeta, FileBrowserViewModel } from '@inkfinite/core'; -import { FileBrowserVM } from '@inkfinite/core'; +import type { BoardMeta } from '@inkfinite/core/persistence'; +import { FileBrowserVM, type FileBrowserViewModel } from '$editor/filebrowser/model'; import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; diff --git a/apps/web/src/lib/tests/document-engine.worker.test.ts b/apps/web/src/lib/tests/document-engine.worker.test.ts index f0d833b..424a4b0 100644 --- a/apps/web/src/lib/tests/document-engine.worker.test.ts +++ b/apps/web/src/lib/tests/document-engine.worker.test.ts @@ -1,9 +1,9 @@ import type { DocumentSnapshot, EditorReconciliationRequest } from '@inkfinite/wasm'; +import type { EditorDocument } from '@inkfinite/core'; import { createEditorReconciliationRequest, - fromEditorProjection, - type EditorDocument -} from '@inkfinite/core'; + fromEditorProjection +} from '@inkfinite/core/persistence'; import { afterEach, describe, expect, it } from 'vitest'; import { getSharedDocumentEngineWorker, diff --git a/apps/web/src/lib/tests/status.test.ts b/apps/web/src/lib/tests/status.test.ts index 92d5029..35bff6b 100644 --- a/apps/web/src/lib/tests/status.test.ts +++ b/apps/web/src/lib/tests/status.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Observable, Observer, Subscription } from 'dexie'; -import type { DocPatch, EditorPageRecord, PersistentDocRepo } from '@inkfinite/core'; +import type { EditorPageRecord } from '@inkfinite/core'; +import type { DocPatch, PersistentDocRepo } from '@inkfinite/core/persistence'; import { describe, expect, it, vi } from 'vitest'; import type { InkfiniteDB } from '$lib/persistence/database'; import { createDexieSession, type DexieAdapterOptions } from '$lib/persistence/dexie'; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a8a98d4..4c572f4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,6 +11,14 @@ export default defineConfig({ alias: { '@inkfinite/bindings': new URL('../../packages/bindings/src/index.ts', import.meta.url) .pathname, + '@inkfinite/core/persistence': new URL( + '../../packages/core/src/persistence.ts', + import.meta.url + ).pathname, + '@inkfinite/core/geometry': new URL( + '../../packages/core/src/geometry.ts', + import.meta.url + ).pathname, '@inkfinite/core': new URL('../../packages/core/src/index.ts', import.meta.url) .pathname, '@inkfinite/editor/input-dom': new URL( @@ -21,6 +29,10 @@ export default defineConfig({ '../../packages/editor/src/renderer.ts', import.meta.url ).pathname, + '@inkfinite/editor/export': new URL( + '../../packages/editor/src/export.ts', + import.meta.url + ).pathname, '@inkfinite/editor/runtime': new URL( '../../packages/editor/src/runtime.ts', import.meta.url diff --git a/packages/core/consumer/index.ts b/packages/core/consumer/index.ts new file mode 100644 index 0000000..16616f6 --- /dev/null +++ b/packages/core/consumer/index.ts @@ -0,0 +1,25 @@ +import { Action, Modifiers } from '@inkfinite/core/commands'; +import { importInterchange } from '@inkfinite/core/interchange'; +import { BoardStatsOps, diffDoc } from '@inkfinite/core/persistence'; +import { duplicateSelection } from '@inkfinite/core/selection'; +import { shapeBounds, Vec2 } from '@inkfinite/core/geometry'; +import { EditorDocument, EditorShapeRecord } from '@inkfinite/core/model'; +import type { EditorState } from '@inkfinite/core'; + +const document = EditorDocument.create(); +const shape = EditorShapeRecord.createRect('page:consumer', 0, 0, { + w: 10, + h: 10, + fill: '#ffffff', + stroke: '#000000', + radius: 0 +}); +const state = { doc: { ...document, shapes: { [shape.id]: shape } } } as EditorState; + +void Action.keyDown('Escape', 'Escape', Modifiers.create()); +void Vec2.create(0, 0); +void shapeBounds(shape); +void duplicateSelection(state); +void importInterchange('{}', 'empty.canvas'); +void BoardStatsOps.formatDocSize(0); +void diffDoc(document, document); diff --git a/packages/core/consumer/tsconfig.json b/packages/core/consumer/tsconfig.json new file mode 100644 index 0000000..bdf43d7 --- /dev/null +++ b/packages/core/consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 4d73a73..1631963 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,12 @@ }, "exports": { ".": "./dist/index.mjs", + "./commands": "./dist/commands.mjs", + "./geometry": "./dist/geometry.mjs", + "./interchange": "./dist/interchange.mjs", + "./model": "./dist/model.mjs", + "./persistence": "./dist/persistence.mjs", + "./selection": "./dist/selection.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", @@ -28,7 +34,7 @@ "build": "tsdown", "dev": "tsdown --watch", "test": "vitest run", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && pnpm run build && tsc -p consumer/tsconfig.json --noEmit", "prepublishOnly": "pnpm run build" }, "devDependencies": { diff --git a/packages/core/src/actions.ts b/packages/core/src/actions.ts index 31909d6..f2cab10 100644 --- a/packages/core/src/actions.ts +++ b/packages/core/src/actions.ts @@ -260,11 +260,13 @@ export const Modifiers = { }, /** - * Check if Cmd (Mac) or Ctrl (other platforms) is pressed + * Check if Cmd (Mac) or Ctrl (other platforms) is pressed. + * + * Platform detection belongs to the host adapter, so the headless core + * defaults to the non-Mac convention and accepts an explicit platform. */ - isPrimaryModifier(modifiers: Modifiers): boolean { - const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC'); - return isMac ? modifiers.meta : modifiers.ctrl; + isPrimaryModifier(modifiers: Modifiers, platform: 'mac' | 'other' = 'other'): boolean { + return platform === 'mac' ? modifiers.meta : modifiers.ctrl; } }; diff --git a/packages/core/src/base64.ts b/packages/core/src/base64.ts new file mode 100644 index 0000000..8b68268 --- /dev/null +++ b/packages/core/src/base64.ts @@ -0,0 +1,49 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** Encode bytes as standard Base64 without relying on a host runtime global. */ +export function encodeBase64(bytes: number[]): string { + let encoded = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = (bytes[index] ?? 0) & 0xff; + const hasSecond = index + 1 < bytes.length; + const hasThird = index + 2 < bytes.length; + const second = hasSecond ? (bytes[index + 1] ?? 0) & 0xff : 0; + const third = hasThird ? (bytes[index + 2] ?? 0) & 0xff : 0; + const combined = (first << 16) | (second << 8) | third; + encoded += BASE64_ALPHABET[(combined >> 18) & 0x3f]; + encoded += BASE64_ALPHABET[(combined >> 12) & 0x3f]; + encoded += hasSecond ? BASE64_ALPHABET[(combined >> 6) & 0x3f] : '='; + encoded += hasThird ? BASE64_ALPHABET[combined & 0x3f] : '='; + } + return encoded; +} + +/** Decode standard Base64 into bytes without relying on a host runtime global. */ +export function decodeBase64(value: string): number[] { + const input = value.replaceAll(/\s/g, ''); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input) || input.length % 4 === 1) { + throw new Error('Invalid Base64 data'); + } + const padding = input.endsWith('==') ? 2 : input.endsWith('=') ? 1 : 0; + const content = input.slice(0, input.length - padding); + const bytes: number[] = []; + + for (let index = 0; index < content.length; index += 4) { + const first = BASE64_ALPHABET.indexOf(content[index] ?? ''); + const second = BASE64_ALPHABET.indexOf(content[index + 1] ?? ''); + const third = BASE64_ALPHABET.indexOf(content[index + 2] ?? ''); + const fourth = BASE64_ALPHABET.indexOf(content[index + 3] ?? ''); + if ( + first < 0 || + second < 0 || + (index + 2 < content.length && third < 0) || + (index + 3 < content.length && fourth < 0) + ) { + throw new Error('Invalid Base64 data'); + } + bytes.push((first << 2) | (second >> 4)); + if (index + 2 < content.length) bytes.push(((second & 0x0f) << 4) | (third >> 2)); + if (index + 3 < content.length) bytes.push(((third & 0x03) << 6) | fourth); + } + return bytes; +} diff --git a/packages/core/src/commands.ts b/packages/core/src/commands.ts new file mode 100644 index 0000000..dfd6b87 --- /dev/null +++ b/packages/core/src/commands.ts @@ -0,0 +1,4 @@ +/** Stable command entry point for actions, history, and editor tools. */ +export * from './actions'; +export * from './history'; +export * from './tools'; diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 3b87748..d5c9bcf 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -1,3 +1,4 @@ +import { encodeBase64 } from './base64'; import { arrowGeometryForShape, getStrokeOutline, localToWorld, pathGeometryBounds, shapeBoundsForState } from './geom'; import { arrowHeadGeometry, arrowLabelPlacement, arrowShaftGeometry } from './arrow-geometry'; import { paintToSvg } from './paint'; @@ -16,7 +17,7 @@ import type { TextShape } from './editor-model'; import type { EditorState } from './reactivity'; -import { getSelectedShapes, getShapesOnCurrentPage } from './reactivity'; +import { getShapesOnCurrentPage } from './reactivity'; export type ExportOptions = { /** @@ -39,84 +40,6 @@ export type ExportOptions = { includeCamera?: boolean; }; -/** - * Export the current viewport as a PNG blob. - * - * This captures whatever is currently visible on the canvas. - * - * @param canvas - The canvas element to export - * @returns Promise resolving to PNG blob - */ -export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise { - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Failed to export canvas to PNG')); - } - }, 'image/png'); - }); -} - -/** - * Export selected shapes as a PNG blob. - * - * This creates a temporary canvas, renders only the selected shapes - * with their bounds, and exports it as PNG. - * - * @param state - Editor state containing shapes - * @param renderFn - Function to render shapes to a canvas context - * @returns Promise resolving to PNG blob, or null if no selection - */ -export async function exportSelectionToPNG( - state: EditorState, - renderFunction: (context: CanvasRenderingContext2D, shapes: EditorShapeRecord[], bounds: Box2) => void -): Promise { - const shapes = getSelectedShapes(state); - if (shapes.length === 0) { - return null; - } - - const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); - if (!bounds) { - return null; - } - - const padding = 20; - const width = Box2Ops.width(bounds) + padding * 2; - const height = Box2Ops.height(bounds) + padding * 2; - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const context = canvas.getContext('2d'); - if (!context) { - throw new Error('Failed to get 2D context'); - } - - context.fillStyle = 'white'; - context.fillRect(0, 0, width, height); - - context.save(); - context.translate(-bounds.min.x + padding, -bounds.min.y + padding); - - renderFunction(context, shapes, bounds); - - context.restore(); - - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Failed to export selection to PNG')); - } - }, 'image/png'); - }); -} - /** * Export shapes to SVG format. * @@ -577,13 +500,6 @@ function markdownToSVG(shape: MarkdownShape, transform: string, definitions: str ].join('\n'); } -function encodeBase64(bytes: number[]): string { - if (typeof btoa !== 'function') return ''; - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - /** * Escape special XML characters in strings. */ @@ -596,7 +512,8 @@ function escapeXML(string_: string): string { .replaceAll("'", '''); } -function exportBounds(state: EditorState, shape: EditorShapeRecord): Box2 { +/** Compute the export bounds for one shape, including arrow heads and labels. */ +export function exportBounds(state: EditorState, shape: EditorShapeRecord): Box2 { if (shape.type !== 'arrow') return shapeBoundsForState(state, shape); const geometry = arrowGeometryForShape(state, shape); if (!geometry) return shapeBoundsForState(state, shape); diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts new file mode 100644 index 0000000..53ba1ea --- /dev/null +++ b/packages/core/src/geometry.ts @@ -0,0 +1,9 @@ +/** Stable geometry entry point for coordinates, paths, routing, and bounds. */ +export * from './arrow-geometry'; +export * from './boolean-paths'; +export * from './geom'; +export * from './math'; +export * from './path-metrics'; +export * from './path-topology'; +export * from './snapping'; +export * from './text-path'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e91ea90..9273399 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,6 @@ +// The root entry point is a convenience API. Capability-oriented consumers +// should prefer ./model, ./geometry, ./commands, ./selection, ./interchange, +// and ./persistence so platform concerns remain visible at import sites. export * from './actions'; export * from './arrow-geometry'; export * from './boolean-paths'; @@ -23,11 +26,6 @@ export * from './path-topology'; export * from './path-metrics'; export * from './text-path'; export * from './paint'; -export * from './persistence/desktop'; -export * from './persistence/canonical'; -export * from './persistence/document'; -export * from './persistence/repo'; -export * from './persistence/stats'; export * from './reactivity'; export * from './selection'; export * from './snapping'; @@ -35,5 +33,3 @@ export * from './style-policy'; export * as stencils from './stencils'; export * from './tools'; export * from './vector-effects'; -export * from './ui/filebrowser'; -export * from './ui/statusbar'; diff --git a/packages/core/src/interchange/excalidraw.ts b/packages/core/src/interchange/excalidraw.ts index b68345b..00403be 100644 --- a/packages/core/src/interchange/excalidraw.ts +++ b/packages/core/src/interchange/excalidraw.ts @@ -1,7 +1,14 @@ +import { decodeBase64, encodeBase64 } from '../base64'; import { shapeBounds } from '../geom'; import { paintColor } from '../paint'; import { clamp, Vec2 } from '../math'; -import { EditorBindingRecord, ensureDocumentLayers, EditorShapeRecord, type ArrowShape, type EditorShapeRecord as Shape } from '../editor-model'; +import { + EditorBindingRecord, + ensureDocumentLayers, + EditorShapeRecord, + type ArrowShape, + type EditorShapeRecord as Shape +} from '../editor-model'; import type { BoardExport } from '../persistence/document'; import type { InterchangeExport, InterchangeImport } from '../interchange'; import { @@ -161,7 +168,13 @@ export function importExcalidraw(root: JsonObject, fileName: string): Interchang digest: assetId, bytes: data.bytes }; - shape = EditorShapeRecord.createImage(pageId, origin.x, origin.y, { w: width, h: height, assetId }, shapeId); + shape = EditorShapeRecord.createImage( + pageId, + origin.x, + origin.y, + { w: width, h: height, assetId }, + shapeId + ); break; } case 'text': @@ -685,13 +698,6 @@ function excalidrawPointTuples(value: unknown, name: string): Array<[number, num }); } -function encodeBase64(bytes: number[]): string { - if (typeof btoa !== 'function') return ''; - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - function decodeImageData(value: string, declaredMediaType: unknown): { mediaType: string; bytes: number[] } | null { let mediaType = typeof declaredMediaType === 'string' ? declaredMediaType : 'image/png'; let encoded = value; @@ -701,8 +707,7 @@ function decodeImageData(value: string, declaredMediaType: unknown): { mediaType encoded = dataUrl[2] ?? ''; } try { - if (typeof atob !== 'function') return null; - return { mediaType, bytes: [...atob(encoded)].map((character) => character.charCodeAt(0)) }; + return { mediaType, bytes: decodeBase64(encoded) }; } catch { return null; } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts new file mode 100644 index 0000000..5ad306f --- /dev/null +++ b/packages/core/src/model.ts @@ -0,0 +1,9 @@ +/** Stable model entry point for the interactive editor document. */ +export * from './editor-model'; +export type { + GradientSpread, + GradientStop, + GradientTransform, + GradientUnits, + PaintValue +} from '@inkfinite/bindings/model'; diff --git a/packages/core/src/paint.ts b/packages/core/src/paint.ts index 1e0b589..2c641de 100644 --- a/packages/core/src/paint.ts +++ b/packages/core/src/paint.ts @@ -5,10 +5,6 @@ export type PaintValue = NativePaintValue; /** Canonical native paint definition. */ export type Paint = NativePaint; -type PaintBounds = - | { x: number; y: number; width: number; height: number } - | { min: { x: number; y: number }; max: { x: number; y: number } }; - /** Returns the first visible colour represented by a paint. */ export function paintColor(value: PaintValue | undefined): string | null { if (typeof value === 'string') return value; @@ -30,50 +26,7 @@ export function paintPreview(value: PaintValue | undefined): string { return `radial-gradient(circle, ${stops})`; } -/** Resolves a native paint to a Canvas fill or stroke style in local space. */ -export function paintForCanvas( - context: CanvasRenderingContext2D, - value: PaintValue | undefined, - bounds: PaintBounds -): string | CanvasGradient | null { - if (typeof value === 'string') return value || null; - if (!value) return null; - if (value.kind === 'solid') return value.color || null; - const area = - 'min' in bounds - ? { - x: bounds.min.x, - y: bounds.min.y, - width: bounds.max.x - bounds.min.x, - height: bounds.max.y - bounds.min.y - } - : bounds; - const point = (x: number, y: number) => { - const localX = value.units === 'object_bounding_box' ? area.x + x * area.width : x; - const localY = value.units === 'object_bounding_box' ? area.y + y * area.height : y; - const transform = value.transform; - return { - x: transform.a * localX + transform.c * localY + transform.e, - y: transform.b * localX + transform.d * localY + transform.f - }; - }; - const stopColor = (color: string, opacity: number) => withOpacity(color, opacity); - if (value.kind === 'linear_gradient') { - const start = point(value.x1, value.y1); - const end = point(value.x2, value.y2); - const gradient = context.createLinearGradient(start.x, start.y, end.x, end.y); - for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); - return gradient; - } - const center = point(value.cx, value.cy); - const focus = point(value.fx, value.fy); - const radius = value.units === 'object_bounding_box' ? value.r * Math.min(area.width, area.height) : value.r; - const gradient = context.createRadialGradient(focus.x, focus.y, 0, center.x, center.y, Math.max(0, radius)); - for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); - return gradient; -} - -/** Adds one paint definition and returns the SVG paint value. */ +/** Add one paint definition and return its SVG paint value. */ export function paintToSvg(value: PaintValue | undefined, id: string, definitions: string[]): string { if (typeof value === 'string') return value ? escapeXml(value) : 'none'; if (!value) return 'none'; diff --git a/packages/core/src/persistence.ts b/packages/core/src/persistence.ts new file mode 100644 index 0000000..e9eb792 --- /dev/null +++ b/packages/core/src/persistence.ts @@ -0,0 +1,5 @@ +/** Stable persistence contract and canonical projection entry point. */ +export * from './persistence/canonical'; +export * from './persistence/document'; +export * from './persistence/repo'; +export * from './persistence/stats'; diff --git a/packages/core/src/persistence/desktop.ts b/packages/core/src/persistence/desktop.ts deleted file mode 100644 index eb946b7..0000000 --- a/packages/core/src/persistence/desktop.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * File handle for desktop - just the path - */ -export type FileHandle = { path: string; name: string; modifiedAt?: number }; - -/** - * Directory entry from file system - */ -export type DirectoryEntry = { path: string; name: string; isDir: boolean; modifiedAt?: number }; - -/** - * Desktop-specific operations interface. - * - * Document contents are intentionally absent. The Rust session service owns - * all document reads and writes; this interface is limited to dialogs, - * metadata, and workspace navigation needed by the shared frontend. - */ -export interface DesktopFileOps { - /** - * Show open dialog and return selected file path - */ - showOpenDialog(): Promise; - - /** - * Show save dialog and return selected file path - */ - showSaveDialog(defaultName?: string): Promise; - - /** - * Show the native SVG file picker and return the selected path. - */ - showSvgDialog(): Promise; - - /** - * Get recent files list - */ - getRecentFiles(): Promise; - - /** - * Add a file to recent files list - */ - addRecentFile(handle: FileHandle): Promise; - - /** - * Remove a file from recent files list - */ - removeRecentFile(path: string): Promise; - - /** - * Clear all recent files - */ - clearRecentFiles(): Promise; - - /** - * Get current workspace directory - */ - getWorkspaceDir(): Promise; - - /** - * Set workspace directory - */ - setWorkspaceDir(path: string | null): Promise; - - /** - * Show directory picker and set as workspace - */ - pickWorkspaceDir(): Promise; - - /** - * Read directory contents (filtered by pattern) - */ - readDirectory(directory: string, pattern?: string): Promise; - - /** - * Read a file's last modification time when the platform exposes it. - */ - getFileModifiedAt?(path: string): Promise; - - /** - * Rename a file on disk - */ - renameFile(oldPath: string, newPath: string): Promise; - - /** - * Delete a file from disk - */ - deleteFile(path: string): Promise; -} diff --git a/packages/core/tests/export.test.ts b/packages/core/tests/export.test.ts index 059662c..9285768 100644 --- a/packages/core/tests/export.test.ts +++ b/packages/core/tests/export.test.ts @@ -20,6 +20,25 @@ describe('exportToSVG', () => { expect(svg).toContain(''); }); + it('serializes embedded image bytes without browser globals', () => { + const { state, pageId } = createTestState(); + const assetId = 'asset:pixel'; + const image = EditorShapeRecord.createImage(pageId, 0, 0, { w: 1, h: 1, assetId }); + state.doc.assets = { + [assetId]: { + id: assetId, + name: 'pixel.png', + mediaType: 'image/png', + digest: 'digest:pixel', + bytes: [0, 1, 2] + } + }; + state.doc.shapes[image.id] = image; + state.doc.pages[pageId].shapeIds.push(image.id); + + expect(exportToSVG(state)).toContain('data:image/png;base64,AAEC'); + }); + it('should export variable-width strokes as outlined paths', () => { const { state, pageId } = createTestState(); const stroke = EditorShapeRecord.createStroke(pageId, 0, 0, { @@ -47,7 +66,13 @@ describe('exportToSVG', () => { it('omits the synthetic background when transparent output is requested', () => { const { state, pageId } = createTestState(); - const rect = EditorShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: 'red', stroke: 'black', radius: 0 }); + const rect = EditorShapeRecord.createRect(pageId, 10, 20, { + w: 100, + h: 50, + fill: 'red', + stroke: 'black', + radius: 0 + }); state.doc.shapes[rect.id] = rect; state.doc.pages[pageId].shapeIds.push(rect.id); @@ -60,7 +85,13 @@ describe('exportToSVG', () => { it('should export SVG with a rectangle shape', () => { const { state, pageId } = createTestState(); - const rect = EditorShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: 'red', stroke: 'black', radius: 0 }); + const rect = EditorShapeRecord.createRect(pageId, 10, 20, { + w: 100, + h: 50, + fill: 'red', + stroke: 'black', + radius: 0 + }); state.doc.shapes[rect.id] = rect; state.doc.pages[pageId].shapeIds.push(rect.id); @@ -204,7 +235,13 @@ describe('exportToSVG', () => { it('should export semantic metadata for ordinary shapes', () => { const { state, pageId } = createTestState(); - const rect = EditorShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: 'red', stroke: 'black', radius: 0 }); + const rect = EditorShapeRecord.createRect(pageId, 10, 20, { + w: 100, + h: 50, + fill: 'red', + stroke: 'black', + radius: 0 + }); rect.metadata = { name: 'Gateway', title: null, @@ -233,7 +270,12 @@ describe('exportToSVG', () => { it('should export SVG with an ellipse shape', () => { const { state, pageId } = createTestState(); - const ellipse = EditorShapeRecord.createEllipse(pageId, 10, 20, { w: 100, h: 50, fill: 'blue', stroke: 'green' }); + const ellipse = EditorShapeRecord.createEllipse(pageId, 10, 20, { + w: 100, + h: 50, + fill: 'blue', + stroke: 'green' + }); state.doc.shapes[ellipse.id] = ellipse; state.doc.pages[pageId].shapeIds.push(ellipse.id); @@ -378,7 +420,13 @@ describe('exportToSVG', () => { it('should export only selected shapes when selectedOnly is true', () => { const { state, pageId } = createTestState(); - const rect1 = EditorShapeRecord.createRect(pageId, 0, 0, { w: 50, h: 50, fill: 'red', stroke: 'black', radius: 0 }); + const rect1 = EditorShapeRecord.createRect(pageId, 0, 0, { + w: 50, + h: 50, + fill: 'red', + stroke: 'black', + radius: 0 + }); const rect2 = EditorShapeRecord.createRect(pageId, 100, 100, { w: 50, h: 50, diff --git a/packages/core/tests/import-boundary.test.ts b/packages/core/tests/import-boundary.test.ts new file mode 100644 index 0000000..23ba081 --- /dev/null +++ b/packages/core/tests/import-boundary.test.ts @@ -0,0 +1,37 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const sourceRoot = fileURLToPath(new URL('../src', import.meta.url)); +const forbidden = [ + /\bHTMLCanvasElement\b/, + /\bCanvasRenderingContext2D\b/, + /\bCanvasGradient\b/, + /\bBlob\b/, + /\bnavigator\b/, + /\b(?:atob|btoa)\b/, + /\bdocument\.createElement\b/, + /\bwindow\./, + /from\s+['"](?:svelte|@inkfinite\/(?:editor|ui|wasm)|@tauri-apps\/|\$app\/|\$lib\/)/, + /import\s+['"](?:svelte|@inkfinite\/(?:editor|ui|wasm)|@tauri-apps\/|\$app\/|\$lib\/)/ +]; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []; + }); +} + +describe('core import boundary', () => { + it('keeps browser, UI, and application concerns out of core source', () => { + const violations = sourceFiles(sourceRoot).flatMap((path) => { + const source = readFileSync(path, 'utf8'); + return forbidden.filter((pattern) => pattern.test(source)).map((pattern) => `${path}: ${pattern}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 5a1cde9..f3dd04f 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,33 +1,40 @@ -import { describe, expect, it } from "vitest"; -import * as core from "../src"; +import { describe, expect, it } from 'vitest'; +import * as core from '../src'; -describe("Core exports", () => { - it("should export math types and functions", () => { - expect(core.Vec2).toBeDefined(); - expect(core.Box2).toBeDefined(); - expect(core.Mat3).toBeDefined(); - }); +describe('Core exports', () => { + it('should export math types and functions', () => { + expect(core.Vec2).toBeDefined(); + expect(core.Box2).toBeDefined(); + expect(core.Mat3).toBeDefined(); + }); - it("should export camera functions", () => { - expect(core.Camera).toBeDefined(); - }); + it('should export camera functions', () => { + expect(core.Camera).toBeDefined(); + }); - it("should export model types and functions", () => { - expect(core.EditorShapeRecord).toBeDefined(); - expect(core.EditorPageRecord).toBeDefined(); - expect(core.EditorDocument).toBeDefined(); - }); + it('should export model types and functions', () => { + expect(core.EditorShapeRecord).toBeDefined(); + expect(core.EditorPageRecord).toBeDefined(); + expect(core.EditorDocument).toBeDefined(); + }); - it("should export reactivity functions", () => { - expect(core.Store).toBeDefined(); - expect(core.getShapesOnCurrentPage).toBeDefined(); - }); + it('should export reactivity functions', () => { + expect(core.Store).toBeDefined(); + expect(core.getShapesOnCurrentPage).toBeDefined(); + }); - it("should export geometry functions", () => { - expect(core.shapeBounds).toBeDefined(); - expect(core.pointInRect).toBeDefined(); - expect(core.pointInEllipse).toBeDefined(); - expect(core.pointNearSegment).toBeDefined(); - expect(core.hitTestPoint).toBeDefined(); - }); + it('should export geometry functions', () => { + expect(core.shapeBounds).toBeDefined(); + expect(core.pointInRect).toBeDefined(); + expect(core.pointInEllipse).toBeDefined(); + expect(core.pointNearSegment).toBeDefined(); + expect(core.hitTestPoint).toBeDefined(); + }); + + it('should keep host and UI contracts out of the root entry point', () => { + expect(core).not.toHaveProperty('exportViewportToPNG'); + expect(core).not.toHaveProperty('exportSelectionToPNG'); + expect(core).not.toHaveProperty('FileBrowserVM'); + expect(core).not.toHaveProperty('buildStatusBarVM'); + }); }); diff --git a/packages/core/tests/interchange-fixtures.test.ts b/packages/core/tests/interchange-fixtures.test.ts index cf904c0..0caabd0 100644 --- a/packages/core/tests/interchange-fixtures.test.ts +++ b/packages/core/tests/interchange-fixtures.test.ts @@ -6,9 +6,9 @@ import { importInterchange, EditorShapeRecord, validateDoc, - type BoardExport, type ImportedAsset } from '../src'; +import type { BoardExport } from '../src/persistence'; function fixture(path: string): string { return readFileSync(new URL(`../../../fixtures/interchange/${path}`, import.meta.url), 'utf8'); diff --git a/packages/core/tests/interchange.test.ts b/packages/core/tests/interchange.test.ts index 94ed99a..972c518 100644 --- a/packages/core/tests/interchange.test.ts +++ b/packages/core/tests/interchange.test.ts @@ -5,9 +5,9 @@ import { importInterchange, EditorLayerRecord, EditorPageRecord, - EditorShapeRecord, - type BoardExport + EditorShapeRecord } from '../src'; +import type { BoardExport } from '../src/persistence'; function board(): BoardExport { const page = EditorPageRecord.create('First', 'page:1'); @@ -36,7 +36,13 @@ function board(): BoardExport { { kind: 'edge', nx: 1, ny: 0 }, 'binding:start' ); - const end = EditorBindingRecord.create('shape:arrow', text.id, 'end', { kind: 'edge', nx: -1, ny: 0 }, 'binding:end'); + const end = EditorBindingRecord.create( + 'shape:arrow', + text.id, + 'end', + { kind: 'edge', nx: -1, ny: 0 }, + 'binding:end' + ); const arrow = EditorShapeRecord.createArrow( page.id, 250, diff --git a/packages/core/tests/statusbar.test.ts b/packages/core/tests/statusbar.test.ts deleted file mode 100644 index 27540fe..0000000 --- a/packages/core/tests/statusbar.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CursorState as CursorStateOps } from "../src/cursor"; -import { EditorShapeRecord } from "../src/editor-model"; -import type { EditorShapeRecord as ShapeRecordType } from "../src/editor-model"; -import { EditorState } from "../src/reactivity"; -import { - buildStatusBarVM, - getSelectionSummary, - getSnapSummary, - getToolId, - getZoomPct, - type PersistenceStatus, -} from "../src/ui/statusbar"; - -describe("Status bar selectors", () => { - describe("getZoomPct", () => { - it("rounds zoom values to percentages", () => { - const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: 1.234 } }; - expect(getZoomPct(state)).toBe(123); - }); - - it("falls back to 100 for invalid zoom", () => { - const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: Number.NaN } }; - expect(getZoomPct(state)).toBe(100); - }); - }); - - describe("getToolId", () => { - it("returns the active tool id", () => { - const base = EditorState.create(); - const state: EditorState = { ...base, ui: { ...base.ui, toolId: "rect", currentPageId: null, selectionIds: [] } }; - expect(getToolId(state)).toBe("rect"); - }); - }); - - describe("getSelectionSummary", () => { - it("returns zero summary when nothing is selected", () => { - const state = buildState([], []); - expect(getSelectionSummary(state)).toEqual({ count: 0 }); - }); - - it("describes a single selected shape", () => { - const rect = EditorShapeRecord.createRect( - "page-1", - 10, - 20, - { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, - "shape-rect", - ); - const state = buildState([rect], ["shape-rect"]); - expect(getSelectionSummary(state)).toEqual({ count: 1, kind: "rect", bounds: { w: 40, h: 20 } }); - }); - - it("summarizes multiple selections with combined bounds and mixed kind", () => { - const rect = EditorShapeRecord.createRect( - "page-1", - 10, - 20, - { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, - "shape-rect", - ); - const ellipse = EditorShapeRecord.createEllipse( - "page-1", - 100, - 50, - { w: 20, h: 20, fill: "#f00", stroke: "#111" }, - "shape-ellipse", - ); - const state = buildState([rect, ellipse], ["shape-rect", "shape-ellipse"]); - - expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "mixed", bounds: { w: 110, h: 50 } }); - }); - - it("marks kind when all selected shapes match", () => { - const rectA = EditorShapeRecord.createRect( - "page-1", - 0, - 0, - { w: 10, h: 10, fill: "#000", stroke: "#fff", radius: 0 }, - "shape-1", - ); - const rectB = EditorShapeRecord.createRect( - "page-1", - 20, - 20, - { w: 10, h: 10, fill: "#111", stroke: "#eee", radius: 0 }, - "shape-2", - ); - const state = buildState([rectA, rectB], ["shape-1", "shape-2"]); - - expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "rect", bounds: { w: 30, h: 30 } }); - }); - }); - - describe("getSnapSummary", () => { - it("returns safe defaults when snapping is disabled", () => { - const state = buildState([], []); - expect(getSnapSummary(state)).toEqual({ enabled: false }); - }); - }); - - describe("buildStatusBarVM", () => { - it("composes slices into a status bar view model", () => { - const rect = EditorShapeRecord.createRect( - "page-1", - 0, - 0, - { w: 50, h: 50, fill: "#000", stroke: "#fff", radius: 0 }, - "shape-rect", - ); - const state = buildState([rect], ["shape-rect"]); - const cursorState = CursorStateOps.create({ x: 5, y: 6 }, { x: 1, y: 2 }, 42); - const persistence: PersistenceStatus = { backend: "indexeddb", state: "saving", pendingWrites: 1 }; - - const vm = buildStatusBarVM(state, cursorState, persistence, "dragging"); - - expect(vm.cursorWorld).toEqual({ x: 5, y: 6 }); - expect(vm.cursorScreen).toEqual({ x: 1, y: 2 }); - expect(vm.toolId).toBe("select"); - expect(vm.mode).toBe("dragging"); - expect(vm.selection).toEqual({ count: 1, kind: "rect", bounds: { w: 50, h: 50 } }); - expect(vm.snap).toEqual({ enabled: false }); - expect(vm.persistence).toEqual({ backend: "indexeddb", state: "saving", pendingWrites: 1 }); - expect(vm.cursorWorld).not.toBe(cursorState.cursorWorld); - expect(vm.persistence).not.toBe(persistence); - }); - }); -}); - -function buildState(shapes: ShapeRecordType[], selectionIds: string[]) { - const base = EditorState.create(); - const pageId = "page-1"; - const docShapes = Object.fromEntries(shapes.map((shape) => [shape.id, shape])); - const page = { id: pageId, name: "Page 1", shapeIds: shapes.map((shape) => shape.id) }; - - return { - ...base, - doc: { pages: { [pageId]: page }, shapes: docShapes, bindings: {} }, - ui: { ...base.ui, currentPageId: pageId, selectionIds }, - }; -} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 12a2c9d..8e1dcd5 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,20 +1,20 @@ { - "compilerOptions": { - "target": "esnext", - "lib": ["es2023", "dom"], - "moduleDetection": "force", - "module": "preserve", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "types": ["node"], - "strict": true, - "noUnusedLocals": true, - "declaration": true, - "emitDeclarationOnly": true, - "esModuleInterop": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true - }, - "include": ["src", "tests"] + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] } diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 7bc2f46..217e49b 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,3 +1,14 @@ -import { defineConfig } from "tsdown"; +import { defineConfig } from 'tsdown'; -export default defineConfig({ exports: true }); +export default defineConfig({ + entry: { + index: 'src/index.ts', + model: 'src/model.ts', + geometry: 'src/geometry.ts', + commands: 'src/commands.ts', + selection: 'src/selection.ts', + interchange: 'src/interchange.ts', + persistence: 'src/persistence.ts' + }, + exports: true +}); diff --git a/packages/editor/package.json b/packages/editor/package.json index c1b3bdf..a637587 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -12,6 +12,14 @@ "types": "./dist/renderer.d.ts", "default": "./dist/renderer.js" }, + "./export": { + "types": "./dist/export.d.ts", + "default": "./dist/export.js" + }, + "./paint": { + "types": "./dist/paint.d.ts", + "default": "./dist/paint.js" + }, "./runtime": { "types": "./dist/runtime.d.ts", "default": "./dist/runtime.js" diff --git a/packages/editor/src/export.ts b/packages/editor/src/export.ts new file mode 100644 index 0000000..d1c7fbe --- /dev/null +++ b/packages/editor/src/export.ts @@ -0,0 +1,82 @@ +import { Box2 as Box2Ops, exportBounds, getSelectedShapes, type Box2 } from '@inkfinite/core'; +import type { EditorShapeRecord, EditorState } from '@inkfinite/core'; + +/** Export a visible HTML canvas viewport as a PNG blob. */ +export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to export canvas to PNG')); + } + }, 'image/png'); + }); +} + +/** + * Render the selected editor shapes into a temporary canvas and export them as + * a PNG blob. + * + * The renderer is supplied by the host so this adapter does not duplicate + * shape drawing or introduce a renderer dependency. + */ +export async function exportSelectionToPNG( + state: EditorState, + renderFunction: (context: CanvasRenderingContext2D, shapes: EditorShapeRecord[], bounds: Box2) => void +): Promise { + const shapes = getSelectedShapes(state); + if (shapes.length === 0) { + return null; + } + + const bounds = combineBounds(shapes.map((shape) => exportBounds(state, shape))); + if (!bounds) { + return null; + } + + const padding = 20; + const width = Box2Ops.width(bounds) + padding * 2; + const height = Box2Ops.height(bounds) + padding * 2; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to get 2D context'); + } + + context.fillStyle = 'white'; + context.fillRect(0, 0, width, height); + context.save(); + context.translate(-bounds.min.x + padding, -bounds.min.y + padding); + renderFunction(context, shapes, bounds); + context.restore(); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to export selection to PNG')); + } + }, 'image/png'); + }); +} + +function combineBounds(boxes: Box2[]): Box2 | null { + if (boxes.length === 0) { + return null; + } + + let combined = Box2Ops.clone(boxes[0]); + for (let index = 1; index < boxes.length; index++) { + const box = boxes[index]; + combined = { + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) } + }; + } + return combined; +} diff --git a/packages/editor/src/input-dom.ts b/packages/editor/src/input-dom.ts index 3259858..1310082 100644 --- a/packages/editor/src/input-dom.ts +++ b/packages/editor/src/input-dom.ts @@ -60,6 +60,7 @@ export type InputAdapterConfig = { */ export class InputAdapter { private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; + private readonly primaryModifierPlatform: 'mac' | 'other'; private pointerState: PointerState; private boundHandlers: { pointerDown: (e: PointerEvent) => void; @@ -84,6 +85,7 @@ export class InputAdapter { preventDefault: config.preventDefault ?? true, captureKeyboard: config.captureKeyboard ?? true }; + this.primaryModifierPlatform = navigator.platform.toUpperCase().includes('MAC') ? 'mac' : 'other'; this.pointerState = { isDown: false, @@ -439,15 +441,15 @@ export class InputAdapter { return true; } - if (Modifiers.isPrimaryModifier(modifiers) && (key === 'z' || key === 'Z')) { + if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform) && (key === 'z' || key === 'Z')) { return true; } - if (Modifiers.isPrimaryModifier(modifiers) && (key === 'y' || key === 'Y')) { + if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform) && (key === 'y' || key === 'Y')) { return true; } - if (Modifiers.isPrimaryModifier(modifiers)) { + if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform)) { if ('abgldcxvzy'.includes(key.toLowerCase()) || key === '[' || key === ']') return true; } diff --git a/packages/editor/src/paint.ts b/packages/editor/src/paint.ts new file mode 100644 index 0000000..c2965c6 --- /dev/null +++ b/packages/editor/src/paint.ts @@ -0,0 +1,69 @@ +import type { PaintValue } from '@inkfinite/core'; + +type PaintBounds = + | { x: number; y: number; width: number; height: number } + | { min: { x: number; y: number }; max: { x: number; y: number } }; + +/** Resolve a paint to a Canvas fill or stroke style in local space. */ +export function paintForCanvas( + context: CanvasRenderingContext2D, + value: PaintValue | undefined, + bounds: PaintBounds +): string | CanvasGradient | null { + if (typeof value === 'string') return value || null; + if (!value) return null; + if (value.kind === 'solid') return value.color || null; + const area = + 'min' in bounds + ? { + x: bounds.min.x, + y: bounds.min.y, + width: bounds.max.x - bounds.min.x, + height: bounds.max.y - bounds.min.y + } + : bounds; + const point = (x: number, y: number) => { + const localX = value.units === 'object_bounding_box' ? area.x + x * area.width : x; + const localY = value.units === 'object_bounding_box' ? area.y + y * area.height : y; + const transform = value.transform; + return { + x: transform.a * localX + transform.c * localY + transform.e, + y: transform.b * localX + transform.d * localY + transform.f + }; + }; + const stopColor = (color: string, opacity: number) => withOpacity(color, opacity); + if (value.kind === 'linear_gradient') { + const start = point(value.x1, value.y1); + const end = point(value.x2, value.y2); + const gradient = context.createLinearGradient(start.x, start.y, end.x, end.y); + for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); + return gradient; + } + const center = point(value.cx, value.cy); + const focus = point(value.fx, value.fy); + const radius = value.units === 'object_bounding_box' ? value.r * Math.min(area.width, area.height) : value.r; + const gradient = context.createRadialGradient(focus.x, focus.y, 0, center.x, center.y, Math.max(0, radius)); + for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); + return gradient; +} + +function withOpacity(color: string, opacity: number): string { + if (opacity >= 1) return color; + const hex = color.match(/^#([\da-f]{3,8})$/i)?.[1]; + if (hex) { + const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((part) => part + part).join('') : hex; + const rgb = expanded + .slice(0, 6) + .match(/[\da-f]{2}/gi) + ?.map((part) => parseInt(part, 16)); + if (rgb?.length === 3) + return `rgba(${rgb.join(', ')}, ${opacity * (expanded.length === 8 ? parseInt(expanded.slice(6), 16) / 255 : 1)})`; + } + const rgba = color.match(/^rgba?\(([^)]+)\)$/i); + if (rgba) { + const parts = rgba[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) + return `rgba(${parts.slice(0, 3).join(', ')}, ${opacity * (parts[3] ? Number(parts[3]) : 1)})`; + } + return color; +} diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index bbabc85..7acd589 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -23,7 +23,6 @@ import type { SnapGuide } from '@inkfinite/core'; import { - paintForCanvas, arrowBendHandleForShape, arrowGeometryForShape, arrowHeadGeometry, @@ -51,6 +50,7 @@ import { strokeWidthHandleId, strokeWidthHandles } from '@inkfinite/core'; +import { paintForCanvas } from './paint.js'; export interface Renderer { /** diff --git a/packages/ui/src/lib/editor/__tests__/statusbar.test.ts b/packages/ui/src/lib/editor/__tests__/statusbar.test.ts new file mode 100644 index 0000000..c054c04 --- /dev/null +++ b/packages/ui/src/lib/editor/__tests__/statusbar.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { CursorState as CursorStateOps, EditorShapeRecord, EditorState } from '@inkfinite/core'; +import type { EditorShapeRecord as ShapeRecordType } from '@inkfinite/core'; +import { + buildStatusBarVM, + getSelectionSummary, + getSnapSummary, + getToolId, + getZoomPct, + type PersistenceStatus +} from '../statusbar'; + +describe('Status bar selectors', () => { + describe('getZoomPct', () => { + it('rounds zoom values to percentages', () => { + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: 1.234 } }; + expect(getZoomPct(state)).toBe(123); + }); + + it('falls back to 100 for invalid zoom', () => { + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: Number.NaN } }; + expect(getZoomPct(state)).toBe(100); + }); + }); + + describe('getToolId', () => { + it('returns the active tool id', () => { + const base = EditorState.create(); + const state: EditorState = { + ...base, + ui: { ...base.ui, toolId: 'rect', currentPageId: null, selectionIds: [] } + }; + expect(getToolId(state)).toBe('rect'); + }); + }); + + describe('getSelectionSummary', () => { + it('returns zero summary when nothing is selected', () => { + const state = buildState([], []); + expect(getSelectionSummary(state)).toEqual({ count: 0 }); + }); + + it('describes a single selected shape', () => { + const rect = EditorShapeRecord.createRect( + 'page-1', + 10, + 20, + { w: 40, h: 20, fill: '#000', stroke: '#fff', radius: 0 }, + 'shape-rect' + ); + const state = buildState([rect], ['shape-rect']); + expect(getSelectionSummary(state)).toEqual({ + count: 1, + kind: 'rect', + bounds: { w: 40, h: 20 } + }); + }); + + it('summarizes multiple selections with combined bounds and mixed kind', () => { + const rect = EditorShapeRecord.createRect( + 'page-1', + 10, + 20, + { w: 40, h: 20, fill: '#000', stroke: '#fff', radius: 0 }, + 'shape-rect' + ); + const ellipse = EditorShapeRecord.createEllipse( + 'page-1', + 100, + 50, + { w: 20, h: 20, fill: '#f00', stroke: '#111' }, + 'shape-ellipse' + ); + const state = buildState([rect, ellipse], ['shape-rect', 'shape-ellipse']); + + expect(getSelectionSummary(state)).toEqual({ + count: 2, + kind: 'mixed', + bounds: { w: 110, h: 50 } + }); + }); + + it('marks kind when all selected shapes match', () => { + const rectA = EditorShapeRecord.createRect( + 'page-1', + 0, + 0, + { w: 10, h: 10, fill: '#000', stroke: '#fff', radius: 0 }, + 'shape-1' + ); + const rectB = EditorShapeRecord.createRect( + 'page-1', + 20, + 20, + { w: 10, h: 10, fill: '#111', stroke: '#eee', radius: 0 }, + 'shape-2' + ); + const state = buildState([rectA, rectB], ['shape-1', 'shape-2']); + + expect(getSelectionSummary(state)).toEqual({ + count: 2, + kind: 'rect', + bounds: { w: 30, h: 30 } + }); + }); + }); + + describe('getSnapSummary', () => { + it('returns safe defaults when snapping is disabled', () => { + const state = buildState([], []); + expect(getSnapSummary(state)).toEqual({ enabled: false }); + }); + }); + + describe('buildStatusBarVM', () => { + it('composes slices into a status bar view model', () => { + const rect = EditorShapeRecord.createRect( + 'page-1', + 0, + 0, + { w: 50, h: 50, fill: '#000', stroke: '#fff', radius: 0 }, + 'shape-rect' + ); + const state = buildState([rect], ['shape-rect']); + const cursorState = CursorStateOps.create({ x: 5, y: 6 }, { x: 1, y: 2 }, 42); + const persistence: PersistenceStatus = { + backend: 'indexeddb', + state: 'saving', + pendingWrites: 1 + }; + + const vm = buildStatusBarVM(state, cursorState, persistence, 'dragging'); + + expect(vm.cursorWorld).toEqual({ x: 5, y: 6 }); + expect(vm.cursorScreen).toEqual({ x: 1, y: 2 }); + expect(vm.toolId).toBe('select'); + expect(vm.mode).toBe('dragging'); + expect(vm.selection).toEqual({ count: 1, kind: 'rect', bounds: { w: 50, h: 50 } }); + expect(vm.snap).toEqual({ enabled: false }); + expect(vm.persistence).toEqual({ + backend: 'indexeddb', + state: 'saving', + pendingWrites: 1 + }); + expect(vm.cursorWorld).not.toBe(cursorState.cursorWorld); + expect(vm.persistence).not.toBe(persistence); + }); + }); +}); + +function buildState(shapes: ShapeRecordType[], selectionIds: string[]) { + const base = EditorState.create(); + const pageId = 'page-1'; + const docShapes = Object.fromEntries(shapes.map((shape) => [shape.id, shape])); + const page = { id: pageId, name: 'Page 1', shapeIds: shapes.map((shape) => shape.id) }; + + return { + ...base, + doc: { pages: { [pageId]: page }, shapes: docShapes, bindings: {} }, + ui: { ...base.ui, currentPageId: pageId, selectionIds } + }; +} diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 3e34b92..71f6f88 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -15,7 +15,6 @@ import { DirectSelectTool, createToolMap, CursorStore, - diffDoc, EllipseTool, FrameTool, getInteractiveShapesOnCurrentPage, @@ -43,13 +42,12 @@ import type { Box2, InterchangeFormat, InterchangeWarning, - LoadedDoc, SvgExport, SvgExportOptions, - PersistenceSink, - PersistentDocRepo, Viewport } from '@inkfinite/core'; +import { diffDoc } from '@inkfinite/core/persistence'; +import type { LoadedDoc, PersistenceSink, PersistentDocRepo } from '@inkfinite/core/persistence'; import { stencils } from '@inkfinite/core'; import { Action, EditorRuntime } from '@inkfinite/editor/runtime'; import { createImageAsset, pasteImage } from '../clipboard'; @@ -1233,7 +1231,9 @@ export function createCanvasController( void sink ?.flush() .then(() => desktopRepo?.closeSession()) - .catch((error) => console.error('Failed to close desktop session', error)); + .catch((error: unknown) => + console.error('Failed to close desktop session', error) + ); } unsubscribeMarqueeCamera(); removeBeforeUnload?.(); diff --git a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts index b89c828..3fb3104 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts @@ -1,4 +1,4 @@ -import type { BoardMeta, LoadedDoc, PersistentDocRepo } from '@inkfinite/core'; +import type { BoardMeta, LoadedDoc, PersistentDocRepo } from '@inkfinite/core/persistence'; import type { DesktopDocumentRepo } from '../../platform'; function isUserCancelled(error: unknown) { diff --git a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts index 6d3eb78..e2cffb1 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts @@ -1,10 +1,9 @@ -import { - FileBrowserVM, - type BoardInspectorData, - type FileBrowserViewModel, - type PersistentDocRepo -} from '@inkfinite/core'; -import type { LoadedDoc } from '@inkfinite/core'; +import { FileBrowserVM, type FileBrowserViewModel } from '../../filebrowser/model'; +import type { + BoardInspectorData, + LoadedDoc, + PersistentDocRepo +} from '@inkfinite/core/persistence'; export class FileBrowserController { open = $state(false); diff --git a/packages/ui/src/lib/editor/components/StatusBar.svelte b/packages/ui/src/lib/editor/components/StatusBar.svelte index 7313425..84403d3 100644 --- a/packages/ui/src/lib/editor/components/StatusBar.svelte +++ b/packages/ui/src/lib/editor/components/StatusBar.svelte @@ -1,13 +1,7 @@