Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/control.ts
1691 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { Editor, Element, Range, Transforms, Point } from "slate";
7
import { ReactEditor } from "./slate-react";
8
import { isEqual } from "lodash";
9
import { rangeAll } from "./slate-util";
10
import { emptyParagraph } from "./padding";
11
import { delay } from "awaiting";
12
13
// Scroll to the n-th heading in the document
14
export async function scrollToHeading(
15
editor: ReactEditor,
16
n: number
17
): Promise<void> {
18
let i = 0;
19
for (const x of Editor.nodes(editor, {
20
at: { path: [], offset: 0 },
21
match: (node) => node["type"] == "heading",
22
})) {
23
if (i == n) {
24
if (!ReactEditor.isUsingWindowing(editor)) {
25
// easy case
26
ReactEditor.toDOMNode(editor, x[0]).scrollIntoView(true);
27
return;
28
}
29
// Do if a few times, in case rendering/measuring changes sizes...
30
// TODO: This sucks, of course!
31
// Note that if clicking on table of contents opened the slate editor,
32
// then it's going to be getting it's scroll reset to where it was last
33
// used at the exact same time that we're trying to move the scroll here.
34
// Yes, this is dumb and the two fight each other.
35
for (const d of [1, 50, 1000]) {
36
try {
37
ReactEditor.scrollIntoDOM(editor, x[1]);
38
// wait for scroll to actually happen resulting in something in the DOM.
39
await new Promise(requestAnimationFrame);
40
ReactEditor.toDOMNode(editor, x[0]).scrollIntoView(true);
41
} catch (_err) {
42
console.log("WARNING: still not in DOM", _err);
43
// There is no guarantee that something else didn't happen to remove the element
44
// from the DOM while we're waiting for it, hence this is OK.
45
return;
46
}
47
await delay(d);
48
}
49
return;
50
}
51
i += 1;
52
}
53
// didn't find it.
54
}
55
56
export function setCursor(editor: ReactEditor, point: Point) {
57
Transforms.setSelection(editor, { anchor: point, focus: point });
58
}
59
60
function move(editor: Editor, options?): void {
61
try {
62
Transforms.move(editor, options);
63
} catch (err) {
64
// I saw this once when moving the cursor, and don't think it is worth crashing
65
// the editor completely.
66
console.warn(`Slate: issue moving cursor -- ${err}`);
67
resetSelection(editor);
68
}
69
}
70
71
export function resetSelection(editor: Editor) {
72
// set to beginning of document -- better than crashing.
73
const focus = { path: [0, 0], offset: 0 };
74
Transforms.setSelection(editor, {
75
focus,
76
anchor: focus,
77
});
78
}
79
80
export function moveCursorDown(editor: Editor, force: boolean = false): void {
81
const focus = editor.selection?.focus;
82
if (focus == null) return;
83
move(editor, { distance: 1, unit: "line" });
84
if (!force) return;
85
const newFocus = editor.selection?.focus;
86
if (newFocus == null) return;
87
if (isEqual(focus, newFocus)) {
88
// didn't move down; at end of doc, so put a blank paragraph there
89
// and move to that.
90
editor.apply({
91
type: "insert_node",
92
path: [editor.children.length],
93
node: emptyParagraph(),
94
});
95
move(editor, { distance: 1, unit: "line" });
96
return;
97
}
98
ensureCursorNotBlocked(editor);
99
}
100
101
export function moveCursorUp(editor: Editor, force: boolean = false): void {
102
const focus = editor.selection?.focus;
103
if (focus == null) return;
104
move(editor, { distance: 1, unit: "line", reverse: true });
105
if (!force) return;
106
const newFocus = editor.selection?.focus;
107
if (newFocus == null) return;
108
if (isEqual(focus, newFocus)) {
109
// didn't move -- put a blank paragraph there
110
// and move to that.
111
editor.apply({
112
type: "insert_node",
113
path: [0],
114
node: emptyParagraph(),
115
});
116
move(editor, { distance: 1, unit: "line", reverse: true });
117
}
118
ensureCursorNotBlocked(editor, true);
119
}
120
121
export function blocksCursor(editor, up: boolean = false): boolean {
122
if (editor.selection == null || !Range.isCollapsed(editor.selection)) {
123
return false;
124
}
125
126
let elt;
127
try {
128
elt = editor.getFragment()[0];
129
} catch (_) {
130
return false;
131
}
132
if (elt == null) {
133
// fragment above was empty.
134
// I hit not checking for this randomly once in production and it caused a crash.
135
return false;
136
}
137
if (Editor.isVoid(editor, elt)) {
138
return true;
139
}
140
141
// Several non-void elements also block the cursor,
142
// in the sense that you can't move the cursor immediately
143
// before/after them.
144
// TODO: instead of listing here, should be part of registration
145
// system in ../elements.
146
if (
147
editor.selection != null &&
148
((up && isAtBeginningOfBlock(editor, { mode: "highest" })) ||
149
(!up && isAtEndOfBlock(editor, { mode: "highest" }))) &&
150
(elt?.type == "blockquote" ||
151
elt?.type == "ordered_list" ||
152
elt?.type == "bullet_list")
153
) {
154
return true;
155
}
156
157
return false;
158
}
159
160
export function ensureCursorNotBlocked(editor: Editor, up: boolean = false) {
161
if (!blocksCursor(editor, !up)) return;
162
// cursor in a void element, so insert a blank paragraph at
163
// cursor and put cursor in that blank paragraph.
164
const { selection } = editor;
165
if (selection == null) return;
166
const path = [selection.focus.path[0] + (up ? +1 : 0)];
167
editor.apply({
168
type: "insert_node",
169
path,
170
node: { type: "paragraph", children: [{ text: "" }] },
171
});
172
const focus = { path: path.concat([0]), offset: 0 };
173
Transforms.setSelection(editor, {
174
focus,
175
anchor: focus,
176
});
177
}
178
179
// Find path to a given element.
180
export function findElement(
181
editor: Editor,
182
element: Element
183
): number[] | undefined {
184
// Usually when called, the element we are searching for is right
185
// near the selection, so this first search finds it.
186
for (const [, path] of Editor.nodes(editor, {
187
match: (node) => node === element,
188
})) {
189
return path;
190
}
191
// Searching at the selection failed, so we try searching the
192
// entire document instead.
193
// This has to work unless element isn't in the document (which
194
// is of course possible).
195
for (const [, path] of Editor.nodes(editor, {
196
match: (node) => node === element,
197
at: rangeAll(editor),
198
})) {
199
return path;
200
}
201
}
202
203
export function moveCursorToElement(editor: Editor, element: Element): void {
204
const path = findElement(editor, element);
205
if (path == null) return;
206
const point = { path, offset: 0 };
207
Transforms.setSelection(editor, { anchor: point, focus: point });
208
}
209
210
// Move cursor to the end of a top-level non-inline element.
211
export function moveCursorToEndOfElement(
212
editor: Editor,
213
element: Element // non-line element
214
): void {
215
// Find the element
216
const path = findElement(editor, element);
217
if (path == null) return;
218
// Create location at start of the element
219
const at = { path, offset: 0 };
220
// Move to block "after" where the element is. This is
221
// sort of random in that it might be at the end of the
222
// element, or it might be in the next block. E.g.,
223
// for "# fo|o**bar**" it is in the next block, but for
224
// "# foo**b|ar**" it is at the end of the current block!?
225
// We work around this bug by moving back 1 character
226
// in case we moved to the next top-level block.
227
let end = Editor.after(editor, at, { unit: "block" });
228
if (end == null) return;
229
if (end.path[0] != path[0]) {
230
end = Editor.before(editor, end);
231
}
232
Transforms.setSelection(editor, { anchor: end, focus: end });
233
}
234
235
export function moveCursorToBeginningOfBlock(
236
editor: Editor,
237
path?: number[]
238
): void {
239
if (path == null) {
240
const selection = editor.selection;
241
if (selection == null || !Range.isCollapsed(selection)) {
242
return;
243
}
244
path = selection.focus.path;
245
}
246
if (path.length > 1) {
247
path = [...path]; // make mutable copy
248
path[path.length - 1] = 0;
249
}
250
const focus = { path, offset: 0 };
251
Transforms.setSelection(editor, { focus, anchor: focus });
252
}
253
254
// True if point is at the beginning of the containing block
255
// that it is in (or top level block if mode='highest').
256
export function isAtBeginningOfBlock(
257
editor: Editor,
258
options: { at?: Point; mode?: "lowest" | "highest" }
259
): boolean {
260
let { at, mode } = options;
261
if (mode == null) mode = "lowest";
262
if (at == null) {
263
at = editor.selection?.focus;
264
if (at == null) return false;
265
}
266
if (at.offset != 0) return false;
267
if (mode == "lowest") {
268
// easy special case.
269
return at.path[at.path.length - 1] == 0;
270
}
271
const before = Editor.before(editor, at);
272
if (before == null) {
273
// at beginning of the entire document, so definitely at the beginning of the block
274
return true;
275
}
276
return before.path[0] < at.path[0];
277
}
278
279
// True if point is at the end of the containing block
280
// that it is in (or top level block if mode='highest').
281
// Also, return false if "at" is not valid.
282
export function isAtEndOfBlock(
283
editor: Editor,
284
options: { at?: Point; mode?: "lowest" | "highest" }
285
): boolean {
286
let { at, mode } = options;
287
if (mode == null) mode = "lowest";
288
if (at == null) {
289
at = editor.selection?.focus;
290
if (at == null) return false;
291
}
292
let after;
293
try {
294
after = Editor.after(editor, at);
295
} catch (_) {
296
// if "at" is no longer valid for some reason.
297
return false;
298
}
299
if (after == null) {
300
// at end of the entire document, so definitely at the end of the block
301
return true;
302
}
303
if (isEqual(after.path, at.path)) {
304
// next point is in the same node, so can't be at the end (not
305
// even the end of this node).
306
return false;
307
}
308
if (mode == "highest") {
309
// next path needs to start with a new number.
310
return after.path[0] > at.path[0];
311
} else {
312
const n = Math.min(after.path.length, at.path.length);
313
if (isEqual(at.path.slice(0, n - 1), after.path.slice(0, n - 1))) {
314
return false;
315
}
316
return true;
317
}
318
}
319
320
export function moveCursorToEndOfLine(editor: Editor) {
321
/*
322
Call the function Transforms.move(editor, {unit:'line'})
323
until the integer editor.selection.anchor[0] increases,
324
then call Transforms.setSelection(editor, ...) with second
325
argument the previous value of editor.selection. Instead
326
of setting the selection back to the initial value, set it
327
to the value right before we exited the while loop, i.e.,
328
the last value before anchor[0] changed. Also, if the
329
entire selection doesn't change exist the while loop
330
to avoid an infinite loop.
331
*/
332
333
let lastSelection = editor.selection;
334
if (lastSelection == null) return;
335
while (
336
editor.selection &&
337
editor.selection.anchor.path[0] === lastSelection.anchor.path[0]
338
) {
339
if (lastSelection == null) return;
340
lastSelection = editor.selection;
341
Transforms.move(editor, { unit: "line" });
342
// Ensure we don't get stuck in an infinite loop
343
if (JSON.stringify(lastSelection) === JSON.stringify(editor.selection)) {
344
return;
345
}
346
}
347
348
if (lastSelection) {
349
Transforms.setSelection(editor, lastSelection);
350
}
351
}
352
353
export function moveCursorToBeginningOfLine(editor: Editor) {
354
let lastSelection = editor.selection;
355
if (lastSelection == null) return;
356
while (
357
editor.selection &&
358
editor.selection.anchor.path[0] === lastSelection.anchor.path[0]
359
) {
360
if (lastSelection == null) return;
361
lastSelection = editor.selection;
362
Transforms.move(editor, { unit: "line", reverse: true });
363
// Ensure we don't get stuck in an infinite loop
364
if (JSON.stringify(lastSelection) === JSON.stringify(editor.selection)) {
365
return;
366
}
367
}
368
369
if (lastSelection) {
370
Transforms.setSelection(editor, lastSelection);
371
}
372
}
373
374