Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/slate-react/plugin/with-react.ts
1698 views
1
import ReactDOM from "react-dom";
2
import {
3
Editor,
4
Element,
5
Descendant,
6
Path,
7
Operation,
8
Transforms,
9
Range,
10
} from "slate";
11
12
import { ReactEditor } from "./react-editor";
13
import { Key } from "../utils/key";
14
import { EDITOR_TO_ON_CHANGE, NODE_TO_KEY } from "../utils/weak-maps";
15
import { findCurrentLineRange } from "../utils/lines";
16
17
/**
18
* `withReact` adds React and DOM specific behaviors to the editor.
19
*/
20
21
export const withReact = <T extends Editor>(editor: T) => {
22
const e = editor as T & ReactEditor;
23
const { apply, onChange, deleteBackward } = e;
24
25
e.windowedListRef = { current: null };
26
27
e.collapsedSections = new WeakMap();
28
29
e.deleteBackward = (unit) => {
30
if (unit !== "line") {
31
return deleteBackward(unit);
32
}
33
34
if (editor.selection && Range.isCollapsed(editor.selection)) {
35
const parentBlockEntry = Editor.above(editor, {
36
match: (node) =>
37
Element.isElement(node) && Editor.isBlock(editor, node),
38
at: editor.selection,
39
});
40
41
if (parentBlockEntry) {
42
const [, parentBlockPath] = parentBlockEntry;
43
const parentElementRange = Editor.range(
44
editor,
45
parentBlockPath,
46
editor.selection.anchor
47
);
48
49
const currentLineRange = findCurrentLineRange(e, parentElementRange);
50
51
if (!Range.isCollapsed(currentLineRange)) {
52
Transforms.delete(editor, { at: currentLineRange });
53
}
54
}
55
}
56
};
57
58
e.apply = (op: Operation) => {
59
const matches: [Path, Key][] = [];
60
61
switch (op.type) {
62
case "insert_text":
63
case "remove_text":
64
case "set_node": {
65
for (const [node, path] of Editor.levels(e, { at: op.path })) {
66
const key = ReactEditor.findKey(e, node);
67
matches.push([path, key]);
68
}
69
70
break;
71
}
72
73
case "insert_node":
74
case "remove_node":
75
case "merge_node":
76
case "split_node": {
77
for (const [node, path] of Editor.levels(e, {
78
at: Path.parent(op.path),
79
})) {
80
const key = ReactEditor.findKey(e, node);
81
matches.push([path, key]);
82
}
83
84
break;
85
}
86
87
case "move_node": {
88
for (const [node, path] of Editor.levels(e, {
89
at: Path.common(Path.parent(op.path), Path.parent(op.newPath)),
90
})) {
91
const key = ReactEditor.findKey(e, node);
92
matches.push([path, key]);
93
}
94
break;
95
}
96
}
97
98
apply(op);
99
100
for (const [path, key] of matches) {
101
const [node] = Editor.node(e, path);
102
NODE_TO_KEY.set(node, key);
103
}
104
};
105
106
e.setFragmentData = (data: DataTransfer) => {
107
const { selection } = e;
108
109
if (!selection) {
110
return;
111
}
112
113
const [start] = Range.edges(selection);
114
const startVoid = Editor.void(e, { at: start.path });
115
if (Range.isCollapsed(selection) && !startVoid) {
116
return;
117
}
118
119
const fragment = e.getFragment();
120
const plain = (e as any).getPlainValue?.(fragment);
121
if (plain == null) {
122
throw Error("copy not implemented");
123
}
124
const encoded = window.btoa(encodeURIComponent(JSON.stringify(fragment)));
125
126
// This application/x-slate-fragment is the only thing that is
127
// used for Firefox and Chrome paste:
128
data.setData("application/x-slate-fragment", encoded);
129
// This data part of text/html is used for Safari, which ignores
130
// the application/x-slate-fragment above.
131
data.setData(
132
"text/html",
133
`<pre data-slate-fragment="${encoded}">\n${plain}\n</pre>`
134
);
135
data.setData("text/plain", plain);
136
};
137
138
e.insertData = (data: DataTransfer) => {
139
let fragment = data.getData("application/x-slate-fragment");
140
141
if (!fragment) {
142
// On Safari (probably for security reasons?), the
143
// application/x-slate-fragment data is not set.
144
// My guess is this is why the html is also modified
145
// even though it is never used in the upstream code!
146
// See https://github.com/ianstormtaylor/slate/issues/3589
147
// Supporting this is also important when copying from
148
// Safari to Chrome (say).
149
const html = data.getData("text/html");
150
if (html) {
151
// It would be nicer to parse html properly, but that's
152
// going to be pretty difficult, so I'm doing the following,
153
// which of course could be tricked if the content
154
// itself happened to have data-slate-fragment="...
155
// in it. That's a reasonable price to pay for
156
// now for restoring this functionality.
157
let i = html.indexOf('data-slate-fragment="');
158
if (i != -1) {
159
i += 'data-slate-fragment="'.length;
160
const j = html.indexOf('"', i);
161
if (j != -1) {
162
fragment = html.slice(i, j);
163
}
164
}
165
}
166
}
167
168
// TODO: we want to do this, but it currently causes a slight
169
// delay, which is very disconcerting. We need some sort of
170
// local slate undo before saving out to markdown (which has
171
// to wait until there is a pause in typing).
172
//(e as any).saveValue?.(true);
173
174
if (fragment) {
175
const decoded = decodeURIComponent(window.atob(fragment));
176
const parsed = JSON.parse(decoded) as Descendant[];
177
e.insertFragment(parsed);
178
return;
179
}
180
181
const text = data.getData("text/plain");
182
183
if (text) {
184
const lines = text.split(/\r\n|\r|\n/);
185
let split = false;
186
187
for (const line of lines) {
188
if (split) {
189
Transforms.splitNodes(e, { always: true });
190
}
191
192
e.insertText(line);
193
split = true;
194
}
195
}
196
};
197
198
e.onChange = () => {
199
// COMPAT: React doesn't batch `setState` hook calls, which means that the
200
// children and selection can get out of sync for one render pass. So we
201
// have to use this unstable API to ensure it batches them. (2019/12/03)
202
// https://github.com/facebook/react/issues/14259#issuecomment-439702367
203
ReactDOM.unstable_batchedUpdates(() => {
204
const onContextChange = EDITOR_TO_ON_CHANGE.get(e);
205
206
if (onContextChange) {
207
onContextChange();
208
}
209
210
onChange();
211
});
212
};
213
214
// only when windowing is enabled.
215
e.scrollIntoDOM = (index) => {
216
let windowed: boolean = e.windowedListRef.current != null;
217
if (windowed) {
218
const visibleRange = e.windowedListRef.current?.visibleRange;
219
if (visibleRange != null) {
220
const { startIndex, endIndex } = visibleRange;
221
if (index < startIndex || index > endIndex) {
222
const virtuoso = e.windowedListRef.current.virtuosoRef?.current;
223
if (virtuoso != null) {
224
virtuoso.scrollIntoView({ index });
225
return true;
226
}
227
}
228
}
229
}
230
return false;
231
};
232
233
e.scrollCaretIntoView = (options?: { middle?: boolean }) => {
234
/* Scroll so Caret is visible. I tested several editors, and
235
I think reasonable behavior is:
236
- If caret is full visible on the screen, do nothing.
237
- If caret is not visible, scroll so caret is at
238
top or bottom. Word and Pages do this but with an extra line;
239
CodeMirror does *exactly this*; some editors like Prosemirror
240
and Typora scroll the caret to the middle of the screen,
241
which is weird. Since most of cocalc is codemirror, being
242
consistent with that seems best. The implementation is also
243
very simple.
244
245
This code below is based on what is
246
https://github.com/ianstormtaylor/slate/pull/4023
247
except that PR seems buggy and does the wrong thing, so I
248
had to rewrite it. I also wrote a version for windowing.
249
250
I think properly implementing this is very important since it is
251
critical to keep users from feeling *lost* when using the editor.
252
If their cursor scrolls off the screen, especially in a very long line,
253
they might move the cursor back or forward one space to make it visible
254
again. In slate with #4023, if you make a single LONG line (that spans
255
more than a page with no formatting), then scroll the cursor out of view,
256
then move the cursor, you often still don't see the cursor. That's
257
because it just scrolls that entire leaf into view, not the cursor
258
itself.
259
*/
260
try {
261
const { selection } = e;
262
if (selection == null) return;
263
264
// Important: this doesn't really work well for many types
265
// of void elements, e.g, when the focused
266
// element is an image -- with several images, when
267
// you click on one, things jump
268
// around randomly and you sometimes can't scroll the image into view.
269
// Better to just do nothing in this case.
270
for (const [node] of Editor.nodes(e, { at: selection.focus })) {
271
if (
272
Element.isElement(node) &&
273
Editor.isVoid(e, node) &&
274
!SCROLL_WHITELIST.has(node["type"])
275
) {
276
return;
277
}
278
}
279
280
// In case we're using windowing, scroll the block with the focus
281
// into the DOM first.
282
let windowed: boolean = e.windowedListRef.current != null;
283
if (windowed && !e.scrollCaretAfterNextScroll) {
284
const index = selection.focus.path[0];
285
const visibleRange = e.windowedListRef.current?.visibleRange;
286
if (visibleRange != null) {
287
const { startIndex, endIndex } = visibleRange;
288
if (index < startIndex || index > endIndex) {
289
// We need to scroll the block containing the cursor into the DOM first?
290
e.scrollIntoDOM(index);
291
// now wait until the actual scroll happens before
292
// doing the measuring below, or it could be wrong.
293
e.scrollCaretAfterNextScroll = true;
294
requestAnimationFrame(() => e.scrollCaretIntoView());
295
return;
296
}
297
}
298
}
299
300
let domSelection;
301
try {
302
domSelection = ReactEditor.toDOMRange(e, {
303
anchor: selection.focus,
304
focus: selection.focus,
305
});
306
} catch (_err) {
307
// harmless to just not do this in case of failure.
308
return;
309
}
310
if (!domSelection) return;
311
const selectionRect = domSelection.getBoundingClientRect();
312
const editorEl = ReactEditor.toDOMNode(e, e);
313
const editorRect = editorEl.getBoundingClientRect();
314
const EXTRA = options?.middle
315
? editorRect.height / 2
316
: editorRect.height > 100
317
? 20
318
: 0; // this much more than the min possible to get it on screen.
319
320
let offset: number = 0;
321
if (selectionRect.top < editorRect.top + EXTRA) {
322
offset = editorRect.top + EXTRA - selectionRect.top;
323
} else if (
324
selectionRect.bottom - editorRect.top >
325
editorRect.height - EXTRA
326
) {
327
offset =
328
editorRect.height - EXTRA - (selectionRect.bottom - editorRect.top);
329
}
330
if (offset) {
331
if (windowed) {
332
const scroller = e.windowedListRef.current?.getScrollerRef();
333
if (scroller != null) {
334
scroller.scrollTop = scroller.scrollTop - offset;
335
}
336
} else {
337
editorEl.scrollTop = editorEl.scrollTop - offset;
338
}
339
}
340
} catch (_err) {
341
// console.log("WARNING: scrollCaretIntoView -- ", err);
342
// The only side effect we are hiding is that the cursor might not
343
// scroll into view, which is way better than crashing everything.
344
// console.log("WARNING: failed to scroll cursor into view", e);
345
}
346
};
347
348
return e;
349
};
350
351
const SCROLL_WHITELIST = new Set(["hashtag", "checkbox"]);
352
353