A world-class math input for the web
1import { Cursor, RawCursor } from "./cursor";
2import { EditorState } from "./editorState";
3import { EmptyFlow } from "./flows/empty";
4import { StrandPath, strandPathsEqual, TokenPath } from "./path";
5import { SelectionRange } from "./selectionRange";
6import { Strand } from "./strand";
7import { Token } from "./token";
8import { h, t, VNode } from "./vdom";
9
10interface TokenSplice {
11 strandPath: StrandPath;
12 fromPos: number;
13 toPos: number;
14 newTokens: readonly Token[];
15}
16
17export class Transaction {
18 startState: EditorState;
19 changes: TokenSplice[];
20 newSelection: SelectionRange | null;
21
22 constructor(
23 startState: EditorState,
24 changes: TokenSplice[],
25 newSelection: SelectionRange | Cursor | null = null
26 ) {
27 this.startState = startState;
28 this.changes = changes;
29 this.newSelection =
30 newSelection instanceof Cursor
31 ? new SelectionRange(newSelection)
32 : newSelection;
33 }
34
35 get newState(): EditorState {
36 const getMatchingChanges = (strandPath: StrandPath) => {
37 return this.changes.filter((change) =>
38 strandPathsEqual(change.strandPath, strandPath)
39 );
40 };
41
42 // I want to apply changes depth-first, so I use a recursive function.
43 // The goal is to map over each strand, depth-first, returning a new strand with the changes applied.
44 const applyChangesToStrand = (
45 strand: Strand,
46 currentPath: StrandPath
47 ): Strand => {
48 let newTokens: Token[] = [];
49 let tokenIndexOffset = 0;
50
51 const matchingChanges = getMatchingChanges(currentPath);
52
53 let lastChangeEnd = 0;
54
55 for (const change of matchingChanges) {
56 // Add tokens before the change
57 newTokens.push(
58 ...strand.tokens
59 .slice(lastChangeEnd, change.fromPos)
60 .map((token) => token.clone())
61 );
62
63 // Add new tokens from the change
64 newTokens.push(...change.newTokens);
65
66 lastChangeEnd = change.toPos;
67 tokenIndexOffset +=
68 change.newTokens.length - (change.toPos - change.fromPos);
69 }
70
71 // Add remaining tokens after the last change
72 newTokens.push(
73 ...strand.tokens.slice(lastChangeEnd).map((token) => token.clone())
74 );
75
76 // Now process children strands
77 newTokens = newTokens.map((token, tokenIndex) => {
78 return token.mapChildren((childStrand, childIndex) => {
79 return applyChangesToStrand(childStrand, [
80 ...currentPath,
81 { tokenIndex, childIndex },
82 ]);
83 });
84 });
85
86 return new Strand(newTokens);
87 };
88
89 const newContent = applyChangesToStrand(this.startState.content, []);
90
91 // Update anchor and head positions
92 const updateCursorPosition = (rawCursor: RawCursor): RawCursor => {
93 let newRawCursor = { ...rawCursor };
94
95 for (const change of this.changes) {
96 if (strandPathsEqual(rawCursor.strandPath, change.strandPath)) {
97 if (rawCursor.pos < change.fromPos) {
98 // Cursor is before the change
99 continue;
100 } else if (rawCursor.pos >= change.toPos) {
101 // Cursor is after the change
102 newRawCursor.pos +=
103 change.newTokens.length - (change.toPos - change.fromPos);
104 } else {
105 // Cursor is within the changed range
106 newRawCursor.pos = change.fromPos + change.newTokens.length;
107 }
108 } else if (
109 strandPathsEqual(
110 rawCursor.strandPath.slice(0, change.strandPath.length),
111 change.strandPath
112 )
113 ) {
114 // Cursor is a child of a token in the changed strand
115 const relativeLevel = rawCursor.strandPath[change.strandPath.length];
116 if (!relativeLevel) continue; // Should not happen
117
118 if (relativeLevel.tokenIndex < change.fromPos) {
119 // Cursor's token is before the change
120 continue;
121 } else if (relativeLevel.tokenIndex >= change.toPos) {
122 // Cursor's token is after the change
123 newRawCursor.strandPath = [
124 ...newRawCursor.strandPath.slice(0, change.strandPath.length),
125 {
126 tokenIndex:
127 relativeLevel.tokenIndex +
128 (change.newTokens.length - (change.toPos - change.fromPos)),
129 childIndex: relativeLevel.childIndex,
130 },
131 ...newRawCursor.strandPath.slice(change.strandPath.length + 1),
132 ];
133 } else {
134 // Cursor's token is within the changed range
135 // Move cursor to the end of the newly inserted tokens
136 newRawCursor.strandPath = [
137 ...newRawCursor.strandPath.slice(0, change.strandPath.length),
138 ];
139 newRawCursor.pos = change.fromPos + change.newTokens.length;
140 break;
141 }
142 }
143 }
144
145 return newRawCursor;
146 };
147
148 let newSelection: SelectionRange;
149 if (this.newSelection) {
150 newSelection = this.newSelection;
151 } else {
152 let anchor = this.startState.selection.anchor.toRaw();
153 let head = this.startState.selection.head.toRaw();
154 anchor = updateCursorPosition(anchor);
155 head = updateCursorPosition(head);
156
157 newSelection = new SelectionRange(
158 new Cursor(anchor.strandPath, anchor.pos),
159 new Cursor(head.strandPath, head.pos)
160 );
161 }
162
163 return new EditorState(newContent, newSelection);
164 }
165
166 setNewSelection(newSelection: SelectionRange | Cursor | null): Transaction {
167 if (newSelection instanceof Cursor) {
168 newSelection = new SelectionRange(newSelection);
169 }
170 return new Transaction(this.startState, this.changes, newSelection);
171 }
172
173 static void(startState: EditorState): Transaction {
174 return new Transaction(startState, []);
175 }
176
177 // TODO: Improve & test this logic
178 static insertAtSelection(
179 startState: EditorState,
180 newTokens:
181 | Token[]
182 | ((params: {
183 setCursor: (
184 type?: "anchor" | "head" | "collapsed"
185 ) => TransactionNewCursor;
186 }) => Token[]),
187 selectionRange: SelectionRange = startState.selection
188 ): Transaction {
189 const {
190 tokens,
191 anchor: newAnchor,
192 head: newHead,
193 } = extractNewCursors(
194 newTokens instanceof Function
195 ? newTokens({ setCursor: (type) => new TransactionNewCursor(type) })
196 : newTokens
197 );
198
199 if (selectionRange.isCollapsed()) {
200 return new Transaction(
201 startState,
202 [
203 {
204 strandPath: selectionRange.anchor.strandPath,
205 fromPos: selectionRange.anchor.pos,
206 toPos: selectionRange.anchor.pos,
207 newTokens: tokens,
208 },
209 ],
210 newAnchor || newHead
211 ? new SelectionRange(
212 newAnchor
213 ? new Cursor(newAnchor.strandPath, newAnchor.pos)
214 : undefined,
215 newHead ? new Cursor(newHead.strandPath, newHead.pos) : undefined
216 )
217 : null
218 );
219 } else {
220 // If the selection is not collapsed, we need to delete the selected range first
221 const anchor = selectionRange.anchor;
222 const head = selectionRange.head;
223
224 // Ensure anchor is before head
225 let fromCursor: Cursor;
226 let toCursor: Cursor;
227 if (
228 strandPathsEqual(anchor.strandPath, head.strandPath) &&
229 anchor.pos <= head.pos
230 ) {
231 fromCursor = anchor;
232 toCursor = head;
233 } else {
234 fromCursor = head;
235 toCursor = anchor;
236 }
237
238 return new Transaction(
239 startState,
240 [
241 {
242 strandPath: fromCursor.strandPath,
243 fromPos: fromCursor.pos,
244 toPos: toCursor.pos,
245 newTokens: tokens,
246 },
247 ],
248 newAnchor || newHead
249 ? new SelectionRange(
250 newAnchor
251 ? new Cursor(newAnchor.strandPath, newAnchor.pos)
252 : undefined,
253 newHead ? new Cursor(newHead.strandPath, newHead.pos) : undefined
254 )
255 : null
256 );
257 }
258 }
259
260 // TODO: Improve & test this logic
261 static deleteAtSelection(
262 startState: EditorState,
263 bias: "forward" | "backward" = "backward",
264 selectionRange: SelectionRange = startState.selection
265 ): Transaction {
266 const changes: TokenSplice[] = [];
267
268 if (selectionRange.isCollapsed()) {
269 const cursor = selectionRange.anchor;
270 if (bias === "backward") {
271 if (cursor.pos === 0) {
272 if (cursor.strandPath.length === 0) {
273 // At the very start of the document; nothing to delete
274 return Transaction.void(startState);
275 } else {
276 // At the start of a sub-strand; delete the parent token
277 // and flatten its children
278 return Transaction.deleteToken(
279 startState,
280 {
281 strandPath: cursor.strandPath.slice(0, -1),
282 tokenIndex: cursor.strandPath.at(-1)!.tokenIndex,
283 },
284 "flatten",
285 cursor
286 );
287 }
288 } else {
289 changes.push({
290 strandPath: cursor.strandPath,
291 fromPos: cursor.pos - 1,
292 toPos: cursor.pos,
293 newTokens: [],
294 });
295 }
296 } else if (bias === "forward") {
297 if (
298 cursor.pos ===
299 startState.content.getStrand(cursor.strandPath)!.tokens.length
300 ) {
301 if (cursor.strandPath.length === 0) {
302 // At the very end of the document; nothing to delete
303 return Transaction.void(startState);
304 } else {
305 // At the end of a sub-strand; delete the parent token
306 // and flatten its children
307 return Transaction.deleteToken(
308 startState,
309 {
310 strandPath: cursor.strandPath.slice(0, -1),
311 tokenIndex: cursor.strandPath.at(-1)!.tokenIndex,
312 },
313 "flatten",
314 cursor
315 );
316 }
317 } else {
318 changes.push({
319 strandPath: cursor.strandPath,
320 fromPos: cursor.pos,
321 toPos: cursor.pos + 1,
322 newTokens: [],
323 });
324 }
325 }
326 } else {
327 changes.push({
328 strandPath: selectionRange.commonStrandPath,
329 fromPos: selectionRange.commonStartPos,
330 toPos: selectionRange.commonEndPos,
331 newTokens: [],
332 });
333 }
334
335 return new Transaction(startState, changes);
336 }
337
338 static deleteToken(
339 startState: EditorState,
340 tokenPath: TokenPath,
341 children: "delete" | "flatten" = "delete",
342 currentCursor: Cursor | null = null
343 ): Transaction {
344 const changes: TokenSplice[] = [];
345
346 const strand = startState.content.getStrand(tokenPath.strandPath);
347 if (!strand) {
348 throw new Error("Invalid strand path");
349 }
350 const token = strand.getToken(tokenPath);
351 if (!token) {
352 throw new Error("Invalid token path");
353 }
354
355 if (children === "delete") {
356 changes.push({
357 strandPath: tokenPath.strandPath,
358 fromPos: tokenPath.tokenIndex,
359 toPos: tokenPath.tokenIndex + 1,
360 newTokens: [],
361 });
362 return new Transaction(startState, changes);
363 } else if (children === "flatten") {
364 changes.push({
365 strandPath: tokenPath.strandPath,
366 fromPos: tokenPath.tokenIndex,
367 toPos: tokenPath.tokenIndex + 1,
368 newTokens: token.children
369 .flatMap((strand) => strand.tokens)
370 .map((t) => t.clone()),
371 });
372
373 let newCursor: Cursor | null = null;
374 if (currentCursor) {
375 const childIndex =
376 currentCursor.strandPath[tokenPath.strandPath.length].childIndex;
377 const posInChildStrand = currentCursor.pos;
378 const tokensBefore = token.children
379 .slice(0, childIndex)
380 .flatMap((strand) => strand.tokens).length;
381 newCursor = new Cursor(
382 tokenPath.strandPath,
383 tokenPath.tokenIndex + tokensBefore + posInChildStrand
384 );
385 }
386
387 return new Transaction(startState, changes, newCursor);
388 }
389
390 children satisfies never;
391 throw new Error("Invalid children option");
392 }
393
394 static setSelection(
395 startState: EditorState,
396 newSelection: SelectionRange | Cursor
397 ): Transaction {
398 if (newSelection instanceof Cursor) {
399 newSelection = new SelectionRange(newSelection);
400 }
401 return new Transaction(startState, [], newSelection);
402 }
403
404 static selectAll(startState: EditorState): Transaction {
405 return Transaction.setSelection(
406 startState,
407 new SelectionRange(
408 new Cursor([], 0),
409 new Cursor([], startState.content.tokens.length)
410 )
411 );
412 }
413
414 static moveCursor(
415 startState: EditorState,
416 direction: "left" | "right",
417 preserveAnchor: boolean = false
418 ): Transaction {
419 const currentCursor = startState.selection.head;
420
421 // Find current strand based on cursor
422 let currentStrand = startState.content;
423 for (const level of currentCursor.strandPath) {
424 const token = currentStrand.tokens[level.tokenIndex];
425 currentStrand = token.children[level.childIndex];
426 }
427
428 if (direction === "right") {
429 // If the selection is not collapsed and we shouldn't preserve the anchor,
430 // move to the end of the selection
431 if (!startState.selection.isCollapsed() && !preserveAnchor) {
432 const newCursor = new Cursor(
433 startState.selection.commonStrandPath,
434 startState.selection.commonEndPos
435 );
436 return Transaction.setSelection(
437 startState,
438 preserveAnchor
439 ? new SelectionRange(startState.selection.anchor, newCursor)
440 : newCursor
441 );
442 }
443
444 // If the next token has children, move into its first child strand
445 const nextToken = currentStrand.tokens[currentCursor.pos];
446 if (nextToken && nextToken.children.length > 0) {
447 const newCursor = new Cursor(
448 [
449 ...currentCursor.strandPath,
450 { tokenIndex: currentCursor.pos, childIndex: 0 },
451 ],
452 0
453 );
454 return Transaction.setSelection(
455 startState,
456 preserveAnchor
457 ? new SelectionRange(startState.selection.anchor, newCursor)
458 : newCursor
459 );
460 }
461
462 // If we are within the strand, move right past the next token
463 if (currentCursor.pos < currentStrand.tokens.length) {
464 const newCursor = new Cursor(
465 currentCursor.strandPath,
466 currentCursor.pos + 1
467 );
468 return Transaction.setSelection(
469 startState,
470 preserveAnchor
471 ? new SelectionRange(startState.selection.anchor, newCursor)
472 : newCursor
473 );
474 }
475
476 // We are at the end of the current strand. Try to move into a sibling strand or up to the parent.
477 let parentStrand = startState.content;
478 for (const level of currentCursor.strandPath.slice(0, -1)) {
479 const token = parentStrand.tokens[level.tokenIndex];
480 parentStrand = token.children[level.childIndex];
481 }
482
483 const lastStrandPathPart = currentCursor.strandPath.at(-1);
484
485 if (!lastStrandPathPart) {
486 // Already at the top-level strand; cannot move right
487 const newCursor = currentCursor.clone();
488 return Transaction.setSelection(
489 startState,
490 preserveAnchor
491 ? new SelectionRange(startState.selection.anchor, newCursor)
492 : newCursor
493 );
494 }
495
496 const { tokenIndex, childIndex } = lastStrandPathPart;
497 const parentToken = parentStrand.tokens[tokenIndex];
498
499 if (childIndex + 1 < parentToken.children.length) {
500 // Move to the next sibling child strand
501 const newCursor = new Cursor(
502 [
503 ...currentCursor.strandPath.slice(0, -1),
504 { tokenIndex, childIndex: childIndex + 1 },
505 ],
506 0
507 );
508 return Transaction.setSelection(
509 startState,
510 preserveAnchor
511 ? new SelectionRange(startState.selection.anchor, newCursor)
512 : newCursor
513 );
514 } else {
515 // Move up to the parent strand
516 const newCursor = new Cursor(
517 currentCursor.strandPath.slice(0, -1),
518 tokenIndex + 1
519 );
520 return Transaction.setSelection(
521 startState,
522 preserveAnchor
523 ? new SelectionRange(startState.selection.anchor, newCursor)
524 : newCursor
525 );
526 }
527 } else {
528 // If the selection is not collapsed and we shouldn't preserve the anchor,
529 // move to the start of the selection
530 if (!startState.selection.isCollapsed() && !preserveAnchor) {
531 const newCursor = new Cursor(
532 startState.selection.commonStrandPath,
533 startState.selection.commonStartPos
534 );
535 return Transaction.setSelection(
536 startState,
537 preserveAnchor
538 ? new SelectionRange(startState.selection.anchor, newCursor)
539 : newCursor
540 );
541 }
542
543 // If the previous token has children, move into its last child strand
544 if (currentCursor.pos > 0) {
545 const prevToken = currentStrand.tokens[currentCursor.pos - 1];
546 if (prevToken && prevToken.children.length > 0) {
547 const newCursor = new Cursor(
548 [
549 ...currentCursor.strandPath,
550 {
551 tokenIndex: currentCursor.pos - 1,
552 childIndex: prevToken.children.length - 1,
553 },
554 ],
555 prevToken.children[prevToken.children.length - 1].tokens.length
556 );
557 return Transaction.setSelection(
558 startState,
559 preserveAnchor
560 ? new SelectionRange(startState.selection.anchor, newCursor)
561 : newCursor
562 );
563 }
564 }
565
566 // If we are within the strand, move left before the previous token
567 if (currentCursor.pos > 0) {
568 const newCursor = new Cursor(
569 currentCursor.strandPath,
570 currentCursor.pos - 1
571 );
572 return Transaction.setSelection(
573 startState,
574 preserveAnchor
575 ? new SelectionRange(startState.selection.anchor, newCursor)
576 : newCursor
577 );
578 }
579
580 // We are at the start of the current strand. Try to move into a sibling strand or up to the parent.
581 let parentStrand = startState.content;
582 for (const level of currentCursor.strandPath.slice(0, -1)) {
583 const token = parentStrand.tokens[level.tokenIndex];
584 parentStrand = token.children[level.childIndex];
585 }
586
587 const lastStrandPathPart = currentCursor.strandPath.at(-1);
588 if (!lastStrandPathPart) {
589 // Already at the top-level strand; cannot move left
590 const newCursor = currentCursor.clone();
591 return Transaction.setSelection(
592 startState,
593 preserveAnchor
594 ? new SelectionRange(startState.selection.anchor, newCursor)
595 : newCursor
596 );
597 }
598
599 const { tokenIndex, childIndex } = lastStrandPathPart;
600 const parentToken = parentStrand.tokens[tokenIndex];
601
602 if (childIndex > 0) {
603 // Move to the previous sibling child strand
604 const siblingStrand = parentToken.children[childIndex - 1];
605 const newCursor = new Cursor(
606 [
607 ...currentCursor.strandPath.slice(0, -1),
608 { tokenIndex, childIndex: childIndex - 1 },
609 ],
610 siblingStrand.tokens.length
611 );
612 return Transaction.setSelection(
613 startState,
614 preserveAnchor
615 ? new SelectionRange(startState.selection.anchor, newCursor)
616 : newCursor
617 );
618 } else {
619 // Move up to the parent strand
620 const newCursor = new Cursor(
621 currentCursor.strandPath.slice(0, -1),
622 tokenIndex
623 );
624 return Transaction.setSelection(
625 startState,
626 preserveAnchor
627 ? new SelectionRange(startState.selection.anchor, newCursor)
628 : newCursor
629 );
630 }
631 }
632 }
633}
634
635class TransactionNewCursor extends Token {
636 flow = new EmptyFlow();
637
638 constructor(public type: "anchor" | "head" | "collapsed" = "collapsed") {
639 super();
640 }
641
642 get children(): readonly Strand[] {
643 return [];
644 }
645
646 mapChildren(
647 _fn: (child: Strand, index: number) => Strand
648 ): TransactionNewCursor {
649 return new TransactionNewCursor();
650 }
651
652 renderToDebugText(): string {
653 return `TransactionNewCursor()`;
654 }
655
656 renderToDebugHTML(): VNode {
657 return h("span", {}, t("▮"));
658 }
659
660 renderToDebugMathML(): VNode {
661 return h("mtext", {}, t("▮"));
662 }
663}
664
665function extractNewCursors(tokens: readonly Token[]): {
666 tokens: readonly Token[];
667 anchor: RawCursor | null;
668 head: RawCursor | null;
669} {
670 let newCursorTokenPaths: TokenPath[] = [];
671 let anchor: RawCursor | null = null;
672 let head: RawCursor | null = null;
673
674 const strand = new Strand(tokens);
675 for (const [str, strandPath] of strand.traverseStrands()) {
676 const tokens = [...str.tokens];
677 for (let i = 0; i < tokens.length; i++) {
678 const token = tokens[i];
679 if (token instanceof TransactionNewCursor) {
680 newCursorTokenPaths.push({ strandPath, tokenIndex: i });
681 if (token.type === "anchor" || token.type === "collapsed") {
682 if (anchor) {
683 throw new Error(
684 "Multiple new `anchor` or `collapsed` cursors found in transaction."
685 );
686 }
687 anchor = { strandPath, pos: i };
688 }
689 if (token.type === "head" || token.type === "collapsed") {
690 if (head) {
691 throw new Error(
692 "Multiple new `head` or `collapsed` cursors found in transaction."
693 );
694 }
695 head = { strandPath, pos: i };
696 }
697 }
698 }
699 }
700
701 function filterOutNewCursors(strand: Strand): Strand {
702 return new Strand(
703 strand.tokens
704 .filter((token) => !(token instanceof TransactionNewCursor))
705 .map((token) =>
706 token.mapChildren((childStrand) => filterOutNewCursors(childStrand))
707 )
708 );
709 }
710
711 const newTokens = filterOutNewCursors(strand).tokens;
712
713 // Adjust cursor positions after removing new cursor tokens
714 function adjustCursorPosition(rawCursor: RawCursor): RawCursor {
715 let adjustedCursor = { ...rawCursor };
716
717 for (const tokenPath of newCursorTokenPaths) {
718 tokenPath.strandPath;
719 rawCursor.strandPath;
720
721 // Example: 1X+a|/b
722 // tokenPath: { strandPath: [], tokenIndex: 1 }
723 // adjustedCursor: { strandPath: [{ tokenIndex: 3, childIndex: 0 }], pos: 1 }
724 // Result (new adjustedCursor): { strandPath: [{ tokenIndex: 2, childIndex: 0 }], pos: 1 }
725 const isSameStrandPath = strandPathsEqual(
726 tokenPath.strandPath,
727 adjustedCursor.strandPath
728 );
729
730 if (isSameStrandPath) {
731 if (tokenPath.tokenIndex < adjustedCursor.pos) {
732 adjustedCursor.pos -= 1;
733 }
734 } else if (
735 strandPathsEqual(
736 tokenPath.strandPath.slice(0, adjustedCursor.strandPath.length),
737 adjustedCursor.strandPath
738 )
739 ) {
740 // Cursor is a child of a token in the changed strand
741 const relativeLevel =
742 adjustedCursor.strandPath[tokenPath.strandPath.length];
743 if (!relativeLevel) continue; // Should not happen
744
745 if (tokenPath.tokenIndex < relativeLevel.tokenIndex) {
746 // Cursor's token is after the removed cursor token
747 adjustedCursor.strandPath = [
748 ...adjustedCursor.strandPath.slice(0, tokenPath.strandPath.length),
749 {
750 tokenIndex: relativeLevel.tokenIndex - 1,
751 childIndex: relativeLevel.childIndex,
752 },
753 ...adjustedCursor.strandPath.slice(tokenPath.strandPath.length + 1),
754 ];
755 }
756 }
757 }
758
759 return adjustedCursor;
760 }
761
762 return {
763 tokens: newTokens,
764 anchor: anchor ? adjustCursorPosition(anchor) : null,
765 head: head ? adjustCursorPosition(head) : null,
766 };
767}