@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.
- package/dist/cake/core/mapping/cursor-source-map.d.ts +11 -0
- package/dist/cake/core/mapping/cursor-source-map.d.ts.map +1 -1
- package/dist/cake/core/mapping/cursor-source-map.js +159 -21
- package/dist/cake/core/runtime.d.ts +6 -0
- package/dist/cake/core/runtime.d.ts.map +1 -1
- package/dist/cake/core/runtime.js +344 -221
- package/dist/cake/dom/render.d.ts +32 -2
- package/dist/cake/dom/render.d.ts.map +1 -1
- package/dist/cake/dom/render.js +401 -118
- package/dist/cake/editor/cake-editor.d.ts +11 -2
- package/dist/cake/editor/cake-editor.d.ts.map +1 -1
- package/dist/cake/editor/cake-editor.js +178 -100
- package/dist/cake/editor/internal/editor-text-model.d.ts +49 -0
- package/dist/cake/editor/internal/editor-text-model.d.ts.map +1 -0
- package/dist/cake/editor/internal/editor-text-model.js +284 -0
- package/dist/cake/editor/selection/selection-geometry-dom.d.ts +5 -1
- package/dist/cake/editor/selection/selection-geometry-dom.d.ts.map +1 -1
- package/dist/cake/editor/selection/selection-geometry-dom.js +4 -5
- package/dist/cake/editor/selection/selection-layout-dom.d.ts.map +1 -1
- package/dist/cake/editor/selection/selection-layout-dom.js +2 -5
- package/dist/cake/editor/selection/selection-layout.d.ts +2 -15
- package/dist/cake/editor/selection/selection-layout.d.ts.map +1 -1
- package/dist/cake/editor/selection/selection-layout.js +1 -99
- package/dist/cake/editor/selection/selection-navigation.d.ts +4 -0
- package/dist/cake/editor/selection/selection-navigation.d.ts.map +1 -1
- package/dist/cake/editor/selection/selection-navigation.js +1 -2
- package/dist/cake/extensions/index.d.ts +2 -1
- package/dist/cake/extensions/index.d.ts.map +1 -1
- package/dist/cake/extensions/index.js +3 -1
- package/dist/cake/extensions/link/link.d.ts.map +1 -1
- package/dist/cake/extensions/link/link.js +1 -7
- package/dist/cake/extensions/shared/structural-reparse-policy.d.ts +7 -0
- package/dist/cake/extensions/shared/structural-reparse-policy.d.ts.map +1 -0
- package/dist/cake/extensions/shared/structural-reparse-policy.js +16 -0
- package/package.json +5 -2
- package/dist/cake/editor/selection/visible-text.d.ts +0 -5
- package/dist/cake/editor/selection/visible-text.d.ts.map +0 -1
- package/dist/cake/editor/selection/visible-text.js +0 -66
- package/dist/cake/engine/cake-engine.d.ts +0 -230
- package/dist/cake/engine/cake-engine.d.ts.map +0 -1
- package/dist/cake/engine/cake-engine.js +0 -3589
- package/dist/cake/engine/selection/selection-geometry-dom.d.ts +0 -24
- package/dist/cake/engine/selection/selection-geometry-dom.d.ts.map +0 -1
- package/dist/cake/engine/selection/selection-geometry-dom.js +0 -302
- package/dist/cake/engine/selection/selection-geometry.d.ts +0 -22
- package/dist/cake/engine/selection/selection-geometry.d.ts.map +0 -1
- package/dist/cake/engine/selection/selection-geometry.js +0 -158
- package/dist/cake/engine/selection/selection-layout-dom.d.ts +0 -50
- package/dist/cake/engine/selection/selection-layout-dom.d.ts.map +0 -1
- package/dist/cake/engine/selection/selection-layout-dom.js +0 -781
- package/dist/cake/engine/selection/selection-layout.d.ts +0 -55
- package/dist/cake/engine/selection/selection-layout.d.ts.map +0 -1
- package/dist/cake/engine/selection/selection-layout.js +0 -128
- package/dist/cake/engine/selection/selection-navigation.d.ts +0 -22
- package/dist/cake/engine/selection/selection-navigation.d.ts.map +0 -1
- package/dist/cake/engine/selection/selection-navigation.js +0 -229
- package/dist/cake/engine/selection/visible-text.d.ts +0 -5
- package/dist/cake/engine/selection/visible-text.d.ts.map +0 -1
- package/dist/cake/engine/selection/visible-text.js +0 -66
- package/dist/cake/react/CakeEditor.d.ts +0 -58
- package/dist/cake/react/CakeEditor.d.ts.map +0 -1
- 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
|
-
}
|