@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,3589 +0,0 @@
1
- import { createRuntime, isApplyEditCommand, } from "../core/runtime";
2
- import { renderDocContent } from "../dom/render";
3
- import { applyDomSelection, readDomSelection } from "../dom/dom-selection";
4
- import { bundledExtensions } from "../extensions";
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
- import { hitTestFromLayout, measureLayoutModelFromDom, } from "./selection/selection-layout-dom";
9
- import { moveSelectionVertically as moveSelectionVerticallyInLayout } from "./selection/selection-navigation";
10
- import { isMacPlatform } from "../shared/platform";
11
- import { getWordBoundaries, nextWordBreak, prevWordBreak, } from "../shared/word-break";
12
- import { htmlToMarkdownForPaste } from "../../cake/clipboard";
13
- const defaultSelection = { start: 0, end: 0, affinity: "forward" };
14
- const COMPOSITION_COMMIT_CLEAR_DELAY_MS = 50;
15
- const HISTORY_GROUPING_INTERVAL_MS = 500;
16
- const MAX_UNDO_STACK_SIZE = 100;
17
- export class CakeEngine {
18
- get state() {
19
- return this._state;
20
- }
21
- set state(value) {
22
- this._state = value;
23
- }
24
- getLastRenderPerf() {
25
- return this.lastRenderPerf;
26
- }
27
- isEventTargetInContentRoot(target) {
28
- // In real browser events, `target` is usually a descendant of `contentRoot`.
29
- // In tests (and some synthetic events), `dispatchEvent` is called on the
30
- // container directly, making `target === container`. Treat that as "inside"
31
- // so the engine can still respond to input/click/keydown in those cases.
32
- if (target === this.container) {
33
- return true;
34
- }
35
- if (!this.contentRoot) {
36
- return false;
37
- }
38
- return (target instanceof Node &&
39
- (target === this.contentRoot || this.contentRoot.contains(target)));
40
- }
41
- // Detect if this is a touch-primary device (mobile/tablet)
42
- // We check for touch support AND coarse pointer to exclude laptops with touchscreens
43
- isTouchDevice() {
44
- return ("ontouchstart" in window &&
45
- window.matchMedia("(pointer: coarse)").matches);
46
- }
47
- constructor(options) {
48
- this.contentRoot = null;
49
- this.domMap = null;
50
- this.isApplyingSelection = false;
51
- this.isComposing = false;
52
- this.beforeInputHandled = false;
53
- this.beforeInputResetId = null;
54
- this.keydownHandledBeforeInput = false;
55
- this.suppressSelectionChange = false;
56
- this.suppressSelectionChangeResetId = null;
57
- this.ignoreTouchNativeSelectionUntil = null;
58
- this.blockTrustedTextDrag = false;
59
- this.selectedAtomicLineIndex = null;
60
- this.lastAppliedSelection = null;
61
- this.compositionCommit = false;
62
- this.compositionCommitTimeoutId = null;
63
- this.overlayRoot = null;
64
- this.caretElement = null;
65
- this.caretBlinkTimeoutId = null;
66
- this.overlayUpdateId = null;
67
- this.scrollCaretIntoViewId = null;
68
- this.selectionRectElements = [];
69
- this.lastSelectionRects = null;
70
- this.extensionsRoot = null;
71
- this.placeholderRoot = null;
72
- this.resizeObserver = null;
73
- this.lastFocusRect = null;
74
- this.verticalNavGoalX = null;
75
- this.lastRenderPerf = null;
76
- this.history = {
77
- undoStack: [],
78
- redoStack: [],
79
- lastEditAt: 0,
80
- lastKind: null,
81
- };
82
- // Pending hit from pointerdown to use in click handler
83
- // This ensures accurate click positioning even with emoji/variable-width characters
84
- this.pendingClickHit = null;
85
- this.handleBeforeInputBound = this.handleBeforeInput.bind(this);
86
- this.handleInputBound = this.handleInput.bind(this);
87
- this.handleCompositionStartBound = this.handleCompositionStart.bind(this);
88
- this.handleCompositionEndBound = this.handleCompositionEnd.bind(this);
89
- this.handleSelectionChangeBound = this.handleSelectionChange.bind(this);
90
- this.handleFocusInBound = this.handleFocusIn.bind(this);
91
- this.handleFocusOutBound = this.handleFocusOut.bind(this);
92
- this.handleScrollBound = this.handleScroll.bind(this);
93
- this.handleResizeBound = this.handleResize.bind(this);
94
- this.handleClickBound = this.handleClick.bind(this);
95
- this.handleKeyDownBound = this.handleKeyDown.bind(this);
96
- this.handlePasteBound = this.handlePaste.bind(this);
97
- this.handleCopyBound = this.handleCopy.bind(this);
98
- this.handleCutBound = this.handleCut.bind(this);
99
- this.handlePointerDownBound = this.handlePointerDown.bind(this);
100
- this.handlePointerMoveBound = this.handlePointerMove.bind(this);
101
- this.handlePointerUpBound = this.handlePointerUp.bind(this);
102
- this.handleDragStartBound = this.handleDragStart.bind(this);
103
- this.handleDragOverBound = this.handleDragOver.bind(this);
104
- this.handleDropBound = this.handleDrop.bind(this);
105
- this.handleDragEndBound = this.handleDragEnd.bind(this);
106
- // Drag state for line moving
107
- this.dragState = null;
108
- this.dropIndicator = null;
109
- // Text drag state for DragEvent-based drag and drop
110
- this.textDragState = null;
111
- this.selectionDragState = null;
112
- // Track if user is creating selection via drag (for single-click handling)
113
- // We track the starting position and only consider it "moved" if the mouse
114
- // moved more than a threshold distance (to avoid false positives from
115
- // micro-movements or synthetic pointermove events)
116
- this.pointerDownPosition = null;
117
- this.hasMovedSincePointerDown = false;
118
- // Touch interaction tracking - when a recent touch occurred, we use native
119
- // selection handling and hide the custom caret overlay
120
- this.lastTouchTime = 0;
121
- this.container = options.container;
122
- this.contentRoot = options.contentRoot ?? null;
123
- this.extensions = options.extensions ?? bundledExtensions;
124
- this.runtime = createRuntime(this.extensions);
125
- this.state = this.runtime.createState(options.value, options.selection ?? defaultSelection);
126
- this.onChange = options.onChange;
127
- this.onSelectionChange = options.onSelectionChange;
128
- this.readOnly = options.readOnly ?? false;
129
- this.spellCheckEnabled = options.spellCheckEnabled ?? true;
130
- this.render();
131
- this.attachListeners();
132
- }
133
- destroy() {
134
- this.detachListeners();
135
- this.clearCaretBlinkTimer();
136
- if (this.overlayUpdateId !== null) {
137
- window.cancelAnimationFrame(this.overlayUpdateId);
138
- this.overlayUpdateId = null;
139
- }
140
- if (this.scrollCaretIntoViewId !== null) {
141
- window.cancelAnimationFrame(this.scrollCaretIntoViewId);
142
- this.scrollCaretIntoViewId = null;
143
- }
144
- }
145
- setReadOnly(readOnly) {
146
- this.readOnly = readOnly;
147
- this.updateContentRootAttributes();
148
- }
149
- setSpellCheckEnabled(enabled) {
150
- this.spellCheckEnabled = enabled;
151
- this.updateContentRootAttributes();
152
- }
153
- getValue() {
154
- return this.state.source;
155
- }
156
- getSelection() {
157
- return this.state.selection;
158
- }
159
- getCursorLength() {
160
- return this.state.map.cursorLength;
161
- }
162
- getFocusRect() {
163
- return this.lastFocusRect;
164
- }
165
- getContainer() {
166
- return this.container;
167
- }
168
- getContentRoot() {
169
- return this.contentRoot;
170
- }
171
- getLines() {
172
- return getDocLines(this.state.doc);
173
- }
174
- getOverlayRoot() {
175
- return this.ensureExtensionsRoot();
176
- }
177
- // Placeholder text is provided by the caller via the container's
178
- // `data-placeholder` attribute (set by the React wrapper).
179
- // The engine owns the placeholder element so it survives internal renders.
180
- syncPlaceholder() {
181
- this.updatePlaceholder();
182
- }
183
- insertText(text) {
184
- if (this.readOnly) {
185
- return;
186
- }
187
- if (!text) {
188
- return;
189
- }
190
- this.applyEdit({ type: "insert", text });
191
- }
192
- replaceText(oldText, newText) {
193
- if (this.readOnly) {
194
- return;
195
- }
196
- if (!oldText) {
197
- return;
198
- }
199
- const index = this.state.source.indexOf(oldText);
200
- if (index === -1) {
201
- return;
202
- }
203
- const nextSource = this.state.source.slice(0, index) +
204
- newText +
205
- this.state.source.slice(index + oldText.length);
206
- this.recordHistory("replace");
207
- this.state = this.runtime.createState(nextSource, this.state.selection);
208
- this.render();
209
- this.onChange?.(this.state.source, this.state.selection);
210
- this.scheduleOverlayUpdate();
211
- this.scheduleScrollCaretIntoView();
212
- }
213
- setSelection(selection) {
214
- this.state = this.runtime.updateSelection(this.state, selection, {
215
- kind: "programmatic",
216
- });
217
- if (!this.isComposing) {
218
- this.applySelection(this.state.selection);
219
- }
220
- this.scheduleScrollCaretIntoView();
221
- }
222
- setValue({ value, selection }) {
223
- const valueChanged = value !== this.state.source;
224
- if (!valueChanged && selection === undefined) {
225
- return;
226
- }
227
- if (!valueChanged && selection !== undefined) {
228
- this.setSelection(selection);
229
- return;
230
- }
231
- const nextSelection = selection ?? this.state.selection;
232
- this.state = this.runtime.createState(value, nextSelection);
233
- this.render();
234
- }
235
- focus(selection) {
236
- // Only set selection if we don't already have focus.
237
- // This prevents stale selection from resetting the current selection
238
- // during fast typing when React effects fire with stale props.
239
- if (selection && !this.hasFocus()) {
240
- this.setSelection(selection);
241
- }
242
- (this.contentRoot ?? this.container).focus();
243
- }
244
- blur() {
245
- (this.contentRoot ?? this.container).blur();
246
- }
247
- hasFocus() {
248
- const active = document.activeElement;
249
- if (!active) {
250
- return false;
251
- }
252
- if (this.contentRoot) {
253
- return active === this.contentRoot || this.contentRoot.contains(active);
254
- }
255
- return active === this.container || this.container.contains(active);
256
- }
257
- selectAll() {
258
- const length = this.state.map.cursorLength;
259
- this.setSelection({ start: 0, end: length, affinity: "forward" });
260
- }
261
- undo() {
262
- const entry = this.history.undoStack.pop();
263
- if (!entry) {
264
- return;
265
- }
266
- this.history.redoStack.push({
267
- source: this.state.source,
268
- selection: this.state.selection,
269
- });
270
- this.history.lastKind = null;
271
- this.state = this.runtime.createState(entry.source, entry.selection);
272
- this.render();
273
- this.onChange?.(this.state.source, this.state.selection);
274
- }
275
- redo() {
276
- const entry = this.history.redoStack.pop();
277
- if (!entry) {
278
- return;
279
- }
280
- this.history.undoStack.push({
281
- source: this.state.source,
282
- selection: this.state.selection,
283
- });
284
- this.history.lastKind = null;
285
- this.state = this.runtime.createState(entry.source, entry.selection);
286
- this.render();
287
- this.onChange?.(this.state.source, this.state.selection);
288
- }
289
- canUndo() {
290
- return this.history.undoStack.length > 0;
291
- }
292
- canRedo() {
293
- return this.history.redoStack.length > 0;
294
- }
295
- executeCommand(command, options) {
296
- // Check for openPopover flag on any command (used by link extension)
297
- const shouldOpenLinkPopover = "openPopover" in command && command.openPopover === true;
298
- const nextState = this.runtime.applyEdit(command, this.state);
299
- if (nextState === this.state) {
300
- return false;
301
- }
302
- this.recordHistory(command.type);
303
- this.state = nextState;
304
- this.render();
305
- // Commands are discrete and often invoked from toolbars; ensure the selection
306
- // overlay is updated immediately rather than waiting for the next animation
307
- // frame (which can vary across engines in test and headless environments).
308
- this.flushOverlayUpdate();
309
- this.onChange?.(this.state.source, this.state.selection);
310
- this.scheduleScrollCaretIntoView();
311
- if (shouldOpenLinkPopover) {
312
- // The popover is rendered via React overlays, and its event listeners are
313
- // registered in `useEffect`. Dispatch after a frame so the overlay has a
314
- // chance to commit and attach listeners across engines.
315
- window.requestAnimationFrame(() => {
316
- this.openLinkPopoverForSelection(true);
317
- });
318
- }
319
- if (options?.restoreFocus) {
320
- this.focus();
321
- this.applySelection(this.state.selection);
322
- }
323
- return true;
324
- }
325
- attachListeners() {
326
- this.container.addEventListener("beforeinput", this.handleBeforeInputBound);
327
- this.container.addEventListener("input", this.handleInputBound);
328
- this.container.addEventListener("compositionstart", this.handleCompositionStartBound);
329
- this.container.addEventListener("compositionend", this.handleCompositionEndBound);
330
- this.container.addEventListener("focusin", this.handleFocusInBound);
331
- this.container.addEventListener("focusout", this.handleFocusOutBound);
332
- document.addEventListener("selectionchange", this.handleSelectionChangeBound);
333
- this.container.addEventListener("scroll", this.handleScrollBound);
334
- window.addEventListener("resize", this.handleResizeBound);
335
- this.resizeObserver = new ResizeObserver(() => {
336
- this.syncPlaceholderPosition();
337
- this.scheduleOverlayUpdate();
338
- });
339
- this.resizeObserver.observe(this.container);
340
- this.container.addEventListener("click", this.handleClickBound);
341
- this.container.addEventListener("keydown", this.handleKeyDownBound);
342
- this.container.addEventListener("paste", this.handlePasteBound);
343
- this.container.addEventListener("copy", this.handleCopyBound);
344
- this.container.addEventListener("cut", this.handleCutBound);
345
- this.container.addEventListener("pointerdown", this.handlePointerDownBound);
346
- this.container.addEventListener("pointermove", this.handlePointerMoveBound);
347
- this.container.addEventListener("pointerup", this.handlePointerUpBound);
348
- }
349
- attachDragListeners() {
350
- if (!this.contentRoot) {
351
- return;
352
- }
353
- this.contentRoot.addEventListener("dragstart", this.handleDragStartBound);
354
- this.contentRoot.addEventListener("dragover", this.handleDragOverBound);
355
- this.contentRoot.addEventListener("drop", this.handleDropBound);
356
- this.contentRoot.addEventListener("dragend", this.handleDragEndBound);
357
- }
358
- detachDragListeners() {
359
- if (!this.contentRoot) {
360
- return;
361
- }
362
- this.contentRoot.removeEventListener("dragstart", this.handleDragStartBound);
363
- this.contentRoot.removeEventListener("dragover", this.handleDragOverBound);
364
- this.contentRoot.removeEventListener("drop", this.handleDropBound);
365
- this.contentRoot.removeEventListener("dragend", this.handleDragEndBound);
366
- }
367
- detachListeners() {
368
- this.container.removeEventListener("beforeinput", this.handleBeforeInputBound);
369
- this.container.removeEventListener("input", this.handleInputBound);
370
- this.container.removeEventListener("compositionstart", this.handleCompositionStartBound);
371
- this.container.removeEventListener("compositionend", this.handleCompositionEndBound);
372
- this.container.removeEventListener("focusin", this.handleFocusInBound);
373
- this.container.removeEventListener("focusout", this.handleFocusOutBound);
374
- document.removeEventListener("selectionchange", this.handleSelectionChangeBound);
375
- this.container.removeEventListener("scroll", this.handleScrollBound);
376
- window.removeEventListener("resize", this.handleResizeBound);
377
- this.resizeObserver?.disconnect();
378
- this.resizeObserver = null;
379
- this.container.removeEventListener("click", this.handleClickBound);
380
- this.container.removeEventListener("keydown", this.handleKeyDownBound);
381
- this.container.removeEventListener("paste", this.handlePasteBound);
382
- this.container.removeEventListener("copy", this.handleCopyBound);
383
- this.container.removeEventListener("cut", this.handleCutBound);
384
- this.container.removeEventListener("pointerdown", this.handlePointerDownBound);
385
- this.container.removeEventListener("pointermove", this.handlePointerMoveBound);
386
- this.container.removeEventListener("pointerup", this.handlePointerUpBound);
387
- this.detachDragListeners();
388
- }
389
- handleFocusIn() {
390
- // Focus-in is a discrete event; update selection overlay immediately so
391
- // selection/caret visuals don't lag behind across engines.
392
- this.flushOverlayUpdate();
393
- }
394
- handleFocusOut() {
395
- queueMicrotask(() => {
396
- this.scheduleOverlayUpdate();
397
- });
398
- }
399
- render() {
400
- const perfEnabled = this.container.dataset.cakePerf === "1";
401
- let perfStart = 0;
402
- let renderStart = 0;
403
- let renderAndMapMs = 0;
404
- let applySelectionMs = 0;
405
- if (perfEnabled) {
406
- perfStart = performance.now();
407
- }
408
- if (!this.contentRoot) {
409
- // Overlay roots are positioned absolutely; ensure the container forms a
410
- // positioning context so browser hit-testing APIs (caretRangeFromPoint)
411
- // keep resolving into line nodes after scrolling.
412
- const containerPosition = window.getComputedStyle(this.container).position;
413
- if (containerPosition === "static") {
414
- this.container.style.position = "relative";
415
- }
416
- this.contentRoot = document.createElement("div");
417
- this.contentRoot.className = "cake-content";
418
- }
419
- // On touch devices, add touch mode class immediately to use native caret
420
- if (this.isTouchDevice()) {
421
- this.contentRoot.classList.add("cake-touch-mode");
422
- }
423
- this.updateContentRootAttributes();
424
- if (!this.overlayRoot) {
425
- const overlay = this.ensureOverlayRoot();
426
- const extensionsRoot = this.extensionsRoot;
427
- const existingContainerChildren = Array.from(this.container.childNodes);
428
- const isCakeManagedContainerChild = (node) => node instanceof Element &&
429
- (node.classList.contains("cake-content") ||
430
- node.classList.contains("cake-selection-overlay") ||
431
- node.classList.contains("cake-extension-overlay") ||
432
- node.classList.contains("cake-placeholder"));
433
- const preservedContainerChildren = existingContainerChildren.filter((node) => !isCakeManagedContainerChild(node));
434
- // If contentRoot was provided externally (React), append siblings;
435
- // otherwise replace container children.
436
- if (this.contentRoot.parentElement === this.container) {
437
- this.container.append(overlay);
438
- if (extensionsRoot && !extensionsRoot.isConnected) {
439
- this.container.append(extensionsRoot);
440
- }
441
- }
442
- else {
443
- this.container.replaceChildren(this.contentRoot, overlay, ...(extensionsRoot ? [extensionsRoot] : []), ...preservedContainerChildren);
444
- }
445
- this.attachDragListeners();
446
- }
447
- if (perfEnabled) {
448
- renderStart = performance.now();
449
- }
450
- const { content, map } = renderDocContent(this.state.doc, this.extensions, this.contentRoot);
451
- const existingChildren = Array.from(this.contentRoot.childNodes);
452
- const isManagedChild = (node) => node instanceof Element &&
453
- (node.hasAttribute("data-line-index") ||
454
- node.hasAttribute("data-block-wrapper"));
455
- const existingManagedChildren = existingChildren.filter(isManagedChild);
456
- const preservedChildren = existingChildren.filter((node) => !isManagedChild(node));
457
- const needsUpdate = content.length !== existingManagedChildren.length ||
458
- content.some((node, i) => node !== existingManagedChildren[i]);
459
- if (needsUpdate) {
460
- // Preserve non-managed direct children so browser extensions (e.g. Grammarly)
461
- // can inject helper elements without getting deleted on every render.
462
- this.contentRoot.replaceChildren(...content, ...preservedChildren);
463
- }
464
- this.domMap = map;
465
- if (perfEnabled) {
466
- renderAndMapMs = performance.now() - renderStart;
467
- }
468
- if (!this.isComposing && this.hasFocus()) {
469
- const selectionStart = perfEnabled ? performance.now() : 0;
470
- this.applySelection(this.state.selection);
471
- if (perfEnabled) {
472
- applySelectionMs = performance.now() - selectionStart;
473
- }
474
- }
475
- if (perfEnabled) {
476
- const totalMs = performance.now() - perfStart;
477
- this.lastRenderPerf = {
478
- totalMs,
479
- renderAndMapMs: renderAndMapMs,
480
- applySelectionMs: applySelectionMs,
481
- didUpdateDom: needsUpdate,
482
- blockCount: this.state.doc.blocks.length,
483
- runCount: map.runs.length,
484
- };
485
- }
486
- this.updatePlaceholder();
487
- this.scheduleOverlayUpdate();
488
- }
489
- isEmptyParagraphDoc() {
490
- const blocks = this.state.doc.blocks;
491
- if (blocks.length !== 1) {
492
- return false;
493
- }
494
- const only = blocks[0];
495
- if (!only || only.type !== "paragraph") {
496
- return false;
497
- }
498
- const hasVisibleInlineContent = (inline) => {
499
- if (inline.type === "text") {
500
- return inline.text.length > 0;
501
- }
502
- if (inline.type === "inline-wrapper") {
503
- return inline.children.some(hasVisibleInlineContent);
504
- }
505
- // Atoms represent visible content (images, embeds, etc.)
506
- return inline.type === "inline-atom";
507
- };
508
- // Treat "empty" as truly no visible content.
509
- return !only.content.some(hasVisibleInlineContent);
510
- }
511
- updatePlaceholder() {
512
- const placeholderText = this.container.dataset.placeholder;
513
- const shouldShow = Boolean(placeholderText) && this.isEmptyParagraphDoc();
514
- if (!this.placeholderRoot) {
515
- this.placeholderRoot = document.createElement("div");
516
- this.placeholderRoot.className = "cake-placeholder";
517
- this.placeholderRoot.style.position = "absolute";
518
- this.placeholderRoot.style.pointerEvents = "none";
519
- }
520
- if (!shouldShow) {
521
- if (this.placeholderRoot.isConnected) {
522
- this.placeholderRoot.remove();
523
- }
524
- this.placeholderRoot.textContent = "";
525
- return;
526
- }
527
- this.placeholderRoot.textContent = placeholderText ?? "";
528
- if (!this.placeholderRoot.isConnected) {
529
- this.container.prepend(this.placeholderRoot);
530
- }
531
- this.syncPlaceholderPosition();
532
- }
533
- syncPlaceholderPosition() {
534
- if (!this.placeholderRoot || !this.contentRoot) {
535
- return;
536
- }
537
- const containerRect = this.container.getBoundingClientRect();
538
- const contentRect = this.contentRoot.getBoundingClientRect();
539
- this.placeholderRoot.style.top = `${contentRect.top - containerRect.top}px`;
540
- this.placeholderRoot.style.left = `${contentRect.left - containerRect.left}px`;
541
- this.placeholderRoot.style.width = `${contentRect.width}px`;
542
- this.placeholderRoot.style.height = `${contentRect.height}px`;
543
- }
544
- updateContentRootAttributes() {
545
- if (!this.contentRoot) {
546
- return;
547
- }
548
- this.contentRoot.contentEditable = this.readOnly ? "false" : "true";
549
- this.contentRoot.spellcheck = this.spellCheckEnabled;
550
- }
551
- applySelection(selection) {
552
- if (!this.contentRoot) {
553
- return;
554
- }
555
- if (!this.domMap) {
556
- return;
557
- }
558
- this.isApplyingSelection = true;
559
- applyDomSelection(selection, this.domMap);
560
- // Read back what the DOM selection actually became (browser may normalize it)
561
- this.lastAppliedSelection = readDomSelection(this.domMap) ?? selection;
562
- queueMicrotask(() => {
563
- this.isApplyingSelection = false;
564
- });
565
- this.scheduleOverlayUpdate();
566
- }
567
- handleSelectionChange() {
568
- if (this.isComposing) {
569
- return;
570
- }
571
- if (this.ignoreTouchNativeSelectionUntil !== null &&
572
- performance.now() < this.ignoreTouchNativeSelectionUntil) {
573
- return;
574
- }
575
- if (this.suppressSelectionChange) {
576
- return;
577
- }
578
- if (this.isApplyingSelection) {
579
- return;
580
- }
581
- const domSelection = window.getSelection();
582
- if (!domSelection || domSelection.rangeCount === 0) {
583
- return;
584
- }
585
- const anchorNode = domSelection.anchorNode;
586
- const focusNode = domSelection.focusNode;
587
- if (!anchorNode ||
588
- !focusNode ||
589
- (!this.container.contains(anchorNode) &&
590
- !this.container.contains(focusNode))) {
591
- return;
592
- }
593
- if (!this.domMap) {
594
- return;
595
- }
596
- const selection = readDomSelection(this.domMap);
597
- if (!selection) {
598
- return;
599
- }
600
- if (this.lastAppliedSelection &&
601
- selectionsEqual(selection, this.lastAppliedSelection)) {
602
- return;
603
- }
604
- if (this.lastAppliedSelection &&
605
- selection.start === this.lastAppliedSelection.start &&
606
- selection.end === this.lastAppliedSelection.end &&
607
- selection.affinity !== this.lastAppliedSelection.affinity) {
608
- return;
609
- }
610
- const previous = this.state.selection;
611
- const isSameSelection = previous.start === selection.start &&
612
- previous.end === selection.end &&
613
- previous.affinity === selection.affinity;
614
- if (isSameSelection) {
615
- this.lastAppliedSelection = selection;
616
- return;
617
- }
618
- // For collapsed selections (cursor moves), check if we landed on an atomic block
619
- // and skip over it in the direction of movement
620
- const adjustedSelection = this.adjustSelectionForAtomicBlocks(selection, previous);
621
- this.selectedAtomicLineIndex = null;
622
- this.state = this.runtime.updateSelection(this.state, adjustedSelection, {
623
- kind: "dom",
624
- });
625
- this.onSelectionChange?.(this.state.selection);
626
- this.scheduleOverlayUpdate();
627
- this.scheduleScrollCaretIntoView();
628
- // If we adjusted selection, apply it to DOM
629
- if (adjustedSelection.start !== selection.start ||
630
- adjustedSelection.end !== selection.end) {
631
- this.applySelection(adjustedSelection);
632
- }
633
- }
634
- syncSelectionFromDom() {
635
- if (this.isComposing) {
636
- return;
637
- }
638
- if (!this.domMap) {
639
- return;
640
- }
641
- const selection = readDomSelection(this.domMap);
642
- if (!selection) {
643
- return;
644
- }
645
- this.selectedAtomicLineIndex = null;
646
- this.state = this.runtime.updateSelection(this.state, selection, {
647
- kind: "dom",
648
- });
649
- this.onSelectionChange?.(this.state.selection);
650
- this.lastAppliedSelection = this.state.selection;
651
- this.scheduleOverlayUpdate();
652
- this.scheduleScrollCaretIntoView();
653
- }
654
- adjustSelectionForAtomicBlocks(selection, previous) {
655
- // Only adjust collapsed selections (cursor navigation)
656
- if (selection.start !== selection.end) {
657
- return selection;
658
- }
659
- const lines = getDocLines(this.state.doc);
660
- const lineOffsets = getLineOffsets(lines);
661
- const { lineIndex } = resolveOffsetToLine(lines, selection.start);
662
- const lineInfo = lines[lineIndex];
663
- if (!lineInfo || !lineInfo.isAtomic) {
664
- return selection;
665
- }
666
- // Determine direction of movement
667
- // If previous was collapsed, use position difference. Otherwise, direction is forward.
668
- const direction = previous.start === previous.end && selection.start < previous.start
669
- ? "backward"
670
- : "forward";
671
- const lineStart = lineOffsets[lineIndex] ?? 0;
672
- const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
673
- if (direction === "forward") {
674
- // Skip to end of atomic line (after newline if not last line)
675
- const isLastLine = lineIndex === lines.length - 1;
676
- const newOffset = isLastLine ? lineEnd : lineEnd;
677
- return { ...selection, start: newOffset, end: newOffset };
678
- }
679
- else {
680
- // Skip to before the atomic line (end of previous line)
681
- if (lineIndex === 0) {
682
- return { ...selection, start: 0, end: 0 };
683
- }
684
- const prevLineEnd = lineStart - 1;
685
- return { ...selection, start: prevLineEnd, end: prevLineEnd };
686
- }
687
- }
688
- getAtomicBlockSelectionFromClick(event) {
689
- const target = event.target;
690
- if (!(target instanceof HTMLElement)) {
691
- return null;
692
- }
693
- // Check if clicking on an atomic block element (e.g., image)
694
- const blockElement = target.closest("[data-block-atom]");
695
- if (!blockElement) {
696
- return null;
697
- }
698
- // Get line index from the element
699
- const lineIndexAttr = blockElement.getAttribute("data-line-index");
700
- if (lineIndexAttr === null) {
701
- return null;
702
- }
703
- const lineIndex = parseInt(lineIndexAttr, 10);
704
- if (Number.isNaN(lineIndex)) {
705
- return null;
706
- }
707
- const lines = getDocLines(this.state.doc);
708
- const lineInfo = lines[lineIndex];
709
- if (!lineInfo || !lineInfo.isAtomic) {
710
- return null;
711
- }
712
- // Calculate the selection range for the entire line including newline
713
- const lineOffsets = getLineOffsets(lines);
714
- const lineStart = lineOffsets[lineIndex] ?? 0;
715
- const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
716
- return {
717
- lineIndex,
718
- selection: {
719
- start: lineStart,
720
- end: lineEnd,
721
- affinity: "forward",
722
- },
723
- };
724
- }
725
- handleClick(event) {
726
- if (this.isComposing) {
727
- return;
728
- }
729
- if (this.ignoreTouchNativeSelectionUntil !== null &&
730
- performance.now() < this.ignoreTouchNativeSelectionUntil) {
731
- return;
732
- }
733
- if (!this.contentRoot || !this.domMap) {
734
- return;
735
- }
736
- if (!this.isEventTargetInContentRoot(event.target)) {
737
- return;
738
- }
739
- // For single clicks (detail=1), selection was already applied in pointerdown.
740
- // Skip if user just created a selection via drag (mouse moved since pointer down)
741
- if (event.detail === 1 && !this.hasMovedSincePointerDown) {
742
- // For shift+click, let the browser handle selection extension
743
- // (we didn't capture pendingClickHit for shift+click in pointerdown)
744
- if (event.shiftKey) {
745
- return;
746
- }
747
- // Check if clicking on an atomic block (like an image)
748
- const atomicResult = this.getAtomicBlockSelectionFromClick(event);
749
- if (atomicResult) {
750
- const atomicBlockSelection = atomicResult.selection;
751
- this.pendingClickHit = null;
752
- event.preventDefault();
753
- this.state = this.runtime.updateSelection(this.state, atomicBlockSelection, { kind: "dom" });
754
- this.applySelection(this.state.selection);
755
- this.onSelectionChange?.(this.state.selection);
756
- this.scheduleOverlayUpdate();
757
- this.selectedAtomicLineIndex = atomicResult.lineIndex;
758
- this.suppressSelectionChange = false;
759
- return;
760
- }
761
- // For clicks inside a range selection that weren't dragged, collapse to the clicked position.
762
- // In pointerdown, we deferred the selection to allow for potential drag operations.
763
- // Now that click has fired without dragging, collapse the selection.
764
- const pendingHit = this.pendingClickHit;
765
- const selection = this.state.selection;
766
- if (pendingHit && selection.start !== selection.end) {
767
- const newSelection = {
768
- start: pendingHit.cursorOffset,
769
- end: pendingHit.cursorOffset,
770
- affinity: pendingHit.affinity,
771
- };
772
- this.pendingClickHit = null;
773
- this.suppressSelectionChange = true;
774
- this.state = this.runtime.updateSelection(this.state, newSelection, {
775
- kind: "dom",
776
- });
777
- this.applySelection(this.state.selection);
778
- this.onSelectionChange?.(this.state.selection);
779
- this.scheduleOverlayUpdate();
780
- setTimeout(() => {
781
- this.suppressSelectionChange = false;
782
- }, 0);
783
- return;
784
- }
785
- // Some environments (notably WebKit in certain test/synthetic setups) may
786
- // not dispatch PointerEvents, which means we never ran pointerdown hit
787
- // testing. In that case, fall back to hit-testing on click so caret
788
- // placement is consistent across engines.
789
- if (!pendingHit) {
790
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
791
- if (!hit) {
792
- return;
793
- }
794
- const newSelection = {
795
- start: hit.cursorOffset,
796
- end: hit.cursorOffset,
797
- affinity: hit.affinity,
798
- };
799
- this.pendingClickHit = null;
800
- this.suppressSelectionChange = true;
801
- this.state = this.runtime.updateSelection(this.state, newSelection, {
802
- kind: "dom",
803
- });
804
- this.applySelection(this.state.selection);
805
- this.onSelectionChange?.(this.state.selection);
806
- this.scheduleOverlayUpdate();
807
- setTimeout(() => {
808
- this.suppressSelectionChange = false;
809
- }, 0);
810
- return;
811
- }
812
- // Selection was already applied in pointerdown, just clear the suppress flag
813
- // Use setTimeout to delay clearing so any pending selectionchange events are ignored
814
- // (selectionchange can fire asynchronously after DOM selection changes)
815
- this.pendingClickHit = null;
816
- setTimeout(() => {
817
- this.suppressSelectionChange = false;
818
- }, 0);
819
- return;
820
- }
821
- // Clear pending hit for non-single-click events
822
- this.pendingClickHit = null;
823
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
824
- if (!hit) {
825
- return;
826
- }
827
- const lines = getDocLines(this.state.doc);
828
- if (event.detail === 2) {
829
- const visibleText = getVisibleText(lines);
830
- const visibleOffset = cursorOffsetToVisibleOffset(lines, hit.cursorOffset);
831
- const wordBounds = getWordBoundaries(visibleText, visibleOffset);
832
- const start = visibleOffsetToCursorOffset(lines, wordBounds.start);
833
- const end = visibleOffsetToCursorOffset(lines, wordBounds.end);
834
- if (start === null || end === null) {
835
- this.suppressSelectionChange = false;
836
- return;
837
- }
838
- const selection = {
839
- start,
840
- end,
841
- affinity: "forward",
842
- };
843
- event.preventDefault();
844
- this.state = this.runtime.updateSelection(this.state, selection, {
845
- kind: "dom",
846
- });
847
- this.applySelection(this.state.selection);
848
- this.onSelectionChange?.(this.state.selection);
849
- this.suppressSelectionChange = false;
850
- return;
851
- }
852
- if (event.detail >= 3) {
853
- const lineOffsets = getLineOffsets(lines);
854
- const { lineIndex } = resolveOffsetToLine(lines, hit.cursorOffset);
855
- const lineInfo = lines[lineIndex];
856
- if (!lineInfo) {
857
- this.suppressSelectionChange = false;
858
- return;
859
- }
860
- const lineStart = lineOffsets[lineIndex] ?? 0;
861
- const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
862
- const selection = {
863
- start: lineStart,
864
- end: lineEnd,
865
- affinity: "forward",
866
- };
867
- event.preventDefault();
868
- this.state = this.runtime.updateSelection(this.state, selection, {
869
- kind: "dom",
870
- });
871
- this.applySelection(this.state.selection);
872
- this.onSelectionChange?.(this.state.selection);
873
- this.suppressSelectionChange = false;
874
- }
875
- }
876
- handleKeyDown(event) {
877
- if (this.isComposing) {
878
- return;
879
- }
880
- if (!this.isEventTargetInContentRoot(event.target)) {
881
- return;
882
- }
883
- const mac = isMacPlatform();
884
- const cmdOrCtrl = mac ? event.metaKey : event.ctrlKey;
885
- if (cmdOrCtrl && event.key.toLowerCase() === "a") {
886
- event.preventDefault();
887
- const end = this.state.map.cursorLength;
888
- this.applySelectionUpdate({ start: 0, end, affinity: "forward" }, "keyboard");
889
- return;
890
- }
891
- if (cmdOrCtrl && event.key.toLowerCase() === "z") {
892
- event.preventDefault();
893
- if (event.shiftKey) {
894
- this.redo();
895
- }
896
- else {
897
- this.undo();
898
- }
899
- return;
900
- }
901
- if (cmdOrCtrl && event.key === "y" && !mac) {
902
- event.preventDefault();
903
- this.redo();
904
- return;
905
- }
906
- // Cmd/Ctrl+Enter dispatches a hard line break, which extensions can use
907
- // to exit block wrappers (e.g., exit blockquote, exit code block).
908
- if (cmdOrCtrl && event.key === "Enter") {
909
- event.preventDefault();
910
- this.keydownHandledBeforeInput = true;
911
- this.applyEdit({ type: "insert-hard-line-break" });
912
- // Reset the flag after any synchronous beforeinput events have been processed
913
- queueMicrotask(() => {
914
- this.keydownHandledBeforeInput = false;
915
- });
916
- return;
917
- }
918
- const isLineModifier = mac && event.metaKey;
919
- const isWordModifier = mac ? event.altKey : event.ctrlKey;
920
- const extendSelection = event.shiftKey;
921
- if (isLineModifier && event.key === "Backspace") {
922
- event.preventDefault();
923
- this.keydownHandledBeforeInput = true;
924
- this.deleteToVisualRowStart();
925
- // Reset the flag after any synchronous beforeinput events have been processed
926
- queueMicrotask(() => {
927
- this.keydownHandledBeforeInput = false;
928
- });
929
- return;
930
- }
931
- if (event.key === "ArrowLeft") {
932
- this.verticalNavGoalX = null;
933
- if (isLineModifier) {
934
- const selection = extendSelection
935
- ? this.extendSelectionToVisualRowStart()
936
- : this.moveSelectionToVisualRowStart();
937
- if (selection) {
938
- event.preventDefault();
939
- this.applySelectionUpdate(selection, "keyboard");
940
- }
941
- return;
942
- }
943
- if (isWordModifier) {
944
- event.preventDefault();
945
- const selection = extendSelection
946
- ? this.extendSelectionByWord("backward")
947
- : this.moveSelectionByWord("backward");
948
- this.applySelectionUpdate(selection, "keyboard");
949
- return;
950
- }
951
- if (extendSelection) {
952
- event.preventDefault();
953
- const cursorLength = this.state.map.cursorLength;
954
- const normalized = normalizeSelection(this.state.selection, cursorLength);
955
- const { anchor, focus } = resolveSelectionAnchorAndFocus(normalized);
956
- const nextFocus = this.moveOffsetByChar(focus, "backward") ?? focus;
957
- this.applySelectionUpdate(selectionFromAnchor(anchor, nextFocus, "backward"), "keyboard");
958
- return;
959
- }
960
- const selection = this.moveSelectionByChar("backward");
961
- if (selection) {
962
- event.preventDefault();
963
- this.applySelectionUpdate(selection, "keyboard");
964
- return;
965
- }
966
- return;
967
- }
968
- if (event.key === "ArrowRight") {
969
- this.verticalNavGoalX = null;
970
- if (isLineModifier) {
971
- const selection = extendSelection
972
- ? this.extendSelectionToVisualRowEnd()
973
- : this.moveSelectionToVisualRowEnd();
974
- if (selection) {
975
- event.preventDefault();
976
- this.applySelectionUpdate(selection, "keyboard");
977
- }
978
- return;
979
- }
980
- if (isWordModifier) {
981
- event.preventDefault();
982
- const selection = extendSelection
983
- ? this.extendSelectionByWord("forward")
984
- : this.moveSelectionByWord("forward");
985
- this.applySelectionUpdate(selection, "keyboard");
986
- return;
987
- }
988
- if (extendSelection) {
989
- event.preventDefault();
990
- const cursorLength = this.state.map.cursorLength;
991
- const normalized = normalizeSelection(this.state.selection, cursorLength);
992
- const { anchor, focus } = resolveSelectionAnchorAndFocus(normalized);
993
- const nextFocus = this.moveOffsetByChar(focus, "forward") ?? focus;
994
- this.applySelectionUpdate(selectionFromAnchor(anchor, nextFocus, "forward"), "keyboard");
995
- return;
996
- }
997
- const selection = this.moveSelectionByChar("forward");
998
- if (selection) {
999
- event.preventDefault();
1000
- this.applySelectionUpdate(selection, "keyboard");
1001
- return;
1002
- }
1003
- return;
1004
- }
1005
- if (event.key === "ArrowUp") {
1006
- if (isLineModifier) {
1007
- const selection = extendSelection
1008
- ? this.extendSelectionToDocumentStart()
1009
- : { start: 0, end: 0, affinity: "backward" };
1010
- event.preventDefault();
1011
- this.applySelectionUpdate(selection, "keyboard");
1012
- return;
1013
- }
1014
- if (extendSelection) {
1015
- const selection = this.extendFullLineSelectionByLine("up");
1016
- if (selection) {
1017
- event.preventDefault();
1018
- this.applySelectionUpdate(selection, "keyboard");
1019
- return;
1020
- }
1021
- }
1022
- // Handle vertical navigation to skip atomic blocks
1023
- if (!extendSelection) {
1024
- const selection = this.moveSelectionVertically("up");
1025
- if (selection) {
1026
- event.preventDefault();
1027
- this.applySelectionUpdate(selection, "keyboard");
1028
- return;
1029
- }
1030
- }
1031
- return;
1032
- }
1033
- if (event.key === "ArrowDown") {
1034
- if (isLineModifier) {
1035
- const end = this.state.map.cursorLength;
1036
- const selection = extendSelection
1037
- ? this.extendSelectionToDocumentEnd()
1038
- : { start: end, end, affinity: "forward" };
1039
- event.preventDefault();
1040
- this.applySelectionUpdate(selection, "keyboard");
1041
- return;
1042
- }
1043
- if (extendSelection) {
1044
- const selection = this.extendFullLineSelectionByLine("down");
1045
- if (selection) {
1046
- event.preventDefault();
1047
- this.applySelectionUpdate(selection, "keyboard");
1048
- return;
1049
- }
1050
- }
1051
- // Handle vertical navigation to skip atomic blocks
1052
- if (!extendSelection) {
1053
- const selection = this.moveSelectionVertically("down");
1054
- if (selection) {
1055
- event.preventDefault();
1056
- this.applySelectionUpdate(selection, "keyboard");
1057
- return;
1058
- }
1059
- }
1060
- return;
1061
- }
1062
- if (event.key === "Tab") {
1063
- this.verticalNavGoalX = null;
1064
- event.preventDefault();
1065
- this.keydownHandledBeforeInput = true;
1066
- if (event.shiftKey) {
1067
- this.handleOutdent();
1068
- }
1069
- else {
1070
- this.handleIndent();
1071
- }
1072
- // Reset the flag after any synchronous beforeinput events have been processed
1073
- queueMicrotask(() => {
1074
- this.keydownHandledBeforeInput = false;
1075
- });
1076
- return;
1077
- }
1078
- const extensionCommand = this.resolveExtensionKeybinding(event);
1079
- if (extensionCommand) {
1080
- this.verticalNavGoalX = null;
1081
- event.preventDefault();
1082
- this.keydownHandledBeforeInput = true;
1083
- this.executeCommand(extensionCommand);
1084
- // Reset the flag after any synchronous beforeinput events have been processed
1085
- queueMicrotask(() => {
1086
- this.keydownHandledBeforeInput = false;
1087
- });
1088
- return;
1089
- }
1090
- // Fallback for printable characters with selection.
1091
- // Some environments may not dispatch beforeinput when typing a character
1092
- // with a selection. Dispatch a generic insert command so extensions can handle it.
1093
- if (event.key.length === 1 &&
1094
- !cmdOrCtrl &&
1095
- !event.altKey &&
1096
- !event.ctrlKey &&
1097
- !event.metaKey) {
1098
- const { selection } = this.state;
1099
- if (selection.start !== selection.end) {
1100
- event.preventDefault();
1101
- this.keydownHandledBeforeInput = true;
1102
- this.applyEdit({ type: "insert", text: event.key });
1103
- queueMicrotask(() => {
1104
- this.keydownHandledBeforeInput = false;
1105
- });
1106
- return;
1107
- }
1108
- }
1109
- }
1110
- resolveExtensionKeybinding(event) {
1111
- const eventKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
1112
- for (const extension of this.extensions) {
1113
- const bindings = extension.keybindings;
1114
- if (!bindings) {
1115
- continue;
1116
- }
1117
- for (const binding of bindings) {
1118
- const bindingKey = binding.key.length === 1 ? binding.key.toLowerCase() : binding.key;
1119
- if (bindingKey !== eventKey) {
1120
- continue;
1121
- }
1122
- if (binding.meta !== undefined && binding.meta !== event.metaKey) {
1123
- continue;
1124
- }
1125
- if (binding.ctrl !== undefined && binding.ctrl !== event.ctrlKey) {
1126
- continue;
1127
- }
1128
- if (binding.alt !== undefined && binding.alt !== event.altKey) {
1129
- continue;
1130
- }
1131
- if (binding.shift !== undefined && binding.shift !== event.shiftKey) {
1132
- continue;
1133
- }
1134
- const command = typeof binding.command === "function"
1135
- ? binding.command(this.state)
1136
- : binding.command;
1137
- if (command) {
1138
- return command;
1139
- }
1140
- }
1141
- }
1142
- return null;
1143
- }
1144
- handleCopy(event) {
1145
- if (!this.isEventTargetInContentRoot(event.target)) {
1146
- return;
1147
- }
1148
- const clipboardData = event.clipboardData;
1149
- if (!clipboardData) {
1150
- return;
1151
- }
1152
- const text = this.runtime.serializeSelection(this.state, this.state.selection);
1153
- if (!text) {
1154
- return;
1155
- }
1156
- event.preventDefault();
1157
- clipboardData.setData("text/plain", text);
1158
- const html = this.runtime.serializeSelectionToHtml(this.state, this.state.selection);
1159
- if (html) {
1160
- clipboardData.setData("text/html", html);
1161
- }
1162
- }
1163
- handleCut(event) {
1164
- if (this.readOnly) {
1165
- return;
1166
- }
1167
- if (!this.isEventTargetInContentRoot(event.target)) {
1168
- return;
1169
- }
1170
- this.handleCopy(event);
1171
- if (this.state.selection.start !== this.state.selection.end) {
1172
- this.applyEdit({ type: "delete-backward" });
1173
- }
1174
- }
1175
- handlePaste(event) {
1176
- if (this.readOnly) {
1177
- return;
1178
- }
1179
- if (!this.isEventTargetInContentRoot(event.target)) {
1180
- return;
1181
- }
1182
- const clipboardData = event.clipboardData;
1183
- const html = clipboardData?.getData("text/html") ?? "";
1184
- if (html) {
1185
- const markdown = htmlToMarkdownForPaste(html);
1186
- if (markdown) {
1187
- event.preventDefault();
1188
- this.applyEdit({ type: "insert", text: markdown });
1189
- return;
1190
- }
1191
- }
1192
- event.preventDefault();
1193
- const text = clipboardData?.getData("text/plain") ?? "";
1194
- if (!text) {
1195
- return;
1196
- }
1197
- for (const extension of this.extensions) {
1198
- const handler = extension.onPasteText;
1199
- if (!handler) {
1200
- continue;
1201
- }
1202
- const command = handler(text, this.state);
1203
- if (!command) {
1204
- continue;
1205
- }
1206
- if (isApplyEditCommand(command)) {
1207
- this.applyEdit(command);
1208
- }
1209
- else {
1210
- this.executeCommand(command);
1211
- }
1212
- return;
1213
- }
1214
- this.applyEdit({ type: "insert", text });
1215
- }
1216
- handleBeforeInput(event) {
1217
- if (this.readOnly || this.isComposing || event.isComposing) {
1218
- return;
1219
- }
1220
- if (!this.isEventTargetInContentRoot(event.target)) {
1221
- return;
1222
- }
1223
- // If already handled by keydown (e.g., Cmd+Backspace for line delete),
1224
- // skip beforeinput processing to avoid double-applying the edit.
1225
- if (this.keydownHandledBeforeInput) {
1226
- this.keydownHandledBeforeInput = false;
1227
- event.preventDefault();
1228
- return;
1229
- }
1230
- const intent = this.resolveBeforeInputIntent(event);
1231
- if (!intent) {
1232
- return;
1233
- }
1234
- event.preventDefault();
1235
- this.markBeforeInputHandled();
1236
- this.suppressSelectionChangeForTick();
1237
- this.applyInputIntent(intent);
1238
- }
1239
- applyInputIntent(intent) {
1240
- if (intent.type === "noop") {
1241
- this.scheduleOverlayUpdate();
1242
- return;
1243
- }
1244
- if (intent.type === "insert-text") {
1245
- this.applyEdit({ type: "insert", text: intent.text });
1246
- return;
1247
- }
1248
- if (intent.type === "insert-line-break") {
1249
- this.applyEdit({ type: "insert-line-break" });
1250
- return;
1251
- }
1252
- if (intent.type === "delete-backward") {
1253
- this.applyEdit({ type: "delete-backward" });
1254
- return;
1255
- }
1256
- if (intent.type === "delete-forward") {
1257
- this.applyEdit({ type: "delete-forward" });
1258
- return;
1259
- }
1260
- if (intent.type === "replace-text") {
1261
- this.state = this.runtime.updateSelection(this.state, intent.selection, {
1262
- kind: "dom",
1263
- });
1264
- this.applyEdit({ type: "insert", text: intent.text });
1265
- return;
1266
- }
1267
- if (intent.type === "undo") {
1268
- this.undo();
1269
- return;
1270
- }
1271
- if (intent.type === "redo") {
1272
- this.redo();
1273
- return;
1274
- }
1275
- }
1276
- handleInput(event) {
1277
- if (!(event instanceof InputEvent)) {
1278
- return;
1279
- }
1280
- if (!this.isEventTargetInContentRoot(event.target)) {
1281
- return;
1282
- }
1283
- if (this.beforeInputHandled) {
1284
- return;
1285
- }
1286
- if (this.compositionCommit && event.inputType === "insertText") {
1287
- this.clearCompositionCommit();
1288
- return;
1289
- }
1290
- if (this.isComposing ||
1291
- event.isComposing ||
1292
- isCompositionInputType(event.inputType)) {
1293
- return;
1294
- }
1295
- if (event.inputType === "historyUndo" ||
1296
- event.inputType === "historyRedo") {
1297
- return;
1298
- }
1299
- if (!this.domMap) {
1300
- return;
1301
- }
1302
- const selection = readDomSelection(this.domMap);
1303
- if (!selection) {
1304
- return;
1305
- }
1306
- // Use reconciliation to preserve formatting markers (for Grammarly-like edits)
1307
- this.reconcileDomChanges(selection);
1308
- }
1309
- handleCompositionStart(event) {
1310
- if (!this.isEventTargetInContentRoot(event.target)) {
1311
- return;
1312
- }
1313
- this.isComposing = true;
1314
- this.clearCompositionCommit();
1315
- }
1316
- handleCompositionEnd(event) {
1317
- if (!this.isEventTargetInContentRoot(event.target)) {
1318
- return;
1319
- }
1320
- this.isComposing = false;
1321
- const selection = this.domMap
1322
- ? (readDomSelection(this.domMap) ?? this.state.selection)
1323
- : this.state.selection;
1324
- // Use reconciliation to preserve formatting markers
1325
- const changed = this.reconcileDomChanges(selection);
1326
- if (!changed) {
1327
- this.setSelection(selection);
1328
- }
1329
- this.markCompositionCommit();
1330
- this.scheduleOverlayUpdate();
1331
- }
1332
- resolveBeforeInputIntent(event) {
1333
- // Input contract:
1334
- // - The model owns selection; DOM selection + targetRanges are not authoritative.
1335
- // - `getTargetRanges()` is only used for replacement/composition-like flows
1336
- // (e.g. spellcheck/Grammarly), never to redefine selection for ordinary typing
1337
- // or Backspace/Delete at a collapsed caret.
1338
- const inputType = event.inputType;
1339
- if (inputType === "insertText") {
1340
- const text = event.data ?? "";
1341
- if (!text) {
1342
- return null;
1343
- }
1344
- // Firefox + Grammarly can send `insertText` (not `insertReplacementText`)
1345
- // with a non-collapsed targetRange describing the intended replacement.
1346
- // Only treat it as replacement when the range is non-collapsed; collapsed
1347
- // caret typing must not become DOM-targetRange-driven.
1348
- const targetResult = this.selectionFromTargetRangesWithStatus(event);
1349
- if (targetResult.status === "valid") {
1350
- const targetSelection = targetResult.selection;
1351
- if (targetSelection.start !== targetSelection.end) {
1352
- return {
1353
- type: "replace-text",
1354
- text,
1355
- selection: targetSelection,
1356
- };
1357
- }
1358
- }
1359
- return {
1360
- type: "insert-text",
1361
- text,
1362
- };
1363
- }
1364
- if (inputType === "insertLineBreak" || inputType === "insertParagraph") {
1365
- return {
1366
- type: "insert-line-break",
1367
- };
1368
- }
1369
- if (inputType === "insertFromPaste") {
1370
- const text = event.data ??
1371
- event.dataTransfer?.getData("text/plain") ??
1372
- event.dataTransfer?.getData("text") ??
1373
- "";
1374
- if (!text) {
1375
- return null;
1376
- }
1377
- return { type: "insert-text", text };
1378
- }
1379
- if (inputType === "deleteContentBackward" ||
1380
- inputType === "deleteByCut" ||
1381
- inputType === "deleteByLineBoundary") {
1382
- return {
1383
- type: "delete-backward",
1384
- };
1385
- }
1386
- if (inputType === "deleteContentForward") {
1387
- return {
1388
- type: "delete-forward",
1389
- };
1390
- }
1391
- if (inputType === "insertReplacementText") {
1392
- // Firefox spellcheck can dispatch `beforeinput` with `insertReplacementText`
1393
- // but omit `data`. In that case we must not preventDefault; allow the
1394
- // browser to apply the replacement and reconcile the DOM on the subsequent
1395
- // `input` event.
1396
- if (event.data === null) {
1397
- return null;
1398
- }
1399
- const targetResult = this.selectionFromTargetRangesWithStatus(event);
1400
- // If targetRanges returned an invalid range (outside container), abort
1401
- if (targetResult.status === "invalid") {
1402
- return { type: "noop" };
1403
- }
1404
- // If targetRanges was empty/missing, fall back to current selection
1405
- const targetSelection = targetResult.status === "valid"
1406
- ? targetResult.selection
1407
- : this.state.selection;
1408
- return {
1409
- type: "replace-text",
1410
- text: event.data ?? "",
1411
- selection: targetSelection,
1412
- };
1413
- }
1414
- if (inputType === "historyUndo") {
1415
- return { type: "undo" };
1416
- }
1417
- if (inputType === "historyRedo") {
1418
- return { type: "redo" };
1419
- }
1420
- return null;
1421
- }
1422
- selectionFromTargetRangesWithStatus(event) {
1423
- if (!event.getTargetRanges) {
1424
- return { status: "none" };
1425
- }
1426
- const ranges = event.getTargetRanges();
1427
- if (!ranges || ranges.length === 0) {
1428
- return { status: "none" };
1429
- }
1430
- const range = ranges[0];
1431
- // If range points outside the editor, it's invalid
1432
- if (!this.container.contains(range.startContainer) ||
1433
- !this.container.contains(range.endContainer)) {
1434
- return { status: "invalid" };
1435
- }
1436
- const start = this.cursorFromDom(range.startContainer, range.startOffset);
1437
- const end = this.cursorFromDom(range.endContainer, range.endOffset);
1438
- if (!start || !end) {
1439
- return { status: "invalid" };
1440
- }
1441
- const affinity = start.cursorOffset === end.cursorOffset ? end.affinity : "forward";
1442
- return {
1443
- status: "valid",
1444
- selection: {
1445
- start: start.cursorOffset,
1446
- end: end.cursorOffset,
1447
- affinity,
1448
- },
1449
- };
1450
- }
1451
- cursorFromDom(node, offset) {
1452
- if (node instanceof Text) {
1453
- return this.domMap?.cursorAtDom(node, offset) ?? null;
1454
- }
1455
- if (!(node instanceof Element)) {
1456
- return null;
1457
- }
1458
- const resolved = resolveTextPoint(node, offset);
1459
- if (!resolved) {
1460
- return null;
1461
- }
1462
- return this.domMap?.cursorAtDom(resolved.node, resolved.offset) ?? null;
1463
- }
1464
- applyEdit(command) {
1465
- // Special handling for backspace at start of line after atomic block
1466
- if (command.type === "delete-backward") {
1467
- const handled = this.handleBackspaceAfterAtomicBlock();
1468
- if (handled) {
1469
- return;
1470
- }
1471
- }
1472
- if (command.type === "delete-backward" ||
1473
- command.type === "delete-forward") {
1474
- const handled = this.handleDeleteAtomicBlockSelection();
1475
- if (handled) {
1476
- return;
1477
- }
1478
- }
1479
- // Use different history kind for replacement operations (selection exists)
1480
- // to prevent grouping with regular typing
1481
- const hasSelection = this.state.selection.start !== this.state.selection.end;
1482
- const historyKind = command.type === "insert" && hasSelection ? "replace" : command.type;
1483
- this.recordHistory(historyKind);
1484
- const nextState = this.runtime.applyEdit(command, this.state);
1485
- this.selectedAtomicLineIndex = null;
1486
- this.state = nextState;
1487
- this.render();
1488
- this.onChange?.(this.state.source, this.state.selection);
1489
- if (this.state.selection.start === this.state.selection.end) {
1490
- this.flushOverlayUpdate();
1491
- }
1492
- else {
1493
- this.scheduleOverlayUpdate();
1494
- }
1495
- this.scheduleScrollCaretIntoView();
1496
- }
1497
- handleDeleteAtomicBlockSelection() {
1498
- if (this.selectedAtomicLineIndex === null) {
1499
- return false;
1500
- }
1501
- const lineIndex = this.selectedAtomicLineIndex;
1502
- const lines = getDocLines(this.state.doc);
1503
- const lineOffsets = getLineOffsets(lines);
1504
- const lineInfo = lines[lineIndex];
1505
- if (!lineInfo || !lineInfo.isAtomic) {
1506
- this.selectedAtomicLineIndex = null;
1507
- return false;
1508
- }
1509
- const lineStart = lineOffsets[lineIndex] ?? 0;
1510
- const source = this.state.source;
1511
- let from = 0;
1512
- for (let i = 0; i < lineIndex; i += 1) {
1513
- const newline = source.indexOf("\n", from);
1514
- if (newline === -1) {
1515
- return false;
1516
- }
1517
- from = newline + 1;
1518
- }
1519
- let to = source.indexOf("\n", from);
1520
- if (to === -1) {
1521
- // Last line: remove the preceding newline if possible.
1522
- if (from > 0) {
1523
- from -= 1;
1524
- }
1525
- to = source.length;
1526
- }
1527
- else {
1528
- // Include the trailing newline so the surrounding lines stay separate.
1529
- to += 1;
1530
- }
1531
- const newSource = source.slice(0, from) + source.slice(to);
1532
- this.recordHistory("delete-backward");
1533
- this.state = this.runtime.createState(newSource, {
1534
- start: lineStart,
1535
- end: lineStart,
1536
- affinity: "forward",
1537
- });
1538
- this.render();
1539
- this.onChange?.(this.state.source, this.state.selection);
1540
- this.flushOverlayUpdate();
1541
- this.scheduleScrollCaretIntoView();
1542
- this.selectedAtomicLineIndex = null;
1543
- return true;
1544
- }
1545
- handleBackspaceAfterAtomicBlock() {
1546
- const selection = this.state.selection;
1547
- // Only for collapsed selection
1548
- if (selection.start !== selection.end) {
1549
- return false;
1550
- }
1551
- const lines = getDocLines(this.state.doc);
1552
- const lineOffsets = getLineOffsets(lines);
1553
- const lineIndex = lineOffsets.findIndex((offset) => offset === selection.start);
1554
- if (lineIndex === -1) {
1555
- return false;
1556
- }
1557
- const prev = lineIndex > 0 ? lines[lineIndex - 1] : null;
1558
- const current = lines[lineIndex] ?? null;
1559
- const next = lineIndex + 1 < lines.length ? lines[lineIndex + 1] : null;
1560
- // v1 behavior:
1561
- // - Backspace at start of a line immediately after an atomic block swaps the text line above the atomic.
1562
- // - If the caret is on the atomic line start (browser/measurement edge cases), apply the swap with the next line.
1563
- let swapA = null;
1564
- let swapB = null;
1565
- if (prev?.isAtomic) {
1566
- swapA = lineIndex - 1;
1567
- swapB = lineIndex;
1568
- }
1569
- else if (current?.isAtomic && next && !next.isAtomic) {
1570
- swapA = lineIndex;
1571
- swapB = lineIndex + 1;
1572
- }
1573
- else {
1574
- return false;
1575
- }
1576
- const sourceLines = this.state.source.split("\n");
1577
- if (swapA < 0 ||
1578
- swapB < 0 ||
1579
- swapA >= sourceLines.length ||
1580
- swapB >= sourceLines.length) {
1581
- return false;
1582
- }
1583
- const aSource = sourceLines[swapA];
1584
- const bSource = sourceLines[swapB];
1585
- if (aSource === undefined || bSource === undefined) {
1586
- return false;
1587
- }
1588
- sourceLines[swapA] = bSource;
1589
- sourceLines[swapB] = aSource;
1590
- const newSource = sourceLines.join("\n");
1591
- const nextState = this.runtime.createState(newSource);
1592
- const nextOffsets = getLineOffsets(getDocLines(nextState.doc));
1593
- const cursorPos = nextOffsets[swapA] ?? 0;
1594
- this.recordHistory("delete-backward");
1595
- this.state = {
1596
- ...nextState,
1597
- selection: { start: cursorPos, end: cursorPos, affinity: "forward" },
1598
- };
1599
- this.render();
1600
- this.onChange?.(this.state.source, this.state.selection);
1601
- this.flushOverlayUpdate();
1602
- this.scheduleScrollCaretIntoView();
1603
- return true;
1604
- }
1605
- recordHistory(kind) {
1606
- const now = Date.now();
1607
- const timeDelta = now - this.history.lastEditAt;
1608
- const shouldGroup = kind === this.history.lastKind &&
1609
- timeDelta < HISTORY_GROUPING_INTERVAL_MS;
1610
- if (!shouldGroup) {
1611
- this.history.undoStack.push({
1612
- source: this.state.source,
1613
- selection: this.state.selection,
1614
- });
1615
- if (this.history.undoStack.length > MAX_UNDO_STACK_SIZE) {
1616
- this.history.undoStack.shift();
1617
- }
1618
- this.history.redoStack = [];
1619
- }
1620
- this.history.lastEditAt = now;
1621
- this.history.lastKind = kind;
1622
- }
1623
- applySelectionUpdate(selection, kind = "programmatic") {
1624
- if (kind !== "keyboard") {
1625
- this.verticalNavGoalX = null;
1626
- }
1627
- this.selectedAtomicLineIndex = null;
1628
- this.state = this.runtime.updateSelection(this.state, selection, { kind });
1629
- if (!this.isComposing) {
1630
- this.applySelection(this.state.selection);
1631
- }
1632
- this.onSelectionChange?.(this.state.selection);
1633
- if (this.state.selection.start === this.state.selection.end) {
1634
- this.flushOverlayUpdate();
1635
- }
1636
- this.scheduleScrollCaretIntoView();
1637
- }
1638
- getLayoutForNavigation() {
1639
- if (!this.contentRoot) {
1640
- return null;
1641
- }
1642
- const lines = getDocLines(this.state.doc);
1643
- const layout = measureLayoutModelFromDom({
1644
- lines,
1645
- root: this.contentRoot,
1646
- container: this.container,
1647
- });
1648
- if (!layout) {
1649
- return null;
1650
- }
1651
- return { lines, layout };
1652
- }
1653
- moveSelectionByChar(direction) {
1654
- const selection = this.state.selection;
1655
- // For non-collapsed selection, collapse to the appropriate edge
1656
- if (selection.start !== selection.end) {
1657
- const target = direction === "forward"
1658
- ? Math.max(selection.start, selection.end)
1659
- : Math.min(selection.start, selection.end);
1660
- return { start: target, end: target, affinity: direction };
1661
- }
1662
- const currentPos = selection.start;
1663
- const currentAffinity = selection.affinity ?? "forward";
1664
- // Check visual row boundaries for wrapped lines
1665
- const measurement = this.getLayoutForNavigation();
1666
- if (measurement) {
1667
- const { lines, layout } = measurement;
1668
- const { rowStart, rowEnd } = getVisualRowBoundaries({
1669
- lines,
1670
- layout,
1671
- offset: currentPos,
1672
- affinity: currentAffinity,
1673
- });
1674
- // Arrow left at start of visual row (not at document start):
1675
- // Stay at same position but change to backward affinity
1676
- if (direction === "backward" &&
1677
- currentPos === rowStart &&
1678
- currentAffinity === "forward" &&
1679
- currentPos > 0) {
1680
- // Check if there's a previous visual row by checking boundaries with backward affinity
1681
- const prevBoundaries = getVisualRowBoundaries({
1682
- lines,
1683
- layout,
1684
- offset: currentPos,
1685
- affinity: "backward",
1686
- });
1687
- // If backward affinity puts us on a different row, just change affinity
1688
- if (prevBoundaries.rowEnd !== rowEnd || prevBoundaries.rowStart !== rowStart) {
1689
- return { start: currentPos, end: currentPos, affinity: "backward" };
1690
- }
1691
- }
1692
- // Arrow right at end of visual row (not at document end):
1693
- // Stay at same position but change to forward affinity
1694
- if (direction === "forward" &&
1695
- currentPos === rowEnd &&
1696
- currentAffinity === "backward" &&
1697
- currentPos < this.state.map.cursorLength) {
1698
- const nextBoundaries = getVisualRowBoundaries({
1699
- lines,
1700
- layout,
1701
- offset: currentPos,
1702
- affinity: "forward",
1703
- });
1704
- // If forward affinity puts us on a different row, just change affinity
1705
- if (nextBoundaries.rowEnd !== rowEnd || nextBoundaries.rowStart !== rowStart) {
1706
- return { start: currentPos, end: currentPos, affinity: "forward" };
1707
- }
1708
- }
1709
- }
1710
- const nextPos = this.moveOffsetByChar(currentPos, direction);
1711
- if (nextPos === null) {
1712
- return null;
1713
- }
1714
- return { start: nextPos, end: nextPos, affinity: direction };
1715
- }
1716
- moveOffsetByChar(offset, direction) {
1717
- const cursorLength = this.state.map.cursorLength;
1718
- const lines = getDocLines(this.state.doc);
1719
- const lineOffsets = getLineOffsets(lines);
1720
- let nextPos;
1721
- if (direction === "forward") {
1722
- if (offset >= cursorLength) {
1723
- return null;
1724
- }
1725
- nextPos = offset + 1;
1726
- }
1727
- else {
1728
- if (offset <= 0) {
1729
- return null;
1730
- }
1731
- nextPos = offset - 1;
1732
- }
1733
- const { lineIndex: nextLineIndex } = resolveOffsetToLine(lines, nextPos);
1734
- const nextLineInfo = lines[nextLineIndex];
1735
- if (nextLineInfo && nextLineInfo.isAtomic) {
1736
- const lineStart = lineOffsets[nextLineIndex] ?? 0;
1737
- const lineEnd = lineStart +
1738
- nextLineInfo.cursorLength +
1739
- (nextLineInfo.hasNewline ? 1 : 0);
1740
- nextPos =
1741
- direction === "forward" ? lineEnd : lineStart > 0 ? lineStart - 1 : 0;
1742
- }
1743
- return Math.max(0, Math.min(nextPos, cursorLength));
1744
- }
1745
- moveSelectionVertically(direction) {
1746
- const measurement = this.getLayoutForNavigation();
1747
- if (!measurement) {
1748
- return null;
1749
- }
1750
- const { lines, layout } = measurement;
1751
- if (!this.lastFocusRect) {
1752
- this.flushOverlayUpdate();
1753
- }
1754
- if (this.verticalNavGoalX === null) {
1755
- if (this.lastFocusRect) {
1756
- this.verticalNavGoalX = this.lastFocusRect.left;
1757
- }
1758
- }
1759
- const { focus } = resolveSelectionAnchorAndFocus(this.state.selection);
1760
- const focusResolved = resolveOffsetToLine(lines, focus);
1761
- const focusLineLayout = layout.lines[focusResolved.lineIndex];
1762
- let focusRowIndex = undefined;
1763
- if (focusLineLayout?.rows.length && this.lastFocusRect) {
1764
- const caretY = this.lastFocusRect.top;
1765
- let bestIndex = 0;
1766
- let bestDistance = Number.POSITIVE_INFINITY;
1767
- for (let index = 0; index < focusLineLayout.rows.length; index += 1) {
1768
- const row = focusLineLayout.rows[index];
1769
- if (!row) {
1770
- continue;
1771
- }
1772
- const distance = Math.abs(row.rect.top - caretY);
1773
- if (distance < bestDistance) {
1774
- bestDistance = distance;
1775
- bestIndex = index;
1776
- }
1777
- }
1778
- focusRowIndex = bestIndex;
1779
- }
1780
- const containerRect = this.container.getBoundingClientRect();
1781
- const scrollLeft = this.container.scrollLeft;
1782
- const scrollTop = this.container.scrollTop;
1783
- const result = moveSelectionVerticallyInLayout({
1784
- lines,
1785
- layout,
1786
- selection: this.state.selection,
1787
- direction,
1788
- goalX: this.verticalNavGoalX,
1789
- focusRowIndex,
1790
- hitTestCursorAt: (x, y) => {
1791
- const hit = this.hitTestFromClientPoint(containerRect.left + x - scrollLeft, containerRect.top + y - scrollTop);
1792
- if (!hit || !this.contentRoot) {
1793
- return null;
1794
- }
1795
- const resolved = resolveOffsetToLine(lines, hit.cursorOffset);
1796
- const lineInfo = lines[resolved.lineIndex];
1797
- const lineElement = this.contentRoot.querySelector(`[data-line-index="${resolved.lineIndex}"]`);
1798
- if (!lineInfo || !(lineElement instanceof HTMLElement)) {
1799
- return { cursorOffset: hit.cursorOffset, affinity: hit.affinity };
1800
- }
1801
- const caret = getDomCaretRect({
1802
- lineElement,
1803
- lineInfo,
1804
- offsetInLine: resolved.offsetInLine,
1805
- affinity: hit.affinity,
1806
- });
1807
- const caretTop = caret?.rect.top !== undefined
1808
- ? caret.rect.top - containerRect.top + scrollTop
1809
- : undefined;
1810
- return {
1811
- cursorOffset: hit.cursorOffset,
1812
- affinity: hit.affinity,
1813
- caretTop,
1814
- };
1815
- },
1816
- });
1817
- if (!result) {
1818
- return null;
1819
- }
1820
- this.verticalNavGoalX = result.goalX;
1821
- return result.selection;
1822
- }
1823
- moveSelectionToVisualRowStart() {
1824
- const measurement = this.getLayoutForNavigation();
1825
- if (!measurement) {
1826
- const start = 0;
1827
- return { start, end: start, affinity: "backward" };
1828
- }
1829
- const { lines, layout } = measurement;
1830
- const selection = this.state.selection;
1831
- const focus = selection.start === selection.end
1832
- ? selection.start
1833
- : Math.min(selection.start, selection.end);
1834
- const { rowStart } = getVisualRowBoundaries({
1835
- lines,
1836
- layout,
1837
- offset: focus,
1838
- affinity: "backward",
1839
- });
1840
- let target = rowStart;
1841
- if (focus === rowStart && focus > 0) {
1842
- const previous = getVisualRowBoundaries({
1843
- lines,
1844
- layout,
1845
- offset: focus - 1,
1846
- affinity: "backward",
1847
- });
1848
- target = previous.rowStart;
1849
- }
1850
- return { start: target, end: target, affinity: "forward" };
1851
- }
1852
- moveSelectionToVisualRowEnd() {
1853
- const measurement = this.getLayoutForNavigation();
1854
- if (!measurement) {
1855
- const end = this.state.map.cursorLength;
1856
- return { start: end, end, affinity: "forward" };
1857
- }
1858
- const { lines, layout } = measurement;
1859
- const selection = this.state.selection;
1860
- const focus = selection.start === selection.end
1861
- ? selection.start
1862
- : Math.max(selection.start, selection.end);
1863
- const { rowEnd } = getVisualRowBoundaries({
1864
- lines,
1865
- layout,
1866
- offset: focus,
1867
- affinity: "forward",
1868
- });
1869
- let target = rowEnd;
1870
- if (focus === rowEnd && focus < this.state.map.cursorLength) {
1871
- const next = getVisualRowBoundaries({
1872
- lines,
1873
- layout,
1874
- offset: focus + 1,
1875
- affinity: "forward",
1876
- });
1877
- target = next.rowEnd;
1878
- }
1879
- return { start: target, end: target, affinity: "backward" };
1880
- }
1881
- extendSelectionToVisualRowStart() {
1882
- const selection = this.state.selection;
1883
- const { anchor, focus } = resolveSelectionAnchorAndFocus(selection);
1884
- const affinity = resolveSelectionAffinity(selection);
1885
- const measurement = this.getLayoutForNavigation();
1886
- if (!measurement) {
1887
- return selectionFromAnchor(anchor, 0, "backward");
1888
- }
1889
- const { lines, layout } = measurement;
1890
- const { rowStart } = getVisualRowBoundaries({
1891
- lines,
1892
- layout,
1893
- offset: focus,
1894
- affinity,
1895
- });
1896
- let target = rowStart;
1897
- if (focus === rowStart && focus > 0) {
1898
- const previous = getVisualRowBoundaries({
1899
- lines,
1900
- layout,
1901
- offset: focus - 1,
1902
- affinity: "backward",
1903
- });
1904
- target = previous.rowStart;
1905
- }
1906
- return selectionFromAnchor(anchor, target, "backward");
1907
- }
1908
- extendSelectionToVisualRowEnd() {
1909
- const selection = this.state.selection;
1910
- const { anchor, focus } = resolveSelectionAnchorAndFocus(selection);
1911
- const affinity = resolveSelectionAffinity(selection);
1912
- const measurement = this.getLayoutForNavigation();
1913
- if (!measurement) {
1914
- return selectionFromAnchor(anchor, this.state.map.cursorLength, "forward");
1915
- }
1916
- const { lines, layout } = measurement;
1917
- const { rowEnd } = getVisualRowBoundaries({
1918
- lines,
1919
- layout,
1920
- offset: focus,
1921
- affinity,
1922
- });
1923
- let target = rowEnd;
1924
- if (focus === rowEnd && focus < this.state.map.cursorLength) {
1925
- const next = getVisualRowBoundaries({
1926
- lines,
1927
- layout,
1928
- offset: focus + 1,
1929
- affinity: "forward",
1930
- });
1931
- target = next.rowEnd;
1932
- }
1933
- return selectionFromAnchor(anchor, target, "forward");
1934
- }
1935
- extendSelectionToDocumentStart() {
1936
- const { anchor } = resolveSelectionAnchorAndFocus(this.state.selection);
1937
- return selectionFromAnchor(anchor, 0, "backward");
1938
- }
1939
- extendSelectionToDocumentEnd() {
1940
- const { anchor } = resolveSelectionAnchorAndFocus(this.state.selection);
1941
- return selectionFromAnchor(anchor, this.state.map.cursorLength, "forward");
1942
- }
1943
- extendFullLineSelectionByLine(direction) {
1944
- const selection = this.state.selection;
1945
- if (selection.start === selection.end) {
1946
- return null;
1947
- }
1948
- const lines = getDocLines(this.state.doc);
1949
- const lineOffsets = getLineOffsets(lines);
1950
- const selStart = Math.min(selection.start, selection.end);
1951
- const selEnd = Math.max(selection.start, selection.end);
1952
- const fullLineInfo = this.detectFullLineSelection(selStart, selEnd, lines, lineOffsets);
1953
- if (!fullLineInfo) {
1954
- return null;
1955
- }
1956
- if (direction === "up") {
1957
- if (fullLineInfo.startLineIndex <= 0) {
1958
- return null;
1959
- }
1960
- const startLineIndex = fullLineInfo.startLineIndex - 1;
1961
- const endLineIndex = fullLineInfo.endLineIndex;
1962
- const start = lineOffsets[startLineIndex] ?? 0;
1963
- const lineInfo = lines[endLineIndex];
1964
- const lineStart = lineOffsets[endLineIndex] ?? 0;
1965
- const end = lineInfo
1966
- ? lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0)
1967
- : selEnd;
1968
- return { start, end, affinity: "forward" };
1969
- }
1970
- if (fullLineInfo.endLineIndex >= lines.length - 1) {
1971
- return null;
1972
- }
1973
- const startLineIndex = fullLineInfo.startLineIndex;
1974
- const endLineIndex = fullLineInfo.endLineIndex + 1;
1975
- const start = lineOffsets[startLineIndex] ?? 0;
1976
- const lineInfo = lines[endLineIndex];
1977
- const lineStart = lineOffsets[endLineIndex] ?? 0;
1978
- const end = lineInfo
1979
- ? lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0)
1980
- : selEnd;
1981
- return { start, end, affinity: "forward" };
1982
- }
1983
- moveSelectionByWord(direction) {
1984
- const maxLength = this.state.map.cursorLength;
1985
- const normalized = normalizeSelection(this.state.selection, maxLength);
1986
- if (normalized.start !== normalized.end) {
1987
- const offset = direction === "backward" ? normalized.start : normalized.end;
1988
- return { start: offset, end: offset, affinity: direction };
1989
- }
1990
- const nextOffset = this.moveOffsetByWord(normalized.start, direction);
1991
- return { start: nextOffset, end: nextOffset, affinity: direction };
1992
- }
1993
- extendSelectionByWord(direction) {
1994
- const maxLength = this.state.map.cursorLength;
1995
- const normalized = normalizeSelection(this.state.selection, maxLength);
1996
- const { anchor, focus } = resolveSelectionAnchorAndFocus(normalized);
1997
- const nextFocus = this.moveOffsetByWord(focus, direction);
1998
- if (nextFocus === focus) {
1999
- return normalized;
2000
- }
2001
- return selectionFromAnchor(anchor, nextFocus, direction);
2002
- }
2003
- moveOffsetByWord(offset, direction) {
2004
- const lines = getDocLines(this.state.doc);
2005
- const visibleText = getVisibleText(lines);
2006
- if (!visibleText) {
2007
- return 0;
2008
- }
2009
- const visibleOffset = cursorOffsetToVisibleOffset(lines, offset);
2010
- const nextVisibleOffset = direction === "backward"
2011
- ? prevWordBreak(visibleText, visibleOffset)
2012
- : nextWordBreak(visibleText, visibleOffset);
2013
- return visibleOffsetToCursorOffset(lines, nextVisibleOffset) ?? offset;
2014
- }
2015
- deleteToVisualRowStart() {
2016
- const selection = this.state.selection;
2017
- const lines = getDocLines(this.state.doc);
2018
- const { lineIndex, offsetInLine } = resolveOffsetToLine(lines, selection.start);
2019
- const lineInfo = lines[lineIndex];
2020
- if (!lineInfo) {
2021
- return;
2022
- }
2023
- const lineOffsets = getLineOffsets(lines);
2024
- const lineStart = lineOffsets[lineIndex] ?? 0;
2025
- const isLineStart = offsetInLine === 0;
2026
- const isCollapsed = selection.start === selection.end;
2027
- if (isCollapsed && isLineStart) {
2028
- this.applyEdit({ type: "delete-backward" });
2029
- return;
2030
- }
2031
- const measurement = this.getLayoutForNavigation();
2032
- if (!measurement) {
2033
- const deleteSelection = {
2034
- start: lineStart,
2035
- end: selection.end,
2036
- affinity: "forward",
2037
- };
2038
- this.state = { ...this.state, selection: deleteSelection };
2039
- this.applyEdit({ type: "delete-backward" });
2040
- return;
2041
- }
2042
- const { layout } = measurement;
2043
- const { rowStart } = getVisualRowBoundaries({
2044
- lines,
2045
- layout,
2046
- offset: selection.start,
2047
- affinity: "backward",
2048
- });
2049
- const isVisualRowStart = selection.start === rowStart;
2050
- if (isCollapsed && isVisualRowStart) {
2051
- this.applyEdit({ type: "delete-backward" });
2052
- return;
2053
- }
2054
- const deleteSelection = {
2055
- start: rowStart,
2056
- end: selection.end,
2057
- affinity: "forward",
2058
- };
2059
- this.state = { ...this.state, selection: deleteSelection };
2060
- this.applyEdit({ type: "delete-backward" });
2061
- }
2062
- handleIndent() {
2063
- const selection = this.state.selection;
2064
- const lines = getDocLines(this.state.doc);
2065
- const TAB_SPACES = " ";
2066
- const isCollapsed = selection.start === selection.end;
2067
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
2068
- const endLineIndex = resolveOffsetToLine(lines, Math.max(selection.start, selection.end - 1)).lineIndex;
2069
- const affectsMultipleLines = endLineIndex > startLineIndex;
2070
- // Check if the current line is a list item by checking source text
2071
- const sourceLines = this.state.source.split("\n");
2072
- const startSourceLine = sourceLines[startLineIndex] ?? "";
2073
- const listPattern = /^(\s*)([-*+]|\d+\.)(\s+)/;
2074
- const isListItem = listPattern.test(startSourceLine);
2075
- // For list items, delegate to runtime so extensions can handle renumbering
2076
- if (isListItem) {
2077
- this.recordHistory("edit");
2078
- const nextState = this.runtime.applyEdit({ type: "indent" }, this.state);
2079
- this.state = nextState;
2080
- this.render();
2081
- this.onChange?.(this.state.source, this.state.selection);
2082
- this.scheduleOverlayUpdate();
2083
- return;
2084
- }
2085
- // For collapsed selection (caret) on non-list lines in middle/end of line,
2086
- // insert at caret position. Otherwise indent at line start.
2087
- if (isCollapsed) {
2088
- // Check if caret is in middle/end of line (not at start)
2089
- const lineOffsets = getLineOffsets(lines);
2090
- const lineStart = lineOffsets[startLineIndex] ?? 0;
2091
- const offsetInLine = selection.start - lineStart;
2092
- if (offsetInLine > 0) {
2093
- // Insert at caret position
2094
- this.applyEdit({ type: "insert", text: TAB_SPACES });
2095
- return;
2096
- }
2097
- }
2098
- // For single-line partial selection on non-list lines, replace selection with tab
2099
- if (!affectsMultipleLines && !isCollapsed) {
2100
- this.applyEdit({ type: "insert", text: TAB_SPACES });
2101
- return;
2102
- }
2103
- // For collapsed selection at line start on non-list lines,
2104
- // indent at line start (fall through to multi-line logic)
2105
- if (!affectsMultipleLines && isCollapsed) {
2106
- // Collapsed at line start on non-list - indent at line start
2107
- this.recordHistory("edit");
2108
- }
2109
- let newSource = this.state.source;
2110
- let totalInserted = 0;
2111
- const sourceLineOffsets = [];
2112
- let sourceOffset = 0;
2113
- for (let i = 0; i < sourceLines.length; i++) {
2114
- sourceLineOffsets.push(sourceOffset);
2115
- sourceOffset += sourceLines[i].length;
2116
- if (i < sourceLines.length - 1) {
2117
- sourceOffset += 1;
2118
- }
2119
- }
2120
- for (let i = startLineIndex; i <= endLineIndex; i++) {
2121
- const insertAt = sourceLineOffsets[i] + totalInserted;
2122
- newSource =
2123
- newSource.slice(0, insertAt) + TAB_SPACES + newSource.slice(insertAt);
2124
- totalInserted += TAB_SPACES.length;
2125
- }
2126
- const selStart = selection.start + TAB_SPACES.length;
2127
- const linesAffected = endLineIndex - startLineIndex + 1;
2128
- const selEnd = selection.end + TAB_SPACES.length * linesAffected;
2129
- const newSelection = {
2130
- start: selStart,
2131
- end: selEnd,
2132
- affinity: "forward",
2133
- };
2134
- this.state = this.runtime.createState(newSource, newSelection);
2135
- this.render();
2136
- this.onChange?.(this.state.source, this.state.selection);
2137
- this.scheduleOverlayUpdate();
2138
- }
2139
- handleOutdent() {
2140
- const selection = this.state.selection;
2141
- const lines = getDocLines(this.state.doc);
2142
- const TAB_SPACES = " ";
2143
- const startLineIndex = resolveOffsetToLine(lines, selection.start).lineIndex;
2144
- const endLineIndex = resolveOffsetToLine(lines, Math.max(selection.start, selection.end - 1)).lineIndex;
2145
- const sourceLines = this.state.source.split("\n");
2146
- // Check if the current line is a list item by checking source text
2147
- const startSourceLine = sourceLines[startLineIndex] ?? "";
2148
- const listPattern = /^(\s*)([-*+]|\d+\.)(\s+)/;
2149
- const isListItem = listPattern.test(startSourceLine);
2150
- // For list items, delegate to runtime so extensions can handle renumbering
2151
- if (isListItem) {
2152
- this.recordHistory("edit");
2153
- const nextState = this.runtime.applyEdit({ type: "outdent" }, this.state);
2154
- // Only update if something changed
2155
- if (nextState.source !== this.state.source) {
2156
- this.state = nextState;
2157
- this.render();
2158
- this.onChange?.(this.state.source, this.state.selection);
2159
- this.scheduleOverlayUpdate();
2160
- }
2161
- return;
2162
- }
2163
- let newSource = this.state.source;
2164
- let totalRemoved = 0;
2165
- const sourceLineOffsets = [];
2166
- let sourceOffset = 0;
2167
- for (let i = 0; i < sourceLines.length; i++) {
2168
- sourceLineOffsets.push(sourceOffset);
2169
- sourceOffset += sourceLines[i].length;
2170
- if (i < sourceLines.length - 1) {
2171
- sourceOffset += 1;
2172
- }
2173
- }
2174
- const removedPerLine = [];
2175
- for (let i = startLineIndex; i <= endLineIndex; i++) {
2176
- const lineStart = sourceLineOffsets[i] - totalRemoved;
2177
- const lineText = newSource.slice(lineStart, newSource.indexOf("\n", lineStart) === -1
2178
- ? newSource.length
2179
- : newSource.indexOf("\n", lineStart));
2180
- let removeCount = 0;
2181
- if (lineText.startsWith(TAB_SPACES)) {
2182
- removeCount = TAB_SPACES.length;
2183
- }
2184
- else if (lineText.startsWith("\t")) {
2185
- removeCount = 1;
2186
- }
2187
- else if (lineText.startsWith(" ")) {
2188
- removeCount = 1;
2189
- }
2190
- if (removeCount > 0) {
2191
- newSource =
2192
- newSource.slice(0, lineStart) +
2193
- newSource.slice(lineStart + removeCount);
2194
- totalRemoved += removeCount;
2195
- }
2196
- removedPerLine.push(removeCount);
2197
- }
2198
- if (totalRemoved === 0) {
2199
- return;
2200
- }
2201
- const firstRemoved = removedPerLine[0] ?? 0;
2202
- const selStart = Math.max(0, selection.start - firstRemoved);
2203
- const selEnd = Math.max(0, selection.end - totalRemoved);
2204
- const newSelection = {
2205
- start: selStart,
2206
- end: selEnd,
2207
- affinity: "forward",
2208
- };
2209
- this.state = this.runtime.createState(newSource, newSelection);
2210
- this.render();
2211
- this.onChange?.(this.state.source, this.state.selection);
2212
- this.scheduleOverlayUpdate();
2213
- }
2214
- readDomText() {
2215
- if (!this.contentRoot) {
2216
- return this.state.source;
2217
- }
2218
- const blocks = Array.from(this.contentRoot.querySelectorAll(".cake-line"));
2219
- if (blocks.length === 0) {
2220
- return this.contentRoot.textContent ?? "";
2221
- }
2222
- const texts = blocks.map((block) => block.textContent ?? "");
2223
- return texts.join("\n");
2224
- }
2225
- /**
2226
- * Reconcile DOM text changes with the model while preserving formatting markers.
2227
- * Used when external agents (IME, Grammarly) modify the DOM directly.
2228
- *
2229
- * Strategy:
2230
- * 1. Get visible text from DOM (what was modified)
2231
- * 2. Get visible text from model (what we had)
2232
- * 3. Find the minimal diff (common prefix/suffix)
2233
- * 4. Map the changed region from cursor space to source space
2234
- * 5. Replace the corresponding source region, preserving markers
2235
- */
2236
- reconcileDomChanges(selection) {
2237
- const domText = this.readDomText();
2238
- const lines = getDocLines(this.state.doc);
2239
- const modelText = getVisibleText(lines);
2240
- if (domText === modelText) {
2241
- return false;
2242
- }
2243
- // Record history before applying the reconciliation
2244
- this.recordHistory("reconcile");
2245
- // Find common prefix (in characters, not cursor units)
2246
- let prefixLen = 0;
2247
- const minLen = Math.min(domText.length, modelText.length);
2248
- while (prefixLen < minLen && domText[prefixLen] === modelText[prefixLen]) {
2249
- prefixLen++;
2250
- }
2251
- // Find common suffix (from the end)
2252
- let suffixLen = 0;
2253
- while (suffixLen < minLen - prefixLen &&
2254
- domText[domText.length - 1 - suffixLen] ===
2255
- modelText[modelText.length - 1 - suffixLen]) {
2256
- suffixLen++;
2257
- }
2258
- // The replacement text from DOM
2259
- const replacementText = domText.slice(prefixLen, domText.length - suffixLen);
2260
- // Convert visible text offsets to cursor offsets
2261
- const cursorStart = visibleOffsetToCursorOffset(lines, prefixLen);
2262
- const cursorEnd = visibleOffsetToCursorOffset(lines, modelText.length - suffixLen);
2263
- if (cursorStart === null || cursorEnd === null) {
2264
- // Fallback: rebuild state from scratch (loses formatting)
2265
- // History was already recorded above
2266
- this.state = this.runtime.createState(domText, selection);
2267
- this.render();
2268
- this.onChange?.(this.state.source, this.state.selection);
2269
- return true;
2270
- }
2271
- // Map cursor positions to source positions.
2272
- // Use "forward" affinity for start so we don't swallow any source-only
2273
- // prefix markers at that cursor boundary (e.g. "[" for links, list prefixes).
2274
- // Use "backward" affinity for end so we don't swallow any source-only
2275
- // suffix markers at that boundary (e.g. closing link markers).
2276
- const map = this.state.map;
2277
- let sourceStart;
2278
- let sourceEnd;
2279
- if (cursorStart === cursorEnd) {
2280
- // Collapsed-caret DOM edits (IME insertions) should not delete source-only
2281
- // markers at the cursor boundary. Prefer using the selection's affinity
2282
- // (model-owned, computed via DomMap) and only fall back to a best-effort
2283
- // DOM-point match against DomMap at this cursor boundary.
2284
- const affinity = this.resolveCollapsedReconcileAffinity(cursorStart) ??
2285
- selection.affinity ??
2286
- "forward";
2287
- const pos = map.cursorToSource(cursorStart, affinity);
2288
- sourceStart = pos;
2289
- sourceEnd = pos;
2290
- }
2291
- else {
2292
- const rawSourceStart = map.cursorToSource(cursorStart, "forward");
2293
- const rawSourceEnd = map.cursorToSource(cursorEnd, "backward");
2294
- sourceStart = Math.min(rawSourceStart, rawSourceEnd);
2295
- sourceEnd = Math.max(rawSourceStart, rawSourceEnd);
2296
- }
2297
- // Build the new source by replacing the changed region
2298
- const source = this.state.source;
2299
- const newSource = source.slice(0, sourceStart) + replacementText + source.slice(sourceEnd);
2300
- // Create new state from the modified source
2301
- const newState = this.runtime.createState(newSource);
2302
- // Compute new selection: caret at end of inserted text
2303
- const newCursorOffset = cursorStart + replacementText.length;
2304
- const clampedOffset = Math.min(newCursorOffset, newState.map.cursorLength);
2305
- const newSelection = {
2306
- start: clampedOffset,
2307
- end: clampedOffset,
2308
- affinity: selection.affinity ?? "forward",
2309
- };
2310
- this.state = { ...newState, selection: newSelection };
2311
- this.render();
2312
- this.onChange?.(this.state.source, this.state.selection);
2313
- return true;
2314
- }
2315
- resolveCollapsedReconcileAffinity(cursorOffset) {
2316
- if (!this.domMap) {
2317
- return null;
2318
- }
2319
- const selection = window.getSelection();
2320
- if (!selection || selection.rangeCount === 0) {
2321
- return null;
2322
- }
2323
- const range = selection.getRangeAt(0);
2324
- if (!range.collapsed) {
2325
- return null;
2326
- }
2327
- if (!(range.startContainer instanceof Text)) {
2328
- return null;
2329
- }
2330
- const backwardPoint = this.domMap.domAtCursor(cursorOffset, "backward");
2331
- if (backwardPoint?.node === range.startContainer) {
2332
- return "backward";
2333
- }
2334
- const forwardPoint = this.domMap.domAtCursor(cursorOffset, "forward");
2335
- if (forwardPoint?.node === range.startContainer) {
2336
- return "forward";
2337
- }
2338
- return null;
2339
- }
2340
- markBeforeInputHandled() {
2341
- this.beforeInputHandled = true;
2342
- if (this.beforeInputResetId !== null) {
2343
- window.cancelAnimationFrame(this.beforeInputResetId);
2344
- }
2345
- this.beforeInputResetId = window.requestAnimationFrame(() => {
2346
- this.beforeInputHandled = false;
2347
- this.beforeInputResetId = null;
2348
- });
2349
- }
2350
- suppressSelectionChangeForTick() {
2351
- this.suppressSelectionChange = true;
2352
- if (this.suppressSelectionChangeResetId !== null) {
2353
- window.cancelAnimationFrame(this.suppressSelectionChangeResetId);
2354
- }
2355
- this.suppressSelectionChangeResetId = window.requestAnimationFrame(() => {
2356
- this.suppressSelectionChange = false;
2357
- this.suppressSelectionChangeResetId = null;
2358
- });
2359
- }
2360
- markCompositionCommit() {
2361
- this.compositionCommit = true;
2362
- if (this.compositionCommitTimeoutId !== null) {
2363
- window.clearTimeout(this.compositionCommitTimeoutId);
2364
- }
2365
- this.compositionCommitTimeoutId = window.setTimeout(() => {
2366
- this.clearCompositionCommit();
2367
- }, COMPOSITION_COMMIT_CLEAR_DELAY_MS);
2368
- }
2369
- clearCompositionCommit() {
2370
- this.compositionCommit = false;
2371
- if (this.compositionCommitTimeoutId !== null) {
2372
- window.clearTimeout(this.compositionCommitTimeoutId);
2373
- this.compositionCommitTimeoutId = null;
2374
- }
2375
- }
2376
- handleScroll() {
2377
- this.scheduleOverlayUpdate();
2378
- this.updateExtensionsOverlayPosition();
2379
- }
2380
- handleResize() {
2381
- this.scheduleOverlayUpdate();
2382
- }
2383
- openLinkPopoverForSelection(isEditing) {
2384
- if (!this.contentRoot || !this.domMap) {
2385
- return;
2386
- }
2387
- const selection = this.state.selection;
2388
- const focus = selection.start === selection.end
2389
- ? selection.start
2390
- : Math.max(selection.start, selection.end);
2391
- const affinity = selection.affinity ?? "forward";
2392
- const domPoint = this.domMap.domAtCursor(focus, affinity);
2393
- if (!domPoint) {
2394
- return;
2395
- }
2396
- const link = domPoint.node.parentElement?.closest("a.cake-link") ??
2397
- this.contentRoot.querySelector("a.cake-link");
2398
- if (!link || !(link instanceof HTMLAnchorElement)) {
2399
- return;
2400
- }
2401
- const event = new CustomEvent("cake-link-popover-open", {
2402
- bubbles: true,
2403
- detail: { link, isEditing },
2404
- });
2405
- this.contentRoot.dispatchEvent(event);
2406
- }
2407
- scheduleOverlayUpdate() {
2408
- if (this.isComposing) {
2409
- return;
2410
- }
2411
- if (this.overlayUpdateId !== null) {
2412
- return;
2413
- }
2414
- this.overlayUpdateId = window.requestAnimationFrame(() => {
2415
- this.overlayUpdateId = null;
2416
- this.updateExtensionsOverlayPosition();
2417
- this.updateSelectionOverlay();
2418
- });
2419
- }
2420
- flushOverlayUpdate() {
2421
- if (this.isComposing) {
2422
- return;
2423
- }
2424
- if (this.overlayUpdateId !== null) {
2425
- window.cancelAnimationFrame(this.overlayUpdateId);
2426
- this.overlayUpdateId = null;
2427
- }
2428
- this.updateSelectionOverlay();
2429
- }
2430
- ensureOverlayRoot() {
2431
- if (this.overlayRoot) {
2432
- return this.overlayRoot;
2433
- }
2434
- const overlay = document.createElement("div");
2435
- overlay.className = "cake-selection-overlay";
2436
- overlay.setAttribute("aria-hidden", "true");
2437
- overlay.contentEditable = "false";
2438
- overlay.style.position = "absolute";
2439
- overlay.style.inset = "0";
2440
- overlay.style.pointerEvents = "none";
2441
- overlay.style.userSelect = "none";
2442
- overlay.style.setProperty("-webkit-user-select", "none");
2443
- overlay.style.zIndex = "2";
2444
- const caret = document.createElement("div");
2445
- caret.className = "cake-caret";
2446
- caret.style.position = "absolute";
2447
- caret.style.display = "none";
2448
- overlay.append(caret);
2449
- this.overlayRoot = overlay;
2450
- this.caretElement = caret;
2451
- this.selectionRectElements = [];
2452
- this.lastSelectionRects = null;
2453
- return overlay;
2454
- }
2455
- selectionRectsEqual(prev, next) {
2456
- if (!prev) {
2457
- return false;
2458
- }
2459
- if (prev.length !== next.length) {
2460
- return false;
2461
- }
2462
- for (let index = 0; index < prev.length; index += 1) {
2463
- const a = prev[index];
2464
- const b = next[index];
2465
- if (!a || !b) {
2466
- return false;
2467
- }
2468
- if (a.top !== b.top ||
2469
- a.left !== b.left ||
2470
- a.width !== b.width ||
2471
- a.height !== b.height) {
2472
- return false;
2473
- }
2474
- }
2475
- return true;
2476
- }
2477
- ensureExtensionsRoot() {
2478
- if (this.extensionsRoot) {
2479
- return this.extensionsRoot;
2480
- }
2481
- const root = document.createElement("div");
2482
- root.className = "cake-extension-overlay";
2483
- root.contentEditable = "false";
2484
- root.style.position = "absolute";
2485
- root.style.inset = "0";
2486
- root.style.pointerEvents = "none";
2487
- root.style.userSelect = "none";
2488
- root.style.setProperty("-webkit-user-select", "none");
2489
- root.style.zIndex = "50";
2490
- root.style.overflow = "hidden";
2491
- this.extensionsRoot = root;
2492
- if (this.overlayRoot && !root.isConnected) {
2493
- this.container.append(root);
2494
- }
2495
- return root;
2496
- }
2497
- updateExtensionsOverlayPosition() {
2498
- if (!this.extensionsRoot) {
2499
- return;
2500
- }
2501
- // Clamp to non-negative to prevent movement during elastic bounce/overscroll
2502
- const scrollTop = Math.max(0, this.container.scrollTop);
2503
- const scrollLeft = Math.max(0, this.container.scrollLeft);
2504
- if (scrollTop === 0 && scrollLeft === 0) {
2505
- this.extensionsRoot.style.transform = "";
2506
- return;
2507
- }
2508
- this.extensionsRoot.style.transform = `translate(${scrollLeft}px, ${scrollTop}px)`;
2509
- }
2510
- updateSelectionOverlay() {
2511
- if (this.isComposing) {
2512
- return;
2513
- }
2514
- if (!this.overlayRoot || !this.contentRoot) {
2515
- return;
2516
- }
2517
- // Hide custom caret/selection when editor doesn't have focus
2518
- if (!this.hasFocus()) {
2519
- this.updateCaret(null);
2520
- this.syncSelectionRects([]);
2521
- return;
2522
- }
2523
- // On touch devices, always use native caret and selection handles
2524
- const isRecentTouch = Date.now() - this.lastTouchTime < 2000;
2525
- if (this.isTouchDevice() || isRecentTouch) {
2526
- this.contentRoot.classList.add("cake-touch-mode");
2527
- this.updateCaret(null);
2528
- this.syncSelectionRects([]);
2529
- return;
2530
- }
2531
- this.contentRoot.classList.remove("cake-touch-mode");
2532
- const lines = getDocLines(this.state.doc);
2533
- const geometry = getSelectionGeometry({
2534
- root: this.contentRoot,
2535
- container: this.container,
2536
- docLines: lines,
2537
- selection: this.state.selection,
2538
- });
2539
- this.lastFocusRect = geometry.focusRect;
2540
- this.syncSelectionRects(geometry.selectionRects);
2541
- if (geometry.caretRect) {
2542
- this.updateCaret({
2543
- top: geometry.caretRect.top,
2544
- left: geometry.caretRect.left,
2545
- height: geometry.caretRect.height,
2546
- });
2547
- this.markCaretActive();
2548
- }
2549
- else {
2550
- this.updateCaret(null);
2551
- }
2552
- }
2553
- syncSelectionRects(rects) {
2554
- if (!this.overlayRoot || !this.caretElement) {
2555
- return;
2556
- }
2557
- if (this.selectionRectsEqual(this.lastSelectionRects, rects)) {
2558
- return;
2559
- }
2560
- this.lastSelectionRects = rects;
2561
- while (this.selectionRectElements.length > rects.length) {
2562
- const element = this.selectionRectElements.pop();
2563
- element?.remove();
2564
- }
2565
- if (this.selectionRectElements.length < rects.length) {
2566
- const fragment = document.createDocumentFragment();
2567
- while (this.selectionRectElements.length < rects.length) {
2568
- const element = document.createElement("div");
2569
- element.className = "cake-selection-rect";
2570
- fragment.append(element);
2571
- this.selectionRectElements.push(element);
2572
- }
2573
- this.overlayRoot.insertBefore(fragment, this.caretElement);
2574
- }
2575
- rects.forEach((rect, index) => {
2576
- const element = this.selectionRectElements[index];
2577
- if (!element) {
2578
- return;
2579
- }
2580
- element.style.top = `${rect.top}px`;
2581
- element.style.left = `${rect.left}px`;
2582
- element.style.width = `${rect.width}px`;
2583
- element.style.height = `${rect.height}px`;
2584
- });
2585
- }
2586
- updateCaret(position) {
2587
- if (!this.caretElement) {
2588
- return;
2589
- }
2590
- if (!position) {
2591
- this.caretElement.style.display = "none";
2592
- this.stopCaretBlink();
2593
- return;
2594
- }
2595
- this.caretElement.style.display = "";
2596
- this.caretElement.style.top = `${position.top}px`;
2597
- this.caretElement.style.left = `${position.left}px`;
2598
- this.caretElement.style.height = `${position.height}px`;
2599
- }
2600
- markCaretActive() {
2601
- if (!this.caretElement) {
2602
- return;
2603
- }
2604
- this.clearCaretBlinkTimer();
2605
- this.caretElement.classList.remove("is-blinking");
2606
- this.caretBlinkTimeoutId = window.setTimeout(() => {
2607
- this.caretBlinkTimeoutId = null;
2608
- this.caretElement?.classList.add("is-blinking");
2609
- }, 80);
2610
- }
2611
- stopCaretBlink() {
2612
- if (!this.caretElement) {
2613
- return;
2614
- }
2615
- this.clearCaretBlinkTimer();
2616
- this.caretElement.classList.remove("is-blinking");
2617
- }
2618
- clearCaretBlinkTimer() {
2619
- if (this.caretBlinkTimeoutId !== null) {
2620
- window.clearTimeout(this.caretBlinkTimeoutId);
2621
- this.caretBlinkTimeoutId = null;
2622
- }
2623
- }
2624
- scheduleScrollCaretIntoView() {
2625
- if (this.isComposing) {
2626
- return;
2627
- }
2628
- if (this.scrollCaretIntoViewId !== null) {
2629
- return;
2630
- }
2631
- this.scrollCaretIntoViewId = window.requestAnimationFrame(() => {
2632
- this.scrollCaretIntoViewId = null;
2633
- this.scrollCaretIntoView();
2634
- });
2635
- }
2636
- scrollCaretIntoView() {
2637
- if (this.isComposing) {
2638
- return;
2639
- }
2640
- if (!this.contentRoot) {
2641
- return;
2642
- }
2643
- const caret = this.lastFocusRect;
2644
- if (!caret) {
2645
- return;
2646
- }
2647
- const container = this.container;
2648
- if (container.clientHeight <= 0) {
2649
- return;
2650
- }
2651
- const styles = window.getComputedStyle(this.contentRoot);
2652
- const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
2653
- const paddingBottom = Number.parseFloat(styles.paddingBottom) || 0;
2654
- const viewportTop = container.scrollTop;
2655
- const viewportBottom = viewportTop + container.clientHeight;
2656
- const caretTop = caret.top;
2657
- const caretBottom = caret.top + caret.height;
2658
- let nextScrollTop = viewportTop;
2659
- if (caretTop < viewportTop + paddingTop) {
2660
- nextScrollTop = caretTop - paddingTop;
2661
- }
2662
- else if (caretBottom > viewportBottom - paddingBottom) {
2663
- nextScrollTop = caretBottom - container.clientHeight + paddingBottom;
2664
- }
2665
- else {
2666
- return;
2667
- }
2668
- const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
2669
- const clamped = Math.max(0, Math.min(nextScrollTop, maxScrollTop));
2670
- if (Math.abs(clamped - container.scrollTop) > 0.5) {
2671
- container.scrollTop = clamped;
2672
- }
2673
- }
2674
- hitTestFromClientPoint(clientX, clientY) {
2675
- if (!this.contentRoot) {
2676
- return null;
2677
- }
2678
- const lines = getDocLines(this.state.doc);
2679
- const hit = hitTestFromLayout({
2680
- clientX,
2681
- clientY,
2682
- root: this.contentRoot,
2683
- container: this.container,
2684
- lines,
2685
- });
2686
- if (!hit) {
2687
- return null;
2688
- }
2689
- const affinity = hit.pastRowEnd ? "backward" : "forward";
2690
- return { cursorOffset: hit.cursorOffset, affinity };
2691
- }
2692
- handlePointerDown(event) {
2693
- if (!this.isEventTargetInContentRoot(event.target)) {
2694
- return;
2695
- }
2696
- // Reset movement tracking for selection-via-drag detection
2697
- this.hasMovedSincePointerDown = false;
2698
- this.pointerDownPosition = { x: event.clientX, y: event.clientY };
2699
- this.pendingClickHit = null;
2700
- if (this.readOnly) {
2701
- return;
2702
- }
2703
- if (event.button !== 0) {
2704
- return;
2705
- }
2706
- if (!this.contentRoot) {
2707
- return;
2708
- }
2709
- if (event.pointerType === "touch") {
2710
- // For touch interactions, let the browser handle native selection.
2711
- // This enables native caret placement, selection handles, and mobile
2712
- // context menus. We track the touch time to hide the custom caret overlay
2713
- // and trust selectionchange events to sync state.
2714
- this.lastTouchTime = Date.now();
2715
- return;
2716
- }
2717
- const selection = this.state.selection;
2718
- this.blockTrustedTextDrag = false;
2719
- // Atomic blocks (like images) should be draggable even without selecting first.
2720
- // Treat pointerdown on an atomic block as selecting the full line and starting a line drag.
2721
- const target = event.target;
2722
- if (target instanceof HTMLElement) {
2723
- const blockElement = target.closest("[data-block-atom]");
2724
- const lineIndexAttr = blockElement?.getAttribute("data-line-index") ?? null;
2725
- if (lineIndexAttr !== null) {
2726
- const lineIndex = Number.parseInt(lineIndexAttr, 10);
2727
- const lines = getDocLines(this.state.doc);
2728
- const lineInfo = lines[lineIndex];
2729
- if (lineInfo?.isAtomic) {
2730
- const lineOffsets = getLineOffsets(lines);
2731
- const lineStart = lineOffsets[lineIndex] ?? 0;
2732
- const lineEnd = lineStart + lineInfo.cursorLength + (lineInfo.hasNewline ? 1 : 0);
2733
- const atomicSelection = {
2734
- start: lineStart,
2735
- end: lineEnd,
2736
- affinity: "forward",
2737
- };
2738
- event.preventDefault();
2739
- event.stopPropagation();
2740
- this.pendingClickHit = null;
2741
- this.suppressSelectionChange = true;
2742
- this.state = { ...this.state, selection: atomicSelection };
2743
- this.applySelection(atomicSelection);
2744
- this.onSelectionChange?.(atomicSelection);
2745
- this.flushOverlayUpdate();
2746
- this.selectedAtomicLineIndex = lineIndex;
2747
- this.dragState = {
2748
- isDragging: true,
2749
- startLineIndex: lineIndex,
2750
- endLineIndex: lineIndex,
2751
- pointerId: event.pointerId,
2752
- hasMoved: false,
2753
- };
2754
- try {
2755
- this.contentRoot.setPointerCapture(event.pointerId);
2756
- }
2757
- catch {
2758
- // Ignore
2759
- }
2760
- return;
2761
- }
2762
- }
2763
- }
2764
- // For clicks without shift key, apply the selection immediately on pointerdown
2765
- // for better responsiveness. Don't wait for click event.
2766
- // Don't capture when shift is held - that's extend-selection behavior.
2767
- if (!event.shiftKey) {
2768
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
2769
- if (!hit) {
2770
- return;
2771
- }
2772
- // For range selections, check if clicking inside the selection (for drag behavior)
2773
- if (selection.start !== selection.end) {
2774
- const selStart = Math.min(selection.start, selection.end);
2775
- const selEnd = Math.max(selection.start, selection.end);
2776
- const clickedInsideSelection = hit.cursorOffset >= selStart && hit.cursorOffset <= selEnd;
2777
- if (clickedInsideSelection) {
2778
- // Clicking inside selection - set up for potential drag
2779
- this.suppressSelectionChange = false;
2780
- this.blockTrustedTextDrag = true;
2781
- this.pendingClickHit = hit;
2782
- // Continue to drag handling code below
2783
- }
2784
- else {
2785
- // Clicking outside selection - collapse to clicked position
2786
- this.suppressSelectionChange = true;
2787
- this.pendingClickHit = hit;
2788
- const newSelection = {
2789
- start: hit.cursorOffset,
2790
- end: hit.cursorOffset,
2791
- affinity: hit.affinity,
2792
- };
2793
- this.state = this.runtime.updateSelection(this.state, newSelection, {
2794
- kind: "dom",
2795
- });
2796
- this.applySelection(this.state.selection);
2797
- this.onSelectionChange?.(this.state.selection);
2798
- this.scheduleOverlayUpdate();
2799
- return;
2800
- }
2801
- }
2802
- else {
2803
- // Collapsed selection - apply immediately
2804
- this.suppressSelectionChange = true;
2805
- this.pendingClickHit = hit;
2806
- const newSelection = {
2807
- start: hit.cursorOffset,
2808
- end: hit.cursorOffset,
2809
- affinity: hit.affinity,
2810
- };
2811
- this.state = this.runtime.updateSelection(this.state, newSelection, {
2812
- kind: "dom",
2813
- });
2814
- this.applySelection(this.state.selection);
2815
- this.onSelectionChange?.(this.state.selection);
2816
- this.scheduleOverlayUpdate();
2817
- return;
2818
- }
2819
- }
2820
- // For shift+click or clicking inside range selection, let native behavior handle it
2821
- this.suppressSelectionChange = false;
2822
- const selStart = Math.min(selection.start, selection.end);
2823
- const selEnd = Math.max(selection.start, selection.end);
2824
- // Hit test to find the cursor offset at the click position
2825
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
2826
- if (!hit) {
2827
- return;
2828
- }
2829
- // Check if clicking inside existing selection (for drag setup)
2830
- const clickedInsideSelection = hit.cursorOffset >= selStart && hit.cursorOffset <= selEnd;
2831
- if (!clickedInsideSelection) {
2832
- this.blockTrustedTextDrag = false;
2833
- return;
2834
- }
2835
- // Check if this is a full line selection (required for line drag)
2836
- const lines = getDocLines(this.state.doc);
2837
- const lineOffsets = getLineOffsets(lines);
2838
- // Find which line the selection starts on
2839
- // We need to handle the case where selStart might be at the newline position
2840
- // of the previous line (offset - 1) due to DOM selection normalization
2841
- let startLineIndex = -1;
2842
- for (let i = 0; i < lineOffsets.length; i++) {
2843
- const lineStart = lineOffsets[i];
2844
- // Check if selection starts exactly at line start
2845
- if (lineStart === selStart) {
2846
- startLineIndex = i;
2847
- break;
2848
- }
2849
- // If selection starts at previous line's end (newline position),
2850
- // treat it as starting at this line
2851
- if (i > 0 && lineStart === selStart + 1) {
2852
- startLineIndex = i;
2853
- break;
2854
- }
2855
- }
2856
- // Find which line the selection ends on
2857
- let endLineIndex = -1;
2858
- for (let i = 0; i < lines.length; i++) {
2859
- const lineStart = lineOffsets[i] ?? 0;
2860
- if (lineStart >= selEnd) {
2861
- continue;
2862
- }
2863
- const lineInfo = lines[i];
2864
- const lineEnd = lineStart + lineInfo.cursorLength;
2865
- // Selection end matches line end (with optional trailing newline)
2866
- if (selEnd === lineEnd || selEnd === lineEnd + 1) {
2867
- endLineIndex = i;
2868
- }
2869
- }
2870
- const isFullLineSelection = startLineIndex !== -1 &&
2871
- endLineIndex !== -1 &&
2872
- endLineIndex >= startLineIndex;
2873
- if (!isFullLineSelection) {
2874
- // Clicking inside a text selection and dragging should adjust selection,
2875
- // not start a native drag-and-drop operation (which collapses selection in Playwright).
2876
- event.preventDefault();
2877
- event.stopPropagation();
2878
- this.blockTrustedTextDrag = true;
2879
- this.suppressSelectionChange = false;
2880
- this.selectionDragState = {
2881
- pointerId: event.pointerId,
2882
- anchorOffset: hit.cursorOffset,
2883
- };
2884
- try {
2885
- this.contentRoot.setPointerCapture(event.pointerId);
2886
- }
2887
- catch {
2888
- // Ignore
2889
- }
2890
- return;
2891
- }
2892
- // Prevent the browser from changing the selection
2893
- event.preventDefault();
2894
- event.stopPropagation();
2895
- // Suppress selection changes while dragging - this prevents the model selection
2896
- // from being updated when the browser's DOM selection changes during drag
2897
- this.suppressSelectionChange = true;
2898
- // Initialize drag state
2899
- this.dragState = {
2900
- isDragging: true,
2901
- startLineIndex,
2902
- endLineIndex,
2903
- pointerId: event.pointerId,
2904
- hasMoved: false,
2905
- };
2906
- // Capture pointer for reliable move/up events
2907
- try {
2908
- this.contentRoot.setPointerCapture(event.pointerId);
2909
- }
2910
- catch {
2911
- // Some browsers may not support pointer capture
2912
- }
2913
- }
2914
- handlePointerMove(event) {
2915
- // Track if mouse has moved significantly since pointer down.
2916
- // We use a threshold to avoid false positives from micro-movements or
2917
- // synthetic pointermove events that some browsers fire even without movement.
2918
- const DRAG_THRESHOLD = 5; // pixels
2919
- if (this.pointerDownPosition && !this.hasMovedSincePointerDown) {
2920
- const dx = event.clientX - this.pointerDownPosition.x;
2921
- const dy = event.clientY - this.pointerDownPosition.y;
2922
- const distance = Math.sqrt(dx * dx + dy * dy);
2923
- if (distance >= DRAG_THRESHOLD) {
2924
- this.hasMovedSincePointerDown = true;
2925
- // When user starts dragging to create/adjust a selection, clear pending click
2926
- // state and allow selectionchange events through (unless we are line-dragging).
2927
- this.pendingClickHit = null;
2928
- if (!this.dragState?.isDragging) {
2929
- this.suppressSelectionChange = false;
2930
- }
2931
- }
2932
- }
2933
- if (this.selectionDragState) {
2934
- if (event.pointerId !== this.selectionDragState.pointerId) {
2935
- return;
2936
- }
2937
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
2938
- if (!hit) {
2939
- return;
2940
- }
2941
- this.applySelectionUpdate({
2942
- start: this.selectionDragState.anchorOffset,
2943
- end: hit.cursorOffset,
2944
- affinity: hit.affinity,
2945
- });
2946
- return;
2947
- }
2948
- if (!this.dragState || !this.dragState.isDragging) {
2949
- return;
2950
- }
2951
- if (event.pointerId !== this.dragState.pointerId) {
2952
- return;
2953
- }
2954
- this.dragState.hasMoved = true;
2955
- // Show drop indicator
2956
- this.showDropIndicator(event.clientY);
2957
- }
2958
- handlePointerUp(event) {
2959
- this.blockTrustedTextDrag = false;
2960
- // Don't clear pendingClickHit here - let the click handler do it.
2961
- // The click event fires after pointerup, and we need to keep
2962
- // suppressSelectionChange=true until click completes.
2963
- if (this.selectionDragState) {
2964
- if (event.pointerId !== this.selectionDragState.pointerId) {
2965
- return;
2966
- }
2967
- this.selectionDragState = null;
2968
- if (this.contentRoot) {
2969
- try {
2970
- this.contentRoot.releasePointerCapture(event.pointerId);
2971
- }
2972
- catch {
2973
- // Ignore
2974
- }
2975
- }
2976
- this.suppressSelectionChange = false;
2977
- if (!this.hasMovedSincePointerDown) {
2978
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
2979
- if (hit) {
2980
- this.applySelectionUpdate({
2981
- start: hit.cursorOffset,
2982
- end: hit.cursorOffset,
2983
- affinity: hit.affinity,
2984
- });
2985
- }
2986
- }
2987
- return;
2988
- }
2989
- if (!this.dragState || !this.dragState.isDragging) {
2990
- if (this.hasMovedSincePointerDown) {
2991
- queueMicrotask(() => {
2992
- this.syncSelectionFromDom();
2993
- this.flushOverlayUpdate();
2994
- });
2995
- }
2996
- return;
2997
- }
2998
- if (event.pointerId !== this.dragState.pointerId) {
2999
- return;
3000
- }
3001
- const { startLineIndex, endLineIndex, hasMoved } = this.dragState;
3002
- // Release pointer capture
3003
- if (this.contentRoot) {
3004
- try {
3005
- this.contentRoot.releasePointerCapture(event.pointerId);
3006
- }
3007
- catch {
3008
- // Ignore
3009
- }
3010
- }
3011
- // Hide drop indicator
3012
- this.hideDropIndicator();
3013
- // Clear drag state and re-enable selection change handling
3014
- this.dragState = null;
3015
- this.suppressSelectionChange = false;
3016
- if (!hasMoved) {
3017
- return;
3018
- }
3019
- // Calculate drop target line index
3020
- const toLineIndex = this.calculateDropLineIndex(event.clientY);
3021
- if (toLineIndex === null) {
3022
- return;
3023
- }
3024
- // Don't move if dropping within the source range
3025
- const isOutsideSourceRange = toLineIndex < startLineIndex || toLineIndex > endLineIndex + 1;
3026
- if (!isOutsideSourceRange) {
3027
- return;
3028
- }
3029
- // Perform the move
3030
- this.moveLines(startLineIndex, endLineIndex, toLineIndex);
3031
- }
3032
- showDropIndicator(clientY) {
3033
- if (!this.contentRoot) {
3034
- return;
3035
- }
3036
- if (!this.dropIndicator) {
3037
- const indicator = document.createElement("div");
3038
- indicator.className = "cake-drop-indicator";
3039
- indicator.style.position = "absolute";
3040
- indicator.style.left = "0";
3041
- indicator.style.right = "0";
3042
- indicator.style.pointerEvents = "none";
3043
- this.dropIndicator = indicator;
3044
- }
3045
- const containerRect = this.container.getBoundingClientRect();
3046
- const scrollTop = this.container.scrollTop;
3047
- // Find the closest line boundary
3048
- const lines = this.contentRoot.querySelectorAll("[data-line-index]");
3049
- let closestY = 0;
3050
- for (const line of lines) {
3051
- const lineRect = line.getBoundingClientRect();
3052
- const lineMidpoint = (lineRect.top + lineRect.bottom) / 2;
3053
- if (clientY >= lineMidpoint) {
3054
- closestY = lineRect.bottom - containerRect.top + scrollTop;
3055
- }
3056
- else {
3057
- break;
3058
- }
3059
- }
3060
- // If cursor is above all lines, show at top
3061
- if (lines.length > 0) {
3062
- const firstLine = lines[0];
3063
- const firstRect = firstLine.getBoundingClientRect();
3064
- if (clientY < (firstRect.top + firstRect.bottom) / 2) {
3065
- closestY = firstRect.top - containerRect.top + scrollTop;
3066
- }
3067
- }
3068
- this.dropIndicator.style.top = `${closestY - 1}px`;
3069
- if (!this.dropIndicator.parentElement) {
3070
- this.container.appendChild(this.dropIndicator);
3071
- }
3072
- }
3073
- hideDropIndicator() {
3074
- if (this.dropIndicator?.parentElement) {
3075
- this.dropIndicator.remove();
3076
- }
3077
- }
3078
- findClosestLineByY(clientY) {
3079
- if (!this.contentRoot) {
3080
- return null;
3081
- }
3082
- const lines = this.contentRoot.querySelectorAll("[data-line-index]");
3083
- if (lines.length === 0) {
3084
- return null;
3085
- }
3086
- let closestLine = null;
3087
- let closestDistance = Infinity;
3088
- for (const line of lines) {
3089
- const rect = line.getBoundingClientRect();
3090
- const centerY = (rect.top + rect.bottom) / 2;
3091
- const distance = Math.abs(clientY - centerY);
3092
- if (distance < closestDistance) {
3093
- closestDistance = distance;
3094
- closestLine = line;
3095
- }
3096
- }
3097
- return closestLine;
3098
- }
3099
- calculateDropLineIndex(clientY) {
3100
- if (!this.contentRoot) {
3101
- return null;
3102
- }
3103
- const lines = this.contentRoot.querySelectorAll("[data-line-index]");
3104
- const numLines = this.state.doc.blocks.length;
3105
- if (lines.length === 0) {
3106
- return 0;
3107
- }
3108
- let toLineIndex = 0;
3109
- for (const line of lines) {
3110
- const lineRect = line.getBoundingClientRect();
3111
- const lineIndex = parseInt(line.getAttribute("data-line-index") ?? "0", 10);
3112
- const lineMidpoint = (lineRect.top + lineRect.bottom) / 2;
3113
- if (clientY >= lineMidpoint) {
3114
- toLineIndex = lineIndex + 1;
3115
- }
3116
- }
3117
- return Math.min(toLineIndex, numLines);
3118
- }
3119
- detectFullLineSelection(selStart, selEnd, lines, lineOffsets) {
3120
- // Find which line the selection starts on
3121
- let startLineIndex = -1;
3122
- for (let i = 0; i < lineOffsets.length; i++) {
3123
- const lineStart = lineOffsets[i];
3124
- // Check if selection starts exactly at line start
3125
- if (lineStart === selStart) {
3126
- startLineIndex = i;
3127
- break;
3128
- }
3129
- // If selection starts at previous line's end (newline position),
3130
- // treat it as starting at this line
3131
- if (i > 0 && lineStart === selStart + 1) {
3132
- startLineIndex = i;
3133
- break;
3134
- }
3135
- }
3136
- // Find which line the selection ends on
3137
- let endLineIndex = -1;
3138
- for (let i = 0; i < lines.length; i++) {
3139
- const lineStart = lineOffsets[i] ?? 0;
3140
- if (lineStart >= selEnd) {
3141
- continue;
3142
- }
3143
- const lineInfo = lines[i];
3144
- const lineEnd = lineStart + lineInfo.cursorLength;
3145
- // Selection end matches line end (with optional trailing newline)
3146
- if (selEnd === lineEnd || selEnd === lineEnd + 1) {
3147
- endLineIndex = i;
3148
- }
3149
- }
3150
- const isFullLineSelection = startLineIndex !== -1 &&
3151
- endLineIndex !== -1 &&
3152
- endLineIndex >= startLineIndex;
3153
- if (!isFullLineSelection) {
3154
- return null;
3155
- }
3156
- return { startLineIndex, endLineIndex };
3157
- }
3158
- moveLines(fromStart, fromEnd, toIndex) {
3159
- const source = this.state.source;
3160
- const sourceLines = source.split("\n");
3161
- const numLines = sourceLines.length;
3162
- // Validate indices
3163
- if (fromStart < 0 ||
3164
- fromEnd >= numLines ||
3165
- fromStart > fromEnd ||
3166
- toIndex < 0 ||
3167
- toIndex > numLines) {
3168
- return;
3169
- }
3170
- // Extract the lines to move
3171
- const linesToMove = sourceLines.slice(fromStart, fromEnd + 1);
3172
- const linesCount = linesToMove.length;
3173
- // Build the new source
3174
- let newLines;
3175
- if (toIndex <= fromStart) {
3176
- // Moving up
3177
- newLines = [
3178
- ...sourceLines.slice(0, toIndex),
3179
- ...linesToMove,
3180
- ...sourceLines.slice(toIndex, fromStart),
3181
- ...sourceLines.slice(fromEnd + 1),
3182
- ];
3183
- }
3184
- else {
3185
- // Moving down
3186
- newLines = [
3187
- ...sourceLines.slice(0, fromStart),
3188
- ...sourceLines.slice(fromEnd + 1, toIndex),
3189
- ...linesToMove,
3190
- ...sourceLines.slice(toIndex),
3191
- ];
3192
- }
3193
- const newSource = newLines.join("\n");
3194
- // Calculate new selection: select the moved lines at their new position
3195
- let newSelectionStart;
3196
- if (toIndex <= fromStart) {
3197
- // Moving up: new position starts at toIndex
3198
- newSelectionStart = newLines.slice(0, toIndex).join("\n").length;
3199
- if (toIndex > 0) {
3200
- newSelectionStart += 1; // Account for the newline before
3201
- }
3202
- }
3203
- else {
3204
- // Moving down: new position
3205
- const newStartLineIndex = toIndex - linesCount;
3206
- newSelectionStart = newLines
3207
- .slice(0, newStartLineIndex)
3208
- .join("\n").length;
3209
- if (newStartLineIndex > 0) {
3210
- newSelectionStart += 1;
3211
- }
3212
- }
3213
- // Record history before the move
3214
- this.recordHistory("move");
3215
- // Create the new state
3216
- this.state = this.runtime.createState(newSource, {
3217
- start: newSelectionStart,
3218
- end: newSelectionStart,
3219
- affinity: "forward",
3220
- });
3221
- this.render();
3222
- this.onChange?.(this.state.source, this.state.selection);
3223
- }
3224
- handleDragStart(event) {
3225
- if (this.readOnly) {
3226
- return;
3227
- }
3228
- if (event.isTrusted && this.blockTrustedTextDrag) {
3229
- event.preventDefault();
3230
- return;
3231
- }
3232
- // Try to use model selection, but fall back to DOM selection if needed
3233
- let selection = this.state.selection;
3234
- let start = Math.min(selection.start, selection.end);
3235
- let end = Math.max(selection.start, selection.end);
3236
- // If model selection is collapsed, try reading from DOM
3237
- if (start === end && this.domMap) {
3238
- const domSelection = readDomSelection(this.domMap);
3239
- if (domSelection && domSelection.start !== domSelection.end) {
3240
- selection = domSelection;
3241
- start = Math.min(domSelection.start, domSelection.end);
3242
- end = Math.max(domSelection.start, domSelection.end);
3243
- }
3244
- }
3245
- if (start === end) {
3246
- return;
3247
- }
3248
- // Get the plain text and source text for the selection
3249
- const lines = getDocLines(this.state.doc);
3250
- const visibleText = getVisibleText(lines);
3251
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
3252
- const visibleEnd = cursorOffsetToVisibleOffset(lines, end);
3253
- const plainText = visibleText.slice(visibleStart, visibleEnd);
3254
- // Get source text for the selection (use backward/forward to capture full markdown syntax)
3255
- const cursorSourceMap = this.state.map;
3256
- const sourceStart = cursorSourceMap.cursorToSource(start, "backward");
3257
- const sourceEnd = cursorSourceMap.cursorToSource(end, "forward");
3258
- const sourceText = this.state.source.slice(sourceStart, sourceEnd);
3259
- this.textDragState = {
3260
- selection: { start, end, affinity: selection.affinity },
3261
- plainText,
3262
- sourceText,
3263
- };
3264
- if (event.dataTransfer) {
3265
- event.dataTransfer.setData("text/plain", plainText);
3266
- event.dataTransfer.effectAllowed = "move";
3267
- }
3268
- }
3269
- handleDragOver(event) {
3270
- if (this.readOnly) {
3271
- return;
3272
- }
3273
- event.preventDefault();
3274
- if (event.dataTransfer) {
3275
- event.dataTransfer.dropEffect = this.textDragState ? "move" : "copy";
3276
- }
3277
- }
3278
- handleDrop(event) {
3279
- if (this.readOnly) {
3280
- return;
3281
- }
3282
- // Let image file drops bubble to the image-drop extension
3283
- const dataTransfer = event.dataTransfer;
3284
- const hasImageFile = this.dataTransferHasImageFile(dataTransfer);
3285
- if (hasImageFile) {
3286
- // Don't prevent default or stop propagation - let the extension handle it
3287
- this.textDragState = null;
3288
- return;
3289
- }
3290
- const hit = this.hitTestFromClientPoint(event.clientX, event.clientY);
3291
- if (!hit) {
3292
- this.textDragState = null;
3293
- return;
3294
- }
3295
- event.preventDefault();
3296
- event.stopPropagation();
3297
- const dropOffset = hit.cursorOffset;
3298
- let dragState = this.textDragState;
3299
- this.textDragState = null;
3300
- // If no drag state, try to reconstruct from current selection (model or DOM)
3301
- if (!dragState) {
3302
- let selection = this.state.selection;
3303
- // Fall back to DOM selection if model selection is collapsed
3304
- if (selection.start === selection.end && this.domMap) {
3305
- const domSelection = readDomSelection(this.domMap);
3306
- if (domSelection && domSelection.start !== domSelection.end) {
3307
- selection = domSelection;
3308
- }
3309
- }
3310
- if (selection.start !== selection.end) {
3311
- const start = Math.min(selection.start, selection.end);
3312
- const end = Math.max(selection.start, selection.end);
3313
- const lines = getDocLines(this.state.doc);
3314
- const visibleText = getVisibleText(lines);
3315
- const visibleStart = cursorOffsetToVisibleOffset(lines, start);
3316
- const visibleEnd = cursorOffsetToVisibleOffset(lines, end);
3317
- const plainText = visibleText.slice(visibleStart, visibleEnd);
3318
- const cursorSourceMap = this.state.map;
3319
- const sourceStart = cursorSourceMap.cursorToSource(start, "backward");
3320
- const sourceEnd = cursorSourceMap.cursorToSource(end, "forward");
3321
- const sourceText = this.state.source.slice(sourceStart, sourceEnd);
3322
- dragState = {
3323
- selection: { start, end, affinity: selection.affinity },
3324
- plainText,
3325
- sourceText,
3326
- };
3327
- }
3328
- }
3329
- if (dragState) {
3330
- // Internal drag - move the content
3331
- const dragStart = dragState.selection.start;
3332
- const dragEnd = dragState.selection.end;
3333
- // Don't drop within the source range
3334
- if (dropOffset >= dragStart && dropOffset <= dragEnd) {
3335
- return;
3336
- }
3337
- // Check if this is a full-line selection - if so, use line-level move
3338
- const lines = getDocLines(this.state.doc);
3339
- const lineOffsets = getLineOffsets(lines);
3340
- const fullLineInfo = this.detectFullLineSelection(dragStart, dragEnd, lines, lineOffsets);
3341
- if (fullLineInfo) {
3342
- // Full line drag - use line-level move
3343
- const toLineIndex = this.calculateDropLineIndex(event.clientY);
3344
- if (toLineIndex === null) {
3345
- return;
3346
- }
3347
- // Don't move if dropping within the source range
3348
- const isOutsideSourceRange = toLineIndex < fullLineInfo.startLineIndex ||
3349
- toLineIndex > fullLineInfo.endLineIndex + 1;
3350
- if (!isOutsideSourceRange) {
3351
- return;
3352
- }
3353
- this.moveLines(fullLineInfo.startLineIndex, fullLineInfo.endLineIndex, toLineIndex);
3354
- return;
3355
- }
3356
- // Text-level drag - move inline content
3357
- // Record history
3358
- this.recordHistory("drag_drop");
3359
- // Calculate positions for the move
3360
- const selectionLength = dragEnd - dragStart;
3361
- const adjustedDrop = dropOffset > dragEnd ? dropOffset - selectionLength : dropOffset;
3362
- // Delete the source content first
3363
- const deleteState = this.runtime.createState(this.state.source, {
3364
- start: dragStart,
3365
- end: dragEnd,
3366
- affinity: "forward",
3367
- });
3368
- const afterDelete = this.runtime.applyEdit({ type: "insert", text: "" }, deleteState);
3369
- // Insert at the adjusted position
3370
- const insertState = this.runtime.createState(afterDelete.source, {
3371
- start: adjustedDrop,
3372
- end: adjustedDrop,
3373
- affinity: "forward",
3374
- });
3375
- const afterInsert = this.runtime.applyEdit({ type: "insert", text: dragState.sourceText }, insertState);
3376
- this.state = afterInsert;
3377
- this.render();
3378
- this.onChange?.(this.state.source, this.state.selection);
3379
- return;
3380
- }
3381
- // External drop - insert the text
3382
- const text = dataTransfer?.getData("text/plain") ?? "";
3383
- if (!text) {
3384
- return;
3385
- }
3386
- this.recordHistory("paste");
3387
- const insertState = this.runtime.createState(this.state.source, {
3388
- start: dropOffset,
3389
- end: dropOffset,
3390
- affinity: "forward",
3391
- });
3392
- const afterInsert = this.runtime.applyEdit({ type: "insert", text }, insertState);
3393
- this.state = afterInsert;
3394
- this.render();
3395
- this.onChange?.(this.state.source, this.state.selection);
3396
- }
3397
- handleDragEnd() {
3398
- this.textDragState = null;
3399
- }
3400
- dataTransferHasImageFile(dataTransfer) {
3401
- if (!dataTransfer) {
3402
- return false;
3403
- }
3404
- if (dataTransfer.files) {
3405
- for (let i = 0; i < dataTransfer.files.length; i++) {
3406
- const file = dataTransfer.files[i];
3407
- if (file && file.type.startsWith("image/")) {
3408
- return true;
3409
- }
3410
- }
3411
- }
3412
- if (dataTransfer.items) {
3413
- for (let i = 0; i < dataTransfer.items.length; i++) {
3414
- const item = dataTransfer.items[i];
3415
- if (item.kind === "file" && item.type.startsWith("image/")) {
3416
- return true;
3417
- }
3418
- }
3419
- }
3420
- return false;
3421
- }
3422
- }
3423
- function selectionsEqual(a, b) {
3424
- return a.start === b.start && a.end === b.end && a.affinity === b.affinity;
3425
- }
3426
- function isCompositionInputType(inputType) {
3427
- return (inputType === "insertCompositionText" || inputType === "compositionend");
3428
- }
3429
- function resolveTextPoint(node, offset) {
3430
- const children = node.childNodes;
3431
- if (children.length === 0) {
3432
- return null;
3433
- }
3434
- if (offset <= 0) {
3435
- const first = findTextNodeAtOrAfter(children, 0);
3436
- return first ? { node: first, offset: 0 } : null;
3437
- }
3438
- if (offset >= children.length) {
3439
- const last = findTextNodeAtOrBefore(children, children.length - 1);
3440
- return last ? { node: last, offset: last.data.length } : null;
3441
- }
3442
- const exact = findFirstTextNode(children[offset]);
3443
- if (exact) {
3444
- return { node: exact, offset: 0 };
3445
- }
3446
- const previous = findTextNodeAtOrBefore(children, offset - 1);
3447
- if (previous) {
3448
- return { node: previous, offset: previous.data.length };
3449
- }
3450
- const next = findTextNodeAtOrAfter(children, offset + 1);
3451
- return next ? { node: next, offset: 0 } : null;
3452
- }
3453
- function findFirstTextNode(node) {
3454
- if (node instanceof Text) {
3455
- return node;
3456
- }
3457
- const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
3458
- const next = walker.nextNode();
3459
- return next instanceof Text ? next : null;
3460
- }
3461
- function findLastTextNode(node) {
3462
- if (node instanceof Text) {
3463
- return node;
3464
- }
3465
- const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
3466
- let last = null;
3467
- let current = walker.nextNode();
3468
- while (current) {
3469
- if (current instanceof Text) {
3470
- last = current;
3471
- }
3472
- current = walker.nextNode();
3473
- }
3474
- return last;
3475
- }
3476
- function findTextNodeAtOrAfter(nodes, start) {
3477
- for (let i = Math.max(0, start); i < nodes.length; i += 1) {
3478
- const found = findFirstTextNode(nodes[i]);
3479
- if (found) {
3480
- return found;
3481
- }
3482
- }
3483
- return null;
3484
- }
3485
- function caretPositionFromPoint(x, y) {
3486
- const doc = document;
3487
- if (typeof doc.caretPositionFromPoint === "function") {
3488
- return doc.caretPositionFromPoint(x, y);
3489
- }
3490
- return null;
3491
- }
3492
- function caretRangeFromPoint(x, y) {
3493
- if (typeof document.caretRangeFromPoint === "function") {
3494
- return document.caretRangeFromPoint(x, y);
3495
- }
3496
- return null;
3497
- }
3498
- function getVisualRowBoundaries(params) {
3499
- const { lines, layout, offset, affinity } = params;
3500
- if (!layout || layout.lines.length === 0) {
3501
- return { rowStart: 0, rowEnd: 0 };
3502
- }
3503
- const resolved = resolveOffsetToLine(lines, offset);
3504
- const line = layout.lines[resolved.lineIndex];
3505
- if (!line) {
3506
- return { rowStart: 0, rowEnd: 0 };
3507
- }
3508
- if (line.rows.length === 0) {
3509
- return {
3510
- rowStart: line.lineStartOffset,
3511
- rowEnd: line.lineStartOffset + line.lineLength,
3512
- };
3513
- }
3514
- const rowIndex = findRowIndexForOffset(line.rows, resolved.offsetInLine, affinity);
3515
- const row = line.rows[rowIndex] ?? line.rows[line.rows.length - 1];
3516
- return {
3517
- rowStart: line.lineStartOffset + row.startOffset,
3518
- rowEnd: line.lineStartOffset + row.endOffset,
3519
- };
3520
- }
3521
- function findRowIndexForOffset(rows, offset, affinity) {
3522
- if (rows.length === 0) {
3523
- return 0;
3524
- }
3525
- for (let index = 0; index < rows.length; index += 1) {
3526
- const row = rows[index];
3527
- if (offset === row.startOffset) {
3528
- if (affinity === "backward" && index > 0) {
3529
- return index - 1;
3530
- }
3531
- return index;
3532
- }
3533
- if (offset === row.endOffset) {
3534
- if (affinity === "forward" && index + 1 < rows.length) {
3535
- return index + 1;
3536
- }
3537
- return index;
3538
- }
3539
- if (offset > row.startOffset && offset < row.endOffset) {
3540
- return index;
3541
- }
3542
- }
3543
- return rows.length - 1;
3544
- }
3545
- function resolveSelectionAffinity(selection) {
3546
- if (selection.start === selection.end) {
3547
- return selection.affinity ?? "backward";
3548
- }
3549
- return selection.affinity ?? "forward";
3550
- }
3551
- function normalizeSelection(selection, maxLength) {
3552
- const start = clampOffset(selection.start, maxLength);
3553
- const end = clampOffset(selection.end, maxLength);
3554
- const normalized = start <= end ? { start, end } : { start: end, end: start };
3555
- return selection.affinity
3556
- ? { ...normalized, affinity: selection.affinity }
3557
- : normalized;
3558
- }
3559
- function resolveSelectionAnchorAndFocus(selection) {
3560
- if (selection.start === selection.end) {
3561
- return { anchor: selection.start, focus: selection.start };
3562
- }
3563
- const affinity = resolveSelectionAffinity(selection);
3564
- if (affinity === "backward") {
3565
- return { anchor: selection.end, focus: selection.start };
3566
- }
3567
- return { anchor: selection.start, focus: selection.end };
3568
- }
3569
- function selectionFromAnchor(anchor, focus, affinity) {
3570
- if (anchor === focus) {
3571
- return { start: focus, end: focus, affinity };
3572
- }
3573
- if (anchor < focus) {
3574
- return { start: anchor, end: focus, affinity: "forward" };
3575
- }
3576
- return { start: focus, end: anchor, affinity: "backward" };
3577
- }
3578
- function clampOffset(offset, maxLength) {
3579
- return Math.max(0, Math.min(offset, maxLength));
3580
- }
3581
- function findTextNodeAtOrBefore(nodes, start) {
3582
- for (let i = Math.min(nodes.length - 1, start); i >= 0; i -= 1) {
3583
- const found = findLastTextNode(nodes[i]);
3584
- if (found) {
3585
- return found;
3586
- }
3587
- }
3588
- return null;
3589
- }