Path: blob/master/src/packages/frontend/editors/slate/elements/codemirror.tsx
1698 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import React, {6CSSProperties,7ReactNode,8useEffect,9useMemo,10useRef,11useState,12useCallback,13} from "react";14import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";15import { Transforms } from "slate";16import { ReactEditor } from "../slate-react";17import { fromTextArea, Editor, commands } from "codemirror";18import {19DARK_GREY_BORDER,20CODE_FOCUSED_COLOR,21CODE_FOCUSED_BACKGROUND,22SELECTED_COLOR,23} from "../util";24import { useFocused, useSelected, useSlate, useCollapsed } from "./hooks";25import {26moveCursorToBeginningOfBlock,27moveCursorUp,28moveCursorDown,29} from "../control";30import { selectAll } from "../keyboard/select-all";31import infoToMode from "./code-block/info-to-mode";32import { file_associations } from "@cocalc/frontend/file-associations";33import { useRedux } from "@cocalc/frontend/app-framework";34import { isEqual } from "lodash";3536const STYLE = {37width: "100%",38overflow: "auto",39overflowX: "hidden",40border: "1px solid #dfdfdf",41borderRadius: "8px",42lineHeight: "1.21429em",43} as CSSProperties;4445interface Props {46onChange?: (string) => void;47info?: string;48value: string;49onShiftEnter?: () => void;50onEscape?: () => void;51onBlur?: () => void;52onFocus?: () => void;53options?: { [option: string]: any };54isInline?: boolean; // impacts how cursor moves out of codemirror.55style?: CSSProperties;56addonBefore?: ReactNode;57addonAfter?: ReactNode;58}5960export const SlateCodeMirror: React.FC<Props> = React.memo(61({62info,63value,64onChange,65onShiftEnter,66onEscape,67onBlur,68onFocus,69options: cmOptions,70isInline,71style,72addonBefore,73addonAfter,74}) => {75const focused = useFocused();76const selected = useSelected();77const editor = useSlate();78const collapsed = useCollapsed();79const { actions } = useFrameContext();80const { id } = useFrameContext();81const justBlurred = useRef<boolean>(false);82const cmRef = useRef<Editor | undefined>(undefined);83const [isFocused, setIsFocused] = useState<boolean>(!!cmOptions?.autofocus);84const textareaRef = useRef<any>(null);8586const editor_settings = useRedux(["account", "editor_settings"]);87const options = useMemo(() => {88const selectAllKeyboard = (cm) => {89if (cm.getSelection() != cm.getValue()) {90// not everything is selected (or editor is empty), so91// select everything.92commands.selectAll(cm);93} else {94// everything selected, so now select all editor content.95// NOTE that this only makes sense if we change focus96// to the enclosing select editor, thus losing the97// cm editor focus, which is a bit weird.98ReactEditor.focus(editor);99selectAll(editor);100}101};102103const bindings = editor_settings.get("bindings");104return {105...cmOptions,106autoCloseBrackets: editor_settings.get("auto_close_brackets", false),107lineWrapping: editor_settings.get("line_wrapping", true),108lineNumbers: false, // editor_settings.get("line_numbers", false), // disabled since breaks when scaling in whiteboard, etc. and is kind of weird in edit mode only.109matchBrackets: editor_settings.get("match_brackets", false),110theme: editor_settings.get("theme", "default"),111keyMap:112bindings == null || bindings == "standard" ? "default" : bindings,113// The two lines below MUST match with the useEffect above that reacts to changing info.114mode: cmOptions?.mode ?? infoToMode(info),115indentUnit:116cmOptions?.indentUnit ??117file_associations[info ?? ""]?.opts.indent_unit ??1184,119120// NOTE: Using the inputStyle of "contenteditable" is challenging121// because we have to take care that copy doesn't end up being handled122// by slate and being wrong. In contrast, textarea does work fine for123// copy. However, textarea does NOT work when any CSS transforms124// are involved, and we use such transforms extensively in the whiteboard.125126inputStyle: "contenteditable" as "contenteditable", // can't change because of whiteboard usage!127extraKeys: {128...cmOptions?.extraKeys,129"Shift-Enter": () => {130Transforms.move(editor, { distance: 1, unit: "line" });131ReactEditor.focus(editor, false, true);132onShiftEnter?.();133},134// We make it so doing select all when not everything is135// selected selects everything in this local Codemirror.136// Doing it *again* then selects the entire external slate editor.137"Cmd-A": selectAllKeyboard,138"Ctrl-A": selectAllKeyboard,139...(onEscape != null ? { Esc: onEscape } : undefined),140},141};142}, [editor_settings, cmOptions]);143144const setCSS = useCallback(145(css) => {146if (cmRef.current == null) return;147$(cmRef.current.getWrapperElement()).css(css);148},149[cmRef],150);151152const focusEditor = useCallback(153(forceCollapsed?) => {154if (editor.getIgnoreSelection()) return;155const cm = cmRef.current;156if (cm == null) return;157if (forceCollapsed || collapsed) {158// collapsed = single cursor, rather than a selection range.159// focus the CodeMirror editor160// It is critical to blur the Slate editor161// itself after focusing codemirror, since otherwise we162// get stuck in an infinite163// loop since slate is confused about whether or not it is164// blurring or getting focused, since codemirror is a contenteditable165// inside of the slate DOM tree. Hence this ReactEditor.blur:166cm.refresh();167cm.focus();168ReactEditor.blur(editor);169}170},171[collapsed, options.theme],172);173174useEffect(() => {175if (focused && selected && !justBlurred.current) {176focusEditor();177}178}, [selected, focused, options.theme]);179180// If the info line changes update the mode.181useEffect(() => {182const cm = cmRef.current;183if (cm == null) return;184cm.setOption("mode", infoToMode(info));185const indentUnit = file_associations[info ?? ""]?.opts.indent_unit ?? 4;186cm.setOption("indentUnit", indentUnit);187}, [info]);188189useEffect(() => {190const node: HTMLTextAreaElement = textareaRef.current;191if (node == null) return;192193const cm = (cmRef.current = fromTextArea(node, options));194195// The Up/Down/Left/Right key handlers are potentially already196// taken by a keymap, so we have to add them explicitly using197// addKeyMap, so that they have top precedence. Otherwise, somewhat198// randomly, things will seem to "hang" and you get stuck, which199// is super annoying.200cm.addKeyMap(cursorHandlers(editor, isInline));201202cm.on("change", (_, _changeObj) => {203if (onChange != null) {204onChange(cm.getValue());205}206});207208if (onBlur != null) {209cm.on("blur", onBlur);210}211212if (onFocus != null) {213cm.on("focus", onFocus);214}215216cm.on("blur", () => {217justBlurred.current = true;218setTimeout(() => {219justBlurred.current = false;220}, 1);221setIsFocused(false);222});223224cm.on("focus", () => {225setIsFocused(true);226focusEditor(true);227if (!justBlurred.current) {228setTimeout(() => focusEditor(true), 0);229}230});231232cm.on("copy", (_, event) => {233// We tell slate to ignore this event.234// I couldn't find any way to get codemirror to allow the copy to happen,235// but at the same time to not let the event propogate. It seems like236// codemirror also would ignore the event, which isn't useful.237// @ts-ignore238event.slateIgnore = true;239});240241(cm as any).undo = () => {242actions.undo(id);243};244(cm as any).redo = () => {245actions.redo(id);246};247// This enables other functionality (e.g., save).248(cm as any).cocalc_actions = actions;249250// Make it so editor height matches text.251const css: any = {252height: "auto",253padding: "5px 15px",254};255setCSS(css);256cm.refresh();257258return () => {259if (cmRef.current == null) return;260$(cmRef.current.getWrapperElement()).remove();261cmRef.current = undefined;262};263}, []);264265useEffect(() => {266const cm = cmRef.current;267if (cm == null) return;268for (const key in options) {269const opt = options[key];270if (!isEqual(cm.options[key], opt)) {271if (opt != null) {272cm.setOption(key as any, opt);273}274}275}276}, [editor_settings]);277278useEffect(() => {279cmRef.current?.setValueNoJump(value);280}, [value]);281282const borderColor = isFocused283? CODE_FOCUSED_COLOR284: selected285? SELECTED_COLOR286: DARK_GREY_BORDER;287return (288<div289contentEditable={false}290style={{291...STYLE,292...{293border: `1px solid ${borderColor}`,294borderRadius: "8px",295},296...style,297position: "relative",298}}299className="smc-vfill"300>301{!isFocused && selected && !collapsed && (302<div303style={{304background: CODE_FOCUSED_BACKGROUND,305position: "absolute",306opacity: 0.5,307zIndex: 1,308top: 0,309left: 0,310right: 0,311bottom: 0,312}}313></div>314)}315{addonBefore}316<div317style={{318borderLeft: `10px solid ${319isFocused ? CODE_FOCUSED_COLOR : borderColor320}`,321}}322>323<textarea ref={textareaRef} defaultValue={value}></textarea>324</div>325{addonAfter}326</div>327);328},329);330331// TODO: vim version of this...332333function cursorHandlers(editor, isInline: boolean | undefined) {334const exitDown = (cm) => {335const cur = cm.getCursor();336const n = cm.lastLine();337const cur_line = cur?.line;338const cur_ch = cur?.ch;339const line = cm.getLine(n);340const line_length = line?.length;341if (cur_line === n && cur_ch === line_length) {342//Transforms.move(editor, { distance: 1, unit: "line" });343moveCursorDown(editor, true);344ReactEditor.focus(editor, false, true);345return true;346} else {347return false;348}349};350351return {352Up: (cm) => {353const cur = cm.getCursor();354if (cur?.line === cm.firstLine() && cur?.ch == 0) {355// Transforms.move(editor, { distance: 1, unit: "line", reverse: true });356moveCursorUp(editor, true);357if (!isInline) {358moveCursorToBeginningOfBlock(editor);359}360ReactEditor.focus(editor, false, true);361} else {362commands.goLineUp(cm);363}364},365Left: (cm) => {366const cur = cm.getCursor();367if (cur?.line === cm.firstLine() && cur?.ch == 0) {368Transforms.move(editor, { distance: 1, unit: "line", reverse: true });369ReactEditor.focus(editor, false, true);370} else {371commands.goCharLeft(cm);372}373},374Right: (cm) => {375if (!exitDown(cm)) {376commands.goCharRight(cm);377}378},379Down: (cm) => {380if (!exitDown(cm)) {381commands.goLineDown(cm);382}383},384};385}386387388