web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: tool state machine

+546
+97
packages/core/src/tools.ts
··· 1 + import type { Action } from "./actions"; 2 + import type { EditorState, ToolId } from "./reactivity"; 3 + 4 + /** 5 + * Tool interface - defines behavior for each editor tool 6 + * 7 + * Tools are explicit state machines that handle user input actions. 8 + * Each tool decides how to respond to actions and can update editor state. 9 + */ 10 + export interface Tool { 11 + /** Unique identifier for this tool */ 12 + readonly id: ToolId; 13 + 14 + /** 15 + * Called when the tool becomes active 16 + * 17 + * @param state - Current editor state 18 + * @returns Updated editor state 19 + */ 20 + onEnter(state: EditorState): EditorState; 21 + 22 + /** 23 + * Called when an action occurs while this tool is active 24 + * 25 + * @param state - Current editor state 26 + * @param action - The action to handle 27 + * @returns Updated editor state 28 + */ 29 + onAction(state: EditorState, action: Action): EditorState; 30 + 31 + /** 32 + * Called when the tool becomes inactive 33 + * 34 + * @param state - Current editor state 35 + * @returns Updated editor state 36 + */ 37 + onExit(state: EditorState): EditorState; 38 + } 39 + 40 + /** 41 + * Route an action to the currently active tool 42 + * 43 + * @param state - Current editor state 44 + * @param action - Action to route 45 + * @param tools - Map of tool ID to tool instance 46 + * @returns Updated editor state after tool handles the action 47 + */ 48 + export function routeAction(state: EditorState, action: Action, tools: Map<ToolId, Tool>): EditorState { 49 + const currentTool = tools.get(state.ui.toolId); 50 + if (!currentTool) return state; 51 + return currentTool.onAction(state, action); 52 + } 53 + 54 + /** 55 + * Switch from current tool to a new tool 56 + * 57 + * Calls onExit on the current tool (if it exists), then onEnter on the new tool. 58 + * 59 + * @param state - Current editor state 60 + * @param newToolId - ID of tool to switch to 61 + * @param tools - Map of tool ID to tool instance 62 + * @returns Updated editor state with new tool active 63 + */ 64 + export function switchTool(state: EditorState, newToolId: ToolId, tools: Map<ToolId, Tool>): EditorState { 65 + if (state.ui.toolId === newToolId) { 66 + return state; 67 + } 68 + 69 + const currentTool = tools.get(state.ui.toolId); 70 + let nextState = state; 71 + if (currentTool) { 72 + nextState = currentTool.onExit(nextState); 73 + } 74 + 75 + nextState = { ...nextState, ui: { ...nextState.ui, toolId: newToolId } }; 76 + 77 + const newTool = tools.get(newToolId); 78 + if (newTool) { 79 + nextState = newTool.onEnter(nextState); 80 + } 81 + 82 + return nextState; 83 + } 84 + 85 + /** 86 + * Create a map of tools from an array 87 + * 88 + * @param toolList - Array of tool instances 89 + * @returns Map of tool ID to tool instance 90 + */ 91 + export function createToolMap(toolList: Tool[]): Map<ToolId, Tool> { 92 + const map = new Map<ToolId, Tool>(); 93 + for (const tool of toolList) { 94 + map.set(tool.id, tool); 95 + } 96 + return map; 97 + }
+449
packages/core/tests/tools.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Action, Modifiers, PointerButtons } from "../src/actions"; 3 + import { Vec2 } from "../src/math"; 4 + import { EditorState } from "../src/reactivity"; 5 + import type { Tool } from "../src/tools"; 6 + import { createToolMap, routeAction, switchTool } from "../src/tools"; 7 + 8 + describe("Tools", () => { 9 + describe("Tool interface", () => { 10 + it("should allow creating a tool with all required methods", () => { 11 + const tool: Tool = { 12 + id: "select", 13 + onEnter: (state) => state, 14 + onAction: (state, _action) => state, 15 + onExit: (state) => state, 16 + }; 17 + 18 + expect(tool.id).toBe("select"); 19 + expect(typeof tool.onEnter).toBe("function"); 20 + expect(typeof tool.onAction).toBe("function"); 21 + expect(typeof tool.onExit).toBe("function"); 22 + }); 23 + }); 24 + 25 + describe("createToolMap", () => { 26 + it("should create a map from tool array", () => { 27 + const selectTool: Tool = { 28 + id: "select", 29 + onEnter: (state) => state, 30 + onAction: (state, _action) => state, 31 + onExit: (state) => state, 32 + }; 33 + 34 + const rectTool: Tool = { 35 + id: "rect", 36 + onEnter: (state) => state, 37 + onAction: (state, _action) => state, 38 + onExit: (state) => state, 39 + }; 40 + 41 + const map = createToolMap([selectTool, rectTool]); 42 + 43 + expect(map.size).toBe(2); 44 + expect(map.get("select")).toBe(selectTool); 45 + expect(map.get("rect")).toBe(rectTool); 46 + }); 47 + 48 + it("should handle empty array", () => { 49 + const map = createToolMap([]); 50 + expect(map.size).toBe(0); 51 + }); 52 + }); 53 + 54 + describe("switchTool", () => { 55 + it("should call onExit and onEnter in correct order when switching tools", () => { 56 + const callLog: string[] = []; 57 + 58 + const selectTool: Tool = { 59 + id: "select", 60 + onEnter: (state) => { 61 + callLog.push("select:onEnter"); 62 + return state; 63 + }, 64 + onAction: (state, _action) => state, 65 + onExit: (state) => { 66 + callLog.push("select:onExit"); 67 + return state; 68 + }, 69 + }; 70 + 71 + const rectTool: Tool = { 72 + id: "rect", 73 + onEnter: (state) => { 74 + callLog.push("rect:onEnter"); 75 + return state; 76 + }, 77 + onAction: (state, _action) => state, 78 + onExit: (state) => { 79 + callLog.push("rect:onExit"); 80 + return state; 81 + }, 82 + }; 83 + 84 + const tools = createToolMap([selectTool, rectTool]); 85 + const initialState = EditorState.create(); 86 + const newState = switchTool(initialState, "rect", tools); 87 + 88 + expect(callLog).toEqual(["select:onExit", "rect:onEnter"]); 89 + expect(newState.ui.toolId).toBe("rect"); 90 + }); 91 + 92 + it("should update toolId in state", () => { 93 + const selectTool: Tool = { 94 + id: "select", 95 + onEnter: (state) => state, 96 + onAction: (state, _action) => state, 97 + onExit: (state) => state, 98 + }; 99 + 100 + const rectTool: Tool = { 101 + id: "rect", 102 + onEnter: (state) => state, 103 + onAction: (state, _action) => state, 104 + onExit: (state) => state, 105 + }; 106 + 107 + const tools = createToolMap([selectTool, rectTool]); 108 + const initialState = EditorState.create(); 109 + 110 + expect(initialState.ui.toolId).toBe("select"); 111 + 112 + const newState = switchTool(initialState, "rect", tools); 113 + expect(newState.ui.toolId).toBe("rect"); 114 + }); 115 + 116 + it("should do nothing if already on the target tool", () => { 117 + const callLog: string[] = []; 118 + 119 + const selectTool: Tool = { 120 + id: "select", 121 + onEnter: (state) => { 122 + callLog.push("select:onEnter"); 123 + return state; 124 + }, 125 + onAction: (state, _action) => state, 126 + onExit: (state) => { 127 + callLog.push("select:onExit"); 128 + return state; 129 + }, 130 + }; 131 + 132 + const tools = createToolMap([selectTool]); 133 + const initialState = EditorState.create(); 134 + 135 + const newState = switchTool(initialState, "select", tools); 136 + 137 + expect(callLog).toEqual([]); 138 + expect(newState.ui.toolId).toBe("select"); 139 + expect(newState).toBe(initialState); 140 + }); 141 + 142 + it("should handle switching when current tool is not registered", () => { 143 + const rectTool: Tool = { 144 + id: "rect", 145 + onEnter: (state) => state, 146 + onAction: (state, _action) => state, 147 + onExit: (state) => state, 148 + }; 149 + 150 + const tools = createToolMap([rectTool]); 151 + const initialState = EditorState.create(); 152 + const newState = switchTool(initialState, "rect", tools); 153 + expect(newState.ui.toolId).toBe("rect"); 154 + }); 155 + 156 + it("should handle switching to unregistered tool", () => { 157 + const selectTool: Tool = { 158 + id: "select", 159 + onEnter: (state) => state, 160 + onAction: (state, _action) => state, 161 + onExit: (state) => state, 162 + }; 163 + 164 + const tools = createToolMap([selectTool]); 165 + const initialState = EditorState.create(); 166 + const newState = switchTool(initialState, "rect", tools); 167 + expect(newState.ui.toolId).toBe("rect"); 168 + }); 169 + 170 + it("should allow tools to modify state during onExit and onEnter", () => { 171 + const selectTool: Tool = { 172 + id: "select", 173 + onEnter: (state) => state, 174 + onAction: (state, _action) => state, 175 + onExit: (state) => ({ ...state, ui: { ...state.ui, selectionIds: [] } }), 176 + }; 177 + 178 + const rectTool: Tool = { 179 + id: "rect", 180 + onEnter: (state) => ({ ...state, ui: { ...state.ui, selectionIds: [] } }), 181 + onAction: (state, _action) => state, 182 + onExit: (state) => state, 183 + }; 184 + 185 + const tools = createToolMap([selectTool, rectTool]); 186 + const initialState = { 187 + ...EditorState.create(), 188 + ui: { ...EditorState.create().ui, selectionIds: ["shape-1", "shape-2"] }, 189 + }; 190 + 191 + const newState = switchTool(initialState, "rect", tools); 192 + 193 + expect(newState.ui.toolId).toBe("rect"); 194 + expect(newState.ui.selectionIds).toEqual([]); 195 + }); 196 + }); 197 + 198 + describe("routeAction", () => { 199 + it("should delegate action to active tool", () => { 200 + const actionsReceived: Action[] = []; 201 + 202 + const selectTool: Tool = { 203 + id: "select", 204 + onEnter: (state) => state, 205 + onAction: (state, action) => { 206 + actionsReceived.push(action); 207 + return state; 208 + }, 209 + onExit: (state) => state, 210 + }; 211 + 212 + const tools = createToolMap([selectTool]); 213 + const state = EditorState.create(); 214 + 215 + const action = Action.pointerDown( 216 + Vec2.create(100, 200), 217 + Vec2.create(50, 100), 218 + 0, 219 + PointerButtons.create(true, false, false), 220 + Modifiers.create(), 221 + ); 222 + 223 + routeAction(state, action, tools); 224 + 225 + expect(actionsReceived).toHaveLength(1); 226 + expect(actionsReceived[0]).toMatchObject({ 227 + type: "pointer-down", 228 + screen: { x: 100, y: 200 }, 229 + world: { x: 50, y: 100 }, 230 + }); 231 + }); 232 + 233 + it("should allow tool to update state based on action", () => { 234 + const selectTool: Tool = { 235 + id: "select", 236 + onEnter: (state) => state, 237 + onAction: (state, action) => { 238 + if (action.type === "pointer-down") { 239 + return { ...state, ui: { ...state.ui, selectionIds: ["shape-1"] } }; 240 + } 241 + return state; 242 + }, 243 + onExit: (state) => state, 244 + }; 245 + 246 + const tools = createToolMap([selectTool]); 247 + const initialState = EditorState.create(); 248 + 249 + const action = Action.pointerDown( 250 + Vec2.create(100, 200), 251 + Vec2.create(50, 100), 252 + 0, 253 + PointerButtons.create(true, false, false), 254 + Modifiers.create(), 255 + ); 256 + 257 + const newState = routeAction(initialState, action, tools); 258 + 259 + expect(newState.ui.selectionIds).toEqual(["shape-1"]); 260 + }); 261 + 262 + it("should return state unchanged if current tool is not registered", () => { 263 + const tools = createToolMap([]); 264 + const state = EditorState.create(); 265 + 266 + const action = Action.pointerDown( 267 + Vec2.create(100, 200), 268 + Vec2.create(50, 100), 269 + 0, 270 + PointerButtons.create(true, false, false), 271 + Modifiers.create(), 272 + ); 273 + 274 + const newState = routeAction(state, action, tools); 275 + 276 + expect(newState).toBe(state); 277 + }); 278 + 279 + it("should allow tool to ignore actions it doesn't care about", () => { 280 + const callLog: string[] = []; 281 + 282 + const selectTool: Tool = { 283 + id: "select", 284 + onEnter: (state) => state, 285 + onAction: (state, action) => { 286 + if (action.type.startsWith("pointer-")) { 287 + callLog.push(action.type); 288 + return { ...state, ui: { ...state.ui, selectionIds: ["handled"] } }; 289 + } 290 + 291 + return state; 292 + }, 293 + onExit: (state) => state, 294 + }; 295 + 296 + const tools = createToolMap([selectTool]); 297 + const state = EditorState.create(); 298 + const keyAction = Action.keyDown("a", "KeyA", Modifiers.create()); 299 + const afterKeyAction = routeAction(state, keyAction, tools); 300 + 301 + expect(afterKeyAction.ui.selectionIds).toEqual([]); 302 + expect(callLog).toEqual([]); 303 + 304 + const pointerAction = Action.pointerDown( 305 + Vec2.create(0, 0), 306 + Vec2.create(0, 0), 307 + 0, 308 + PointerButtons.create(true, false, false), 309 + Modifiers.create(), 310 + ); 311 + const afterPointerAction = routeAction(state, pointerAction, tools); 312 + 313 + expect(afterPointerAction.ui.selectionIds).toEqual(["handled"]); 314 + expect(callLog).toEqual(["pointer-down"]); 315 + }); 316 + 317 + it("should handle multiple different actions deterministically", () => { 318 + const stateLog: string[] = []; 319 + 320 + const dummyTool: Tool = { 321 + id: "select", 322 + onEnter: (state) => state, 323 + onAction: (state, action) => { 324 + switch (action.type) { 325 + case "pointer-down": { 326 + stateLog.push("down"); 327 + return { ...state, ui: { ...state.ui, selectionIds: ["pointer-down"] } }; 328 + } 329 + case "pointer-move": { 330 + stateLog.push("move"); 331 + return { ...state, ui: { ...state.ui, selectionIds: ["pointer-move"] } }; 332 + } 333 + case "pointer-up": { 334 + stateLog.push("up"); 335 + return { ...state, ui: { ...state.ui, selectionIds: [] } }; 336 + } 337 + default: { 338 + return state; 339 + } 340 + } 341 + }, 342 + onExit: (state) => state, 343 + }; 344 + 345 + const tools = createToolMap([dummyTool]); 346 + let state = EditorState.create(); 347 + 348 + const down = Action.pointerDown( 349 + Vec2.create(0, 0), 350 + Vec2.create(0, 0), 351 + 0, 352 + PointerButtons.create(true, false, false), 353 + Modifiers.create(), 354 + ); 355 + state = routeAction(state, down, tools); 356 + 357 + const move = Action.pointerMove( 358 + Vec2.create(10, 10), 359 + Vec2.create(5, 5), 360 + PointerButtons.create(true, false, false), 361 + Modifiers.create(), 362 + ); 363 + state = routeAction(state, move, tools); 364 + 365 + const up = Action.pointerUp( 366 + Vec2.create(10, 10), 367 + Vec2.create(5, 5), 368 + 0, 369 + PointerButtons.create(false, false, false), 370 + Modifiers.create(), 371 + ); 372 + state = routeAction(state, up, tools); 373 + 374 + expect(stateLog).toEqual(["down", "move", "up"]); 375 + expect(state.ui.selectionIds).toEqual([]); 376 + }); 377 + }); 378 + 379 + describe("Tool state machine behavior", () => { 380 + it("should demonstrate complete tool lifecycle", () => { 381 + const lifecycle: string[] = []; 382 + 383 + const selectTool: Tool = { 384 + id: "select", 385 + onEnter: (state) => { 386 + lifecycle.push("select:enter"); 387 + return state; 388 + }, 389 + onAction: (state, action) => { 390 + lifecycle.push(`select:action:${action.type}`); 391 + return state; 392 + }, 393 + onExit: (state) => { 394 + lifecycle.push("select:exit"); 395 + return state; 396 + }, 397 + }; 398 + 399 + const rectTool: Tool = { 400 + id: "rect", 401 + onEnter: (state) => { 402 + lifecycle.push("rect:enter"); 403 + return state; 404 + }, 405 + onAction: (state, action) => { 406 + lifecycle.push(`rect:action:${action.type}`); 407 + return state; 408 + }, 409 + onExit: (state) => { 410 + lifecycle.push("rect:exit"); 411 + return state; 412 + }, 413 + }; 414 + 415 + const tools = createToolMap([selectTool, rectTool]); 416 + let _state = EditorState.create(); 417 + 418 + const action1 = Action.pointerDown( 419 + Vec2.create(0, 0), 420 + Vec2.create(0, 0), 421 + 0, 422 + PointerButtons.create(true, false, false), 423 + Modifiers.create(), 424 + ); 425 + _state = routeAction(_state, action1, tools); 426 + 427 + _state = switchTool(_state, "rect", tools); 428 + 429 + const action2 = Action.pointerMove( 430 + Vec2.create(10, 10), 431 + Vec2.create(5, 5), 432 + PointerButtons.create(true, false, false), 433 + Modifiers.create(), 434 + ); 435 + _state = routeAction(_state, action2, tools); 436 + 437 + _state = switchTool(_state, "select", tools); 438 + 439 + expect(lifecycle).toEqual([ 440 + "select:action:pointer-down", 441 + "select:exit", 442 + "rect:enter", 443 + "rect:action:pointer-move", 444 + "rect:exit", 445 + "select:enter", 446 + ]); 447 + }); 448 + }); 449 + });