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