diff --git a/.gitignore b/.gitignore
index 77db5ae..aa6a8f0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ dist
*.log
.DS_Store
coverage
+__screenshots__
diff --git a/TODO.txt b/TODO.txt
index f274a1a..c9373ee 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -140,7 +140,7 @@ Selectors (pure functions, no RxJS):
- getShapesOnCurrentPage(state)
- getSelectedShapes(state)
-Invariants (pick “repair” and test it):
+Invariants (pick "repair" and test it):
[x] Implement enforceInvariants(state): EditorState (repair strategy):
- selectionIds := selectionIds filtered to existing shapes
- currentPageId must exist:
@@ -204,10 +204,6 @@ Layering:
[x] Define draw order = page.shapeIds order
[x] hitTest uses reverse order for topmost selection
-Tests:
-[x] hitTestPoint returns expected shapeId for overlapping shapes
-[x] tolerance works for line/arrow selection
-
(DoD):
- You can hover shapes and log the hit shape id (no selection yet).
@@ -255,10 +251,6 @@ Tools (/packages/core/src/tools):
Tool router:
[x] routeAction(state, action) -> newState (delegates to active tool)
-Tests:
-[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.
@@ -287,11 +279,6 @@ UX:
[x] Escape clears selection
[x] Delete removes selected shapes
-Tests:
-[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.
@@ -302,26 +289,22 @@ Tests:
Goal: place shapes with dedicated tools.
Rect tool (repeat pattern for others):
-[ ] PointerDown on canvas:
+[x] PointerDown on canvas:
- create a draft rect shape with w/h=0 at startWorld
- selection = [newId]
-[ ] PointerMove:
+[x] PointerMove:
- update w/h based on currentWorld - startWorld
-[ ] PointerUp:
+[x] PointerUp:
- if too small, delete it (click-cancel behavior)
- else finalize
-[ ] Implement ellipse tool (same mechanics)
-[ ] Implement line tool (a=startWorld, b=currentWorld)
-[ ] Implement arrow tool (same as line but type="arrow")
-[ ] Implement text tool:
+[x] Implement ellipse tool (same mechanics)
+[x] Implement line tool (a=startWorld, b=currentWorld)
+[x] Implement arrow tool (same as line but type="arrow")
+[x] Implement text tool:
- click to create text shape
- open in-place editor overlay (contenteditable) in Svelte
-Tests:
-[ ] shape creation creates one record with correct props
-[ ] click-cancel removes zero-area shapes
-
(DoD):
- You can draw rect/ellipse/line/arrow/text on the canvas.
@@ -329,7 +312,7 @@ Tests:
11. Milestone K: Bindings for arrows (v0) *wb-K*
==============================================================================
-Goal: arrow endpoints can “stick” to shapes.
+Goal: arrow endpoints can "stick" to shapes.
Binding creation:
[ ] On arrow finalize:
@@ -506,7 +489,7 @@ Approach A (recommended): CRDT document (Yjs)
18. Milestone R: Quality polish (what makes it feel "real") *wb-R*
==============================================================================
-Goal: the UX crosses the “this is legit” threshold.
+Goal: the UX crosses the "this is legit" threshold.
[ ] Snapping:
- snap move to grid
diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc
index b513262..9faa8cc 100644
--- a/apps/web/.prettierrc
+++ b/apps/web/.prettierrc
@@ -2,7 +2,9 @@
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
- "printWidth": 100,
+ "printWidth": 120,
+ "objectWrap": "collapse",
+ "bracketSameLine": true,
"plugins": [
"prettier-plugin-svelte"
],
diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg
index cc5dc66..a59e894 100644
--- a/apps/web/src/lib/assets/favicon.svg
+++ b/apps/web/src/lib/assets/favicon.svg
@@ -1 +1,4 @@
-
\ No newline at end of file
+
diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte
index ced2322..1729a02 100644
--- a/apps/web/src/lib/canvas/Canvas.svelte
+++ b/apps/web/src/lib/canvas/Canvas.svelte
@@ -1,116 +1,128 @@
-
+
+
+
+
diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte
new file mode 100644
index 0000000..70bc024
--- /dev/null
+++ b/apps/web/src/lib/components/Toolbar.svelte
@@ -0,0 +1,86 @@
+
+
+
+ {#each tools as tool}
+
+ {/each}
+
+
+
diff --git a/apps/web/src/lib/tests/canvas.svelte.test.ts b/apps/web/src/lib/tests/canvas.svelte.test.ts
index 99c1b1c..5f3cef2 100644
--- a/apps/web/src/lib/tests/canvas.svelte.test.ts
+++ b/apps/web/src/lib/tests/canvas.svelte.test.ts
@@ -49,4 +49,59 @@ describe("Canvas component", () => {
// FIXME: We can't directly access the store
expect(component).toBeTruthy();
});
+
+ it("should render the Toolbar component", () => {
+ const { container } = render(Canvas);
+ const toolbar = container.querySelector(".toolbar");
+
+ expect(toolbar).toBeTruthy();
+ expect(toolbar?.getAttribute("role")).toBe("toolbar");
+ });
+
+ it("should render editor wrapper with correct layout", () => {
+ const { container } = render(Canvas);
+ const editor = container.querySelector(".editor");
+
+ expect(editor).toBeTruthy();
+ const style = window.getComputedStyle(editor as Element);
+ expect(style.display).toBe("flex");
+ expect(style.flexDirection).toBe("column");
+ });
+
+ it("should render all tool buttons in toolbar", () => {
+ const { container } = render(Canvas);
+ const toolButtons = container.querySelectorAll(".tool-button");
+
+ expect(toolButtons.length).toBe(6);
+
+ const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id"));
+ expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]);
+ });
+
+ it("should have select tool active by default", () => {
+ const { container } = render(Canvas);
+ const selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]");
+
+ expect(selectButton?.classList.contains("active")).toBe(true);
+ });
+
+ it("should change active tool when toolbar button is clicked", async () => {
+ const { container } = render(Canvas);
+
+ const selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]");
+ const rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]") as HTMLButtonElement;
+
+ expect(selectButton?.classList.contains("active")).toBe(true);
+ expect(rectButton?.classList.contains("active")).toBe(false);
+
+ rectButton.click();
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const selectButtonAfter = container.querySelector(".tool-button[data-tool-id=\"select\"]");
+ const rectButtonAfter = container.querySelector(".tool-button[data-tool-id=\"rect\"]");
+
+ expect(selectButtonAfter?.classList.contains("active")).toBe(false);
+ expect(rectButtonAfter?.classList.contains("active")).toBe(true);
+ });
});
diff --git a/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts b/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
new file mode 100644
index 0000000..2c1e138
--- /dev/null
+++ b/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
@@ -0,0 +1,86 @@
+import type { ToolId } from "inkfinite-core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render } from "vitest-browser-svelte";
+import Toolbar from "../../components/Toolbar.svelte";
+
+describe("Toolbar component", () => {
+ beforeEach(() => {
+ cleanup();
+ });
+
+ it("should render all tool buttons", () => {
+ const onToolChange = vi.fn();
+ const { container } = render(Toolbar, { currentTool: "select", onToolChange });
+
+ const buttons = container.querySelectorAll(".tool-button");
+ expect(buttons.length).toBe(6);
+
+ const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id"));
+ expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]);
+ });
+
+ it("should mark the current tool as active", () => {
+ const onToolChange = vi.fn();
+ const { container } = render(Toolbar, { currentTool: "rect", onToolChange });
+
+ const activeButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]");
+ expect(activeButton?.classList.contains("active")).toBe(true);
+ expect(activeButton?.getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("should call onToolChange when a tool button is clicked", async () => {
+ const onToolChange = vi.fn();
+ const { container } = render(Toolbar, { currentTool: "select", onToolChange });
+
+ const ellipseButton = container.querySelector(".tool-button[data-tool-id=\"ellipse\"]") as HTMLButtonElement;
+ expect(ellipseButton).toBeTruthy();
+
+ ellipseButton.click();
+
+ expect(onToolChange).toHaveBeenCalledTimes(1);
+ expect(onToolChange).toHaveBeenCalledWith("ellipse");
+ });
+
+ it.each([
+ { toolId: "select" as ToolId, label: "Select" },
+ { toolId: "rect" as ToolId, label: "Rectangle" },
+ { toolId: "ellipse" as ToolId, label: "Ellipse" },
+ { toolId: "line" as ToolId, label: "Line" },
+ { toolId: "arrow" as ToolId, label: "Arrow" },
+ { toolId: "text" as ToolId, label: "Text" },
+ ])("should have correct aria-label for $toolId tool", ({ toolId, label }) => {
+ const onToolChange = vi.fn();
+ const { container } = render(Toolbar, { currentTool: "select", onToolChange });
+
+ const button = container.querySelector(`.tool-button[data-tool-id="${toolId}"]`);
+ expect(button?.getAttribute("aria-label")).toBe(label);
+ });
+
+ it("should update active state when currentTool prop changes", async () => {
+ const onToolChange = vi.fn();
+ const { container, rerender } = render(Toolbar, { currentTool: "select", onToolChange });
+
+ let selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]");
+ let rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]");
+
+ expect(selectButton?.classList.contains("active")).toBe(true);
+ expect(rectButton?.classList.contains("active")).toBe(false);
+
+ await rerender({ currentTool: "rect", onToolChange });
+
+ selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]");
+ rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]");
+
+ expect(selectButton?.classList.contains("active")).toBe(false);
+ expect(rectButton?.classList.contains("active")).toBe(true);
+ });
+
+ it("should have proper accessibility attributes", () => {
+ const onToolChange = vi.fn();
+ const { container } = render(Toolbar, { currentTool: "select", onToolChange });
+
+ const toolbar = container.querySelector(".toolbar");
+ expect(toolbar?.getAttribute("role")).toBe("toolbar");
+ expect(toolbar?.getAttribute("aria-label")).toBe("Drawing tools");
+ });
+});
diff --git a/apps/web/src/routes/+layout.ts b/apps/web/src/routes/+layout.ts
new file mode 100644
index 0000000..189f71e
--- /dev/null
+++ b/apps/web/src/routes/+layout.ts
@@ -0,0 +1 @@
+export const prerender = true;
diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts
index feadaee..cf30e5d 100644
--- a/packages/core/src/tools.test.ts
+++ b/packages/core/src/tools.test.ts
@@ -1,8 +1,16 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Action, Modifiers, PointerButtons } from "./actions";
-import { PageRecord, ShapeRecord } from "./model";
+import {
+ type ArrowProps,
+ type EllipseProps,
+ type LineProps,
+ PageRecord,
+ type RectProps,
+ ShapeRecord,
+ type TextProps,
+} from "./model";
import { EditorState } from "./reactivity";
-import { SelectTool } from "./tools";
+import { ArrowTool, EllipseTool, LineTool, RectTool, SelectTool, TextTool } from "./tools";
describe("SelectTool", () => {
let tool: SelectTool;
@@ -383,3 +391,676 @@ describe("SelectTool", () => {
});
});
});
+
+describe("RectTool", () => {
+ let tool: RectTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+
+ beforeEach(() => {
+ tool = new RectTool();
+ page = PageRecord.create("Test Page");
+
+ initialState = {
+ ...EditorState.create(),
+ doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "rect" },
+ };
+ });
+
+ describe("shape creation", () => {
+ it("should create a rect shape on pointer down", () => {
+ const action = Action.pointerDown(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ );
+
+ const result = tool.onAction(initialState, action);
+
+ const shapeIds = Object.keys(result.doc.shapes);
+ expect(shapeIds.length).toBe(1);
+
+ const shape = result.doc.shapes[shapeIds[0]];
+ expect(shape.type).toBe("rect");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ expect((shape.props as RectProps).w).toBe(0);
+ expect((shape.props as RectProps).h).toBe(0);
+ expect(result.ui.selectionIds).toEqual([shape.id]);
+ });
+
+ it("should update rect dimensions on pointer move", () => {
+ 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: 200, y: 150 },
+ { x: 200, y: 150 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeId = Object.keys(result.doc.shapes)[0];
+ const shape = result.doc.shapes[shapeId];
+
+ expect(shape.type).toBe("rect");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ expect((shape.props as RectProps).w).toBe(100);
+ expect((shape.props as RectProps).h).toBe(50);
+ });
+
+ it("should handle negative dragging (drag up-left)", () => {
+ let result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ 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(),
+ ),
+ );
+
+ const shapeId = Object.keys(result.doc.shapes)[0];
+ const shape = result.doc.shapes[shapeId];
+
+ expect(shape.type).toBe("rect");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ expect((shape.props as RectProps).w).toBe(100);
+ expect((shape.props as RectProps).h).toBe(100);
+ });
+
+ it("should remove shape if too small on pointer up", () => {
+ 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: 102, y: 102 },
+ { x: 102, y: 102 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 102, y: 102 },
+ { x: 102, y: 102 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+
+ it("should keep shape if large enough on pointer up", () => {
+ 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: 200, y: 200 },
+ { x: 200, y: 200 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(1);
+ });
+
+ it("should cancel shape creation on Escape", () => {
+ 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.keyDown("Escape", "Escape", Modifiers.create()));
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+
+ it("should cleanup on tool exit", () => {
+ 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.onExit(result);
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ expect(result.ui.selectionIds).toEqual([]);
+ });
+ });
+});
+
+describe("EllipseTool", () => {
+ let tool: EllipseTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+
+ beforeEach(() => {
+ tool = new EllipseTool();
+ page = PageRecord.create("Test Page");
+
+ initialState = {
+ ...EditorState.create(),
+ doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "ellipse" },
+ };
+ });
+
+ it("should create an ellipse shape on pointer down", () => {
+ const result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeIds = Object.keys(result.doc.shapes);
+ expect(shapeIds.length).toBe(1);
+
+ const shape = result.doc.shapes[shapeIds[0]];
+ expect(shape.type).toBe("ellipse");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ });
+
+ it("should update ellipse dimensions on pointer move", () => {
+ 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: 250, y: 200 },
+ { x: 250, y: 200 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeId = Object.keys(result.doc.shapes)[0];
+ const shape = result.doc.shapes[shapeId];
+
+ expect(shape.type).toBe("ellipse");
+ expect((shape.props as EllipseProps).w).toBe(150);
+ expect((shape.props as EllipseProps).h).toBe(100);
+ });
+
+ it("should remove ellipse if too small on pointer up", () => {
+ 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: 103, y: 103 },
+ { x: 103, y: 103 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 103, y: 103 },
+ { x: 103, y: 103 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ });
+});
+
+describe("LineTool", () => {
+ let tool: LineTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+
+ beforeEach(() => {
+ tool = new LineTool();
+ page = PageRecord.create("Test Page");
+
+ initialState = {
+ ...EditorState.create(),
+ doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "line" },
+ };
+ });
+
+ it("should create a line shape on pointer down", () => {
+ const result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeIds = Object.keys(result.doc.shapes);
+ expect(shapeIds.length).toBe(1);
+
+ const shape = result.doc.shapes[shapeIds[0]];
+ expect(shape.type).toBe("line");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ expect((shape.props as LineProps).a).toEqual({ x: 0, y: 0 });
+ expect((shape.props as LineProps).b).toEqual({ x: 0, y: 0 });
+ });
+
+ it("should update line endpoint on pointer move", () => {
+ 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: 200, y: 150 },
+ { x: 200, y: 150 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeId = Object.keys(result.doc.shapes)[0];
+ const shape = result.doc.shapes[shapeId];
+
+ expect(shape.type).toBe("line");
+ expect((shape.props as LineProps).b).toEqual({ x: 100, y: 50 });
+ });
+
+ it("should remove line if too short on pointer up", () => {
+ 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: 102, y: 102 },
+ { x: 102, y: 102 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 102, y: 102 },
+ { x: 102, y: 102 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ });
+
+ it("should keep line if long enough on pointer up", () => {
+ 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: 200, y: 200 },
+ { x: 200, y: 200 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(1);
+ });
+});
+
+describe("ArrowTool", () => {
+ let tool: ArrowTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+
+ beforeEach(() => {
+ tool = new ArrowTool();
+ page = PageRecord.create("Test Page");
+
+ initialState = {
+ ...EditorState.create(),
+ doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "arrow" },
+ };
+ });
+
+ it("should create an arrow shape on pointer down", () => {
+ const result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeIds = Object.keys(result.doc.shapes);
+ expect(shapeIds.length).toBe(1);
+
+ const shape = result.doc.shapes[shapeIds[0]];
+ expect(shape.type).toBe("arrow");
+ expect(shape.x).toBe(100);
+ expect(shape.y).toBe(100);
+ });
+
+ it("should update arrow endpoint on pointer move", () => {
+ 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: 300, y: 200 },
+ { x: 300, y: 200 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeId = Object.keys(result.doc.shapes)[0];
+ const shape = result.doc.shapes[shapeId];
+
+ expect(shape.type).toBe("arrow");
+ expect((shape.props as ArrowProps).b).toEqual({ x: 200, y: 100 });
+ });
+
+ it("should remove arrow if too short on pointer up", () => {
+ 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: 101, y: 101 },
+ { x: 101, y: 101 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 101, y: 101 },
+ { x: 101, y: 101 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(0);
+ });
+});
+
+describe("TextTool", () => {
+ let tool: TextTool;
+ let initialState: EditorState;
+ let page: PageRecord;
+
+ beforeEach(() => {
+ tool = new TextTool();
+ page = PageRecord.create("Test Page");
+
+ initialState = {
+ ...EditorState.create(),
+ doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} },
+ ui: { currentPageId: page.id, selectionIds: [], toolId: "text" },
+ };
+ });
+
+ it("should create a text shape on pointer down", () => {
+ const result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 150, y: 200 },
+ { x: 150, y: 200 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const shapeIds = Object.keys(result.doc.shapes);
+ expect(shapeIds.length).toBe(1);
+
+ const shape = result.doc.shapes[shapeIds[0]];
+ expect(shape.type).toBe("text");
+ expect(shape.x).toBe(150);
+ expect(shape.y).toBe(200);
+ expect((shape.props as TextProps).text).toBe("Text");
+ expect((shape.props as TextProps).fontSize).toBe(16);
+ expect(result.ui.selectionIds).toEqual([shape.id]);
+ });
+
+ it("should create new text shape on each click", () => {
+ 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.pointerDown(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(Object.keys(result.doc.shapes).length).toBe(2);
+ });
+
+ it("should not respond to pointer move or up", () => {
+ let result = tool.onAction(
+ initialState,
+ Action.pointerDown(
+ { x: 100, y: 100 },
+ { x: 100, y: 100 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ const beforeMove = result;
+ const shapeCountBefore = Object.keys(result.doc.shapes).length;
+
+ result = tool.onAction(
+ result,
+ Action.pointerMove(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(result).toBe(beforeMove);
+ expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore);
+
+ result = tool.onAction(
+ result,
+ Action.pointerUp(
+ { x: 200, y: 200 },
+ { x: 200, y: 200 },
+ 0,
+ PointerButtons.create(false, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(result).toBe(beforeMove);
+ expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore);
+ });
+});
diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts
index 7794fe8..be070d3 100644
--- a/packages/core/src/tools.ts
+++ b/packages/core/src/tools.ts
@@ -1,6 +1,7 @@
import type { Action } from "./actions";
import { hitTestPoint, shapeBounds } from "./geom";
import { Box2, Vec2 } from "./math";
+import { createId, ShapeRecord } from "./model";
import type { EditorState, ToolId } from "./reactivity";
import { getCurrentPage } from "./reactivity";
@@ -399,3 +400,711 @@ export class SelectTool implements Tool {
return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]);
}
}
+
+/**
+ * Internal state for shape creation tools
+ */
+type ShapeCreationToolState = {
+ /** Whether we're currently creating a shape */
+ isCreating: boolean;
+ /** World coordinates where creation started */
+ startWorld: Vec2 | null;
+ /** ID of the shape being created */
+ creatingShapeId: string | null;
+};
+
+/**
+ * Minimum size threshold for shapes (in world units)
+ * Shapes smaller than this on either dimension will be deleted
+ */
+const MIN_SHAPE_SIZE = 5;
+
+/**
+ * Rect tool - creates rectangle shapes by dragging
+ *
+ * Features:
+ * - Drag to create a rectangle from start point to current point
+ * - Click-cancel: shapes too small are deleted on pointer up
+ */
+export class RectTool implements Tool {
+ readonly id: ToolId = "rect";
+ private toolState: ShapeCreationToolState;
+
+ constructor() {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+
+ onEnter(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ let newState = state;
+ if (this.toolState.creatingShapeId) {
+ newState = this.cancelShapeCreation(state);
+ }
+ this.resetToolState();
+ return newState;
+ }
+
+ 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;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createRect(currentPage.id, action.world.x, action.world.y, {
+ w: 0,
+ h: 0,
+ fill: "#4a90e2",
+ stroke: "#2e5c8a",
+ radius: 4,
+ }, shapeId);
+
+ this.toolState.isCreating = true;
+ this.toolState.startWorld = action.world;
+ this.toolState.creatingShapeId = shapeId;
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+
+ private handlePointerMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state;
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "rect") return state;
+
+ const delta = Vec2.sub(action.world, this.toolState.startWorld);
+ const w = Math.abs(delta.x);
+ const h = Math.abs(delta.y);
+
+ const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x;
+ const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y;
+
+ const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } },
+ };
+ }
+
+ private handlePointerUp(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "rect") return state;
+
+ let newState = state;
+
+ if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) {
+ newState = this.cancelShapeCreation(state);
+ }
+
+ this.resetToolState();
+ return newState;
+ }
+
+ private handleKeyDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "key-down") return state;
+
+ if (action.key === "Escape" && this.toolState.creatingShapeId) {
+ const newState = this.cancelShapeCreation(state);
+ this.resetToolState();
+ return newState;
+ }
+
+ return state;
+ }
+
+ private cancelShapeCreation(state: EditorState): EditorState {
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape) return state;
+
+ const newShapes = { ...state.doc.shapes };
+ delete newShapes[this.toolState.creatingShapeId];
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const newPage = {
+ ...currentPage,
+ shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId),
+ };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } },
+ ui: { ...state.ui, selectionIds: [] },
+ };
+ }
+
+ private resetToolState(): void {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+}
+
+/**
+ * Ellipse tool - creates ellipse shapes by dragging
+ *
+ * Features:
+ * - Drag to create an ellipse from start point to current point
+ * - Click-cancel: shapes too small are deleted on pointer up
+ */
+export class EllipseTool implements Tool {
+ readonly id: ToolId = "ellipse";
+ private toolState: ShapeCreationToolState;
+
+ constructor() {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+
+ onEnter(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ let newState = state;
+ if (this.toolState.creatingShapeId) {
+ newState = this.cancelShapeCreation(state);
+ }
+ this.resetToolState();
+ return newState;
+ }
+
+ 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;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createEllipse(currentPage.id, action.world.x, action.world.y, {
+ w: 0,
+ h: 0,
+ fill: "#51cf66",
+ stroke: "#2f9e44",
+ }, shapeId);
+
+ this.toolState.isCreating = true;
+ this.toolState.startWorld = action.world;
+ this.toolState.creatingShapeId = shapeId;
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+
+ private handlePointerMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state;
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "ellipse") return state;
+
+ const delta = Vec2.sub(action.world, this.toolState.startWorld);
+ const w = Math.abs(delta.x);
+ const h = Math.abs(delta.y);
+
+ const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x;
+ const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y;
+
+ const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } },
+ };
+ }
+
+ private handlePointerUp(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "ellipse") return state;
+
+ let newState = state;
+
+ if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) {
+ newState = this.cancelShapeCreation(state);
+ }
+
+ this.resetToolState();
+ return newState;
+ }
+
+ private handleKeyDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "key-down") return state;
+
+ if (action.key === "Escape" && this.toolState.creatingShapeId) {
+ const newState = this.cancelShapeCreation(state);
+ this.resetToolState();
+ return newState;
+ }
+
+ return state;
+ }
+
+ private cancelShapeCreation(state: EditorState): EditorState {
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape) return state;
+
+ const newShapes = { ...state.doc.shapes };
+ delete newShapes[this.toolState.creatingShapeId];
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const newPage = {
+ ...currentPage,
+ shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId),
+ };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } },
+ ui: { ...state.ui, selectionIds: [] },
+ };
+ }
+
+ private resetToolState(): void {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+}
+
+/**
+ * Line tool - creates line shapes by dragging
+ *
+ * Features:
+ * - Drag to create a line from start point (a) to current point (b)
+ * - Click-cancel: very short lines are deleted on pointer up
+ */
+export class LineTool implements Tool {
+ readonly id: ToolId = "line";
+ private toolState: ShapeCreationToolState;
+
+ constructor() {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+
+ onEnter(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ let newState = state;
+ if (this.toolState.creatingShapeId) {
+ newState = this.cancelShapeCreation(state);
+ }
+ this.resetToolState();
+ return newState;
+ }
+
+ 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;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createLine(currentPage.id, action.world.x, action.world.y, {
+ a: { x: 0, y: 0 },
+ b: { x: 0, y: 0 },
+ stroke: "#495057",
+ width: 2,
+ }, shapeId);
+
+ this.toolState.isCreating = true;
+ this.toolState.startWorld = action.world;
+ this.toolState.creatingShapeId = shapeId;
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+
+ private handlePointerMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state;
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "line") return state;
+
+ const b = Vec2.sub(action.world, this.toolState.startWorld);
+ const updatedShape = { ...shape, props: { ...shape.props, b } };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } },
+ };
+ }
+
+ private handlePointerUp(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "line") return state;
+
+ let newState = state;
+
+ const lineLength = Vec2.len(shape.props.b);
+ if (lineLength < MIN_SHAPE_SIZE) {
+ newState = this.cancelShapeCreation(state);
+ }
+
+ this.resetToolState();
+ return newState;
+ }
+
+ private handleKeyDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "key-down") return state;
+
+ if (action.key === "Escape" && this.toolState.creatingShapeId) {
+ const newState = this.cancelShapeCreation(state);
+ this.resetToolState();
+ return newState;
+ }
+
+ return state;
+ }
+
+ private cancelShapeCreation(state: EditorState): EditorState {
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape) return state;
+
+ const newShapes = { ...state.doc.shapes };
+ delete newShapes[this.toolState.creatingShapeId];
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const newPage = {
+ ...currentPage,
+ shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId),
+ };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } },
+ ui: { ...state.ui, selectionIds: [] },
+ };
+ }
+
+ private resetToolState(): void {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+}
+
+/**
+ * Arrow tool - creates arrow shapes by dragging
+ *
+ * Features:
+ * - Drag to create an arrow from start point (a) to current point (b)
+ * - Click-cancel: very short arrows are deleted on pointer up
+ */
+export class ArrowTool implements Tool {
+ readonly id: ToolId = "arrow";
+ private toolState: ShapeCreationToolState;
+
+ constructor() {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+
+ onEnter(state: EditorState): EditorState {
+ this.resetToolState();
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ let newState = state;
+ if (this.toolState.creatingShapeId) {
+ newState = this.cancelShapeCreation(state);
+ }
+ this.resetToolState();
+ return newState;
+ }
+
+ 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;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, {
+ a: { x: 0, y: 0 },
+ b: { x: 0, y: 0 },
+ stroke: "#495057",
+ width: 2,
+ }, shapeId);
+
+ this.toolState.isCreating = true;
+ this.toolState.startWorld = action.world;
+ this.toolState.creatingShapeId = shapeId;
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+
+ private handlePointerMove(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state;
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "arrow") return state;
+
+ const b = Vec2.sub(action.world, this.toolState.startWorld);
+ const updatedShape = { ...shape, props: { ...shape.props, b } };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } },
+ };
+ }
+
+ private handlePointerUp(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape || shape.type !== "arrow") return state;
+
+ let newState = state;
+
+ const arrowLength = Vec2.len(shape.props.b);
+ if (arrowLength < MIN_SHAPE_SIZE) {
+ newState = this.cancelShapeCreation(state);
+ }
+
+ this.resetToolState();
+ return newState;
+ }
+
+ private handleKeyDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "key-down") return state;
+
+ if (action.key === "Escape" && this.toolState.creatingShapeId) {
+ const newState = this.cancelShapeCreation(state);
+ this.resetToolState();
+ return newState;
+ }
+
+ return state;
+ }
+
+ private cancelShapeCreation(state: EditorState): EditorState {
+ if (!this.toolState.creatingShapeId) return state;
+
+ const shape = state.doc.shapes[this.toolState.creatingShapeId];
+ if (!shape) return state;
+
+ const newShapes = { ...state.doc.shapes };
+ delete newShapes[this.toolState.creatingShapeId];
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const newPage = {
+ ...currentPage,
+ shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId),
+ };
+
+ return {
+ ...state,
+ doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } },
+ ui: { ...state.ui, selectionIds: [] },
+ };
+ }
+
+ private resetToolState(): void {
+ this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null };
+ }
+}
+
+/**
+ * Text tool - creates text shapes on click
+ *
+ * Features:
+ * - Click to create a text shape at the pointer position
+ * - Text is created with default content "Text"
+ * - Shape is immediately selected after creation
+ */
+export class TextTool implements Tool {
+ readonly id: ToolId = "text";
+
+ onEnter(state: EditorState): EditorState {
+ return state;
+ }
+
+ onExit(state: EditorState): EditorState {
+ return state;
+ }
+
+ onAction(state: EditorState, action: Action): EditorState {
+ switch (action.type) {
+ case "pointer-down": {
+ return this.handlePointerDown(state, action);
+ }
+ default: {
+ return state;
+ }
+ }
+ }
+
+ private handlePointerDown(state: EditorState, action: Action): EditorState {
+ if (action.type !== "pointer-down") return state;
+
+ const currentPage = getCurrentPage(state);
+ if (!currentPage) return state;
+
+ const shapeId = createId("shape");
+
+ const shape = ShapeRecord.createText(currentPage.id, action.world.x, action.world.y, {
+ text: "Text",
+ fontSize: 16,
+ fontFamily: "sans-serif",
+ color: "#000000",
+ }, shapeId);
+
+ const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+
+ return {
+ ...state,
+ doc: {
+ ...state.doc,
+ shapes: { ...state.doc.shapes, [shapeId]: shape },
+ pages: { ...state.doc.pages, [currentPage.id]: newPage },
+ },
+ ui: { ...state.ui, selectionIds: [shapeId] },
+ };
+ }
+}