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