A world-class math input for the web
1import { expect, test, describe } from "vitest";
2import { EditorState } from "./editorState";
3import { Strand } from "./strand";
4// import { FractionToken, RadicalToken } from "@caret-js/math";
5import { Cursor } from "./cursor";
6import { CharToken } from "./tokens/char";
7import { Token } from "./token";
8import { VerticalFlow } from "./flows/vertical";
9import { HorizontalFlow } from "./flows/horizontal";
10import { t, VNode } from "./vdom";
11import { SelectionRange } from "./selectionRange";
12import { Transaction } from "./transaction";
13
14// Define custom token types here instead of using @caret-js/math for testing
15export class FractionToken extends Token {
16 flow = new VerticalFlow();
17 numerator: Strand;
18 denominator: Strand;
19
20 constructor(numerator: Strand, denominator: Strand) {
21 super();
22 this.numerator = numerator;
23 this.denominator = denominator;
24 numerator.parent = this;
25 denominator.parent = this;
26 }
27
28 get children(): readonly Strand[] {
29 return [this.numerator, this.denominator];
30 }
31
32 mapChildren(fn: (child: Strand, index: number) => Strand): FractionToken {
33 const newNumerator = fn(this.numerator, 0);
34 const newDenominator = fn(this.denominator, 1);
35 return new FractionToken(newNumerator, newDenominator);
36 }
37
38 renderToDebugText = () => "";
39 renderToDebugHTML = () => t("");
40 renderToDebugMathML = () => t("");
41}
42
43export class RadicalToken extends Token {
44 flow = new HorizontalFlow();
45 index: Strand | null;
46 radicand: Strand;
47
48 constructor(index: Strand | null, radicand: Strand) {
49 super();
50 this.index = index;
51 this.radicand = radicand;
52
53 if (index) index.parent = this;
54 radicand.parent = this;
55 }
56
57 get children(): readonly Strand[] {
58 return this.index ? [this.index, this.radicand] : [this.radicand];
59 }
60
61 mapChildren(fn: (child: Strand, index: number) => Strand): RadicalToken {
62 const newIndex = this.index ? fn(this.index, 0) : null;
63 const newRadicand = fn(this.radicand, this.index ? 1 : 0);
64 return new RadicalToken(newIndex, newRadicand);
65 }
66
67 renderToDebugText = () => "";
68 renderToDebugHTML = () => t("");
69 renderToDebugMathML = () => t("");
70}
71
72describe("Get Token Path", () => {
73 test("Find top-level token", () => {
74 const x = new CharToken("x");
75 const editorState = new EditorState(new Strand([x, new CharToken("y")]));
76
77 const path = editorState.content.findTokenPath(x);
78 expect(path).toEqual({ strandPath: [], tokenIndex: 0 });
79 });
80
81 test("Find deeply nested token", () => {
82 const three = new CharToken("3");
83 const editorState = new EditorState(
84 new Strand([
85 new CharToken("z"),
86 new CharToken("="),
87 new FractionToken(
88 new Strand([
89 new RadicalToken(
90 new Strand([three]),
91 new Strand([new CharToken("x")])
92 ),
93 ]),
94 new Strand([new CharToken("y")])
95 ),
96 ])
97 );
98
99 const path = editorState.content.findTokenPath(three);
100 expect(path).toEqual({
101 strandPath: [
102 { tokenIndex: 2, childIndex: 0 },
103 { tokenIndex: 0, childIndex: 0 },
104 ],
105 tokenIndex: 0,
106 });
107 });
108
109 test("Return null for non-existent node", () => {
110 const editorState = new EditorState(new Strand([new CharToken("x")]));
111 const nonExistentNode = new CharToken("y");
112
113 const path = editorState.content.findTokenPath(nonExistentNode);
114 expect(path).toBeNull();
115 });
116});
117
118describe("Get Node At Path", () => {
119 test("Get top-level node", () => {
120 const x = new CharToken("x");
121 const y = new CharToken("y");
122 const editorState = new EditorState(new Strand([x, y]));
123
124 const node = editorState.content.getToken({
125 strandPath: [],
126 tokenIndex: 1,
127 });
128 expect(node).toBe(y);
129 });
130
131 test("Get deeply nested node", () => {
132 const three = new CharToken("3");
133 const editorState = new EditorState(
134 new Strand([
135 new CharToken("z"),
136 new CharToken("="),
137 new FractionToken(
138 new Strand([
139 new RadicalToken(
140 new Strand([three]),
141 new Strand([new CharToken("x")])
142 ),
143 ]),
144 new Strand([new CharToken("y")])
145 ),
146 ])
147 );
148
149 const node = editorState.content.getToken({
150 strandPath: [
151 { tokenIndex: 2, childIndex: 0 },
152 { tokenIndex: 0, childIndex: 0 },
153 ],
154 tokenIndex: 0,
155 });
156 expect(node).toBe(three);
157 });
158
159 test("Return null for invalid node path", () => {
160 const editorState = new EditorState(new Strand([new CharToken("x")]));
161
162 const validNode = editorState.content.getToken({
163 strandPath: [],
164 tokenIndex: 0,
165 });
166 expect(validNode).not.toBeNull();
167
168 const invalidNode = editorState.content.getToken({
169 strandPath: [{ tokenIndex: 5, childIndex: 0 }],
170 tokenIndex: 0,
171 });
172 expect(invalidNode).toBeNull();
173 });
174
175 test("Return null for invalid strand path", () => {
176 const editorState = new EditorState(
177 new Strand([
178 new FractionToken(
179 new Strand([new CharToken("x")]),
180 new Strand([new CharToken("y")])
181 ),
182 ])
183 );
184
185 const validNode = editorState.content.getToken({
186 strandPath: [{ tokenIndex: 0, childIndex: 1 }],
187 tokenIndex: 0,
188 });
189 expect(validNode).not.toBeNull();
190
191 const invalidNode = editorState.content.getToken({
192 strandPath: [{ tokenIndex: 0, childIndex: 2 }],
193 tokenIndex: 0,
194 });
195 expect(invalidNode).toBeNull();
196 });
197});
198
199test("Node Path Finding and Following Are Inverses", () => {
200 const z = new CharToken("z");
201 const equals = new CharToken("=");
202 const x = new CharToken("x");
203 const three = new CharToken("3");
204 const radical = new RadicalToken(new Strand([three]), new Strand([x]));
205 const y = new CharToken("y");
206 const fraction = new FractionToken(new Strand([radical]), new Strand([y]));
207 const editorState = new EditorState(new Strand([z, equals, fraction]));
208
209 const nodes = [z, equals, fraction, radical, x, three, y];
210
211 for (const node of nodes) {
212 const path = editorState.content.findTokenPath(node);
213 expect(path).not.toBeNull();
214 if (path !== null) {
215 const foundNode = editorState.content.getToken(path);
216 expect(foundNode).toBe(node);
217 }
218 }
219});
220
221describe("Get Strand Path", () => {
222 test("Find top-level strand", () => {
223 const strand = new Strand([new CharToken("x")]);
224 const editorState = new EditorState(strand);
225
226 const path = editorState.content.findStrandPath(strand);
227 expect(path).toEqual([]);
228 });
229
230 test("Find deeply nested strand", () => {
231 const innerStrand = new Strand([new CharToken("x")]);
232 const editorState = new EditorState(
233 new Strand([
234 new CharToken("z"),
235 new CharToken("="),
236 new FractionToken(
237 new Strand([
238 new RadicalToken(new Strand([new CharToken("3")]), innerStrand),
239 ]),
240 new Strand([new CharToken("y")])
241 ),
242 ])
243 );
244
245 const path = editorState.content.findStrandPath(innerStrand);
246 expect(path).toEqual([
247 { tokenIndex: 2, childIndex: 0 },
248 { tokenIndex: 0, childIndex: 1 },
249 ]);
250 });
251
252 test("Return null for non-existent strand", () => {
253 const editorState = new EditorState(new Strand([new CharToken("x")]));
254 const nonExistentStrand = new Strand([new CharToken("y")]);
255
256 const path = editorState.content.findStrandPath(nonExistentStrand);
257 expect(path).toBeNull();
258 });
259});
260
261describe("Get Strand At Path", () => {
262 test("Get top-level strand", () => {
263 const strand = new Strand([new CharToken("x")]);
264 const editorState = new EditorState(strand);
265
266 const foundStrand = editorState.content.getStrand([]);
267 expect(foundStrand).toBe(strand);
268 });
269
270 test("Get deeply nested strand", () => {
271 const innerStrand = new Strand([new CharToken("x")]);
272 const editorState = new EditorState(
273 new Strand([
274 new CharToken("z"),
275 new CharToken("="),
276 new FractionToken(
277 new Strand([
278 new RadicalToken(new Strand([new CharToken("3")]), innerStrand),
279 ]),
280 new Strand([new CharToken("y")])
281 ),
282 ])
283 );
284
285 const foundStrand = editorState.content.getStrand([
286 { tokenIndex: 2, childIndex: 0 },
287 { tokenIndex: 0, childIndex: 1 },
288 ]);
289 expect(foundStrand).toBe(innerStrand);
290 });
291
292 test("Return null for invalid strand path (incorrect node index)", () => {
293 const editorState = new EditorState(
294 new Strand([
295 new FractionToken(
296 new Strand([new CharToken("x")]),
297 new Strand([new CharToken("y")])
298 ),
299 ])
300 );
301
302 const validStrand = editorState.content.getStrand([
303 { tokenIndex: 0, childIndex: 0 },
304 ]);
305 expect(validStrand).not.toBeNull();
306
307 const invalidStrand = editorState.content.getStrand([
308 { tokenIndex: 1, childIndex: 0 },
309 ]);
310 expect(invalidStrand).toBeNull();
311 });
312
313 test("Return null for invalid strand path (incorrect child index)", () => {
314 const editorState = new EditorState(
315 new Strand([
316 new FractionToken(
317 new Strand([new CharToken("x")]),
318 new Strand([new CharToken("y")])
319 ),
320 ])
321 );
322
323 const validStrand = editorState.content.getStrand([
324 { tokenIndex: 0, childIndex: 1 },
325 ]);
326 expect(validStrand).not.toBeNull();
327
328 const invalidStrand = editorState.content.getStrand([
329 { tokenIndex: 0, childIndex: 2 },
330 ]);
331 expect(invalidStrand).toBeNull();
332 });
333});
334
335test("Strand Path Finding and Following Are Inverses", () => {
336 const indexStrand = new Strand([new CharToken("3")]);
337 const radicandStrand = new Strand([new CharToken("x")]);
338 const numeratorStrand = new Strand([
339 new RadicalToken(indexStrand, radicandStrand),
340 ]);
341 const denominatorStrand = new Strand([new CharToken("y")]);
342 const topStrand = new Strand([
343 new CharToken("z"),
344 new CharToken("="),
345 new FractionToken(numeratorStrand, denominatorStrand),
346 ]);
347
348 const editorState = new EditorState(topStrand);
349
350 const strands = [
351 topStrand,
352 numeratorStrand,
353 denominatorStrand,
354 indexStrand,
355 radicandStrand,
356 ];
357
358 for (const strand of strands) {
359 const path = editorState.content.findStrandPath(strand);
360 expect(path).not.toBeNull();
361 if (path !== null) {
362 const foundStrand = editorState.content.getStrand(path);
363 expect(foundStrand).toBe(strand);
364 }
365 }
366});
367
368test("EditorState is set for all nodes and strands upon construction", () => {
369 const x = new CharToken("x");
370 const three = new CharToken("3");
371 const indexStrand = new Strand([three]);
372 const radicandStrand = new Strand([x]);
373 const radical = new RadicalToken(indexStrand, radicandStrand);
374 const numeratorStrand = new Strand([radical]);
375 const y = new CharToken("y");
376 const denominatorStrand = new Strand([y]);
377 const fraction = new FractionToken(numeratorStrand, denominatorStrand);
378 const z = new CharToken("z");
379 const equals = new CharToken("=");
380 const topStrand = new Strand([z, equals, fraction]);
381
382 const editorState = new EditorState(topStrand);
383
384 const tokens = [x, three, radical, y, fraction, z, equals];
385 const strands = [
386 indexStrand,
387 radicandStrand,
388 numeratorStrand,
389 denominatorStrand,
390 topStrand,
391 ];
392
393 for (const token of tokens) {
394 expect(token.editorState).toBe(editorState);
395 }
396
397 for (const strand of strands) {
398 expect(strand.editorState).toBe(editorState);
399 }
400});
401
402describe("Insert At Cursor", () => {
403 test("Inserts character in empty editor", () => {
404 let editorState = new EditorState(new Strand([]), new Cursor([], 0));
405 editorState = Transaction.insertAtSelection(editorState, [
406 new CharToken("x"),
407 ]).newState;
408
409 expect(editorState.content.tokens.length).toBe(1);
410 expect((editorState.content.tokens[0] as CharToken).char).toBe("x");
411 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
412 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
413 });
414
415 test("Inserts complex node at provided cursor", () => {
416 let editorState = new EditorState(
417 new Strand([new CharToken("a"), new CharToken("b")]),
418 new Cursor([], 0)
419 );
420
421 const fraction = new FractionToken(
422 new Strand([new CharToken("1")]),
423 new Strand([new CharToken("2")])
424 );
425
426 // editorState.insertAtCursor(fraction, new Cursor([], 1));
427 editorState = Transaction.insertAtSelection(
428 editorState,
429 [fraction],
430 new SelectionRange(new Cursor([], 1))
431 ).newState;
432
433 expect(editorState.content.tokens.length).toBe(3);
434 expect(editorState.content.tokens[1]).toBeInstanceOf(FractionToken);
435 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 0 }); // Cursor should not have moved
436 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 0 });
437 });
438
439 test("Inserts character in nested strand", () => {
440 // This test turns x/y into x/zy by inserting 'z' at the start of the denominator strand
441 // The cursor is positioned after the y in the denominator and must be adjusted after insertion
442
443 const denominatorStrand = new Strand([new CharToken("y")]);
444 const fraction = new FractionToken(
445 new Strand([new CharToken("x")]),
446 denominatorStrand
447 );
448 let editorState = new EditorState(
449 new Strand([fraction]),
450 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
451 );
452
453 editorState = Transaction.insertAtSelection(
454 editorState,
455 [new CharToken("z")],
456 new SelectionRange(new Cursor([{ tokenIndex: 0, childIndex: 1 }], 0))
457 ).newState;
458
459 const newDenominatorStrand = (
460 editorState.content.tokens[0] as FractionToken
461 ).denominator;
462
463 expect(newDenominatorStrand.tokens.length).toBe(2);
464 expect((newDenominatorStrand.tokens[0] as CharToken).char).toBe("z");
465 expect((newDenominatorStrand.tokens[1] as CharToken).char).toBe("y");
466 expect(editorState.selection.anchor).toEqual(
467 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 2)
468 ); // Cursor should have moved forward to account for the new character
469 expect(editorState.selection.head).toEqual(
470 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 2)
471 );
472 });
473});
474
475describe("Delete At Cursor", () => {
476 test("Deletes character before cursor in top-level strand", () => {
477 let editorState = new EditorState(
478 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
479 new Cursor([], 2)
480 );
481
482 editorState = Transaction.deleteAtSelection(
483 editorState,
484 "backward"
485 ).newState;
486
487 expect(editorState.content.tokens.length).toBe(2);
488 expect((editorState.content.tokens[0] as CharToken).char).toBe("a");
489 expect((editorState.content.tokens[1] as CharToken).char).toBe("c");
490 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
491 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
492 });
493
494 test("Deletes character before cursor in nested strand", () => {
495 const denominatorStrand = new Strand([
496 new CharToken("x"),
497 new CharToken("y"),
498 new CharToken("z"),
499 ]);
500 const fraction = new FractionToken(
501 new Strand([new CharToken("1")]),
502 denominatorStrand
503 );
504 let editorState = new EditorState(
505 new Strand([fraction]),
506 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 2)
507 );
508
509 editorState = Transaction.deleteAtSelection(
510 editorState,
511 "backward"
512 ).newState;
513
514 const newDenominatorStrand = (
515 editorState.content.tokens[0] as FractionToken
516 ).denominator;
517
518 expect(newDenominatorStrand.tokens.length).toBe(2);
519 expect((newDenominatorStrand.tokens[0] as CharToken).char).toBe("x");
520 expect((newDenominatorStrand.tokens[1] as CharToken).char).toBe("z");
521 expect(editorState.selection.anchor).toEqual(
522 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
523 );
524 expect(editorState.selection.head).toEqual(
525 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
526 );
527 });
528
529 test("No deletion when cursor at start of top-level strand", () => {
530 let editorState = new EditorState(
531 new Strand([new CharToken("a"), new CharToken("b")]),
532 new Cursor([], 0)
533 );
534
535 editorState = Transaction.deleteAtSelection(
536 editorState,
537 "backward"
538 ).newState;
539
540 expect(editorState.content.tokens.length).toBe(2);
541 expect((editorState.content.tokens[0] as CharToken).char).toBe("a");
542 expect((editorState.content.tokens[1] as CharToken).char).toBe("b");
543 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 0 });
544 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 0 });
545 });
546
547 test("Flattens token when cursor at start of nested strand", () => {
548 const fraction = new FractionToken(
549 new Strand([new CharToken("1")]),
550 new Strand([new CharToken("x"), new CharToken("y")])
551 );
552 let editorState = new EditorState(
553 new Strand([fraction]),
554 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 0)
555 );
556
557 editorState = Transaction.deleteAtSelection(
558 editorState,
559 "backward"
560 ).newState;
561
562 expect(editorState.content.tokens.length).toBe(3);
563 expect((editorState.content.tokens[0] as CharToken).char).toBe("1");
564 expect((editorState.content.tokens[1] as CharToken).char).toBe("x");
565 expect((editorState.content.tokens[2] as CharToken).char).toBe("y");
566 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
567 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
568 });
569
570 test("Deletes character after cursor in top-level strand", () => {
571 let editorState = new EditorState(
572 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
573 new Cursor([], 1)
574 );
575
576 editorState = Transaction.deleteAtSelection(
577 editorState,
578 "forward"
579 ).newState;
580
581 expect(editorState.content.tokens.length).toBe(2);
582 expect((editorState.content.tokens[0] as CharToken).char).toBe("a");
583 expect((editorState.content.tokens[1] as CharToken).char).toBe("c");
584 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
585 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
586 });
587
588 test("Deletes character after cursor in nested strand", () => {
589 const denominatorStrand = new Strand([
590 new CharToken("x"),
591 new CharToken("y"),
592 new CharToken("z"),
593 ]);
594 const fraction = new FractionToken(
595 new Strand([new CharToken("1")]),
596 denominatorStrand
597 );
598 let editorState = new EditorState(
599 new Strand([fraction]),
600 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
601 );
602
603 editorState = Transaction.deleteAtSelection(
604 editorState,
605 "forward"
606 ).newState;
607
608 const newDenominatorStrand = (
609 editorState.content.tokens[0] as FractionToken
610 ).denominator;
611
612 expect(newDenominatorStrand.tokens.length).toBe(2);
613 expect((newDenominatorStrand.tokens[0] as CharToken).char).toBe("x");
614 expect((newDenominatorStrand.tokens[1] as CharToken).char).toBe("z");
615 expect(editorState.selection.anchor).toEqual(
616 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
617 );
618 expect(editorState.selection.head).toEqual(
619 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
620 );
621 });
622
623 test("No deletion when cursor at end of top-level strand", () => {
624 let editorState = new EditorState(
625 new Strand([new CharToken("a"), new CharToken("b")]),
626 new Cursor([], 2)
627 );
628
629 editorState = Transaction.deleteAtSelection(
630 editorState,
631 "forward"
632 ).newState;
633
634 expect(editorState.content.tokens.length).toBe(2);
635 expect((editorState.content.tokens[0] as CharToken).char).toBe("a");
636 expect((editorState.content.tokens[1] as CharToken).char).toBe("b");
637 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 2 });
638 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 2 });
639 });
640
641 test("Flattens token when cursor at end of nested strand", () => {
642 const denominatorStrand = new Strand([
643 new CharToken("x"),
644 new CharToken("y"),
645 ]);
646 const fraction = new FractionToken(
647 new Strand([new CharToken("1")]),
648 denominatorStrand
649 );
650 let editorState = new EditorState(
651 new Strand([fraction]),
652 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 2)
653 );
654
655 editorState = Transaction.deleteAtSelection(
656 editorState,
657 "forward"
658 ).newState;
659
660 expect(editorState.content.tokens.length).toBe(3);
661 expect((editorState.content.tokens[0] as CharToken).char).toBe("1");
662 expect((editorState.content.tokens[1] as CharToken).char).toBe("x");
663 expect((editorState.content.tokens[2] as CharToken).char).toBe("y");
664 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 3 });
665 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 3 });
666 });
667
668 test("Deletes range in top-level strand", () => {
669 let editorState = new EditorState(
670 new Strand([
671 new CharToken("a"),
672 new CharToken("b"),
673 new CharToken("c"),
674 new CharToken("d"),
675 ]),
676 new SelectionRange(new Cursor([], 1), new Cursor([], 3))
677 );
678
679 editorState = Transaction.deleteAtSelection(editorState).newState;
680
681 expect(editorState.content.tokens.length).toBe(2);
682 expect((editorState.content.tokens[0] as CharToken).char).toBe("a");
683 expect((editorState.content.tokens[1] as CharToken).char).toBe("d");
684 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
685 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
686 });
687
688 test("Deletes range in nested strand", () => {
689 const denominatorStrand = new Strand([
690 new CharToken("w"),
691 new CharToken("x"),
692 new CharToken("y"),
693 new CharToken("z"),
694 ]);
695 const fraction = new FractionToken(
696 new Strand([new CharToken("1")]),
697 denominatorStrand
698 );
699 let editorState = new EditorState(
700 new Strand([fraction]),
701 new SelectionRange(
702 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1),
703 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 3)
704 )
705 );
706
707 editorState = Transaction.deleteAtSelection(editorState).newState;
708
709 const newDenominatorStrand = (
710 editorState.content.tokens[0] as FractionToken
711 ).denominator;
712
713 expect(newDenominatorStrand.tokens.length).toBe(2);
714 expect((newDenominatorStrand.tokens[0] as CharToken).char).toBe("w");
715 expect((newDenominatorStrand.tokens[1] as CharToken).char).toBe("z");
716 expect(editorState.selection.anchor).toEqual(
717 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
718 );
719 expect(editorState.selection.head).toEqual(
720 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
721 );
722 });
723
724 test("Deletes range across children of one token", () => {
725 const numeratorStrand = new Strand([
726 new CharToken("a"),
727 new CharToken("b"),
728 ]);
729 const denominatorStrand = new Strand([
730 new CharToken("x"),
731 new CharToken("y"),
732 ]);
733 let editorState = new EditorState(
734 new Strand([
735 new CharToken("y"),
736 new CharToken("="),
737 new FractionToken(numeratorStrand, denominatorStrand),
738 ]),
739 new SelectionRange(
740 new Cursor([{ tokenIndex: 2, childIndex: 0 }], 1),
741 new Cursor([{ tokenIndex: 2, childIndex: 1 }], 1)
742 )
743 );
744
745 editorState = Transaction.deleteAtSelection(editorState).newState;
746
747 expect(editorState.content.tokens.length).toBe(2);
748 expect((editorState.content.tokens[0] as CharToken).char).toBe("y");
749 expect((editorState.content.tokens[1] as CharToken).char).toBe("=");
750 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 2 });
751 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 2 });
752 });
753
754 test("Deletes range from top strand into nested strand", () => {
755 let editorState = new EditorState(
756 new Strand([
757 new CharToken("z"),
758 new CharToken("="),
759 new FractionToken(
760 new Strand([new CharToken("1")]),
761 new Strand([new CharToken("x"), new CharToken("y")])
762 ),
763 new CharToken("+"),
764 new CharToken("2"),
765 ]),
766 new SelectionRange(
767 new Cursor([], 1),
768 new Cursor([{ tokenIndex: 2, childIndex: 1 }], 1)
769 )
770 );
771
772 editorState = Transaction.deleteAtSelection(editorState).newState;
773
774 expect(editorState.content.tokens.length).toBe(3);
775 expect((editorState.content.tokens[0] as CharToken).char).toBe("z");
776 expect((editorState.content.tokens[1] as CharToken).char).toBe("+");
777 expect((editorState.content.tokens[2] as CharToken).char).toBe("2");
778 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
779 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
780 });
781});
782
783describe("Cursor Movement Left/Right", () => {
784 test("Move Cursor Right", () => {
785 let editorState = new EditorState(
786 new Strand([new CharToken("x")]),
787 new Cursor([], 0)
788 );
789
790 editorState = Transaction.moveCursor(editorState, "right").newState;
791
792 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
793 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
794 });
795
796 test("Move Cursor Right into Child", () => {
797 let editorState = new EditorState(
798 new Strand([
799 new FractionToken(
800 new Strand([new CharToken("x")]),
801 new Strand([new CharToken("y")])
802 ),
803 ]),
804 new Cursor([], 0)
805 );
806
807 editorState = Transaction.moveCursor(editorState, "right").newState;
808
809 expect(editorState.selection.anchor).toEqual({
810 strandPath: [{ tokenIndex: 0, childIndex: 0 }],
811 pos: 0,
812 });
813 expect(editorState.selection.head).toEqual({
814 strandPath: [{ tokenIndex: 0, childIndex: 0 }],
815 pos: 0,
816 });
817 });
818
819 test("Move Cursor Right into Sibling", () => {
820 let editorState = new EditorState(
821 new Strand([
822 new FractionToken(
823 new Strand([new CharToken("x")]),
824 new Strand([new CharToken("y")])
825 ),
826 new CharToken("+"),
827 ]),
828 new Cursor([{ tokenIndex: 0, childIndex: 0 }], 1)
829 );
830
831 editorState = Transaction.moveCursor(editorState, "right").newState;
832
833 expect(editorState.selection.anchor).toEqual({
834 strandPath: [{ tokenIndex: 0, childIndex: 1 }],
835 pos: 0,
836 });
837 expect(editorState.selection.head).toEqual({
838 strandPath: [{ tokenIndex: 0, childIndex: 1 }],
839 pos: 0,
840 });
841 });
842
843 test("Move Cursor Right out of Child", () => {
844 let editorState = new EditorState(
845 new Strand([
846 new FractionToken(
847 new Strand([new CharToken("x")]),
848 new Strand([new CharToken("y")])
849 ),
850 ]),
851 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 1)
852 );
853
854 editorState = Transaction.moveCursor(editorState, "right").newState;
855
856 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
857 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
858 });
859
860 test("Move Cursor Right at End of Editor", () => {
861 let editorState = new EditorState(
862 new Strand([new CharToken("x")]),
863 new Cursor([], 1)
864 );
865
866 editorState = Transaction.moveCursor(editorState, "right").newState;
867
868 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
869 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
870 });
871
872 test("Move Cursor Left", () => {
873 let editorState = new EditorState(
874 new Strand([new CharToken("x")]),
875 new Cursor([], 1)
876 );
877
878 editorState = Transaction.moveCursor(editorState, "left").newState;
879 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 0 });
880 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 0 });
881 });
882
883 test("Move Cursor Left into Child", () => {
884 let editorState = new EditorState(
885 new Strand([
886 new FractionToken(
887 new Strand([new CharToken("x")]),
888 new Strand([new CharToken("y")])
889 ),
890 ]),
891 new Cursor([], 1)
892 );
893
894 editorState = Transaction.moveCursor(editorState, "left").newState;
895
896 expect(editorState.selection.anchor).toEqual({
897 strandPath: [{ tokenIndex: 0, childIndex: 1 }],
898 pos: 1,
899 });
900 expect(editorState.selection.head).toEqual({
901 strandPath: [{ tokenIndex: 0, childIndex: 1 }],
902 pos: 1,
903 });
904 });
905
906 test("Move Cursor Left into Sibling", () => {
907 let editorState = new EditorState(
908 new Strand([
909 new FractionToken(
910 new Strand([new CharToken("x")]),
911 new Strand([new CharToken("y")])
912 ),
913 ]),
914 new Cursor([{ tokenIndex: 0, childIndex: 1 }], 0)
915 );
916
917 editorState = Transaction.moveCursor(editorState, "left").newState;
918
919 expect(editorState.selection.anchor).toEqual({
920 strandPath: [{ tokenIndex: 0, childIndex: 0 }],
921 pos: 1,
922 });
923 expect(editorState.selection.head).toEqual({
924 strandPath: [{ tokenIndex: 0, childIndex: 0 }],
925 pos: 1,
926 });
927 });
928
929 test("Move Cursor Left out of Child", () => {
930 let editorState = new EditorState(
931 new Strand([
932 new FractionToken(
933 new Strand([new CharToken("x")]),
934 new Strand([new CharToken("y")])
935 ),
936 ]),
937 new Cursor([{ tokenIndex: 0, childIndex: 0 }], 0)
938 );
939
940 editorState = Transaction.moveCursor(editorState, "left").newState;
941
942 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 0 });
943 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 0 });
944 });
945
946 test("Move Cursor Left at Start of Editor", () => {
947 let editorState = new EditorState(
948 new Strand([new CharToken("x")]),
949 new Cursor([], 0)
950 );
951
952 editorState = Transaction.moveCursor(editorState, "left").newState;
953
954 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 0 });
955 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 0 });
956 });
957
958 test("Collapse selection to right", () => {
959 let editorState = new EditorState(
960 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
961 new SelectionRange(new Cursor([], 0), new Cursor([], 2))
962 );
963
964 editorState = Transaction.moveCursor(editorState, "right").newState;
965
966 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 2 });
967 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 2 });
968 });
969
970 test("Collapse selection to right in reverse selection", () => {
971 let editorState = new EditorState(
972 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
973 new SelectionRange(new Cursor([], 2), new Cursor([], 0))
974 );
975
976 editorState = Transaction.moveCursor(editorState, "right").newState;
977
978 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 2 });
979 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 2 });
980 });
981
982 test("Collapse selection to left", () => {
983 let editorState = new EditorState(
984 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
985 new SelectionRange(new Cursor([], 1), new Cursor([], 3))
986 );
987
988 editorState = Transaction.moveCursor(editorState, "left").newState;
989
990 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
991 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
992 });
993
994 test("Collapse selection to left in reverse selection", () => {
995 let editorState = new EditorState(
996 new Strand([new CharToken("a"), new CharToken("b"), new CharToken("c")]),
997 new SelectionRange(new Cursor([], 3), new Cursor([], 1))
998 );
999
1000 editorState = Transaction.moveCursor(editorState, "left").newState;
1001
1002 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 1 });
1003 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 1 });
1004 });
1005
1006 test("Collapse selection to right in common ancestor strand", () => {
1007 let editorState = new EditorState(
1008 new Strand([
1009 new CharToken("y"),
1010 new CharToken("="),
1011 new FractionToken(
1012 new Strand([new CharToken("a"), new CharToken("b")]),
1013 new Strand([new CharToken("x"), new CharToken("y")])
1014 ),
1015 ]),
1016 new SelectionRange(
1017 new Cursor([], 1),
1018 new Cursor([{ tokenIndex: 2, childIndex: 1 }], 1)
1019 )
1020 );
1021
1022 editorState = Transaction.moveCursor(editorState, "right").newState;
1023
1024 expect(editorState.selection.anchor).toEqual({ strandPath: [], pos: 3 });
1025 expect(editorState.selection.head).toEqual({ strandPath: [], pos: 3 });
1026 });
1027});