Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/markdown-input/multimode.tsx
1691 views
1
/*
2
Edit with either plain text input **or** WYSIWYG slate-based input.
3
*/
4
5
import { Popover, Radio } from "antd";
6
import { Map as ImmutableMap, fromJS } from "immutable";
7
import LRU from "lru-cache";
8
import {
9
CSSProperties,
10
MutableRefObject,
11
ReactNode,
12
RefObject,
13
useEffect,
14
useMemo,
15
useRef,
16
useState,
17
} from "react";
18
import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";
19
import { Icon } from "@cocalc/frontend/components";
20
import { EditableMarkdown } from "@cocalc/frontend/editors/slate/editable-markdown";
21
import "@cocalc/frontend/editors/slate/elements/math/math-widget";
22
import { IS_MOBILE } from "@cocalc/frontend/feature";
23
import { SAVE_DEBOUNCE_MS } from "@cocalc/frontend/frame-editors/code-editor/const";
24
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
25
import { get_local_storage, set_local_storage } from "@cocalc/frontend/misc";
26
import { COLORS } from "@cocalc/util/theme";
27
import { BLURED_STYLE, FOCUSED_STYLE, MarkdownInput } from "./component";
28
29
// NOTE: on mobile there is very little suppport for "editor" = "slate", but
30
// very good support for "markdown", hence the default below.
31
32
export interface EditorFunctions {
33
set_cursor: (pos: { x?: number; y?: number }) => void;
34
get_cursor: () => { x: number; y: number };
35
}
36
37
interface MultimodeState {
38
mode?: Mode;
39
markdown?: any;
40
editor?: any;
41
}
42
43
const multimodeStateCache = new LRU<string, MultimodeState>({ max: 500 });
44
45
// markdown uses codemirror
46
// editor uses slate. TODO: this should be "text", not "editor". Oops.
47
// UI equivalent:
48
// editor = "Text" = Slate/wysiwyg
49
// markdown = "Markdown"
50
const Modes = ["markdown", "editor"] as const;
51
export type Mode = (typeof Modes)[number];
52
53
const LOCAL_STORAGE_KEY = "markdown-editor-mode";
54
55
function getLocalStorageMode(): Mode | undefined {
56
const m = get_local_storage(LOCAL_STORAGE_KEY);
57
if (typeof m === "string" && Modes.includes(m as any)) {
58
return m as Mode;
59
}
60
}
61
62
interface Props {
63
cacheId?: string; // unique **within this file**; the project_id and path are automatically also used
64
value?: string;
65
defaultMode?: Mode; // defaults to editor or whatever was last used (as stored in localStorage)
66
fixedMode?: Mode; // only use this mode; no option to switch
67
onChange: (value: string) => void;
68
69
// use getValueRef to obtain a function getValueRef.current() that returns the current
70
// value of the editor *NOW*, without waiting for onChange. Even with saveDebounceMs=0,
71
// there is definitely no guarantee that onChange is always up to date, but definitely
72
// up to date values are required to implement realtime sync!
73
getValueRef?: MutableRefObject<() => string>;
74
75
onModeChange?: (mode: Mode) => void;
76
onShiftEnter?: (value: string) => void;
77
placeholder?: string;
78
fontSize?: number;
79
height?: string; // css height and also "auto" is fully supported.
80
style?: CSSProperties;
81
modeSwitchStyle?: CSSProperties;
82
autoFocus?: boolean; // note - this is broken on safari for the slate editor, but works on chrome and firefox.
83
enableMentions?: boolean;
84
enableUpload?: boolean; // whether to enable upload of files via drag-n-drop or paste. This is on by default! (Note: not possible to disable for slate editor mode anyways.)
85
onUploadStart?: () => void;
86
onUploadEnd?: () => void;
87
submitMentionsRef?: SubmitMentionsRef;
88
extraHelp?: ReactNode;
89
hideHelp?: boolean;
90
// debounce how frequently get updates from onChange; if saveDebounceMs=0 get them on every change. Default is the global SAVE_DEBOUNCE_MS const.
91
// can be a little more frequent in case of shift or alt enter, or blur.
92
saveDebounceMs?: number;
93
onBlur?: () => void;
94
onFocus?: () => void;
95
minimal?: boolean;
96
editBarStyle?: CSSProperties;
97
98
// onCursors is called when user cursor(s) move. "editable" mode only supports a single
99
// cursor right now, but "markdown" mode supports multiple cursors. An array is
100
// output in all cases. In editable mode, the cursor is positioned where it would be
101
// in the plain text.
102
onCursors?: (cursors: { x: number; y: number }[]) => void;
103
// If cursors are given, then they get rendered in the editor. This is a map
104
// from account_id to objects {x:number,y:number} that give the 0-based row and column
105
// in the plain markdown text, as of course output by onCursors above.
106
cursors?: ImmutableMap<string, any>;
107
noVfill?: boolean;
108
editorDivRef?: RefObject<HTMLDivElement>; // if in slate "editor" mode, this is the top-level div
109
cmOptions?: { [key: string]: any }; // used for codemirror options override above and account settings
110
// It is important to handle all of these, rather than trying to rely
111
// on some global keyboard shortcuts. E.g., in vim mode codemirror,
112
// user types ":w" in their editor and whole document should save
113
// to disk...
114
onUndo?: () => void; // called when user requests to undo
115
onRedo?: () => void; // called when user requests redo
116
onSave?: () => void; // called when user requests to save document
117
118
compact?: boolean; // optimize for compact embedded usage.
119
120
// onCursorTop and onCursorBottom are called when the cursor is on top line and goes up,
121
// so that client could move to another editor (e.g., in Jupyter this is how you move out
122
// of a cell to an adjacent cell).
123
onCursorTop?: () => void;
124
onCursorBottom?: () => void;
125
126
// Declarative control of whether or not the editor is focused. Only has an impact
127
// if it is explicitly set to true or false.
128
isFocused?: boolean;
129
130
registerEditor?: (editor: EditorFunctions) => void;
131
unregisterEditor?: () => void;
132
133
// refresh codemirror if this changes
134
refresh?: any;
135
136
overflowEllipsis?: boolean; // if true (the default!), show "..." button popping up all menu entries
137
138
dirtyRef?: MutableRefObject<boolean>; // a boolean react ref that gets set to true whenever document changes for any reason (client should explicitly set this back to false).
139
140
controlRef?: MutableRefObject<any>;
141
}
142
143
export default function MultiMarkdownInput({
144
autoFocus,
145
cacheId,
146
cmOptions,
147
compact,
148
cursors,
149
defaultMode,
150
dirtyRef,
151
editBarStyle,
152
editorDivRef,
153
enableMentions,
154
enableUpload = true,
155
extraHelp,
156
fixedMode,
157
fontSize,
158
getValueRef,
159
height = "auto",
160
hideHelp,
161
isFocused,
162
minimal,
163
modeSwitchStyle,
164
noVfill,
165
onBlur,
166
onChange,
167
onCursorBottom,
168
onCursors,
169
onCursorTop,
170
onFocus,
171
onModeChange,
172
onRedo,
173
onSave,
174
onShiftEnter,
175
onUndo,
176
onUploadEnd,
177
onUploadStart,
178
overflowEllipsis = true,
179
placeholder,
180
refresh,
181
registerEditor,
182
saveDebounceMs = SAVE_DEBOUNCE_MS,
183
style,
184
submitMentionsRef,
185
unregisterEditor,
186
value,
187
controlRef,
188
}: Props) {
189
const {
190
isFocused: isFocusedFrame,
191
isVisible,
192
project_id,
193
path,
194
} = useFrameContext();
195
196
// We use refs for shiftEnter and onChange to be absolutely
197
// 100% certain that if either of these functions is changed,
198
// then the new function is used, even if the components
199
// implementing our markdown editor mess up somehow and hang on.
200
const onShiftEnterRef = useRef<any>(onShiftEnter);
201
useEffect(() => {
202
onShiftEnterRef.current = onShiftEnter;
203
}, [onShiftEnter]);
204
const onChangeRef = useRef<any>(onChange);
205
useEffect(() => {
206
onChangeRef.current = onChange;
207
}, [onChange]);
208
209
const editBar2 = useRef<React.JSX.Element | undefined>(undefined);
210
211
const getKey = () => `${project_id}${path}:${cacheId}`;
212
213
function getCache() {
214
return cacheId == null ? undefined : multimodeStateCache.get(getKey());
215
}
216
217
const [mode, setMode0] = useState<Mode>(
218
fixedMode ??
219
getCache()?.mode ??
220
defaultMode ??
221
getLocalStorageMode() ??
222
(IS_MOBILE ? "markdown" : "editor"),
223
);
224
225
const [editBarPopover, setEditBarPopover] = useState<boolean>(false);
226
227
useEffect(() => {
228
onModeChange?.(mode);
229
}, []);
230
231
const setMode = (mode: Mode) => {
232
set_local_storage(LOCAL_STORAGE_KEY, mode);
233
setMode0(mode);
234
onModeChange?.(mode);
235
if (cacheId !== undefined) {
236
multimodeStateCache.set(`${project_id}${path}:${cacheId}`, {
237
...getCache(),
238
mode,
239
});
240
}
241
};
242
const [focused, setFocused] = useState<boolean>(!!autoFocus);
243
const ignoreBlur = useRef<boolean>(false);
244
245
const cursorsMap = useMemo(() => {
246
return cursors == null ? undefined : fromJS(cursors);
247
}, [cursors]);
248
249
const selectionRef = useRef<{
250
getSelection: Function;
251
setSelection: Function;
252
} | null>(null);
253
254
useEffect(() => {
255
if (cacheId == null) {
256
return;
257
}
258
const cache = getCache();
259
if (cache?.[mode] != null && selectionRef.current != null) {
260
// restore selection on mount.
261
try {
262
selectionRef.current.setSelection(cache?.[mode]);
263
} catch (_err) {
264
// it might just be that the document isn't initialized yet
265
setTimeout(() => {
266
try {
267
selectionRef.current?.setSelection(cache?.[mode]);
268
} catch (_err2) {
269
// console.warn(_err2); // definitely don't need this.
270
// This is expected to fail, since the selection from last
271
// use will be invalid now if another user changed the
272
// document, etc., or you did in a different mode, possibly.
273
}
274
}, 100);
275
}
276
}
277
return () => {
278
if (selectionRef.current == null || cacheId == null) {
279
return;
280
}
281
const selection = selectionRef.current.getSelection();
282
multimodeStateCache.set(getKey(), {
283
...getCache(),
284
[mode]: selection,
285
});
286
};
287
}, [mode]);
288
289
function toggleEditBarPopover() {
290
setEditBarPopover(!editBarPopover);
291
}
292
293
function renderEditBarEllipsis() {
294
return (
295
<span style={{ fontWeight: 400 }}>
296
{"\u22EF"}
297
<Popover
298
open={isFocusedFrame && isVisible && editBarPopover}
299
content={
300
<div style={{ display: "flex" }}>
301
{editBar2.current}
302
<Icon
303
onClick={() => setEditBarPopover(false)}
304
name="times"
305
style={{
306
color: COLORS.GRAY_M,
307
marginTop: "5px",
308
}}
309
/>
310
</div>
311
}
312
/>
313
</span>
314
);
315
}
316
317
return (
318
<div
319
style={{
320
position: "relative",
321
width: "100%",
322
height: "100%",
323
...(minimal
324
? undefined
325
: {
326
overflow: "hidden",
327
background: "white",
328
color: "black",
329
...(focused ? FOCUSED_STYLE : BLURED_STYLE),
330
}),
331
}}
332
>
333
<div
334
onMouseDown={() => {
335
// Clicking the checkbox blurs the edit field, but
336
// this is the one case we do NOT want to trigger the
337
// onBlur callback, since that would make switching
338
// back and forth between edit modes impossible.
339
ignoreBlur.current = true;
340
setTimeout(() => (ignoreBlur.current = false), 100);
341
}}
342
onTouchStart={() => {
343
ignoreBlur.current = true;
344
setTimeout(() => (ignoreBlur.current = false), 100);
345
}}
346
>
347
{!fixedMode && (
348
<div
349
style={{
350
background: "white",
351
color: COLORS.GRAY_M,
352
...(mode == "editor" || hideHelp
353
? {
354
float: "right",
355
position: "relative",
356
zIndex: 1,
357
}
358
: { float: "right" }),
359
...modeSwitchStyle,
360
}}
361
>
362
<Radio.Group
363
options={[
364
...(overflowEllipsis && mode == "editor"
365
? [
366
{
367
label: renderEditBarEllipsis(),
368
value: "menu",
369
style: {
370
backgroundColor: editBarPopover
371
? COLORS.GRAY_L
372
: "white",
373
paddingLeft: 10,
374
paddingRight: 10,
375
},
376
},
377
]
378
: []),
379
// fontWeight is needed to undo a stupid conflict with bootstrap css, which will go away when we get rid of that ancient nonsense.
380
{
381
label: <span style={{ fontWeight: 400 }}>Text</span>,
382
value: "editor",
383
},
384
{
385
label: <span style={{ fontWeight: 400 }}>Markdown</span>,
386
value: "markdown",
387
},
388
]}
389
onChange={(e) => {
390
const mode = e.target.value;
391
if (mode === "menu") {
392
toggleEditBarPopover();
393
} else {
394
setMode(mode as Mode);
395
}
396
}}
397
value={mode}
398
optionType="button"
399
size="small"
400
buttonStyle="solid"
401
style={{ display: "block" }}
402
/>
403
</div>
404
)}
405
</div>
406
{mode === "markdown" ? (
407
<MarkdownInput
408
divRef={editorDivRef}
409
selectionRef={selectionRef}
410
value={value}
411
onChange={(value) => {
412
onChangeRef.current?.(value);
413
}}
414
saveDebounceMs={saveDebounceMs}
415
getValueRef={getValueRef}
416
project_id={project_id}
417
path={path}
418
enableUpload={enableUpload}
419
onUploadStart={onUploadStart}
420
onUploadEnd={onUploadEnd}
421
enableMentions={enableMentions}
422
onShiftEnter={(value) => {
423
onShiftEnterRef.current?.(value);
424
}}
425
placeholder={placeholder ?? "Type markdown..."}
426
fontSize={fontSize}
427
cmOptions={cmOptions}
428
height={height}
429
style={style}
430
autoFocus={focused}
431
submitMentionsRef={submitMentionsRef}
432
extraHelp={extraHelp}
433
hideHelp={hideHelp}
434
onBlur={(value) => {
435
onChangeRef.current?.(value);
436
if (!ignoreBlur.current) {
437
onBlur?.();
438
}
439
}}
440
onFocus={onFocus}
441
onSave={onSave}
442
onUndo={onUndo}
443
onRedo={onRedo}
444
onCursors={onCursors}
445
cursors={cursorsMap}
446
onCursorTop={onCursorTop}
447
onCursorBottom={onCursorBottom}
448
isFocused={isFocused}
449
registerEditor={registerEditor}
450
unregisterEditor={unregisterEditor}
451
refresh={refresh}
452
compact={compact}
453
dirtyRef={dirtyRef}
454
/>
455
) : undefined}
456
{mode === "editor" ? (
457
<div
458
style={{
459
height: height ?? "100%",
460
width: "100%",
461
fontSize: "14px" /* otherwise button bar can be skewed */,
462
...style, // make it possible to override width, height, etc. This of course allows for problems but is essential. E.g., we override width for chat input in a whiteboard.
463
}}
464
className={height != "auto" ? "smc-vfill" : undefined}
465
>
466
<EditableMarkdown
467
selectionRef={selectionRef}
468
divRef={editorDivRef}
469
noVfill={noVfill}
470
value={value}
471
is_current={true}
472
hidePath
473
disableWindowing={
474
true /* I tried making this false when height != 'auto', but then *clicking to set selection* doesn't work at least for task list.*/
475
}
476
style={
477
minimal
478
? { background: undefined, backgroundColor: undefined }
479
: undefined
480
}
481
pageStyle={
482
minimal
483
? { background: undefined, padding: 0 }
484
: { padding: "5px 15px" }
485
}
486
minimal={minimal}
487
height={height}
488
editBarStyle={
489
{
490
paddingRight: "127px",
491
...editBarStyle,
492
} /* this paddingRight is of course just a stupid temporary hack, since by default the mode switch is on top of it, which matters when cursor in a list or URL */
493
}
494
saveDebounceMs={saveDebounceMs}
495
getValueRef={getValueRef}
496
actions={{
497
set_value: (value) => {
498
onChangeRef.current?.(value);
499
},
500
shiftEnter: (value) => {
501
onChangeRef.current?.(value);
502
onShiftEnterRef.current?.(value);
503
},
504
altEnter: (value) => {
505
onChangeRef.current?.(value);
506
setMode("markdown");
507
},
508
set_cursor_locs: onCursors,
509
undo: onUndo,
510
redo: onRedo,
511
save: onSave as any,
512
}}
513
cursors={cursorsMap}
514
font_size={fontSize}
515
autoFocus={focused}
516
onFocus={() => {
517
setFocused(true);
518
onFocus?.();
519
}}
520
onBlur={() => {
521
setFocused(false);
522
if (!ignoreBlur.current) {
523
onBlur?.();
524
}
525
}}
526
hideSearch
527
onCursorTop={onCursorTop}
528
onCursorBottom={onCursorBottom}
529
isFocused={isFocused}
530
registerEditor={registerEditor}
531
unregisterEditor={unregisterEditor}
532
placeholder={placeholder ?? "Type text..."}
533
submitMentionsRef={submitMentionsRef}
534
editBar2={editBar2}
535
dirtyRef={dirtyRef}
536
controlRef={controlRef}
537
/>
538
</div>
539
) : undefined}
540
</div>
541
);
542
}
543
544