From 8f66daa5a38800c73a2997f42f71486c9e993aa9 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 22 Dec 2025 00:27:25 -0600 Subject: [PATCH] feat: arrow bindings --- TODO.txt | 204 ++++++++++---- apps/desktop/.gitkeep | 0 apps/web/src/lib/components/Toolbar.svelte | 21 +- packages/core/package.json | 8 +- packages/core/src/geom.ts | 46 ++++ packages/core/src/tools.test.ts | 206 ++++++++++++++ packages/core/src/tools.ts | 39 ++- packages/core/tests/geom.test.ts | 301 +++++++++++++++++++++ packages/renderer/package.json | 8 +- packages/renderer/src/index.ts | 66 ++++- 10 files changed, 827 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/.gitkeep diff --git a/TODO.txt b/TODO.txt index c9373ee..f33aa78 100644 --- a/TODO.txt +++ b/TODO.txt @@ -308,36 +308,32 @@ Rect tool (repeat pattern for others): (DoD): - You can draw rect/ellipse/line/arrow/text on the canvas. -============================================================================== -11. Milestone K: Bindings for arrows (v0) *wb-K* -============================================================================== +================================================================================ +11. Milestone K: Bindings for arrows (v0) *wb-K* +================================================================================ Goal: arrow endpoints can "stick" to shapes. Binding creation: -[ ] On arrow finalize: +[x] On arrow finalize: - hit test start/end points - if point hits a target shape, create binding record for that handle Binding resolution: -[ ] resolveArrowEndpoints(state, arrowId) -> { a, b } in world coords +[x] resolveArrowEndpoints(state, arrowId) -> { a, b } in world coords - if bound, compute endpoint at target shape bounds center (v0) - else use arrow props a/b Live update: -[ ] When a target shape moves, bound arrow rerenders automatically +[x] When a target shape moves, bound arrow rerenders automatically - no mutation required if resolve happens during render -Tests: -[ ] moving bound shape changes resolved endpoint -[ ] binding to missing target is ignored (or removed)-pick one and test - (DoD): - Arrows remain connected to moved shapes (center-to-center is fine for v0). -============================================================================== -12. Milestone L: History (undo/redo) *wb-L* -============================================================================== +================================================================================ +12. Milestone L: History (undo/redo) *wb-L* +================================================================================ Goal: every user-visible change is undoable. @@ -368,28 +364,66 @@ Tests: - Undo/redo works for create/move/delete and camera changes. ============================================================================== -13. Milestone M: Persistence (web) *wb-M* +13. Milestone M: Persistence (web) via Yjs + IndexedDB *wb-M* ============================================================================== -Goal: save/load documents locally. +Goal: The web app persists boards locally using Yjs, with offline support via + IndexedDB (y-indexeddb). -Serialization: -[ ] serialize(state.doc) -> JSON string -[ ] deserialize(json) -> doc (validate) +Web persistence primitives (/packages/core/src/persist/web): +[ ] Add deps (web build): + - yjs + - y-indexeddb (IndexedDB persistence provider) -Web storage: -[ ] Implement autosave to IndexedDB (or localStorage v0) -[ ] Implement "New / Save / Load" UI: - - New resets doc - - Save exports .json - - Load imports .json and validates +[ ] Define a Yjs schema for the drawing document (doc layer only): + - ydoc.getMap("pages"): Y.Map + - ydoc.getMap("shapes"): Y.Map + - ydoc.getMap("bindings"): Y.Map + - ydoc.getArray("pageOrder"): Y.Array + - per-page shape order: either + a. PageRecord.shapeIds is source-of-truth, or + b. ydoc.getArray(`page:${id}:shapeOrder`) + Pick ONE and document the choice. -Tests: -[ ] load(save(doc)) yields equivalent doc +[ ] Implement WebDocRepo (board-level API): + - listBoards(): Promise + - createBoard(name): Promise + - openBoard(boardId): Promise<{ ydoc, provider, meta }> + - renameBoard(boardId, name): Promise + - deleteBoard(boardId): Promise -(DoD): -- You can close the tab, reopen, and recover your last drawing. + Notes: + - y-indexeddb persistence is keyed by a string name; use a stable boardId. + - Keep exactly ONE IndexeddbPersistence instance per (boardId, tab) to avoid + duplicated persistence work. + +[ ] Implement a tiny "board index" for listBoards(): + - Store BoardMeta (id, name, createdAt, updatedAt) in: + - localStorage (v0), or + - a small IndexedDB store you control (v1) + - Update updatedAt whenever the Y.Doc changes. + +[ ] Bridge Yjs doc -> in-memory EditorState.doc snapshot: + - Add docSubscription: + - observe ydoc changes and rebuild/patch the in-memory doc snapshot + - Keep UI state out of Yjs for now: + - state.ui (selection/tool) remains local + - state.camera remains local + +[ ] Export/import (still useful for sharing/debugging): + - exportJSON(boardId): string (serialize to your plain JSON doc model) + - importJSON(json): createBoard + populate Y.Doc via Yjs transaction + +Tests (/packages/core/test): +[ ] opening the same boardId twice reuses metadata and does not create duplicate + persistence instances (unit test at repo layer; integration test later). +[ ] after mutating Y.Doc, closing and reopening loads the same content (offline). + (This is the expected y-indexeddb behavior.) +[ ] board index updatedAt changes on Y.Doc update. +(DoD): +- Web app: create a board, draw, refresh page, content is still there. +- Renderer redraws when the Y.Doc changes (through your bridge layer). ============================================================================== 14. Milestone N: Desktop packaging (Tauri) *wb-N* @@ -456,33 +490,109 @@ Goal: the editor stays responsive with many shapes. ============================================================================== -17. Milestone Q: Collaboration (optional, after v1) *wb-Q* +17. Milestone Q: File Browser (web: Yjs/IndexedDB peek, desktop: FS) *wb-Q* ============================================================================== -Goal: real-time multiplayer without breaking the core model. +Goal: A unified "Open board" experience: +- Web: browse locally persisted Yjs boards + basic "storage inspector" +- Desktop: browse real directories/files (native file browser semantics) -Approach A (recommended): CRDT document (Yjs) -[ ] Map doc model to Y.Map / Y.Array: - - pages (Y.Map) - - shapes (Y.Map) - - bindings (Y.Map) - - ordering (Y.Array of ids) +------------------------------------------------------------------------------ +Q1. Shared UX + contracts (core) +------------------------------------------------------------------------------ + +Contracts (/packages/core/src/persist): +[ ] Define BoardMeta: + - id, name, createdAt, updatedAt + - optional: storageKind = "web-indexeddb" | "desktop-fs" + +[ ] Define DocRepo interface (implemented by web + desktop): + - listBoards(): Promise + - createBoard(name): Promise + - openBoard(id): Promise (or returns handle) + - renameBoard(id, name): Promise + - deleteBoard(id): Promise + +[ ] Define a FileBrowserViewModel (pure data for UI): + - query string + - filtered list of BoardMeta + - selected board id + - actions: open/create/rename/delete + +(DoD): +- Svelte UI can render the browser from a single ViewModel shape. + +------------------------------------------------------------------------------ +Q2. Web file browser: "Boards" + IndexedDB/Yjs inspector +------------------------------------------------------------------------------ + +UI (/apps/web/src/lib/filebrowser): +[ ] Implement Boards panel: + - listBoards() -> render list + - search filter (client-side) + - create/rename/delete + - open on click + +[ ] Implement a "Storage inspector" drawer for the selected board: + - show boardId + y-indexeddb persistence name (same string) + - show "synced/loaded" status from the provider lifecycle (v0: boolean) + - show a rough persisted size indicator: + - compute encoded state size (bytes) from Yjs state vector/update + (v0: best-effort number; label it "approx") + - add buttons: + - Export JSON + - Export "Yjs update" (debug artifact) + +Svelte runes integration: +[ ] Subscribe to repo changes using $effect and cleanup unsubscribe. + +(DoD): +- Web: you can browse boards, open one, and view basic persistence/debug info + that confirms it’s backed by y-indexeddb. + +------------------------------------------------------------------------------ +Q3. Desktop file browser: real directory + files (Tauri) +------------------------------------------------------------------------------ + +Desktop behavior: +[ ] Add "Workspace folder" concept: + - choose a directory using Tauri dialog open({ directory: true }) + - remember last workspace path (v0: local settings file) + +[ ] Implement directory listing: + - read directories + files via Tauri FS plugin APIs + - display as: + - v0: flat list of "*.wanderboard.json" files, or + - v1: tree view (folders expandable) + +[ ] Implement file actions: + - New board: creates new file in workspace + - Rename: renames file + - Delete: deletes file + - Open: loads file into editor + +[ ] Scope/security note handling: + - paths returned by dialog are scoped for FS access; persist the chosen + workspace path yourself for the next launch. + +(DoD): +- Desktop: pick a folder, browse files, open/save boards from disk. -[ ] Implement doc<->store bridge: - - local store updates write to Yjs - - Yjs remote updates update store - - avoid feedback loops (transaction origin tagging) +------------------------------------------------------------------------------ +Q4. "Parity" behaviors (web + desktop) +------------------------------------------------------------------------------ -[ ] Add presence: - - user cursors (id, name, color, world pos) - - selections (ids[]) +[ ] Consistent command surface: + - Ctrl/Cmd+O opens the file browser modal + - Ctrl/Cmd+N creates a board + - rename/delete via context menu -[ ] Add provider: - - websocket provider (self-host) - - awareness / presence channel +[ ] Consistent metadata: + - show updatedAt and name in both modes (DoD): -- Two clients can draw, select, and see each other’s cursors live. +- Switching between web and desktop feels like the same app, but the web mode + clearly indicates "local Yjs/IndexedDB" storage, while desktop is real files. ============================================================================== diff --git a/apps/desktop/.gitkeep b/apps/desktop/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte index 70bc024..5aac215 100644 --- a/apps/web/src/lib/components/Toolbar.svelte +++ b/apps/web/src/lib/components/Toolbar.svelte @@ -39,8 +39,8 @@ display: flex; gap: 8px; padding: 12px; - background: #f5f5f5; - border-bottom: 1px solid #e0e0e0; + background: var(--surface-elevated); + border-bottom: 1px solid var(--border); } .tool-button { @@ -49,28 +49,29 @@ align-items: center; gap: 4px; padding: 8px 12px; - border: 1px solid #d0d0d0; + border: 1px solid var(--border); border-radius: 4px; - background: white; + background: var(--surface); + color: var(--text); cursor: pointer; transition: all 0.2s; min-width: 60px; } .tool-button:hover { - background: #f0f0f0; - border-color: #b0b0b0; + background: var(--surface-elevated); + border-color: var(--text-muted); } .tool-button:focus { - outline: 2px solid #4a90e2; + outline: 2px solid var(--accent); outline-offset: 2px; } .tool-button.active { - background: #4a90e2; - color: white; - border-color: #357abd; + background: var(--accent); + color: var(--surface); + border-color: var(--accent-hover); } .tool-icon { diff --git a/packages/core/package.json b/packages/core/package.json index 00d1f8c..e9c3f5c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,11 +3,11 @@ "type": "module", "version": "0.0.0", "description": "A starter for creating a TypeScript package.", - "author": "Author Name ", + "author": "Owais J. ", "license": "MIT", - "homepage": "https://github.com/author/library#readme", - "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, - "bugs": { "url": "https://github.com/author/library/issues" }, + "homepage": "https://github.com/stormlightlabs/inkfinite#readme", + "repository": { "type": "git", "url": "git+https://github.com/stormlightlabs/inkfinite.git" }, + "bugs": { "url": "https://github.com/stormlightlabs/inkfinite/issues" }, "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 78d0279..a7e3bd7 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -298,3 +298,49 @@ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5 return null; } + +/** + * Get the center point of a shape's bounding box in world coordinates + * + * @param shape - The shape to get center for + * @returns Center point in world coordinates + */ +export function shapeCenter(shape: ShapeRecord): Vec2 { + const bounds = shapeBounds(shape); + return { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; +} + +/** + * Resolve arrow endpoints considering bindings + * + * If an arrow endpoint is bound to a target shape, returns the bound position + * (center of target shape for v0). Otherwise returns the arrow's stored endpoint. + * + * @param state - Editor state + * @param arrowId - ID of the arrow shape + * @returns Resolved endpoints {a, b} in world coordinates, or null if arrow not found + */ +export function resolveArrowEndpoints(state: EditorState, arrowId: string): { a: Vec2; b: Vec2 } | null { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== "arrow") return null; + + let a: Vec2 = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; + let b: Vec2 = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; + + for (const binding of Object.values(state.doc.bindings)) { + if (binding.fromShapeId !== arrowId) continue; + + const targetShape = state.doc.shapes[binding.toShapeId]; + if (!targetShape) continue; + + const targetCenter = shapeCenter(targetShape); + + if (binding.handle === "start") { + a = targetCenter; + } else if (binding.handle === "end") { + b = targetCenter; + } + } + + return { a, b }; +} diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts index cf30e5d..beac8e0 100644 --- a/packages/core/src/tools.test.ts +++ b/packages/core/src/tools.test.ts @@ -1064,3 +1064,209 @@ describe("TextTool", () => { expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore); }); }); + +describe("Arrow Bindings", () => { + let tool: ArrowTool; + let initialState: EditorState; + let page: PageRecord; + let targetShape: ShapeRecord; + + beforeEach(() => { + tool = new ArrowTool(); + page = PageRecord.create("Test Page"); + + targetShape = ShapeRecord.createRect(page.id, 100, 100, { + w: 100, + h: 100, + fill: "#ff0000", + stroke: "#000000", + radius: 0, + }); + + page.shapeIds = [targetShape.id]; + + initialState = { + ...EditorState.create(), + doc: { pages: { [page.id]: page }, shapes: { [targetShape.id]: targetShape }, bindings: {} }, + ui: { currentPageId: page.id, selectionIds: [], toolId: "arrow" }, + }; + }); + + it("should create binding when arrow start hits a shape", () => { + let result = tool.onAction( + initialState, + Action.pointerDown( + { x: 150, y: 150 }, + { x: 150, y: 150 }, + 0, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerMove( + { x: 300, y: 300 }, + { x: 300, y: 300 }, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerUp( + { x: 300, y: 300 }, + { x: 300, y: 300 }, + 0, + PointerButtons.create(false, false, false), + Modifiers.create(), + ), + ); + + const bindings = Object.values(result.doc.bindings); + expect(bindings.length).toBe(1); + expect(bindings[0].toShapeId).toBe(targetShape.id); + expect(bindings[0].handle).toBe("start"); + }); + + it("should create binding when arrow end hits a shape", () => { + let result = tool.onAction( + initialState, + Action.pointerDown( + { x: 50, y: 50 }, + { x: 50, y: 50 }, + 0, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerMove( + { x: 150, y: 150 }, + { x: 150, y: 150 }, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerUp( + { x: 150, y: 150 }, + { x: 150, y: 150 }, + 0, + PointerButtons.create(false, false, false), + Modifiers.create(), + ), + ); + + const bindings = Object.values(result.doc.bindings); + expect(bindings.length).toBe(1); + expect(bindings[0].toShapeId).toBe(targetShape.id); + expect(bindings[0].handle).toBe("end"); + }); + + it("should create bindings for both ends when both hit shapes", () => { + const targetShape2 = ShapeRecord.createRect(page.id, 300, 300, { + w: 100, + h: 100, + fill: "#00ff00", + stroke: "#000000", + radius: 0, + }); + + const stateWithTwoTargets = { + ...initialState, + doc: { + ...initialState.doc, + shapes: { ...initialState.doc.shapes, [targetShape2.id]: targetShape2 }, + pages: { [page.id]: { ...page, shapeIds: [targetShape.id, targetShape2.id] } }, + }, + }; + + let result = tool.onAction( + stateWithTwoTargets, + Action.pointerDown( + { x: 150, y: 150 }, + { x: 150, y: 150 }, + 0, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerMove( + { x: 350, y: 350 }, + { x: 350, y: 350 }, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerUp( + { x: 350, y: 350 }, + { x: 350, y: 350 }, + 0, + PointerButtons.create(false, false, false), + Modifiers.create(), + ), + ); + + const bindings = Object.values(result.doc.bindings); + expect(bindings.length).toBe(2); + + const startBinding = bindings.find((b) => b.handle === "start"); + const endBinding = bindings.find((b) => b.handle === "end"); + + expect(startBinding).toBeDefined(); + expect(startBinding?.toShapeId).toBe(targetShape.id); + + expect(endBinding).toBeDefined(); + expect(endBinding?.toShapeId).toBe(targetShape2.id); + }); + + it("should not create binding when arrow does not hit any shape", () => { + let result = tool.onAction( + initialState, + Action.pointerDown( + { x: 50, y: 50 }, + { x: 50, y: 50 }, + 0, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerMove( + { x: 80, y: 80 }, + { x: 80, y: 80 }, + PointerButtons.create(true, false, false), + Modifiers.create(), + ), + ); + + result = tool.onAction( + result, + Action.pointerUp( + { x: 80, y: 80 }, + { x: 80, y: 80 }, + 0, + PointerButtons.create(false, false, false), + Modifiers.create(), + ), + ); + + const bindings = Object.values(result.doc.bindings); + expect(bindings.length).toBe(0); + }); +}); diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index be070d3..4e76fbf 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -1,7 +1,7 @@ import type { Action } from "./actions"; import { hitTestPoint, shapeBounds } from "./geom"; import { Box2, Vec2 } from "./math"; -import { createId, ShapeRecord } from "./model"; +import { BindingRecord, createId, ShapeRecord } from "./model"; import type { EditorState, ToolId } from "./reactivity"; import { getCurrentPage } from "./reactivity"; @@ -1003,12 +1003,49 @@ export class ArrowTool implements Tool { const arrowLength = Vec2.len(shape.props.b); if (arrowLength < MIN_SHAPE_SIZE) { newState = this.cancelShapeCreation(state); + } else { + newState = this.createBindingsForArrow(state, this.toolState.creatingShapeId); } this.resetToolState(); return newState; } + /** + * Create bindings for arrow endpoints that hit other shapes + */ + private createBindingsForArrow(state: EditorState, arrowId: string): EditorState { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== "arrow") return state; + + const startWorld = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; + const endWorld = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; + + const newBindings = { ...state.doc.bindings }; + + const stateWithoutArrow = { + ...state, + doc: { + ...state.doc, + shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), + }, + }; + + const startHitId = hitTestPoint(stateWithoutArrow, startWorld); + if (startHitId) { + const binding = BindingRecord.create(arrowId, startHitId, "start"); + newBindings[binding.id] = binding; + } + + const endHitId = hitTestPoint(stateWithoutArrow, endWorld); + if (endHitId) { + const binding = BindingRecord.create(arrowId, endHitId, "end"); + newBindings[binding.id] = binding; + } + + return { ...state, doc: { ...state.doc, bindings: newBindings } }; + } + private handleKeyDown(state: EditorState, action: Action): EditorState { if (action.type !== "key-down") return state; diff --git a/packages/core/tests/geom.test.ts b/packages/core/tests/geom.test.ts index 6dc17a2..e3b0e06 100644 --- a/packages/core/tests/geom.test.ts +++ b/packages/core/tests/geom.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; import { + BindingRecord, hitTestPoint, PageRecord, pointInEllipse, pointInRect, pointNearSegment, + resolveArrowEndpoints, shapeBounds, + shapeCenter, ShapeRecord, Store, } from "../src"; @@ -693,4 +696,302 @@ describe("Geometry", () => { expect(hitTestPoint(state, { x: 50, y: 252 }, 5)).toBe("shape:3"); }); }); + + describe("shapeCenter", () => { + it("should return center of rect shape", () => { + const rect = ShapeRecord.createRect("page:1", 100, 100, { w: 100, h: 50, fill: "", stroke: "", radius: 0 }); + + const center = shapeCenter(rect); + + expect(center).toEqual({ x: 150, y: 125 }); + }); + + it("should return center of ellipse shape", () => { + const ellipse = ShapeRecord.createEllipse("page:1", 100, 100, { w: 80, h: 60, fill: "", stroke: "" }); + + const center = shapeCenter(ellipse); + + expect(center).toEqual({ x: 140, y: 130 }); + }); + + it("should return center of line shape", () => { + const line = ShapeRecord.createLine("page:1", 100, 100, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 50 }, + stroke: "", + width: 2, + }); + + const center = shapeCenter(line); + + expect(center).toEqual({ x: 150, y: 125 }); + }); + + it("should return center of arrow shape", () => { + const arrow = ShapeRecord.createArrow("page:1", 50, 50, { + a: { x: -50, y: -50 }, + b: { x: 50, y: 50 }, + stroke: "", + width: 2, + }); + + const center = shapeCenter(arrow); + + expect(center).toEqual({ x: 50, y: 50 }); + }); + + it("should handle rotated shapes", () => { + const rect = ShapeRecord.createRect("page:1", 0, 0, { w: 100, h: 50, fill: "", stroke: "", radius: 0 }); + rect.rot = Math.PI / 4; + + const center = shapeCenter(rect); + expect(center.x).toBeDefined(); + expect(center.y).toBeDefined(); + }); + }); + + describe("resolveArrowEndpoints", () => { + it("should return arrow's own endpoints when no bindings exist", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const arrow = ShapeRecord.createArrow("page:1", 100, 100, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 50 }, + stroke: "", + width: 2, + }, "arrow:1"); + + store.setState((state) => ({ + ...state, + doc: { pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, shapes: { [arrow.id]: arrow }, bindings: {} }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + + expect(resolved).toEqual({ a: { x: 100, y: 100 }, b: { x: 200, y: 150 } }); + }); + + it("should resolve start endpoint when bound to a shape", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const targetRect = ShapeRecord.createRect( + "page:1", + 100, + 100, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + const arrow = ShapeRecord.createArrow("page:1", 300, 300, { + a: { x: -150, y: -150 }, + b: { x: 100, y: 100 }, + stroke: "", + width: 2, + }, "arrow:1"); + + const binding = BindingRecord.create(arrow.id, targetRect.id, "start", { kind: "center" }, "binding:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [targetRect.id, arrow.id] } }, + shapes: { [targetRect.id]: targetRect, [arrow.id]: arrow }, + bindings: { [binding.id]: binding }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + + expect(resolved?.a).toEqual({ x: 150, y: 150 }); + expect(resolved?.b).toEqual({ x: 400, y: 400 }); + }); + + it("should resolve end endpoint when bound to a shape", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const targetRect = ShapeRecord.createRect( + "page:1", + 200, + 200, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + const arrow = ShapeRecord.createArrow("page:1", 50, 50, { + a: { x: 0, y: 0 }, + b: { x: 200, y: 200 }, + stroke: "", + width: 2, + }, "arrow:1"); + + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [targetRect.id, arrow.id] } }, + shapes: { [targetRect.id]: targetRect, [arrow.id]: arrow }, + bindings: { [binding.id]: binding }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + expect(resolved?.a).toEqual({ x: 50, y: 50 }); + expect(resolved?.b).toEqual({ x: 250, y: 250 }); + }); + + it("should resolve both endpoints when both are bound", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const rect1 = ShapeRecord.createRect( + "page:1", + 100, + 100, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + const rect2 = ShapeRecord.createRect( + "page:1", + 300, + 300, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:2", + ); + const arrow = ShapeRecord.createArrow("page:1", 0, 0, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 100 }, + stroke: "", + width: 2, + }, "arrow:1"); + + const binding1 = BindingRecord.create(arrow.id, rect1.id, "start", { kind: "center" }, "binding:1"); + const binding2 = BindingRecord.create(arrow.id, rect2.id, "end", { kind: "center" }, "binding:2"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [rect1.id, rect2.id, arrow.id] } }, + shapes: { [rect1.id]: rect1, [rect2.id]: rect2, [arrow.id]: arrow }, + bindings: { [binding1.id]: binding1, [binding2.id]: binding2 }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + + expect(resolved?.a).toEqual({ x: 150, y: 150 }); + expect(resolved?.b).toEqual({ x: 350, y: 350 }); + }); + + it("should ignore bindings to missing shapes", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const arrow = ShapeRecord.createArrow("page:1", 100, 100, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 50 }, + stroke: "", + width: 2, + }, "arrow:1"); + + const binding = BindingRecord.create(arrow.id, "nonexistent:1", "start", { kind: "center" }, "binding:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [arrow.id] } }, + shapes: { [arrow.id]: arrow }, + bindings: { [binding.id]: binding }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, arrow.id); + + expect(resolved?.a).toEqual({ x: 100, y: 100 }); + expect(resolved?.b).toEqual({ x: 200, y: 150 }); + }); + + it("should return null for non-existent arrow", () => { + const store = new Store(); + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, "nonexistent:1"); + + expect(resolved).toBeNull(); + }); + + it("should return null for non-arrow shapes", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const rect = ShapeRecord.createRect( + "page:1", + 100, + 100, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + + store.setState((state) => ({ + ...state, + doc: { pages: { [page.id]: { ...page, shapeIds: [rect.id] } }, shapes: { [rect.id]: rect }, bindings: {} }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + const state = store.getState(); + const resolved = resolveArrowEndpoints(state, rect.id); + + expect(resolved).toBeNull(); + }); + + it("should handle bound arrows when target shape moves", () => { + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + const targetRect = ShapeRecord.createRect( + "page:1", + 100, + 100, + { w: 100, h: 100, fill: "", stroke: "", radius: 0 }, + "rect:1", + ); + const arrow = ShapeRecord.createArrow("page:1", 50, 50, { + a: { x: 0, y: 0 }, + b: { x: 100, y: 100 }, + stroke: "", + width: 2, + }, "arrow:1"); + + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); + + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [targetRect.id, arrow.id] } }, + shapes: { [targetRect.id]: targetRect, [arrow.id]: arrow }, + bindings: { [binding.id]: binding }, + }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + const resolved1 = resolveArrowEndpoints(state, arrow.id); + + expect(resolved1?.b).toEqual({ x: 150, y: 150 }); + + const movedRect = { ...targetRect, x: 300, y: 300 }; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, shapes: { ...state.doc.shapes, [targetRect.id]: movedRect } }, + })); + + state = store.getState(); + const resolved2 = resolveArrowEndpoints(state, arrow.id); + + expect(resolved2?.b).toEqual({ x: 350, y: 350 }); + }); + }); }); diff --git a/packages/renderer/package.json b/packages/renderer/package.json index d4f663d..db29bc1 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -3,11 +3,11 @@ "type": "module", "version": "0.0.0", "description": "A starter for creating a TypeScript package.", - "author": "Author Name ", + "author": "Owais J. ", "license": "MIT", - "homepage": "https://github.com/author/library#readme", - "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, - "bugs": { "url": "https://github.com/author/library/issues" }, + "homepage": "https://github.com/stormlightlabs/inkfinite#readme", + "repository": { "type": "git", "url": "git+https://github.com/stormlightlabs/inkfinite.git" }, + "bugs": { "url": "https://github.com/stormlightlabs/inkfinite/issues" }, "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index a748246..81776d3 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -10,7 +10,7 @@ import type { TextShape, Viewport, } from "inkfinite-core"; -import { getShapesOnCurrentPage } from "inkfinite-core"; +import { getShapesOnCurrentPage, resolveArrowEndpoints } from "inkfinite-core"; export interface Renderer { /** @@ -138,9 +138,11 @@ function drawScene(context: CanvasRenderingContext2D, state: EditorState, viewpo applyCameraTransform(context, state.camera, viewport); + drawGrid(context, state.camera, viewport); + const shapes = getShapesOnCurrentPage(state); for (const shape of shapes) { - drawShape(context, shape); + drawShape(context, state, shape); } drawSelection(context, state, shapes); @@ -162,10 +164,56 @@ function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, context.translate(-camera.x, -camera.y); } +/** + * Draw grid/graph paper background + * + * Draws a subtle grid that helps with spatial awareness and alignment. + * The grid adapts to zoom level to maintain visual clarity. + */ +function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { + const gridSize = 50; + const minorGridColor = "rgba(128, 128, 128, 0.1)"; + const majorGridColor = "rgba(128, 128, 128, 0.2)"; + + const topLeft = { + x: camera.x - viewport.width / (2 * camera.zoom), + y: camera.y - viewport.height / (2 * camera.zoom), + }; + const bottomRight = { + x: camera.x + viewport.width / (2 * camera.zoom), + y: camera.y + viewport.height / (2 * camera.zoom), + }; + + const startX = Math.floor(topLeft.x / gridSize) * gridSize; + const endX = Math.ceil(bottomRight.x / gridSize) * gridSize; + const startY = Math.floor(topLeft.y / gridSize) * gridSize; + const endY = Math.ceil(bottomRight.y / gridSize) * gridSize; + + context.lineWidth = 1 / camera.zoom; + + for (let x = startX; x <= endX; x += gridSize) { + const isMajor = x % (gridSize * 5) === 0; + context.strokeStyle = isMajor ? majorGridColor : minorGridColor; + context.beginPath(); + context.moveTo(x, startY); + context.lineTo(x, endY); + context.stroke(); + } + + for (let y = startY; y <= endY; y += gridSize) { + const isMajor = y % (gridSize * 5) === 0; + context.strokeStyle = isMajor ? majorGridColor : minorGridColor; + context.beginPath(); + context.moveTo(startX, y); + context.lineTo(endX, y); + context.stroke(); + } +} + /** * Draw a single shape */ -function drawShape(context: CanvasRenderingContext2D, shape: ShapeRecord) { +function drawShape(context: CanvasRenderingContext2D, state: EditorState, shape: ShapeRecord) { context.save(); context.translate(shape.x, shape.y); @@ -187,7 +235,7 @@ function drawShape(context: CanvasRenderingContext2D, shape: ShapeRecord) { break; } case "arrow": { - drawArrow(context, shape); + drawArrow(context, state, shape); break; } case "text": { @@ -273,8 +321,14 @@ function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { /** * Draw an arrow shape */ -function drawArrow(context: CanvasRenderingContext2D, shape: ArrowShape) { - const { a, b, stroke, width } = shape.props; +function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { + const { stroke, width } = shape.props; + + const resolved = resolveArrowEndpoints(state, shape.id); + if (!resolved) return; + + const a = { x: resolved.a.x - shape.x, y: resolved.a.y - shape.y }; + const b = { x: resolved.b.x - shape.x, y: resolved.b.y - shape.y }; context.beginPath(); context.moveTo(a.x, a.y); -- 2.51.2