From 64bf3e02ecbb3926079c938e67c989cf55357479 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 21 Dec 2025 23:50:49 -0600 Subject: [PATCH] feat: basic shape creation --- .gitignore | 1 + TODO.txt | 37 +- apps/web/.prettierrc | 4 +- apps/web/src/lib/assets/favicon.svg | 5 +- apps/web/src/lib/canvas/Canvas.svelte | 232 +++--- apps/web/src/lib/components/Toolbar.svelte | 86 +++ apps/web/src/lib/tests/canvas.svelte.test.ts | 55 ++ .../tests/components/Toolbar.svelte.test.ts | 86 +++ apps/web/src/routes/+layout.ts | 1 + packages/core/src/tools.test.ts | 685 ++++++++++++++++- packages/core/src/tools.ts | 709 ++++++++++++++++++ 11 files changed, 1760 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/lib/components/Toolbar.svelte create mode 100644 apps/web/src/lib/tests/components/Toolbar.svelte.test.ts create mode 100644 apps/web/src/routes/+layout.ts 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 @@ -svelte-logo \ 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 @@ + + + + + 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] }, + }; + } +} -- 2.51.2