diff --git a/TODO.txt b/TODO.txt
index f60dfdc..f274a1a 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -245,19 +245,19 @@ Action bus (/packages/core/src/actions.ts):
Goal: tools are explicit, testable state machines (RxJS based)
Tools (/packages/core/src/tools):
-[ ] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen"
-[ ] Define Tool interface:
+[x] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen"
+[x] Define Tool interface:
- id
- onEnter(state)
- onAction(state, action) -> newState
- onExit(state)
Tool router:
-[ ] routeAction(state, action) -> newState (delegates to active tool)
+[x] routeAction(state, action) -> newState (delegates to active tool)
Tests:
-[ ] Switching tools calls onExit/onEnter in correct order
-[ ] Tool ignores actions it doesn't care about
+[x] Switching tools calls onExit/onEnter in correct order
+[x] Tool ignores actions it doesn't care about
(DoD):
- A dummy tool can consume pointer events and update state deterministically.
@@ -270,32 +270,31 @@ Tests:
Goal: select shapes and drag them.
Selection:
-[ ] PointerDown:
+[x] PointerDown:
- if hit shape: selection = [shapeId] (or add with shift)
- else selection = []
-[ ] PointerMove while dragging selected:
+[x] PointerMove while dragging selected:
- translate selected shapes by deltaWorld
-[ ] PointerUp:
+[x] PointerUp:
- end drag
Marquee select (smallest slices):
-[ ] Implement marquee start (on empty canvas pointerdown)
-[ ] Render marquee rectangle overlay
-[ ] On pointerup, select shapes whose bounds intersect marquee
+[x] Implement marquee start (on empty canvas pointerdown)
+[x] Render marquee rectangle overlay
+[x] On pointerup, select shapes whose bounds intersect marquee
UX:
-[ ] Escape clears selection
-[ ] Delete removes selected shapes
+[x] Escape clears selection
+[x] Delete removes selected shapes
Tests:
-[ ] drag moves exactly by delta
-[ ] shift-click toggles membership
-[ ] delete removes and clears selection
+[x] drag moves exactly by delta
+[x] shift-click toggles membership
+[x] delete removes and clears selection
(DoD):
- You can select and move shapes reliably.
-
==============================================================================
10. Milestone J: Create basic shapes via tools *wb-J*
==============================================================================
@@ -326,7 +325,6 @@ Tests:
(DoD):
- You can draw rect/ellipse/line/arrow/text on the canvas.
-
==============================================================================
11. Milestone K: Bindings for arrows (v0) *wb-K*
==============================================================================
@@ -354,7 +352,6 @@ Tests:
(DoD):
- Arrows remain connected to moved shapes (center-to-center is fine for v0).
-
==============================================================================
12. Milestone L: History (undo/redo) *wb-L*
==============================================================================
@@ -387,7 +384,6 @@ Tests:
(DoD):
- Undo/redo works for create/move/delete and camera changes.
-
==============================================================================
13. Milestone M: Persistence (web) *wb-M*
==============================================================================
@@ -431,7 +427,6 @@ File dialogs + FS:
(DoD):
- Desktop app opens/saves JSON files on disk and reopens them correctly.
-
==============================================================================
15. Milestone O: Export (PNG/SVG) *wb-O*
==============================================================================
diff --git a/apps/web/package.json b/apps/web/package.json
index 0cd34bd..f6cc5c1 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,7 +15,7 @@
"test": "npm run test:unit -- --run",
"format": "prettier --write ."
},
- "dependencies": { "inkfinite-core": "workspace:*" },
+ "dependencies": { "inkfinite-core": "workspace:*", "inkfinite-renderer": "workspace:*" },
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte
new file mode 100644
index 0000000..ced2322
--- /dev/null
+++ b/apps/web/src/lib/canvas/Canvas.svelte
@@ -0,0 +1,116 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/tests/canvas.svelte.test.ts b/apps/web/src/lib/tests/canvas.svelte.test.ts
new file mode 100644
index 0000000..99c1b1c
--- /dev/null
+++ b/apps/web/src/lib/tests/canvas.svelte.test.ts
@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { cleanup, render } from "vitest-browser-svelte";
+import Canvas from "../canvas/Canvas.svelte";
+
+describe("Canvas component", () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it("should render a canvas element", () => {
+ const { container } = render(Canvas);
+ const canvas = container.querySelector("canvas");
+
+ expect(canvas).toBeTruthy();
+ expect(canvas?.tagName).toBe("CANVAS");
+ });
+
+ it("should create canvas with full dimensions", () => {
+ const { container } = render(Canvas);
+ const canvas = container.querySelector("canvas") as HTMLCanvasElement;
+
+ const style = window.getComputedStyle(canvas);
+ expect(style.width).toBeTruthy();
+ expect(style.height).toBeTruthy();
+ expect(style.display).toBe("block");
+ });
+
+ it("should have touch-action: none for pointer events", () => {
+ const { container } = render(Canvas);
+ const canvas = container.querySelector("canvas") as HTMLCanvasElement;
+
+ const style = window.getComputedStyle(canvas);
+ expect(style.touchAction).toBe("none");
+ });
+
+ it("should get 2D rendering context", () => {
+ const { container } = render(Canvas);
+ const canvas = container.querySelector("canvas") as HTMLCanvasElement;
+
+ const context = canvas.getContext("2d");
+ expect(context).toBeTruthy();
+ expect(context).toBeInstanceOf(CanvasRenderingContext2D);
+ });
+
+ it("should initialize with test shapes", async () => {
+ const { component } = render(Canvas);
+
+ // Canvas component initializes store with test shapes
+ // FIXME: We can't directly access the store
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
index 9cebde5..9ce03e0 100644
--- a/apps/web/src/routes/+layout.svelte
+++ b/apps/web/src/routes/+layout.svelte
@@ -1,11 +1,13 @@
+ Inkfinite - Infinite Canvas
{@render children()}
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte
index cc88df0..6e00521 100644
--- a/apps/web/src/routes/+page.svelte
+++ b/apps/web/src/routes/+page.svelte
@@ -1,2 +1,18 @@
-
Welcome to SvelteKit
-Visit svelte.dev/docs/kit to read the documentation
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/page.svelte.spec.ts b/apps/web/src/routes/page.svelte.spec.ts
deleted file mode 100644
index 9b564bb..0000000
--- a/apps/web/src/routes/page.svelte.spec.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { page } from 'vitest/browser';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import Page from './+page.svelte';
-
-describe('/+page.svelte', () => {
- it('should render h1', async () => {
- render(Page);
-
- const heading = page.getByRole('heading', { level: 1 });
- await expect.element(heading).toBeInTheDocument();
- });
-});
diff --git a/eslint.config.js b/eslint.config.js
index e0df4d4..7812ca2 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -20,6 +20,7 @@ export default defineConfig(
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true,
}],
+ "unicorn/prefer-ternary": "off",
"unicorn/no-null": "off",
"unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }],
},
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 63a6dcb..372b299 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -4,3 +4,4 @@ export * from "./geom";
export * from "./math";
export * from "./model";
export * from "./reactivity";
+export * from "./tools";
diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts
new file mode 100644
index 0000000..feadaee
--- /dev/null
+++ b/packages/core/src/tools.test.ts
@@ -0,0 +1,385 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { Action, Modifiers, PointerButtons } from "./actions";
+import { PageRecord, ShapeRecord } from "./model";
+import { EditorState } from "./reactivity";
+import { SelectTool } from "./tools";
+
+describe("SelectTool", () => {
+ let tool: SelectTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+ let shape1: ShapeRecord;
+ let shape2: ShapeRecord;
+ let shape3: ShapeRecord;
+
+ beforeEach(() => {
+ tool = new SelectTool();
+ page = PageRecord.create("Test Page");
+ shape1 = ShapeRecord.createRect(page.id, 0, 0, { w: 100, h: 100, fill: "#ff0000", stroke: "#000000", radius: 0 });
+ shape2 = ShapeRecord.createRect(page.id, 200, 0, { w: 100, h: 100, fill: "#00ff00", stroke: "#000000", radius: 0 });
+ shape3 = ShapeRecord.createEllipse(page.id, 0, 200, { w: 80, h: 80, fill: "#0000ff", stroke: "#000000" });
+
+ page.shapeIds = [shape1.id, shape2.id, shape3.id];
+
+ initialState = {
+ ...EditorState.create(),
+ doc: {
+ pages: { [page.id]: page },
+ shapes: { [shape1.id]: shape1, [shape2.id]: shape2, [shape3.id]: shape3 },
+ bindings: {},
+ },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "select" },
+ };
+ });
+
+ describe("onEnter/onExit", () => {
+ it("should not modify state on enter", () => {
+ const result = tool.onEnter(initialState);
+ expect(result).toBe(initialState);
+ });
+
+ it("should not modify state on exit", () => {
+ const result = tool.onExit(initialState);
+ expect(result).toBe(initialState);
+ });
+ });
+
+ describe("shape selection", () => {
+ it("should select shape when clicking on it", () => {
+ const action = Action.pointerDown(
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(initialState, action);
+
+ expect(result.ui.selectionIds).toEqual([shape1.id]);
+ });
+
+ it("should replace selection when clicking on different shape", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
+
+ const action = Action.pointerDown(
+ { x: 250, y: 50 },
+ { x: 250, y: 50 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(state, action);
+
+ expect(result.ui.selectionIds).toEqual([shape2.id]);
+ });
+
+ it("should keep selection when clicking on already selected shape", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
+
+ const action = Action.pointerDown(
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(state, action);
+ expect(result.ui.selectionIds).toEqual([shape1.id]);
+ });
+
+ it("should clear selection when clicking on empty space", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
+
+ const action = Action.pointerDown(
+ { x: 500, y: 500 },
+ { x: 500, y: 500 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(state, action);
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+ });
+
+ describe("shift-click selection", () => {
+ it("should add unselected shape to selection when shift-clicking", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
+
+ const action = Action.pointerDown(
+ { x: 250, y: 50 },
+ { x: 250, y: 50 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(false, true, false, false),
+ );
+
+ const result = tool.onAction(state, action);
+
+ expect(result.ui.selectionIds).toEqual([shape1.id, shape2.id]);
+ });
+
+ it("should remove selected shape from selection when shift-clicking", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
+
+ const action = Action.pointerDown(
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(false, true, false, false),
+ );
+
+ const result = tool.onAction(state, action);
+
+ expect(result.ui.selectionIds).toEqual([shape2.id]);
+ });
+ });
+
+ describe("dragging shapes", () => {
+ it("should move selected shape by exact delta", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
+
+ let result = tool.onAction(
+ state,
+ 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: 100 },
+ { x: 150, y: 100 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const movedShape = result.doc.shapes[shape1.id];
+ expect(movedShape.x).toBe(100);
+ expect(movedShape.y).toBe(50);
+ });
+
+ it("should move multiple selected shapes together", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
+
+ let result = tool.onAction(
+ state,
+ 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: 100, y: 150 },
+ { x: 100, y: 150 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const movedShape1 = result.doc.shapes[shape1.id];
+ const movedShape2 = result.doc.shapes[shape2.id];
+
+ expect(movedShape1.x).toBe(50);
+ expect(movedShape1.y).toBe(100);
+ expect(movedShape2.x).toBe(250);
+ expect(movedShape2.y).toBe(100);
+ });
+
+ it("should reset drag state on pointer up", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
+
+ let result = tool.onAction(
+ state,
+ 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: 100, y: 100 },
+ { x: 100, y: 100 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const movedShape = result.doc.shapes[shape1.id];
+ expect(movedShape.x).toBe(50);
+ expect(movedShape.y).toBe(50);
+ });
+ });
+
+ describe("marquee selection", () => {
+ it("should select shapes within marquee bounds", () => {
+ 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: 350, y: 150 },
+ { x: 350, y: 150 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 350, y: 150 },
+ { x: 350, y: 150 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(result.ui.selectionIds).toContain(shape1.id);
+ expect(result.ui.selectionIds).toContain(shape2.id);
+ expect(result.ui.selectionIds).not.toContain(shape3.id);
+ });
+
+ it("should select all shapes when marquee covers entire canvas", () => {
+ let result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: -100, y: -100 },
+ { x: -100, y: -100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerMove(
+ { x: 500, y: 500 },
+ { x: 500, y: 500 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 500, y: 500 },
+ { x: 500, y: 500 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(result.ui.selectionIds).toContain(shape1.id);
+ expect(result.ui.selectionIds).toContain(shape2.id);
+ expect(result.ui.selectionIds).toContain(shape3.id);
+ });
+ });
+
+ describe("keyboard shortcuts", () => {
+ it("should clear selection on Escape", () => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
+ const result = tool.onAction(state, Action.keyDown("Escape", "Escape", Modifiers.create()));
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+
+ it.each([{ description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, {
+ description: "Backspace key removes selected shapes",
+ key: "Backspace",
+ code: "Backspace",
+ }])("should handle $description", ({ key, code }) => {
+ const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
+ const result = tool.onAction(state, Action.keyDown(key, code, Modifiers.create()));
+
+ expect(result.doc.shapes[shape1.id]).toBeUndefined();
+ expect(result.doc.shapes[shape2.id]).toBeUndefined();
+ expect(result.doc.shapes[shape3.id]).toBeDefined();
+
+ expect(result.ui.selectionIds).toEqual([]);
+
+ const updatedPage = result.doc.pages[page.id];
+ expect(updatedPage.shapeIds).toEqual([shape3.id]);
+ });
+
+ it("should do nothing when delete pressed with no selection", () => {
+ const result = tool.onAction(initialState, Action.keyDown("Delete", "Delete", Modifiers.create()));
+
+ expect(result.doc.shapes).toEqual(initialState.doc.shapes);
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle clicking on overlapping shapes (topmost wins)", () => {
+ const overlappingState = {
+ ...initialState,
+ doc: { ...initialState.doc, shapes: { ...initialState.doc.shapes, [shape2.id]: { ...shape2, x: 50, y: 50 } } },
+ };
+
+ const action = Action.pointerDown(
+ { x: 75, y: 75 },
+ { x: 75, y: 75 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(overlappingState, action);
+ expect(result.ui.selectionIds).toEqual([shape2.id]);
+ });
+
+ it("should ignore unrelated action types", () => {
+ const wheelAction = Action.wheel({ x: 100, y: 100 }, { x: 100, y: 100 }, -10, Modifiers.create());
+
+ const result = tool.onAction(initialState, wheelAction);
+
+ expect(result).toBe(initialState);
+ });
+ });
+});
diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts
index bbfd0a6..7794fe8 100644
--- a/packages/core/src/tools.ts
+++ b/packages/core/src/tools.ts
@@ -1,5 +1,8 @@
import type { Action } from "./actions";
+import { hitTestPoint, shapeBounds } from "./geom";
+import { Box2, Vec2 } from "./math";
import type { EditorState, ToolId } from "./reactivity";
+import { getCurrentPage } from "./reactivity";
/**
* Tool interface - defines behavior for each editor tool
@@ -95,3 +98,304 @@ export function createToolMap(toolList: Tool[]): Map {
}
return map;
}
+
+/**
+ * Internal state for the select tool
+ */
+type SelectToolState = {
+ /** Whether we're currently dragging selected shapes */
+ isDragging: boolean;
+ /** World coordinates where drag started */
+ dragStartWorld: Vec2 | null;
+ /** Initial positions of shapes being dragged (shape id -> {x, y}) */
+ initialShapePositions: Map;
+ /** Marquee selection start point in world coordinates */
+ marqueeStart: Vec2 | null;
+ /** Marquee selection end point in world coordinates */
+ marqueeEnd: Vec2 | null;
+};
+
+/**
+ * Select tool - allows selecting and moving shapes
+ *
+ * Features:
+ * - Click to select shapes (clears previous selection unless shift is held)
+ * - Shift-click to add/remove shapes from selection
+ * - Drag selected shapes to move them
+ * - Drag on empty canvas to create marquee selection
+ * - Escape key to clear selection
+ * - Delete/Backspace to remove selected shapes
+ */
+export class SelectTool implements Tool {
+ readonly id: ToolId = "select";
+ private toolState: SelectToolState;
+
+ constructor() {
+ this.toolState = {
+ isDragging: false,
+ dragStartWorld: null,
+ initialShapePositions: new Map(),
+ marqueeStart: null,
+ marqueeEnd: null,
+ };
+ }
+
+ onEnter(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onAction(state: EditorState, action: Action): EditorState {
+ switch (action.type) {
+ case "pointer-down": {
+ return this.handlePointerDown(state, action);
+ }
+ case "pointer-move": {
+ return this.handlePointerMove(state, action);
+ }
+ case "pointer-up": {
+ return this.handlePointerUp(state, action);
+ }
+ case "key-down": {
+ return this.handleKeyDown(state, action);
+ }
+ default: {
+ return state;
+ }
+ }
+ }
+
+ /**
+ * Handle pointer down - select shapes or start marquee
+ */
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const hitShapeId = hitTestPoint(state, action.world);
+
+ return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action);
+ }
+
+ /**
+ * Handle clicking on a shape
+ */
+ private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const isShiftHeld = action.modifiers.shift;
+ const isAlreadySelected = state.ui.selectionIds.includes(shapeId);
+
+ let newSelectionIds: string[];
+
+ if (isShiftHeld) {
+ newSelectionIds = isAlreadySelected
+ ? state.ui.selectionIds.filter((id) => id !== shapeId)
+ : [...state.ui.selectionIds, shapeId];
+ } else {
+ newSelectionIds = isAlreadySelected ? state.ui.selectionIds : [shapeId];
+ }
+
+ this.toolState.isDragging = true;
+ this.toolState.dragStartWorld = action.world;
+ this.toolState.initialShapePositions.clear();
+
+ for (const id of newSelectionIds) {
+ const shape = state.doc.shapes[id];
+ if (shape) {
+ this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y });
+ }
+ }
+
+ return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } };
+ }
+
+ /**
+ * Handle clicking on empty canvas - clear selection or start marquee
+ */
+ private handleEmptyClick(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const isShiftHeld = action.modifiers.shift;
+
+ if (!isShiftHeld) {
+ this.toolState.marqueeStart = action.world;
+ this.toolState.marqueeEnd = action.world;
+
+ return { ...state, ui: { ...state.ui, selectionIds: [] } };
+ }
+
+ return state;
+ }
+
+ /**
+ * Handle pointer move - drag shapes or update marquee
+ */
+ private handlePointerMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move") return state;
+
+ if (this.toolState.isDragging && this.toolState.dragStartWorld) {
+ return this.handleDragMove(state, action);
+ } else if (this.toolState.marqueeStart) {
+ return this.handleMarqueeMove(state, action);
+ }
+
+ return state;
+ }
+
+ /**
+ * Handle dragging selected shapes
+ */
+ private handleDragMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state;
+
+ const delta = Vec2.sub(action.world, this.toolState.dragStartWorld);
+
+ const newShapes = { ...state.doc.shapes };
+
+ for (const [shapeId, initialPos] of this.toolState.initialShapePositions) {
+ const shape = newShapes[shapeId];
+ if (shape) {
+ newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y };
+ }
+ }
+
+ return { ...state, doc: { ...state.doc, shapes: newShapes } };
+ }
+
+ /**
+ * Handle updating marquee selection
+ */
+ private handleMarqueeMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move") return state;
+
+ this.toolState.marqueeEnd = action.world;
+
+ return state;
+ }
+
+ /**
+ * Handle pointer up - end drag or complete marquee selection
+ */
+ private handlePointerUp(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-up") return state;
+
+ let newState = state;
+
+ if (this.toolState.marqueeStart && this.toolState.marqueeEnd) {
+ newState = this.completeMarqueeSelection(state);
+ }
+
+ this.toolState.isDragging = false;
+ this.toolState.dragStartWorld = null;
+ this.toolState.initialShapePositions.clear();
+ this.toolState.marqueeStart = null;
+ this.toolState.marqueeEnd = null;
+
+ return newState;
+ }
+
+ /**
+ * Complete marquee selection - select shapes whose bounds intersect the marquee
+ */
+ private completeMarqueeSelection(state: EditorState): EditorState {
+ if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state;
+
+ const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]);
+ const currentPage = getCurrentPage(state);
+
+ if (!currentPage) return state;
+
+ const selectedIds: string[] = [];
+
+ for (const shapeId of currentPage.shapeIds) {
+ const shape = state.doc.shapes[shapeId];
+ if (shape) {
+ const bounds = shapeBounds(shape);
+ if (Box2.intersectsBox(marqueeBox, bounds)) {
+ selectedIds.push(shapeId);
+ }
+ }
+ }
+
+ return { ...state, ui: { ...state.ui, selectionIds: selectedIds } };
+ }
+
+ /**
+ * Handle keyboard input - Escape to clear selection, Delete to remove shapes
+ */
+ private handleKeyDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "key-down") return state;
+
+ if (action.key === "Escape") {
+ return { ...state, ui: { ...state.ui, selectionIds: [] } };
+ }
+
+ if (action.key === "Delete" || action.key === "Backspace") {
+ return this.deleteSelectedShapes(state);
+ }
+
+ return state;
+ }
+
+ /**
+ * Delete all selected shapes
+ */
+ private deleteSelectedShapes(state: EditorState): EditorState {
+ const shapesToDelete = new Set(state.ui.selectionIds);
+
+ if (shapesToDelete.size === 0) return state;
+
+ const newShapes = { ...state.doc.shapes };
+ const newBindings = { ...state.doc.bindings };
+ const newPages = { ...state.doc.pages };
+
+ for (const shapeId of shapesToDelete) {
+ delete newShapes[shapeId];
+ }
+
+ for (const [bindingId, binding] of Object.entries(newBindings)) {
+ if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) {
+ delete newBindings[bindingId];
+ }
+ }
+
+ for (const [pageId, page] of Object.entries(newPages)) {
+ const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id));
+ if (filteredShapeIds.length !== page.shapeIds.length) {
+ newPages[pageId] = { ...page, shapeIds: filteredShapeIds };
+ }
+ }
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages },
+ ui: { ...state.ui, selectionIds: [] },
+ };
+ }
+
+ /**
+ * Reset internal tool state
+ */
+ private resetToolState(): void {
+ this.toolState = {
+ isDragging: false,
+ dragStartWorld: null,
+ initialShapePositions: new Map(),
+ marqueeStart: null,
+ marqueeEnd: null,
+ };
+ }
+
+ /**
+ * Get current marquee bounds (for rendering)
+ */
+ getMarqueeBounds(): Box2 | null {
+ if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null;
+ return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]);
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7952a21..475f320 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
inkfinite-core:
specifier: workspace:*
version: link:../../packages/core
+ inkfinite-renderer:
+ specifier: workspace:*
+ version: link:../../packages/renderer
devDependencies:
'@eslint/compat':
specifier: ^1.4.0