Path: blob/master/src/packages/frontend/editors/slate/slate-react/plugin/with-react.ts
1698 views
import ReactDOM from "react-dom";1import {2Editor,3Element,4Descendant,5Path,6Operation,7Transforms,8Range,9} from "slate";1011import { ReactEditor } from "./react-editor";12import { Key } from "../utils/key";13import { EDITOR_TO_ON_CHANGE, NODE_TO_KEY } from "../utils/weak-maps";14import { findCurrentLineRange } from "../utils/lines";1516/**17* `withReact` adds React and DOM specific behaviors to the editor.18*/1920export const withReact = <T extends Editor>(editor: T) => {21const e = editor as T & ReactEditor;22const { apply, onChange, deleteBackward } = e;2324e.windowedListRef = { current: null };2526e.collapsedSections = new WeakMap();2728e.deleteBackward = (unit) => {29if (unit !== "line") {30return deleteBackward(unit);31}3233if (editor.selection && Range.isCollapsed(editor.selection)) {34const parentBlockEntry = Editor.above(editor, {35match: (node) =>36Element.isElement(node) && Editor.isBlock(editor, node),37at: editor.selection,38});3940if (parentBlockEntry) {41const [, parentBlockPath] = parentBlockEntry;42const parentElementRange = Editor.range(43editor,44parentBlockPath,45editor.selection.anchor46);4748const currentLineRange = findCurrentLineRange(e, parentElementRange);4950if (!Range.isCollapsed(currentLineRange)) {51Transforms.delete(editor, { at: currentLineRange });52}53}54}55};5657e.apply = (op: Operation) => {58const matches: [Path, Key][] = [];5960switch (op.type) {61case "insert_text":62case "remove_text":63case "set_node": {64for (const [node, path] of Editor.levels(e, { at: op.path })) {65const key = ReactEditor.findKey(e, node);66matches.push([path, key]);67}6869break;70}7172case "insert_node":73case "remove_node":74case "merge_node":75case "split_node": {76for (const [node, path] of Editor.levels(e, {77at: Path.parent(op.path),78})) {79const key = ReactEditor.findKey(e, node);80matches.push([path, key]);81}8283break;84}8586case "move_node": {87for (const [node, path] of Editor.levels(e, {88at: Path.common(Path.parent(op.path), Path.parent(op.newPath)),89})) {90const key = ReactEditor.findKey(e, node);91matches.push([path, key]);92}93break;94}95}9697apply(op);9899for (const [path, key] of matches) {100const [node] = Editor.node(e, path);101NODE_TO_KEY.set(node, key);102}103};104105e.setFragmentData = (data: DataTransfer) => {106const { selection } = e;107108if (!selection) {109return;110}111112const [start] = Range.edges(selection);113const startVoid = Editor.void(e, { at: start.path });114if (Range.isCollapsed(selection) && !startVoid) {115return;116}117118const fragment = e.getFragment();119const plain = (e as any).getPlainValue?.(fragment);120if (plain == null) {121throw Error("copy not implemented");122}123const encoded = window.btoa(encodeURIComponent(JSON.stringify(fragment)));124125// This application/x-slate-fragment is the only thing that is126// used for Firefox and Chrome paste:127data.setData("application/x-slate-fragment", encoded);128// This data part of text/html is used for Safari, which ignores129// the application/x-slate-fragment above.130data.setData(131"text/html",132`<pre data-slate-fragment="${encoded}">\n${plain}\n</pre>`133);134data.setData("text/plain", plain);135};136137e.insertData = (data: DataTransfer) => {138let fragment = data.getData("application/x-slate-fragment");139140if (!fragment) {141// On Safari (probably for security reasons?), the142// application/x-slate-fragment data is not set.143// My guess is this is why the html is also modified144// even though it is never used in the upstream code!145// See https://github.com/ianstormtaylor/slate/issues/3589146// Supporting this is also important when copying from147// Safari to Chrome (say).148const html = data.getData("text/html");149if (html) {150// It would be nicer to parse html properly, but that's151// going to be pretty difficult, so I'm doing the following,152// which of course could be tricked if the content153// itself happened to have data-slate-fragment="...154// in it. That's a reasonable price to pay for155// now for restoring this functionality.156let i = html.indexOf('data-slate-fragment="');157if (i != -1) {158i += 'data-slate-fragment="'.length;159const j = html.indexOf('"', i);160if (j != -1) {161fragment = html.slice(i, j);162}163}164}165}166167// TODO: we want to do this, but it currently causes a slight168// delay, which is very disconcerting. We need some sort of169// local slate undo before saving out to markdown (which has170// to wait until there is a pause in typing).171//(e as any).saveValue?.(true);172173if (fragment) {174const decoded = decodeURIComponent(window.atob(fragment));175const parsed = JSON.parse(decoded) as Descendant[];176e.insertFragment(parsed);177return;178}179180const text = data.getData("text/plain");181182if (text) {183const lines = text.split(/\r\n|\r|\n/);184let split = false;185186for (const line of lines) {187if (split) {188Transforms.splitNodes(e, { always: true });189}190191e.insertText(line);192split = true;193}194}195};196197e.onChange = () => {198// COMPAT: React doesn't batch `setState` hook calls, which means that the199// children and selection can get out of sync for one render pass. So we200// have to use this unstable API to ensure it batches them. (2019/12/03)201// https://github.com/facebook/react/issues/14259#issuecomment-439702367202ReactDOM.unstable_batchedUpdates(() => {203const onContextChange = EDITOR_TO_ON_CHANGE.get(e);204205if (onContextChange) {206onContextChange();207}208209onChange();210});211};212213// only when windowing is enabled.214e.scrollIntoDOM = (index) => {215let windowed: boolean = e.windowedListRef.current != null;216if (windowed) {217const visibleRange = e.windowedListRef.current?.visibleRange;218if (visibleRange != null) {219const { startIndex, endIndex } = visibleRange;220if (index < startIndex || index > endIndex) {221const virtuoso = e.windowedListRef.current.virtuosoRef?.current;222if (virtuoso != null) {223virtuoso.scrollIntoView({ index });224return true;225}226}227}228}229return false;230};231232e.scrollCaretIntoView = (options?: { middle?: boolean }) => {233/* Scroll so Caret is visible. I tested several editors, and234I think reasonable behavior is:235- If caret is full visible on the screen, do nothing.236- If caret is not visible, scroll so caret is at237top or bottom. Word and Pages do this but with an extra line;238CodeMirror does *exactly this*; some editors like Prosemirror239and Typora scroll the caret to the middle of the screen,240which is weird. Since most of cocalc is codemirror, being241consistent with that seems best. The implementation is also242very simple.243244This code below is based on what is245https://github.com/ianstormtaylor/slate/pull/4023246except that PR seems buggy and does the wrong thing, so I247had to rewrite it. I also wrote a version for windowing.248249I think properly implementing this is very important since it is250critical to keep users from feeling *lost* when using the editor.251If their cursor scrolls off the screen, especially in a very long line,252they might move the cursor back or forward one space to make it visible253again. In slate with #4023, if you make a single LONG line (that spans254more than a page with no formatting), then scroll the cursor out of view,255then move the cursor, you often still don't see the cursor. That's256because it just scrolls that entire leaf into view, not the cursor257itself.258*/259try {260const { selection } = e;261if (selection == null) return;262263// Important: this doesn't really work well for many types264// of void elements, e.g, when the focused265// element is an image -- with several images, when266// you click on one, things jump267// around randomly and you sometimes can't scroll the image into view.268// Better to just do nothing in this case.269for (const [node] of Editor.nodes(e, { at: selection.focus })) {270if (271Element.isElement(node) &&272Editor.isVoid(e, node) &&273!SCROLL_WHITELIST.has(node["type"])274) {275return;276}277}278279// In case we're using windowing, scroll the block with the focus280// into the DOM first.281let windowed: boolean = e.windowedListRef.current != null;282if (windowed && !e.scrollCaretAfterNextScroll) {283const index = selection.focus.path[0];284const visibleRange = e.windowedListRef.current?.visibleRange;285if (visibleRange != null) {286const { startIndex, endIndex } = visibleRange;287if (index < startIndex || index > endIndex) {288// We need to scroll the block containing the cursor into the DOM first?289e.scrollIntoDOM(index);290// now wait until the actual scroll happens before291// doing the measuring below, or it could be wrong.292e.scrollCaretAfterNextScroll = true;293requestAnimationFrame(() => e.scrollCaretIntoView());294return;295}296}297}298299let domSelection;300try {301domSelection = ReactEditor.toDOMRange(e, {302anchor: selection.focus,303focus: selection.focus,304});305} catch (_err) {306// harmless to just not do this in case of failure.307return;308}309if (!domSelection) return;310const selectionRect = domSelection.getBoundingClientRect();311const editorEl = ReactEditor.toDOMNode(e, e);312const editorRect = editorEl.getBoundingClientRect();313const EXTRA = options?.middle314? editorRect.height / 2315: editorRect.height > 100316? 20317: 0; // this much more than the min possible to get it on screen.318319let offset: number = 0;320if (selectionRect.top < editorRect.top + EXTRA) {321offset = editorRect.top + EXTRA - selectionRect.top;322} else if (323selectionRect.bottom - editorRect.top >324editorRect.height - EXTRA325) {326offset =327editorRect.height - EXTRA - (selectionRect.bottom - editorRect.top);328}329if (offset) {330if (windowed) {331const scroller = e.windowedListRef.current?.getScrollerRef();332if (scroller != null) {333scroller.scrollTop = scroller.scrollTop - offset;334}335} else {336editorEl.scrollTop = editorEl.scrollTop - offset;337}338}339} catch (_err) {340// console.log("WARNING: scrollCaretIntoView -- ", err);341// The only side effect we are hiding is that the cursor might not342// scroll into view, which is way better than crashing everything.343// console.log("WARNING: failed to scroll cursor into view", e);344}345};346347return e;348};349350const SCROLL_WHITELIST = new Set(["hashtag", "checkbox"]);351352353