Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/editable-markdown.tsx
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
// Component that allows WYSIWYG editing of markdown.
7
8
const EXPENSIVE_DEBUG = false;
9
// const EXPENSIVE_DEBUG = (window as any).cc != null && true; // EXTRA SLOW -- turn off before release!
10
11
import { delay } from "awaiting";
12
import { Map } from "immutable";
13
import { debounce, isEqual, throttle } from "lodash";
14
import {
15
MutableRefObject,
16
RefObject,
17
useCallback,
18
useEffect,
19
useMemo,
20
useRef,
21
useState,
22
} from "react";
23
import { CSS, React, useIsMountedRef } from "@cocalc/frontend/app-framework";
24
import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";
25
import { useMentionableUsers } from "@cocalc/frontend/editors/markdown-input/mentionable-users";
26
import { submit_mentions } from "@cocalc/frontend/editors/markdown-input/mentions";
27
import { EditorFunctions } from "@cocalc/frontend/editors/markdown-input/multimode";
28
import { SAVE_DEBOUNCE_MS } from "@cocalc/frontend/frame-editors/code-editor/const";
29
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
30
import { Path } from "@cocalc/frontend/frame-editors/frame-tree/path";
31
import { EditorState } from "@cocalc/frontend/frame-editors/frame-tree/types";
32
import { markdown_to_html } from "@cocalc/frontend/markdown";
33
import Fragment, { FragmentId } from "@cocalc/frontend/misc/fragment-id";
34
import { Descendant, Editor, Range, Transforms, createEditor } from "slate";
35
import { resetSelection } from "./control";
36
import * as control from "./control";
37
import { useBroadcastCursors, useCursorDecorate } from "./cursors";
38
import { EditBar, useLinkURL, useListProperties, useMarks } from "./edit-bar";
39
import { Element } from "./element";
40
import { estimateSize } from "./elements";
41
import { createEmoji } from "./elements/emoji/index";
42
import { withInsertBreakHack } from "./elements/link/editable";
43
import { createMention } from "./elements/mention/editable";
44
import { Mention } from "./elements/mention/index";
45
import { withAutoFormat } from "./format";
46
import { getHandler as getKeyboardHandler } from "./keyboard";
47
import Leaf from "./leaf-with-cursor";
48
import { markdown_to_slate } from "./markdown-to-slate";
49
import { withNormalize } from "./normalize";
50
import { applyOperations, preserveScrollPosition } from "./operations";
51
import { withNonfatalRange } from "./patches";
52
import { withIsInline, withIsVoid } from "./plugins";
53
import { getScrollState, setScrollState } from "./scroll";
54
import { SearchHook, useSearch } from "./search";
55
import { slateDiff } from "./slate-diff";
56
import { useEmojis } from "./slate-emojis";
57
import { useMentions } from "./slate-mentions";
58
import { Editable, ReactEditor, Slate, withReact } from "./slate-react";
59
import { slate_to_markdown } from "./slate-to-markdown";
60
import { slatePointToMarkdownPosition } from "./sync";
61
import type { SlateEditor } from "./types";
62
import { Actions } from "./types";
63
import useUpload from "./upload";
64
import { ChangeContext } from "./use-change";
65
66
export type { SlateEditor };
67
68
// Whether or not to use windowing by default (=only rendering visible elements).
69
// This is unfortunately essential. I've tried everything I can think
70
// of to optimize slate without using windowing, and I just can't do it
71
// (and my attempts have always been misleading). I think the problem is
72
// that all the subtle computations that are done when selection, etc.
73
// gets updated, just have to be done one way or another anyways. Doing
74
// them without the framework of windowing is probably much harder.
75
// NOTE: we also fully use slate without windowing in many context in which
76
// we're editing small snippets of Markdown, e.g., Jupyter notebook markdown
77
// cells, task lists, whiteboard sticky notes, etc.
78
const USE_WINDOWING = true;
79
// const USE_WINDOWING = false;
80
81
const STYLE: CSS = {
82
width: "100%",
83
overflow: "auto",
84
} as const;
85
86
interface Props {
87
value?: string;
88
placeholder?: string;
89
actions?: Actions;
90
read_only?: boolean;
91
font_size?: number;
92
id?: string;
93
reload_images?: boolean; // I think this is used only to trigger an update
94
is_current?: boolean;
95
is_fullscreen?: boolean;
96
editor_state?: EditorState;
97
cursors?: Map<string, any>;
98
hidePath?: boolean;
99
disableWindowing?: boolean;
100
style?: CSS;
101
pageStyle?: CSS;
102
editBarStyle?: CSS;
103
onFocus?: () => void;
104
onBlur?: () => void;
105
autoFocus?: boolean;
106
hideSearch?: boolean;
107
saveDebounceMs?: number;
108
noVfill?: boolean;
109
divRef?: RefObject<HTMLDivElement>;
110
selectionRef?: MutableRefObject<{
111
setSelection: Function;
112
getSelection: Function;
113
} | null>;
114
height?: string; // css style or if "auto", then editor will grow to size of content instead of scrolling.
115
onCursorTop?: () => void;
116
onCursorBottom?: () => void;
117
isFocused?: boolean;
118
registerEditor?: (editor: EditorFunctions) => void;
119
unregisterEditor?: () => void;
120
getValueRef?: MutableRefObject<() => string>; // see comment in src/packages/frontend/editors/markdown-input/multimode.tsx
121
submitMentionsRef?: SubmitMentionsRef; // when called this will submit all mentions in the document, and also returns current value of the document (for compat with markdown editor). If not set, mentions are submitted when you create them. This prop is used mainly for implementing chat, which has a clear "time of submission".
122
editBar2?: MutableRefObject<React.JSX.Element | undefined>;
123
dirtyRef?: MutableRefObject<boolean>;
124
minimal?: boolean;
125
controlRef?: MutableRefObject<{
126
moveCursorToEndOfLine: () => void;
127
} | null>;
128
showEditBar?: boolean;
129
}
130
131
export const EditableMarkdown: React.FC<Props> = React.memo((props: Props) => {
132
const {
133
actions: actions0,
134
autoFocus,
135
cursors,
136
dirtyRef,
137
disableWindowing = !USE_WINDOWING,
138
divRef,
139
editBar2,
140
editBarStyle,
141
editor_state,
142
font_size: font_size0,
143
getValueRef,
144
height,
145
hidePath,
146
hideSearch,
147
id: id0,
148
is_current,
149
is_fullscreen,
150
isFocused,
151
minimal,
152
noVfill,
153
onBlur,
154
onCursorBottom,
155
onCursorTop,
156
onFocus,
157
pageStyle,
158
placeholder,
159
read_only,
160
registerEditor,
161
saveDebounceMs = SAVE_DEBOUNCE_MS,
162
selectionRef,
163
style,
164
submitMentionsRef,
165
unregisterEditor,
166
value,
167
controlRef,
168
showEditBar,
169
} = props;
170
const { project_id, path, desc, isVisible } = useFrameContext();
171
const isMountedRef = useIsMountedRef();
172
const id = id0 ?? "";
173
const actions = actions0 ?? {};
174
const font_size = font_size0 ?? desc?.get("font_size") ?? 14; // so possible to use without specifying this. TODO: should be from account settings
175
const [change, setChange] = useState<number>(0);
176
177
const editor = useMemo(() => {
178
const ed = withNonfatalRange(
179
withInsertBreakHack(
180
withNormalize(
181
withAutoFormat(withIsInline(withIsVoid(withReact(createEditor())))),
182
),
183
),
184
) as SlateEditor;
185
actions.registerSlateEditor?.(id, ed);
186
187
ed.getSourceValue = (fragment?) => {
188
return fragment ? slate_to_markdown(fragment) : ed.getMarkdownValue();
189
};
190
191
// hasUnsavedChanges is true if the children changed
192
// since last time resetHasUnsavedChanges() was called.
193
ed._hasUnsavedChanges = false;
194
ed.resetHasUnsavedChanges = () => {
195
delete ed.markdownValue;
196
ed._hasUnsavedChanges = ed.children;
197
};
198
ed.hasUnsavedChanges = () => {
199
if (ed._hasUnsavedChanges === false) {
200
// initially no unsaved changes
201
return false;
202
}
203
return ed._hasUnsavedChanges !== ed.children;
204
};
205
206
ed.markdownValue = value;
207
ed.getMarkdownValue = () => {
208
if (ed.markdownValue != null && !ed.hasUnsavedChanges()) {
209
return ed.markdownValue;
210
}
211
ed.markdownValue = slate_to_markdown(ed.children, {
212
cache: ed.syncCache,
213
});
214
return ed.markdownValue;
215
};
216
217
ed.selectionIsCollapsed = () => {
218
return ed.selection == null || Range.isCollapsed(ed.selection);
219
};
220
221
if (getValueRef != null) {
222
getValueRef.current = ed.getMarkdownValue;
223
}
224
225
ed.getPlainValue = (fragment?) => {
226
const markdown = ed.getSourceValue(fragment);
227
return $("<div>" + markdown_to_html(markdown) + "</div>").text();
228
};
229
230
ed.saveValue = (force?) => {
231
if (!force && !editor.hasUnsavedChanges()) {
232
return;
233
}
234
setSyncstringFromSlate();
235
actions.ensure_syncstring_is_saved?.();
236
};
237
238
ed.syncCache = {};
239
if (selectionRef != null) {
240
selectionRef.current = {
241
setSelection: (selection: any) => {
242
if (!selection) return;
243
// We confirm that the selection is valid.
244
// If not, this will throw an error.
245
const { anchor, focus } = selection;
246
Editor.node(editor, anchor);
247
Editor.node(editor, focus);
248
ed.selection = selection;
249
},
250
getSelection: () => {
251
return ed.selection;
252
},
253
};
254
}
255
256
if (controlRef != null) {
257
controlRef.current = {
258
moveCursorToEndOfLine: () => control.moveCursorToEndOfLine(ed),
259
};
260
}
261
262
ed.onCursorBottom = onCursorBottom;
263
ed.onCursorTop = onCursorTop;
264
265
return ed as SlateEditor;
266
}, []);
267
268
// hook up to syncstring if available:
269
useEffect(() => {
270
if (actions._syncstring == null) return;
271
const beforeChange = setSyncstringFromSlateNOW;
272
const change = () => {
273
setEditorToValue(actions._syncstring.to_str());
274
};
275
actions._syncstring.on("before-change", beforeChange);
276
actions._syncstring.on("change", change);
277
return () => {
278
if (actions._syncstring == null) {
279
// This can be null if doc closed before unmounting. I hit a crash because of this in production.
280
return;
281
}
282
actions._syncstring.removeListener("before-change", beforeChange);
283
actions._syncstring.removeListener("change", change);
284
};
285
}, []);
286
287
useEffect(() => {
288
if (registerEditor != null) {
289
registerEditor({
290
set_cursor: ({ y }) => {
291
// This is used for navigating in Jupyter. Of course cursors
292
// or NOT given by x,y positions in Slate, so we have to interpret
293
// this as follows, since that's what is used by our Jupyter actions.
294
// y = 0: top of document
295
// y = -1: bottom of document
296
let path;
297
if (y == 0) {
298
// top of doc
299
path = [0, 0];
300
} else if (y == -1) {
301
// bottom of doc
302
path = [editor.children.length - 1, 0];
303
} else {
304
return;
305
}
306
const focus = { path, offset: 0 };
307
Transforms.setSelection(editor, {
308
focus,
309
anchor: focus,
310
});
311
},
312
get_cursor: () => {
313
const point = editor.selection?.anchor;
314
if (point == null) {
315
return { x: 0, y: 0 };
316
}
317
const pos = slatePointToMarkdownPosition(editor, point);
318
if (pos == null) return { x: 0, y: 0 };
319
const { line, ch } = pos;
320
return { y: line, x: ch };
321
},
322
});
323
324
return unregisterEditor;
325
}
326
}, [registerEditor, unregisterEditor]);
327
328
useEffect(() => {
329
if (isFocused == null) return;
330
if (ReactEditor.isFocused(editor) != isFocused) {
331
if (isFocused) {
332
ReactEditor.focus(editor);
333
} else {
334
ReactEditor.blur(editor);
335
}
336
}
337
}, [isFocused]);
338
339
const [editorValue, setEditorValue] = useState<Descendant[]>(() =>
340
markdown_to_slate(value ?? "", false, editor.syncCache),
341
);
342
343
const rowSizeEstimator = useCallback((node) => {
344
return estimateSize({ node, fontSize: font_size });
345
}, []);
346
347
const mentionableUsers = useMentionableUsers();
348
349
const mentions = useMentions({
350
isVisible,
351
editor,
352
insertMention: (editor, account_id) => {
353
Transforms.insertNodes(editor, [
354
createMention(account_id),
355
{ text: " " },
356
]);
357
if (submitMentionsRef == null) {
358
// submit immediately, since no ref for controlling this:
359
submit_mentions(project_id, path, [{ account_id, description: "" }]);
360
}
361
},
362
matchingUsers: (search) => mentionableUsers(search, { avatarLLMSize: 16 }),
363
});
364
365
const emojis = useEmojis({
366
editor,
367
insertEmoji: (editor, content, markup) => {
368
Transforms.insertNodes(editor, [
369
createEmoji(content, markup),
370
{ text: " " },
371
]);
372
},
373
});
374
375
useEffect(() => {
376
if (submitMentionsRef != null) {
377
submitMentionsRef.current = (
378
fragmentId?: FragmentId,
379
onlyValue = false,
380
) => {
381
if (project_id == null || path == null) {
382
throw Error(
383
"project_id and path must be set in order to use mentions.",
384
);
385
}
386
387
if (!onlyValue) {
388
const fragment_id = Fragment.encode(fragmentId);
389
390
// No mentions in the document were already sent, so we send them now.
391
// We have to find all mentions in the document tree, and submit them.
392
const mentions: {
393
account_id: string;
394
description: string;
395
fragment_id: string;
396
}[] = [];
397
for (const [node, path] of Editor.nodes(editor, {
398
at: { path: [], offset: 0 },
399
match: (node) => node["type"] == "mention",
400
})) {
401
const [parent] = Editor.parent(editor, path);
402
mentions.push({
403
account_id: (node as Mention).account_id,
404
description: slate_to_markdown([parent]),
405
fragment_id,
406
});
407
}
408
409
submit_mentions(project_id, path, mentions);
410
}
411
const value = editor.getMarkdownValue();
412
return value;
413
};
414
}
415
}, [submitMentionsRef]);
416
417
const search: SearchHook = useSearch({ editor });
418
419
const { marks, updateMarks } = useMarks(editor);
420
421
const { linkURL, updateLinkURL } = useLinkURL(editor);
422
423
const { listProperties, updateListProperties } = useListProperties(editor);
424
425
const updateScrollState = useMemo(() => {
426
const { save_editor_state } = actions;
427
if (save_editor_state == null) return () => {};
428
if (disableWindowing) {
429
return throttle(() => {
430
if (!isMountedRef.current || !didRestoreScrollRef.current) return;
431
const scroll = scrollRef.current?.scrollTop;
432
if (scroll != null) {
433
save_editor_state(id, { scroll });
434
}
435
}, 250);
436
} else {
437
return throttle(() => {
438
if (!isMountedRef.current || !didRestoreScrollRef.current) return;
439
const scroll = getScrollState(editor);
440
if (scroll != null) {
441
save_editor_state(id, { scroll });
442
}
443
}, 250);
444
}
445
}, []);
446
447
const broadcastCursors = useBroadcastCursors({
448
editor,
449
broadcastCursors: (x) => actions.set_cursor_locs?.(x),
450
});
451
452
const cursorDecorate = useCursorDecorate({
453
editor,
454
cursors,
455
value: value ?? "",
456
search,
457
});
458
459
const scrollRef = useRef<HTMLDivElement | null>(null);
460
const didRestoreScrollRef = useRef<boolean>(false);
461
const restoreScroll = useMemo(() => {
462
return async () => {
463
if (didRestoreScrollRef.current) return; // so we only ever do this once.
464
try {
465
const scroll = editor_state?.get("scroll");
466
if (!scroll) return;
467
468
if (!disableWindowing) {
469
// Restore scroll for windowing
470
try {
471
await setScrollState(editor, scroll.toJS());
472
} catch (err) {
473
// could happen, e.g, if we change the format or change windowing.
474
console.log(`restoring scroll state -- ${err}`);
475
}
476
return;
477
}
478
479
// Restore scroll for no windowing.
480
// scroll = the scrollTop position, though we wrap in
481
// exception since it could be anything.
482
await new Promise(requestAnimationFrame);
483
if (scrollRef.current == null || !isMountedRef.current) {
484
return;
485
}
486
const elt = $(scrollRef.current);
487
try {
488
elt.scrollTop(scroll);
489
// scrolling after image loads
490
elt.find("img").on("load", () => {
491
if (!isMountedRef.current) return;
492
elt.scrollTop(scroll);
493
});
494
} catch (_) {}
495
} finally {
496
didRestoreScrollRef.current = true;
497
setOpacity(undefined);
498
}
499
};
500
}, []);
501
502
useEffect(() => {
503
if (actions._syncstring == null) {
504
setEditorToValue(value);
505
}
506
if (value != "Loading...") {
507
restoreScroll();
508
}
509
}, [value]);
510
511
const lastSetValueRef = useRef<string | null>(null);
512
513
const setSyncstringFromSlateNOW = () => {
514
if (actions.set_value == null) {
515
// no way to save the value out (e.g., just beginning to test
516
// using the component).
517
return;
518
}
519
if (!editor.hasUnsavedChanges()) {
520
// there are no changes to save
521
return;
522
}
523
524
const markdown = editor.getMarkdownValue();
525
lastSetValueRef.current = markdown;
526
actions.set_value(markdown);
527
actions.syncstring_commit?.();
528
529
// Record that the syncstring's value is now equal to ours:
530
editor.resetHasUnsavedChanges();
531
};
532
533
const setSyncstringFromSlate = useMemo(() => {
534
if (saveDebounceMs) {
535
return debounce(setSyncstringFromSlateNOW, saveDebounceMs);
536
} else {
537
// this case shouldn't happen
538
return setSyncstringFromSlateNOW;
539
}
540
}, []);
541
542
// We don't want to do saveValue too much, since it presumably can be slow,
543
// especially if the document is large. By debouncing, we only do this when
544
// the user pauses typing for a moment. Also, this avoids making too many commits.
545
// For tiny documents, user can make this small or even 0 to not debounce.
546
const saveValueDebounce =
547
saveDebounceMs != null && !saveDebounceMs
548
? () => editor.saveValue()
549
: useMemo(
550
() =>
551
debounce(
552
() => editor.saveValue(),
553
saveDebounceMs ?? SAVE_DEBOUNCE_MS,
554
),
555
[],
556
);
557
558
function onKeyDown(e) {
559
if (read_only) {
560
e.preventDefault();
561
return;
562
}
563
564
mentions.onKeyDown(e);
565
emojis.onKeyDown(e);
566
567
if (e.defaultPrevented) return;
568
569
if (!ReactEditor.isFocused(editor)) {
570
// E.g., when typing into a codemirror editor embedded
571
// in slate, we get the keystrokes, but at the same time
572
// the (contenteditable) editor itself is not focused.
573
return;
574
}
575
576
const handler = getKeyboardHandler(e);
577
if (handler != null) {
578
const extra = { actions, id, search };
579
if (handler({ editor, extra })) {
580
e.preventDefault();
581
// key was handled.
582
return;
583
}
584
}
585
}
586
587
useEffect(() => {
588
if (!is_current) {
589
if (editor.hasUnsavedChanges()) {
590
// just switched from focused to not and there was
591
// an unsaved change, so save state.
592
setSyncstringFromSlate();
593
actions.ensure_syncstring_is_saved?.();
594
}
595
}
596
}, [is_current]);
597
598
const setEditorToValue = (value) => {
599
// console.log("setEditorToValue", { value, ed: editor.getMarkdownValue() });
600
if (lastSetValueRef.current == value) {
601
// this always happens once right after calling setSyncstringFromSlateNOW
602
// and it can randomly undo the last thing done, so don't do that!
603
// Also, this is an excellent optimization to do as well.
604
lastSetValueRef.current = null;
605
// console.log("setEditorToValue: skip");
606
return;
607
}
608
if (value == null) return;
609
if (value == editor.getMarkdownValue()) {
610
// nothing to do, and in fact doing something
611
// could be really annoying, since we don't want to
612
// autoformat via markdown everything immediately,
613
// as ambiguity is resolved while typing...
614
return;
615
}
616
const previousEditorValue = editor.children;
617
618
// we only use the latest version of the document
619
// for caching purposes.
620
editor.syncCache = {};
621
// There is an assumption here that markdown_to_slate produces
622
// a document that is properly normalized. If that isn't the
623
// case, things will go horribly wrong, since it'll be impossible
624
// to convert the document to equal nextEditorValue. In the current
625
// code we do nomalize the output of markdown_to_slate, so
626
// that assumption is definitely satisfied.
627
const nextEditorValue = markdown_to_slate(value, false, editor.syncCache);
628
629
try {
630
//const t = new Date();
631
632
if (
633
// length is basically changing from "Loading..."; in this case, just reset everything, rather than transforming via operations (which preserves selection, etc.)
634
previousEditorValue.length <= 1 &&
635
nextEditorValue.length >= 40 &&
636
!ReactEditor.isFocused(editor)
637
) {
638
// This is a **MASSIVE** optimization. E.g., for a few thousand
639
// lines markdown file with about 500 top level elements (and lots
640
// of nested lists), applying operations below starting with the
641
// empty document can take 5-10 seconds, whereas just setting the
642
// value is instant. The drawback to directly setting the value
643
// is only that it messes up selection, and it's difficult
644
// to know where to move the selection to after changing.
645
// However, if the editor isn't focused, we don't have to worry
646
// about selection at all. TODO: we might be able to avoid the
647
// slateDiff stuff entirely via some tricky stuff, e.g., managing
648
// the cursor on the plain text side before/after the change, since
649
// codemirror is much faster att "setValueNoJump".
650
// The main time we use this optimization here is when opening the
651
// document in the first place, in which case we're converting
652
// the document from "Loading..." to it's initial value.
653
// Also, the default config is source text focused on the left and
654
// editable text acting as a preview on the right not focused, and
655
// again this makes things fastest.
656
// DRAWBACK: this doesn't preserve scroll position and breaks selection.
657
editor.syncCausedUpdate = true;
658
// we call "onChange" instead of setEditorValue, since
659
// we want all the change handler stuff to happen, e.g.,
660
// broadcasting cursors.
661
onChange(nextEditorValue);
662
// console.log("time to set directly ", new Date() - t);
663
} else {
664
const operations = slateDiff(previousEditorValue, nextEditorValue);
665
if (operations.length == 0) {
666
// no actual change needed.
667
return;
668
}
669
// Applying this operation below will trigger
670
// an onChange, which it is best to ignore to save time and
671
// also so we don't update the source editor (and other browsers)
672
// with a view with things like loan $'s escaped.'
673
editor.syncCausedUpdate = true;
674
// console.log("setEditorToValue: applying operations...", { operations });
675
preserveScrollPosition(editor, operations);
676
applyOperations(editor, operations);
677
// console.log("time to set via diff", new Date() - t);
678
}
679
} finally {
680
// In all cases, now that we have transformed editor into the new value
681
// let's save the fact that we haven't changed anything yet and we
682
// know the markdown state with zero changes. This is important, so
683
// we don't save out a change if we don't explicitly make one.
684
editor.resetHasUnsavedChanges();
685
editor.markdownValue = value;
686
}
687
688
try {
689
if (editor.selection != null) {
690
// console.log("setEditorToValue: restore selection", editor.selection);
691
const { anchor, focus } = editor.selection;
692
Editor.node(editor, anchor);
693
Editor.node(editor, focus);
694
}
695
} catch (err) {
696
// TODO!
697
console.warn(
698
"slate - invalid selection after upstream patch. Resetting selection.",
699
err,
700
);
701
// set to beginning of document -- better than crashing.
702
resetSelection(editor);
703
}
704
705
// if ((window as any).cc?.slate != null) {
706
// (window as any).cc.slate.eval = (s) => console.log(eval(s));
707
// }
708
709
if (EXPENSIVE_DEBUG) {
710
const stringify = require("json-stable-stringify");
711
// We use JSON rather than isEqual here, since {foo:undefined}
712
// is not equal to {}, but they JSON the same, and this is
713
// fine for our purposes.
714
if (stringify(editor.children) != stringify(nextEditorValue)) {
715
// NOTE -- this does not 100% mean things are wrong. One case where
716
// this is expected behavior is if you put the cursor at the end of the
717
// document, say right after a horizontal rule, and then edit at the
718
// beginning of the document in another browser. The discrepancy
719
// is because a "fake paragraph" is placed at the end of the browser
720
// so your cursor has somewhere to go while you wait and type; however,
721
// that space is not really part of the markdown document, and it goes
722
// away when you move your cursor out of that space.
723
console.warn(
724
"**WARNING: slateDiff might not have properly transformed editor, though this may be fine. See window.diffBug **",
725
);
726
(window as any).diffBug = {
727
previousEditorValue,
728
nextEditorValue,
729
editorValue: editor.children,
730
stringify,
731
slateDiff,
732
applyOperations,
733
markdown_to_slate,
734
value,
735
};
736
}
737
}
738
};
739
740
if ((window as any).cc != null) {
741
// This only gets set when running in cc-in-cc dev mode.
742
const { Editor, Node, Path, Range, Text } = require("slate");
743
(window as any).cc.slate = {
744
slateDiff,
745
editor,
746
actions,
747
editor_state,
748
Transforms,
749
ReactEditor,
750
Node,
751
Path,
752
Editor,
753
Range,
754
Text,
755
scrollRef,
756
applyOperations,
757
markdown_to_slate,
758
robot: async (s: string, iterations = 1) => {
759
/*
760
This little "robot" function is so you can run rtc on several browsers at once,
761
with each typing random stuff at random, and checking that their input worked
762
without loss of data.
763
*/
764
let inserted = "";
765
let focus = editor.selection?.focus;
766
if (focus == null) throw Error("must have selection");
767
let lastOffset = focus.offset;
768
for (let n = 0; n < iterations; n++) {
769
for (const x of s) {
770
// Transforms.setSelection(editor, {
771
// focus,
772
// anchor: focus,
773
// });
774
editor.insertText(x);
775
focus = editor.selection?.focus;
776
if (focus == null) throw Error("must have selection");
777
inserted += x;
778
const offset = focus.offset;
779
console.log(
780
`${
781
n + 1
782
}/${iterations}: inserted '${inserted}'; focus="${JSON.stringify(
783
editor.selection?.focus,
784
)}"`,
785
);
786
if (offset != (lastOffset ?? 0) + 1) {
787
console.error("SYNC FAIL!!", { offset, lastOffset });
788
return;
789
}
790
lastOffset = offset;
791
await delay(100 * Math.random());
792
if (Math.random() < 0.2) {
793
await delay(2 * SAVE_DEBOUNCE_MS);
794
}
795
}
796
}
797
console.log("SUCCESS!");
798
},
799
};
800
}
801
802
editor.inverseSearch = async function inverseSearch(
803
force?: boolean,
804
): Promise<void> {
805
if (
806
!force &&
807
(is_fullscreen || !actions.get_matching_frame?.({ type: "cm" }))
808
) {
809
// - if user is fullscreen assume they just want to WYSIWYG edit
810
// and double click is to select. They can use sync button to
811
// force opening source panel.
812
// - if no source view, also don't do anything. We only let
813
// double click do something when there is an open source view,
814
// since double click is used for selecting.
815
return;
816
}
817
// delay to give double click a chance to change current focus.
818
// This takes surprisingly long!
819
let t = 0;
820
while (editor.selection == null) {
821
await delay(1);
822
t += 50;
823
if (t > 2000) return; // give up
824
}
825
const point = editor.selection?.anchor; // using anchor since double click selects word.
826
if (point == null) {
827
return;
828
}
829
const pos = slatePointToMarkdownPosition(editor, point);
830
if (pos == null) return;
831
actions.programmatical_goto_line?.(
832
pos.line + 1, // 1 based (TODO: could use codemirror option)
833
true,
834
false, // it is REALLY annoying to switch focus to be honest, e.g., because double click to select a word is common in WYSIWYG editing. If change this to true, make sure to put an extra always 50ms delay above due to focus even order.
835
undefined,
836
pos.ch,
837
);
838
};
839
840
// WARNING: onChange does not fire immediately after changes occur.
841
// It is fired by react and happens in some potentialy later render
842
// loop after changes. Thus you absolutely can't depend on it in any
843
// way for checking if the state of the editor has changed. Instead
844
// check editor.children itself explicitly.
845
const onChange = (newEditorValue) => {
846
if (dirtyRef != null) {
847
// but see comment above
848
dirtyRef.current = true;
849
}
850
if (editor._hasUnsavedChanges === false) {
851
// just for initial change.
852
editor._hasUnsavedChanges = undefined;
853
}
854
if (!isMountedRef.current) return;
855
broadcastCursors();
856
updateMarks();
857
updateLinkURL();
858
updateListProperties();
859
// Track where the last editor selection was,
860
// since this is very useful to know, e.g., for
861
// understanding cursor movement, format fallback, etc.
862
// @ts-ignore
863
if (editor.lastSelection == null && editor.selection != null) {
864
// initialize
865
// @ts-ignore
866
editor.lastSelection = editor.curSelection = editor.selection;
867
}
868
// @ts-ignore
869
if (!isEqual(editor.selection, editor.curSelection)) {
870
// @ts-ignore
871
editor.lastSelection = editor.curSelection;
872
if (editor.selection != null) {
873
// @ts-ignore
874
editor.curSelection = editor.selection;
875
}
876
}
877
878
if (editorValue === newEditorValue) {
879
// Editor didn't actually change value so nothing to do.
880
return;
881
}
882
883
setEditorValue(newEditorValue);
884
setChange(change + 1);
885
886
// Update mentions state whenever editor actually changes.
887
// This may pop up the mentions selector.
888
mentions.onChange();
889
// Similar for emojis.
890
emojis.onChange();
891
892
if (!is_current) {
893
// Do not save when editor not current since user could be typing
894
// into another editor of the same underlying document. This will
895
// cause bugs (e.g., type, switch from slate to codemirror, type, and
896
// see what you typed into codemirror disappear). E.g., this
897
// happens due to a spurious change when the editor is defocused.
898
899
return;
900
}
901
saveValueDebounce();
902
};
903
904
useEffect(() => {
905
editor.syncCausedUpdate = false;
906
}, [editorValue]);
907
908
const [opacity, setOpacity] = useState<number | undefined>(0);
909
910
if (editBar2 != null) {
911
editBar2.current = (
912
<EditBar
913
Search={search.Search}
914
isCurrent={is_current}
915
marks={marks}
916
linkURL={linkURL}
917
listProperties={listProperties}
918
editor={editor}
919
style={{ ...editBarStyle, paddingRight: 0 }}
920
hideSearch={hideSearch}
921
/>
922
);
923
}
924
925
let slate = (
926
<Slate editor={editor} value={editorValue} onChange={onChange}>
927
<Editable
928
placeholder={placeholder}
929
autoFocus={autoFocus}
930
className={
931
!disableWindowing && height != "auto" ? "smc-vfill" : undefined
932
}
933
readOnly={read_only}
934
renderElement={Element}
935
renderLeaf={Leaf}
936
onKeyDown={onKeyDown}
937
onBlur={() => {
938
editor.saveValue();
939
updateMarks();
940
onBlur?.();
941
}}
942
onFocus={() => {
943
updateMarks();
944
onFocus?.();
945
}}
946
decorate={cursorDecorate}
947
divref={scrollRef}
948
onScroll={updateScrollState}
949
style={
950
!disableWindowing
951
? undefined
952
: {
953
height,
954
position: "relative", // CRITICAL!!! Without this, editor will sometimes scroll the entire frame off the screen. Do NOT delete position:'relative'. 5+ hours of work to figure this out! Note that this isn't needed when using windowing above.
955
minWidth: "80%",
956
padding: "70px",
957
background: "white",
958
overflow:
959
height == "auto"
960
? "hidden" /* for height='auto' we never want a scrollbar */
961
: "auto" /* for this overflow, see https://github.com/ianstormtaylor/slate/issues/3706 */,
962
...pageStyle,
963
}
964
}
965
windowing={
966
!disableWindowing
967
? {
968
rowStyle: {
969
// WARNING: do *not* use margin in rowStyle.
970
padding: minimal ? 0 : "0 70px",
971
overflow: "hidden", // CRITICAL: this makes it so the div height accounts for margin of contents (e.g., p element has margin), so virtuoso can measure it correctly. Otherwise, things jump around like crazy.
972
minHeight: "1px", // virtuoso can't deal with 0-height items
973
},
974
marginTop: "40px",
975
marginBottom: "40px",
976
rowSizeEstimator,
977
}
978
: undefined
979
}
980
/>
981
</Slate>
982
);
983
let body = (
984
<ChangeContext.Provider value={{ change, editor }}>
985
<div
986
ref={divRef}
987
className={noVfill || height === "auto" ? undefined : "smc-vfill"}
988
style={{
989
overflow: noVfill || height === "auto" ? undefined : "auto",
990
backgroundColor: "white",
991
...style,
992
height,
993
minHeight: height == "auto" ? "50px" : undefined,
994
}}
995
>
996
{!hidePath && (
997
<Path is_current={is_current} path={path} project_id={project_id} />
998
)}
999
{showEditBar && (
1000
<EditBar
1001
Search={search.Search}
1002
isCurrent={is_current}
1003
marks={marks}
1004
linkURL={linkURL}
1005
listProperties={listProperties}
1006
editor={editor}
1007
style={editBarStyle}
1008
hideSearch={hideSearch}
1009
/>
1010
)}
1011
<div
1012
className={noVfill || height == "auto" ? undefined : "smc-vfill"}
1013
style={{
1014
...STYLE,
1015
fontSize: font_size,
1016
height,
1017
opacity,
1018
}}
1019
>
1020
{mentions.Mentions}
1021
{emojis.Emojis}
1022
{slate}
1023
</div>
1024
</div>
1025
</ChangeContext.Provider>
1026
);
1027
return useUpload(editor, body);
1028
});
1029
1030