Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/elements/codemirror.tsx
1698 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
import React, {
7
CSSProperties,
8
ReactNode,
9
useEffect,
10
useMemo,
11
useRef,
12
useState,
13
useCallback,
14
} from "react";
15
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
16
import { Transforms } from "slate";
17
import { ReactEditor } from "../slate-react";
18
import { fromTextArea, Editor, commands } from "codemirror";
19
import {
20
DARK_GREY_BORDER,
21
CODE_FOCUSED_COLOR,
22
CODE_FOCUSED_BACKGROUND,
23
SELECTED_COLOR,
24
} from "../util";
25
import { useFocused, useSelected, useSlate, useCollapsed } from "./hooks";
26
import {
27
moveCursorToBeginningOfBlock,
28
moveCursorUp,
29
moveCursorDown,
30
} from "../control";
31
import { selectAll } from "../keyboard/select-all";
32
import infoToMode from "./code-block/info-to-mode";
33
import { file_associations } from "@cocalc/frontend/file-associations";
34
import { useRedux } from "@cocalc/frontend/app-framework";
35
import { isEqual } from "lodash";
36
37
const STYLE = {
38
width: "100%",
39
overflow: "auto",
40
overflowX: "hidden",
41
border: "1px solid #dfdfdf",
42
borderRadius: "8px",
43
lineHeight: "1.21429em",
44
} as CSSProperties;
45
46
interface Props {
47
onChange?: (string) => void;
48
info?: string;
49
value: string;
50
onShiftEnter?: () => void;
51
onEscape?: () => void;
52
onBlur?: () => void;
53
onFocus?: () => void;
54
options?: { [option: string]: any };
55
isInline?: boolean; // impacts how cursor moves out of codemirror.
56
style?: CSSProperties;
57
addonBefore?: ReactNode;
58
addonAfter?: ReactNode;
59
}
60
61
export const SlateCodeMirror: React.FC<Props> = React.memo(
62
({
63
info,
64
value,
65
onChange,
66
onShiftEnter,
67
onEscape,
68
onBlur,
69
onFocus,
70
options: cmOptions,
71
isInline,
72
style,
73
addonBefore,
74
addonAfter,
75
}) => {
76
const focused = useFocused();
77
const selected = useSelected();
78
const editor = useSlate();
79
const collapsed = useCollapsed();
80
const { actions } = useFrameContext();
81
const { id } = useFrameContext();
82
const justBlurred = useRef<boolean>(false);
83
const cmRef = useRef<Editor | undefined>(undefined);
84
const [isFocused, setIsFocused] = useState<boolean>(!!cmOptions?.autofocus);
85
const textareaRef = useRef<any>(null);
86
87
const editor_settings = useRedux(["account", "editor_settings"]);
88
const options = useMemo(() => {
89
const selectAllKeyboard = (cm) => {
90
if (cm.getSelection() != cm.getValue()) {
91
// not everything is selected (or editor is empty), so
92
// select everything.
93
commands.selectAll(cm);
94
} else {
95
// everything selected, so now select all editor content.
96
// NOTE that this only makes sense if we change focus
97
// to the enclosing select editor, thus losing the
98
// cm editor focus, which is a bit weird.
99
ReactEditor.focus(editor);
100
selectAll(editor);
101
}
102
};
103
104
const bindings = editor_settings.get("bindings");
105
return {
106
...cmOptions,
107
autoCloseBrackets: editor_settings.get("auto_close_brackets", false),
108
lineWrapping: editor_settings.get("line_wrapping", true),
109
lineNumbers: false, // editor_settings.get("line_numbers", false), // disabled since breaks when scaling in whiteboard, etc. and is kind of weird in edit mode only.
110
matchBrackets: editor_settings.get("match_brackets", false),
111
theme: editor_settings.get("theme", "default"),
112
keyMap:
113
bindings == null || bindings == "standard" ? "default" : bindings,
114
// The two lines below MUST match with the useEffect above that reacts to changing info.
115
mode: cmOptions?.mode ?? infoToMode(info),
116
indentUnit:
117
cmOptions?.indentUnit ??
118
file_associations[info ?? ""]?.opts.indent_unit ??
119
4,
120
121
// NOTE: Using the inputStyle of "contenteditable" is challenging
122
// because we have to take care that copy doesn't end up being handled
123
// by slate and being wrong. In contrast, textarea does work fine for
124
// copy. However, textarea does NOT work when any CSS transforms
125
// are involved, and we use such transforms extensively in the whiteboard.
126
127
inputStyle: "contenteditable" as "contenteditable", // can't change because of whiteboard usage!
128
extraKeys: {
129
...cmOptions?.extraKeys,
130
"Shift-Enter": () => {
131
Transforms.move(editor, { distance: 1, unit: "line" });
132
ReactEditor.focus(editor, false, true);
133
onShiftEnter?.();
134
},
135
// We make it so doing select all when not everything is
136
// selected selects everything in this local Codemirror.
137
// Doing it *again* then selects the entire external slate editor.
138
"Cmd-A": selectAllKeyboard,
139
"Ctrl-A": selectAllKeyboard,
140
...(onEscape != null ? { Esc: onEscape } : undefined),
141
},
142
};
143
}, [editor_settings, cmOptions]);
144
145
const setCSS = useCallback(
146
(css) => {
147
if (cmRef.current == null) return;
148
$(cmRef.current.getWrapperElement()).css(css);
149
},
150
[cmRef],
151
);
152
153
const focusEditor = useCallback(
154
(forceCollapsed?) => {
155
if (editor.getIgnoreSelection()) return;
156
const cm = cmRef.current;
157
if (cm == null) return;
158
if (forceCollapsed || collapsed) {
159
// collapsed = single cursor, rather than a selection range.
160
// focus the CodeMirror editor
161
// It is critical to blur the Slate editor
162
// itself after focusing codemirror, since otherwise we
163
// get stuck in an infinite
164
// loop since slate is confused about whether or not it is
165
// blurring or getting focused, since codemirror is a contenteditable
166
// inside of the slate DOM tree. Hence this ReactEditor.blur:
167
cm.refresh();
168
cm.focus();
169
ReactEditor.blur(editor);
170
}
171
},
172
[collapsed, options.theme],
173
);
174
175
useEffect(() => {
176
if (focused && selected && !justBlurred.current) {
177
focusEditor();
178
}
179
}, [selected, focused, options.theme]);
180
181
// If the info line changes update the mode.
182
useEffect(() => {
183
const cm = cmRef.current;
184
if (cm == null) return;
185
cm.setOption("mode", infoToMode(info));
186
const indentUnit = file_associations[info ?? ""]?.opts.indent_unit ?? 4;
187
cm.setOption("indentUnit", indentUnit);
188
}, [info]);
189
190
useEffect(() => {
191
const node: HTMLTextAreaElement = textareaRef.current;
192
if (node == null) return;
193
194
const cm = (cmRef.current = fromTextArea(node, options));
195
196
// The Up/Down/Left/Right key handlers are potentially already
197
// taken by a keymap, so we have to add them explicitly using
198
// addKeyMap, so that they have top precedence. Otherwise, somewhat
199
// randomly, things will seem to "hang" and you get stuck, which
200
// is super annoying.
201
cm.addKeyMap(cursorHandlers(editor, isInline));
202
203
cm.on("change", (_, _changeObj) => {
204
if (onChange != null) {
205
onChange(cm.getValue());
206
}
207
});
208
209
if (onBlur != null) {
210
cm.on("blur", onBlur);
211
}
212
213
if (onFocus != null) {
214
cm.on("focus", onFocus);
215
}
216
217
cm.on("blur", () => {
218
justBlurred.current = true;
219
setTimeout(() => {
220
justBlurred.current = false;
221
}, 1);
222
setIsFocused(false);
223
});
224
225
cm.on("focus", () => {
226
setIsFocused(true);
227
focusEditor(true);
228
if (!justBlurred.current) {
229
setTimeout(() => focusEditor(true), 0);
230
}
231
});
232
233
cm.on("copy", (_, event) => {
234
// We tell slate to ignore this event.
235
// I couldn't find any way to get codemirror to allow the copy to happen,
236
// but at the same time to not let the event propogate. It seems like
237
// codemirror also would ignore the event, which isn't useful.
238
// @ts-ignore
239
event.slateIgnore = true;
240
});
241
242
(cm as any).undo = () => {
243
actions.undo(id);
244
};
245
(cm as any).redo = () => {
246
actions.redo(id);
247
};
248
// This enables other functionality (e.g., save).
249
(cm as any).cocalc_actions = actions;
250
251
// Make it so editor height matches text.
252
const css: any = {
253
height: "auto",
254
padding: "5px 15px",
255
};
256
setCSS(css);
257
cm.refresh();
258
259
return () => {
260
if (cmRef.current == null) return;
261
$(cmRef.current.getWrapperElement()).remove();
262
cmRef.current = undefined;
263
};
264
}, []);
265
266
useEffect(() => {
267
const cm = cmRef.current;
268
if (cm == null) return;
269
for (const key in options) {
270
const opt = options[key];
271
if (!isEqual(cm.options[key], opt)) {
272
if (opt != null) {
273
cm.setOption(key as any, opt);
274
}
275
}
276
}
277
}, [editor_settings]);
278
279
useEffect(() => {
280
cmRef.current?.setValueNoJump(value);
281
}, [value]);
282
283
const borderColor = isFocused
284
? CODE_FOCUSED_COLOR
285
: selected
286
? SELECTED_COLOR
287
: DARK_GREY_BORDER;
288
return (
289
<div
290
contentEditable={false}
291
style={{
292
...STYLE,
293
...{
294
border: `1px solid ${borderColor}`,
295
borderRadius: "8px",
296
},
297
...style,
298
position: "relative",
299
}}
300
className="smc-vfill"
301
>
302
{!isFocused && selected && !collapsed && (
303
<div
304
style={{
305
background: CODE_FOCUSED_BACKGROUND,
306
position: "absolute",
307
opacity: 0.5,
308
zIndex: 1,
309
top: 0,
310
left: 0,
311
right: 0,
312
bottom: 0,
313
}}
314
></div>
315
)}
316
{addonBefore}
317
<div
318
style={{
319
borderLeft: `10px solid ${
320
isFocused ? CODE_FOCUSED_COLOR : borderColor
321
}`,
322
}}
323
>
324
<textarea ref={textareaRef} defaultValue={value}></textarea>
325
</div>
326
{addonAfter}
327
</div>
328
);
329
},
330
);
331
332
// TODO: vim version of this...
333
334
function cursorHandlers(editor, isInline: boolean | undefined) {
335
const exitDown = (cm) => {
336
const cur = cm.getCursor();
337
const n = cm.lastLine();
338
const cur_line = cur?.line;
339
const cur_ch = cur?.ch;
340
const line = cm.getLine(n);
341
const line_length = line?.length;
342
if (cur_line === n && cur_ch === line_length) {
343
//Transforms.move(editor, { distance: 1, unit: "line" });
344
moveCursorDown(editor, true);
345
ReactEditor.focus(editor, false, true);
346
return true;
347
} else {
348
return false;
349
}
350
};
351
352
return {
353
Up: (cm) => {
354
const cur = cm.getCursor();
355
if (cur?.line === cm.firstLine() && cur?.ch == 0) {
356
// Transforms.move(editor, { distance: 1, unit: "line", reverse: true });
357
moveCursorUp(editor, true);
358
if (!isInline) {
359
moveCursorToBeginningOfBlock(editor);
360
}
361
ReactEditor.focus(editor, false, true);
362
} else {
363
commands.goLineUp(cm);
364
}
365
},
366
Left: (cm) => {
367
const cur = cm.getCursor();
368
if (cur?.line === cm.firstLine() && cur?.ch == 0) {
369
Transforms.move(editor, { distance: 1, unit: "line", reverse: true });
370
ReactEditor.focus(editor, false, true);
371
} else {
372
commands.goCharLeft(cm);
373
}
374
},
375
Right: (cm) => {
376
if (!exitDown(cm)) {
377
commands.goCharRight(cm);
378
}
379
},
380
Down: (cm) => {
381
if (!exitDown(cm)) {
382
commands.goLineDown(cm);
383
}
384
},
385
};
386
}
387
388