@blankdotpage/cake 0.1.67 → 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 (62) 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 +6 -0
  5. package/dist/cake/core/runtime.d.ts.map +1 -1
  6. package/dist/cake/core/runtime.js +344 -221
  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 +11 -2
  11. package/dist/cake/editor/cake-editor.d.ts.map +1 -1
  12. package/dist/cake/editor/cake-editor.js +178 -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/index.d.ts +2 -1
  28. package/dist/cake/extensions/index.d.ts.map +1 -1
  29. package/dist/cake/extensions/index.js +3 -1
  30. package/dist/cake/extensions/link/link.d.ts.map +1 -1
  31. package/dist/cake/extensions/link/link.js +1 -7
  32. package/dist/cake/extensions/shared/structural-reparse-policy.d.ts +7 -0
  33. package/dist/cake/extensions/shared/structural-reparse-policy.d.ts.map +1 -0
  34. package/dist/cake/extensions/shared/structural-reparse-policy.js +16 -0
  35. package/package.json +5 -2
  36. package/dist/cake/editor/selection/visible-text.d.ts +0 -5
  37. package/dist/cake/editor/selection/visible-text.d.ts.map +0 -1
  38. package/dist/cake/editor/selection/visible-text.js +0 -66
  39. package/dist/cake/engine/cake-engine.d.ts +0 -230
  40. package/dist/cake/engine/cake-engine.d.ts.map +0 -1
  41. package/dist/cake/engine/cake-engine.js +0 -3589
  42. package/dist/cake/engine/selection/selection-geometry-dom.d.ts +0 -24
  43. package/dist/cake/engine/selection/selection-geometry-dom.d.ts.map +0 -1
  44. package/dist/cake/engine/selection/selection-geometry-dom.js +0 -302
  45. package/dist/cake/engine/selection/selection-geometry.d.ts +0 -22
  46. package/dist/cake/engine/selection/selection-geometry.d.ts.map +0 -1
  47. package/dist/cake/engine/selection/selection-geometry.js +0 -158
  48. package/dist/cake/engine/selection/selection-layout-dom.d.ts +0 -50
  49. package/dist/cake/engine/selection/selection-layout-dom.d.ts.map +0 -1
  50. package/dist/cake/engine/selection/selection-layout-dom.js +0 -781
  51. package/dist/cake/engine/selection/selection-layout.d.ts +0 -55
  52. package/dist/cake/engine/selection/selection-layout.d.ts.map +0 -1
  53. package/dist/cake/engine/selection/selection-layout.js +0 -128
  54. package/dist/cake/engine/selection/selection-navigation.d.ts +0 -22
  55. package/dist/cake/engine/selection/selection-navigation.d.ts.map +0 -1
  56. package/dist/cake/engine/selection/selection-navigation.js +0 -229
  57. package/dist/cake/engine/selection/visible-text.d.ts +0 -5
  58. package/dist/cake/engine/selection/visible-text.d.ts.map +0 -1
  59. package/dist/cake/engine/selection/visible-text.js +0 -66
  60. package/dist/cake/react/CakeEditor.d.ts +0 -58
  61. package/dist/cake/react/CakeEditor.d.ts.map +0 -1
  62. 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;
@@ -69,6 +113,7 @@ export class CakeEditor {
69
113
  this.normalizeBlockFns = [];
70
114
  this.normalizeInlineFns = [];
71
115
  this.onEditFns = [];
116
+ this.structuralReparsePolicies = [];
72
117
  this.keybindings = [];
73
118
  this.keyDownInterceptors = [];
74
119
  this.onPasteTextHandlers = [];
@@ -77,6 +122,7 @@ export class CakeEditor {
77
122
  this.domBlockRenderers = [];
78
123
  this.uiComponents = [];
79
124
  this.extensionDisposers = [];
125
+ this.textModel = new EditorTextModel();
80
126
  this.contentRoot = null;
81
127
  this.domMap = null;
82
128
  this.isApplyingSelection = false;
@@ -116,6 +162,8 @@ export class CakeEditor {
116
162
  this.lastFocusRect = null;
117
163
  this.verticalNavGoalX = null;
118
164
  this.lastRenderPerf = null;
165
+ this.renderSnapshot = null;
166
+ this.lastRenderedState = null;
119
167
  this.history = {
120
168
  undoStack: [],
121
169
  redoStack: [],
@@ -203,6 +251,7 @@ export class CakeEditor {
203
251
  normalizeBlockFns: this.normalizeBlockFns,
204
252
  normalizeInlineFns: this.normalizeInlineFns,
205
253
  onEditFns: this.onEditFns,
254
+ structuralReparsePolicies: this.structuralReparsePolicies,
206
255
  domInlineRenderers: this.domInlineRenderers,
207
256
  domBlockRenderers: this.domBlockRenderers,
208
257
  });
@@ -282,6 +331,10 @@ export class CakeEditor {
282
331
  this.onEditFns.push(fn);
283
332
  return () => removeFromArray(this.onEditFns, fn);
284
333
  }
334
+ registerStructuralReparsePolicy(fn) {
335
+ this.structuralReparsePolicies.push(fn);
336
+ return () => removeFromArray(this.structuralReparsePolicies, fn);
337
+ }
285
338
  registerOnPasteText(fn) {
286
339
  this.onPasteTextHandlers.push(fn);
287
340
  return () => removeFromArray(this.onPasteTextHandlers, fn);
@@ -362,16 +415,26 @@ export class CakeEditor {
362
415
  return this.state.selection;
363
416
  }
364
417
  getText() {
365
- const lines = getDocLines(this.state.doc);
366
- return getVisibleText(lines);
418
+ return this.textModel.getVisibleText();
367
419
  }
368
420
  getTextSelection() {
369
- const lines = getDocLines(this.state.doc);
370
421
  return {
371
- start: cursorOffsetToVisibleOffset(lines, this.state.selection.start),
372
- 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),
373
424
  };
374
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
+ }
375
438
  getActiveMarks() {
376
439
  const { start, end } = this.state.selection;
377
440
  if (start === end) {
@@ -684,9 +747,8 @@ export class CakeEditor {
684
747
  return { found: false, marks: [], nextOffset: offset };
685
748
  }
686
749
  setTextSelection(selection) {
687
- const lines = getDocLines(this.state.doc);
688
- const start = visibleOffsetToCursorOffset(lines, selection.start);
689
- const end = visibleOffsetToCursorOffset(lines, selection.end);
750
+ const start = this.visibleOffsetToCursorOffset(selection.start);
751
+ const end = this.visibleOffsetToCursorOffset(selection.end);
690
752
  if (start === null || end === null) {
691
753
  return;
692
754
  }
@@ -699,34 +761,25 @@ export class CakeEditor {
699
761
  });
700
762
  }
701
763
  getTextBeforeCursor(maxChars = Number.POSITIVE_INFINITY) {
702
- const text = this.getText();
703
- const { start } = this.getTextSelection();
704
- const cursor = Math.max(0, Math.min(start, text.length));
705
- const length = Math.max(0, maxChars);
706
- return text.slice(Math.max(0, cursor - length), cursor);
764
+ return this.textModel.getTextBeforeCursor(this.state.selection, maxChars);
707
765
  }
708
766
  getTextAroundCursor(before, after) {
709
- const text = this.getText();
710
- const { start } = this.getTextSelection();
711
- const cursor = Math.max(0, Math.min(start, text.length));
712
- const beforeLength = Math.max(0, before);
713
- const afterLength = Math.max(0, after);
714
- const beforeText = text.slice(Math.max(0, cursor - beforeLength), cursor);
715
- const afterText = text.slice(cursor, Math.min(text.length, cursor + afterLength));
716
- 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);
717
771
  }
718
772
  replaceTextBeforeCursor(chars, replacement) {
719
- const lines = getDocLines(this.state.doc);
720
773
  const selection = this.state.selection;
721
774
  const focus = selection.start === selection.end
722
775
  ? selection.start
723
776
  : selection.affinity === "backward"
724
777
  ? selection.start
725
778
  : selection.end;
726
- const focusVisible = cursorOffsetToVisibleOffset(lines, focus);
779
+ const focusVisible = this.textModel.cursorOffsetToVisibleOffset(focus);
727
780
  const length = Math.max(0, chars);
728
781
  const startVisible = Math.max(0, focusVisible - length);
729
- const startCursor = visibleOffsetToCursorOffset(lines, startVisible);
782
+ const startCursor = this.visibleOffsetToCursorOffset(startVisible);
730
783
  if (startCursor === null) {
731
784
  return;
732
785
  }
@@ -751,7 +804,7 @@ export class CakeEditor {
751
804
  this.scheduleScrollCaretIntoView();
752
805
  }
753
806
  getCursorLength() {
754
- return this.state.map.cursorLength;
807
+ return this.textModel.getCursorLength();
755
808
  }
756
809
  getFocusRect() {
757
810
  return this.lastFocusRect;
@@ -780,12 +833,13 @@ export class CakeEditor {
780
833
  if (!this.contentRoot) {
781
834
  return null;
782
835
  }
783
- const lines = getDocLines(this.state.doc);
836
+ const lines = this.textModel.getLines();
784
837
  const geometry = getSelectionGeometry({
785
838
  root: this.contentRoot,
786
839
  container: this.container,
787
840
  docLines: lines,
788
841
  selection: this.state.selection,
842
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
789
843
  });
790
844
  const focus = geometry.caretRect ?? geometry.focusRect;
791
845
  if (!focus) {
@@ -815,12 +869,13 @@ export class CakeEditor {
815
869
  if (!this.contentRoot) {
816
870
  return [];
817
871
  }
818
- const lines = getDocLines(this.state.doc);
872
+ const lines = this.textModel.getLines();
819
873
  const geometry = getSelectionGeometry({
820
874
  root: this.contentRoot,
821
875
  container: this.container,
822
876
  docLines: lines,
823
877
  selection: this.state.selection,
878
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
824
879
  });
825
880
  if (geometry.selectionRects.length === 0) {
826
881
  return [];
@@ -844,7 +899,7 @@ export class CakeEditor {
844
899
  }));
845
900
  }
846
901
  getLines() {
847
- return getDocLines(this.state.doc);
902
+ return this.textModel.getLines();
848
903
  }
849
904
  getOverlayRoot() {
850
905
  return this.ensureExtensionsRoot();
@@ -1242,7 +1297,11 @@ export class CakeEditor {
1242
1297
  if (perfEnabled) {
1243
1298
  renderStart = performance.now();
1244
1299
  }
1245
- 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
+ });
1246
1305
  const existingChildren = Array.from(this.contentRoot.childNodes);
1247
1306
  const isManagedChild = (node) => node instanceof Element &&
1248
1307
  (node.hasAttribute("data-line-index") ||
@@ -1257,6 +1316,11 @@ export class CakeEditor {
1257
1316
  this.contentRoot.replaceChildren(...content, ...preservedChildren);
1258
1317
  }
1259
1318
  this.domMap = map;
1319
+ this.renderSnapshot = snapshot;
1320
+ this.lastRenderedState = {
1321
+ source: this.state.source,
1322
+ map: this.state.map,
1323
+ };
1260
1324
  if (perfEnabled) {
1261
1325
  renderAndMapMs = performance.now() - renderStart;
1262
1326
  }
@@ -1518,9 +1582,9 @@ export class CakeEditor {
1518
1582
  if (selection.start !== selection.end) {
1519
1583
  return selection;
1520
1584
  }
1521
- const lines = getDocLines(this.state.doc);
1522
- const lineOffsets = getLineOffsets(lines);
1523
- 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);
1524
1588
  const lineInfo = lines[lineIndex];
1525
1589
  if (!lineInfo || !lineInfo.isAtomic) {
1526
1590
  return selection;
@@ -1566,13 +1630,13 @@ export class CakeEditor {
1566
1630
  if (Number.isNaN(lineIndex)) {
1567
1631
  return null;
1568
1632
  }
1569
- const lines = getDocLines(this.state.doc);
1633
+ const lines = this.textModel.getLines();
1570
1634
  const lineInfo = lines[lineIndex];
1571
1635
  if (!lineInfo || !lineInfo.isAtomic) {
1572
1636
  return null;
1573
1637
  }
1574
1638
  // Calculate the selection range for the entire line including newline
1575
- const lineOffsets = getLineOffsets(lines);
1639
+ const lineOffsets = this.textModel.getLineOffsets();
1576
1640
  const lineStart = lineOffsets[lineIndex] ?? 0;
1577
1641
  const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
1578
1642
  return {
@@ -1703,10 +1767,10 @@ export class CakeEditor {
1703
1767
  if (!hit) {
1704
1768
  return;
1705
1769
  }
1706
- const lines = getDocLines(this.state.doc);
1770
+ const lines = this.textModel.getLines();
1707
1771
  if (event.detail === 2) {
1708
- const lineOffsets = getLineOffsets(lines);
1709
- const { lineIndex } = resolveOffsetToLine(lines, hit.cursorOffset);
1772
+ const lineOffsets = this.textModel.getLineOffsets();
1773
+ const { lineIndex } = this.textModel.resolveOffsetToLine(hit.cursorOffset);
1710
1774
  const lineInfo = lines[lineIndex];
1711
1775
  if (lineInfo && lineInfo.cursorLength === 0) {
1712
1776
  const lineStart = lineOffsets[lineIndex] ?? 0;
@@ -1724,11 +1788,11 @@ export class CakeEditor {
1724
1788
  this.suppressSelectionChange = false;
1725
1789
  return;
1726
1790
  }
1727
- const visibleText = getVisibleText(lines);
1728
- const visibleOffset = cursorOffsetToVisibleOffset(lines, hit.cursorOffset);
1791
+ const visibleText = this.textModel.getVisibleText();
1792
+ const visibleOffset = this.textModel.cursorOffsetToVisibleOffset(hit.cursorOffset);
1729
1793
  const wordBounds = getWordBoundaries(visibleText, visibleOffset);
1730
- const start = visibleOffsetToCursorOffset(lines, wordBounds.start);
1731
- const end = visibleOffsetToCursorOffset(lines, wordBounds.end);
1794
+ const start = this.visibleOffsetToCursorOffset(wordBounds.start);
1795
+ const end = this.visibleOffsetToCursorOffset(wordBounds.end);
1732
1796
  if (start === null || end === null) {
1733
1797
  this.suppressSelectionChange = false;
1734
1798
  return;
@@ -1748,8 +1812,8 @@ export class CakeEditor {
1748
1812
  return;
1749
1813
  }
1750
1814
  if (event.detail >= 3) {
1751
- const lineOffsets = getLineOffsets(lines);
1752
- const { lineIndex } = resolveOffsetToLine(lines, hit.cursorOffset);
1815
+ const lineOffsets = this.textModel.getLineOffsets();
1816
+ const { lineIndex } = this.textModel.resolveOffsetToLine(hit.cursorOffset);
1753
1817
  const lineInfo = lines[lineIndex];
1754
1818
  if (!lineInfo) {
1755
1819
  this.suppressSelectionChange = false;
@@ -2176,8 +2240,7 @@ export class CakeEditor {
2176
2240
  if (this.compositionCommit && event.inputType === "insertText") {
2177
2241
  this.clearCompositionCommit();
2178
2242
  const domText = this.readDomText();
2179
- const lines = getDocLines(this.state.doc);
2180
- const modelText = getVisibleText(lines);
2243
+ const modelText = this.textModel.getVisibleText();
2181
2244
  if (domText === modelText) {
2182
2245
  if (this.domMap) {
2183
2246
  const domSelection = readDomSelection(this.domMap);
@@ -2203,8 +2266,7 @@ export class CakeEditor {
2203
2266
  // we must not drop the edit; reconcile if the DOM diverged from the model.
2204
2267
  if (this.beforeInputHandled) {
2205
2268
  const domText = this.readDomText();
2206
- const lines = getDocLines(this.state.doc);
2207
- const modelText = getVisibleText(lines);
2269
+ const modelText = this.textModel.getVisibleText();
2208
2270
  if (domText === modelText) {
2209
2271
  return;
2210
2272
  }
@@ -2412,8 +2474,8 @@ export class CakeEditor {
2412
2474
  return false;
2413
2475
  }
2414
2476
  const lineIndex = this.selectedAtomicLineIndex;
2415
- const lines = getDocLines(this.state.doc);
2416
- const lineOffsets = getLineOffsets(lines);
2477
+ const lines = this.textModel.getLines();
2478
+ const lineOffsets = this.textModel.getLineOffsets();
2417
2479
  const lineInfo = lines[lineIndex];
2418
2480
  if (!lineInfo || !lineInfo.isAtomic) {
2419
2481
  this.selectedAtomicLineIndex = null;
@@ -2461,8 +2523,8 @@ export class CakeEditor {
2461
2523
  if (selection.start !== selection.end) {
2462
2524
  return false;
2463
2525
  }
2464
- const lines = getDocLines(this.state.doc);
2465
- const lineOffsets = getLineOffsets(lines);
2526
+ const lines = this.textModel.getLines();
2527
+ const lineOffsets = this.textModel.getLineOffsets();
2466
2528
  const lineIndex = lineOffsets.findIndex((offset) => offset === selection.start);
2467
2529
  if (lineIndex === -1) {
2468
2530
  return false;
@@ -2502,8 +2564,11 @@ export class CakeEditor {
2502
2564
  sourceLines[swapB] = aSource;
2503
2565
  const newSource = sourceLines.join("\n");
2504
2566
  const nextState = this.runtime.createState(newSource);
2505
- const nextOffsets = getLineOffsets(getDocLines(nextState.doc));
2506
- 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;
2507
2572
  this.recordHistory("delete-backward");
2508
2573
  this.state = {
2509
2574
  ...nextState,
@@ -2554,7 +2619,7 @@ export class CakeEditor {
2554
2619
  if (!this.contentRoot) {
2555
2620
  return null;
2556
2621
  }
2557
- const lines = getDocLines(this.state.doc);
2622
+ const lines = this.textModel.getLines();
2558
2623
  const layout = measureLayoutModelFromDom({
2559
2624
  lines,
2560
2625
  root: this.contentRoot,
@@ -2585,6 +2650,7 @@ export class CakeEditor {
2585
2650
  layout,
2586
2651
  offset: currentPos,
2587
2652
  affinity: currentAffinity,
2653
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2588
2654
  });
2589
2655
  // Arrow left at start of visual row (not at document start):
2590
2656
  // Stay at same position but change to backward affinity
@@ -2598,6 +2664,7 @@ export class CakeEditor {
2598
2664
  layout,
2599
2665
  offset: currentPos,
2600
2666
  affinity: "backward",
2667
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2601
2668
  });
2602
2669
  // If backward affinity puts us on a different row, just change affinity
2603
2670
  if (prevBoundaries.rowEnd !== rowEnd ||
@@ -2616,6 +2683,7 @@ export class CakeEditor {
2616
2683
  layout,
2617
2684
  offset: currentPos,
2618
2685
  affinity: "forward",
2686
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2619
2687
  });
2620
2688
  // If forward affinity puts us on a different row, just change affinity
2621
2689
  if (nextBoundaries.rowEnd !== rowEnd ||
@@ -2632,8 +2700,8 @@ export class CakeEditor {
2632
2700
  }
2633
2701
  moveOffsetByChar(offset, direction) {
2634
2702
  const cursorLength = this.state.map.cursorLength;
2635
- const lines = getDocLines(this.state.doc);
2636
- const lineOffsets = getLineOffsets(lines);
2703
+ const lines = this.textModel.getLines();
2704
+ const lineOffsets = this.textModel.getLineOffsets();
2637
2705
  let nextPos;
2638
2706
  if (direction === "forward") {
2639
2707
  if (offset >= cursorLength) {
@@ -2647,7 +2715,7 @@ export class CakeEditor {
2647
2715
  }
2648
2716
  nextPos = offset - 1;
2649
2717
  }
2650
- const { lineIndex: nextLineIndex } = resolveOffsetToLine(lines, nextPos);
2718
+ const { lineIndex: nextLineIndex } = this.textModel.resolveOffsetToLine(nextPos);
2651
2719
  const nextLineInfo = lines[nextLineIndex];
2652
2720
  if (nextLineInfo && nextLineInfo.isAtomic) {
2653
2721
  const lineStart = lineOffsets[nextLineIndex] ?? 0;
@@ -2674,7 +2742,7 @@ export class CakeEditor {
2674
2742
  }
2675
2743
  }
2676
2744
  const { focus } = resolveSelectionAnchorAndFocus(this.state.selection);
2677
- const focusResolved = resolveOffsetToLine(lines, focus);
2745
+ const focusResolved = this.textModel.resolveOffsetToLine(focus);
2678
2746
  const focusLineLayout = layout.lines[focusResolved.lineIndex];
2679
2747
  let focusRowIndex = undefined;
2680
2748
  if (focusLineLayout?.rows.length && this.lastFocusRect) {
@@ -2701,6 +2769,7 @@ export class CakeEditor {
2701
2769
  lines,
2702
2770
  layout,
2703
2771
  selection: this.state.selection,
2772
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2704
2773
  direction,
2705
2774
  goalX: this.verticalNavGoalX,
2706
2775
  focusRowIndex,
@@ -2709,7 +2778,7 @@ export class CakeEditor {
2709
2778
  if (!hit || !this.contentRoot) {
2710
2779
  return null;
2711
2780
  }
2712
- const resolved = resolveOffsetToLine(lines, hit.cursorOffset);
2781
+ const resolved = this.textModel.resolveOffsetToLine(hit.cursorOffset);
2713
2782
  const lineInfo = lines[resolved.lineIndex];
2714
2783
  const lineElement = this.contentRoot.querySelector(`[data-line-index="${resolved.lineIndex}"]`);
2715
2784
  if (!lineInfo || !(lineElement instanceof HTMLElement)) {
@@ -2753,6 +2822,7 @@ export class CakeEditor {
2753
2822
  layout,
2754
2823
  offset: focus,
2755
2824
  affinity: "backward",
2825
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2756
2826
  });
2757
2827
  let target = rowStart;
2758
2828
  if (focus === rowStart && focus > 0) {
@@ -2761,6 +2831,7 @@ export class CakeEditor {
2761
2831
  layout,
2762
2832
  offset: focus - 1,
2763
2833
  affinity: "backward",
2834
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2764
2835
  });
2765
2836
  target = previous.rowStart;
2766
2837
  }
@@ -2782,6 +2853,7 @@ export class CakeEditor {
2782
2853
  layout,
2783
2854
  offset: focus,
2784
2855
  affinity: "forward",
2856
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2785
2857
  });
2786
2858
  let target = rowEnd;
2787
2859
  if (focus === rowEnd && focus < this.state.map.cursorLength) {
@@ -2790,6 +2862,7 @@ export class CakeEditor {
2790
2862
  layout,
2791
2863
  offset: focus + 1,
2792
2864
  affinity: "forward",
2865
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2793
2866
  });
2794
2867
  target = next.rowEnd;
2795
2868
  }
@@ -2809,6 +2882,7 @@ export class CakeEditor {
2809
2882
  layout,
2810
2883
  offset: focus,
2811
2884
  affinity,
2885
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2812
2886
  });
2813
2887
  let target = rowStart;
2814
2888
  if (focus === rowStart && focus > 0) {
@@ -2817,6 +2891,7 @@ export class CakeEditor {
2817
2891
  layout,
2818
2892
  offset: focus - 1,
2819
2893
  affinity: "backward",
2894
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2820
2895
  });
2821
2896
  target = previous.rowStart;
2822
2897
  }
@@ -2836,6 +2911,7 @@ export class CakeEditor {
2836
2911
  layout,
2837
2912
  offset: focus,
2838
2913
  affinity,
2914
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2839
2915
  });
2840
2916
  let target = rowEnd;
2841
2917
  if (focus === rowEnd && focus < this.state.map.cursorLength) {
@@ -2844,6 +2920,7 @@ export class CakeEditor {
2844
2920
  layout,
2845
2921
  offset: focus + 1,
2846
2922
  affinity: "forward",
2923
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2847
2924
  });
2848
2925
  target = next.rowEnd;
2849
2926
  }
@@ -2862,8 +2939,8 @@ export class CakeEditor {
2862
2939
  if (selection.start === selection.end) {
2863
2940
  return null;
2864
2941
  }
2865
- const lines = getDocLines(this.state.doc);
2866
- const lineOffsets = getLineOffsets(lines);
2942
+ const lines = this.textModel.getLines();
2943
+ const lineOffsets = this.textModel.getLineOffsets();
2867
2944
  const selStart = Math.min(selection.start, selection.end);
2868
2945
  const selEnd = Math.max(selection.start, selection.end);
2869
2946
  const fullLineInfo = this.detectFullLineSelection(selStart, selEnd, lines, lineOffsets);
@@ -2918,26 +2995,25 @@ export class CakeEditor {
2918
2995
  return selectionFromAnchor(anchor, nextFocus, direction);
2919
2996
  }
2920
2997
  moveOffsetByWord(offset, direction) {
2921
- const lines = getDocLines(this.state.doc);
2922
- const visibleText = getVisibleText(lines);
2998
+ const visibleText = this.textModel.getVisibleText();
2923
2999
  if (!visibleText) {
2924
3000
  return 0;
2925
3001
  }
2926
- const visibleOffset = cursorOffsetToVisibleOffset(lines, offset);
3002
+ const visibleOffset = this.textModel.cursorOffsetToVisibleOffset(offset);
2927
3003
  const nextVisibleOffset = direction === "backward"
2928
3004
  ? prevWordBreak(visibleText, visibleOffset)
2929
3005
  : nextWordBreak(visibleText, visibleOffset);
2930
- return visibleOffsetToCursorOffset(lines, nextVisibleOffset) ?? offset;
3006
+ return this.visibleOffsetToCursorOffset(nextVisibleOffset) ?? offset;
2931
3007
  }
2932
3008
  deleteToVisualRowStart() {
2933
3009
  const selection = this.state.selection;
2934
- const lines = getDocLines(this.state.doc);
2935
- const { lineIndex, offsetInLine } = resolveOffsetToLine(lines, selection.start);
3010
+ const lines = this.textModel.getLines();
3011
+ const { lineIndex, offsetInLine } = this.textModel.resolveOffsetToLine(selection.start);
2936
3012
  const lineInfo = lines[lineIndex];
2937
3013
  if (!lineInfo) {
2938
3014
  return;
2939
3015
  }
2940
- const lineOffsets = getLineOffsets(lines);
3016
+ const lineOffsets = this.textModel.getLineOffsets();
2941
3017
  const lineStart = lineOffsets[lineIndex] ?? 0;
2942
3018
  const isLineStart = offsetInLine === 0;
2943
3019
  const isCollapsed = selection.start === selection.end;
@@ -2962,6 +3038,7 @@ export class CakeEditor {
2962
3038
  layout,
2963
3039
  offset: selection.start,
2964
3040
  affinity: "backward",
3041
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
2965
3042
  });
2966
3043
  const isVisualRowStart = selection.start === rowStart;
2967
3044
  if (isCollapsed && isVisualRowStart) {
@@ -2978,11 +3055,11 @@ export class CakeEditor {
2978
3055
  }
2979
3056
  handleIndent() {
2980
3057
  const selection = this.state.selection;
2981
- const lines = getDocLines(this.state.doc);
3058
+ const lines = this.textModel.getLines();
2982
3059
  const TAB_SPACES = " ";
2983
3060
  const isCollapsed = selection.start === selection.end;
2984
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
2985
- 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;
2986
3063
  const affectsMultipleLines = endLineIndex > startLineIndex;
2987
3064
  // Check if the current line is a list item by checking source text
2988
3065
  const sourceLines = this.state.source.split("\n");
@@ -3003,7 +3080,7 @@ export class CakeEditor {
3003
3080
  // insert at caret position. Otherwise indent at line start.
3004
3081
  if (isCollapsed) {
3005
3082
  // Check if caret is in middle/end of line (not at start)
3006
- const lineOffsets = getLineOffsets(lines);
3083
+ const lineOffsets = this.textModel.getLineOffsets();
3007
3084
  const lineStart = lineOffsets[startLineIndex] ?? 0;
3008
3085
  const offsetInLine = selection.start - lineStart;
3009
3086
  if (offsetInLine > 0) {
@@ -3055,10 +3132,10 @@ export class CakeEditor {
3055
3132
  }
3056
3133
  handleOutdent() {
3057
3134
  const selection = this.state.selection;
3058
- const lines = getDocLines(this.state.doc);
3135
+ const lines = this.textModel.getLines();
3059
3136
  const TAB_SPACES = " ";
3060
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
3061
- 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;
3062
3139
  const sourceLines = this.state.source.split("\n");
3063
3140
  // Check if the current line is a list item by checking source text
3064
3141
  const startSourceLine = sourceLines[startLineIndex] ?? "";
@@ -3152,8 +3229,7 @@ export class CakeEditor {
3152
3229
  */
3153
3230
  reconcileDomChanges(selection) {
3154
3231
  const domText = this.readDomText();
3155
- const lines = getDocLines(this.state.doc);
3156
- const modelText = getVisibleText(lines);
3232
+ const modelText = this.textModel.getVisibleText();
3157
3233
  if (domText === modelText) {
3158
3234
  return false;
3159
3235
  }
@@ -3175,8 +3251,8 @@ export class CakeEditor {
3175
3251
  // The replacement text from DOM
3176
3252
  const replacementText = domText.slice(prefixLen, domText.length - suffixLen);
3177
3253
  // Convert visible text offsets to cursor offsets
3178
- const cursorStart = visibleOffsetToCursorOffset(lines, prefixLen);
3179
- const cursorEnd = visibleOffsetToCursorOffset(lines, modelText.length - suffixLen);
3254
+ const cursorStart = this.visibleOffsetToCursorOffset(prefixLen);
3255
+ const cursorEnd = this.visibleOffsetToCursorOffset(modelText.length - suffixLen);
3180
3256
  if (cursorStart === null || cursorEnd === null) {
3181
3257
  // Fallback: rebuild state from scratch (loses formatting)
3182
3258
  // History was already recorded above
@@ -3464,12 +3540,13 @@ export class CakeEditor {
3464
3540
  this.syncSelectionRects([]);
3465
3541
  return;
3466
3542
  }
3467
- const lines = getDocLines(this.state.doc);
3543
+ const lines = this.textModel.getLines();
3468
3544
  const geometry = getSelectionGeometry({
3469
3545
  root: this.contentRoot,
3470
3546
  container: this.container,
3471
3547
  docLines: lines,
3472
3548
  selection,
3549
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
3473
3550
  });
3474
3551
  this.lastFocusRect = geometry.focusRect;
3475
3552
  this.syncSelectionRects(geometry.selectionRects);
@@ -3483,12 +3560,13 @@ export class CakeEditor {
3483
3560
  return;
3484
3561
  }
3485
3562
  this.contentRoot.classList.remove("cake-touch-mode");
3486
- const lines = getDocLines(this.state.doc);
3563
+ const lines = this.textModel.getLines();
3487
3564
  const geometry = getSelectionGeometry({
3488
3565
  root: this.contentRoot,
3489
3566
  container: this.container,
3490
3567
  docLines: lines,
3491
3568
  selection: this.state.selection,
3569
+ resolveOffsetToLine: (offset) => this.textModel.resolveOffsetToLine(offset),
3492
3570
  });
3493
3571
  this.lastFocusRect = geometry.focusRect;
3494
3572
  this.syncSelectionRects(geometry.selectionRects);
@@ -3673,7 +3751,7 @@ export class CakeEditor {
3673
3751
  if (!this.contentRoot) {
3674
3752
  return null;
3675
3753
  }
3676
- const lines = getDocLines(this.state.doc);
3754
+ const lines = this.textModel.getLines();
3677
3755
  const hit = hitTestFromLayout({
3678
3756
  clientX,
3679
3757
  clientY,
@@ -3736,10 +3814,10 @@ export class CakeEditor {
3736
3814
  const lineIndexAttr = blockElement?.getAttribute("data-line-index") ?? null;
3737
3815
  if (lineIndexAttr !== null) {
3738
3816
  const lineIndex = Number.parseInt(lineIndexAttr, 10);
3739
- const lines = getDocLines(this.state.doc);
3817
+ const lines = this.textModel.getLines();
3740
3818
  const lineInfo = lines[lineIndex];
3741
3819
  if (lineInfo?.isAtomic) {
3742
- const lineOffsets = getLineOffsets(lines);
3820
+ const lineOffsets = this.textModel.getLineOffsets();
3743
3821
  const lineStart = lineOffsets[lineIndex] ?? 0;
3744
3822
  const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
3745
3823
  const atomicSelection = {
@@ -3845,8 +3923,8 @@ export class CakeEditor {
3845
3923
  return;
3846
3924
  }
3847
3925
  // Check if this is a full line selection (required for line drag)
3848
- const lines = getDocLines(this.state.doc);
3849
- const lineOffsets = getLineOffsets(lines);
3926
+ const lines = this.textModel.getLines();
3927
+ const lineOffsets = this.textModel.getLineOffsets();
3850
3928
  // Find which line the selection starts on
3851
3929
  // We need to handle the case where selStart might be at the newline position
3852
3930
  // of the previous line (offset - 1) due to DOM selection normalization
@@ -4258,10 +4336,10 @@ export class CakeEditor {
4258
4336
  return;
4259
4337
  }
4260
4338
  // Get the plain text and source text for the selection
4261
- const lines = getDocLines(this.state.doc);
4262
- const visibleText = getVisibleText(lines);
4263
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
4264
- 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);
4265
4343
  const plainText = visibleText.slice(visibleStart, visibleEnd);
4266
4344
  // Get source text for the selection (use backward/forward to capture full markdown syntax)
4267
4345
  const cursorSourceMap = this.state.map;
@@ -4323,10 +4401,10 @@ export class CakeEditor {
4323
4401
  if (selection.start !== selection.end) {
4324
4402
  const start = Math.min(selection.start, selection.end);
4325
4403
  const end = Math.max(selection.start, selection.end);
4326
- const lines = getDocLines(this.state.doc);
4327
- const visibleText = getVisibleText(lines);
4328
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
4329
- 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);
4330
4408
  const plainText = visibleText.slice(visibleStart, visibleEnd);
4331
4409
  const cursorSourceMap = this.state.map;
4332
4410
  const sourceStart = cursorSourceMap.cursorToSource(start, "backward");
@@ -4348,8 +4426,8 @@ export class CakeEditor {
4348
4426
  return;
4349
4427
  }
4350
4428
  // Check if this is a full-line selection - if so, use line-level move
4351
- const lines = getDocLines(this.state.doc);
4352
- const lineOffsets = getLineOffsets(lines);
4429
+ const lines = this.textModel.getLines();
4430
+ const lineOffsets = this.textModel.getLineOffsets();
4353
4431
  const fullLineInfo = this.detectFullLineSelection(dragStart, dragEnd, lines, lineOffsets);
4354
4432
  if (fullLineInfo) {
4355
4433
  // Full line drag - use line-level move
@@ -4500,7 +4578,7 @@ function getVisualRowBoundaries(params) {
4500
4578
  if (!layout || layout.lines.length === 0) {
4501
4579
  return { rowStart: 0, rowEnd: 0 };
4502
4580
  }
4503
- const resolved = resolveOffsetToLine(lines, offset);
4581
+ const resolved = params.resolveOffsetToLine(offset);
4504
4582
  const line = layout.lines[resolved.lineIndex];
4505
4583
  if (!line) {
4506
4584
  return { rowStart: 0, rowEnd: 0 };