Path: blob/master/src/packages/frontend/editors/markdown-input/multimode.tsx
1691 views
/*1Edit with either plain text input **or** WYSIWYG slate-based input.2*/34import { Popover, Radio } from "antd";5import { Map as ImmutableMap, fromJS } from "immutable";6import LRU from "lru-cache";7import {8CSSProperties,9MutableRefObject,10ReactNode,11RefObject,12useEffect,13useMemo,14useRef,15useState,16} from "react";17import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";18import { Icon } from "@cocalc/frontend/components";19import { EditableMarkdown } from "@cocalc/frontend/editors/slate/editable-markdown";20import "@cocalc/frontend/editors/slate/elements/math/math-widget";21import { IS_MOBILE } from "@cocalc/frontend/feature";22import { SAVE_DEBOUNCE_MS } from "@cocalc/frontend/frame-editors/code-editor/const";23import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";24import { get_local_storage, set_local_storage } from "@cocalc/frontend/misc";25import { COLORS } from "@cocalc/util/theme";26import { BLURED_STYLE, FOCUSED_STYLE, MarkdownInput } from "./component";2728// NOTE: on mobile there is very little suppport for "editor" = "slate", but29// very good support for "markdown", hence the default below.3031export interface EditorFunctions {32set_cursor: (pos: { x?: number; y?: number }) => void;33get_cursor: () => { x: number; y: number };34}3536interface MultimodeState {37mode?: Mode;38markdown?: any;39editor?: any;40}4142const multimodeStateCache = new LRU<string, MultimodeState>({ max: 500 });4344// markdown uses codemirror45// editor uses slate. TODO: this should be "text", not "editor". Oops.46// UI equivalent:47// editor = "Text" = Slate/wysiwyg48// markdown = "Markdown"49const Modes = ["markdown", "editor"] as const;50export type Mode = (typeof Modes)[number];5152const LOCAL_STORAGE_KEY = "markdown-editor-mode";5354function getLocalStorageMode(): Mode | undefined {55const m = get_local_storage(LOCAL_STORAGE_KEY);56if (typeof m === "string" && Modes.includes(m as any)) {57return m as Mode;58}59}6061interface Props {62cacheId?: string; // unique **within this file**; the project_id and path are automatically also used63value?: string;64defaultMode?: Mode; // defaults to editor or whatever was last used (as stored in localStorage)65fixedMode?: Mode; // only use this mode; no option to switch66onChange: (value: string) => void;6768// use getValueRef to obtain a function getValueRef.current() that returns the current69// value of the editor *NOW*, without waiting for onChange. Even with saveDebounceMs=0,70// there is definitely no guarantee that onChange is always up to date, but definitely71// up to date values are required to implement realtime sync!72getValueRef?: MutableRefObject<() => string>;7374onModeChange?: (mode: Mode) => void;75onShiftEnter?: (value: string) => void;76placeholder?: string;77fontSize?: number;78height?: string; // css height and also "auto" is fully supported.79style?: CSSProperties;80modeSwitchStyle?: CSSProperties;81autoFocus?: boolean; // note - this is broken on safari for the slate editor, but works on chrome and firefox.82enableMentions?: boolean;83enableUpload?: 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.)84onUploadStart?: () => void;85onUploadEnd?: () => void;86submitMentionsRef?: SubmitMentionsRef;87extraHelp?: ReactNode;88hideHelp?: boolean;89// debounce how frequently get updates from onChange; if saveDebounceMs=0 get them on every change. Default is the global SAVE_DEBOUNCE_MS const.90// can be a little more frequent in case of shift or alt enter, or blur.91saveDebounceMs?: number;92onBlur?: () => void;93onFocus?: () => void;94minimal?: boolean;95editBarStyle?: CSSProperties;9697// onCursors is called when user cursor(s) move. "editable" mode only supports a single98// cursor right now, but "markdown" mode supports multiple cursors. An array is99// output in all cases. In editable mode, the cursor is positioned where it would be100// in the plain text.101onCursors?: (cursors: { x: number; y: number }[]) => void;102// If cursors are given, then they get rendered in the editor. This is a map103// from account_id to objects {x:number,y:number} that give the 0-based row and column104// in the plain markdown text, as of course output by onCursors above.105cursors?: ImmutableMap<string, any>;106noVfill?: boolean;107editorDivRef?: RefObject<HTMLDivElement>; // if in slate "editor" mode, this is the top-level div108cmOptions?: { [key: string]: any }; // used for codemirror options override above and account settings109// It is important to handle all of these, rather than trying to rely110// on some global keyboard shortcuts. E.g., in vim mode codemirror,111// user types ":w" in their editor and whole document should save112// to disk...113onUndo?: () => void; // called when user requests to undo114onRedo?: () => void; // called when user requests redo115onSave?: () => void; // called when user requests to save document116117compact?: boolean; // optimize for compact embedded usage.118119// onCursorTop and onCursorBottom are called when the cursor is on top line and goes up,120// so that client could move to another editor (e.g., in Jupyter this is how you move out121// of a cell to an adjacent cell).122onCursorTop?: () => void;123onCursorBottom?: () => void;124125// Declarative control of whether or not the editor is focused. Only has an impact126// if it is explicitly set to true or false.127isFocused?: boolean;128129registerEditor?: (editor: EditorFunctions) => void;130unregisterEditor?: () => void;131132// refresh codemirror if this changes133refresh?: any;134135overflowEllipsis?: boolean; // if true (the default!), show "..." button popping up all menu entries136137dirtyRef?: 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).138139controlRef?: MutableRefObject<any>;140}141142export default function MultiMarkdownInput({143autoFocus,144cacheId,145cmOptions,146compact,147cursors,148defaultMode,149dirtyRef,150editBarStyle,151editorDivRef,152enableMentions,153enableUpload = true,154extraHelp,155fixedMode,156fontSize,157getValueRef,158height = "auto",159hideHelp,160isFocused,161minimal,162modeSwitchStyle,163noVfill,164onBlur,165onChange,166onCursorBottom,167onCursors,168onCursorTop,169onFocus,170onModeChange,171onRedo,172onSave,173onShiftEnter,174onUndo,175onUploadEnd,176onUploadStart,177overflowEllipsis = true,178placeholder,179refresh,180registerEditor,181saveDebounceMs = SAVE_DEBOUNCE_MS,182style,183submitMentionsRef,184unregisterEditor,185value,186controlRef,187}: Props) {188const {189isFocused: isFocusedFrame,190isVisible,191project_id,192path,193} = useFrameContext();194195// We use refs for shiftEnter and onChange to be absolutely196// 100% certain that if either of these functions is changed,197// then the new function is used, even if the components198// implementing our markdown editor mess up somehow and hang on.199const onShiftEnterRef = useRef<any>(onShiftEnter);200useEffect(() => {201onShiftEnterRef.current = onShiftEnter;202}, [onShiftEnter]);203const onChangeRef = useRef<any>(onChange);204useEffect(() => {205onChangeRef.current = onChange;206}, [onChange]);207208const editBar2 = useRef<React.JSX.Element | undefined>(undefined);209210const getKey = () => `${project_id}${path}:${cacheId}`;211212function getCache() {213return cacheId == null ? undefined : multimodeStateCache.get(getKey());214}215216const [mode, setMode0] = useState<Mode>(217fixedMode ??218getCache()?.mode ??219defaultMode ??220getLocalStorageMode() ??221(IS_MOBILE ? "markdown" : "editor"),222);223224const [editBarPopover, setEditBarPopover] = useState<boolean>(false);225226useEffect(() => {227onModeChange?.(mode);228}, []);229230const setMode = (mode: Mode) => {231set_local_storage(LOCAL_STORAGE_KEY, mode);232setMode0(mode);233onModeChange?.(mode);234if (cacheId !== undefined) {235multimodeStateCache.set(`${project_id}${path}:${cacheId}`, {236...getCache(),237mode,238});239}240};241const [focused, setFocused] = useState<boolean>(!!autoFocus);242const ignoreBlur = useRef<boolean>(false);243244const cursorsMap = useMemo(() => {245return cursors == null ? undefined : fromJS(cursors);246}, [cursors]);247248const selectionRef = useRef<{249getSelection: Function;250setSelection: Function;251} | null>(null);252253useEffect(() => {254if (cacheId == null) {255return;256}257const cache = getCache();258if (cache?.[mode] != null && selectionRef.current != null) {259// restore selection on mount.260try {261selectionRef.current.setSelection(cache?.[mode]);262} catch (_err) {263// it might just be that the document isn't initialized yet264setTimeout(() => {265try {266selectionRef.current?.setSelection(cache?.[mode]);267} catch (_err2) {268// console.warn(_err2); // definitely don't need this.269// This is expected to fail, since the selection from last270// use will be invalid now if another user changed the271// document, etc., or you did in a different mode, possibly.272}273}, 100);274}275}276return () => {277if (selectionRef.current == null || cacheId == null) {278return;279}280const selection = selectionRef.current.getSelection();281multimodeStateCache.set(getKey(), {282...getCache(),283[mode]: selection,284});285};286}, [mode]);287288function toggleEditBarPopover() {289setEditBarPopover(!editBarPopover);290}291292function renderEditBarEllipsis() {293return (294<span style={{ fontWeight: 400 }}>295{"\u22EF"}296<Popover297open={isFocusedFrame && isVisible && editBarPopover}298content={299<div style={{ display: "flex" }}>300{editBar2.current}301<Icon302onClick={() => setEditBarPopover(false)}303name="times"304style={{305color: COLORS.GRAY_M,306marginTop: "5px",307}}308/>309</div>310}311/>312</span>313);314}315316return (317<div318style={{319position: "relative",320width: "100%",321height: "100%",322...(minimal323? undefined324: {325overflow: "hidden",326background: "white",327color: "black",328...(focused ? FOCUSED_STYLE : BLURED_STYLE),329}),330}}331>332<div333onMouseDown={() => {334// Clicking the checkbox blurs the edit field, but335// this is the one case we do NOT want to trigger the336// onBlur callback, since that would make switching337// back and forth between edit modes impossible.338ignoreBlur.current = true;339setTimeout(() => (ignoreBlur.current = false), 100);340}}341onTouchStart={() => {342ignoreBlur.current = true;343setTimeout(() => (ignoreBlur.current = false), 100);344}}345>346{!fixedMode && (347<div348style={{349background: "white",350color: COLORS.GRAY_M,351...(mode == "editor" || hideHelp352? {353float: "right",354position: "relative",355zIndex: 1,356}357: { float: "right" }),358...modeSwitchStyle,359}}360>361<Radio.Group362options={[363...(overflowEllipsis && mode == "editor"364? [365{366label: renderEditBarEllipsis(),367value: "menu",368style: {369backgroundColor: editBarPopover370? COLORS.GRAY_L371: "white",372paddingLeft: 10,373paddingRight: 10,374},375},376]377: []),378// fontWeight is needed to undo a stupid conflict with bootstrap css, which will go away when we get rid of that ancient nonsense.379{380label: <span style={{ fontWeight: 400 }}>Text</span>,381value: "editor",382},383{384label: <span style={{ fontWeight: 400 }}>Markdown</span>,385value: "markdown",386},387]}388onChange={(e) => {389const mode = e.target.value;390if (mode === "menu") {391toggleEditBarPopover();392} else {393setMode(mode as Mode);394}395}}396value={mode}397optionType="button"398size="small"399buttonStyle="solid"400style={{ display: "block" }}401/>402</div>403)}404</div>405{mode === "markdown" ? (406<MarkdownInput407divRef={editorDivRef}408selectionRef={selectionRef}409value={value}410onChange={(value) => {411onChangeRef.current?.(value);412}}413saveDebounceMs={saveDebounceMs}414getValueRef={getValueRef}415project_id={project_id}416path={path}417enableUpload={enableUpload}418onUploadStart={onUploadStart}419onUploadEnd={onUploadEnd}420enableMentions={enableMentions}421onShiftEnter={(value) => {422onShiftEnterRef.current?.(value);423}}424placeholder={placeholder ?? "Type markdown..."}425fontSize={fontSize}426cmOptions={cmOptions}427height={height}428style={style}429autoFocus={focused}430submitMentionsRef={submitMentionsRef}431extraHelp={extraHelp}432hideHelp={hideHelp}433onBlur={(value) => {434onChangeRef.current?.(value);435if (!ignoreBlur.current) {436onBlur?.();437}438}}439onFocus={onFocus}440onSave={onSave}441onUndo={onUndo}442onRedo={onRedo}443onCursors={onCursors}444cursors={cursorsMap}445onCursorTop={onCursorTop}446onCursorBottom={onCursorBottom}447isFocused={isFocused}448registerEditor={registerEditor}449unregisterEditor={unregisterEditor}450refresh={refresh}451compact={compact}452dirtyRef={dirtyRef}453/>454) : undefined}455{mode === "editor" ? (456<div457style={{458height: height ?? "100%",459width: "100%",460fontSize: "14px" /* otherwise button bar can be skewed */,461...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.462}}463className={height != "auto" ? "smc-vfill" : undefined}464>465<EditableMarkdown466selectionRef={selectionRef}467divRef={editorDivRef}468noVfill={noVfill}469value={value}470is_current={true}471hidePath472disableWindowing={473true /* I tried making this false when height != 'auto', but then *clicking to set selection* doesn't work at least for task list.*/474}475style={476minimal477? { background: undefined, backgroundColor: undefined }478: undefined479}480pageStyle={481minimal482? { background: undefined, padding: 0 }483: { padding: "5px 15px" }484}485minimal={minimal}486height={height}487editBarStyle={488{489paddingRight: "127px",490...editBarStyle,491} /* 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 */492}493saveDebounceMs={saveDebounceMs}494getValueRef={getValueRef}495actions={{496set_value: (value) => {497onChangeRef.current?.(value);498},499shiftEnter: (value) => {500onChangeRef.current?.(value);501onShiftEnterRef.current?.(value);502},503altEnter: (value) => {504onChangeRef.current?.(value);505setMode("markdown");506},507set_cursor_locs: onCursors,508undo: onUndo,509redo: onRedo,510save: onSave as any,511}}512cursors={cursorsMap}513font_size={fontSize}514autoFocus={focused}515onFocus={() => {516setFocused(true);517onFocus?.();518}}519onBlur={() => {520setFocused(false);521if (!ignoreBlur.current) {522onBlur?.();523}524}}525hideSearch526onCursorTop={onCursorTop}527onCursorBottom={onCursorBottom}528isFocused={isFocused}529registerEditor={registerEditor}530unregisterEditor={unregisterEditor}531placeholder={placeholder ?? "Type text..."}532submitMentionsRef={submitMentionsRef}533editBar2={editBar2}534dirtyRef={dirtyRef}535controlRef={controlRef}536/>537</div>538) : undefined}539</div>540);541}542543544