diff --git a/ROADMAP.md b/ROADMAP.md index b1244c7..e04267f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,9 +42,11 @@ the CLI and a bundled `SKILL.md`; MCP and UI automation are not part of vNext. - TypeScript currently owns a flat page/shape model, snapshot undo/redo, tools, and browser persistence. Web documents use Dexie; desktop documents use a thin adapter over Rust-owned sessions and typed Tauri commands. -- `createCanvasController` combines tools, persistence, overlays, rendering, and - input. The renderer walks every shape on the page and resizes its backing - canvas on each draw. +- `@inkfinite/runtime` owns framework-neutral camera, tool, selection, snapping, + shortcut, and gesture-preview state. `@inkfinite/input-dom` normalizes browser + events, while the Svelte canvas controller wires DOM overlays, rendering, and + session persistence around those packages. The renderer still walks every + shape on the page and resizes its backing canvas on each draw. - Built-in stencils, grid snapping, a dirty-frame loop, Markdown shapes, and cursor coordinate mapping already exist. vNext must preserve them while adding active-layer placement, measured rendering improvements, Markdown layout @@ -181,6 +183,16 @@ mirror and a thin metadata/dialog adapter. Desktop document bytes no longer cross the frontend file APIs. Recovery and failure-path tests cover stale heads, failed validation, interrupted writes, and save-as path replacement. +V2-09 completed on July 17, 2026. `@inkfinite/runtime` now owns normalized +action routing, camera panning, selection shortcuts, grid snapping, and local +gesture previews without importing Svelte, Tauri, or persistence code. Completed +document gestures emit one runtime transaction draft; the Svelte adapter turns +that boundary into one history command, and the desktop session adapter builds +one Rust transaction before replacing its mirror from the returned snapshot. +`@inkfinite/input-dom` owns browser listener and coordinate normalization. A +browser integration test covers drag, simulated Rust commit, patch-driven +redraw, and undo back to the original document. + One Inkfinite transaction maps to one Automerge change. Causal heads, rather than a scalar revision, are the concurrency token. A local sequence number may be displayed, but callers use inspected heads and operation preconditions. diff --git a/TODO.md b/TODO.md index 59eaeff..d7a4660 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,6 @@ # Inkfinite vNext tickets -These tickets implement [ROADMAP.md](ROADMAP.md). Work one ticket per fresh -implementation context. A ticket can start when all of its blockers are done. +These tickets implement [ROADMAP.md](ROADMAP.md). ## Milestone 1: Architecture gate @@ -10,64 +9,14 @@ evidence. ### V2-01: Freeze compatibility fixtures and baselines -What to build: Capture representative v1 web and desktop documents, invalid -documents, rendering examples, and a generated 10,000-shape board before the -model changes. - -Blocked by: None - can start immediately - -Acceptance criteria: - -- [x] Fixtures cover every current shape, bindings, groups, pages, Markdown, - stencils, history-relevant edits, and persisted ordering. -- [x] Import, render, hit-test, open/save, and cursor-after-resize behavior have - baseline tests; known failures are marked as such rather than normalized. -- [x] The performance harness records hardware, runtime versions, fixture seed, - visible-shape count, frame time, hit-test time, memory, open, and save time. -- [x] Current package tests, type checks, web checks, lint, and desktop Rust tests - run from documented commands. - -Verification: - -```sh -pnpm --filter @inkfinite/core test --run -pnpm --filter @inkfinite/renderer test --run -pnpm --filter @inkfinite/web test -pnpm --filter @inkfinite/core typecheck -pnpm --filter @inkfinite/renderer typecheck -pnpm --filter @inkfinite/web check -pnpm --filter @inkfinite/web lint -cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml -``` +Captured representative v1 web and desktop documents, invalid documents, rendering +examples, and a generated 10,000-shape board before the model changes. ### V2-02: Prove the CRDT boundary -What to build: Implement a disposable Automerge proof through an +Implemented a disposable Automerge proof through an Inkfinite-owned interface. Compare Yjs/Yrs only if Automerge misses a gate. -Blocked by: V2-01 - -Acceptance criteria: - -- [x] Rust and JavaScript round-trip the same nested maps, ordered child lists, - text, and 10,000-shape document without semantic drift. -- [x] Two offline replicas merge concurrent property, list, text, delete, and - reparent edits and converge byte-independently to the same materialized - snapshot. -- [x] Incremental patches, change heads, actor IDs, actor-scoped undo, sync, - save/load, and compaction are demonstrated. -- [x] A merge is applied on a fork and adopted only after deterministic repair - and validation; duplicate children, missing parents, dangling bindings, - and zero-layer pages have convergence tests. -- [x] Results compare time, memory, storage growth, API risk, and dependency - versions with the V2-01 baseline and record the final Automerge or Yjs/Yrs - decision in `ROADMAP.md`. - -Verification: - -- Run the proof's Rust, JavaScript, cross-language, convergence, and benchmark - suites twice with fixed seeds; both runs must materialize identical snapshots. - ## Milestone 2: Rust document authority Exit when Rust can import, edit, merge, validate, persist, and expose the v2 @@ -75,146 +24,30 @@ document independently of Tauri and Svelte. ### V2-03: Scaffold the vNext project structure -What to build: Create the target Cargo and pnpm workspace layout as a compileable -shell before moving behavior or defining the v2 model. - -Blocked by: V2-02 - -Acceptance criteria: - -- [x] A root Cargo workspace contains the model, CRDT, engine, file, protocol, - SVG, IPC, and CLI crates named in the roadmap. -- [x] The pnpm workspace contains `@inkfinite/runtime`, - `@inkfinite/input-dom`, `@inkfinite/bindings`, and - `@inkfinite/renderer`; the existing apps and packages keep working in - place during the scaffold step. -- [x] New crates and packages contain only minimal compileable entry points, - manifests, and dependency edges. This ticket moves no product behavior. -- [x] Workspace commands can discover, build, test, type-check, and lint the new - members from the repository root. - -Verification: - -```sh -cargo metadata --no-deps -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings -pnpm -r --if-present build -pnpm -r --if-present typecheck -``` +Created the target Cargo and pnpm workspace layout as a compileable shell +before moving behavior or defining the v2 model. ### V2-04: Define the Rust document and protocol contracts -What to build: Add typed v2 records, project-owned CRDT traits, and protocol -records without UI, Tauri, transport, or CLI parsing dependencies. - -Blocked by: V2-03 - -Acceptance criteria: - -- [x] The model covers pages, layers, scene hierarchy, built-in shape kinds, - bindings, assets, semantic metadata, provenance, opacity, and format IDs. -- [x] Ordered child lists have one source of truth and public operations use - sibling anchors rather than numeric indexes. -- [x] CRDT, engine, file, rendering, IPC, and CLI code depend on the contracts in - one direction; model and protocol crates do not import those consumers. -- [x] `cargo test --workspace` and strict Clippy run from the repository root. - -Verification: - -```sh -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings -``` +Added typed v2 records, project-owned CRDT traits, and protocol records without UI, +Tauri, transport, or CLI parsing dependencies. ### V2-05: Implement validated CRDT transactions -What to build: Apply every durable operation through one engine transaction and +Applies every durable operation through one engine transaction and one CRDT change, returning patches, heads, inverse metadata, affected IDs, and warnings. -Blocked by: V2-04 - -Acceptance criteria: - -- [x] Operations cover page, layer, shape, hierarchy, binding, asset, and layout - changes with head and record-version preconditions. -- [x] Schema, precondition, permission, invariant, repair, and final snapshot - checks run on a fork before an atomic commit. -- [x] Undo and redo emit actor-scoped compensating changes and preserve - intervening edits from other actors. -- [x] Production `inkfinite-core::crdt` tests absorb the reusable V2-02 cases for - nested data, patches, heads, actor IDs, undo, fork repair, convergence, - transport-independent sync, save/load, compaction, and 10,000 shapes. - Preserve the measured benchmark result, then remove - `spikes/crdt-automerge`, its workspace entry, dependency, and root proof - commands. Keep the V1 compatibility fixtures and baselines. -- [x] Queries support IDs, names, roles, tags, kinds, parents, layers, bounds, - and affected regions; alignment and distribution use the same engine. -- [x] Property-based tests cover apply/invert, merge order, deterministic repair, - and convergence. - -Verification: - -```sh -cargo test -p inkfinite-core -test ! -d spikes/crdt-automerge -``` - ### V2-06: Generate schemas and TypeScript bindings -What to build: Generate document, transaction, and protocol bindings from Rust -and prove the Rust and TypeScript shape registries agree. - -Blocked by: V2-04 - -Acceptance criteria: - -- [x] Schemars emits JSON Schemas and `ts-rs` emits TypeScript bindings from the - authoritative Rust records. -- [x] Shared fixtures verify kind names, property validation, serialization, - bounds, transforms, and geometry conventions in Rust and TypeScript. -- [x] A check command fails on stale generated output; generated files carry a - clear do-not-edit header. -- [x] Schema changes require fixture and migration updates. - -Verification: - -- `cargo run -p inkfinite-cli --bin generate-bindings` generates the - schemas, bindings, and shared fixture. Running it again leaves the generated - files unchanged. -- `cargo run -p inkfinite-cli --bin generate-bindings -- --check` fails - when any generated artifact is stale. -- `cargo test -p inkfinite-core` runs the Rust registry - and shared-fixture conformance tests. -- `pnpm --filter @inkfinite/bindings test` typechecks the bindings and runs the - TypeScript fixture conformance test. +Generates document, transaction, and protocol bindings from Rust and prove the Rust and +TypeScript shape registries agree. ### V2-07: Import v1 and persist v2 safely -What to build: Import `.inkfinite.json`, persist canonical `.inkfinite` CRDT +Import `.inkfinite.json`, persist canonical `.inkfinite` CRDT files, export stable JSON snapshots, and recover from interrupted writes. -Blocked by: V2-04, V2-05 - -Acceptance criteria: - -- [x] Every V2-01 valid fixture imports with the same page, shape, binding, - group, style, and draw order; each page gains one default layer. -- [x] Invalid or newer formats produce typed errors and never overwrite input. -- [x] Saves use a same-directory temporary file, flush, atomic replacement where - supported, recovery copies, and advisory locking. -- [x] Recovery stores a compact snapshot plus bounded change journal and can - restore after failures at each write step. -- [x] JSON export is deterministic and documented as a snapshot that cannot - preserve CRDT history. - -Verification: - -```sh -cargo test -p inkfinite-core file:: -``` - ## Milestone 3: Desktop vertical slice Exit when a desktop gesture commits through Rust, redraws from a returned patch, @@ -224,36 +57,8 @@ not create a separate desktop component tree or theme. ### V2-08: Make Tauri own document sessions -What to build: Replace frontend-owned desktop documents with a Rust session -service and typed Tauri commands. - -Blocked by: V2-05, V2-06, V2-07 - -Acceptance criteria: - -- [x] Sessions track path, CRDT state, materialized snapshot, actor undo/redo, - dirty state, locks, recovery, and sync state. -- [x] Create/open/snapshot/commit/undo/redo/save/save-as/query/validate/close - commands call shared crates and return typed errors and patches. -- [x] File I/O leaves the frontend and plugin capabilities are reduced to the - minimum still required. -- [x] `apps/web` imports `@inkfinite/ui/styles.css` once and uses shared package - components for the desktop vertical slice instead of adding new local - copies under `apps/web/src/lib/components`. -- [x] `apps/desktop` builds and packages the same `apps/web` frontend. Desktop-only - behavior stays in Tauri commands or thin adapters, while components, - themes, fonts, and icons come from `@inkfinite/ui`. -- [x] Integration tests cover open, edit, save, reopen, undo, failed validation, - stale heads, and a simulated write failure. - -Verification: - -```sh -cargo test -p desktop -pnpm --filter @inkfinite/web test -pnpm --filter @inkfinite/ui check -pnpm --filter @inkfinite/ui test -``` +Replaced frontend-owned desktop documents with a Rust session service and typed Tauri +commands. ### V2-09: Extract the editor runtime and transaction drafts @@ -265,18 +70,18 @@ Blocked by: V2-06, V2-08 Acceptance criteria: -- [ ] Camera, tools, selection, input routing, and gesture previews have no +- [x] Camera, tools, selection, input routing, and gesture previews have no Svelte or persistence dependency. -- [ ] Pointer movement stays local; each completed drag, resize, text edit, +- [x] Pointer movement stays local; each completed drag, resize, text edit, stencil insertion, or shortcut produces one transaction draft. -- [ ] The frontend document mirror changes only through snapshots or commit/sync +- [x] The frontend document mirror changes only through snapshots or commit/sync patches from Rust. -- [ ] Svelte adapters compose controls from `@inkfinite/ui`; reusable visual +- [x] Svelte adapters compose controls from `@inkfinite/ui`; reusable visual components do not gain `@inkfinite/runtime`, persistence, or Tauri dependencies. -- [ ] Existing keyboard, selection, text, Markdown, arrow, pen, grid snap, +- [x] Existing keyboard, selection, text, Markdown, arrow, pen, grid snap, stencil, and history behavior remains covered. -- [ ] One browser integration test performs drag → Rust commit → patch → redraw +- [x] One browser integration test performs drag → Rust commit → patch → redraw → undo and compares the original document. Verification: diff --git a/apps/web/package.json b/apps/web/package.json index 83a4efa..7ce4d7a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,9 @@ "dexie": "^4.2.1", "@inkfinite/bindings": "workspace:*", "@inkfinite/core": "workspace:*", + "@inkfinite/input-dom": "workspace:*", "@inkfinite/renderer": "workspace:*", + "@inkfinite/runtime": "workspace:*", "@inkfinite/ui": "workspace:*" }, "devDependencies": { diff --git a/apps/web/src/lib/canvas/canvas-helpers.ts b/apps/web/src/lib/canvas/canvas-helpers.ts index 6f251ef..65e54cc 100644 --- a/apps/web/src/lib/canvas/canvas-helpers.ts +++ b/apps/web/src/lib/canvas/canvas-helpers.ts @@ -1,5 +1,3 @@ -import type { Action, CommandKind, EditorState } from "@inkfinite/core"; - export const handleCursorMap: Record = { n: "ns-resize", s: "ns-resize", @@ -39,53 +37,6 @@ export function computeCursor( return "default"; } -export function statesEqual(a: EditorState, b: EditorState): boolean { - return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; -} - -export function getCommandKind(before: EditorState, after: EditorState): CommandKind { - if (before.doc !== after.doc) { - return "doc"; - } - if (before.camera !== after.camera) { - return "camera"; - } - return "ui"; -} - -export function describeAction(action: Action, kind: CommandKind): string { - switch (action.type) { - case "key-down": { - if (action.key.startsWith("Arrow")) { - return "Nudge"; - } - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - if (primaryModifier && (action.key === "d" || action.key === "D")) { - return "Duplicate"; - } - if (primaryModifier && action.key === "]") { - return "Bring Forward"; - } - if (primaryModifier && action.key === "[") { - return "Send Backward"; - } - return "Key down"; - } - case "pointer-down": - return "Pointer down"; - case "pointer-move": - return "Pointer move"; - case "pointer-up": - return "Pointer up"; - case "wheel": - return "Wheel"; - case "key-up": - return "Key up"; - default: - return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; - } -} - export function isUserCancelled(error: unknown) { return error instanceof Error && /cancel/i.test(error.message); } diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index dfa56b1..0aa577e 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -12,7 +12,6 @@ import { createToolMap, CursorStore, diffDoc, - EditorState, EllipseTool, getShapesOnCurrentPage, InkfiniteDB, @@ -20,22 +19,20 @@ import { MarkdownTool, PenTool, RectTool, - routeAction, SelectTool, shapeBounds, - ShapeRecord, SnapshotCommand, Store, TextTool, } from "@inkfinite/core"; -import type { Action, Box2, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "@inkfinite/core"; +import type { Box2, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "@inkfinite/core"; import { stencils } from "@inkfinite/core"; +import { Action, EditorRuntime } from "@inkfinite/runtime"; import { createRenderer, type Renderer } from "@inkfinite/renderer"; type Stencil = stencils.Stencil; import { onDestroy, onMount } from "svelte"; -import { SvelteSet } from "svelte/reactivity"; -import { computeCursor, describeAction, getCommandKind, statesEqual } from "./canvas-helpers"; +import { computeCursor } from "./canvas-helpers"; import { ArrowLabelEditorController } from "./controllers/arrowlabel-controller.svelte"; import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; @@ -44,7 +41,6 @@ import { MarkdownEditorController } from "./controllers/markdown-controller.svel import { TextEditorController } from "./controllers/texteditor-controller.svelte"; import { ToolController } from "./controllers/tool-controller.svelte"; import { HandleState } from "./store/handle-state.svelte"; -import { PanState } from "./store/pan-state.svelte"; import { PointerState } from "./store/pointer-state.svelte"; export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): void }; @@ -79,7 +75,6 @@ export function createCanvasController(bindings: CanvasControllerBindings) { const pointerState = new PointerState(); const handleState = new HandleState(); - const panState = new PanState(); const store = new Store(undefined, { onHistoryEvent: (event) => { @@ -132,9 +127,12 @@ export function createCanvasController(bindings: CanvasControllerBindings) { } const cursor = computeCursor( textEditor.isEditing || arrowLabelEditor.isEditing || markdownEditor.isEditing, - { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, + { + isPanning: runtime.getInteractionState().panning, + spaceHeld: runtime.getInteractionState().spaceHeld, + }, { hover: handleState.hover, active: handleState.active }, - pointerState.isPointerDown, + runtime.getInteractionState().pointerDown, ); canvas.style.cursor = cursor; } @@ -201,6 +199,26 @@ export function createCanvasController(bindings: CanvasControllerBindings) { setActiveBoardId(boardId); applyLoadedDoc(doc); }); + const runtime = new EditorRuntime({ + store, + tools, + selectionTool: selectTool, + getSnapSettings: () => snapStore.get(), + onTransactionDraft: ({ name, kind, before, after }) => { + // Tool movement renders `after` as a local preview. Restore the durable + // mirror before executing the command so history and persistence receive + // the real before/after pair exactly once. + store.setState(() => before); + store.executeCommand(new SnapshotCommand(name, kind, before, after)); + syncHandleState(); + }, + onOpenRequested: () => fileBrowser.handleOpen(), + onHandleHover: setHandleHover, + onInteractionChanged: syncHandleState, + onSnappedWorldChanged: (world) => { + pointerState.snappedWorld = world; + }, + }); function setHandleHover(handle: string | null) { if (handleState.hover === handle) { @@ -215,220 +233,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { refreshCursor(); } - function applySnapping(action: Action): Action { - if (!("world" in action) || !action.world) { - return action; - } - const snap = snapStore.get(); - if (!snap.snapEnabled || !snap.gridEnabled) { - return action; - } - const gridSize = snap.gridSize; - const snappedX = Math.round(action.world.x / gridSize) * gridSize; - const snappedY = Math.round(action.world.y / gridSize) * gridSize; - return { ...action, world: { x: snappedX, y: snappedY } }; - } - - let pendingCommandStart: EditorState | null = null; - - function duplicateSelection(state: EditorState): EditorState | null { - const selectedIds = state.ui.selectionIds; - if (selectedIds.length === 0) { - return null; - } - const shapes = { ...state.doc.shapes }; - const pages = { ...state.doc.pages }; - const nextSelection: string[] = []; - - for (const id of selectedIds) { - const shape = shapes[id]; - if (!shape) continue; - const cloned = ShapeRecord.clone(shape); - const newId = createId("shape"); - const shifted = { ...cloned, id: newId, x: cloned.x + 12, y: cloned.y + 12 }; - shapes[newId] = shifted; - const originalPage = state.doc.pages[shape.pageId]; - if (!originalPage) continue; - const existingPage = pages[shape.pageId]; - const pageClone = !existingPage || existingPage === originalPage - ? { ...originalPage, shapeIds: [...originalPage.shapeIds] } - : { ...existingPage, shapeIds: [...existingPage.shapeIds] }; - pageClone.shapeIds.push(newId); - pages[shape.pageId] = pageClone; - nextSelection.push(newId); - } - - if (!nextSelection.length) { - return null; - } - - return { ...state, doc: { ...state.doc, shapes, pages }, ui: { ...state.ui, selectionIds: nextSelection } }; - } - - function reorderSelection(state: EditorState, direction: "forward" | "backward"): EditorState | null { - const pageId = state.ui.currentPageId; - if (!pageId) return null; - const page = state.doc.pages[pageId]; - if (!page) return null; - const selection = new SvelteSet(state.ui.selectionIds); - if (selection.size === 0) { - return null; - } - - const shapeIds = [...page.shapeIds]; - let changed = false; - - if (direction === "forward") { - for (let index = shapeIds.length - 2; index >= 0; index--) { - const id = shapeIds[index]; - if (!selection.has(id)) continue; - const nextId = shapeIds[index + 1]; - if (nextId && !selection.has(nextId)) { - shapeIds[index] = nextId; - shapeIds[index + 1] = id; - changed = true; - } - } - } else { - for (let index = 1; index < shapeIds.length; index++) { - const id = shapeIds[index]; - if (!selection.has(id)) continue; - const prevId = shapeIds[index - 1]; - if (prevId && !selection.has(prevId)) { - shapeIds[index] = prevId; - shapeIds[index - 1] = id; - changed = true; - } - } - } - - if (!changed) { - return null; - } - - return { ...state, doc: { ...state.doc, pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds } } } }; - } - - function ungroupSelection(state: EditorState): EditorState | null { - const selectionIds = state.ui.selectionIds; - if (selectionIds.length === 0) { - return null; - } - - const groupsToDissolve = new SvelteSet(); - const shapes = state.doc.shapes; - - for (const id of selectionIds) { - const shape = shapes[id]; - if (shape && shape.groupId) { - groupsToDissolve.add(shape.groupId); - } - } - - if (groupsToDissolve.size === 0) { - return null; - } - - const newShapes = { ...shapes }; - let changed = false; - - for (const id in newShapes) { - const shape = newShapes[id]; - if (shape.groupId && groupsToDissolve.has(shape.groupId)) { - const newShape = { ...shape }; - delete newShape.groupId; - newShapes[id] = newShape; - changed = true; - } - } - - if (!changed) return null; - - return { ...state, doc: { ...state.doc, shapes: newShapes } }; - } - - function handleKeyboardShortcuts(state: EditorState, action: Action): EditorState | null { - if (action.type !== "key-down") { - return null; - } - - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "o" || action.key === "O")) { - fileBrowser.handleOpen(); - return null; - } - - if (primaryModifier && (action.key === "n" || action.key === "N")) { - fileBrowser.handleOpen(); - return null; - } - - const selectionIds = state.ui.selectionIds; - if (selectionIds.length === 0) { - return null; - } - - if (action.key.startsWith("Arrow")) { - const step = action.modifiers.shift ? 10 : 1; - let dx = 0; - let dy = 0; - switch (action.key) { - case "ArrowLeft": - dx = -step; - break; - case "ArrowRight": - dx = step; - break; - case "ArrowUp": - dy = -step; - break; - case "ArrowDown": - dy = step; - break; - } - if (dx !== 0 || dy !== 0) { - const shapes = { ...state.doc.shapes }; - let changed = false; - for (const id of selectionIds) { - const shape = shapes[id]; - if (!shape) continue; - shapes[id] = { ...shape, x: shape.x + dx, y: shape.y + dy }; - changed = true; - } - if (!changed) { - return null; - } - return { ...state, doc: { ...state.doc, shapes } }; - } - } - - if (primaryModifier && (action.key === "d" || action.key === "D")) { - return duplicateSelection(state); - } - if (primaryModifier && action.key === "]") { - return reorderSelection(state, "forward"); - } - if (primaryModifier && action.key === "[") { - return reorderSelection(state, "backward"); - } - - if (primaryModifier && action.modifiers.shift && (action.key === "g" || action.key === "G")) { - return ungroupSelection(state); - } - - return null; - } - - function commitSnapshot(beforeState: EditorState, afterState: EditorState, action: Action) { - const kind = getCommandKind(beforeState, afterState); - const name = describeAction(action, kind); - const command = new SnapshotCommand(name, kind, EditorState.clone(beforeState), EditorState.clone(afterState)); - store.executeCommand(command); - syncHandleState(); - } - - function handleAction(action: Action) { + function handleAction(action: import("@inkfinite/core").Action) { if (textEditor.isEditing && (action.type === "pointer-down" || action.type === "pointer-up")) { textEditor.commit(); } @@ -437,84 +242,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { markdownEditor.commit(); } - if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { - const hover = selectTool.getHandleAtPoint(store.getState(), action.world); - setHandleHover(hover); - } - - if (action.type === "key-down" && action.key === " " && !action.repeat) { - panState.spaceHeld = true; - refreshCursor(); - return; - } - - if (action.type === "key-up" && action.key === " ") { - panState.spaceHeld = false; - panState.isPanning = false; - refreshCursor(); - return; - } - - if (action.type === "pointer-down" && (action.button === 1 || (action.button === 0 && panState.spaceHeld))) { - panState.isPanning = true; - panState.lastScreen = action.screen; - refreshCursor(); - return; - } - - if (action.type === "pointer-move" && panState.isPanning) { - const delta = { x: action.screen.x - panState.lastScreen.x, y: action.screen.y - panState.lastScreen.y }; - panState.lastScreen = action.screen; - store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, delta) })); - return; - } - - if (action.type === "pointer-up" && panState.isPanning) { - panState.isPanning = false; - refreshCursor(); - return; - } - - if (panState.isPanning || panState.spaceHeld) { - return; - } - - const actionWithSnap = applySnapping(action); - if ("world" in actionWithSnap) { - pointerState.snappedWorld = actionWithSnap.world; - } - - if (actionWithSnap.type === "pointer-down" && actionWithSnap.button === 0) { - pointerState.isPointerDown = true; - setHandleHover(null); - pendingCommandStart = EditorState.clone(store.getState()); - } - - const before = store.getState(); - const shortcutResult = handleKeyboardShortcuts(before, actionWithSnap); - const after = shortcutResult ?? routeAction(before, actionWithSnap, tools); - - if (!statesEqual(before, after)) { - const kind = getCommandKind(before, after); - const shouldCommitImmediately = !pendingCommandStart && kind === "doc"; - - if (shouldCommitImmediately) { - commitSnapshot(before, after, actionWithSnap); - } else { - store.setState(() => after); - syncHandleState(); - } - } - - if (actionWithSnap.type === "pointer-up" && actionWithSnap.button === 0) { - pointerState.isPointerDown = false; - refreshCursor(); - - if (pendingCommandStart && !statesEqual(pendingCommandStart, after)) { - commitSnapshot(pendingCommandStart, after, actionWithSnap); - } - pendingCommandStart = null; - } + runtime.handleAction(action); } function handleCanvasDoubleClick(event: MouseEvent) { @@ -573,7 +301,10 @@ export function createCanvasController(bindings: CanvasControllerBindings) { snapProvider: { get: () => snapStore.get() }, cursorProvider: { get: () => cursorStore.getState() }, pointerStateProvider: { - get: () => ({ isPointerDown: pointerState.isPointerDown, snappedWorld: pointerState.snappedWorld }), + get: () => ({ + isPointerDown: runtime.getInteractionState().pointerDown, + snappedWorld: pointerState.snappedWorld, + }), }, handleProvider: { get: () => handleState.getSnapshot() }, themeProvider: { get: () => themeStore.current }, @@ -749,7 +480,11 @@ export function createCanvasController(bindings: CanvasControllerBindings) { ui: { ...state.ui, selectionIds: newSelection }, }; - const command = new SnapshotCommand("Insert Stencil", "doc", state, nextState); - store.executeCommand(command); + runtime.commit( + state, + nextState, + "Insert Stencil", + Action.keyDown("InsertStencil", "InsertStencil", { ctrl: false, shift: false, alt: false, meta: false }), + ); } } diff --git a/apps/web/src/lib/input.ts b/apps/web/src/lib/input.ts index c4251cf..4289bf6 100644 --- a/apps/web/src/lib/input.ts +++ b/apps/web/src/lib/input.ts @@ -1,383 +1,2 @@ -import { - Action, - type Action as ActionType, - Camera, - Modifiers, - PointerButtons, - type Vec2, - type Viewport, -} from "@inkfinite/core"; - -/** - * Pointer state tracked by the input adapter - */ -export type PointerState = { - /** Whether any pointer button is currently down */ - isDown: boolean; - /** Last known world coordinates */ - lastWorld: { x: number; y: number } | null; - /** World coordinates where pointer was first pressed */ - startWorld: { x: number; y: number } | null; - /** Last known screen coordinates */ - lastScreen: { x: number; y: number } | null; - /** Screen coordinates where pointer was first pressed */ - startScreen: { x: number; y: number } | null; - /** Current button state */ - buttons: PointerButtons; -}; - -/** - * Input adapter configuration - */ -export type InputAdapterConfig = { - /** Canvas element to attach listeners to */ - canvas: HTMLCanvasElement; - /** Function to get current camera state */ - getCamera: () => Camera; - /** Function to get current viewport dimensions */ - getViewport: () => Viewport; - /** Callback for dispatching actions */ - onAction: (action: ActionType) => void; - /** Optional callback for raw cursor updates */ - onCursorUpdate?: (world: Vec2, screen: Vec2) => void; - /** Whether to prevent default browser behavior (default: true) */ - preventDefault?: boolean; - /** Whether to capture keyboard events on window (default: true) */ - captureKeyboard?: boolean; -}; - -/** - * Input adapter for capturing and normalizing DOM input events - * - * Features: - * - Captures pointer events (down, move, up) on canvas - * - Captures wheel events for zooming - * - Captures keyboard events (optionally on window) - * - Converts screen coordinates to world coordinates - * - Tracks pointer state - * - Normalizes modifiers (ctrl/cmd, shift, alt) - * - Dispatches normalized actions - */ -export class InputAdapter { - private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; - private pointerState: PointerState; - private boundHandlers: { - pointerDown: (e: PointerEvent) => void; - pointerMove: (e: PointerEvent) => void; - pointerUp: (e: PointerEvent) => void; - wheel: (e: WheelEvent) => void; - keyDown: (e: KeyboardEvent) => void; - keyUp: (e: KeyboardEvent) => void; - contextMenu: (e: Event) => void; - }; - private cursorUpdateFrame: number | null; - private pendingCursorWorld: Vec2 | null; - private pendingCursorScreen: Vec2 | null; - - constructor(config: InputAdapterConfig) { - this.config = { - ...config, - preventDefault: config.preventDefault ?? true, - captureKeyboard: config.captureKeyboard ?? true, - }; - - this.pointerState = { - isDown: false, - lastWorld: null, - startWorld: null, - lastScreen: null, - startScreen: null, - buttons: PointerButtons.create(), - }; - - this.boundHandlers = { - pointerDown: this.handlePointerDown.bind(this), - pointerMove: this.handlePointerMove.bind(this), - pointerUp: this.handlePointerUp.bind(this), - wheel: this.handleWheel.bind(this), - keyDown: this.handleKeyDown.bind(this), - keyUp: this.handleKeyUp.bind(this), - contextMenu: this.handleContextMenu.bind(this), - }; - this.cursorUpdateFrame = null; - this.pendingCursorWorld = null; - this.pendingCursorScreen = null; - - this.attach(); - } - - /** - * Get current pointer state - */ - getPointerState(): Readonly { - return { ...this.pointerState }; - } - - /** - * Attach event listeners - */ - private attach(): void { - const { canvas } = this.config; - - canvas.addEventListener("pointerdown", this.boundHandlers.pointerDown); - canvas.addEventListener("pointermove", this.boundHandlers.pointerMove); - canvas.addEventListener("pointerup", this.boundHandlers.pointerUp); - canvas.addEventListener("wheel", this.boundHandlers.wheel, { passive: false }); - canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu); - - if (this.config.captureKeyboard) { - window.addEventListener("keydown", this.boundHandlers.keyDown); - window.addEventListener("keyup", this.boundHandlers.keyUp); - } - } - - /** - * Detach event listeners and cleanup - */ - dispose(): void { - const { canvas } = this.config; - - canvas.removeEventListener("pointerdown", this.boundHandlers.pointerDown); - canvas.removeEventListener("pointermove", this.boundHandlers.pointerMove); - canvas.removeEventListener("pointerup", this.boundHandlers.pointerUp); - canvas.removeEventListener("wheel", this.boundHandlers.wheel); - canvas.removeEventListener("contextmenu", this.boundHandlers.contextMenu); - - if (this.config.captureKeyboard) { - window.removeEventListener("keydown", this.boundHandlers.keyDown); - window.removeEventListener("keyup", this.boundHandlers.keyUp); - } - - if ( - this.cursorUpdateFrame !== null - && typeof window !== "undefined" - && typeof window.cancelAnimationFrame === "function" - ) { - window.cancelAnimationFrame(this.cursorUpdateFrame); - this.cursorUpdateFrame = null; - } - } - - /** - * Get screen coordinates from pointer event - */ - private getScreenCoords(e: PointerEvent): { x: number; y: number } { - const rect = this.config.canvas.getBoundingClientRect(); - return { x: e.clientX - rect.left, y: e.clientY - rect.top }; - } - - /** - * Convert screen coordinates to world coordinates - */ - private screenToWorld(screen: { x: number; y: number }): { x: number; y: number } { - const camera = this.config.getCamera(); - const viewport = this.config.getViewport(); - return Camera.screenToWorld(camera, screen, viewport); - } - - /** - * Handle pointer down event - */ - private handlePointerDown(e: PointerEvent): void { - if (this.config.preventDefault) { - e.preventDefault(); - } - - const screen = this.getScreenCoords(e); - const world = this.screenToWorld(screen); - const buttons = PointerButtons.fromButtons(e.buttons); - const modifiers = Modifiers.fromEvent(e); - - this.pointerState.isDown = true; - this.pointerState.startWorld = world; - this.pointerState.startScreen = screen; - this.pointerState.lastWorld = world; - this.pointerState.lastScreen = screen; - this.pointerState.buttons = buttons; - - this.config.onAction(Action.pointerDown(screen, world, e.button, buttons, modifiers)); - } - - /** - * Handle pointer move event - */ - private handlePointerMove(e: PointerEvent): void { - if (this.config.preventDefault && this.pointerState.isDown) { - e.preventDefault(); - } - - const screen = this.getScreenCoords(e); - const world = this.screenToWorld(screen); - const buttons = PointerButtons.fromButtons(e.buttons); - const modifiers = Modifiers.fromEvent(e); - - this.pointerState.lastWorld = world; - this.pointerState.lastScreen = screen; - this.pointerState.buttons = buttons; - - this.config.onAction(Action.pointerMove(screen, world, buttons, modifiers)); - this.queueCursorUpdate(world, screen); - } - - /** - * Handle pointer up event - */ - private handlePointerUp(e: PointerEvent): void { - if (this.config.preventDefault) { - e.preventDefault(); - } - - const screen = this.getScreenCoords(e); - const world = this.screenToWorld(screen); - const buttons = PointerButtons.fromButtons(e.buttons); - const modifiers = Modifiers.fromEvent(e); - - this.pointerState.isDown = false; - this.pointerState.lastWorld = world; - this.pointerState.lastScreen = screen; - this.pointerState.buttons = buttons; - - if (PointerButtons.isEmpty(buttons)) { - this.pointerState.startWorld = null; - this.pointerState.startScreen = null; - } - - this.config.onAction(Action.pointerUp(screen, world, e.button, buttons, modifiers)); - } - - /** - * Handle wheel event - */ - private handleWheel(e: WheelEvent): void { - if (this.config.preventDefault) { - e.preventDefault(); - } - - const rect = this.config.canvas.getBoundingClientRect(); - const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - const world = this.screenToWorld(screen); - const modifiers = Modifiers.fromEvent(e); - - this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers)); - } - - private handleKeyDown(e: KeyboardEvent): void { - const target = e.target as HTMLElement; - if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { - return; - } - - const modifiers = Modifiers.fromEvent(e); - - this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat)); - - if (this.config.preventDefault && this.shouldPreventDefault(e)) { - e.preventDefault(); - } - } - - private handleKeyUp(e: KeyboardEvent): void { - const target = e.target as HTMLElement; - if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { - return; - } - - const modifiers = Modifiers.fromEvent(e); - this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); - } - - private handleContextMenu(e: Event): void { - if (this.config.preventDefault) { - e.preventDefault(); - } - } - - /** - * Throttle cursor updates using requestAnimationFrame. - */ - private queueCursorUpdate(world: Vec2, screen: Vec2): void { - if (!this.config.onCursorUpdate) { - return; - } - - this.pendingCursorWorld = { x: world.x, y: world.y }; - this.pendingCursorScreen = { x: screen.x, y: screen.y }; - - if (this.cursorUpdateFrame !== null) { - return; - } - - const schedule = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" - ? window.requestAnimationFrame.bind(window) - : null; - - if (!schedule) { - this.flushCursorUpdate(); - return; - } - - this.cursorUpdateFrame = schedule(() => { - this.cursorUpdateFrame = null; - this.flushCursorUpdate(); - }); - } - - private flushCursorUpdate(): void { - if (!this.config.onCursorUpdate || !this.pendingCursorWorld || !this.pendingCursorScreen) { - this.pendingCursorWorld = null; - this.pendingCursorScreen = null; - return; - } - - this.config.onCursorUpdate(this.pendingCursorWorld, this.pendingCursorScreen); - this.pendingCursorWorld = null; - this.pendingCursorScreen = null; - } - - /** - * Determine if default behavior should be prevented for a key event - * - * Prevents default for: - * - Space (scroll) - * - Arrow keys (scroll) - * - Backspace/Delete (navigation) - * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) - * - Tab (focus change) - */ - private shouldPreventDefault(e: KeyboardEvent): boolean { - const key = e.key; - const modifiers = Modifiers.fromEvent(e); - - if (key === " " || key.startsWith("Arrow")) { - return true; - } - - if (key === "Backspace" || key === "Delete") { - return true; - } - - if (key === "Tab") { - return true; - } - - if (Modifiers.isPrimaryModifier(modifiers) && (key === "z" || key === "Z")) { - return true; - } - - if (Modifiers.isPrimaryModifier(modifiers) && (key === "y" || key === "Y")) { - return true; - } - - return false; - } -} - -/** - * Create an input adapter - * - * @param config - Input adapter configuration - * @returns Input adapter instance - */ -export function createInputAdapter(config: InputAdapterConfig): InputAdapter { - return new InputAdapter(config); -} +/** @deprecated Import DOM input normalization from `@inkfinite/input-dom`. */ +export * from "@inkfinite/input-dom"; diff --git a/apps/web/src/lib/tests/runtime.integration.test.ts b/apps/web/src/lib/tests/runtime.integration.test.ts new file mode 100644 index 0000000..4344400 --- /dev/null +++ b/apps/web/src/lib/tests/runtime.integration.test.ts @@ -0,0 +1,99 @@ +import { + Action, + EditorState, + SnapshotCommand, + Store, + type Tool, +} from "@inkfinite/core"; +import { EditorRuntime, type RuntimeTransactionDraft, type SelectionTool } from "@inkfinite/runtime"; +import { describe, expect, it } from "vitest"; + +const modifiers = { ctrl: false, shift: false, alt: false, meta: false }; +const leftDown = { left: true, middle: false, right: false }; +const buttonsUp = { left: false, middle: false, right: false }; + +class DragTool implements Tool { + readonly id = "select" as const; + private origin: { x: number; y: number } | null = null; + + onEnter(state: EditorState) { return state; } + onExit(state: EditorState) { return state; } + + onAction(state: EditorState, action: import("@inkfinite/core").Action): EditorState { + if (action.type === "pointer-down") { + this.origin = action.world; + return state; + } + if (action.type !== "pointer-move" || !this.origin) return state; + const shape = state.doc.shapes["shape:1"]; + if (!shape) return state; + return { + ...state, + doc: { + ...state.doc, + shapes: { + ...state.doc.shapes, + [shape.id]: { + ...shape, + x: action.world.x - this.origin.x, + y: action.world.y - this.origin.y, + }, + }, + }, + }; + } +} + +describe("editor runtime Rust commit boundary", () => { + it("keeps drag movement local, applies one committed patch, redraws, and restores the original on undo", () => { + const initial = EditorState.create(); + initial.doc.pages["page:1"] = { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] }; + initial.doc.shapes["shape:1"] = { + id: "shape:1", + type: "rect", + pageId: "page:1", + x: 0, + y: 0, + rot: 0, + props: { w: 20, h: 20, fill: "#000", stroke: "#000", radius: 0 }, + }; + initial.ui.currentPageId = "page:1"; + initial.ui.selectionIds = ["shape:1"]; + const originalDocument = structuredClone(initial.doc); + const store = new Store(initial); + const tool = new DragTool(); + const drafts: RuntimeTransactionDraft[] = []; + let redraws = 0; + store.subscribe(() => redraws++); + + const runtime = new EditorRuntime({ + store, + tools: new Map([[tool.id, tool]]), + selectionTool: Object.assign(tool, { + getHandleAtPoint: () => null, + }) satisfies SelectionTool, + getSnapSettings: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 25 }), + onTransactionDraft: (draft) => { + drafts.push(draft); + // The fake Rust boundary accepts the draft and returns its materialized + // document as the patch applied by the frontend adapter. + store.setState(() => draft.before); + store.executeCommand(new SnapshotCommand(draft.name, draft.kind, draft.before, draft.after)); + }, + }); + + runtime.handleAction(Action.pointerDown({ x: 0, y: 0 }, { x: 0, y: 0 }, 0, leftDown, modifiers)); + runtime.handleAction(Action.pointerMove({ x: 10, y: 8 }, { x: 10, y: 8 }, leftDown, modifiers)); + + expect(drafts).toHaveLength(0); + expect(store.getState().doc.shapes["shape:1"]?.x).toBe(10); + + runtime.handleAction(Action.pointerUp({ x: 10, y: 8 }, { x: 10, y: 8 }, 0, buttonsUp, modifiers)); + + expect(drafts).toHaveLength(1); + expect(store.getState().doc.shapes["shape:1"]?.y).toBe(8); + expect(redraws).toBeGreaterThan(1); + expect(store.undo()).toBe(true); + expect(store.getState().doc).toEqual(originalDocument); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e23c6b3..e28e311 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,9 @@ export default defineConfig({ alias: { "@inkfinite/bindings": new URL("../../packages/bindings/src/index.ts", import.meta.url).pathname, "@inkfinite/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname, + "@inkfinite/input-dom": new URL("../../packages/input-dom/src/index.ts", import.meta.url).pathname, "@inkfinite/renderer": new URL("../../packages/renderer/src/index.ts", import.meta.url).pathname, + "@inkfinite/runtime": new URL("../../packages/runtime/src/index.ts", import.meta.url).pathname, "@inkfinite/ui/styles.css": new URL("../../packages/ui/src/lib/styles/index.css", import.meta.url).pathname, "@inkfinite/ui": new URL("../../packages/ui/src/lib/index.ts", import.meta.url).pathname, }, diff --git a/crates/inkfinite-core/src/engine/mod.rs b/crates/inkfinite-core/src/engine/mod.rs index d911dcd..df481f5 100644 --- a/crates/inkfinite-core/src/engine/mod.rs +++ b/crates/inkfinite-core/src/engine/mod.rs @@ -424,24 +424,24 @@ fn refresh_inverse_preconditions(operations: &mut [Operation], document: &Docume match operation { Operation::RenamePage { page_id, expected_version, .. } | Operation::DeletePage { page_id, expected_version } => { - *expected_version = document.pages.get(page_id).map(|record| record.version) + *expected_version = document.pages.get(page_id).map(|record| record.version); } Operation::PatchLayer { layer_id, expected_version, .. } | Operation::ReorderLayer { layer_id, expected_version, .. } | Operation::DeleteLayer { layer_id, expected_version, .. } => { - *expected_version = document.layers.get(layer_id).map(|record| record.version) + *expected_version = document.layers.get(layer_id).map(|record| record.version); } Operation::PatchShape { shape_id, expected_version, .. } | Operation::ReparentShape { shape_id, expected_version, .. } | Operation::DeleteShape { shape_id, expected_version } => { - *expected_version = document.shapes.get(shape_id).map(|record| record.version) + *expected_version = document.shapes.get(shape_id).map(|record| record.version); } Operation::DeleteBinding { binding_id, expected_version } => { *expected_version = document.bindings.get(binding_id).map(|record| record.version); } Operation::PatchAsset { asset_id, expected_version, .. } | Operation::DeleteAsset { asset_id, expected_version } => { - *expected_version = document.assets.get(asset_id).map(|record| record.version) + *expected_version = document.assets.get(asset_id).map(|record| record.version); } Operation::AlignShapes { shape_ids, expected_versions, .. } | Operation::DistributeShapes { shape_ids, expected_versions, .. } => { diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 1a6edcf..6d589e2 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -16,6 +16,7 @@ use std::fmt; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; +use thiserror::Error; use ts_rs::TS; /// Stable format identifier for an Inkfinite v2 document snapshot. @@ -24,24 +25,102 @@ pub const INKFINITE_FORMAT_ID: &str = "inkfinite.document"; /// First version of the Rust-owned Inkfinite document contract. pub const INKFINITE_FORMAT_VERSION: u32 = 2; -// FIXME: This should be a shapekind enum with a display Impl or something +/// A built-in shape registry key. +/// +/// Document records retain their string `kind` field so the registry can grow +/// without changing the serialized document contract. Use this enum whenever +/// code specifically requires one of Inkfinite's built-in kinds. +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum BuiltinShapeKind { + /// Rectangle shape. + #[serde(rename = "rect")] + #[ts(rename = "rect")] + Rectangle, + /// Ellipse shape. + Ellipse, + /// Straight line shape. + Line, + /// Arrow shape. + Arrow, + /// Plain-text shape. + Text, + /// Freehand stroke shape. + Stroke, + /// Markdown shape. + Markdown, + /// Container shape. + Container, +} + +impl BuiltinShapeKind { + /// Built-in kinds in their stable serialized order. + pub const ALL: [Self; 8] = [ + Self::Rectangle, + Self::Ellipse, + Self::Line, + Self::Arrow, + Self::Text, + Self::Stroke, + Self::Markdown, + Self::Container, + ]; + + /// Returns the stable registry key for this kind. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Rectangle => "rect", + Self::Ellipse => "ellipse", + Self::Line => "line", + Self::Arrow => "arrow", + Self::Text => "text", + Self::Stroke => "stroke", + Self::Markdown => "markdown", + Self::Container => "container", + } + } + + /// Parses a built-in registry key. + #[must_use] + pub const fn parse(value: &str) -> Option { + match value.as_bytes() { + b"rect" => Some(Self::Rectangle), + b"ellipse" => Some(Self::Ellipse), + b"line" => Some(Self::Line), + b"arrow" => Some(Self::Arrow), + b"text" => Some(Self::Text), + b"stroke" => Some(Self::Stroke), + b"markdown" => Some(Self::Markdown), + b"container" => Some(Self::Container), + _ => None, + } + } +} + +impl fmt::Display for BuiltinShapeKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} /// Built-in rectangle shape kind. -pub const RECTANGLE_KIND: &str = "rect"; +pub const RECTANGLE_KIND: &str = BuiltinShapeKind::Rectangle.as_str(); /// Built-in ellipse shape kind. -pub const ELLIPSE_KIND: &str = "ellipse"; +pub const ELLIPSE_KIND: &str = BuiltinShapeKind::Ellipse.as_str(); /// Built-in line shape kind. -pub const LINE_KIND: &str = "line"; +pub const LINE_KIND: &str = BuiltinShapeKind::Line.as_str(); /// Built-in arrow shape kind. -pub const ARROW_KIND: &str = "arrow"; +pub const ARROW_KIND: &str = BuiltinShapeKind::Arrow.as_str(); /// Built-in plain-text shape kind. -pub const TEXT_KIND: &str = "text"; +pub const TEXT_KIND: &str = BuiltinShapeKind::Text.as_str(); /// Built-in freehand stroke shape kind. -pub const STROKE_KIND: &str = "stroke"; +pub const STROKE_KIND: &str = BuiltinShapeKind::Stroke.as_str(); /// Built-in Markdown shape kind. -pub const MARKDOWN_KIND: &str = "markdown"; +pub const MARKDOWN_KIND: &str = BuiltinShapeKind::Markdown.as_str(); /// Built-in container shape kind. -pub const CONTAINER_KIND: &str = "container"; +pub const CONTAINER_KIND: &str = BuiltinShapeKind::Container.as_str(); /// Stable order of the built-in shape kinds exposed to both registries. pub const BUILTIN_SHAPE_KINDS: &[&str] = &[ @@ -72,7 +151,7 @@ pub const fn builtin_shape_kinds() -> &'static [&'static str] { /// Returns whether `kind` is one of the built-in shape registry keys. #[must_use] pub fn is_builtin_shape_kind(kind: &str) -> bool { - BUILTIN_SHAPE_KINDS.contains(&kind) + BuiltinShapeKind::parse(kind).is_some() } /// Kind-specific shape properties owned by the shape registry. @@ -110,39 +189,22 @@ pub enum BindingAnchor { } /// Error returned when a shape registry property violates a shared registry rule. -/// -/// FIXME: thiserror -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum ShapePropertyError { /// The shape kind is not part of the built-in registry. + #[error("unknown shape kind {kind}")] UnknownKind { kind: String }, /// A dimension property was not a JSON number. + #[error("shape kind {kind} property {property} must be a number")] ExpectedNumber { kind: String, property: String }, /// A dimension property was non-finite. + #[error("shape kind {kind} property {property} must be finite")] NonFiniteNumber { kind: String, property: String }, /// A dimension property was negative. + #[error("shape kind {kind} property {property} must not be negative")] NegativeNumber { kind: String, property: String }, } -impl fmt::Display for ShapePropertyError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnknownKind { kind } => write!(formatter, "unknown shape kind {kind}"), - Self::ExpectedNumber { kind, property } => { - write!(formatter, "shape kind {kind} property {property} must be a number") - } - Self::NonFiniteNumber { kind, property } => { - write!(formatter, "shape kind {kind} property {property} must be finite") - } - Self::NegativeNumber { kind, property } => { - write!(formatter, "shape kind {kind} property {property} must not be negative") - } - } - } -} - -impl std::error::Error for ShapePropertyError {} - /// Anchor used to place an item in an ordered child list without numeric indexes. #[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "position", content = "sibling_id")] @@ -363,19 +425,10 @@ fn opacity_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema } /// Error returned when an opacity is non-finite or outside `0.0..=1.0`. -/// -/// FIXME: thiserror -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Error, PartialEq)] +#[error("opacity must be finite and between 0 and 1, got {0}")] pub struct InvalidOpacity(pub f32); -impl fmt::Display for InvalidOpacity { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "opacity must be finite and between 0 and 1, got {}", self.0) - } -} - -impl std::error::Error for InvalidOpacity {} - /// Two-dimensional point or vector in document coordinates. #[derive(Clone, Copy, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] pub struct Vec2 { @@ -676,5 +729,11 @@ mod tests { validate_shape_properties("unknown", &BTreeMap::new()), Err(ShapePropertyError::UnknownKind { .. }) )); + assert_eq!(BuiltinShapeKind::parse("rect"), Some(BuiltinShapeKind::Rectangle)); + assert_eq!(BuiltinShapeKind::Rectangle.to_string(), RECTANGLE_KIND); + assert_eq!( + serde_json::to_value(BuiltinShapeKind::Rectangle).expect("built-in kind should serialize"), + Value::from(RECTANGLE_KIND) + ); } } diff --git a/packages/input-dom/src/index.ts b/packages/input-dom/src/index.ts index 37b3262..5a999c1 100644 --- a/packages/input-dom/src/index.ts +++ b/packages/input-dom/src/index.ts @@ -1,2 +1,383 @@ -/** Browser input-normalization contracts will be exported here. */ -export {}; +import { + Action, + type Action as ActionType, + Camera, + Modifiers, + PointerButtons, + type Vec2, + type Viewport, +} from "@inkfinite/runtime"; + +/** + * Pointer state tracked by the input adapter + */ +export type PointerState = { + /** Whether any pointer button is currently down */ + isDown: boolean; + /** Last known world coordinates */ + lastWorld: { x: number; y: number } | null; + /** World coordinates where pointer was first pressed */ + startWorld: { x: number; y: number } | null; + /** Last known screen coordinates */ + lastScreen: { x: number; y: number } | null; + /** Screen coordinates where pointer was first pressed */ + startScreen: { x: number; y: number } | null; + /** Current button state */ + buttons: PointerButtons; +}; + +/** + * Input adapter configuration + */ +export type InputAdapterConfig = { + /** Canvas element to attach listeners to */ + canvas: HTMLCanvasElement; + /** Function to get current camera state */ + getCamera: () => Camera; + /** Function to get current viewport dimensions */ + getViewport: () => Viewport; + /** Callback for dispatching actions */ + onAction: (action: ActionType) => void; + /** Optional callback for raw cursor updates */ + onCursorUpdate?: (world: Vec2, screen: Vec2) => void; + /** Whether to prevent default browser behavior (default: true) */ + preventDefault?: boolean; + /** Whether to capture keyboard events on window (default: true) */ + captureKeyboard?: boolean; +}; + +/** + * Input adapter for capturing and normalizing DOM input events + * + * Features: + * - Captures pointer events (down, move, up) on canvas + * - Captures wheel events for zooming + * - Captures keyboard events (optionally on window) + * - Converts screen coordinates to world coordinates + * - Tracks pointer state + * - Normalizes modifiers (ctrl/cmd, shift, alt) + * - Dispatches normalized actions + */ +export class InputAdapter { + private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; + private pointerState: PointerState; + private boundHandlers: { + pointerDown: (e: PointerEvent) => void; + pointerMove: (e: PointerEvent) => void; + pointerUp: (e: PointerEvent) => void; + wheel: (e: WheelEvent) => void; + keyDown: (e: KeyboardEvent) => void; + keyUp: (e: KeyboardEvent) => void; + contextMenu: (e: Event) => void; + }; + private cursorUpdateFrame: number | null; + private pendingCursorWorld: Vec2 | null; + private pendingCursorScreen: Vec2 | null; + + constructor(config: InputAdapterConfig) { + this.config = { + ...config, + preventDefault: config.preventDefault ?? true, + captureKeyboard: config.captureKeyboard ?? true, + }; + + this.pointerState = { + isDown: false, + lastWorld: null, + startWorld: null, + lastScreen: null, + startScreen: null, + buttons: PointerButtons.create(), + }; + + this.boundHandlers = { + pointerDown: this.handlePointerDown.bind(this), + pointerMove: this.handlePointerMove.bind(this), + pointerUp: this.handlePointerUp.bind(this), + wheel: this.handleWheel.bind(this), + keyDown: this.handleKeyDown.bind(this), + keyUp: this.handleKeyUp.bind(this), + contextMenu: this.handleContextMenu.bind(this), + }; + this.cursorUpdateFrame = null; + this.pendingCursorWorld = null; + this.pendingCursorScreen = null; + + this.attach(); + } + + /** + * Get current pointer state + */ + getPointerState(): Readonly { + return { ...this.pointerState }; + } + + /** + * Attach event listeners + */ + private attach(): void { + const { canvas } = this.config; + + canvas.addEventListener("pointerdown", this.boundHandlers.pointerDown); + canvas.addEventListener("pointermove", this.boundHandlers.pointerMove); + canvas.addEventListener("pointerup", this.boundHandlers.pointerUp); + canvas.addEventListener("wheel", this.boundHandlers.wheel, { passive: false }); + canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu); + + if (this.config.captureKeyboard) { + window.addEventListener("keydown", this.boundHandlers.keyDown); + window.addEventListener("keyup", this.boundHandlers.keyUp); + } + } + + /** + * Detach event listeners and cleanup + */ + dispose(): void { + const { canvas } = this.config; + + canvas.removeEventListener("pointerdown", this.boundHandlers.pointerDown); + canvas.removeEventListener("pointermove", this.boundHandlers.pointerMove); + canvas.removeEventListener("pointerup", this.boundHandlers.pointerUp); + canvas.removeEventListener("wheel", this.boundHandlers.wheel); + canvas.removeEventListener("contextmenu", this.boundHandlers.contextMenu); + + if (this.config.captureKeyboard) { + window.removeEventListener("keydown", this.boundHandlers.keyDown); + window.removeEventListener("keyup", this.boundHandlers.keyUp); + } + + if ( + this.cursorUpdateFrame !== null + && typeof window !== "undefined" + && typeof window.cancelAnimationFrame === "function" + ) { + window.cancelAnimationFrame(this.cursorUpdateFrame); + this.cursorUpdateFrame = null; + } + } + + /** + * Get screen coordinates from pointer event + */ + private getScreenCoords(e: PointerEvent): { x: number; y: number } { + const rect = this.config.canvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + /** + * Convert screen coordinates to world coordinates + */ + private screenToWorld(screen: { x: number; y: number }): { x: number; y: number } { + const camera = this.config.getCamera(); + const viewport = this.config.getViewport(); + return Camera.screenToWorld(camera, screen, viewport); + } + + /** + * Handle pointer down event + */ + private handlePointerDown(e: PointerEvent): void { + if (this.config.preventDefault) { + e.preventDefault(); + } + + const screen = this.getScreenCoords(e); + const world = this.screenToWorld(screen); + const buttons = PointerButtons.fromButtons(e.buttons); + const modifiers = Modifiers.fromEvent(e); + + this.pointerState.isDown = true; + this.pointerState.startWorld = world; + this.pointerState.startScreen = screen; + this.pointerState.lastWorld = world; + this.pointerState.lastScreen = screen; + this.pointerState.buttons = buttons; + + this.config.onAction(Action.pointerDown(screen, world, e.button, buttons, modifiers)); + } + + /** + * Handle pointer move event + */ + private handlePointerMove(e: PointerEvent): void { + if (this.config.preventDefault && this.pointerState.isDown) { + e.preventDefault(); + } + + const screen = this.getScreenCoords(e); + const world = this.screenToWorld(screen); + const buttons = PointerButtons.fromButtons(e.buttons); + const modifiers = Modifiers.fromEvent(e); + + this.pointerState.lastWorld = world; + this.pointerState.lastScreen = screen; + this.pointerState.buttons = buttons; + + this.config.onAction(Action.pointerMove(screen, world, buttons, modifiers)); + this.queueCursorUpdate(world, screen); + } + + /** + * Handle pointer up event + */ + private handlePointerUp(e: PointerEvent): void { + if (this.config.preventDefault) { + e.preventDefault(); + } + + const screen = this.getScreenCoords(e); + const world = this.screenToWorld(screen); + const buttons = PointerButtons.fromButtons(e.buttons); + const modifiers = Modifiers.fromEvent(e); + + this.pointerState.isDown = false; + this.pointerState.lastWorld = world; + this.pointerState.lastScreen = screen; + this.pointerState.buttons = buttons; + + if (PointerButtons.isEmpty(buttons)) { + this.pointerState.startWorld = null; + this.pointerState.startScreen = null; + } + + this.config.onAction(Action.pointerUp(screen, world, e.button, buttons, modifiers)); + } + + /** + * Handle wheel event + */ + private handleWheel(e: WheelEvent): void { + if (this.config.preventDefault) { + e.preventDefault(); + } + + const rect = this.config.canvas.getBoundingClientRect(); + const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + const world = this.screenToWorld(screen); + const modifiers = Modifiers.fromEvent(e); + + this.config.onAction(Action.wheel(screen, world, e.deltaY, modifiers)); + } + + private handleKeyDown(e: KeyboardEvent): void { + const target = e.target as HTMLElement; + if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { + return; + } + + const modifiers = Modifiers.fromEvent(e); + + this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat)); + + if (this.config.preventDefault && this.shouldPreventDefault(e)) { + e.preventDefault(); + } + } + + private handleKeyUp(e: KeyboardEvent): void { + const target = e.target as HTMLElement; + if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable) { + return; + } + + const modifiers = Modifiers.fromEvent(e); + this.config.onAction(Action.keyUp(e.key, e.code, modifiers)); + } + + private handleContextMenu(e: Event): void { + if (this.config.preventDefault) { + e.preventDefault(); + } + } + + /** + * Throttle cursor updates using requestAnimationFrame. + */ + private queueCursorUpdate(world: Vec2, screen: Vec2): void { + if (!this.config.onCursorUpdate) { + return; + } + + this.pendingCursorWorld = { x: world.x, y: world.y }; + this.pendingCursorScreen = { x: screen.x, y: screen.y }; + + if (this.cursorUpdateFrame !== null) { + return; + } + + const schedule = typeof window !== "undefined" && typeof window.requestAnimationFrame === "function" + ? window.requestAnimationFrame.bind(window) + : null; + + if (!schedule) { + this.flushCursorUpdate(); + return; + } + + this.cursorUpdateFrame = schedule(() => { + this.cursorUpdateFrame = null; + this.flushCursorUpdate(); + }); + } + + private flushCursorUpdate(): void { + if (!this.config.onCursorUpdate || !this.pendingCursorWorld || !this.pendingCursorScreen) { + this.pendingCursorWorld = null; + this.pendingCursorScreen = null; + return; + } + + this.config.onCursorUpdate(this.pendingCursorWorld, this.pendingCursorScreen); + this.pendingCursorWorld = null; + this.pendingCursorScreen = null; + } + + /** + * Determine if default behavior should be prevented for a key event + * + * Prevents default for: + * - Space (scroll) + * - Arrow keys (scroll) + * - Backspace/Delete (navigation) + * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) + * - Tab (focus change) + */ + private shouldPreventDefault(e: KeyboardEvent): boolean { + const key = e.key; + const modifiers = Modifiers.fromEvent(e); + + if (key === " " || key.startsWith("Arrow")) { + return true; + } + + if (key === "Backspace" || key === "Delete") { + return true; + } + + if (key === "Tab") { + return true; + } + + if (Modifiers.isPrimaryModifier(modifiers) && (key === "z" || key === "Z")) { + return true; + } + + if (Modifiers.isPrimaryModifier(modifiers) && (key === "y" || key === "Y")) { + return true; + } + + return false; + } +} + +/** + * Create an input adapter + * + * @param config - Input adapter configuration + * @returns Input adapter instance + */ +export function createInputAdapter(config: InputAdapterConfig): InputAdapter { + return new InputAdapter(config); +} diff --git a/packages/input-dom/tsconfig.json b/packages/input-dom/tsconfig.json index bcaab9d..307df93 100644 --- a/packages/input-dom/tsconfig.json +++ b/packages/input-dom/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", + "skipLibCheck": true, "strict": true, "target": "ES2022" }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 203285a..8a50f23 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -10,5 +10,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "dprint check src/index.ts" }, - "dependencies": { "@inkfinite/bindings": "workspace:*" } + "dependencies": { + "@inkfinite/bindings": "workspace:*", + "@inkfinite/core": "workspace:*" + } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 3a96716..c6c1332 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,2 +1,314 @@ -/** Framework-neutral editor interaction contracts will be exported here. */ -export {}; +import { + type Action, + Camera, + type CommandKind, + createId, + EditorState, + routeAction, + ShapeRecord, + type Store, + type Tool, +} from "@inkfinite/core"; + +/** Grid settings consumed by the editor runtime. */ +export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; + +/** Select-tool behavior used for resize handles. */ +export type SelectionTool = Tool & { + getHandleAtPoint(state: EditorState, world: { x: number; y: number }): string | null; + getActiveHandle?(): string | null; +}; + +/** One durable document change produced at an interaction boundary. */ +export type RuntimeTransactionDraft = { + name: string; + kind: CommandKind; + before: EditorState; + after: EditorState; + action: Action; +}; + +/** Dependencies supplied by a UI adapter. */ +export type EditorRuntimeOptions = { + store: Store; + tools: Map; + selectionTool: SelectionTool; + getSnapSettings: () => SnapSettings; + onTransactionDraft: (draft: RuntimeTransactionDraft) => void; + onOpenRequested?: () => void; + onHandleHover?: (handle: string | null) => void; + onInteractionChanged?: () => void; + onSnappedWorldChanged?: (world: { x: number; y: number }) => void; +}; + +/** + * Framework-neutral editor interaction state machine. + * + * Pointer movement updates an ephemeral state preview. A pointer-up boundary, + * text-style explicit commit, stencil insertion, or document shortcut emits + * exactly one transaction draft through `onTransactionDraft`. + */ +export class EditorRuntime { + private readonly options: EditorRuntimeOptions; + private gestureStart: EditorState | null = null; + private pointerDown = false; + private spaceHeld = false; + private panning = false; + private lastPanScreen = { x: 0, y: 0 }; + + constructor(options: EditorRuntimeOptions) { + this.options = options; + } + + /** Returns the local interaction state needed by cursor and overlay adapters. */ + getInteractionState() { + return { pointerDown: this.pointerDown, spaceHeld: this.spaceHeld, panning: this.panning } as const; + } + + /** Routes one normalized action through camera, selection, and active-tool state. */ + handleAction(action: Action): void { + const { store, selectionTool } = this.options; + + if (action.type === "pointer-move" && !this.panning && !this.spaceHeld) { + this.options.onHandleHover?.(selectionTool.getHandleAtPoint(store.getState(), action.world)); + } + + if (action.type === "key-down" && action.key === " " && !action.repeat) { + this.spaceHeld = true; + this.interactionChanged(); + return; + } + + if (action.type === "key-up" && action.key === " ") { + this.spaceHeld = false; + this.panning = false; + this.interactionChanged(); + return; + } + + if (action.type === "pointer-down" && (action.button === 1 || (action.button === 0 && this.spaceHeld))) { + this.panning = true; + this.lastPanScreen = action.screen; + this.interactionChanged(); + return; + } + + if (action.type === "pointer-move" && this.panning) { + const delta = { x: action.screen.x - this.lastPanScreen.x, y: action.screen.y - this.lastPanScreen.y }; + this.lastPanScreen = action.screen; + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, delta) })); + return; + } + + if (action.type === "pointer-up" && this.panning) { + this.panning = false; + this.interactionChanged(); + return; + } + + if (this.panning || this.spaceHeld) return; + + const routedAction = snapAction(action, this.options.getSnapSettings()); + if ("world" in routedAction) this.options.onSnappedWorldChanged?.(routedAction.world); + + if (routedAction.type === "pointer-down" && routedAction.button === 0) { + this.pointerDown = true; + this.options.onHandleHover?.(null); + this.gestureStart = EditorState.clone(store.getState()); + this.interactionChanged(); + } + + const before = store.getState(); + const shortcut = applyKeyboardShortcut(before, routedAction, this.options.onOpenRequested); + const after = shortcut ?? routeAction(before, routedAction, this.options.tools); + + if (!statesEqual(before, after)) { + const kind = commandKind(before, after); + if (!this.gestureStart && kind === "doc") { + this.emitDraft(before, after, routedAction); + } else { + store.setState(() => after); + this.interactionChanged(); + } + } + + if (routedAction.type === "pointer-up" && routedAction.button === 0) { + this.pointerDown = false; + const preview = store.getState(); + if (this.gestureStart && !statesEqual(this.gestureStart, preview)) { + this.emitDraft(this.gestureStart, preview, routedAction); + } + this.gestureStart = null; + this.interactionChanged(); + } + } + + /** Emits an explicit document draft, used by DOM editors and stencil insertion. */ + commit(before: EditorState, after: EditorState, name: string, action: Action): void { + if (statesEqual(before, after)) return; + this.options.onTransactionDraft({ + name, + kind: commandKind(before, after), + before: EditorState.clone(before), + after: EditorState.clone(after), + action, + }); + } + + private emitDraft(before: EditorState, after: EditorState, action: Action): void { + this.commit(before, after, describeAction(action, commandKind(before, after)), action); + } + + private interactionChanged(): void { + this.options.onInteractionChanged?.(); + } +} + +/** Returns whether two editor states share the same immutable branches. */ +export function statesEqual(a: EditorState, b: EditorState): boolean { + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; +} + +/** Classifies which editor state branch changed. */ +export function commandKind(before: EditorState, after: EditorState): CommandKind { + if (before.doc !== after.doc) return "doc"; + if (before.camera !== after.camera) return "camera"; + return "ui"; +} + +function snapAction(action: Action, snap: SnapSettings): Action { + if (!("world" in action) || !snap.snapEnabled || !snap.gridEnabled) return action; + const { gridSize } = snap; + return { + ...action, + world: { x: Math.round(action.world.x / gridSize) * gridSize, y: Math.round(action.world.y / gridSize) * gridSize }, + }; +} + +function applyKeyboardShortcut( + state: EditorState, + action: Action, + onOpenRequested: (() => void) | undefined, +): EditorState | null { + if (action.type !== "key-down") return null; + const primary = action.modifiers.meta || action.modifiers.ctrl; + if (primary && ["o", "O", "n", "N"].includes(action.key)) { + onOpenRequested?.(); + return null; + } + if (state.ui.selectionIds.length === 0) return null; + + if (action.key.startsWith("Arrow")) { + const step = action.modifiers.shift ? 10 : 1; + const delta = arrowDelta(action.key, step); + if (delta) { + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const id of state.ui.selectionIds) { + const shape = shapes[id]; + if (!shape) continue; + shapes[id] = { ...shape, x: shape.x + delta.x, y: shape.y + delta.y }; + changed = true; + } + if (changed) return { ...state, doc: { ...state.doc, shapes } }; + } + } + if (primary && ["d", "D"].includes(action.key)) return duplicateSelection(state); + if (primary && action.key === "]") return reorderSelection(state, "forward"); + if (primary && action.key === "[") return reorderSelection(state, "backward"); + if (primary && action.modifiers.shift && ["g", "G"].includes(action.key)) return ungroupSelection(state); + return null; +} + +function arrowDelta(key: string, step: number): { x: number; y: number } | null { + switch (key) { + case "ArrowLeft": + return { x: -step, y: 0 }; + case "ArrowRight": + return { x: step, y: 0 }; + case "ArrowUp": + return { x: 0, y: -step }; + case "ArrowDown": + return { x: 0, y: step }; + default: + return null; + } +} + +function duplicateSelection(state: EditorState): EditorState | null { + const shapes = { ...state.doc.shapes }; + const pages = { ...state.doc.pages }; + const selectionIds: string[] = []; + for (const id of state.ui.selectionIds) { + const shape = shapes[id]; + if (!shape) continue; + const copy = ShapeRecord.clone(shape); + const newId = createId("shape"); + shapes[newId] = { ...copy, id: newId, x: copy.x + 12, y: copy.y + 12 }; + const page = pages[shape.pageId]; + if (!page) continue; + pages[shape.pageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; + selectionIds.push(newId); + } + return selectionIds.length === 0 + ? null + : { ...state, doc: { ...state.doc, shapes, pages }, ui: { ...state.ui, selectionIds } }; +} + +function reorderSelection(state: EditorState, direction: "forward" | "backward"): EditorState | null { + const pageId = state.ui.currentPageId; + const page = pageId ? state.doc.pages[pageId] : undefined; + if (!page) return null; + const selected = new Set(state.ui.selectionIds); + const shapeIds = [...page.shapeIds]; + let changed = false; + const start = direction === "forward" ? shapeIds.length - 2 : 1; + const end = direction === "forward" ? -1 : shapeIds.length; + const step = direction === "forward" ? -1 : 1; + for (let index = start; index !== end; index += step) { + const id = shapeIds[index]; + const neighborIndex = index + (direction === "forward" ? 1 : -1); + const neighbor = shapeIds[neighborIndex]; + if (id && selected.has(id) && neighbor && !selected.has(neighbor)) { + shapeIds[index] = neighbor; + shapeIds[neighborIndex] = id; + changed = true; + } + } + return changed + ? { ...state, doc: { ...state.doc, pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds } } } } + : null; +} + +function ungroupSelection(state: EditorState): EditorState | null { + const groups = new Set( + state.ui.selectionIds.map((id) => state.doc.shapes[id]?.groupId).filter((id): id is string => Boolean(id)), + ); + if (groups.size === 0) return null; + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const [id, shape] of Object.entries(shapes)) { + if (shape.groupId && groups.has(shape.groupId)) { + const copy = { ...shape }; + delete copy.groupId; + shapes[id] = copy; + changed = true; + } + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : null; +} + +function describeAction(action: Action, kind: CommandKind): string { + if (action.type === "key-down") { + if (action.key.startsWith("Arrow")) return "Nudge"; + const primary = action.modifiers.meta || action.modifiers.ctrl; + if (primary && ["d", "D"].includes(action.key)) return "Duplicate"; + if (primary && action.key === "]") return "Bring Forward"; + if (primary && action.key === "[") return "Send Backward"; + } + if (action.type === "pointer-up") return "Pointer up"; + return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; +} + +export { Action, Camera, Modifiers, PointerButtons } from "@inkfinite/core"; +export type { Vec2, Viewport } from "@inkfinite/core"; diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json index bcaab9d..307df93 100644 --- a/packages/runtime/tsconfig.json +++ b/packages/runtime/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", + "skipLibCheck": true, "strict": true, "target": "ES2022" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2791d96..3349317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,15 @@ importers: '@inkfinite/core': specifier: workspace:* version: link:../../packages/core + '@inkfinite/input-dom': + specifier: workspace:* + version: link:../../packages/input-dom '@inkfinite/renderer': specifier: workspace:* version: link:../../packages/renderer + '@inkfinite/runtime': + specifier: workspace:* + version: link:../../packages/runtime '@inkfinite/ui': specifier: workspace:* version: link:../../packages/ui @@ -232,6 +238,9 @@ importers: '@inkfinite/bindings': specifier: workspace:* version: link:../bindings + '@inkfinite/core': + specifier: workspace:* + version: link:../core packages/ui: dependencies: