@blankdotpage/cake 0.1.68 → 0.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/cake/core/mapping/cursor-source-map.d.ts +11 -0
  2. package/dist/cake/core/mapping/cursor-source-map.d.ts.map +1 -1
  3. package/dist/cake/core/mapping/cursor-source-map.js +159 -21
  4. package/dist/cake/core/runtime.d.ts +4 -0
  5. package/dist/cake/core/runtime.d.ts.map +1 -1
  6. package/dist/cake/core/runtime.js +332 -215
  7. package/dist/cake/dom/render.d.ts +32 -2
  8. package/dist/cake/dom/render.d.ts.map +1 -1
  9. package/dist/cake/dom/render.js +401 -118
  10. package/dist/cake/editor/cake-editor.d.ts +8 -1
  11. package/dist/cake/editor/cake-editor.d.ts.map +1 -1
  12. package/dist/cake/editor/cake-editor.js +172 -100
  13. package/dist/cake/editor/internal/editor-text-model.d.ts +49 -0
  14. package/dist/cake/editor/internal/editor-text-model.d.ts.map +1 -0
  15. package/dist/cake/editor/internal/editor-text-model.js +284 -0
  16. package/dist/cake/editor/selection/selection-geometry-dom.d.ts +5 -1
  17. package/dist/cake/editor/selection/selection-geometry-dom.d.ts.map +1 -1
  18. package/dist/cake/editor/selection/selection-geometry-dom.js +4 -5
  19. package/dist/cake/editor/selection/selection-layout-dom.d.ts.map +1 -1
  20. package/dist/cake/editor/selection/selection-layout-dom.js +2 -5
  21. package/dist/cake/editor/selection/selection-layout.d.ts +2 -15
  22. package/dist/cake/editor/selection/selection-layout.d.ts.map +1 -1
  23. package/dist/cake/editor/selection/selection-layout.js +1 -99
  24. package/dist/cake/editor/selection/selection-navigation.d.ts +4 -0
  25. package/dist/cake/editor/selection/selection-navigation.d.ts.map +1 -1
  26. package/dist/cake/editor/selection/selection-navigation.js +1 -2
  27. package/dist/cake/extensions/link/link.d.ts.map +1 -1
  28. package/dist/cake/extensions/link/link.js +1 -7
  29. package/dist/cake/extensions/shared/structural-reparse-policy.js +2 -2
  30. package/package.json +5 -2
  31. package/dist/cake/editor/selection/visible-text.d.ts +0 -5
  32. package/dist/cake/editor/selection/visible-text.d.ts.map +0 -1
  33. package/dist/cake/editor/selection/visible-text.js +0 -66
  34. package/dist/cake/engine/cake-engine.d.ts +0 -230
  35. package/dist/cake/engine/cake-engine.d.ts.map +0 -1
  36. package/dist/cake/engine/cake-engine.js +0 -3589
  37. package/dist/cake/engine/selection/selection-geometry-dom.d.ts +0 -24
  38. package/dist/cake/engine/selection/selection-geometry-dom.d.ts.map +0 -1
  39. package/dist/cake/engine/selection/selection-geometry-dom.js +0 -302
  40. package/dist/cake/engine/selection/selection-geometry.d.ts +0 -22
  41. package/dist/cake/engine/selection/selection-geometry.d.ts.map +0 -1
  42. package/dist/cake/engine/selection/selection-geometry.js +0 -158
  43. package/dist/cake/engine/selection/selection-layout-dom.d.ts +0 -50
  44. package/dist/cake/engine/selection/selection-layout-dom.d.ts.map +0 -1
  45. package/dist/cake/engine/selection/selection-layout-dom.js +0 -781
  46. package/dist/cake/engine/selection/selection-layout.d.ts +0 -55
  47. package/dist/cake/engine/selection/selection-layout.d.ts.map +0 -1
  48. package/dist/cake/engine/selection/selection-layout.js +0 -128
  49. package/dist/cake/engine/selection/selection-navigation.d.ts +0 -22
  50. package/dist/cake/engine/selection/selection-navigation.d.ts.map +0 -1
  51. package/dist/cake/engine/selection/selection-navigation.js +0 -229
  52. package/dist/cake/engine/selection/visible-text.d.ts +0 -5
  53. package/dist/cake/engine/selection/visible-text.d.ts.map +0 -1
  54. package/dist/cake/engine/selection/visible-text.js +0 -66
  55. package/dist/cake/react/CakeEditor.d.ts +0 -58
  56. package/dist/cake/react/CakeEditor.d.ts.map +0 -1
  57. package/dist/cake/react/CakeEditor.js +0 -225
@@ -1,12 +1,11 @@
1
1
  import { isApplyEditCommand, createRuntimeFromRegistry, } from "../core/runtime";
2
- import { renderDocContent } from "../dom/render";
2
+ import { renderDocContent, } from "../dom/render";
3
3
  import { applyDomSelection, readDomSelection } from "../dom/dom-selection";
4
4
  import { bundledExtensions } from "../extensions";
5
5
  import { getCaretRect as getDomCaretRect, getSelectionGeometry, } from "./selection/selection-geometry-dom";
6
- import { getDocLines, getLineOffsets, resolveOffsetToLine, } from "./selection/selection-layout";
7
- import { cursorOffsetToVisibleOffset, getVisibleText, visibleOffsetToCursorOffset, } from "./selection/visible-text";
8
6
  import { hitTestFromLayout, measureLayoutModelFromDom, } from "./selection/selection-layout-dom";
9
7
  import { moveSelectionVertically as moveSelectionVerticallyInLayout } from "./selection/selection-navigation";
8
+ import { EditorTextModel, } from "./internal/editor-text-model";
10
9
  import { isMacPlatform } from "../shared/platform";
11
10
  import { graphemeCount } from "../shared/segmenter";
12
11
  import { getWordBoundaries, nextWordBreak, prevWordBreak, } from "../shared/word-break";
@@ -15,6 +14,47 @@ const defaultSelection = { start: 0, end: 0, affinity: "forward" };
15
14
  const COMPOSITION_COMMIT_CLEAR_DELAY_MS = 50;
16
15
  const HISTORY_GROUPING_INTERVAL_MS = 500;
17
16
  const MAX_UNDO_STACK_SIZE = 100;
17
+ function computeDirtyCursorRange(previous, next) {
18
+ if (!previous) {
19
+ return null;
20
+ }
21
+ const prevSource = previous.source;
22
+ const nextSource = next.source;
23
+ if (prevSource === nextSource) {
24
+ return null;
25
+ }
26
+ const prevLength = prevSource.length;
27
+ const nextLength = nextSource.length;
28
+ let prefix = 0;
29
+ while (prefix < prevLength &&
30
+ prefix < nextLength &&
31
+ prevSource.charCodeAt(prefix) === nextSource.charCodeAt(prefix)) {
32
+ prefix += 1;
33
+ }
34
+ let prevSuffix = prevLength;
35
+ let nextSuffix = nextLength;
36
+ while (prevSuffix > prefix &&
37
+ nextSuffix > prefix &&
38
+ prevSource.charCodeAt(prevSuffix - 1) ===
39
+ nextSource.charCodeAt(nextSuffix - 1)) {
40
+ prevSuffix -= 1;
41
+ nextSuffix -= 1;
42
+ }
43
+ const previousStart = previous.map.sourceToCursor(prefix, "forward").cursorOffset;
44
+ const previousEnd = previous.map.sourceToCursor(prevSuffix, "backward").cursorOffset;
45
+ const nextStart = next.map.sourceToCursor(prefix, "forward").cursorOffset;
46
+ const nextEnd = next.map.sourceToCursor(nextSuffix, "backward").cursorOffset;
47
+ return {
48
+ previous: {
49
+ start: previousStart,
50
+ end: previousEnd,
51
+ },
52
+ next: {
53
+ start: nextStart,
54
+ end: nextEnd,
55
+ },
56
+ };
57
+ }
18
58
  function removeFromArray(arr, value) {
19
59
  const index = arr.indexOf(value);
20
60
  if (index === -1) {
@@ -27,7 +67,11 @@ export class CakeEditor {
27
67
  return this._state;
28
68
  }
29
69
  set state(value) {
70
+ const previousDoc = this._state?.doc;
30
71
  this._state = value;
72
+ if (previousDoc !== value.doc) {
73
+ this.textModel.rebuild(value.doc);
74
+ }
31
75
  }
32
76
  getLastRenderPerf() {
33
77
  return this.lastRenderPerf;
@@ -78,6 +122,7 @@ export class CakeEditor {
78
122
  this.domBlockRenderers = [];
79
123
  this.uiComponents = [];
80
124
  this.extensionDisposers = [];
125
+ this.textModel = new EditorTextModel();
81
126
  this.contentRoot = null;
82
127
  this.domMap = null;
83
128
  this.isApplyingSelection = false;
@@ -117,6 +162,8 @@ export class CakeEditor {
117
162
  this.lastFocusRect = null;
118
163
  this.verticalNavGoalX = null;
119
164
  this.lastRenderPerf = null;
165
+ this.renderSnapshot = null;
166
+ this.lastRenderedState = null;
120
167
  this.history = {
121
168
  undoStack: [],
122
169
  redoStack: [],
@@ -368,16 +415,26 @@ export class CakeEditor {
368
415
  return this.state.selection;
369
416
  }
370
417
  getText() {
371
- const lines = getDocLines(this.state.doc);
372
- return getVisibleText(lines);
418
+ return this.textModel.getVisibleText();
373
419
  }
374
420
  getTextSelection() {
375
- const lines = getDocLines(this.state.doc);
376
421
  return {
377
- start: cursorOffsetToVisibleOffset(lines, this.state.selection.start),
378
- end: cursorOffsetToVisibleOffset(lines, this.state.selection.end),
422
+ start: this.textModel.cursorOffsetToVisibleOffset(this.state.selection.start),
423
+ end: this.textModel.cursorOffsetToVisibleOffset(this.state.selection.end),
379
424
  };
380
425
  }
426
+ rebuildTextModelFallback() {
427
+ // Single fallback branch for complex cases where cursor/visible mapping misses.
428
+ this.textModel.rebuild(this.state.doc);
429
+ }
430
+ visibleOffsetToCursorOffset(visibleOffset) {
431
+ const resolved = this.textModel.visibleOffsetToCursorOffset(visibleOffset);
432
+ if (resolved !== null) {
433
+ return resolved;
434
+ }
435
+ this.rebuildTextModelFallback();
436
+ return this.textModel.visibleOffsetToCursorOffset(visibleOffset);
437
+ }
381
438
  getActiveMarks() {
382
439
  const { start, end } = this.state.selection;
383
440
  if (start === end) {
@@ -690,9 +747,8 @@ export class CakeEditor {
690
747
  return { found: false, marks: [], nextOffset: offset };
691
748
  }
692
749
  setTextSelection(selection) {
693
- const lines = getDocLines(this.state.doc);
694
- const start = visibleOffsetToCursorOffset(lines, selection.start);
695
- const end = visibleOffsetToCursorOffset(lines, selection.end);
750
+ const start = this.visibleOffsetToCursorOffset(selection.start);
751
+ const end = this.visibleOffsetToCursorOffset(selection.end);
696
752
  if (start === null || end === null) {
697
753
  return;
698
754
  }
@@ -705,34 +761,25 @@ export class CakeEditor {
705
761
  });
706
762
  }
707
763
  getTextBeforeCursor(maxChars = Number.POSITIVE_INFINITY) {
708
- const text = this.getText();
709
- const { start } = this.getTextSelection();
710
- const cursor = Math.max(0, Math.min(start, text.length));
711
- const length = Math.max(0, maxChars);
712
- return text.slice(Math.max(0, cursor - length), cursor);
764
+ return this.textModel.getTextBeforeCursor(this.state.selection, maxChars);
713
765
  }
714
766
  getTextAroundCursor(before, after) {
715
- const text = this.getText();
716
- const { start } = this.getTextSelection();
717
- const cursor = Math.max(0, Math.min(start, text.length));
718
- const beforeLength = Math.max(0, before);
719
- const afterLength = Math.max(0, after);
720
- const beforeText = text.slice(Math.max(0, cursor - beforeLength), cursor);
721
- const afterText = text.slice(cursor, Math.min(text.length, cursor + afterLength));
722
- return { before: beforeText, after: afterText };
767
+ return this.textModel.getTextAroundCursor(this.state.selection, before, after);
768
+ }
769
+ getTextForCursorRange(start, end) {
770
+ return this.textModel.getTextForCursorRange(start, end);
723
771
  }
724
772
  replaceTextBeforeCursor(chars, replacement) {
725
- const lines = getDocLines(this.state.doc);
726
773
  const selection = this.state.selection;
727
774
  const focus = selection.start === selection.end
728
775
  ? selection.start
729
776
  : selection.affinity === "backward"
730
777
  ? selection.start
731
778
  : selection.end;
732
- const focusVisible = cursorOffsetToVisibleOffset(lines, focus);
779
+ const focusVisible = this.textModel.cursorOffsetToVisibleOffset(focus);
733
780
  const length = Math.max(0, chars);
734
781
  const startVisible = Math.max(0, focusVisible - length);
735
- const startCursor = visibleOffsetToCursorOffset(lines, startVisible);
782
+ const startCursor = this.visibleOffsetToCursorOffset(startVisible);
736
783
  if (startCursor === null) {
737
784
  return;
738
785
  }
@@ -757,7 +804,7 @@ export class CakeEditor {
757
804
  this.scheduleScrollCaretIntoView();
758
805
  }
759
806
  getCursorLength() {
760
- return this.state.map.cursorLength;
807
+ return this.textModel.getCursorLength();
761
808
  }
762
809
  getFocusRect() {
763
810
  return this.lastFocusRect;
@@ -786,12 +833,13 @@ export class CakeEditor {
786
833
  if (!this.contentRoot) {
787
834
  return null;
788
835
  }
789
- const lines = getDocLines(this.state.doc);
836
+ const lines = this.textModel.getLines();
790
837
  const geometry = getSelectionGeometry({
791
838
  root: this.contentRoot,
792
839
  container: this.container,
793
840
  docLines: lines,
794
841
  selection: this.state.selection,
842
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
795
843
  });
796
844
  const focus = geometry.caretRect ?? geometry.focusRect;
797
845
  if (!focus) {
@@ -821,12 +869,13 @@ export class CakeEditor {
821
869
  if (!this.contentRoot) {
822
870
  return [];
823
871
  }
824
- const lines = getDocLines(this.state.doc);
872
+ const lines = this.textModel.getLines();
825
873
  const geometry = getSelectionGeometry({
826
874
  root: this.contentRoot,
827
875
  container: this.container,
828
876
  docLines: lines,
829
877
  selection: this.state.selection,
878
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
830
879
  });
831
880
  if (geometry.selectionRects.length === 0) {
832
881
  return [];
@@ -850,7 +899,7 @@ export class CakeEditor {
850
899
  }));
851
900
  }
852
901
  getLines() {
853
- return getDocLines(this.state.doc);
902
+ return this.textModel.getLines();
854
903
  }
855
904
  getOverlayRoot() {
856
905
  return this.ensureExtensionsRoot();
@@ -1248,7 +1297,11 @@ export class CakeEditor {
1248
1297
  if (perfEnabled) {
1249
1298
  renderStart = performance.now();
1250
1299
  }
1251
- const { content, map } = renderDocContent(this.state.doc, this.runtime.dom, this.contentRoot);
1300
+ const dirtyCursorRange = computeDirtyCursorRange(this.lastRenderedState, this.state);
1301
+ const { content, map, snapshot } = renderDocContent(this.state.doc, this.runtime.dom, this.contentRoot, {
1302
+ previousSnapshot: this.renderSnapshot,
1303
+ dirtyCursorRange,
1304
+ });
1252
1305
  const existingChildren = Array.from(this.contentRoot.childNodes);
1253
1306
  const isManagedChild = (node) => node instanceof Element &&
1254
1307
  (node.hasAttribute("data-line-index") ||
@@ -1263,6 +1316,11 @@ export class CakeEditor {
1263
1316
  this.contentRoot.replaceChildren(...content, ...preservedChildren);
1264
1317
  }
1265
1318
  this.domMap = map;
1319
+ this.renderSnapshot = snapshot;
1320
+ this.lastRenderedState = {
1321
+ source: this.state.source,
1322
+ map: this.state.map,
1323
+ };
1266
1324
  if (perfEnabled) {
1267
1325
  renderAndMapMs = performance.now() - renderStart;
1268
1326
  }
@@ -1524,9 +1582,9 @@ export class CakeEditor {
1524
1582
  if (selection.start !== selection.end) {
1525
1583
  return selection;
1526
1584
  }
1527
- const lines = getDocLines(this.state.doc);
1528
- const lineOffsets = getLineOffsets(lines);
1529
- const { lineIndex } = resolveOffsetToLine(lines, selection.start);
1585
+ const lines = this.textModel.getLines();
1586
+ const lineOffsets = this.textModel.getLineOffsets();
1587
+ const { lineIndex } = this.textModel.resolveOffsetToLine(selection.start);
1530
1588
  const lineInfo = lines[lineIndex];
1531
1589
  if (!lineInfo || !lineInfo.isAtomic) {
1532
1590
  return selection;
@@ -1572,13 +1630,13 @@ export class CakeEditor {
1572
1630
  if (Number.isNaN(lineIndex)) {
1573
1631
  return null;
1574
1632
  }
1575
- const lines = getDocLines(this.state.doc);
1633
+ const lines = this.textModel.getLines();
1576
1634
  const lineInfo = lines[lineIndex];
1577
1635
  if (!lineInfo || !lineInfo.isAtomic) {
1578
1636
  return null;
1579
1637
  }
1580
1638
  // Calculate the selection range for the entire line including newline
1581
- const lineOffsets = getLineOffsets(lines);
1639
+ const lineOffsets = this.textModel.getLineOffsets();
1582
1640
  const lineStart = lineOffsets[lineIndex] ?? 0;
1583
1641
  const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
1584
1642
  return {
@@ -1709,10 +1767,10 @@ export class CakeEditor {
1709
1767
  if (!hit) {
1710
1768
  return;
1711
1769
  }
1712
- const lines = getDocLines(this.state.doc);
1770
+ const lines = this.textModel.getLines();
1713
1771
  if (event.detail === 2) {
1714
- const lineOffsets = getLineOffsets(lines);
1715
- const { lineIndex } = resolveOffsetToLine(lines, hit.cursorOffset);
1772
+ const lineOffsets = this.textModel.getLineOffsets();
1773
+ const { lineIndex } = this.textModel.resolveOffsetToLine(hit.cursorOffset);
1716
1774
  const lineInfo = lines[lineIndex];
1717
1775
  if (lineInfo && lineInfo.cursorLength === 0) {
1718
1776
  const lineStart = lineOffsets[lineIndex] ?? 0;
@@ -1730,11 +1788,11 @@ export class CakeEditor {
1730
1788
  this.suppressSelectionChange = false;
1731
1789
  return;
1732
1790
  }
1733
- const visibleText = getVisibleText(lines);
1734
- const visibleOffset = cursorOffsetToVisibleOffset(lines, hit.cursorOffset);
1791
+ const visibleText = this.textModel.getVisibleText();
1792
+ const visibleOffset = this.textModel.cursorOffsetToVisibleOffset(hit.cursorOffset);
1735
1793
  const wordBounds = getWordBoundaries(visibleText, visibleOffset);
1736
- const start = visibleOffsetToCursorOffset(lines, wordBounds.start);
1737
- const end = visibleOffsetToCursorOffset(lines, wordBounds.end);
1794
+ const start = this.visibleOffsetToCursorOffset(wordBounds.start);
1795
+ const end = this.visibleOffsetToCursorOffset(wordBounds.end);
1738
1796
  if (start === null || end === null) {
1739
1797
  this.suppressSelectionChange = false;
1740
1798
  return;
@@ -1754,8 +1812,8 @@ export class CakeEditor {
1754
1812
  return;
1755
1813
  }
1756
1814
  if (event.detail >= 3) {
1757
- const lineOffsets = getLineOffsets(lines);
1758
- const { lineIndex } = resolveOffsetToLine(lines, hit.cursorOffset);
1815
+ const lineOffsets = this.textModel.getLineOffsets();
1816
+ const { lineIndex } = this.textModel.resolveOffsetToLine(hit.cursorOffset);
1759
1817
  const lineInfo = lines[lineIndex];
1760
1818
  if (!lineInfo) {
1761
1819
  this.suppressSelectionChange = false;
@@ -2182,8 +2240,7 @@ export class CakeEditor {
2182
2240
  if (this.compositionCommit && event.inputType === "insertText") {
2183
2241
  this.clearCompositionCommit();
2184
2242
  const domText = this.readDomText();
2185
- const lines = getDocLines(this.state.doc);
2186
- const modelText = getVisibleText(lines);
2243
+ const modelText = this.textModel.getVisibleText();
2187
2244
  if (domText === modelText) {
2188
2245
  if (this.domMap) {
2189
2246
  const domSelection = readDomSelection(this.domMap);
@@ -2209,8 +2266,7 @@ export class CakeEditor {
2209
2266
  // we must not drop the edit; reconcile if the DOM diverged from the model.
2210
2267
  if (this.beforeInputHandled) {
2211
2268
  const domText = this.readDomText();
2212
- const lines = getDocLines(this.state.doc);
2213
- const modelText = getVisibleText(lines);
2269
+ const modelText = this.textModel.getVisibleText();
2214
2270
  if (domText === modelText) {
2215
2271
  return;
2216
2272
  }
@@ -2418,8 +2474,8 @@ export class CakeEditor {
2418
2474
  return false;
2419
2475
  }
2420
2476
  const lineIndex = this.selectedAtomicLineIndex;
2421
- const lines = getDocLines(this.state.doc);
2422
- const lineOffsets = getLineOffsets(lines);
2477
+ const lines = this.textModel.getLines();
2478
+ const lineOffsets = this.textModel.getLineOffsets();
2423
2479
  const lineInfo = lines[lineIndex];
2424
2480
  if (!lineInfo || !lineInfo.isAtomic) {
2425
2481
  this.selectedAtomicLineIndex = null;
@@ -2467,8 +2523,8 @@ export class CakeEditor {
2467
2523
  if (selection.start !== selection.end) {
2468
2524
  return false;
2469
2525
  }
2470
- const lines = getDocLines(this.state.doc);
2471
- const lineOffsets = getLineOffsets(lines);
2526
+ const lines = this.textModel.getLines();
2527
+ const lineOffsets = this.textModel.getLineOffsets();
2472
2528
  const lineIndex = lineOffsets.findIndex((offset) => offset === selection.start);
2473
2529
  if (lineIndex === -1) {
2474
2530
  return false;
@@ -2508,8 +2564,11 @@ export class CakeEditor {
2508
2564
  sourceLines[swapB] = aSource;
2509
2565
  const newSource = sourceLines.join("\n");
2510
2566
  const nextState = this.runtime.createState(newSource);
2511
- const nextOffsets = getLineOffsets(getDocLines(nextState.doc));
2512
- const cursorPos = nextOffsets[swapA] ?? 0;
2567
+ let lineStartSource = 0;
2568
+ for (let index = 0; index < swapA; index += 1) {
2569
+ lineStartSource += (sourceLines[index]?.length ?? 0) + 1;
2570
+ }
2571
+ const cursorPos = nextState.map.sourceToCursor(lineStartSource, "forward").cursorOffset;
2513
2572
  this.recordHistory("delete-backward");
2514
2573
  this.state = {
2515
2574
  ...nextState,
@@ -2560,7 +2619,7 @@ export class CakeEditor {
2560
2619
  if (!this.contentRoot) {
2561
2620
  return null;
2562
2621
  }
2563
- const lines = getDocLines(this.state.doc);
2622
+ const lines = this.textModel.getLines();
2564
2623
  const layout = measureLayoutModelFromDom({
2565
2624
  lines,
2566
2625
  root: this.contentRoot,
@@ -2591,6 +2650,7 @@ export class CakeEditor {
2591
2650
  layout,
2592
2651
  offset: currentPos,
2593
2652
  affinity: currentAffinity,
2653
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2594
2654
  });
2595
2655
  // Arrow left at start of visual row (not at document start):
2596
2656
  // Stay at same position but change to backward affinity
@@ -2604,6 +2664,7 @@ export class CakeEditor {
2604
2664
  layout,
2605
2665
  offset: currentPos,
2606
2666
  affinity: "backward",
2667
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2607
2668
  });
2608
2669
  // If backward affinity puts us on a different row, just change affinity
2609
2670
  if (prevBoundaries.rowEnd !== rowEnd ||
@@ -2622,6 +2683,7 @@ export class CakeEditor {
2622
2683
  layout,
2623
2684
  offset: currentPos,
2624
2685
  affinity: "forward",
2686
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2625
2687
  });
2626
2688
  // If forward affinity puts us on a different row, just change affinity
2627
2689
  if (nextBoundaries.rowEnd !== rowEnd ||
@@ -2638,8 +2700,8 @@ export class CakeEditor {
2638
2700
  }
2639
2701
  moveOffsetByChar(offset, direction) {
2640
2702
  const cursorLength = this.state.map.cursorLength;
2641
- const lines = getDocLines(this.state.doc);
2642
- const lineOffsets = getLineOffsets(lines);
2703
+ const lines = this.textModel.getLines();
2704
+ const lineOffsets = this.textModel.getLineOffsets();
2643
2705
  let nextPos;
2644
2706
  if (direction === "forward") {
2645
2707
  if (offset >= cursorLength) {
@@ -2653,7 +2715,7 @@ export class CakeEditor {
2653
2715
  }
2654
2716
  nextPos = offset - 1;
2655
2717
  }
2656
- const { lineIndex: nextLineIndex } = resolveOffsetToLine(lines, nextPos);
2718
+ const { lineIndex: nextLineIndex } = this.textModel.resolveOffsetToLine(nextPos);
2657
2719
  const nextLineInfo = lines[nextLineIndex];
2658
2720
  if (nextLineInfo && nextLineInfo.isAtomic) {
2659
2721
  const lineStart = lineOffsets[nextLineIndex] ?? 0;
@@ -2680,7 +2742,7 @@ export class CakeEditor {
2680
2742
  }
2681
2743
  }
2682
2744
  const { focus } = resolveSelectionAnchorAndFocus(this.state.selection);
2683
- const focusResolved = resolveOffsetToLine(lines, focus);
2745
+ const focusResolved = this.textModel.resolveOffsetToLine(focus);
2684
2746
  const focusLineLayout = layout.lines[focusResolved.lineIndex];
2685
2747
  let focusRowIndex = undefined;
2686
2748
  if (focusLineLayout?.rows.length && this.lastFocusRect) {
@@ -2707,6 +2769,7 @@ export class CakeEditor {
2707
2769
  lines,
2708
2770
  layout,
2709
2771
  selection: this.state.selection,
2772
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2710
2773
  direction,
2711
2774
  goalX: this.verticalNavGoalX,
2712
2775
  focusRowIndex,
@@ -2715,7 +2778,7 @@ export class CakeEditor {
2715
2778
  if (!hit || !this.contentRoot) {
2716
2779
  return null;
2717
2780
  }
2718
- const resolved = resolveOffsetToLine(lines, hit.cursorOffset);
2781
+ const resolved = this.textModel.resolveOffsetToLine(hit.cursorOffset);
2719
2782
  const lineInfo = lines[resolved.lineIndex];
2720
2783
  const lineElement = this.contentRoot.querySelector(`[data-line-index="${resolved.lineIndex}"]`);
2721
2784
  if (!lineInfo || !(lineElement instanceof HTMLElement)) {
@@ -2759,6 +2822,7 @@ export class CakeEditor {
2759
2822
  layout,
2760
2823
  offset: focus,
2761
2824
  affinity: "backward",
2825
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2762
2826
  });
2763
2827
  let target = rowStart;
2764
2828
  if (focus === rowStart && focus > 0) {
@@ -2767,6 +2831,7 @@ export class CakeEditor {
2767
2831
  layout,
2768
2832
  offset: focus - 1,
2769
2833
  affinity: "backward",
2834
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2770
2835
  });
2771
2836
  target = previous.rowStart;
2772
2837
  }
@@ -2788,6 +2853,7 @@ export class CakeEditor {
2788
2853
  layout,
2789
2854
  offset: focus,
2790
2855
  affinity: "forward",
2856
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2791
2857
  });
2792
2858
  let target = rowEnd;
2793
2859
  if (focus === rowEnd && focus < this.state.map.cursorLength) {
@@ -2796,6 +2862,7 @@ export class CakeEditor {
2796
2862
  layout,
2797
2863
  offset: focus + 1,
2798
2864
  affinity: "forward",
2865
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2799
2866
  });
2800
2867
  target = next.rowEnd;
2801
2868
  }
@@ -2815,6 +2882,7 @@ export class CakeEditor {
2815
2882
  layout,
2816
2883
  offset: focus,
2817
2884
  affinity,
2885
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2818
2886
  });
2819
2887
  let target = rowStart;
2820
2888
  if (focus === rowStart && focus > 0) {
@@ -2823,6 +2891,7 @@ export class CakeEditor {
2823
2891
  layout,
2824
2892
  offset: focus - 1,
2825
2893
  affinity: "backward",
2894
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2826
2895
  });
2827
2896
  target = previous.rowStart;
2828
2897
  }
@@ -2842,6 +2911,7 @@ export class CakeEditor {
2842
2911
  layout,
2843
2912
  offset: focus,
2844
2913
  affinity,
2914
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2845
2915
  });
2846
2916
  let target = rowEnd;
2847
2917
  if (focus === rowEnd && focus < this.state.map.cursorLength) {
@@ -2850,6 +2920,7 @@ export class CakeEditor {
2850
2920
  layout,
2851
2921
  offset: focus + 1,
2852
2922
  affinity: "forward",
2923
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2853
2924
  });
2854
2925
  target = next.rowEnd;
2855
2926
  }
@@ -2868,8 +2939,8 @@ export class CakeEditor {
2868
2939
  if (selection.start === selection.end) {
2869
2940
  return null;
2870
2941
  }
2871
- const lines = getDocLines(this.state.doc);
2872
- const lineOffsets = getLineOffsets(lines);
2942
+ const lines = this.textModel.getLines();
2943
+ const lineOffsets = this.textModel.getLineOffsets();
2873
2944
  const selStart = Math.min(selection.start, selection.end);
2874
2945
  const selEnd = Math.max(selection.start, selection.end);
2875
2946
  const fullLineInfo = this.detectFullLineSelection(selStart, selEnd, lines, lineOffsets);
@@ -2924,26 +2995,25 @@ export class CakeEditor {
2924
2995
  return selectionFromAnchor(anchor, nextFocus, direction);
2925
2996
  }
2926
2997
  moveOffsetByWord(offset, direction) {
2927
- const lines = getDocLines(this.state.doc);
2928
- const visibleText = getVisibleText(lines);
2998
+ const visibleText = this.textModel.getVisibleText();
2929
2999
  if (!visibleText) {
2930
3000
  return 0;
2931
3001
  }
2932
- const visibleOffset = cursorOffsetToVisibleOffset(lines, offset);
3002
+ const visibleOffset = this.textModel.cursorOffsetToVisibleOffset(offset);
2933
3003
  const nextVisibleOffset = direction === "backward"
2934
3004
  ? prevWordBreak(visibleText, visibleOffset)
2935
3005
  : nextWordBreak(visibleText, visibleOffset);
2936
- return visibleOffsetToCursorOffset(lines, nextVisibleOffset) ?? offset;
3006
+ return this.visibleOffsetToCursorOffset(nextVisibleOffset) ?? offset;
2937
3007
  }
2938
3008
  deleteToVisualRowStart() {
2939
3009
  const selection = this.state.selection;
2940
- const lines = getDocLines(this.state.doc);
2941
- const { lineIndex, offsetInLine } = resolveOffsetToLine(lines, selection.start);
3010
+ const lines = this.textModel.getLines();
3011
+ const { lineIndex, offsetInLine } = this.textModel.resolveOffsetToLine(selection.start);
2942
3012
  const lineInfo = lines[lineIndex];
2943
3013
  if (!lineInfo) {
2944
3014
  return;
2945
3015
  }
2946
- const lineOffsets = getLineOffsets(lines);
3016
+ const lineOffsets = this.textModel.getLineOffsets();
2947
3017
  const lineStart = lineOffsets[lineIndex] ?? 0;
2948
3018
  const isLineStart = offsetInLine === 0;
2949
3019
  const isCollapsed = selection.start === selection.end;
@@ -2968,6 +3038,7 @@ export class CakeEditor {
2968
3038
  layout,
2969
3039
  offset: selection.start,
2970
3040
  affinity: "backward",
3041
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2971
3042
  });
2972
3043
  const isVisualRowStart = selection.start === rowStart;
2973
3044
  if (isCollapsed && isVisualRowStart) {
@@ -2984,11 +3055,11 @@ export class CakeEditor {
2984
3055
  }
2985
3056
  handleIndent() {
2986
3057
  const selection = this.state.selection;
2987
- const lines = getDocLines(this.state.doc);
3058
+ const lines = this.textModel.getLines();
2988
3059
  const TAB_SPACES = " ";
2989
3060
  const isCollapsed = selection.start === selection.end;
2990
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
2991
- const endLineIndex = resolveOffsetToLine(lines, Math.max(selection.start, selection.end - 1)).lineIndex;
3061
+ const startLineIndex = this.textModel.resolveOffsetToLine(selection.start).lineIndex;
3062
+ const endLineIndex = this.textModel.resolveOffsetToLine(Math.max(selection.start, selection.end - 1)).lineIndex;
2992
3063
  const affectsMultipleLines = endLineIndex > startLineIndex;
2993
3064
  // Check if the current line is a list item by checking source text
2994
3065
  const sourceLines = this.state.source.split("\n");
@@ -3009,7 +3080,7 @@ export class CakeEditor {
3009
3080
  // insert at caret position. Otherwise indent at line start.
3010
3081
  if (isCollapsed) {
3011
3082
  // Check if caret is in middle/end of line (not at start)
3012
- const lineOffsets = getLineOffsets(lines);
3083
+ const lineOffsets = this.textModel.getLineOffsets();
3013
3084
  const lineStart = lineOffsets[startLineIndex] ?? 0;
3014
3085
  const offsetInLine = selection.start - lineStart;
3015
3086
  if (offsetInLine > 0) {
@@ -3061,10 +3132,10 @@ export class CakeEditor {
3061
3132
  }
3062
3133
  handleOutdent() {
3063
3134
  const selection = this.state.selection;
3064
- const lines = getDocLines(this.state.doc);
3135
+ const lines = this.textModel.getLines();
3065
3136
  const TAB_SPACES = " ";
3066
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
3067
- const endLineIndex = resolveOffsetToLine(lines, Math.max(selection.start, selection.end - 1)).lineIndex;
3137
+ const startLineIndex = this.textModel.resolveOffsetToLine(selection.start).lineIndex;
3138
+ const endLineIndex = this.textModel.resolveOffsetToLine(Math.max(selection.start, selection.end - 1)).lineIndex;
3068
3139
  const sourceLines = this.state.source.split("\n");
3069
3140
  // Check if the current line is a list item by checking source text
3070
3141
  const startSourceLine = sourceLines[startLineIndex] ?? "";
@@ -3158,8 +3229,7 @@ export class CakeEditor {
3158
3229
  */
3159
3230
  reconcileDomChanges(selection) {
3160
3231
  const domText = this.readDomText();
3161
- const lines = getDocLines(this.state.doc);
3162
- const modelText = getVisibleText(lines);
3232
+ const modelText = this.textModel.getVisibleText();
3163
3233
  if (domText === modelText) {
3164
3234
  return false;
3165
3235
  }
@@ -3181,8 +3251,8 @@ export class CakeEditor {
3181
3251
  // The replacement text from DOM
3182
3252
  const replacementText = domText.slice(prefixLen, domText.length - suffixLen);
3183
3253
  // Convert visible text offsets to cursor offsets
3184
- const cursorStart = visibleOffsetToCursorOffset(lines, prefixLen);
3185
- const cursorEnd = visibleOffsetToCursorOffset(lines, modelText.length - suffixLen);
3254
+ const cursorStart = this.visibleOffsetToCursorOffset(prefixLen);
3255
+ const cursorEnd = this.visibleOffsetToCursorOffset(modelText.length - suffixLen);
3186
3256
  if (cursorStart === null || cursorEnd === null) {
3187
3257
  // Fallback: rebuild state from scratch (loses formatting)
3188
3258
  // History was already recorded above
@@ -3470,12 +3540,13 @@ export class CakeEditor {
3470
3540
  this.syncSelectionRects([]);
3471
3541
  return;
3472
3542
  }
3473
- const lines = getDocLines(this.state.doc);
3543
+ const lines = this.textModel.getLines();
3474
3544
  const geometry = getSelectionGeometry({
3475
3545
  root: this.contentRoot,
3476
3546
  container: this.container,
3477
3547
  docLines: lines,
3478
3548
  selection,
3549
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
3479
3550
  });
3480
3551
  this.lastFocusRect = geometry.focusRect;
3481
3552
  this.syncSelectionRects(geometry.selectionRects);
@@ -3489,12 +3560,13 @@ export class CakeEditor {
3489
3560
  return;
3490
3561
  }
3491
3562
  this.contentRoot.classList.remove("cake-touch-mode");
3492
- const lines = getDocLines(this.state.doc);
3563
+ const lines = this.textModel.getLines();
3493
3564
  const geometry = getSelectionGeometry({
3494
3565
  root: this.contentRoot,
3495
3566
  container: this.container,
3496
3567
  docLines: lines,
3497
3568
  selection: this.state.selection,
3569
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
3498
3570
  });
3499
3571
  this.lastFocusRect = geometry.focusRect;
3500
3572
  this.syncSelectionRects(geometry.selectionRects);
@@ -3679,7 +3751,7 @@ export class CakeEditor {
3679
3751
  if (!this.contentRoot) {
3680
3752
  return null;
3681
3753
  }
3682
- const lines = getDocLines(this.state.doc);
3754
+ const lines = this.textModel.getLines();
3683
3755
  const hit = hitTestFromLayout({
3684
3756
  clientX,
3685
3757
  clientY,
@@ -3742,10 +3814,10 @@ export class CakeEditor {
3742
3814
  const lineIndexAttr = blockElement?.getAttribute("data-line-index") ?? null;
3743
3815
  if (lineIndexAttr !== null) {
3744
3816
  const lineIndex = Number.parseInt(lineIndexAttr, 10);
3745
- const lines = getDocLines(this.state.doc);
3817
+ const lines = this.textModel.getLines();
3746
3818
  const lineInfo = lines[lineIndex];
3747
3819
  if (lineInfo?.isAtomic) {
3748
- const lineOffsets = getLineOffsets(lines);
3820
+ const lineOffsets = this.textModel.getLineOffsets();
3749
3821
  const lineStart = lineOffsets[lineIndex] ?? 0;
3750
3822
  const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
3751
3823
  const atomicSelection = {
@@ -3851,8 +3923,8 @@ export class CakeEditor {
3851
3923
  return;
3852
3924
  }
3853
3925
  // Check if this is a full line selection (required for line drag)
3854
- const lines = getDocLines(this.state.doc);
3855
- const lineOffsets = getLineOffsets(lines);
3926
+ const lines = this.textModel.getLines();
3927
+ const lineOffsets = this.textModel.getLineOffsets();
3856
3928
  // Find which line the selection starts on
3857
3929
  // We need to handle the case where selStart might be at the newline position
3858
3930
  // of the previous line (offset - 1) due to DOM selection normalization
@@ -4264,10 +4336,10 @@ export class CakeEditor {
4264
4336
  return;
4265
4337
  }
4266
4338
  // Get the plain text and source text for the selection
4267
- const lines = getDocLines(this.state.doc);
4268
- const visibleText = getVisibleText(lines);
4269
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
4270
- const visibleEnd = cursorOffsetToVisibleOffset(lines, end);
4339
+ const lines = this.textModel.getLines();
4340
+ const visibleText = this.textModel.getVisibleText();
4341
+ const visibleStart = this.textModel.cursorOffsetToVisibleOffset(start);
4342
+ const visibleEnd = this.textModel.cursorOffsetToVisibleOffset(end);
4271
4343
  const plainText = visibleText.slice(visibleStart, visibleEnd);
4272
4344
  // Get source text for the selection (use backward/forward to capture full markdown syntax)
4273
4345
  const cursorSourceMap = this.state.map;
@@ -4329,10 +4401,10 @@ export class CakeEditor {
4329
4401
  if (selection.start !== selection.end) {
4330
4402
  const start = Math.min(selection.start, selection.end);
4331
4403
  const end = Math.max(selection.start, selection.end);
4332
- const lines = getDocLines(this.state.doc);
4333
- const visibleText = getVisibleText(lines);
4334
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
4335
- const visibleEnd = cursorOffsetToVisibleOffset(lines, end);
4404
+ const lines = this.textModel.getLines();
4405
+ const visibleText = this.textModel.getVisibleText();
4406
+ const visibleStart = this.textModel.cursorOffsetToVisibleOffset(start);
4407
+ const visibleEnd = this.textModel.cursorOffsetToVisibleOffset(end);
4336
4408
  const plainText = visibleText.slice(visibleStart, visibleEnd);
4337
4409
  const cursorSourceMap = this.state.map;
4338
4410
  const sourceStart = cursorSourceMap.cursorToSource(start, "backward");
@@ -4354,8 +4426,8 @@ export class CakeEditor {
4354
4426
  return;
4355
4427
  }
4356
4428
  // Check if this is a full-line selection - if so, use line-level move
4357
- const lines = getDocLines(this.state.doc);
4358
- const lineOffsets = getLineOffsets(lines);
4429
+ const lines = this.textModel.getLines();
4430
+ const lineOffsets = this.textModel.getLineOffsets();
4359
4431
  const fullLineInfo = this.detectFullLineSelection(dragStart, dragEnd, lines, lineOffsets);
4360
4432
  if (fullLineInfo) {
4361
4433
  // Full line drag - use line-level move
@@ -4506,7 +4578,7 @@ function getVisualRowBoundaries(params) {
4506
4578
  if (!layout || layout.lines.length === 0) {
4507
4579
  return { rowStart: 0, rowEnd: 0 };
4508
4580
  }
4509
- const resolved = resolveOffsetToLine(lines, offset);
4581
+ const resolved = params.resolveOffsetToLine(offset);
4510
4582
  const line = layout.lines[resolved.lineIndex];
4511
4583
  if (!line) {
4512
4584
  return { rowStart: 0, rowEnd: 0 };