Path: blob/master/src/packages/frontend/editors/slate/editable-markdown.tsx
1691 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 { EditorState } from "@cocalc/frontend/frame-editors/frame-tree/types";31import { markdown_to_html } from "@cocalc/frontend/markdown";32import Fragment, { FragmentId } from "@cocalc/frontend/misc/fragment-id";33import { Descendant, Editor, Range, Transforms, createEditor } from "slate";34import { resetSelection } from "./control";35import * as control from "./control";36import { useBroadcastCursors, useCursorDecorate } from "./cursors";37import { EditBar, useLinkURL, useListProperties, useMarks } from "./edit-bar";38import { Element } from "./element";39import { estimateSize } from "./elements";40import { createEmoji } from "./elements/emoji/index";41import { withInsertBreakHack } from "./elements/link/editable";42import { createMention } from "./elements/mention/editable";43import { Mention } from "./elements/mention/index";44import { withAutoFormat } from "./format";45import { getHandler as getKeyboardHandler } from "./keyboard";46import Leaf from "./leaf-with-cursor";47import { markdown_to_slate } from "./markdown-to-slate";48import { withNormalize } from "./normalize";49import { applyOperations, preserveScrollPosition } from "./operations";50import { withNonfatalRange } from "./patches";51import { withIsInline, withIsVoid } from "./plugins";52import { getScrollState, setScrollState } from "./scroll";53import { SearchHook, useSearch } from "./search";54import { slateDiff } from "./slate-diff";55import { useEmojis } from "./slate-emojis";56import { useMentions } from "./slate-mentions";57import { Editable, ReactEditor, Slate, withReact } from "./slate-react";58import { slate_to_markdown } from "./slate-to-markdown";59import { slatePointToMarkdownPosition } from "./sync";60import type { SlateEditor } from "./types";61import { Actions } from "./types";62import useUpload from "./upload";63import { ChangeContext } from "./use-change";6465export type { SlateEditor };6667// Whether or not to use windowing by default (=only rendering visible elements).68// This is unfortunately essential. I've tried everything I can think69// of to optimize slate without using windowing, and I just can't do it70// (and my attempts have always been misleading). I think the problem is71// that all the subtle computations that are done when selection, etc.72// gets updated, just have to be done one way or another anyways. Doing73// them without the framework of windowing is probably much harder.74// NOTE: we also fully use slate without windowing in many context in which75// we're editing small snippets of Markdown, e.g., Jupyter notebook markdown76// cells, task lists, whiteboard sticky notes, etc.77const USE_WINDOWING = true;78// const USE_WINDOWING = false;7980const STYLE: CSS = {81width: "100%",82overflow: "auto",83} as const;8485interface Props {86value?: string;87placeholder?: string;88actions?: Actions;89read_only?: boolean;90font_size?: number;91id?: string;92reload_images?: boolean; // I think this is used only to trigger an update93is_current?: boolean;94is_fullscreen?: boolean;95editor_state?: EditorState;96cursors?: Map<string, any>;97hidePath?: boolean;98disableWindowing?: boolean;99style?: CSS;100pageStyle?: CSS;101editBarStyle?: CSS;102onFocus?: () => void;103onBlur?: () => void;104autoFocus?: boolean;105hideSearch?: boolean;106saveDebounceMs?: number;107noVfill?: boolean;108divRef?: RefObject<HTMLDivElement>;109selectionRef?: MutableRefObject<{110setSelection: Function;111getSelection: Function;112} | null>;113height?: string; // css style or if "auto", then editor will grow to size of content instead of scrolling.114onCursorTop?: () => void;115onCursorBottom?: () => void;116isFocused?: boolean;117registerEditor?: (editor: EditorFunctions) => void;118unregisterEditor?: () => void;119getValueRef?: MutableRefObject<() => string>; // see comment in src/packages/frontend/editors/markdown-input/multimode.tsx120submitMentionsRef?: 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".121editBar2?: MutableRefObject<React.JSX.Element | undefined>;122dirtyRef?: MutableRefObject<boolean>;123minimal?: boolean;124controlRef?: MutableRefObject<{125moveCursorToEndOfLine: () => void;126} | null>;127showEditBar?: boolean;128}129130export const EditableMarkdown: React.FC<Props> = React.memo((props: Props) => {131const {132actions: actions0,133autoFocus,134cursors,135dirtyRef,136disableWindowing = !USE_WINDOWING,137divRef,138editBar2,139editBarStyle,140editor_state,141font_size: font_size0,142getValueRef,143height,144hidePath,145hideSearch,146id: id0,147is_current,148is_fullscreen,149isFocused,150minimal,151noVfill,152onBlur,153onCursorBottom,154onCursorTop,155onFocus,156pageStyle,157placeholder,158read_only,159registerEditor,160saveDebounceMs = SAVE_DEBOUNCE_MS,161selectionRef,162style,163submitMentionsRef,164unregisterEditor,165value,166controlRef,167showEditBar,168} = props;169const { project_id, path, desc, isVisible } = useFrameContext();170const isMountedRef = useIsMountedRef();171const id = id0 ?? "";172const actions = actions0 ?? {};173const font_size = font_size0 ?? desc?.get("font_size") ?? 14; // so possible to use without specifying this. TODO: should be from account settings174const [change, setChange] = useState<number>(0);175176const editor = useMemo(() => {177const ed = withNonfatalRange(178withInsertBreakHack(179withNormalize(180withAutoFormat(withIsInline(withIsVoid(withReact(createEditor())))),181),182),183) as SlateEditor;184actions.registerSlateEditor?.(id, ed);185186ed.getSourceValue = (fragment?) => {187return fragment ? slate_to_markdown(fragment) : ed.getMarkdownValue();188};189190// hasUnsavedChanges is true if the children changed191// since last time resetHasUnsavedChanges() was called.192ed._hasUnsavedChanges = false;193ed.resetHasUnsavedChanges = () => {194delete ed.markdownValue;195ed._hasUnsavedChanges = ed.children;196};197ed.hasUnsavedChanges = () => {198if (ed._hasUnsavedChanges === false) {199// initially no unsaved changes200return false;201}202return ed._hasUnsavedChanges !== ed.children;203};204205ed.markdownValue = value;206ed.getMarkdownValue = () => {207if (ed.markdownValue != null && !ed.hasUnsavedChanges()) {208return ed.markdownValue;209}210ed.markdownValue = slate_to_markdown(ed.children, {211cache: ed.syncCache,212});213return ed.markdownValue;214};215216ed.selectionIsCollapsed = () => {217return ed.selection == null || Range.isCollapsed(ed.selection);218};219220if (getValueRef != null) {221getValueRef.current = ed.getMarkdownValue;222}223224ed.getPlainValue = (fragment?) => {225const markdown = ed.getSourceValue(fragment);226return $("<div>" + markdown_to_html(markdown) + "</div>").text();227};228229ed.saveValue = (force?) => {230if (!force && !editor.hasUnsavedChanges()) {231return;232}233setSyncstringFromSlate();234actions.ensure_syncstring_is_saved?.();235};236237ed.syncCache = {};238if (selectionRef != null) {239selectionRef.current = {240setSelection: (selection: any) => {241if (!selection) return;242// We confirm that the selection is valid.243// If not, this will throw an error.244const { anchor, focus } = selection;245Editor.node(editor, anchor);246Editor.node(editor, focus);247ed.selection = selection;248},249getSelection: () => {250return ed.selection;251},252};253}254255if (controlRef != null) {256controlRef.current = {257moveCursorToEndOfLine: () => control.moveCursorToEndOfLine(ed),258};259}260261ed.onCursorBottom = onCursorBottom;262ed.onCursorTop = onCursorTop;263264return ed as SlateEditor;265}, []);266267// hook up to syncstring if available:268useEffect(() => {269if (actions._syncstring == null) return;270const beforeChange = setSyncstringFromSlateNOW;271const change = () => {272setEditorToValue(actions._syncstring.to_str());273};274actions._syncstring.on("before-change", beforeChange);275actions._syncstring.on("change", change);276return () => {277if (actions._syncstring == null) {278// This can be null if doc closed before unmounting. I hit a crash because of this in production.279return;280}281actions._syncstring.removeListener("before-change", beforeChange);282actions._syncstring.removeListener("change", change);283};284}, []);285286useEffect(() => {287if (registerEditor != null) {288registerEditor({289set_cursor: ({ y }) => {290// This is used for navigating in Jupyter. Of course cursors291// or NOT given by x,y positions in Slate, so we have to interpret292// this as follows, since that's what is used by our Jupyter actions.293// y = 0: top of document294// y = -1: bottom of document295let path;296if (y == 0) {297// top of doc298path = [0, 0];299} else if (y == -1) {300// bottom of doc301path = [editor.children.length - 1, 0];302} else {303return;304}305const focus = { path, offset: 0 };306Transforms.setSelection(editor, {307focus,308anchor: focus,309});310},311get_cursor: () => {312const point = editor.selection?.anchor;313if (point == null) {314return { x: 0, y: 0 };315}316const pos = slatePointToMarkdownPosition(editor, point);317if (pos == null) return { x: 0, y: 0 };318const { line, ch } = pos;319return { y: line, x: ch };320},321});322323return unregisterEditor;324}325}, [registerEditor, unregisterEditor]);326327useEffect(() => {328if (isFocused == null) return;329if (ReactEditor.isFocused(editor) != isFocused) {330if (isFocused) {331ReactEditor.focus(editor);332} else {333ReactEditor.blur(editor);334}335}336}, [isFocused]);337338const [editorValue, setEditorValue] = useState<Descendant[]>(() =>339markdown_to_slate(value ?? "", false, editor.syncCache),340);341342const rowSizeEstimator = useCallback((node) => {343return estimateSize({ node, fontSize: font_size });344}, []);345346const mentionableUsers = useMentionableUsers();347348const mentions = useMentions({349isVisible,350editor,351insertMention: (editor, account_id) => {352Transforms.insertNodes(editor, [353createMention(account_id),354{ text: " " },355]);356if (submitMentionsRef == null) {357// submit immediately, since no ref for controlling this:358submit_mentions(project_id, path, [{ account_id, description: "" }]);359}360},361matchingUsers: (search) => mentionableUsers(search, { avatarLLMSize: 16 }),362});363364const emojis = useEmojis({365editor,366insertEmoji: (editor, content, markup) => {367Transforms.insertNodes(editor, [368createEmoji(content, markup),369{ text: " " },370]);371},372});373374useEffect(() => {375if (submitMentionsRef != null) {376submitMentionsRef.current = (377fragmentId?: FragmentId,378onlyValue = false,379) => {380if (project_id == null || path == null) {381throw Error(382"project_id and path must be set in order to use mentions.",383);384}385386if (!onlyValue) {387const fragment_id = Fragment.encode(fragmentId);388389// No mentions in the document were already sent, so we send them now.390// We have to find all mentions in the document tree, and submit them.391const mentions: {392account_id: string;393description: string;394fragment_id: string;395}[] = [];396for (const [node, path] of Editor.nodes(editor, {397at: { path: [], offset: 0 },398match: (node) => node["type"] == "mention",399})) {400const [parent] = Editor.parent(editor, path);401mentions.push({402account_id: (node as Mention).account_id,403description: slate_to_markdown([parent]),404fragment_id,405});406}407408submit_mentions(project_id, path, mentions);409}410const value = editor.getMarkdownValue();411return value;412};413}414}, [submitMentionsRef]);415416const search: SearchHook = useSearch({ editor });417418const { marks, updateMarks } = useMarks(editor);419420const { linkURL, updateLinkURL } = useLinkURL(editor);421422const { listProperties, updateListProperties } = useListProperties(editor);423424const updateScrollState = useMemo(() => {425const { save_editor_state } = actions;426if (save_editor_state == null) return () => {};427if (disableWindowing) {428return throttle(() => {429if (!isMountedRef.current || !didRestoreScrollRef.current) return;430const scroll = scrollRef.current?.scrollTop;431if (scroll != null) {432save_editor_state(id, { scroll });433}434}, 250);435} else {436return throttle(() => {437if (!isMountedRef.current || !didRestoreScrollRef.current) return;438const scroll = getScrollState(editor);439if (scroll != null) {440save_editor_state(id, { scroll });441}442}, 250);443}444}, []);445446const broadcastCursors = useBroadcastCursors({447editor,448broadcastCursors: (x) => actions.set_cursor_locs?.(x),449});450451const cursorDecorate = useCursorDecorate({452editor,453cursors,454value: value ?? "",455search,456});457458const scrollRef = useRef<HTMLDivElement | null>(null);459const didRestoreScrollRef = useRef<boolean>(false);460const restoreScroll = useMemo(() => {461return async () => {462if (didRestoreScrollRef.current) return; // so we only ever do this once.463try {464const scroll = editor_state?.get("scroll");465if (!scroll) return;466467if (!disableWindowing) {468// Restore scroll for windowing469try {470await setScrollState(editor, scroll.toJS());471} catch (err) {472// could happen, e.g, if we change the format or change windowing.473console.log(`restoring scroll state -- ${err}`);474}475return;476}477478// Restore scroll for no windowing.479// scroll = the scrollTop position, though we wrap in480// exception since it could be anything.481await new Promise(requestAnimationFrame);482if (scrollRef.current == null || !isMountedRef.current) {483return;484}485const elt = $(scrollRef.current);486try {487elt.scrollTop(scroll);488// scrolling after image loads489elt.find("img").on("load", () => {490if (!isMountedRef.current) return;491elt.scrollTop(scroll);492});493} catch (_) {}494} finally {495didRestoreScrollRef.current = true;496setOpacity(undefined);497}498};499}, []);500501useEffect(() => {502if (actions._syncstring == null) {503setEditorToValue(value);504}505if (value != "Loading...") {506restoreScroll();507}508}, [value]);509510const lastSetValueRef = useRef<string | null>(null);511512const setSyncstringFromSlateNOW = () => {513if (actions.set_value == null) {514// no way to save the value out (e.g., just beginning to test515// using the component).516return;517}518if (!editor.hasUnsavedChanges()) {519// there are no changes to save520return;521}522523const markdown = editor.getMarkdownValue();524lastSetValueRef.current = markdown;525actions.set_value(markdown);526actions.syncstring_commit?.();527528// Record that the syncstring's value is now equal to ours:529editor.resetHasUnsavedChanges();530};531532const setSyncstringFromSlate = useMemo(() => {533if (saveDebounceMs) {534return debounce(setSyncstringFromSlateNOW, saveDebounceMs);535} else {536// this case shouldn't happen537return setSyncstringFromSlateNOW;538}539}, []);540541// We don't want to do saveValue too much, since it presumably can be slow,542// especially if the document is large. By debouncing, we only do this when543// the user pauses typing for a moment. Also, this avoids making too many commits.544// For tiny documents, user can make this small or even 0 to not debounce.545const saveValueDebounce =546saveDebounceMs != null && !saveDebounceMs547? () => editor.saveValue()548: useMemo(549() =>550debounce(551() => editor.saveValue(),552saveDebounceMs ?? SAVE_DEBOUNCE_MS,553),554[],555);556557function onKeyDown(e) {558if (read_only) {559e.preventDefault();560return;561}562563mentions.onKeyDown(e);564emojis.onKeyDown(e);565566if (e.defaultPrevented) return;567568if (!ReactEditor.isFocused(editor)) {569// E.g., when typing into a codemirror editor embedded570// in slate, we get the keystrokes, but at the same time571// the (contenteditable) editor itself is not focused.572return;573}574575const handler = getKeyboardHandler(e);576if (handler != null) {577const extra = { actions, id, search };578if (handler({ editor, extra })) {579e.preventDefault();580// key was handled.581return;582}583}584}585586useEffect(() => {587if (!is_current) {588if (editor.hasUnsavedChanges()) {589// just switched from focused to not and there was590// an unsaved change, so save state.591setSyncstringFromSlate();592actions.ensure_syncstring_is_saved?.();593}594}595}, [is_current]);596597const setEditorToValue = (value) => {598// console.log("setEditorToValue", { value, ed: editor.getMarkdownValue() });599if (lastSetValueRef.current == value) {600// this always happens once right after calling setSyncstringFromSlateNOW601// and it can randomly undo the last thing done, so don't do that!602// Also, this is an excellent optimization to do as well.603lastSetValueRef.current = null;604// console.log("setEditorToValue: skip");605return;606}607if (value == null) return;608if (value == editor.getMarkdownValue()) {609// nothing to do, and in fact doing something610// could be really annoying, since we don't want to611// autoformat via markdown everything immediately,612// as ambiguity is resolved while typing...613return;614}615const previousEditorValue = editor.children;616617// we only use the latest version of the document618// for caching purposes.619editor.syncCache = {};620// There is an assumption here that markdown_to_slate produces621// a document that is properly normalized. If that isn't the622// case, things will go horribly wrong, since it'll be impossible623// to convert the document to equal nextEditorValue. In the current624// code we do nomalize the output of markdown_to_slate, so625// that assumption is definitely satisfied.626const nextEditorValue = markdown_to_slate(value, false, editor.syncCache);627628try {629//const t = new Date();630631if (632// length is basically changing from "Loading..."; in this case, just reset everything, rather than transforming via operations (which preserves selection, etc.)633previousEditorValue.length <= 1 &&634nextEditorValue.length >= 40 &&635!ReactEditor.isFocused(editor)636) {637// This is a **MASSIVE** optimization. E.g., for a few thousand638// lines markdown file with about 500 top level elements (and lots639// of nested lists), applying operations below starting with the640// empty document can take 5-10 seconds, whereas just setting the641// value is instant. The drawback to directly setting the value642// is only that it messes up selection, and it's difficult643// to know where to move the selection to after changing.644// However, if the editor isn't focused, we don't have to worry645// about selection at all. TODO: we might be able to avoid the646// slateDiff stuff entirely via some tricky stuff, e.g., managing647// the cursor on the plain text side before/after the change, since648// codemirror is much faster att "setValueNoJump".649// The main time we use this optimization here is when opening the650// document in the first place, in which case we're converting651// the document from "Loading..." to it's initial value.652// Also, the default config is source text focused on the left and653// editable text acting as a preview on the right not focused, and654// again this makes things fastest.655// DRAWBACK: this doesn't preserve scroll position and breaks selection.656editor.syncCausedUpdate = true;657// we call "onChange" instead of setEditorValue, since658// we want all the change handler stuff to happen, e.g.,659// broadcasting cursors.660onChange(nextEditorValue);661// console.log("time to set directly ", new Date() - t);662} else {663const operations = slateDiff(previousEditorValue, nextEditorValue);664if (operations.length == 0) {665// no actual change needed.666return;667}668// Applying this operation below will trigger669// an onChange, which it is best to ignore to save time and670// also so we don't update the source editor (and other browsers)671// with a view with things like loan $'s escaped.'672editor.syncCausedUpdate = true;673// console.log("setEditorToValue: applying operations...", { operations });674preserveScrollPosition(editor, operations);675applyOperations(editor, operations);676// console.log("time to set via diff", new Date() - t);677}678} finally {679// In all cases, now that we have transformed editor into the new value680// let's save the fact that we haven't changed anything yet and we681// know the markdown state with zero changes. This is important, so682// we don't save out a change if we don't explicitly make one.683editor.resetHasUnsavedChanges();684editor.markdownValue = value;685}686687try {688if (editor.selection != null) {689// console.log("setEditorToValue: restore selection", editor.selection);690const { anchor, focus } = editor.selection;691Editor.node(editor, anchor);692Editor.node(editor, focus);693}694} catch (err) {695// TODO!696console.warn(697"slate - invalid selection after upstream patch. Resetting selection.",698err,699);700// set to beginning of document -- better than crashing.701resetSelection(editor);702}703704// if ((window as any).cc?.slate != null) {705// (window as any).cc.slate.eval = (s) => console.log(eval(s));706// }707708if (EXPENSIVE_DEBUG) {709const stringify = require("json-stable-stringify");710// We use JSON rather than isEqual here, since {foo:undefined}711// is not equal to {}, but they JSON the same, and this is712// fine for our purposes.713if (stringify(editor.children) != stringify(nextEditorValue)) {714// NOTE -- this does not 100% mean things are wrong. One case where715// this is expected behavior is if you put the cursor at the end of the716// document, say right after a horizontal rule, and then edit at the717// beginning of the document in another browser. The discrepancy718// is because a "fake paragraph" is placed at the end of the browser719// so your cursor has somewhere to go while you wait and type; however,720// that space is not really part of the markdown document, and it goes721// away when you move your cursor out of that space.722console.warn(723"**WARNING: slateDiff might not have properly transformed editor, though this may be fine. See window.diffBug **",724);725(window as any).diffBug = {726previousEditorValue,727nextEditorValue,728editorValue: editor.children,729stringify,730slateDiff,731applyOperations,732markdown_to_slate,733value,734};735}736}737};738739if ((window as any).cc != null) {740// This only gets set when running in cc-in-cc dev mode.741const { Editor, Node, Path, Range, Text } = require("slate");742(window as any).cc.slate = {743slateDiff,744editor,745actions,746editor_state,747Transforms,748ReactEditor,749Node,750Path,751Editor,752Range,753Text,754scrollRef,755applyOperations,756markdown_to_slate,757robot: async (s: string, iterations = 1) => {758/*759This little "robot" function is so you can run rtc on several browsers at once,760with each typing random stuff at random, and checking that their input worked761without loss of data.762*/763let inserted = "";764let focus = editor.selection?.focus;765if (focus == null) throw Error("must have selection");766let lastOffset = focus.offset;767for (let n = 0; n < iterations; n++) {768for (const x of s) {769// Transforms.setSelection(editor, {770// focus,771// anchor: focus,772// });773editor.insertText(x);774focus = editor.selection?.focus;775if (focus == null) throw Error("must have selection");776inserted += x;777const offset = focus.offset;778console.log(779`${780n + 1781}/${iterations}: inserted '${inserted}'; focus="${JSON.stringify(782editor.selection?.focus,783)}"`,784);785if (offset != (lastOffset ?? 0) + 1) {786console.error("SYNC FAIL!!", { offset, lastOffset });787return;788}789lastOffset = offset;790await delay(100 * Math.random());791if (Math.random() < 0.2) {792await delay(2 * SAVE_DEBOUNCE_MS);793}794}795}796console.log("SUCCESS!");797},798};799}800801editor.inverseSearch = async function inverseSearch(802force?: boolean,803): Promise<void> {804if (805!force &&806(is_fullscreen || !actions.get_matching_frame?.({ type: "cm" }))807) {808// - if user is fullscreen assume they just want to WYSIWYG edit809// and double click is to select. They can use sync button to810// force opening source panel.811// - if no source view, also don't do anything. We only let812// double click do something when there is an open source view,813// since double click is used for selecting.814return;815}816// delay to give double click a chance to change current focus.817// This takes surprisingly long!818let t = 0;819while (editor.selection == null) {820await delay(1);821t += 50;822if (t > 2000) return; // give up823}824const point = editor.selection?.anchor; // using anchor since double click selects word.825if (point == null) {826return;827}828const pos = slatePointToMarkdownPosition(editor, point);829if (pos == null) return;830actions.programmatical_goto_line?.(831pos.line + 1, // 1 based (TODO: could use codemirror option)832true,833false, // 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.834undefined,835pos.ch,836);837};838839// WARNING: onChange does not fire immediately after changes occur.840// It is fired by react and happens in some potentialy later render841// loop after changes. Thus you absolutely can't depend on it in any842// way for checking if the state of the editor has changed. Instead843// check editor.children itself explicitly.844const onChange = (newEditorValue) => {845if (dirtyRef != null) {846// but see comment above847dirtyRef.current = true;848}849if (editor._hasUnsavedChanges === false) {850// just for initial change.851editor._hasUnsavedChanges = undefined;852}853if (!isMountedRef.current) return;854broadcastCursors();855updateMarks();856updateLinkURL();857updateListProperties();858// Track where the last editor selection was,859// since this is very useful to know, e.g., for860// understanding cursor movement, format fallback, etc.861// @ts-ignore862if (editor.lastSelection == null && editor.selection != null) {863// initialize864// @ts-ignore865editor.lastSelection = editor.curSelection = editor.selection;866}867// @ts-ignore868if (!isEqual(editor.selection, editor.curSelection)) {869// @ts-ignore870editor.lastSelection = editor.curSelection;871if (editor.selection != null) {872// @ts-ignore873editor.curSelection = editor.selection;874}875}876877if (editorValue === newEditorValue) {878// Editor didn't actually change value so nothing to do.879return;880}881882setEditorValue(newEditorValue);883setChange(change + 1);884885// Update mentions state whenever editor actually changes.886// This may pop up the mentions selector.887mentions.onChange();888// Similar for emojis.889emojis.onChange();890891if (!is_current) {892// Do not save when editor not current since user could be typing893// into another editor of the same underlying document. This will894// cause bugs (e.g., type, switch from slate to codemirror, type, and895// see what you typed into codemirror disappear). E.g., this896// happens due to a spurious change when the editor is defocused.897898return;899}900saveValueDebounce();901};902903useEffect(() => {904editor.syncCausedUpdate = false;905}, [editorValue]);906907const [opacity, setOpacity] = useState<number | undefined>(0);908909if (editBar2 != null) {910editBar2.current = (911<EditBar912Search={search.Search}913isCurrent={is_current}914marks={marks}915linkURL={linkURL}916listProperties={listProperties}917editor={editor}918style={{ ...editBarStyle, paddingRight: 0 }}919hideSearch={hideSearch}920/>921);922}923924let slate = (925<Slate editor={editor} value={editorValue} onChange={onChange}>926<Editable927placeholder={placeholder}928autoFocus={autoFocus}929className={930!disableWindowing && height != "auto" ? "smc-vfill" : undefined931}932readOnly={read_only}933renderElement={Element}934renderLeaf={Leaf}935onKeyDown={onKeyDown}936onBlur={() => {937editor.saveValue();938updateMarks();939onBlur?.();940}}941onFocus={() => {942updateMarks();943onFocus?.();944}}945decorate={cursorDecorate}946divref={scrollRef}947onScroll={updateScrollState}948style={949!disableWindowing950? undefined951: {952height,953position: "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.954minWidth: "80%",955padding: "70px",956background: "white",957overflow:958height == "auto"959? "hidden" /* for height='auto' we never want a scrollbar */960: "auto" /* for this overflow, see https://github.com/ianstormtaylor/slate/issues/3706 */,961...pageStyle,962}963}964windowing={965!disableWindowing966? {967rowStyle: {968// WARNING: do *not* use margin in rowStyle.969padding: minimal ? 0 : "0 70px",970overflow: "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.971minHeight: "1px", // virtuoso can't deal with 0-height items972},973marginTop: "40px",974marginBottom: "40px",975rowSizeEstimator,976}977: undefined978}979/>980</Slate>981);982let body = (983<ChangeContext.Provider value={{ change, editor }}>984<div985ref={divRef}986className={noVfill || height === "auto" ? undefined : "smc-vfill"}987style={{988overflow: noVfill || height === "auto" ? undefined : "auto",989backgroundColor: "white",990...style,991height,992minHeight: height == "auto" ? "50px" : undefined,993}}994>995{!hidePath && (996<Path is_current={is_current} path={path} project_id={project_id} />997)}998{showEditBar && (999<EditBar1000Search={search.Search}1001isCurrent={is_current}1002marks={marks}1003linkURL={linkURL}1004listProperties={listProperties}1005editor={editor}1006style={editBarStyle}1007hideSearch={hideSearch}1008/>1009)}1010<div1011className={noVfill || height == "auto" ? undefined : "smc-vfill"}1012style={{1013...STYLE,1014fontSize: font_size,1015height,1016opacity,1017}}1018>1019{mentions.Mentions}1020{emojis.Emojis}1021{slate}1022</div>1023</div>1024</ChangeContext.Provider>1025);1026return useUpload(editor, body);1027});102810291030