web based infinite canvas
at main 763 lines 23 kB view raw
1import type { Action } from "../actions"; 2import { computeNormalizedAnchor, hitTestPoint } from "../geom"; 3import { Vec2 } from "../math"; 4import { BindingRecord, createId, ShapeRecord } from "../model"; 5import type { EditorState, ToolId } from "../reactivity"; 6import { getCurrentPage } from "../reactivity"; 7import type { Tool } from "../tools/base"; 8 9/** 10 * Internal state for shape creation tools 11 */ 12type ShapeCreationToolState = { 13 /** Whether we're currently creating a shape */ 14 isCreating: boolean; 15 /** World coordinates where creation started */ 16 startWorld: Vec2 | null; 17 /** ID of the shape being created */ 18 creatingShapeId: string | null; 19}; 20 21/** 22 * Minimum size threshold for shapes (in world units) 23 * Shapes smaller than this on either dimension will be deleted 24 */ 25const MIN_SHAPE_SIZE = 5; 26 27/** 28 * Rect tool - creates rectangle shapes by dragging 29 * 30 * Features: 31 * - Drag to create a rectangle from start point to current point 32 * - Click-cancel: shapes too small are deleted on pointer up 33 */ 34export class RectTool implements Tool { 35 readonly id: ToolId = "rect"; 36 private toolState: ShapeCreationToolState; 37 38 constructor() { 39 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 40 } 41 42 onEnter(state: EditorState): EditorState { 43 this.resetToolState(); 44 return state; 45 } 46 47 onExit(state: EditorState): EditorState { 48 let newState = state; 49 if (this.toolState.creatingShapeId) { 50 newState = this.cancelShapeCreation(state); 51 } 52 this.resetToolState(); 53 return newState; 54 } 55 56 onAction(state: EditorState, action: Action): EditorState { 57 switch (action.type) { 58 case "pointer-down": { 59 return this.handlePointerDown(state, action); 60 } 61 case "pointer-move": { 62 return this.handlePointerMove(state, action); 63 } 64 case "pointer-up": { 65 return this.handlePointerUp(state, action); 66 } 67 case "key-down": { 68 return this.handleKeyDown(state, action); 69 } 70 default: { 71 return state; 72 } 73 } 74 } 75 76 private handlePointerDown(state: EditorState, action: Action): EditorState { 77 if (action.type !== "pointer-down") return state; 78 79 const currentPage = getCurrentPage(state); 80 if (!currentPage) return state; 81 82 const shapeId = createId("shape"); 83 84 const shape = ShapeRecord.createRect(currentPage.id, action.world.x, action.world.y, { 85 w: 0, 86 h: 0, 87 fill: "#4a90e2", 88 stroke: "#2e5c8a", 89 radius: 4, 90 }, shapeId); 91 92 this.toolState.isCreating = true; 93 this.toolState.startWorld = action.world; 94 this.toolState.creatingShapeId = shapeId; 95 96 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 97 98 return { 99 ...state, 100 doc: { 101 ...state.doc, 102 shapes: { ...state.doc.shapes, [shapeId]: shape }, 103 pages: { ...state.doc.pages, [currentPage.id]: newPage }, 104 }, 105 ui: { ...state.ui, selectionIds: [shapeId] }, 106 }; 107 } 108 109 private handlePointerMove(state: EditorState, action: Action): EditorState { 110 if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 111 if (!this.toolState.creatingShapeId) return state; 112 113 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 114 if (!shape || shape.type !== "rect") return state; 115 116 const delta = Vec2.sub(action.world, this.toolState.startWorld); 117 const w = Math.abs(delta.x); 118 const h = Math.abs(delta.y); 119 120 const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 121 const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 122 123 const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 124 125 return { 126 ...state, 127 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 128 }; 129 } 130 131 private handlePointerUp(state: EditorState, action: Action): EditorState { 132 if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 133 134 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 135 if (!shape || shape.type !== "rect") return state; 136 137 let newState = state; 138 139 if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 140 newState = this.cancelShapeCreation(state); 141 } 142 143 this.resetToolState(); 144 return newState; 145 } 146 147 private handleKeyDown(state: EditorState, action: Action): EditorState { 148 if (action.type !== "key-down") return state; 149 150 if (action.key === "Escape" && this.toolState.creatingShapeId) { 151 const newState = this.cancelShapeCreation(state); 152 this.resetToolState(); 153 return newState; 154 } 155 156 return state; 157 } 158 159 private cancelShapeCreation(state: EditorState): EditorState { 160 if (!this.toolState.creatingShapeId) return state; 161 162 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 163 if (!shape) return state; 164 165 const newShapes = { ...state.doc.shapes }; 166 delete newShapes[this.toolState.creatingShapeId]; 167 168 const currentPage = getCurrentPage(state); 169 if (!currentPage) return state; 170 171 const newPage = { 172 ...currentPage, 173 shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 174 }; 175 176 return { 177 ...state, 178 doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 179 ui: { ...state.ui, selectionIds: [] }, 180 }; 181 } 182 183 private resetToolState(): void { 184 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 185 } 186} 187 188/** 189 * Ellipse tool - creates ellipse shapes by dragging 190 * 191 * Features: 192 * - Drag to create an ellipse from start point to current point 193 * - Click-cancel: shapes too small are deleted on pointer up 194 */ 195export class EllipseTool implements Tool { 196 readonly id: ToolId = "ellipse"; 197 private toolState: ShapeCreationToolState; 198 199 constructor() { 200 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 201 } 202 203 onEnter(state: EditorState): EditorState { 204 this.resetToolState(); 205 return state; 206 } 207 208 onExit(state: EditorState): EditorState { 209 let newState = state; 210 if (this.toolState.creatingShapeId) { 211 newState = this.cancelShapeCreation(state); 212 } 213 this.resetToolState(); 214 return newState; 215 } 216 217 onAction(state: EditorState, action: Action): EditorState { 218 switch (action.type) { 219 case "pointer-down": { 220 return this.handlePointerDown(state, action); 221 } 222 case "pointer-move": { 223 return this.handlePointerMove(state, action); 224 } 225 case "pointer-up": { 226 return this.handlePointerUp(state, action); 227 } 228 case "key-down": { 229 return this.handleKeyDown(state, action); 230 } 231 default: { 232 return state; 233 } 234 } 235 } 236 237 private handlePointerDown(state: EditorState, action: Action): EditorState { 238 if (action.type !== "pointer-down") return state; 239 240 const currentPage = getCurrentPage(state); 241 if (!currentPage) return state; 242 243 const shapeId = createId("shape"); 244 245 const shape = ShapeRecord.createEllipse(currentPage.id, action.world.x, action.world.y, { 246 w: 0, 247 h: 0, 248 fill: "#51cf66", 249 stroke: "#2f9e44", 250 }, shapeId); 251 252 this.toolState.isCreating = true; 253 this.toolState.startWorld = action.world; 254 this.toolState.creatingShapeId = shapeId; 255 256 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 257 258 return { 259 ...state, 260 doc: { 261 ...state.doc, 262 shapes: { ...state.doc.shapes, [shapeId]: shape }, 263 pages: { ...state.doc.pages, [currentPage.id]: newPage }, 264 }, 265 ui: { ...state.ui, selectionIds: [shapeId] }, 266 }; 267 } 268 269 private handlePointerMove(state: EditorState, action: Action): EditorState { 270 if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 271 if (!this.toolState.creatingShapeId) return state; 272 273 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 274 if (!shape || shape.type !== "ellipse") return state; 275 276 const delta = Vec2.sub(action.world, this.toolState.startWorld); 277 const w = Math.abs(delta.x); 278 const h = Math.abs(delta.y); 279 280 const x = delta.x < 0 ? this.toolState.startWorld.x - w : this.toolState.startWorld.x; 281 const y = delta.y < 0 ? this.toolState.startWorld.y - h : this.toolState.startWorld.y; 282 283 const updatedShape = { ...shape, x, y, props: { ...shape.props, w, h } }; 284 285 return { 286 ...state, 287 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 288 }; 289 } 290 291 private handlePointerUp(state: EditorState, action: Action): EditorState { 292 if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 293 294 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 295 if (!shape || shape.type !== "ellipse") return state; 296 297 let newState = state; 298 299 if (shape.props.w < MIN_SHAPE_SIZE || shape.props.h < MIN_SHAPE_SIZE) { 300 newState = this.cancelShapeCreation(state); 301 } 302 303 this.resetToolState(); 304 return newState; 305 } 306 307 private handleKeyDown(state: EditorState, action: Action): EditorState { 308 if (action.type !== "key-down") return state; 309 310 if (action.key === "Escape" && this.toolState.creatingShapeId) { 311 const newState = this.cancelShapeCreation(state); 312 this.resetToolState(); 313 return newState; 314 } 315 316 return state; 317 } 318 319 private cancelShapeCreation(state: EditorState): EditorState { 320 if (!this.toolState.creatingShapeId) return state; 321 322 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 323 if (!shape) return state; 324 325 const newShapes = { ...state.doc.shapes }; 326 delete newShapes[this.toolState.creatingShapeId]; 327 328 const currentPage = getCurrentPage(state); 329 if (!currentPage) return state; 330 331 const newPage = { 332 ...currentPage, 333 shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 334 }; 335 336 return { 337 ...state, 338 doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 339 ui: { ...state.ui, selectionIds: [] }, 340 }; 341 } 342 343 private resetToolState(): void { 344 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 345 } 346} 347 348/** 349 * Line tool - creates line shapes by dragging 350 * 351 * Features: 352 * - Drag to create a line from start point (a) to current point (b) 353 * - Click-cancel: very short lines are deleted on pointer up 354 */ 355export class LineTool implements Tool { 356 readonly id: ToolId = "line"; 357 private toolState: ShapeCreationToolState; 358 359 constructor() { 360 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 361 } 362 363 onEnter(state: EditorState): EditorState { 364 this.resetToolState(); 365 return state; 366 } 367 368 onExit(state: EditorState): EditorState { 369 let newState = state; 370 if (this.toolState.creatingShapeId) { 371 newState = this.cancelShapeCreation(state); 372 } 373 this.resetToolState(); 374 return newState; 375 } 376 377 onAction(state: EditorState, action: Action): EditorState { 378 switch (action.type) { 379 case "pointer-down": { 380 return this.handlePointerDown(state, action); 381 } 382 case "pointer-move": { 383 return this.handlePointerMove(state, action); 384 } 385 case "pointer-up": { 386 return this.handlePointerUp(state, action); 387 } 388 case "key-down": { 389 return this.handleKeyDown(state, action); 390 } 391 default: { 392 return state; 393 } 394 } 395 } 396 397 private handlePointerDown(state: EditorState, action: Action): EditorState { 398 if (action.type !== "pointer-down") return state; 399 400 const currentPage = getCurrentPage(state); 401 if (!currentPage) return state; 402 403 const shapeId = createId("shape"); 404 405 const shape = ShapeRecord.createLine(currentPage.id, action.world.x, action.world.y, { 406 a: { x: 0, y: 0 }, 407 b: { x: 0, y: 0 }, 408 stroke: "#495057", 409 width: 2, 410 }, shapeId); 411 412 this.toolState.isCreating = true; 413 this.toolState.startWorld = action.world; 414 this.toolState.creatingShapeId = shapeId; 415 416 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 417 418 return { 419 ...state, 420 doc: { 421 ...state.doc, 422 shapes: { ...state.doc.shapes, [shapeId]: shape }, 423 pages: { ...state.doc.pages, [currentPage.id]: newPage }, 424 }, 425 ui: { ...state.ui, selectionIds: [shapeId] }, 426 }; 427 } 428 429 private handlePointerMove(state: EditorState, action: Action): EditorState { 430 if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 431 if (!this.toolState.creatingShapeId) return state; 432 433 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 434 if (!shape || shape.type !== "line") return state; 435 436 const b = Vec2.sub(action.world, this.toolState.startWorld); 437 const updatedShape = { ...shape, props: { ...shape.props, b } }; 438 439 return { 440 ...state, 441 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 442 }; 443 } 444 445 private handlePointerUp(state: EditorState, action: Action): EditorState { 446 if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 447 448 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 449 if (!shape || shape.type !== "line") return state; 450 451 let newState = state; 452 453 const lineLength = Vec2.len(shape.props.b); 454 if (lineLength < MIN_SHAPE_SIZE) { 455 newState = this.cancelShapeCreation(state); 456 } 457 458 this.resetToolState(); 459 return newState; 460 } 461 462 private handleKeyDown(state: EditorState, action: Action): EditorState { 463 if (action.type !== "key-down") return state; 464 465 if (action.key === "Escape" && this.toolState.creatingShapeId) { 466 const newState = this.cancelShapeCreation(state); 467 this.resetToolState(); 468 return newState; 469 } 470 471 return state; 472 } 473 474 private cancelShapeCreation(state: EditorState): EditorState { 475 if (!this.toolState.creatingShapeId) return state; 476 477 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 478 if (!shape) return state; 479 480 const newShapes = { ...state.doc.shapes }; 481 delete newShapes[this.toolState.creatingShapeId]; 482 483 const currentPage = getCurrentPage(state); 484 if (!currentPage) return state; 485 486 const newPage = { 487 ...currentPage, 488 shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 489 }; 490 491 return { 492 ...state, 493 doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 494 ui: { ...state.ui, selectionIds: [] }, 495 }; 496 } 497 498 private resetToolState(): void { 499 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 500 } 501} 502 503/** 504 * Arrow tool - creates arrow shapes by dragging 505 * 506 * Features: 507 * - Drag to create an arrow from start point (a) to current point (b) 508 * - Click-cancel: very short arrows are deleted on pointer up 509 */ 510export class ArrowTool implements Tool { 511 readonly id: ToolId = "arrow"; 512 private toolState: ShapeCreationToolState; 513 514 constructor() { 515 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 516 } 517 518 onEnter(state: EditorState): EditorState { 519 this.resetToolState(); 520 return state; 521 } 522 523 onExit(state: EditorState): EditorState { 524 let newState = state; 525 if (this.toolState.creatingShapeId) { 526 newState = this.cancelShapeCreation(state); 527 } 528 this.resetToolState(); 529 return newState; 530 } 531 532 onAction(state: EditorState, action: Action): EditorState { 533 switch (action.type) { 534 case "pointer-down": { 535 return this.handlePointerDown(state, action); 536 } 537 case "pointer-move": { 538 return this.handlePointerMove(state, action); 539 } 540 case "pointer-up": { 541 return this.handlePointerUp(state, action); 542 } 543 case "key-down": { 544 return this.handleKeyDown(state, action); 545 } 546 default: { 547 return state; 548 } 549 } 550 } 551 552 private handlePointerDown(state: EditorState, action: Action): EditorState { 553 if (action.type !== "pointer-down") return state; 554 555 const currentPage = getCurrentPage(state); 556 if (!currentPage) return state; 557 558 const shapeId = createId("shape"); 559 560 const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { 561 points: [{ x: 0, y: 0 }, { x: 0, y: 0 }], 562 start: { kind: "free" }, 563 end: { kind: "free" }, 564 style: { stroke: "#2563eb", width: 2, headEnd: true }, 565 routing: { kind: "straight" }, 566 }, shapeId); 567 568 this.toolState.isCreating = true; 569 this.toolState.startWorld = action.world; 570 this.toolState.creatingShapeId = shapeId; 571 572 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; 573 574 return { 575 ...state, 576 doc: { 577 ...state.doc, 578 shapes: { ...state.doc.shapes, [shapeId]: shape }, 579 pages: { ...state.doc.pages, [currentPage.id]: newPage }, 580 }, 581 ui: { ...state.ui, selectionIds: [shapeId] }, 582 }; 583 } 584 585 private handlePointerMove(state: EditorState, action: Action): EditorState { 586 if (action.type !== "pointer-move" || !this.toolState.isCreating || !this.toolState.startWorld) return state; 587 if (!this.toolState.creatingShapeId) return state; 588 589 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 590 if (!shape || shape.type !== "arrow") return state; 591 592 const b = Vec2.sub(action.world, this.toolState.startWorld); 593 const updatedPoints = [{ x: 0, y: 0 }, b]; 594 const updatedShape = { ...shape, props: { ...shape.props, points: updatedPoints } }; 595 596 let newState = { 597 ...state, 598 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 599 }; 600 601 const stateWithoutArrow = { 602 ...newState, 603 doc: { 604 ...newState.doc, 605 shapes: Object.fromEntries( 606 Object.entries(newState.doc.shapes).filter(([id]) => id !== this.toolState.creatingShapeId), 607 ), 608 }, 609 }; 610 611 const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); 612 613 if (hitShapeId) { 614 newState = { 615 ...newState, 616 ui: { 617 ...newState.ui, 618 bindingPreview: { arrowId: this.toolState.creatingShapeId, targetShapeId: hitShapeId, handle: "end" }, 619 }, 620 }; 621 } else { 622 newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 623 } 624 625 return newState; 626 } 627 628 private handlePointerUp(state: EditorState, action: Action): EditorState { 629 if (action.type !== "pointer-up" || !this.toolState.creatingShapeId) return state; 630 631 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 632 if (!shape || shape.type !== "arrow") return state; 633 634 let newState = state; 635 636 const points = shape.props.points; 637 if (!points || points.length < 2) { 638 newState = this.cancelShapeCreation(state); 639 this.resetToolState(); 640 return newState; 641 } 642 643 const endPoint = points[points.length - 1]; 644 const arrowLength = Vec2.len(endPoint); 645 if (arrowLength < MIN_SHAPE_SIZE) { 646 newState = this.cancelShapeCreation(state); 647 } else { 648 newState = this.createBindingsForArrow(state, this.toolState.creatingShapeId); 649 } 650 651 if (newState.ui.bindingPreview) { 652 newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 653 } 654 655 this.resetToolState(); 656 return newState; 657 } 658 659 /** 660 * Create bindings for arrow endpoints that hit other shapes 661 */ 662 private createBindingsForArrow(state: EditorState, arrowId: string): EditorState { 663 const arrow = state.doc.shapes[arrowId]; 664 if (!arrow || arrow.type !== "arrow") return state; 665 666 const points = arrow.props.points; 667 if (!points || points.length < 2) return state; 668 669 const startPoint = points[0]; 670 const endPoint = points[points.length - 1]; 671 672 const startWorld = { x: arrow.x + startPoint.x, y: arrow.y + startPoint.y }; 673 const endWorld = { x: arrow.x + endPoint.x, y: arrow.y + endPoint.y }; 674 675 const newBindings = { ...state.doc.bindings }; 676 let updatedArrow = arrow; 677 678 const stateWithoutArrow = { 679 ...state, 680 doc: { 681 ...state.doc, 682 shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), 683 }, 684 }; 685 686 const startHitId = hitTestPoint(stateWithoutArrow, startWorld); 687 if (startHitId) { 688 const targetShape = state.doc.shapes[startHitId]; 689 if (targetShape) { 690 const anchor = computeNormalizedAnchor(startWorld, targetShape); 691 const binding = BindingRecord.create(arrowId, startHitId, "start", { 692 kind: "edge", 693 nx: anchor.nx, 694 ny: anchor.ny, 695 }); 696 newBindings[binding.id] = binding; 697 updatedArrow = { 698 ...updatedArrow, 699 props: { ...updatedArrow.props, start: { kind: "bound", bindingId: binding.id } }, 700 }; 701 } 702 } 703 704 const endHitId = hitTestPoint(stateWithoutArrow, endWorld); 705 if (endHitId) { 706 const targetShape = state.doc.shapes[endHitId]; 707 if (targetShape) { 708 const anchor = computeNormalizedAnchor(endWorld, targetShape); 709 const binding = BindingRecord.create(arrowId, endHitId, "end", { kind: "edge", nx: anchor.nx, ny: anchor.ny }); 710 newBindings[binding.id] = binding; 711 updatedArrow = { 712 ...updatedArrow, 713 props: { ...updatedArrow.props, end: { kind: "bound", bindingId: binding.id } }, 714 }; 715 } 716 } 717 718 return { 719 ...state, 720 doc: { ...state.doc, bindings: newBindings, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } }, 721 }; 722 } 723 724 private handleKeyDown(state: EditorState, action: Action): EditorState { 725 if (action.type !== "key-down") return state; 726 727 if (action.key === "Escape" && this.toolState.creatingShapeId) { 728 const newState = this.cancelShapeCreation(state); 729 this.resetToolState(); 730 return newState; 731 } 732 733 return state; 734 } 735 736 private cancelShapeCreation(state: EditorState): EditorState { 737 if (!this.toolState.creatingShapeId) return state; 738 739 const shape = state.doc.shapes[this.toolState.creatingShapeId]; 740 if (!shape) return state; 741 742 const newShapes = { ...state.doc.shapes }; 743 delete newShapes[this.toolState.creatingShapeId]; 744 745 const currentPage = getCurrentPage(state); 746 if (!currentPage) return state; 747 748 const newPage = { 749 ...currentPage, 750 shapeIds: currentPage.shapeIds.filter((id) => id !== this.toolState.creatingShapeId), 751 }; 752 753 return { 754 ...state, 755 doc: { ...state.doc, shapes: newShapes, pages: { ...state.doc.pages, [currentPage.id]: newPage } }, 756 ui: { ...state.ui, selectionIds: [] }, 757 }; 758 } 759 760 private resetToolState(): void { 761 this.toolState = { isCreating: false, startWorld: null, creatingShapeId: null }; 762 } 763}