web based infinite canvas
at main 1273 lines 34 kB view raw
1// TODO: split up and move to test dir 2import { beforeEach, describe, expect, it } from "vitest"; 3import { Action, Modifiers, PointerButtons } from "./actions"; 4import { 5 type ArrowProps, 6 type EllipseProps, 7 type LineProps, 8 PageRecord, 9 type RectProps, 10 ShapeRecord, 11 type TextProps, 12} from "./model"; 13import { EditorState } from "./reactivity"; 14import { ArrowTool, EllipseTool, LineTool, RectTool, SelectTool, TextTool } from "./tools"; 15 16describe("SelectTool", () => { 17 let tool: SelectTool; 18 let initialState: EditorState; 19 let page: PageRecord; 20 let shape1: ShapeRecord; 21 let shape2: ShapeRecord; 22 let shape3: ShapeRecord; 23 24 beforeEach(() => { 25 tool = new SelectTool(); 26 page = PageRecord.create("Test Page"); 27 shape1 = ShapeRecord.createRect(page.id, 0, 0, { w: 100, h: 100, fill: "#ff0000", stroke: "#000000", radius: 0 }); 28 shape2 = ShapeRecord.createRect(page.id, 200, 0, { w: 100, h: 100, fill: "#00ff00", stroke: "#000000", radius: 0 }); 29 shape3 = ShapeRecord.createEllipse(page.id, 0, 200, { w: 80, h: 80, fill: "#0000ff", stroke: "#000000" }); 30 31 page.shapeIds = [shape1.id, shape2.id, shape3.id]; 32 33 initialState = { 34 ...EditorState.create(), 35 doc: { 36 pages: { [page.id]: page }, 37 shapes: { [shape1.id]: shape1, [shape2.id]: shape2, [shape3.id]: shape3 }, 38 bindings: {}, 39 }, 40 ui: { currentPageId: page.id, selectionIds: [], toolId: "select" }, 41 }; 42 }); 43 44 describe("onEnter/onExit", () => { 45 it("should not modify state on enter", () => { 46 const result = tool.onEnter(initialState); 47 expect(result).toBe(initialState); 48 }); 49 50 it("should not modify state on exit", () => { 51 const result = tool.onExit(initialState); 52 expect(result).toBe(initialState); 53 }); 54 }); 55 56 describe("shape selection", () => { 57 it("should select shape when clicking on it", () => { 58 const action = Action.pointerDown( 59 { x: 50, y: 50 }, 60 { x: 50, y: 50 }, 61 0, 62 PointerButtons.create(true, false, false), 63 Modifiers.create(), 64 ); 65 66 const result = tool.onAction(initialState, action); 67 68 expect(result.ui.selectionIds).toEqual([shape1.id]); 69 }); 70 71 it("should replace selection when clicking on different shape", () => { 72 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 73 74 const action = Action.pointerDown( 75 { x: 250, y: 50 }, 76 { x: 250, y: 50 }, 77 0, 78 PointerButtons.create(true, false, false), 79 Modifiers.create(), 80 ); 81 82 const result = tool.onAction(state, action); 83 84 expect(result.ui.selectionIds).toEqual([shape2.id]); 85 }); 86 87 it("should keep selection when clicking on already selected shape", () => { 88 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 89 90 const action = Action.pointerDown( 91 { x: 50, y: 50 }, 92 { x: 50, y: 50 }, 93 0, 94 PointerButtons.create(true, false, false), 95 Modifiers.create(), 96 ); 97 98 const result = tool.onAction(state, action); 99 expect(result.ui.selectionIds).toEqual([shape1.id]); 100 }); 101 102 it("should clear selection when clicking on empty space", () => { 103 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 104 105 const action = Action.pointerDown( 106 { x: 500, y: 500 }, 107 { x: 500, y: 500 }, 108 0, 109 PointerButtons.create(true, false, false), 110 Modifiers.create(), 111 ); 112 113 const result = tool.onAction(state, action); 114 expect(result.ui.selectionIds).toEqual([]); 115 }); 116 }); 117 118 describe("shift-click selection", () => { 119 it("should add unselected shape to selection when shift-clicking", () => { 120 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 121 122 const action = Action.pointerDown( 123 { x: 250, y: 50 }, 124 { x: 250, y: 50 }, 125 0, 126 PointerButtons.create(true, false, false), 127 Modifiers.create(false, true, false, false), 128 ); 129 130 const result = tool.onAction(state, action); 131 132 expect(result.ui.selectionIds).toEqual([shape1.id, shape2.id]); 133 }); 134 135 it("should remove selected shape from selection when shift-clicking", () => { 136 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 137 138 const action = Action.pointerDown( 139 { x: 50, y: 50 }, 140 { x: 50, y: 50 }, 141 0, 142 PointerButtons.create(true, false, false), 143 Modifiers.create(false, true, false, false), 144 ); 145 146 const result = tool.onAction(state, action); 147 148 expect(result.ui.selectionIds).toEqual([shape2.id]); 149 }); 150 }); 151 152 describe("dragging shapes", () => { 153 it("should move selected shape by exact delta", () => { 154 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 155 156 let result = tool.onAction( 157 state, 158 Action.pointerDown( 159 { x: 50, y: 50 }, 160 { x: 50, y: 50 }, 161 0, 162 PointerButtons.create(true, false, false), 163 Modifiers.create(), 164 ), 165 ); 166 167 result = tool.onAction( 168 result, 169 Action.pointerMove( 170 { x: 150, y: 100 }, 171 { x: 150, y: 100 }, 172 PointerButtons.create(true, false, false), 173 Modifiers.create(), 174 ), 175 ); 176 177 const movedShape = result.doc.shapes[shape1.id]; 178 expect(movedShape.x).toBe(100); 179 expect(movedShape.y).toBe(50); 180 }); 181 182 it("should move multiple selected shapes together", () => { 183 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 184 185 let result = tool.onAction( 186 state, 187 Action.pointerDown( 188 { x: 50, y: 50 }, 189 { x: 50, y: 50 }, 190 0, 191 PointerButtons.create(true, false, false), 192 Modifiers.create(), 193 ), 194 ); 195 196 result = tool.onAction( 197 result, 198 Action.pointerMove( 199 { x: 100, y: 150 }, 200 { x: 100, y: 150 }, 201 PointerButtons.create(true, false, false), 202 Modifiers.create(), 203 ), 204 ); 205 206 const movedShape1 = result.doc.shapes[shape1.id]; 207 const movedShape2 = result.doc.shapes[shape2.id]; 208 209 expect(movedShape1.x).toBe(50); 210 expect(movedShape1.y).toBe(100); 211 expect(movedShape2.x).toBe(250); 212 expect(movedShape2.y).toBe(100); 213 }); 214 215 it("should reset drag state on pointer up", () => { 216 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } }; 217 218 let result = tool.onAction( 219 state, 220 Action.pointerDown( 221 { x: 50, y: 50 }, 222 { x: 50, y: 50 }, 223 0, 224 PointerButtons.create(true, false, false), 225 Modifiers.create(), 226 ), 227 ); 228 229 result = tool.onAction( 230 result, 231 Action.pointerMove( 232 { x: 100, y: 100 }, 233 { x: 100, y: 100 }, 234 PointerButtons.create(true, false, false), 235 Modifiers.create(), 236 ), 237 ); 238 239 result = tool.onAction( 240 result, 241 Action.pointerUp( 242 { x: 100, y: 100 }, 243 { x: 100, y: 100 }, 244 0, 245 PointerButtons.create(false, false, false), 246 Modifiers.create(), 247 ), 248 ); 249 250 const movedShape = result.doc.shapes[shape1.id]; 251 expect(movedShape.x).toBe(50); 252 expect(movedShape.y).toBe(50); 253 }); 254 }); 255 256 describe("marquee selection", () => { 257 it("should select shapes within marquee bounds", () => { 258 let result = tool.onAction( 259 initialState, 260 Action.pointerDown( 261 { x: -50, y: -50 }, 262 { x: -50, y: -50 }, 263 0, 264 PointerButtons.create(true, false, false), 265 Modifiers.create(), 266 ), 267 ); 268 269 result = tool.onAction( 270 result, 271 Action.pointerMove( 272 { x: 350, y: 150 }, 273 { x: 350, y: 150 }, 274 PointerButtons.create(true, false, false), 275 Modifiers.create(), 276 ), 277 ); 278 279 result = tool.onAction( 280 result, 281 Action.pointerUp( 282 { x: 350, y: 150 }, 283 { x: 350, y: 150 }, 284 0, 285 PointerButtons.create(false, false, false), 286 Modifiers.create(), 287 ), 288 ); 289 290 expect(result.ui.selectionIds).toContain(shape1.id); 291 expect(result.ui.selectionIds).toContain(shape2.id); 292 expect(result.ui.selectionIds).not.toContain(shape3.id); 293 }); 294 295 it("should select all shapes when marquee covers entire canvas", () => { 296 let result = tool.onAction( 297 initialState, 298 Action.pointerDown( 299 { x: -100, y: -100 }, 300 { x: -100, y: -100 }, 301 0, 302 PointerButtons.create(true, false, false), 303 Modifiers.create(), 304 ), 305 ); 306 307 result = tool.onAction( 308 result, 309 Action.pointerMove( 310 { x: 500, y: 500 }, 311 { x: 500, y: 500 }, 312 PointerButtons.create(true, false, false), 313 Modifiers.create(), 314 ), 315 ); 316 317 result = tool.onAction( 318 result, 319 Action.pointerUp( 320 { x: 500, y: 500 }, 321 { x: 500, y: 500 }, 322 0, 323 PointerButtons.create(false, false, false), 324 Modifiers.create(), 325 ), 326 ); 327 328 expect(result.ui.selectionIds).toContain(shape1.id); 329 expect(result.ui.selectionIds).toContain(shape2.id); 330 expect(result.ui.selectionIds).toContain(shape3.id); 331 }); 332 }); 333 334 describe("keyboard shortcuts", () => { 335 it("should clear selection on Escape", () => { 336 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 337 const result = tool.onAction(state, Action.keyDown("Escape", "Escape", Modifiers.create())); 338 expect(result.ui.selectionIds).toEqual([]); 339 }); 340 341 it.each([{ description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, { 342 description: "Backspace key removes selected shapes", 343 key: "Backspace", 344 code: "Backspace", 345 }])("should handle $description", ({ key, code }) => { 346 const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } }; 347 const result = tool.onAction(state, Action.keyDown(key, code, Modifiers.create())); 348 349 expect(result.doc.shapes[shape1.id]).toBeUndefined(); 350 expect(result.doc.shapes[shape2.id]).toBeUndefined(); 351 expect(result.doc.shapes[shape3.id]).toBeDefined(); 352 353 expect(result.ui.selectionIds).toEqual([]); 354 355 const updatedPage = result.doc.pages[page.id]; 356 expect(updatedPage.shapeIds).toEqual([shape3.id]); 357 }); 358 359 it("should do nothing when delete pressed with no selection", () => { 360 const result = tool.onAction(initialState, Action.keyDown("Delete", "Delete", Modifiers.create())); 361 362 expect(result.doc.shapes).toEqual(initialState.doc.shapes); 363 expect(result.ui.selectionIds).toEqual([]); 364 }); 365 }); 366 367 describe("edge cases", () => { 368 it("should handle clicking on overlapping shapes (topmost wins)", () => { 369 const overlappingState = { 370 ...initialState, 371 doc: { ...initialState.doc, shapes: { ...initialState.doc.shapes, [shape2.id]: { ...shape2, x: 50, y: 50 } } }, 372 }; 373 374 const action = Action.pointerDown( 375 { x: 75, y: 75 }, 376 { x: 75, y: 75 }, 377 0, 378 PointerButtons.create(true, false, false), 379 Modifiers.create(), 380 ); 381 382 const result = tool.onAction(overlappingState, action); 383 expect(result.ui.selectionIds).toEqual([shape2.id]); 384 }); 385 386 it("should ignore unrelated action types", () => { 387 const wheelAction = Action.wheel({ x: 100, y: 100 }, { x: 100, y: 100 }, -10, Modifiers.create()); 388 389 const result = tool.onAction(initialState, wheelAction); 390 391 expect(result).toBe(initialState); 392 }); 393 }); 394}); 395 396describe("RectTool", () => { 397 let tool: RectTool; 398 let initialState: EditorState; 399 let page: PageRecord; 400 401 beforeEach(() => { 402 tool = new RectTool(); 403 page = PageRecord.create("Test Page"); 404 405 initialState = { 406 ...EditorState.create(), 407 doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 408 ui: { currentPageId: page.id, selectionIds: [], toolId: "rect" }, 409 }; 410 }); 411 412 describe("shape creation", () => { 413 it("should create a rect shape on pointer down", () => { 414 const action = Action.pointerDown( 415 { x: 100, y: 100 }, 416 { x: 100, y: 100 }, 417 0, 418 PointerButtons.create(true, false, false), 419 Modifiers.create(), 420 ); 421 422 const result = tool.onAction(initialState, action); 423 424 const shapeIds = Object.keys(result.doc.shapes); 425 expect(shapeIds.length).toBe(1); 426 427 const shape = result.doc.shapes[shapeIds[0]]; 428 expect(shape.type).toBe("rect"); 429 expect(shape.x).toBe(100); 430 expect(shape.y).toBe(100); 431 expect((shape.props as RectProps).w).toBe(0); 432 expect((shape.props as RectProps).h).toBe(0); 433 expect(result.ui.selectionIds).toEqual([shape.id]); 434 }); 435 436 it("should update rect dimensions on pointer move", () => { 437 let result = tool.onAction( 438 initialState, 439 Action.pointerDown( 440 { x: 100, y: 100 }, 441 { x: 100, y: 100 }, 442 0, 443 PointerButtons.create(true, false, false), 444 Modifiers.create(), 445 ), 446 ); 447 448 result = tool.onAction( 449 result, 450 Action.pointerMove( 451 { x: 200, y: 150 }, 452 { x: 200, y: 150 }, 453 PointerButtons.create(true, false, false), 454 Modifiers.create(), 455 ), 456 ); 457 458 const shapeId = Object.keys(result.doc.shapes)[0]; 459 const shape = result.doc.shapes[shapeId]; 460 461 expect(shape.type).toBe("rect"); 462 expect(shape.x).toBe(100); 463 expect(shape.y).toBe(100); 464 expect((shape.props as RectProps).w).toBe(100); 465 expect((shape.props as RectProps).h).toBe(50); 466 }); 467 468 it("should handle negative dragging (drag up-left)", () => { 469 let result = tool.onAction( 470 initialState, 471 Action.pointerDown( 472 { x: 200, y: 200 }, 473 { x: 200, y: 200 }, 474 0, 475 PointerButtons.create(true, false, false), 476 Modifiers.create(), 477 ), 478 ); 479 480 result = tool.onAction( 481 result, 482 Action.pointerMove( 483 { x: 100, y: 100 }, 484 { x: 100, y: 100 }, 485 PointerButtons.create(true, false, false), 486 Modifiers.create(), 487 ), 488 ); 489 490 const shapeId = Object.keys(result.doc.shapes)[0]; 491 const shape = result.doc.shapes[shapeId]; 492 493 expect(shape.type).toBe("rect"); 494 expect(shape.x).toBe(100); 495 expect(shape.y).toBe(100); 496 expect((shape.props as RectProps).w).toBe(100); 497 expect((shape.props as RectProps).h).toBe(100); 498 }); 499 500 it("should remove shape if too small on pointer up", () => { 501 let result = tool.onAction( 502 initialState, 503 Action.pointerDown( 504 { x: 100, y: 100 }, 505 { x: 100, y: 100 }, 506 0, 507 PointerButtons.create(true, false, false), 508 Modifiers.create(), 509 ), 510 ); 511 512 result = tool.onAction( 513 result, 514 Action.pointerMove( 515 { x: 102, y: 102 }, 516 { x: 102, y: 102 }, 517 PointerButtons.create(true, false, false), 518 Modifiers.create(), 519 ), 520 ); 521 522 result = tool.onAction( 523 result, 524 Action.pointerUp( 525 { x: 102, y: 102 }, 526 { x: 102, y: 102 }, 527 0, 528 PointerButtons.create(false, false, false), 529 Modifiers.create(), 530 ), 531 ); 532 533 expect(Object.keys(result.doc.shapes).length).toBe(0); 534 expect(result.ui.selectionIds).toEqual([]); 535 }); 536 537 it("should keep shape if large enough on pointer up", () => { 538 let result = tool.onAction( 539 initialState, 540 Action.pointerDown( 541 { x: 100, y: 100 }, 542 { x: 100, y: 100 }, 543 0, 544 PointerButtons.create(true, false, false), 545 Modifiers.create(), 546 ), 547 ); 548 549 result = tool.onAction( 550 result, 551 Action.pointerMove( 552 { x: 200, y: 200 }, 553 { x: 200, y: 200 }, 554 PointerButtons.create(true, false, false), 555 Modifiers.create(), 556 ), 557 ); 558 559 result = tool.onAction( 560 result, 561 Action.pointerUp( 562 { x: 200, y: 200 }, 563 { x: 200, y: 200 }, 564 0, 565 PointerButtons.create(false, false, false), 566 Modifiers.create(), 567 ), 568 ); 569 570 expect(Object.keys(result.doc.shapes).length).toBe(1); 571 }); 572 573 it("should cancel shape creation on Escape", () => { 574 let result = tool.onAction( 575 initialState, 576 Action.pointerDown( 577 { x: 100, y: 100 }, 578 { x: 100, y: 100 }, 579 0, 580 PointerButtons.create(true, false, false), 581 Modifiers.create(), 582 ), 583 ); 584 585 result = tool.onAction(result, Action.keyDown("Escape", "Escape", Modifiers.create())); 586 587 expect(Object.keys(result.doc.shapes).length).toBe(0); 588 expect(result.ui.selectionIds).toEqual([]); 589 }); 590 591 it("should cleanup on tool exit", () => { 592 let result = tool.onAction( 593 initialState, 594 Action.pointerDown( 595 { x: 100, y: 100 }, 596 { x: 100, y: 100 }, 597 0, 598 PointerButtons.create(true, false, false), 599 Modifiers.create(), 600 ), 601 ); 602 603 result = tool.onExit(result); 604 605 expect(Object.keys(result.doc.shapes).length).toBe(0); 606 expect(result.ui.selectionIds).toEqual([]); 607 }); 608 }); 609}); 610 611describe("EllipseTool", () => { 612 let tool: EllipseTool; 613 let initialState: EditorState; 614 let page: PageRecord; 615 616 beforeEach(() => { 617 tool = new EllipseTool(); 618 page = PageRecord.create("Test Page"); 619 620 initialState = { 621 ...EditorState.create(), 622 doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 623 ui: { currentPageId: page.id, selectionIds: [], toolId: "ellipse" }, 624 }; 625 }); 626 627 it("should create an ellipse shape on pointer down", () => { 628 const result = tool.onAction( 629 initialState, 630 Action.pointerDown( 631 { x: 100, y: 100 }, 632 { x: 100, y: 100 }, 633 0, 634 PointerButtons.create(true, false, false), 635 Modifiers.create(), 636 ), 637 ); 638 639 const shapeIds = Object.keys(result.doc.shapes); 640 expect(shapeIds.length).toBe(1); 641 642 const shape = result.doc.shapes[shapeIds[0]]; 643 expect(shape.type).toBe("ellipse"); 644 expect(shape.x).toBe(100); 645 expect(shape.y).toBe(100); 646 }); 647 648 it("should update ellipse dimensions on pointer move", () => { 649 let result = tool.onAction( 650 initialState, 651 Action.pointerDown( 652 { x: 100, y: 100 }, 653 { x: 100, y: 100 }, 654 0, 655 PointerButtons.create(true, false, false), 656 Modifiers.create(), 657 ), 658 ); 659 660 result = tool.onAction( 661 result, 662 Action.pointerMove( 663 { x: 250, y: 200 }, 664 { x: 250, y: 200 }, 665 PointerButtons.create(true, false, false), 666 Modifiers.create(), 667 ), 668 ); 669 670 const shapeId = Object.keys(result.doc.shapes)[0]; 671 const shape = result.doc.shapes[shapeId]; 672 673 expect(shape.type).toBe("ellipse"); 674 expect((shape.props as EllipseProps).w).toBe(150); 675 expect((shape.props as EllipseProps).h).toBe(100); 676 }); 677 678 it("should remove ellipse if too small on pointer up", () => { 679 let result = tool.onAction( 680 initialState, 681 Action.pointerDown( 682 { x: 100, y: 100 }, 683 { x: 100, y: 100 }, 684 0, 685 PointerButtons.create(true, false, false), 686 Modifiers.create(), 687 ), 688 ); 689 690 result = tool.onAction( 691 result, 692 Action.pointerMove( 693 { x: 103, y: 103 }, 694 { x: 103, y: 103 }, 695 PointerButtons.create(true, false, false), 696 Modifiers.create(), 697 ), 698 ); 699 700 result = tool.onAction( 701 result, 702 Action.pointerUp( 703 { x: 103, y: 103 }, 704 { x: 103, y: 103 }, 705 0, 706 PointerButtons.create(false, false, false), 707 Modifiers.create(), 708 ), 709 ); 710 711 expect(Object.keys(result.doc.shapes).length).toBe(0); 712 }); 713}); 714 715describe("LineTool", () => { 716 let tool: LineTool; 717 let initialState: EditorState; 718 let page: PageRecord; 719 720 beforeEach(() => { 721 tool = new LineTool(); 722 page = PageRecord.create("Test Page"); 723 724 initialState = { 725 ...EditorState.create(), 726 doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 727 ui: { currentPageId: page.id, selectionIds: [], toolId: "line" }, 728 }; 729 }); 730 731 it("should create a line shape on pointer down", () => { 732 const result = tool.onAction( 733 initialState, 734 Action.pointerDown( 735 { x: 100, y: 100 }, 736 { x: 100, y: 100 }, 737 0, 738 PointerButtons.create(true, false, false), 739 Modifiers.create(), 740 ), 741 ); 742 743 const shapeIds = Object.keys(result.doc.shapes); 744 expect(shapeIds.length).toBe(1); 745 746 const shape = result.doc.shapes[shapeIds[0]]; 747 expect(shape.type).toBe("line"); 748 expect(shape.x).toBe(100); 749 expect(shape.y).toBe(100); 750 expect((shape.props as LineProps).a).toEqual({ x: 0, y: 0 }); 751 expect((shape.props as LineProps).b).toEqual({ x: 0, y: 0 }); 752 }); 753 754 it("should update line endpoint on pointer move", () => { 755 let result = tool.onAction( 756 initialState, 757 Action.pointerDown( 758 { x: 100, y: 100 }, 759 { x: 100, y: 100 }, 760 0, 761 PointerButtons.create(true, false, false), 762 Modifiers.create(), 763 ), 764 ); 765 766 result = tool.onAction( 767 result, 768 Action.pointerMove( 769 { x: 200, y: 150 }, 770 { x: 200, y: 150 }, 771 PointerButtons.create(true, false, false), 772 Modifiers.create(), 773 ), 774 ); 775 776 const shapeId = Object.keys(result.doc.shapes)[0]; 777 const shape = result.doc.shapes[shapeId]; 778 779 expect(shape.type).toBe("line"); 780 expect((shape.props as LineProps).b).toEqual({ x: 100, y: 50 }); 781 }); 782 783 it("should remove line if too short on pointer up", () => { 784 let result = tool.onAction( 785 initialState, 786 Action.pointerDown( 787 { x: 100, y: 100 }, 788 { x: 100, y: 100 }, 789 0, 790 PointerButtons.create(true, false, false), 791 Modifiers.create(), 792 ), 793 ); 794 795 result = tool.onAction( 796 result, 797 Action.pointerMove( 798 { x: 102, y: 102 }, 799 { x: 102, y: 102 }, 800 PointerButtons.create(true, false, false), 801 Modifiers.create(), 802 ), 803 ); 804 805 result = tool.onAction( 806 result, 807 Action.pointerUp( 808 { x: 102, y: 102 }, 809 { x: 102, y: 102 }, 810 0, 811 PointerButtons.create(false, false, false), 812 Modifiers.create(), 813 ), 814 ); 815 816 expect(Object.keys(result.doc.shapes).length).toBe(0); 817 }); 818 819 it("should keep line if long enough on pointer up", () => { 820 let result = tool.onAction( 821 initialState, 822 Action.pointerDown( 823 { x: 100, y: 100 }, 824 { x: 100, y: 100 }, 825 0, 826 PointerButtons.create(true, false, false), 827 Modifiers.create(), 828 ), 829 ); 830 831 result = tool.onAction( 832 result, 833 Action.pointerMove( 834 { x: 200, y: 200 }, 835 { x: 200, y: 200 }, 836 PointerButtons.create(true, false, false), 837 Modifiers.create(), 838 ), 839 ); 840 841 result = tool.onAction( 842 result, 843 Action.pointerUp( 844 { x: 200, y: 200 }, 845 { x: 200, y: 200 }, 846 0, 847 PointerButtons.create(false, false, false), 848 Modifiers.create(), 849 ), 850 ); 851 852 expect(Object.keys(result.doc.shapes).length).toBe(1); 853 }); 854}); 855 856describe("ArrowTool", () => { 857 let tool: ArrowTool; 858 let initialState: EditorState; 859 let page: PageRecord; 860 861 beforeEach(() => { 862 tool = new ArrowTool(); 863 page = PageRecord.create("Test Page"); 864 865 initialState = { 866 ...EditorState.create(), 867 doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 868 ui: { currentPageId: page.id, selectionIds: [], toolId: "arrow" }, 869 }; 870 }); 871 872 it("should create an arrow shape on pointer down", () => { 873 const result = tool.onAction( 874 initialState, 875 Action.pointerDown( 876 { x: 100, y: 100 }, 877 { x: 100, y: 100 }, 878 0, 879 PointerButtons.create(true, false, false), 880 Modifiers.create(), 881 ), 882 ); 883 884 const shapeIds = Object.keys(result.doc.shapes); 885 expect(shapeIds.length).toBe(1); 886 887 const shape = result.doc.shapes[shapeIds[0]]; 888 expect(shape.type).toBe("arrow"); 889 expect(shape.x).toBe(100); 890 expect(shape.y).toBe(100); 891 }); 892 893 it("should update arrow endpoint on pointer move", () => { 894 let result = tool.onAction( 895 initialState, 896 Action.pointerDown( 897 { x: 100, y: 100 }, 898 { x: 100, y: 100 }, 899 0, 900 PointerButtons.create(true, false, false), 901 Modifiers.create(), 902 ), 903 ); 904 905 result = tool.onAction( 906 result, 907 Action.pointerMove( 908 { x: 300, y: 200 }, 909 { x: 300, y: 200 }, 910 PointerButtons.create(true, false, false), 911 Modifiers.create(), 912 ), 913 ); 914 915 const shapeId = Object.keys(result.doc.shapes)[0]; 916 const shape = result.doc.shapes[shapeId]; 917 918 expect(shape.type).toBe("arrow"); 919 expect((shape.props as ArrowProps).points[1]).toEqual({ x: 200, y: 100 }); 920 }); 921 922 it("should remove arrow if too short on pointer up", () => { 923 let result = tool.onAction( 924 initialState, 925 Action.pointerDown( 926 { x: 100, y: 100 }, 927 { x: 100, y: 100 }, 928 0, 929 PointerButtons.create(true, false, false), 930 Modifiers.create(), 931 ), 932 ); 933 934 result = tool.onAction( 935 result, 936 Action.pointerMove( 937 { x: 101, y: 101 }, 938 { x: 101, y: 101 }, 939 PointerButtons.create(true, false, false), 940 Modifiers.create(), 941 ), 942 ); 943 944 result = tool.onAction( 945 result, 946 Action.pointerUp( 947 { x: 101, y: 101 }, 948 { x: 101, y: 101 }, 949 0, 950 PointerButtons.create(false, false, false), 951 Modifiers.create(), 952 ), 953 ); 954 955 expect(Object.keys(result.doc.shapes).length).toBe(0); 956 }); 957}); 958 959describe("TextTool", () => { 960 let tool: TextTool; 961 let initialState: EditorState; 962 let page: PageRecord; 963 964 beforeEach(() => { 965 tool = new TextTool(); 966 page = PageRecord.create("Test Page"); 967 968 initialState = { 969 ...EditorState.create(), 970 doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, 971 ui: { currentPageId: page.id, selectionIds: [], toolId: "text" }, 972 }; 973 }); 974 975 it("should create a text shape on pointer down", () => { 976 const result = tool.onAction( 977 initialState, 978 Action.pointerDown( 979 { x: 150, y: 200 }, 980 { x: 150, y: 200 }, 981 0, 982 PointerButtons.create(true, false, false), 983 Modifiers.create(), 984 ), 985 ); 986 987 const shapeIds = Object.keys(result.doc.shapes); 988 expect(shapeIds.length).toBe(1); 989 990 const shape = result.doc.shapes[shapeIds[0]]; 991 expect(shape.type).toBe("text"); 992 expect(shape.x).toBe(150); 993 expect(shape.y).toBe(200); 994 expect((shape.props as TextProps).text).toBe("Text"); 995 expect((shape.props as TextProps).fontSize).toBe(16); 996 expect(result.ui.selectionIds).toEqual([shape.id]); 997 }); 998 999 it("should create new text shape on each click", () => { 1000 let result = tool.onAction( 1001 initialState, 1002 Action.pointerDown( 1003 { x: 100, y: 100 }, 1004 { x: 100, y: 100 }, 1005 0, 1006 PointerButtons.create(true, false, false), 1007 Modifiers.create(), 1008 ), 1009 ); 1010 1011 result = tool.onAction( 1012 result, 1013 Action.pointerDown( 1014 { x: 200, y: 200 }, 1015 { x: 200, y: 200 }, 1016 0, 1017 PointerButtons.create(true, false, false), 1018 Modifiers.create(), 1019 ), 1020 ); 1021 1022 expect(Object.keys(result.doc.shapes).length).toBe(2); 1023 }); 1024 1025 it("should not respond to pointer move or up", () => { 1026 let result = tool.onAction( 1027 initialState, 1028 Action.pointerDown( 1029 { x: 100, y: 100 }, 1030 { x: 100, y: 100 }, 1031 0, 1032 PointerButtons.create(true, false, false), 1033 Modifiers.create(), 1034 ), 1035 ); 1036 1037 const beforeMove = result; 1038 const shapeCountBefore = Object.keys(result.doc.shapes).length; 1039 1040 result = tool.onAction( 1041 result, 1042 Action.pointerMove( 1043 { x: 200, y: 200 }, 1044 { x: 200, y: 200 }, 1045 PointerButtons.create(true, false, false), 1046 Modifiers.create(), 1047 ), 1048 ); 1049 1050 expect(result).toBe(beforeMove); 1051 expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore); 1052 1053 result = tool.onAction( 1054 result, 1055 Action.pointerUp( 1056 { x: 200, y: 200 }, 1057 { x: 200, y: 200 }, 1058 0, 1059 PointerButtons.create(false, false, false), 1060 Modifiers.create(), 1061 ), 1062 ); 1063 1064 expect(result).toBe(beforeMove); 1065 expect(Object.keys(result.doc.shapes).length).toBe(shapeCountBefore); 1066 }); 1067}); 1068 1069describe("Arrow Bindings", () => { 1070 let tool: ArrowTool; 1071 let initialState: EditorState; 1072 let page: PageRecord; 1073 let targetShape: ShapeRecord; 1074 1075 beforeEach(() => { 1076 tool = new ArrowTool(); 1077 page = PageRecord.create("Test Page"); 1078 1079 targetShape = ShapeRecord.createRect(page.id, 100, 100, { 1080 w: 100, 1081 h: 100, 1082 fill: "#ff0000", 1083 stroke: "#000000", 1084 radius: 0, 1085 }); 1086 1087 page.shapeIds = [targetShape.id]; 1088 1089 initialState = { 1090 ...EditorState.create(), 1091 doc: { pages: { [page.id]: page }, shapes: { [targetShape.id]: targetShape }, bindings: {} }, 1092 ui: { currentPageId: page.id, selectionIds: [], toolId: "arrow" }, 1093 }; 1094 }); 1095 1096 it("should create binding when arrow start hits a shape", () => { 1097 let result = tool.onAction( 1098 initialState, 1099 Action.pointerDown( 1100 { x: 150, y: 150 }, 1101 { x: 150, y: 150 }, 1102 0, 1103 PointerButtons.create(true, false, false), 1104 Modifiers.create(), 1105 ), 1106 ); 1107 1108 result = tool.onAction( 1109 result, 1110 Action.pointerMove( 1111 { x: 300, y: 300 }, 1112 { x: 300, y: 300 }, 1113 PointerButtons.create(true, false, false), 1114 Modifiers.create(), 1115 ), 1116 ); 1117 1118 result = tool.onAction( 1119 result, 1120 Action.pointerUp( 1121 { x: 300, y: 300 }, 1122 { x: 300, y: 300 }, 1123 0, 1124 PointerButtons.create(false, false, false), 1125 Modifiers.create(), 1126 ), 1127 ); 1128 1129 const bindings = Object.values(result.doc.bindings); 1130 expect(bindings.length).toBe(1); 1131 expect(bindings[0].toShapeId).toBe(targetShape.id); 1132 expect(bindings[0].handle).toBe("start"); 1133 }); 1134 1135 it("should create binding when arrow end hits a shape", () => { 1136 let result = tool.onAction( 1137 initialState, 1138 Action.pointerDown( 1139 { x: 50, y: 50 }, 1140 { x: 50, y: 50 }, 1141 0, 1142 PointerButtons.create(true, false, false), 1143 Modifiers.create(), 1144 ), 1145 ); 1146 1147 result = tool.onAction( 1148 result, 1149 Action.pointerMove( 1150 { x: 150, y: 150 }, 1151 { x: 150, y: 150 }, 1152 PointerButtons.create(true, false, false), 1153 Modifiers.create(), 1154 ), 1155 ); 1156 1157 result = tool.onAction( 1158 result, 1159 Action.pointerUp( 1160 { x: 150, y: 150 }, 1161 { x: 150, y: 150 }, 1162 0, 1163 PointerButtons.create(false, false, false), 1164 Modifiers.create(), 1165 ), 1166 ); 1167 1168 const bindings = Object.values(result.doc.bindings); 1169 expect(bindings.length).toBe(1); 1170 expect(bindings[0].toShapeId).toBe(targetShape.id); 1171 expect(bindings[0].handle).toBe("end"); 1172 }); 1173 1174 it("should create bindings for both ends when both hit shapes", () => { 1175 const targetShape2 = ShapeRecord.createRect(page.id, 300, 300, { 1176 w: 100, 1177 h: 100, 1178 fill: "#00ff00", 1179 stroke: "#000000", 1180 radius: 0, 1181 }); 1182 1183 const stateWithTwoTargets = { 1184 ...initialState, 1185 doc: { 1186 ...initialState.doc, 1187 shapes: { ...initialState.doc.shapes, [targetShape2.id]: targetShape2 }, 1188 pages: { [page.id]: { ...page, shapeIds: [targetShape.id, targetShape2.id] } }, 1189 }, 1190 }; 1191 1192 let result = tool.onAction( 1193 stateWithTwoTargets, 1194 Action.pointerDown( 1195 { x: 150, y: 150 }, 1196 { x: 150, y: 150 }, 1197 0, 1198 PointerButtons.create(true, false, false), 1199 Modifiers.create(), 1200 ), 1201 ); 1202 1203 result = tool.onAction( 1204 result, 1205 Action.pointerMove( 1206 { x: 350, y: 350 }, 1207 { x: 350, y: 350 }, 1208 PointerButtons.create(true, false, false), 1209 Modifiers.create(), 1210 ), 1211 ); 1212 1213 result = tool.onAction( 1214 result, 1215 Action.pointerUp( 1216 { x: 350, y: 350 }, 1217 { x: 350, y: 350 }, 1218 0, 1219 PointerButtons.create(false, false, false), 1220 Modifiers.create(), 1221 ), 1222 ); 1223 1224 const bindings = Object.values(result.doc.bindings); 1225 expect(bindings.length).toBe(2); 1226 1227 const startBinding = bindings.find((b) => b.handle === "start"); 1228 const endBinding = bindings.find((b) => b.handle === "end"); 1229 1230 expect(startBinding).toBeDefined(); 1231 expect(startBinding?.toShapeId).toBe(targetShape.id); 1232 1233 expect(endBinding).toBeDefined(); 1234 expect(endBinding?.toShapeId).toBe(targetShape2.id); 1235 }); 1236 1237 it("should not create binding when arrow does not hit any shape", () => { 1238 let result = tool.onAction( 1239 initialState, 1240 Action.pointerDown( 1241 { x: 50, y: 50 }, 1242 { x: 50, y: 50 }, 1243 0, 1244 PointerButtons.create(true, false, false), 1245 Modifiers.create(), 1246 ), 1247 ); 1248 1249 result = tool.onAction( 1250 result, 1251 Action.pointerMove( 1252 { x: 80, y: 80 }, 1253 { x: 80, y: 80 }, 1254 PointerButtons.create(true, false, false), 1255 Modifiers.create(), 1256 ), 1257 ); 1258 1259 result = tool.onAction( 1260 result, 1261 Action.pointerUp( 1262 { x: 80, y: 80 }, 1263 { x: 80, y: 80 }, 1264 0, 1265 PointerButtons.create(false, false, false), 1266 Modifiers.create(), 1267 ), 1268 ); 1269 1270 const bindings = Object.values(result.doc.bindings); 1271 expect(bindings.length).toBe(0); 1272 }); 1273});