A world-class math input for the web
at main 767 lines 24 kB view raw
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}