Path: blob/master/src/packages/frontend/editors/slate/slate-react/components/selection-sync.ts
1698 views
/*1Syncing the selection between slate and the DOM.23This started by factoring out the relevant code from editable.tsx.4We then rewrote it to work with windowing, which of course discards5the DOM outside the visible window, hence full sync no longer makes6sense -- instead the slate selection is the sole source of truth, and7the DOM just partly reflects that, and user manipulation of the DOM8merely influences slate's state, rather than completely determining it.910I spent forever (!) trying various strategies involving locks and11timeouts, which could never work perfectly on many different12platforms. This simple algorithm evidently does, and involves *NO*13asynchronous code or locks at all! Also, there are no platform14specific hacks at all.15*/1617import { useCallback } from "react";18import { useIsomorphicLayoutEffect } from "../hooks/use-isomorphic-layout-effect";19import { ReactEditor } from "..";20import { EDITOR_TO_ELEMENT } from "../utils/weak-maps";21import { Editor, Point, Range, Selection, Transforms } from "slate";22import { hasEditableTarget, isTargetInsideVoid } from "./dom-utils";23import { DOMElement } from "../utils/dom";24import { isEqual } from "lodash";2526interface SelectionState {27isComposing: boolean;28shiftKey: boolean;29latestElement: DOMElement | null;3031// If part of the selection gets scrolled out of the DOM, we set windowedSelection32// to true. The next time the selection in the DOM is read, we then set33// windowedSelection to that read value and don't update editor.selection34// unless the selection in the DOM is changed to something else manually.35// This way editor.selection doesn't change at all (unless user actually manually36// changes it), and true selection is then used to select proper part of editor37// that is actually rendered in the DOM.38windowedSelection?: true | Range;3940// if true, the selection sync hooks are temporarily disabled.41ignoreSelection: boolean;42}4344export const useUpdateDOMSelection = ({45editor,46state,47}: {48editor: ReactEditor;49state: SelectionState;50}) => {51// Ensure that the DOM selection state is set to the editor selection.52// Note that whenever the DOM gets updated (e.g., with every keystroke when editing)53// the DOM selection gets completely reset (because react replaces the selected text54// by new text), so this setting of the selection usually happens, and happens55// **a lot**.56const updateDOMSelection = () => {57if (58state.isComposing ||59!ReactEditor.isFocused(editor) ||60state.ignoreSelection61) {62return;63}6465const domSelection = window.getSelection();66if (!domSelection) {67delete state.windowedSelection;68return;69}7071let selection;72try {73selection = getWindowedSelection(editor);74} catch (err) {75// in rare cases when document / selection seriously "messed up", this76// can happen because Editor.before throws below. In such cases we77// give up by setting the selection to empty, so it will get cleared in78// the DOM. I saw this once in development.79console.warn(80`getWindowedSelection warning - ${err} - so clearing selection`,81);82Transforms.deselect(editor); // just clear selection83selection = undefined;84}85const isCropped = !isEqual(editor.selection, selection);86if (!isCropped) {87delete state.windowedSelection;88}89// console.log(90// "\nwindowed selection =",91// JSON.stringify(selection),92// "\neditor.selection =",93// JSON.stringify(editor.selection)94// );95const hasDomSelection = domSelection.type !== "None";9697// If the DOM selection is properly unset, we're done.98if (!selection && !hasDomSelection) {99return;100}101102// verify that the DOM selection is in the editor103const editorElement = EDITOR_TO_ELEMENT.get(editor);104const hasDomSelectionInEditor =105editorElement?.contains(domSelection.anchorNode) &&106editorElement?.contains(domSelection.focusNode);107108if (!selection) {109// need to clear selection:110if (hasDomSelectionInEditor) {111// the current nontrivial selection is inside the editor,112// so we just clear it.113domSelection.removeAllRanges();114if (isCropped) {115state.windowedSelection = true;116}117}118return;119}120let newDomRange;121try {122newDomRange = ReactEditor.toDOMRange(editor, selection);123} catch (_err) {124// console.warn(125// `slate -- toDOMRange error ${_err}, range=${JSON.stringify(selection)}`126// );127// This error happens and is expected! e.g., if you set the selection to a128// point that isn't valid in the document. TODO: Our129// autoformat code perhaps stupidly does this sometimes,130// at least when working on it.131// It's better to just give up in this case, rather than132// crash the entire cocalc. The user will click somewhere133// and be good to go again.134return;135}136137// Flip orientation of newDomRange if selection is backward,138// since setBaseAndExtent (which we use below) is not oriented.139if (Range.isBackward(selection)) {140newDomRange = {141endContainer: newDomRange.startContainer,142endOffset: newDomRange.startOffset,143startContainer: newDomRange.endContainer,144startOffset: newDomRange.endOffset,145};146}147148// Compare the new DOM range we want to what's actually149// selected. If they are the same, done. If different,150// we change the selection in the DOM.151if (152domSelection.anchorNode?.isSameNode(newDomRange.startContainer) &&153domSelection.focusNode?.isSameNode(newDomRange.endContainer) &&154domSelection.anchorOffset === newDomRange.startOffset &&155domSelection.focusOffset === newDomRange.endOffset156) {157// It's correct already -- we're done.158// console.log("useUpdateDOMSelection: selection already correct");159return;160}161162// Finally, make the change:163if (isCropped) {164// record that we're making a change that diverges from true selection.165state.windowedSelection = true;166}167domSelection.setBaseAndExtent(168newDomRange.startContainer,169newDomRange.startOffset,170newDomRange.endContainer,171newDomRange.endOffset,172);173};174175// Always ensure DOM selection gets set to slate selection176// right after the editor updates. This is especially important177// because the react update sets parts of the contenteditable178// area, and can easily mess up or reset the cursor, so we have179// to immediately set it back.180useIsomorphicLayoutEffect(updateDOMSelection);181182// We also attach this function to the editor,183// so can be called on scroll, which is needed to support windowing.184editor.updateDOMSelection = updateDOMSelection;185};186187export const useDOMSelectionChange = ({188editor,189state,190readOnly,191}: {192editor: ReactEditor;193state: SelectionState;194readOnly: boolean;195}) => {196// Listen on the native `selectionchange` event to be able to update any time197// the selection changes. This is required because React's `onSelect` is leaky198// and non-standard so it doesn't fire until after a selection has been199// released. This causes issues in situations where another change happens200// while a selection is being dragged.201202const onDOMSelectionChange = useCallback(() => {203if (readOnly || state.isComposing || state.ignoreSelection) {204return;205}206207const domSelection = window.getSelection();208if (!domSelection) {209Transforms.deselect(editor);210return;211}212const { anchorNode, focusNode } = domSelection;213214if (!isSelectable(editor, anchorNode) || !isSelectable(editor, focusNode)) {215return;216}217218let range;219try {220range = ReactEditor.toSlateRange(editor, domSelection);221} catch (err) {222// isSelectable should catch any situation where the above might cause an223// error, but in practice it doesn't. Just ignore selection change when this224// happens.225console.warn(`slate selection sync issue - ${err}`);226return;227}228229// console.log(JSON.stringify({ range, sel: state.windowedSelection }));230if (state.windowedSelection === true) {231state.windowedSelection = range;232}233234const { selection } = editor;235if (selection != null) {236const visibleRange = editor.windowedListRef.current?.visibleRange;237if (visibleRange != null) {238// Trickier case due to windowing. If we're not changing the selection239// via shift click but the selection in the DOM is trimmed due to windowing,240// then make no change to editor.selection based on the DOM.241if (242!state.shiftKey &&243state.windowedSelection != null &&244isEqual(range, state.windowedSelection)245) {246// selection is what was set using window clipping, so not changing247return;248}249250// Shift+clicking to select a range, done via code that works in251// case of windowing.252if (state.shiftKey) {253// What *should* actually happen on shift+click to extend a254// selection is not so obvious! For starters, the behavior255// in text editors like CodeMirror, VSCode and Ace Editor256// (set range.anchor to selection.anchor) is totally different257// than rich editors like Word, Pages, and browser258// contenteditable, which mostly *extend* the selection in259// various ways. We match exactly what default browser260// selection does, since otherwise we would have to *change*261// that when not using windowing or when everything is in262// the visible window, which seems silly.263const edges = Range.edges(selection);264if (Point.isBefore(range.focus, edges[0])) {265// Shift+click before the entire existing selection:266range.anchor = edges[1];267} else if (Point.isAfter(range.focus, edges[1])) {268// Shift+click after the entire existing selection:269range.anchor = edges[0];270} else {271// Shift+click inside the existing selection. What browsers272// do is they shrink selection so the new focus is273// range.focus, and the new anchor is whichever of274// selection.focus or selection.anchor makes the resulting275// selection "longer".276const a = Editor.string(277editor,278{ focus: range.focus, anchor: selection.anchor },279{ voids: true },280).length;281const b = Editor.string(282editor,283{ focus: range.focus, anchor: selection.focus },284{ voids: true },285).length;286range.anchor = a > b ? selection.anchor : selection.focus;287}288}289}290}291292if (selection == null || !Range.equals(selection, range)) {293Transforms.select(editor, range);294}295}, [readOnly]);296297// Attach a native DOM event handler for `selectionchange`, because React's298// built-in `onSelect` handler doesn't fire for all selection changes. It's a299// leaky polyfill that only fires on keypresses or clicks. Instead, we want to300// fire for any change to the selection inside the editor. (2019/11/04)301// https://github.com/facebook/react/issues/5785302useIsomorphicLayoutEffect(() => {303window.document.addEventListener("selectionchange", onDOMSelectionChange);304return () => {305window.document.removeEventListener(306"selectionchange",307onDOMSelectionChange,308);309};310}, [onDOMSelectionChange]);311312return onDOMSelectionChange;313};314315function getWindowedSelection(editor: ReactEditor): Selection | null {316const { selection } = editor;317if (selection == null || editor.windowedListRef?.current == null) {318// No selection, or not using windowing, or collapsed so easy.319return selection;320}321322// Now we trim non-collapsed selection to part of window in the DOM.323const visibleRange = editor.windowedListRef.current?.visibleRange;324if (visibleRange == null) return selection;325const { anchor, focus } = selection;326return {327anchor: clipPoint(editor, anchor, visibleRange),328focus: clipPoint(editor, focus, visibleRange),329};330}331332function clipPoint(333editor: Editor,334point: Point,335visibleRange: { startIndex: number; endIndex: number },336): Point {337const { startIndex, endIndex } = visibleRange;338const n = point.path[0];339if (n < startIndex) {340return { path: [startIndex, 0], offset: 0 };341}342if (n > endIndex) {343// We have to use Editor.before, since we need to select344// the entire endIndex block. The ?? below should just be345// to make typescript happy.346return (347Editor.before(editor, { path: [endIndex + 1, 0], offset: 0 }) ?? {348path: [endIndex, 0],349offset: 0,350}351);352}353return point;354}355356function isSelectable(editor, node): boolean {357return hasEditableTarget(editor, node) || isTargetInsideVoid(editor, node);358}359360361