Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/slate-react/components/selection-sync.ts
1698 views
1
/*
2
Syncing the selection between slate and the DOM.
3
4
This started by factoring out the relevant code from editable.tsx.
5
We then rewrote it to work with windowing, which of course discards
6
the DOM outside the visible window, hence full sync no longer makes
7
sense -- instead the slate selection is the sole source of truth, and
8
the DOM just partly reflects that, and user manipulation of the DOM
9
merely influences slate's state, rather than completely determining it.
10
11
I spent forever (!) trying various strategies involving locks and
12
timeouts, which could never work perfectly on many different
13
platforms. This simple algorithm evidently does, and involves *NO*
14
asynchronous code or locks at all! Also, there are no platform
15
specific hacks at all.
16
*/
17
18
import { useCallback } from "react";
19
import { useIsomorphicLayoutEffect } from "../hooks/use-isomorphic-layout-effect";
20
import { ReactEditor } from "..";
21
import { EDITOR_TO_ELEMENT } from "../utils/weak-maps";
22
import { Editor, Point, Range, Selection, Transforms } from "slate";
23
import { hasEditableTarget, isTargetInsideVoid } from "./dom-utils";
24
import { DOMElement } from "../utils/dom";
25
import { isEqual } from "lodash";
26
27
interface SelectionState {
28
isComposing: boolean;
29
shiftKey: boolean;
30
latestElement: DOMElement | null;
31
32
// If part of the selection gets scrolled out of the DOM, we set windowedSelection
33
// to true. The next time the selection in the DOM is read, we then set
34
// windowedSelection to that read value and don't update editor.selection
35
// unless the selection in the DOM is changed to something else manually.
36
// This way editor.selection doesn't change at all (unless user actually manually
37
// changes it), and true selection is then used to select proper part of editor
38
// that is actually rendered in the DOM.
39
windowedSelection?: true | Range;
40
41
// if true, the selection sync hooks are temporarily disabled.
42
ignoreSelection: boolean;
43
}
44
45
export const useUpdateDOMSelection = ({
46
editor,
47
state,
48
}: {
49
editor: ReactEditor;
50
state: SelectionState;
51
}) => {
52
// Ensure that the DOM selection state is set to the editor selection.
53
// Note that whenever the DOM gets updated (e.g., with every keystroke when editing)
54
// the DOM selection gets completely reset (because react replaces the selected text
55
// by new text), so this setting of the selection usually happens, and happens
56
// **a lot**.
57
const updateDOMSelection = () => {
58
if (
59
state.isComposing ||
60
!ReactEditor.isFocused(editor) ||
61
state.ignoreSelection
62
) {
63
return;
64
}
65
66
const domSelection = window.getSelection();
67
if (!domSelection) {
68
delete state.windowedSelection;
69
return;
70
}
71
72
let selection;
73
try {
74
selection = getWindowedSelection(editor);
75
} catch (err) {
76
// in rare cases when document / selection seriously "messed up", this
77
// can happen because Editor.before throws below. In such cases we
78
// give up by setting the selection to empty, so it will get cleared in
79
// the DOM. I saw this once in development.
80
console.warn(
81
`getWindowedSelection warning - ${err} - so clearing selection`,
82
);
83
Transforms.deselect(editor); // just clear selection
84
selection = undefined;
85
}
86
const isCropped = !isEqual(editor.selection, selection);
87
if (!isCropped) {
88
delete state.windowedSelection;
89
}
90
// console.log(
91
// "\nwindowed selection =",
92
// JSON.stringify(selection),
93
// "\neditor.selection =",
94
// JSON.stringify(editor.selection)
95
// );
96
const hasDomSelection = domSelection.type !== "None";
97
98
// If the DOM selection is properly unset, we're done.
99
if (!selection && !hasDomSelection) {
100
return;
101
}
102
103
// verify that the DOM selection is in the editor
104
const editorElement = EDITOR_TO_ELEMENT.get(editor);
105
const hasDomSelectionInEditor =
106
editorElement?.contains(domSelection.anchorNode) &&
107
editorElement?.contains(domSelection.focusNode);
108
109
if (!selection) {
110
// need to clear selection:
111
if (hasDomSelectionInEditor) {
112
// the current nontrivial selection is inside the editor,
113
// so we just clear it.
114
domSelection.removeAllRanges();
115
if (isCropped) {
116
state.windowedSelection = true;
117
}
118
}
119
return;
120
}
121
let newDomRange;
122
try {
123
newDomRange = ReactEditor.toDOMRange(editor, selection);
124
} catch (_err) {
125
// console.warn(
126
// `slate -- toDOMRange error ${_err}, range=${JSON.stringify(selection)}`
127
// );
128
// This error happens and is expected! e.g., if you set the selection to a
129
// point that isn't valid in the document. TODO: Our
130
// autoformat code perhaps stupidly does this sometimes,
131
// at least when working on it.
132
// It's better to just give up in this case, rather than
133
// crash the entire cocalc. The user will click somewhere
134
// and be good to go again.
135
return;
136
}
137
138
// Flip orientation of newDomRange if selection is backward,
139
// since setBaseAndExtent (which we use below) is not oriented.
140
if (Range.isBackward(selection)) {
141
newDomRange = {
142
endContainer: newDomRange.startContainer,
143
endOffset: newDomRange.startOffset,
144
startContainer: newDomRange.endContainer,
145
startOffset: newDomRange.endOffset,
146
};
147
}
148
149
// Compare the new DOM range we want to what's actually
150
// selected. If they are the same, done. If different,
151
// we change the selection in the DOM.
152
if (
153
domSelection.anchorNode?.isSameNode(newDomRange.startContainer) &&
154
domSelection.focusNode?.isSameNode(newDomRange.endContainer) &&
155
domSelection.anchorOffset === newDomRange.startOffset &&
156
domSelection.focusOffset === newDomRange.endOffset
157
) {
158
// It's correct already -- we're done.
159
// console.log("useUpdateDOMSelection: selection already correct");
160
return;
161
}
162
163
// Finally, make the change:
164
if (isCropped) {
165
// record that we're making a change that diverges from true selection.
166
state.windowedSelection = true;
167
}
168
domSelection.setBaseAndExtent(
169
newDomRange.startContainer,
170
newDomRange.startOffset,
171
newDomRange.endContainer,
172
newDomRange.endOffset,
173
);
174
};
175
176
// Always ensure DOM selection gets set to slate selection
177
// right after the editor updates. This is especially important
178
// because the react update sets parts of the contenteditable
179
// area, and can easily mess up or reset the cursor, so we have
180
// to immediately set it back.
181
useIsomorphicLayoutEffect(updateDOMSelection);
182
183
// We also attach this function to the editor,
184
// so can be called on scroll, which is needed to support windowing.
185
editor.updateDOMSelection = updateDOMSelection;
186
};
187
188
export const useDOMSelectionChange = ({
189
editor,
190
state,
191
readOnly,
192
}: {
193
editor: ReactEditor;
194
state: SelectionState;
195
readOnly: boolean;
196
}) => {
197
// Listen on the native `selectionchange` event to be able to update any time
198
// the selection changes. This is required because React's `onSelect` is leaky
199
// and non-standard so it doesn't fire until after a selection has been
200
// released. This causes issues in situations where another change happens
201
// while a selection is being dragged.
202
203
const onDOMSelectionChange = useCallback(() => {
204
if (readOnly || state.isComposing || state.ignoreSelection) {
205
return;
206
}
207
208
const domSelection = window.getSelection();
209
if (!domSelection) {
210
Transforms.deselect(editor);
211
return;
212
}
213
const { anchorNode, focusNode } = domSelection;
214
215
if (!isSelectable(editor, anchorNode) || !isSelectable(editor, focusNode)) {
216
return;
217
}
218
219
let range;
220
try {
221
range = ReactEditor.toSlateRange(editor, domSelection);
222
} catch (err) {
223
// isSelectable should catch any situation where the above might cause an
224
// error, but in practice it doesn't. Just ignore selection change when this
225
// happens.
226
console.warn(`slate selection sync issue - ${err}`);
227
return;
228
}
229
230
// console.log(JSON.stringify({ range, sel: state.windowedSelection }));
231
if (state.windowedSelection === true) {
232
state.windowedSelection = range;
233
}
234
235
const { selection } = editor;
236
if (selection != null) {
237
const visibleRange = editor.windowedListRef.current?.visibleRange;
238
if (visibleRange != null) {
239
// Trickier case due to windowing. If we're not changing the selection
240
// via shift click but the selection in the DOM is trimmed due to windowing,
241
// then make no change to editor.selection based on the DOM.
242
if (
243
!state.shiftKey &&
244
state.windowedSelection != null &&
245
isEqual(range, state.windowedSelection)
246
) {
247
// selection is what was set using window clipping, so not changing
248
return;
249
}
250
251
// Shift+clicking to select a range, done via code that works in
252
// case of windowing.
253
if (state.shiftKey) {
254
// What *should* actually happen on shift+click to extend a
255
// selection is not so obvious! For starters, the behavior
256
// in text editors like CodeMirror, VSCode and Ace Editor
257
// (set range.anchor to selection.anchor) is totally different
258
// than rich editors like Word, Pages, and browser
259
// contenteditable, which mostly *extend* the selection in
260
// various ways. We match exactly what default browser
261
// selection does, since otherwise we would have to *change*
262
// that when not using windowing or when everything is in
263
// the visible window, which seems silly.
264
const edges = Range.edges(selection);
265
if (Point.isBefore(range.focus, edges[0])) {
266
// Shift+click before the entire existing selection:
267
range.anchor = edges[1];
268
} else if (Point.isAfter(range.focus, edges[1])) {
269
// Shift+click after the entire existing selection:
270
range.anchor = edges[0];
271
} else {
272
// Shift+click inside the existing selection. What browsers
273
// do is they shrink selection so the new focus is
274
// range.focus, and the new anchor is whichever of
275
// selection.focus or selection.anchor makes the resulting
276
// selection "longer".
277
const a = Editor.string(
278
editor,
279
{ focus: range.focus, anchor: selection.anchor },
280
{ voids: true },
281
).length;
282
const b = Editor.string(
283
editor,
284
{ focus: range.focus, anchor: selection.focus },
285
{ voids: true },
286
).length;
287
range.anchor = a > b ? selection.anchor : selection.focus;
288
}
289
}
290
}
291
}
292
293
if (selection == null || !Range.equals(selection, range)) {
294
Transforms.select(editor, range);
295
}
296
}, [readOnly]);
297
298
// Attach a native DOM event handler for `selectionchange`, because React's
299
// built-in `onSelect` handler doesn't fire for all selection changes. It's a
300
// leaky polyfill that only fires on keypresses or clicks. Instead, we want to
301
// fire for any change to the selection inside the editor. (2019/11/04)
302
// https://github.com/facebook/react/issues/5785
303
useIsomorphicLayoutEffect(() => {
304
window.document.addEventListener("selectionchange", onDOMSelectionChange);
305
return () => {
306
window.document.removeEventListener(
307
"selectionchange",
308
onDOMSelectionChange,
309
);
310
};
311
}, [onDOMSelectionChange]);
312
313
return onDOMSelectionChange;
314
};
315
316
function getWindowedSelection(editor: ReactEditor): Selection | null {
317
const { selection } = editor;
318
if (selection == null || editor.windowedListRef?.current == null) {
319
// No selection, or not using windowing, or collapsed so easy.
320
return selection;
321
}
322
323
// Now we trim non-collapsed selection to part of window in the DOM.
324
const visibleRange = editor.windowedListRef.current?.visibleRange;
325
if (visibleRange == null) return selection;
326
const { anchor, focus } = selection;
327
return {
328
anchor: clipPoint(editor, anchor, visibleRange),
329
focus: clipPoint(editor, focus, visibleRange),
330
};
331
}
332
333
function clipPoint(
334
editor: Editor,
335
point: Point,
336
visibleRange: { startIndex: number; endIndex: number },
337
): Point {
338
const { startIndex, endIndex } = visibleRange;
339
const n = point.path[0];
340
if (n < startIndex) {
341
return { path: [startIndex, 0], offset: 0 };
342
}
343
if (n > endIndex) {
344
// We have to use Editor.before, since we need to select
345
// the entire endIndex block. The ?? below should just be
346
// to make typescript happy.
347
return (
348
Editor.before(editor, { path: [endIndex + 1, 0], offset: 0 }) ?? {
349
path: [endIndex, 0],
350
offset: 0,
351
}
352
);
353
}
354
return point;
355
}
356
357
function isSelectable(editor, node): boolean {
358
return hasEditableTarget(editor, node) || isTargetInsideVoid(editor, node);
359
}
360
361