Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/markdown-input/component.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
/*
7
Markdown editor
8
*/
9
10
import * as CodeMirror from "codemirror";
11
import { debounce, isEqual } from "lodash";
12
import {
13
CSSProperties,
14
MutableRefObject,
15
ReactNode,
16
RefObject,
17
useCallback,
18
useEffect,
19
useMemo,
20
useRef,
21
useState,
22
} from "react";
23
import { alert_message } from "@cocalc/frontend/alerts";
24
import { redux, useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";
25
import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";
26
import { A } from "@cocalc/frontend/components";
27
import { IS_MOBILE } from "@cocalc/frontend/feature";
28
import { Dropzone, BlobUpload } from "@cocalc/frontend/file-upload";
29
import { Cursors, CursorsType } from "@cocalc/frontend/jupyter/cursors";
30
import Fragment, { FragmentId } from "@cocalc/frontend/misc/fragment-id";
31
import { useProjectHasInternetAccess } from "@cocalc/frontend/project/settings/has-internet-access-hook";
32
import { len, trunc, trunc_middle } from "@cocalc/util/misc";
33
import { Complete, Item } from "./complete";
34
import { useMentionableUsers } from "./mentionable-users";
35
import { submit_mentions } from "./mentions";
36
import { EditorFunctions } from "./multimode";
37
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
38
39
type EventHandlerFunction = (cm: CodeMirror.Editor) => void;
40
41
// This code depends on codemirror being initialized.
42
import "@cocalc/frontend/codemirror/init";
43
44
export const BLURED_STYLE: CSSProperties = {
45
border: "1px solid rgb(204,204,204)", // focused will be rgb(112, 178, 230);
46
borderRadius: "5px",
47
} as const;
48
49
export const FOCUSED_STYLE: CSSProperties = {
50
outline: "none !important",
51
boxShadow: "0px 0px 5px #719ECE",
52
borderRadius: "5px",
53
border: "1px solid #719ECE",
54
} as const;
55
56
const PADDING_TOP = 6;
57
58
const MENTION_CSS =
59
"color:#7289da; background:rgba(114,137,218,.1); border-radius: 3px; padding: 0 2px;";
60
61
interface Props {
62
project_id?: string; // must be set if enableUpload or enableMentions is set (todo: enforce via typescript)
63
path?: string; // must be set if enableUpload or enableMentions is set (todo: enforce via typescript)
64
value?: string;
65
onChange?: (value: string) => void;
66
saveDebounceMs?: number; // if given, calls to onChange are debounced by this param
67
getValueRef?: MutableRefObject<() => string>;
68
enableUpload?: boolean; // if true, enable drag-n-drop and pasted files
69
onUploadStart?: () => void;
70
onUploadEnd?: () => void;
71
enableMentions?: boolean;
72
submitMentionsRef?: SubmitMentionsRef;
73
style?: CSSProperties;
74
onShiftEnter?: (value: string) => void; // also ctrl/alt/cmd-enter call this; see https://github.com/sagemathinc/cocalc/issues/1914
75
onEscape?: () => void;
76
onBlur?: (value: string) => void;
77
onFocus?: () => void;
78
isFocused?: boolean; // see docs in multimode.tsx
79
placeholder?: string;
80
height?: string;
81
instructionsStyle?: CSSProperties;
82
extraHelp?: ReactNode;
83
hideHelp?: boolean;
84
fontSize?: number;
85
styleActiveLine?: boolean;
86
lineWrapping?: boolean;
87
lineNumbers?: boolean;
88
autoFocus?: boolean;
89
cmOptions?: { [key: string]: any }; // if given, use this for CodeMirror options, taking precedence over anything derived from other inputs, e.g., lineNumbers, above and account settings.
90
selectionRef?: MutableRefObject<{
91
setSelection: Function;
92
getSelection: Function;
93
} | null>;
94
onUndo?: () => void; // user requests undo -- if given, codemirror's internal undo is not used
95
onRedo?: () => void; // user requests redo
96
onSave?: () => void; // user requests save
97
onCursors?: (cursors: { x: number; y: number }[]) => void; // cursor location(s).
98
cursors?: CursorsType;
99
divRef?: RefObject<HTMLDivElement>;
100
onCursorTop?: () => void;
101
onCursorBottom?: () => void;
102
registerEditor?: (editor: EditorFunctions) => void;
103
unregisterEditor?: () => void;
104
refresh?: any; // refresh codemirror if this changes
105
compact?: boolean;
106
dirtyRef?: MutableRefObject<boolean>;
107
}
108
109
export function MarkdownInput(props: Props) {
110
const {
111
autoFocus,
112
cmOptions,
113
compact,
114
cursors,
115
dirtyRef,
116
divRef,
117
enableMentions,
118
enableUpload,
119
extraHelp,
120
fontSize,
121
getValueRef,
122
height,
123
hideHelp,
124
instructionsStyle,
125
isFocused,
126
onBlur,
127
onChange,
128
onCursorBottom,
129
onCursors,
130
onCursorTop,
131
onEscape,
132
onFocus,
133
onRedo,
134
onSave,
135
onShiftEnter,
136
onUndo,
137
onUploadEnd,
138
onUploadStart,
139
path,
140
placeholder,
141
project_id,
142
refresh,
143
registerEditor,
144
saveDebounceMs,
145
selectionRef,
146
style,
147
submitMentionsRef,
148
unregisterEditor,
149
value,
150
} = props;
151
const { actions, isVisible } = useFrameContext();
152
const cm = useRef<CodeMirror.Editor | undefined>(undefined);
153
const textarea_ref = useRef<HTMLTextAreaElement | null>(null);
154
const editor_settings = useRedux(["account", "editor_settings"]);
155
const options = useMemo(() => {
156
return {
157
indentUnit: 2,
158
indentWithTabs: false,
159
autoCloseBrackets: editor_settings.get("auto_close_brackets", false),
160
lineWrapping: editor_settings.get("line_wrapping", true),
161
lineNumbers: editor_settings.get("line_numbers", false),
162
matchBrackets: editor_settings.get("match_brackets", false),
163
styleActiveLine: editor_settings.get("style_active_line", true),
164
theme: editor_settings.get("theme", "default"),
165
...cmOptions,
166
};
167
}, [editor_settings, cmOptions]);
168
169
const defaultFontSize = useTypedRedux("account", "font_size");
170
171
const dropzone_ref = useRef<Dropzone>(null);
172
const upload_close_preview_ref = useRef<Function | null>(null);
173
const current_uploads_ref = useRef<{ [name: string]: boolean } | null>(null);
174
const [isFocusedStyle, setIsFocusedStyle] = useState<boolean>(!!autoFocus);
175
const isFocusedRef = useRef<boolean>(!!autoFocus);
176
177
const [mentions, set_mentions] = useState<undefined | Item[]>(undefined);
178
const [mentions_offset, set_mentions_offset] = useState<
179
undefined | { left: number; top: number }
180
>(undefined);
181
const [mentions_search, set_mentions_search] = useState<string>("");
182
const mentions_cursor_ref = useRef<{
183
cursor: EventHandlerFunction;
184
change: EventHandlerFunction;
185
from: { line: number; ch: number };
186
} | undefined>(undefined);
187
188
const mentionableUsers = useMentionableUsers();
189
190
const focus = useCallback(() => {
191
if (isFocusedRef.current) return; // already focused
192
const ed = cm.current;
193
if (ed == null) return;
194
ed.getInputField().focus({ preventScroll: true });
195
}, []);
196
197
const blur = useCallback(() => {
198
if (!isFocusedRef.current) return; // already blured
199
const ed = cm.current;
200
if (ed == null) return;
201
ed.getInputField().blur();
202
}, []);
203
204
useEffect(() => {
205
if (isFocusedRef.current == null || cm.current == null) return;
206
207
if (isFocused && !isFocusedRef.current) {
208
focus();
209
} else if (!isFocused && isFocusedRef.current) {
210
blur();
211
}
212
}, [isFocused]);
213
214
useEffect(() => {
215
cm.current?.refresh();
216
}, [refresh]);
217
218
useEffect(() => {
219
// initialize the codemirror editor
220
const node = textarea_ref.current;
221
if (node == null) {
222
// maybe unmounted right as this happened.
223
return;
224
}
225
const extraKeys: CodeMirror.KeyMap = {};
226
if (onShiftEnter != null) {
227
const f = (cm) => onShiftEnter(cm.getValue());
228
extraKeys["Shift-Enter"] = f;
229
extraKeys["Ctrl-Enter"] = f;
230
extraKeys["Alt-Enter"] = f;
231
extraKeys["Cmd-Enter"] = f;
232
}
233
if (onEscape != null) {
234
extraKeys["Esc"] = () => {
235
if (mentions_cursor_ref.current == null) {
236
onEscape();
237
}
238
};
239
}
240
extraKeys["Enter"] = (cm) => {
241
// We only allow enter when mentions isn't in use
242
if (mentions_cursor_ref.current == null) {
243
cm.execCommand("newlineAndIndent");
244
}
245
};
246
247
if (onCursorTop != null) {
248
extraKeys["Up"] = (cm) => {
249
const cur = cm.getCursor();
250
if (cur?.line === cm.firstLine() && cur?.ch === 0) {
251
onCursorTop();
252
} else {
253
CodeMirror.commands.goLineUp(cm);
254
}
255
};
256
}
257
if (onCursorBottom != null) {
258
extraKeys["Down"] = (cm) => {
259
const cur = cm.getCursor();
260
const n = cm.lastLine();
261
const cur_line = cur?.line;
262
const cur_ch = cur?.ch;
263
const line = cm.getLine(n);
264
const line_length = line?.length;
265
if (cur_line === n && cur_ch === line_length) {
266
onCursorBottom();
267
} else {
268
CodeMirror.commands.goLineDown(cm);
269
}
270
};
271
}
272
273
cm.current = CodeMirror.fromTextArea(node, {
274
...options,
275
// dragDrop=false: instead of useless codemirror dnd, we upload file and make link.
276
// Note that for the md editor or other full code editors, we DO want dragDrop true,
277
// since, e.g., you can select some text, then drag it around, which is useful. For
278
// a simple chat message or tiny bit of markdown (like this is for), that's not so
279
// useful and drag-n-drop file upload is way better.
280
dragDrop: false,
281
// IMPORTANT: there is a useEffect involving options below
282
// where the following four properties must be explicitly excluded!
283
inputStyle: "contenteditable" as "contenteditable", // needed for spellcheck to work!
284
spellcheck: true,
285
mode: { name: "gfm" },
286
});
287
// gives this highest precedence:
288
cm.current.addKeyMap(extraKeys);
289
290
if (getValueRef != null) {
291
getValueRef.current = cm.current.getValue.bind(cm.current);
292
}
293
// UNCOMMENT FOR DEBUGGING ONLY
294
// (window as any).cm = cm.current;
295
cm.current.setValue(value ?? "");
296
cm.current.on("change", saveValue);
297
298
if (dirtyRef != null) {
299
cm.current.on("change", () => {
300
dirtyRef.current = true;
301
});
302
}
303
304
if (onBlur != null) {
305
cm.current.on("blur", (editor) => onBlur(editor.getValue()));
306
}
307
if (onFocus != null) {
308
cm.current.on("focus", onFocus);
309
}
310
311
cm.current.on("blur", () => {
312
isFocusedRef.current = false;
313
setIsFocusedStyle(false);
314
});
315
cm.current.on("focus", () => {
316
isFocusedRef.current = true;
317
setIsFocusedStyle(true);
318
cm.current?.refresh();
319
});
320
if (onCursors != null) {
321
cm.current.on("cursorActivity", () => {
322
if (cm.current == null || !isFocusedRef.current) return;
323
if (ignoreChangeRef.current) return;
324
onCursors(
325
cm.current
326
.getDoc()
327
.listSelections()
328
.map((c) => ({ x: c.anchor.ch, y: c.anchor.line })),
329
);
330
});
331
}
332
333
if (onUndo != null) {
334
cm.current.undo = () => {
335
if (cm.current == null) return;
336
saveValue();
337
onUndo();
338
};
339
}
340
if (onRedo != null) {
341
cm.current.redo = () => {
342
if (cm.current == null) return;
343
saveValue();
344
onRedo();
345
};
346
}
347
if (onSave != null) {
348
// This funny cocalc_actions is just how this is setup
349
// elsewhere in cocalc... Basically the global
350
// CodeMirror.commands.save
351
// is set to use this at the bottom of src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx
352
// @ts-ignore
353
cm.current.cocalc_actions = { save: onSave };
354
}
355
356
if (enableUpload) {
357
// as any because the @types for codemirror are WRONG in this case.
358
cm.current.on("paste", handle_paste_event as any);
359
}
360
361
const e: any = cm.current.getWrapperElement();
362
let s = `height:${height}; font-family:sans-serif !important;`;
363
if (compact) {
364
s += "padding:0";
365
} else {
366
s += !options.lineNumbers ? `padding:${PADDING_TOP}px 12px` : "";
367
}
368
e.setAttribute("style", s);
369
370
if (enableMentions) {
371
cm.current.on("change", (cm, changeObj) => {
372
if (changeObj.text[0] == "@") {
373
const before = cm
374
.getLine(changeObj.to.line)
375
.slice(changeObj.to.ch - 1, changeObj.to.ch)
376
?.trim();
377
// If previous character is whitespace or nothing, then activate mentions:
378
if (!before || before == "(" || before == "[") {
379
show_mentions();
380
}
381
}
382
});
383
}
384
385
if (submitMentionsRef != null) {
386
submitMentionsRef.current = (
387
fragmentId?: FragmentId,
388
onlyValue = false,
389
) => {
390
if (project_id == null || path == null) {
391
throw Error(
392
"project_id and path must be set if enableMentions is set.",
393
);
394
}
395
const fragment_id = Fragment.encode(fragmentId);
396
const mentions: {
397
account_id: string;
398
description: string;
399
fragment_id: string;
400
}[] = [];
401
if (cm.current == null) return;
402
// Get lines here, since we modify the doc as we go below.
403
const doc = (cm.current.getDoc() as any).linkedDoc();
404
doc.unlinkDoc(cm.current.getDoc());
405
const marks = cm.current.getAllMarks();
406
marks.reverse();
407
for (const mark of marks) {
408
if (mark == null) continue;
409
const { attributes } = mark as any;
410
if (attributes == null) continue; // some other sort of mark?
411
const { account_id } = attributes;
412
if (account_id == null) continue;
413
const loc = mark.find();
414
if (loc == null) continue;
415
let from, to;
416
if (loc["from"]) {
417
// @ts-ignore
418
({ from, to } = loc);
419
} else {
420
from = to = loc;
421
}
422
const text = `<span class="user-mention" account-id=${account_id} >${cm.current.getRange(
423
from,
424
to,
425
)}</span>`;
426
const description = trunc(cm.current.getLine(from.line).trim(), 160);
427
doc.replaceRange(text, from, to);
428
mentions.push({ account_id, description, fragment_id });
429
}
430
const value = doc.getValue();
431
if (!onlyValue) {
432
submit_mentions(project_id, path, mentions);
433
}
434
return value;
435
};
436
}
437
438
if (autoFocus) {
439
cm.current.focus();
440
}
441
442
if (selectionRef != null) {
443
selectionRef.current = {
444
setSelection: (selection: any) => {
445
cm.current?.setSelections(selection);
446
},
447
getSelection: () => {
448
return cm.current?.listSelections();
449
},
450
};
451
}
452
453
if (registerEditor != null) {
454
registerEditor({
455
set_cursor: (pos: { x?: number; y?: number }) => {
456
if (cm.current == null) return;
457
let { x = 0, y = 0 } = pos; // must be defined!
458
if (y < 0) {
459
// for getting last line...
460
y += cm.current.lastLine() + 1;
461
}
462
cm.current.setCursor({ line: y, ch: x });
463
},
464
get_cursor: () => {
465
if (cm.current == null) return { x: 0, y: 0 };
466
const { line, ch } = cm.current.getCursor();
467
return { y: line, x: ch };
468
},
469
});
470
}
471
472
setTimeout(() => {
473
cm.current?.refresh();
474
}, 0);
475
476
// clean up
477
return () => {
478
if (cm.current == null) return;
479
unregisterEditor?.();
480
cm.current.getWrapperElement().remove();
481
cm.current = undefined;
482
};
483
}, []);
484
485
useEffect(() => {
486
const bindings = editor_settings.get("bindings");
487
if (bindings == null || bindings == "standard") {
488
cm.current?.setOption("keyMap", "default");
489
} else {
490
cm.current?.setOption("keyMap", bindings);
491
}
492
}, [editor_settings.get("bindings")]);
493
494
useEffect(() => {
495
if (cm.current == null) return;
496
for (const key in options) {
497
if (
498
key == "inputStyle" ||
499
key == "spellcheck" ||
500
key == "mode" ||
501
key == "extraKeys"
502
)
503
continue;
504
const opt = options[key];
505
if (!isEqual(cm.current.options[key], opt)) {
506
if (opt != null) {
507
cm.current.setOption(key as any, opt);
508
}
509
}
510
}
511
}, [options]);
512
513
const ignoreChangeRef = useRef<boolean>(false);
514
// use valueRef since we can't just refer to value in saveValue
515
// below, due to not wanted to regenerate the saveValue function
516
// every time, due to debouncing, etc.
517
const valueRef = useRef<string | undefined>(value);
518
valueRef.current = value;
519
const saveValue = useMemo(() => {
520
// save value to owner via onChange
521
if (onChange == null) return () => {}; // no op
522
const f = () => {
523
if (cm.current == null) return;
524
if (ignoreChangeRef.current) return;
525
if (current_uploads_ref.current != null) {
526
// IMPORTANT: we do NOT report the latest version back while
527
// uploading files. Otherwise, if more than one is being
528
// uploaded at once, then we end up with an infinite loop
529
// of updates. In any case, once all the uploads finish
530
// we'll start reporting changes again. This is fine
531
// since you don't want to submit input *during* uploads anyways.
532
return;
533
}
534
const newValue = cm.current.getValue();
535
if (valueRef.current !== newValue) {
536
onChange(newValue);
537
}
538
};
539
if (saveDebounceMs) {
540
return debounce(f, saveDebounceMs);
541
} else {
542
return f;
543
}
544
}, []);
545
546
const setValueNoJump = useCallback((newValue: string | undefined) => {
547
if (
548
newValue == null ||
549
cm.current == null ||
550
cm.current.getValue() === newValue
551
) {
552
return;
553
}
554
ignoreChangeRef.current = true;
555
cm.current.setValueNoJump(newValue);
556
ignoreChangeRef.current = false;
557
}, []);
558
559
useEffect(() => {
560
setValueNoJump(value);
561
if (upload_close_preview_ref.current != null) {
562
upload_close_preview_ref.current(true);
563
}
564
}, [value]);
565
566
function upload_sending(file: { name: string }): void {
567
if (project_id == null || path == null) {
568
throw Error("path must be set if enableUploads is set.");
569
}
570
571
// console.log("upload_sending", file);
572
if (current_uploads_ref.current == null) {
573
current_uploads_ref.current = { [file.name]: true };
574
onUploadStart?.();
575
} else {
576
current_uploads_ref.current[file.name] = true;
577
}
578
if (cm.current == null) return;
579
const input = cm.current.getValue();
580
const s = upload_temp_link(file);
581
if (input.indexOf(s) != -1) {
582
// already have link.
583
return;
584
}
585
cm.current.replaceRange(s, cm.current.getCursor());
586
saveValue();
587
}
588
589
function upload_complete(file): void {
590
if (path == null) {
591
throw Error("path must be set if enableUploads is set.");
592
}
593
const filename = file.name ?? file.upload.filename;
594
595
if (current_uploads_ref.current != null) {
596
delete current_uploads_ref.current[filename];
597
if (len(current_uploads_ref.current) == 0) {
598
current_uploads_ref.current = null;
599
onUploadEnd?.();
600
}
601
}
602
603
if (cm.current == null) return;
604
const input = cm.current.getValue();
605
const s0 = upload_temp_link(file);
606
let s1: string;
607
if (file.status == "error") {
608
s1 = "";
609
alert_message({ type: "error", message: "Error uploading file." });
610
} else if (file.status == "canceled") {
611
// users can cancel files when they are being uploaded.
612
s1 = "";
613
} else {
614
s1 = upload_link(file);
615
}
616
const newValue = input.replace(s0, s1);
617
setValueNoJump(newValue);
618
saveValue();
619
}
620
621
function handle_paste_event(_, e): void {
622
const items = e.clipboardData.items;
623
for (let i = 0; i < items.length; i++) {
624
const item = items[i];
625
if (item != null && item.type.startsWith("image/")) {
626
e.preventDefault();
627
const file = item.getAsFile();
628
if (file != null) {
629
const blob = file.slice(0, -1, item.type);
630
dropzone_ref.current?.addFile(
631
new File([blob], `paste-${Math.random()}`, { type: item.type }),
632
);
633
}
634
return;
635
}
636
}
637
}
638
639
function render_mention_email(): React.JSX.Element | undefined {
640
if (project_id == null) {
641
throw Error("project_id and path must be set if enableMentions is set.");
642
}
643
return <EnableInternetAccess project_id={project_id} />;
644
}
645
646
function render_mobile_instructions() {
647
if (hideHelp) {
648
return <div style={{ height: "24px", ...instructionsStyle }}></div>;
649
}
650
return (
651
<div
652
style={{
653
color: "#767676",
654
fontSize: "12px",
655
padding: "2.5px 15px",
656
background: "white",
657
...instructionsStyle,
658
}}
659
>
660
{render_mention_instructions()}
661
{render_mention_email()}. Use{" "}
662
<A href="https://help.github.com/articles/getting-started-with-writing-and-formatting-on-github/">
663
Markdown
664
</A>{" "}
665
and <A href="https://en.wikibooks.org/wiki/LaTeX/Mathematics">LaTeX</A>.{" "}
666
{render_upload_instructions()}
667
{extraHelp}
668
</div>
669
);
670
}
671
672
function render_desktop_instructions() {
673
if (hideHelp)
674
return <div style={{ height: "24px", ...instructionsStyle }}></div>;
675
return (
676
<div
677
style={{
678
color: "#767676",
679
fontSize: "12px",
680
padding: "3px 15px",
681
background: "white",
682
...instructionsStyle,
683
}}
684
>
685
<A href="https://help.github.com/articles/getting-started-with-writing-and-formatting-on-github/">
686
Markdown
687
</A>
688
{" and "}
689
<A href="https://en.wikibooks.org/wiki/LaTeX/Mathematics">
690
LaTeX formulas
691
</A>
692
. {render_mention_instructions()}
693
{render_upload_instructions()}
694
{extraHelp}
695
</div>
696
);
697
// I removed the emoticons list; it should really be a dropdown that
698
// appears like with github... Emoticons: {emoticons}.
699
}
700
701
function render_mention_instructions(): React.JSX.Element | undefined {
702
if (!enableMentions) return;
703
return (
704
<>
705
{" "}
706
Use @name to mention people
707
{render_mention_email()}.{" "}
708
</>
709
);
710
}
711
712
function render_upload_instructions(): React.JSX.Element | undefined {
713
if (!enableUpload) return;
714
const text = IS_MOBILE ? (
715
<a>Tap here to upload images.</a>
716
) : (
717
<>
718
Attach images by drag & drop, <a>select</a> or paste.
719
</>
720
);
721
return (
722
<>
723
{" "}
724
<span
725
style={{ cursor: "pointer" }}
726
onClick={() => {
727
// I could not get the clickable config to work,
728
// but reading the source code I found that this does:
729
dropzone_ref.current?.hiddenFileInput?.click();
730
}}
731
>
732
{text}
733
</span>{" "}
734
</>
735
);
736
}
737
738
function render_instructions() {
739
return IS_MOBILE
740
? render_mobile_instructions()
741
: render_desktop_instructions();
742
}
743
744
// Show the mentions popup selector. We *do* allow mentioning ourself,
745
// since Discord and Github both do, and maybe it's just one of those
746
// "symmetry" things (like liking your own post) that people feel is right.
747
function show_mentions() {
748
if (cm.current == null) return;
749
if (project_id == null) {
750
throw Error("project_id and path must be set if enableMentions is set.");
751
}
752
const v = mentionableUsers(undefined, { avatarLLMSize: 16 });
753
if (v.length == 0) {
754
// nobody to mention (e.g., admin doesn't have this)
755
return;
756
}
757
set_mentions(v);
758
set_mentions_search("");
759
760
const cursor = cm.current.getCursor();
761
const pos = cm.current.cursorCoords(cursor, "local");
762
const scrollOffset = cm.current.getScrollInfo().top;
763
const top = pos.bottom - scrollOffset + PADDING_TOP;
764
// gutter is empty right now, but let's include this in case
765
// we implement line number support...
766
const gutter = $(cm.current.getGutterElement()).width() ?? 0;
767
const left = pos.left + gutter;
768
set_mentions_offset({ left, top });
769
770
let last_cursor = cursor;
771
mentions_cursor_ref.current = {
772
from: { line: cursor.line, ch: cursor.ch - 1 },
773
cursor: (cm) => {
774
const pos = cm.getCursor();
775
// The hitSide and sticky attributes of pos below
776
// are set when you manually move the cursor, rather than
777
// it moving due to typing. We check them to avoid
778
// confusion such as
779
// https://github.com/sagemathinc/cocalc/issues/4833
780
// and in that case move the cursor back.
781
if (
782
pos.line != last_cursor.line ||
783
(pos as { hitSide?: boolean }).hitSide ||
784
(pos as { sticky?: string }).sticky != null
785
) {
786
cm.setCursor(last_cursor);
787
} else {
788
last_cursor = pos;
789
}
790
},
791
change: (cm) => {
792
const pos = cm.getCursor();
793
const search = cm.getRange(cursor, pos);
794
set_mentions_search(search.trim().toLowerCase());
795
},
796
};
797
cm.current.on("cursorActivity", mentions_cursor_ref.current.cursor);
798
cm.current.on("change", mentions_cursor_ref.current.change);
799
}
800
801
function close_mentions() {
802
set_mentions(undefined);
803
if (cm.current != null) {
804
if (mentions_cursor_ref.current != null) {
805
cm.current.off("cursorActivity", mentions_cursor_ref.current.cursor);
806
cm.current.off("change", mentions_cursor_ref.current.change);
807
mentions_cursor_ref.current = undefined;
808
}
809
cm.current.focus();
810
}
811
}
812
813
// make sure that mentions is closed if we switch to another tab.
814
useEffect(() => {
815
if (mentions && !isVisible) {
816
close_mentions();
817
}
818
}, [isVisible]);
819
820
function render_mentions_popup() {
821
if (mentions == null || mentions_offset == null) return;
822
823
const items: Item[] = [];
824
for (const item of mentions) {
825
if (item.search?.indexOf(mentions_search) != -1) {
826
items.push(item);
827
}
828
}
829
if (items.length == 0) {
830
if (mentions.length == 0) {
831
// See https://github.com/sagemathinc/cocalc/issues/4909
832
close_mentions();
833
return;
834
}
835
}
836
837
return (
838
<Complete
839
items={items}
840
onCancel={close_mentions}
841
onSelect={(account_id) => {
842
if (mentions_cursor_ref.current == null) return;
843
const text =
844
"@" +
845
trunc_middle(redux.getStore("users").get_name(account_id), 64);
846
if (cm.current == null) return;
847
const from = mentions_cursor_ref.current.from;
848
const to = cm.current.getCursor();
849
cm.current.replaceRange(text + " ", from, to);
850
cm.current.markText(
851
from,
852
{ line: from.line, ch: from.ch + text.length },
853
{
854
atomic: true,
855
css: MENTION_CSS,
856
attributes: { account_id },
857
} as CodeMirror.TextMarkerOptions /* @types are out of date */,
858
);
859
close_mentions(); // must be after use of mentions_cursor_ref above.
860
cm.current.focus();
861
}}
862
offset={mentions_offset}
863
/>
864
);
865
}
866
867
const showInstructions = !!value?.trim();
868
869
let body: React.JSX.Element = (
870
<div style={{ height: showInstructions ? "calc(100% - 22px)" : "100%" }}>
871
{showInstructions ? render_instructions() : undefined}
872
<div
873
ref={divRef}
874
style={{
875
...(isFocusedStyle ? FOCUSED_STYLE : BLURED_STYLE),
876
...style,
877
...{
878
fontSize: `${fontSize ? fontSize : defaultFontSize}px`,
879
height,
880
},
881
}}
882
>
883
{render_mentions_popup()}
884
{cursors != null && cm.current != null && (
885
<Cursors cursors={cursors} codemirror={cm.current} />
886
)}
887
<textarea
888
style={{ display: "none" }}
889
ref={textarea_ref}
890
placeholder={placeholder}
891
/>
892
</div>
893
</div>
894
);
895
if (enableUpload) {
896
const event_handlers = {
897
complete: upload_complete,
898
sending: upload_sending,
899
error: (_, message) => {
900
actions?.set_error(`${message}`);
901
},
902
};
903
if (project_id == null || path == null) {
904
throw Error("project_id and path must be set if enableUploads is set.");
905
}
906
body = (
907
<BlobUpload
908
show_upload={false}
909
project_id={project_id}
910
event_handlers={event_handlers}
911
style={{ height: "100%", width: "100%" }}
912
dropzone_ref={dropzone_ref}
913
close_preview_ref={upload_close_preview_ref}
914
>
915
{body}
916
</BlobUpload>
917
);
918
}
919
920
return body;
921
}
922
923
function upload_temp_link(file): string {
924
return `[Uploading...]\(${file.name ?? file.upload?.filename ?? ""}\)`;
925
}
926
927
function upload_link(file): string {
928
const { url, dataURL, height, upload } = file;
929
if (!height && !dataURL?.startsWith("data:image")) {
930
return `[${upload.filename ? upload.filename : "file"}](${url})`;
931
} else {
932
return `![](${url})`;
933
}
934
}
935
936
function EnableInternetAccess({ project_id }: { project_id: string }) {
937
const haveInternetAccess = useProjectHasInternetAccess(project_id);
938
939
if (!haveInternetAccess) {
940
return <span> (enable the Internet Access upgrade to send emails)</span>;
941
} else {
942
return null;
943
}
944
}
945
946