web based infinite canvas
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});