Path: blob/master/src/packages/frontend/editors/slate/editable-markdown.tsx
5963 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// Component that allows WYSIWYG editing of markdown.67const EXPENSIVE_DEBUG = false;8// const EXPENSIVE_DEBUG = (window as any).cc != null && true; // EXTRA SLOW -- turn off before release!910import { delay } from "awaiting";11import { Map } from "immutable";12import { debounce, isEqual, throttle } from "lodash";13import {14MutableRefObject,15RefObject,16useCallback,17useEffect,18useMemo,19useRef,20useState,21} from "react";22import { CSS, React, useIsMountedRef } from "@cocalc/frontend/app-framework";23import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";24import { useMentionableUsers } from "@cocalc/frontend/editors/markdown-input/mentionable-users";25import { submit_mentions } from "@cocalc/frontend/editors/markdown-input/mentions";26import { EditorFunctions } from "@cocalc/frontend/editors/markdown-input/multimode";27import { SAVE_DEBOUNCE_MS } from "@cocalc/frontend/frame-editors/code-editor/const";28import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";29import { Path } from "@cocalc/frontend/frame-editors/frame-tree/path";30import { DEFAULT_FONT_SIZE } from "@cocalc/util/consts/ui";31import { EditorState } from "@cocalc/frontend/frame-editors/frame-tree/types";32import { markdown_to_html } from "@cocalc/frontend/markdown";33import Fragment, { FragmentId } from "@cocalc/frontend/misc/fragment-id";34import { Descendant, Editor, Range, Transforms, createEditor } from "slate";35import { resetSelection } from "./control";36import * as control from "./control";37import { useBroadcastCursors, useCursorDecorate } from "./cursors";38import { EditBar, useLinkURL, useListProperties, useMarks } from "./edit-bar";39import { Element } from "./element";40import { estimateSize } from "./elements";41import { createEmoji } from "./elements/emoji/index";42import { withInsertBreakHack } from "./elements/link/editable";43import { createMention } from "./elements/mention/editable";44import { Mention } from "./elements/mention/index";45import { withAutoFormat } from "./format";46import { getHandler as getKeyboardHandler } from "./keyboard";47import Leaf from "./leaf-with-cursor";48import { markdown_to_slate } from "./markdown-to-slate";49import { withNormalize } from "./normalize";50import { applyOperations, preserveScrollPosition } from "./operations";51import { withNonfatalRange } from "./patches";52import { withIsInline, withIsVoid } from "./plugins";53import { getScrollState, setScrollState } from "./scroll";54import { SearchHook, useSearch } from "./search";55import { slateDiff } from "./slate-diff";56import { useEmojis } from "./slate-emojis";57import { useMentions } from "./slate-mentions";58import { Editable, ReactEditor, Slate, withReact } from "./slate-react";59import { slate_to_markdown } from "./slate-to-markdown";60import { slatePointToMarkdownPosition } from "./sync";61import type { SlateEditor } from "./types";62import { Actions } from "./types";63import useUpload from "./upload";64import { ChangeContext } from "./use-change";6566export type { SlateEditor };6768// Whether or not to use windowing by default (=only rendering visible elements).69// This is unfortunately essential. I've tried everything I can think70// of to optimize slate without using windowing, and I just can't do it71// (and my attempts have always been misleading). I think the problem is72// that all the subtle computations that are done when selection, etc.73// gets updated, just have to be done one way or another anyways. Doing74// them without the framework of windowing is probably much harder.75// NOTE: we also fully use slate without windowing in many context in which76// we're editing small snippets of Markdown, e.g., Jupyter notebook markdown77// cells, task lists, whiteboard sticky notes, etc.78const USE_WINDOWING = true;79// const USE_WINDOWING = false;8081const STYLE: CSS = {82width: "100%",83overflow: "auto",84} as const;8586interface Props {87value?: string;88placeholder?: string;89actions?: Actions;90read_only?: boolean;91font_size?: number;92id?: string;93reload_images?: boolean; // I think this is used only to trigger an update94is_current?: boolean;95is_fullscreen?: boolean;96editor_state?: EditorState;97cursors?: Map<string, any>;98hidePath?: boolean;99disableWindowing?: boolean;100style?: CSS;101pageStyle?: CSS;102editBarStyle?: CSS;103onFocus?: () => void;104onBlur?: () => void;105autoFocus?: boolean;106hideSearch?: boolean;107saveDebounceMs?: number;108noVfill?: boolean;109divRef?: RefObject<HTMLDivElement>;110selectionRef?: MutableRefObject<{111setSelection: Function;112getSelection: Function;113} | null>;114height?: string; // css style or if "auto", then editor will grow to size of content instead of scrolling.115onCursorTop?: () => void;116onCursorBottom?: () => void;117isFocused?: boolean;118registerEditor?: (editor: EditorFunctions) => void;119unregisterEditor?: () => void;120getValueRef?: MutableRefObject<() => string>; // see comment in src/packages/frontend/editors/markdown-input/multimode.tsx121submitMentionsRef?: SubmitMentionsRef; // when called this will submit all mentions in the document, and also returns current value of the document (for compat with markdown editor). If not set, mentions are submitted when you create them. This prop is used mainly for implementing chat, which has a clear "time of submission".122editBar2?: MutableRefObject<React.JSX.Element | undefined>;123dirtyRef?: MutableRefObject<boolean>;124minimal?: boolean;125controlRef?: MutableRefObject<{126moveCursorToEndOfLine: () => void;127} | null>;128showEditBar?: boolean;129}130131export const EditableMarkdown: React.FC<Props> = React.memo((props: Props) => {132const {133actions: actions0,134autoFocus,135cursors,136dirtyRef,137disableWindowing = !USE_WINDOWING,138divRef,139editBar2,140editBarStyle,141editor_state,142font_size: font_size0,143getValueRef,144height,145hidePath,146hideSearch,147id: id0,148is_current,149is_fullscreen,150isFocused,151minimal,152noVfill,153onBlur,154onCursorBottom,155onCursorTop,156onFocus,157pageStyle,158placeholder,159read_only,160registerEditor,161saveDebounceMs = SAVE_DEBOUNCE_MS,162selectionRef,163style,164submitMentionsRef,165unregisterEditor,166value,167controlRef,168showEditBar,169} = props;170const { project_id, path, desc, isVisible } = useFrameContext();171const isMountedRef = useIsMountedRef();172const id = id0 ?? "";173const actions = actions0 ?? {};174const font_size = font_size0 ?? desc?.get("font_size") ?? DEFAULT_FONT_SIZE; // so possible to use without specifying this. TODO: should be from account settings175const [change, setChange] = useState<number>(0);176177const editor = useMemo(() => {178const ed = withNonfatalRange(179withInsertBreakHack(180withNormalize(181withAutoFormat(withIsInline(withIsVoid(withReact(createEditor())))),182),183),184) as SlateEditor;185actions.registerSlateEditor?.(id, ed);186187ed.getSourceValue = (fragment?) => {188return fragment ? slate_to_markdown(fragment) : ed.getMarkdownValue();189};190191// hasUnsavedChanges is true if the children changed192// since last time resetHasUnsavedChanges() was called.193ed._hasUnsavedChanges = false;194ed.resetHasUnsavedChanges = () => {195delete ed.markdownValue;196ed._hasUnsavedChanges = ed.children;197};198ed.hasUnsavedChanges = () => {199if (ed._hasUnsavedChanges === false) {200// initially no unsaved changes201return false;202}203return ed._hasUnsavedChanges !== ed.children;204};205206ed.markdownValue = value;207ed.getMarkdownValue = () => {208if (ed.markdownValue != null && !ed.hasUnsavedChanges()) {209return ed.markdownValue;210}211ed.markdownValue = slate_to_markdown(ed.children, {212cache: ed.syncCache,213});214return ed.markdownValue;215};216217ed.selectionIsCollapsed = () => {218return ed.selection == null || Range.isCollapsed(ed.selection);219};220221if (getValueRef != null) {222getValueRef.current = ed.getMarkdownValue;223}224225ed.getPlainValue = (fragment?) => {226const markdown = ed.getSourceValue(fragment);227return $("<div>" + markdown_to_html(markdown) + "</div>").text();228};229230ed.saveValue = (force?) => {231if (!force && !editor.hasUnsavedChanges()) {232return;233}234setSyncstringFromSlate();235actions.ensure_syncstring_is_saved?.();236};237238ed.syncCache = {};239if (selectionRef != null) {240selectionRef.current = {241setSelection: (selection: any) => {242if (!selection) return;243// We confirm that the selection is valid.244// If not, this will throw an error.245const { anchor, focus } = selection;246Editor.node(editor, anchor);247Editor.node(editor, focus);248ed.selection = selection;249},250getSelection: () => {251return ed.selection;252},253};254}255256if (controlRef != null) {257controlRef.current = {258moveCursorToEndOfLine: () => control.moveCursorToEndOfLine(ed),259};260}261262ed.onCursorBottom = onCursorBottom;263ed.onCursorTop = onCursorTop;264265return ed as SlateEditor;266}, []);267268// hook up to syncstring if available:269useEffect(() => {270if (actions._syncstring == null) return;271const beforeChange = setSyncstringFromSlateNOW;272const change = () => {273setEditorToValue(actions._syncstring.to_str());274};275actions._syncstring.on("before-change", beforeChange);276actions._syncstring.on("change", change);277return () => {278if (actions._syncstring == null) {279// This can be null if doc closed before unmounting. I hit a crash because of this in production.280return;281}282actions._syncstring.removeListener("before-change", beforeChange);283actions._syncstring.removeListener("change", change);284};285}, []);286287useEffect(() => {288if (registerEditor != null) {289registerEditor({290set_cursor: ({ y }) => {291// This is used for navigating in Jupyter. Of course cursors292// or NOT given by x,y positions in Slate, so we have to interpret293// this as follows, since that's what is used by our Jupyter actions.294// y = 0: top of document295// y = -1: bottom of document296let path;297if (y == 0) {298// top of doc299path = [0, 0];300} else if (y == -1) {301// bottom of doc302path = [editor.children.length - 1, 0];303} else {304return;305}306const focus = { path, offset: 0 };307Transforms.setSelection(editor, {308focus,309anchor: focus,310});311},312get_cursor: () => {313const point = editor.selection?.anchor;314if (point == null) {315return { x: 0, y: 0 };316}317const pos = slatePointToMarkdownPosition(editor, point);318if (pos == null) return { x: 0, y: 0 };319const { line, ch } = pos;320return { y: line, x: ch };321},322});323324return unregisterEditor;325}326}, [registerEditor, unregisterEditor]);327328useEffect(() => {329if (isFocused == null) return;330if (ReactEditor.isFocused(editor) != isFocused) {331if (isFocused) {332ReactEditor.focus(editor);333} else {334ReactEditor.blur(editor);335}336}337}, [isFocused]);338339const [editorValue, setEditorValue] = useState<Descendant[]>(() =>340markdown_to_slate(value ?? "", false, editor.syncCache),341);342343const rowSizeEstimator = useCallback((node) => {344return estimateSize({ node, fontSize: font_size });345}, []);346347const mentionableUsers = useMentionableUsers();348349const mentions = useMentions({350isVisible,351editor,352insertMention: (editor, account_id) => {353Transforms.insertNodes(editor, [354createMention(account_id),355{ text: " " },356]);357if (submitMentionsRef == null) {358// submit immediately, since no ref for controlling this:359submit_mentions(project_id, path, [{ account_id, description: "" }]);360}361},362matchingUsers: (search) => mentionableUsers(search, { avatarLLMSize: 16 }),363});364365const emojis = useEmojis({366editor,367insertEmoji: (editor, content, markup) => {368Transforms.insertNodes(editor, [369createEmoji(content, markup),370{ text: " " },371]);372},373});374375useEffect(() => {376if (submitMentionsRef != null) {377submitMentionsRef.current = (378fragmentId?: FragmentId,379onlyValue = false,380) => {381if (project_id == null || path == null) {382throw Error(383"project_id and path must be set in order to use mentions.",384);385}386387if (!onlyValue) {388const fragment_id = Fragment.encode(fragmentId);389390// No mentions in the document were already sent, so we send them now.391// We have to find all mentions in the document tree, and submit them.392const mentions: {393account_id: string;394description: string;395fragment_id: string;396}[] = [];397for (const [node, path] of Editor.nodes(editor, {398at: { path: [], offset: 0 },399match: (node) => node["type"] == "mention",400})) {401const [parent] = Editor.parent(editor, path);402mentions.push({403account_id: (node as Mention).account_id,404description: slate_to_markdown([parent]),405fragment_id,406});407}408409submit_mentions(project_id, path, mentions);410}411const value = editor.getMarkdownValue();412return value;413};414}415}, [submitMentionsRef]);416417const search: SearchHook = useSearch({ editor });418419const { marks, updateMarks } = useMarks(editor);420421const { linkURL, updateLinkURL } = useLinkURL(editor);422423const { listProperties, updateListProperties } = useListProperties(editor);424425const updateScrollState = useMemo(() => {426const { save_editor_state } = actions;427if (save_editor_state == null) return () => {};428if (disableWindowing) {429return throttle(() => {430if (!isMountedRef.current || !didRestoreScrollRef.current) return;431const scroll = scrollRef.current?.scrollTop;432if (scroll != null) {433save_editor_state(id, { scroll });434}435}, 250);436} else {437return throttle(() => {438if (!isMountedRef.current || !didRestoreScrollRef.current) return;439const scroll = getScrollState(editor);440if (scroll != null) {441save_editor_state(id, { scroll });442}443}, 250);444}445}, []);446447const broadcastCursors = useBroadcastCursors({448editor,449broadcastCursors: (x) => actions.set_cursor_locs?.(x),450});451452const cursorDecorate = useCursorDecorate({453editor,454cursors,455value: value ?? "",456search,457});458459const scrollRef = useRef<HTMLDivElement | null>(null);460const didRestoreScrollRef = useRef<boolean>(false);461const restoreScroll = useMemo(() => {462return async () => {463if (didRestoreScrollRef.current) return; // so we only ever do this once.464try {465const scroll = editor_state?.get("scroll");466if (!scroll) return;467468if (!disableWindowing) {469// Restore scroll for windowing470try {471await setScrollState(editor, scroll.toJS());472} catch (err) {473// could happen, e.g, if we change the format or change windowing.474console.log(`restoring scroll state -- ${err}`);475}476return;477}478479// Restore scroll for no windowing.480// scroll = the scrollTop position, though we wrap in481// exception since it could be anything.482await new Promise(requestAnimationFrame);483if (scrollRef.current == null || !isMountedRef.current) {484return;485}486const elt = $(scrollRef.current);487try {488elt.scrollTop(scroll);489// scrolling after image loads490elt.find("img").on("load", () => {491if (!isMountedRef.current) return;492elt.scrollTop(scroll);493});494} catch (_) {}495} finally {496didRestoreScrollRef.current = true;497setOpacity(undefined);498}499};500}, []);501502useEffect(() => {503if (actions._syncstring == null) {504setEditorToValue(value);505}506if (value != "Loading...") {507restoreScroll();508}509}, [value]);510511const lastSetValueRef = useRef<string | null>(null);512513const setSyncstringFromSlateNOW = () => {514if (actions.set_value == null) {515// no way to save the value out (e.g., just beginning to test516// using the component).517return;518}519if (!editor.hasUnsavedChanges()) {520// there are no changes to save521return;522}523524const markdown = editor.getMarkdownValue();525lastSetValueRef.current = markdown;526actions.set_value(markdown);527actions.syncstring_commit?.();528529// Record that the syncstring's value is now equal to ours:530editor.resetHasUnsavedChanges();531};532533const setSyncstringFromSlate = useMemo(() => {534if (saveDebounceMs) {535return debounce(setSyncstringFromSlateNOW, saveDebounceMs);536} else {537// this case shouldn't happen538return setSyncstringFromSlateNOW;539}540}, []);541542// We don't want to do saveValue too much, since it presumably can be slow,543// especially if the document is large. By debouncing, we only do this when544// the user pauses typing for a moment. Also, this avoids making too many commits.545// For tiny documents, user can make this small or even 0 to not debounce.546const saveValueDebounce = useMemo(() => {547if (saveDebounceMs != null && !saveDebounceMs) {548return () => editor.saveValue();549}550return debounce(551() => editor.saveValue(),552saveDebounceMs ?? SAVE_DEBOUNCE_MS,553);554}, [editor, saveDebounceMs]);555556function onKeyDown(e) {557if (read_only) {558e.preventDefault();559return;560}561562mentions.onKeyDown(e);563emojis.onKeyDown(e);564565if (e.defaultPrevented) return;566567if (!ReactEditor.isFocused(editor)) {568// E.g., when typing into a codemirror editor embedded569// in slate, we get the keystrokes, but at the same time570// the (contenteditable) editor itself is not focused.571return;572}573574const handler = getKeyboardHandler(e);575if (handler != null) {576const extra = { actions, id, search };577if (handler({ editor, extra })) {578e.preventDefault();579// key was handled.580return;581}582}583}584585useEffect(() => {586if (!is_current) {587if (editor.hasUnsavedChanges()) {588// just switched from focused to not and there was589// an unsaved change, so save state.590setSyncstringFromSlate();591actions.ensure_syncstring_is_saved?.();592}593}594}, [is_current]);595596const setEditorToValue = (value) => {597// console.log("setEditorToValue", { value, ed: editor.getMarkdownValue() });598if (lastSetValueRef.current == value) {599// this always happens once right after calling setSyncstringFromSlateNOW600// and it can randomly undo the last thing done, so don't do that!601// Also, this is an excellent optimization to do as well.602lastSetValueRef.current = null;603// console.log("setEditorToValue: skip");604return;605}606if (value == null) return;607if (value == editor.getMarkdownValue()) {608// nothing to do, and in fact doing something609// could be really annoying, since we don't want to610// autoformat via markdown everything immediately,611// as ambiguity is resolved while typing...612return;613}614const previousEditorValue = editor.children;615616// we only use the latest version of the document617// for caching purposes.618editor.syncCache = {};619// There is an assumption here that markdown_to_slate produces620// a document that is properly normalized. If that isn't the621// case, things will go horribly wrong, since it'll be impossible622// to convert the document to equal nextEditorValue. In the current623// code we do nomalize the output of markdown_to_slate, so624// that assumption is definitely satisfied.625const nextEditorValue = markdown_to_slate(value, false, editor.syncCache);626627try {628//const t = new Date();629630if (631// length is basically changing from "Loading..."; in this case, just reset everything, rather than transforming via operations (which preserves selection, etc.)632previousEditorValue.length <= 1 &&633nextEditorValue.length >= 40 &&634!ReactEditor.isFocused(editor)635) {636// This is a **MASSIVE** optimization. E.g., for a few thousand637// lines markdown file with about 500 top level elements (and lots638// of nested lists), applying operations below starting with the639// empty document can take 5-10 seconds, whereas just setting the640// value is instant. The drawback to directly setting the value641// is only that it messes up selection, and it's difficult642// to know where to move the selection to after changing.643// However, if the editor isn't focused, we don't have to worry644// about selection at all. TODO: we might be able to avoid the645// slateDiff stuff entirely via some tricky stuff, e.g., managing646// the cursor on the plain text side before/after the change, since647// codemirror is much faster att "setValueNoJump".648// The main time we use this optimization here is when opening the649// document in the first place, in which case we're converting650// the document from "Loading..." to it's initial value.651// Also, the default config is source text focused on the left and652// editable text acting as a preview on the right not focused, and653// again this makes things fastest.654// DRAWBACK: this doesn't preserve scroll position and breaks selection.655editor.syncCausedUpdate = true;656// we call "onChange" instead of setEditorValue, since657// we want all the change handler stuff to happen, e.g.,658// broadcasting cursors.659onChange(nextEditorValue);660// console.log("time to set directly ", new Date() - t);661} else {662const operations = slateDiff(previousEditorValue, nextEditorValue);663if (operations.length == 0) {664// no actual change needed.665return;666}667// Applying this operation below will trigger668// an onChange, which it is best to ignore to save time and669// also so we don't update the source editor (and other browsers)670// with a view with things like loan $'s escaped.'671editor.syncCausedUpdate = true;672// console.log("setEditorToValue: applying operations...", { operations });673preserveScrollPosition(editor, operations);674applyOperations(editor, operations);675// console.log("time to set via diff", new Date() - t);676}677} finally {678// In all cases, now that we have transformed editor into the new value679// let's save the fact that we haven't changed anything yet and we680// know the markdown state with zero changes. This is important, so681// we don't save out a change if we don't explicitly make one.682editor.resetHasUnsavedChanges();683editor.markdownValue = value;684}685686try {687if (editor.selection != null) {688// console.log("setEditorToValue: restore selection", editor.selection);689const { anchor, focus } = editor.selection;690Editor.node(editor, anchor);691Editor.node(editor, focus);692}693} catch (err) {694// TODO!695console.warn(696"slate - invalid selection after upstream patch. Resetting selection.",697err,698);699// set to beginning of document -- better than crashing.700resetSelection(editor);701}702703// if ((window as any).cc?.slate != null) {704// (window as any).cc.slate.eval = (s) => console.log(eval(s));705// }706707if (EXPENSIVE_DEBUG) {708const stringify = require("json-stable-stringify");709// We use JSON rather than isEqual here, since {foo:undefined}710// is not equal to {}, but they JSON the same, and this is711// fine for our purposes.712if (stringify(editor.children) != stringify(nextEditorValue)) {713// NOTE -- this does not 100% mean things are wrong. One case where714// this is expected behavior is if you put the cursor at the end of the715// document, say right after a horizontal rule, and then edit at the716// beginning of the document in another browser. The discrepancy717// is because a "fake paragraph" is placed at the end of the browser718// so your cursor has somewhere to go while you wait and type; however,719// that space is not really part of the markdown document, and it goes720// away when you move your cursor out of that space.721console.warn(722"**WARNING: slateDiff might not have properly transformed editor, though this may be fine. See window.diffBug **",723);724(window as any).diffBug = {725previousEditorValue,726nextEditorValue,727editorValue: editor.children,728stringify,729slateDiff,730applyOperations,731markdown_to_slate,732value,733};734}735}736};737738if ((window as any).cc != null) {739// This only gets set when running in cc-in-cc dev mode.740const { Editor, Node, Path, Range, Text } = require("slate");741(window as any).cc.slate = {742slateDiff,743editor,744actions,745editor_state,746Transforms,747ReactEditor,748Node,749Path,750Editor,751Range,752Text,753scrollRef,754applyOperations,755markdown_to_slate,756robot: async (s: string, iterations = 1) => {757/*758This little "robot" function is so you can run rtc on several browsers at once,759with each typing random stuff at random, and checking that their input worked760without loss of data.761*/762let inserted = "";763let focus = editor.selection?.focus;764if (focus == null) throw Error("must have selection");765let lastOffset = focus.offset;766for (let n = 0; n < iterations; n++) {767for (const x of s) {768// Transforms.setSelection(editor, {769// focus,770// anchor: focus,771// });772editor.insertText(x);773focus = editor.selection?.focus;774if (focus == null) throw Error("must have selection");775inserted += x;776const offset = focus.offset;777console.log(778`${779n + 1780}/${iterations}: inserted '${inserted}'; focus="${JSON.stringify(781editor.selection?.focus,782)}"`,783);784if (offset != (lastOffset ?? 0) + 1) {785console.error("SYNC FAIL!!", { offset, lastOffset });786return;787}788lastOffset = offset;789await delay(100 * Math.random());790if (Math.random() < 0.2) {791await delay(2 * SAVE_DEBOUNCE_MS);792}793}794}795console.log("SUCCESS!");796},797};798}799800editor.inverseSearch = async function inverseSearch(801force?: boolean,802): Promise<void> {803if (804!force &&805(is_fullscreen || !actions.get_matching_frame?.({ type: "cm" }))806) {807// - if user is fullscreen assume they just want to WYSIWYG edit808// and double click is to select. They can use sync button to809// force opening source panel.810// - if no source view, also don't do anything. We only let811// double click do something when there is an open source view,812// since double click is used for selecting.813return;814}815// delay to give double click a chance to change current focus.816// This takes surprisingly long!817let t = 0;818while (editor.selection == null) {819await delay(1);820t += 50;821if (t > 2000) return; // give up822}823const point = editor.selection?.anchor; // using anchor since double click selects word.824if (point == null) {825return;826}827const pos = slatePointToMarkdownPosition(editor, point);828if (pos == null) return;829actions.programmatical_goto_line?.(830pos.line + 1, // 1 based (TODO: could use codemirror option)831true,832false, // it is REALLY annoying to switch focus to be honest, e.g., because double click to select a word is common in WYSIWYG editing. If change this to true, make sure to put an extra always 50ms delay above due to focus even order.833undefined,834pos.ch,835);836};837838// WARNING: onChange does not fire immediately after changes occur.839// It is fired by react and happens in some potentialy later render840// loop after changes. Thus you absolutely can't depend on it in any841// way for checking if the state of the editor has changed. Instead842// check editor.children itself explicitly.843const onChange = (newEditorValue) => {844if (dirtyRef != null) {845// but see comment above846dirtyRef.current = true;847}848if (editor._hasUnsavedChanges === false) {849// just for initial change.850editor._hasUnsavedChanges = undefined;851}852if (!isMountedRef.current) return;853broadcastCursors();854updateMarks();855updateLinkURL();856updateListProperties();857// Track where the last editor selection was,858// since this is very useful to know, e.g., for859// understanding cursor movement, format fallback, etc.860// @ts-ignore861if (editor.lastSelection == null && editor.selection != null) {862// initialize863// @ts-ignore864editor.lastSelection = editor.curSelection = editor.selection;865}866// @ts-ignore867if (!isEqual(editor.selection, editor.curSelection)) {868// @ts-ignore869editor.lastSelection = editor.curSelection;870if (editor.selection != null) {871// @ts-ignore872editor.curSelection = editor.selection;873}874}875876if (editorValue === newEditorValue) {877// Editor didn't actually change value so nothing to do.878return;879}880881setEditorValue(newEditorValue);882setChange(change + 1);883884// Update mentions state whenever editor actually changes.885// This may pop up the mentions selector.886mentions.onChange();887// Similar for emojis.888emojis.onChange();889890if (!is_current) {891// Do not save when editor not current since user could be typing892// into another editor of the same underlying document. This will893// cause bugs (e.g., type, switch from slate to codemirror, type, and894// see what you typed into codemirror disappear). E.g., this895// happens due to a spurious change when the editor is defocused.896897return;898}899saveValueDebounce();900};901902useEffect(() => {903editor.syncCausedUpdate = false;904}, [editorValue]);905906const [opacity, setOpacity] = useState<number | undefined>(0);907908if (editBar2 != null) {909editBar2.current = (910<EditBar911Search={search.Search}912isCurrent={is_current}913marks={marks}914linkURL={linkURL}915listProperties={listProperties}916editor={editor}917style={{ ...editBarStyle, paddingRight: 0 }}918hideSearch={hideSearch}919/>920);921}922923let slate = (924<Slate editor={editor} value={editorValue} onChange={onChange}>925<Editable926placeholder={placeholder}927autoFocus={autoFocus}928className={929!disableWindowing && height != "auto" ? "smc-vfill" : undefined930}931readOnly={read_only}932renderElement={Element}933renderLeaf={Leaf}934onKeyDown={onKeyDown}935onBlur={() => {936editor.saveValue();937updateMarks();938onBlur?.();939}}940onFocus={() => {941updateMarks();942onFocus?.();943}}944decorate={cursorDecorate}945divref={scrollRef}946onScroll={updateScrollState}947style={948!disableWindowing949? undefined950: {951height,952position: "relative", // CRITICAL!!! Without this, editor will sometimes scroll the entire frame off the screen. Do NOT delete position:'relative'. 5+ hours of work to figure this out! Note that this isn't needed when using windowing above.953minWidth: "80%",954padding: "70px",955background: "white",956overflow:957height == "auto"958? "hidden" /* for height='auto' we never want a scrollbar */959: "auto" /* for this overflow, see https://github.com/ianstormtaylor/slate/issues/3706 */,960...pageStyle,961}962}963windowing={964!disableWindowing965? {966rowStyle: {967// WARNING: do *not* use margin in rowStyle.968padding: minimal ? 0 : "0 70px",969overflow: "hidden", // CRITICAL: this makes it so the div height accounts for margin of contents (e.g., p element has margin), so virtuoso can measure it correctly. Otherwise, things jump around like crazy.970minHeight: "1px", // virtuoso can't deal with 0-height items971},972marginTop: "40px",973marginBottom: "40px",974rowSizeEstimator,975}976: undefined977}978/>979</Slate>980);981let body = (982<ChangeContext.Provider value={{ change, editor }}>983<div984ref={divRef}985className={noVfill || height === "auto" ? undefined : "smc-vfill"}986style={{987overflow: noVfill || height === "auto" ? undefined : "auto",988backgroundColor: "white",989...style,990height,991minHeight: height == "auto" ? "50px" : undefined,992}}993>994{!hidePath && (995<Path is_current={is_current} path={path} project_id={project_id} />996)}997{showEditBar && (998<EditBar999Search={search.Search}1000isCurrent={is_current}1001marks={marks}1002linkURL={linkURL}1003listProperties={listProperties}1004editor={editor}1005style={editBarStyle}1006hideSearch={hideSearch}1007/>1008)}1009<div1010className={noVfill || height == "auto" ? undefined : "smc-vfill"}1011style={{1012...STYLE,1013fontSize: font_size,1014height,1015opacity,1016}}1017>1018{mentions.Mentions}1019{emojis.Emojis}1020{slate}1021</div>1022</div>1023</ChangeContext.Provider>1024);1025return useUpload(editor, body);1026});102710281029