From 916001369dd30fd61a0ffb0a76a74b175505f57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Mon, 16 Feb 2026 08:35:19 +0000 Subject: [PATCH 1/6] chore: copy/paste Slate source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor `slate@0.120.0`, `slate-dom@0.119.0`, and `slate-react@0.120.0` source from github.com/ianstormtaylor/slate (tag: slate@0.120.0). Pure copy of src/ directories — no modifications, no git history. packages/editor/src/slate/ — 118 files (slate core) packages/editor/src/slate-dom/ — 14 files (DOM integration) packages/editor/src/slate-react/ — 40 files (React bindings) --- packages/editor/src/slate-dom/custom-types.ts | 45 + packages/editor/src/slate-dom/index.ts | 92 + .../editor/src/slate-dom/plugin/dom-editor.ts | 1098 +++++++++ .../editor/src/slate-dom/plugin/with-dom.ts | 383 +++ .../editor/src/slate-dom/utils/constants.ts | 1 + .../editor/src/slate-dom/utils/diff-text.ts | 415 ++++ packages/editor/src/slate-dom/utils/dom.ts | 426 ++++ .../editor/src/slate-dom/utils/environment.ts | 83 + .../editor/src/slate-dom/utils/hotkeys.ts | 97 + packages/editor/src/slate-dom/utils/key.ts | 18 + packages/editor/src/slate-dom/utils/lines.ts | 75 + .../editor/src/slate-dom/utils/range-list.ts | 161 ++ packages/editor/src/slate-dom/utils/types.ts | 3 + .../editor/src/slate-dom/utils/weak-maps.ts | 98 + .../src/slate-react/@types/direction.d.ts | 4 + .../slate-react/chunking/children-helper.ts | 122 + .../slate-react/chunking/chunk-tree-helper.ts | 574 +++++ .../chunking/get-chunk-tree-for-node.ts | 47 + .../editor/src/slate-react/chunking/index.ts | 2 + .../chunking/reconcile-children.ts | 127 + .../editor/src/slate-react/chunking/types.ts | 52 + .../src/slate-react/components/chunk-tree.tsx | 65 + .../src/slate-react/components/editable.tsx | 2058 +++++++++++++++++ .../src/slate-react/components/element.tsx | 174 ++ .../src/slate-react/components/leaf.tsx | 190 ++ .../restore-dom/restore-dom-manager.ts | 61 + .../components/restore-dom/restore-dom.tsx | 81 + .../src/slate-react/components/slate.tsx | 124 + .../src/slate-react/components/string.tsx | 144 ++ .../src/slate-react/components/text.tsx | 119 + .../editor/src/slate-react/custom-types.ts | 45 + .../android-input-manager.ts | 828 +++++++ .../use-android-input-manager.ts | 53 + .../src/slate-react/hooks/use-children.tsx | 190 ++ .../src/slate-react/hooks/use-composing.ts | 15 + .../src/slate-react/hooks/use-decorations.ts | 81 + .../src/slate-react/hooks/use-editor.tsx | 19 + .../src/slate-react/hooks/use-element.ts | 25 + .../src/slate-react/hooks/use-focused.ts | 15 + .../hooks/use-generic-selector.tsx | 92 + .../src/slate-react/hooks/use-is-mounted.tsx | 14 + .../hooks/use-isomorphic-layout-effect.ts | 10 + .../hooks/use-mutation-observer.ts | 25 + .../src/slate-react/hooks/use-read-only.ts | 15 + .../src/slate-react/hooks/use-selected.ts | 37 + .../slate-react/hooks/use-slate-selection.tsx | 16 + .../slate-react/hooks/use-slate-selector.tsx | 125 + .../slate-react/hooks/use-slate-static.tsx | 25 + .../src/slate-react/hooks/use-slate.tsx | 64 + .../slate-react/hooks/use-track-user-input.ts | 32 + packages/editor/src/slate-react/index.ts | 34 + .../src/slate-react/plugin/react-editor.ts | 20 + .../src/slate-react/plugin/with-react.ts | 78 + .../src/slate-react/utils/environment.ts | 3 + packages/editor/src/slate/core/apply.ts | 53 + .../src/slate/core/batch-dirty-paths.ts | 24 + .../editor/src/slate/core/get-dirty-paths.ts | 83 + .../editor/src/slate/core/get-fragment.ts | 13 + packages/editor/src/slate/core/index.ts | 5 + .../editor/src/slate/core/normalize-node.ts | 124 + .../editor/src/slate/core/should-normalize.ts | 17 + .../src/slate/core/update-dirty-paths.ts | 50 + packages/editor/src/slate/create-editor.ts | 190 ++ packages/editor/src/slate/editor/above.ts | 30 + packages/editor/src/slate/editor/add-mark.ts | 52 + packages/editor/src/slate/editor/after.ts | 27 + packages/editor/src/slate/editor/before.ts | 28 + .../src/slate/editor/delete-backward.ts | 15 + .../editor/src/slate/editor/delete-forward.ts | 15 + .../src/slate/editor/delete-fragment.ts | 14 + packages/editor/src/slate/editor/edges.ts | 5 + .../src/slate/editor/element-read-only.ts | 12 + packages/editor/src/slate/editor/end.ts | 5 + packages/editor/src/slate/editor/first.ts | 6 + packages/editor/src/slate/editor/fragment.ts | 7 + packages/editor/src/slate/editor/get-void.ts | 9 + .../editor/src/slate/editor/has-blocks.ts | 8 + .../editor/src/slate/editor/has-inlines.ts | 8 + packages/editor/src/slate/editor/has-path.ts | 6 + packages/editor/src/slate/editor/has-texts.ts | 6 + packages/editor/src/slate/editor/index.ts | 55 + .../editor/src/slate/editor/insert-break.ts | 6 + .../editor/src/slate/editor/insert-node.ts | 10 + .../src/slate/editor/insert-soft-break.ts | 6 + .../editor/src/slate/editor/insert-text.ts | 24 + packages/editor/src/slate/editor/is-block.ts | 5 + packages/editor/src/slate/editor/is-edge.ts | 5 + packages/editor/src/slate/editor/is-editor.ts | 38 + packages/editor/src/slate/editor/is-empty.ts | 14 + packages/editor/src/slate/editor/is-end.ts | 7 + .../editor/src/slate/editor/is-normalizing.ts | 7 + packages/editor/src/slate/editor/is-start.ts | 12 + packages/editor/src/slate/editor/last.ts | 6 + packages/editor/src/slate/editor/leaf.ts | 8 + packages/editor/src/slate/editor/levels.ts | 40 + packages/editor/src/slate/editor/marks.ts | 85 + packages/editor/src/slate/editor/next.ts | 36 + packages/editor/src/slate/editor/node.ts | 8 + packages/editor/src/slate/editor/nodes.ts | 117 + packages/editor/src/slate/editor/normalize.ts | 93 + packages/editor/src/slate/editor/parent.ts | 10 + packages/editor/src/slate/editor/path-ref.ts | 25 + packages/editor/src/slate/editor/path-refs.ts | 13 + packages/editor/src/slate/editor/path.ts | 35 + packages/editor/src/slate/editor/point-ref.ts | 25 + .../editor/src/slate/editor/point-refs.ts | 13 + packages/editor/src/slate/editor/point.ts | 38 + packages/editor/src/slate/editor/positions.ts | 220 ++ packages/editor/src/slate/editor/previous.ts | 47 + packages/editor/src/slate/editor/range-ref.ts | 25 + .../editor/src/slate/editor/range-refs.ts | 13 + packages/editor/src/slate/editor/range.ts | 12 + .../editor/src/slate/editor/remove-mark.ts | 45 + .../src/slate/editor/set-normalizing.ts | 9 + .../should-merge-nodes-remove-prev-node.ts | 17 + packages/editor/src/slate/editor/start.ts | 5 + packages/editor/src/slate/editor/string.ts | 31 + .../editor/src/slate/editor/unhang-range.ts | 53 + .../src/slate/editor/without-normalizing.ts | 15 + packages/editor/src/slate/index.ts | 9 + .../editor/src/slate/interfaces/editor.ts | 991 ++++++++ .../editor/src/slate/interfaces/element.ts | 147 ++ packages/editor/src/slate/interfaces/index.ts | 14 + .../editor/src/slate/interfaces/location.ts | 49 + packages/editor/src/slate/interfaces/node.ts | 641 +++++ .../editor/src/slate/interfaces/operation.ts | 323 +++ .../editor/src/slate/interfaces/path-ref.ts | 38 + packages/editor/src/slate/interfaces/path.ts | 517 +++++ .../editor/src/slate/interfaces/point-ref.ts | 39 + packages/editor/src/slate/interfaces/point.ts | 188 ++ .../editor/src/slate/interfaces/range-ref.ts | 38 + packages/editor/src/slate/interfaces/range.ts | 265 +++ .../editor/src/slate/interfaces/scrubber.ts | 34 + packages/editor/src/slate/interfaces/text.ts | 214 ++ .../slate/interfaces/transforms/general.ts | 355 +++ .../src/slate/interfaces/transforms/index.ts | 14 + .../src/slate/interfaces/transforms/node.ts | 196 ++ .../slate/interfaces/transforms/selection.ts | 75 + .../src/slate/interfaces/transforms/text.ts | 106 + .../editor/src/slate/transforms-node/index.ts | 10 + .../src/slate/transforms-node/insert-nodes.ts | 167 ++ .../src/slate/transforms-node/lift-nodes.ts | 61 + .../src/slate/transforms-node/merge-nodes.ts | 146 ++ .../src/slate/transforms-node/move-nodes.ts | 48 + .../src/slate/transforms-node/remove-nodes.ts | 42 + .../src/slate/transforms-node/set-nodes.ts | 128 + .../src/slate/transforms-node/split-nodes.ts | 140 ++ .../src/slate/transforms-node/unset-nodes.ts | 20 + .../src/slate/transforms-node/unwrap-nodes.ts | 61 + .../src/slate/transforms-node/wrap-nodes.ts | 130 ++ .../slate/transforms-selection/collapse.ts | 25 + .../slate/transforms-selection/deselect.ts | 13 + .../src/slate/transforms-selection/index.ts | 6 + .../src/slate/transforms-selection/move.ts | 48 + .../src/slate/transforms-selection/select.ts | 29 + .../slate/transforms-selection/set-point.ts | 31 + .../transforms-selection/set-selection.ts | 41 + .../src/slate/transforms-text/delete-text.ts | 208 ++ .../editor/src/slate/transforms-text/index.ts | 2 + .../slate/transforms-text/insert-fragment.ts | 267 +++ .../editor/src/slate/types/custom-types.ts | 30 + packages/editor/src/slate/types/index.ts | 2 + packages/editor/src/slate/types/types.ts | 19 + packages/editor/src/slate/utils/deep-equal.ts | 45 + .../utils/get-default-insert-location.ts | 17 + packages/editor/src/slate/utils/index.ts | 7 + packages/editor/src/slate/utils/is-object.ts | 2 + packages/editor/src/slate/utils/match-path.ts | 11 + packages/editor/src/slate/utils/modify.ts | 99 + packages/editor/src/slate/utils/string.ts | 325 +++ packages/editor/src/slate/utils/types.ts | 17 + packages/editor/src/slate/utils/weak-maps.ts | 9 + 172 files changed, 17304 insertions(+) create mode 100644 packages/editor/src/slate-dom/custom-types.ts create mode 100644 packages/editor/src/slate-dom/index.ts create mode 100644 packages/editor/src/slate-dom/plugin/dom-editor.ts create mode 100644 packages/editor/src/slate-dom/plugin/with-dom.ts create mode 100644 packages/editor/src/slate-dom/utils/constants.ts create mode 100644 packages/editor/src/slate-dom/utils/diff-text.ts create mode 100644 packages/editor/src/slate-dom/utils/dom.ts create mode 100644 packages/editor/src/slate-dom/utils/environment.ts create mode 100644 packages/editor/src/slate-dom/utils/hotkeys.ts create mode 100644 packages/editor/src/slate-dom/utils/key.ts create mode 100644 packages/editor/src/slate-dom/utils/lines.ts create mode 100644 packages/editor/src/slate-dom/utils/range-list.ts create mode 100644 packages/editor/src/slate-dom/utils/types.ts create mode 100644 packages/editor/src/slate-dom/utils/weak-maps.ts create mode 100644 packages/editor/src/slate-react/@types/direction.d.ts create mode 100644 packages/editor/src/slate-react/chunking/children-helper.ts create mode 100644 packages/editor/src/slate-react/chunking/chunk-tree-helper.ts create mode 100644 packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts create mode 100644 packages/editor/src/slate-react/chunking/index.ts create mode 100644 packages/editor/src/slate-react/chunking/reconcile-children.ts create mode 100644 packages/editor/src/slate-react/chunking/types.ts create mode 100644 packages/editor/src/slate-react/components/chunk-tree.tsx create mode 100644 packages/editor/src/slate-react/components/editable.tsx create mode 100644 packages/editor/src/slate-react/components/element.tsx create mode 100644 packages/editor/src/slate-react/components/leaf.tsx create mode 100644 packages/editor/src/slate-react/components/restore-dom/restore-dom-manager.ts create mode 100644 packages/editor/src/slate-react/components/restore-dom/restore-dom.tsx create mode 100644 packages/editor/src/slate-react/components/slate.tsx create mode 100644 packages/editor/src/slate-react/components/string.tsx create mode 100644 packages/editor/src/slate-react/components/text.tsx create mode 100644 packages/editor/src/slate-react/custom-types.ts create mode 100644 packages/editor/src/slate-react/hooks/android-input-manager/android-input-manager.ts create mode 100644 packages/editor/src/slate-react/hooks/android-input-manager/use-android-input-manager.ts create mode 100644 packages/editor/src/slate-react/hooks/use-children.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-composing.ts create mode 100644 packages/editor/src/slate-react/hooks/use-decorations.ts create mode 100644 packages/editor/src/slate-react/hooks/use-editor.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-element.ts create mode 100644 packages/editor/src/slate-react/hooks/use-focused.ts create mode 100644 packages/editor/src/slate-react/hooks/use-generic-selector.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-is-mounted.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-isomorphic-layout-effect.ts create mode 100644 packages/editor/src/slate-react/hooks/use-mutation-observer.ts create mode 100644 packages/editor/src/slate-react/hooks/use-read-only.ts create mode 100644 packages/editor/src/slate-react/hooks/use-selected.ts create mode 100644 packages/editor/src/slate-react/hooks/use-slate-selection.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-slate-selector.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-slate-static.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-slate.tsx create mode 100644 packages/editor/src/slate-react/hooks/use-track-user-input.ts create mode 100644 packages/editor/src/slate-react/index.ts create mode 100644 packages/editor/src/slate-react/plugin/react-editor.ts create mode 100644 packages/editor/src/slate-react/plugin/with-react.ts create mode 100644 packages/editor/src/slate-react/utils/environment.ts create mode 100644 packages/editor/src/slate/core/apply.ts create mode 100644 packages/editor/src/slate/core/batch-dirty-paths.ts create mode 100644 packages/editor/src/slate/core/get-dirty-paths.ts create mode 100644 packages/editor/src/slate/core/get-fragment.ts create mode 100644 packages/editor/src/slate/core/index.ts create mode 100644 packages/editor/src/slate/core/normalize-node.ts create mode 100644 packages/editor/src/slate/core/should-normalize.ts create mode 100644 packages/editor/src/slate/core/update-dirty-paths.ts create mode 100644 packages/editor/src/slate/create-editor.ts create mode 100644 packages/editor/src/slate/editor/above.ts create mode 100644 packages/editor/src/slate/editor/add-mark.ts create mode 100644 packages/editor/src/slate/editor/after.ts create mode 100644 packages/editor/src/slate/editor/before.ts create mode 100644 packages/editor/src/slate/editor/delete-backward.ts create mode 100644 packages/editor/src/slate/editor/delete-forward.ts create mode 100644 packages/editor/src/slate/editor/delete-fragment.ts create mode 100644 packages/editor/src/slate/editor/edges.ts create mode 100644 packages/editor/src/slate/editor/element-read-only.ts create mode 100644 packages/editor/src/slate/editor/end.ts create mode 100644 packages/editor/src/slate/editor/first.ts create mode 100644 packages/editor/src/slate/editor/fragment.ts create mode 100644 packages/editor/src/slate/editor/get-void.ts create mode 100644 packages/editor/src/slate/editor/has-blocks.ts create mode 100644 packages/editor/src/slate/editor/has-inlines.ts create mode 100644 packages/editor/src/slate/editor/has-path.ts create mode 100644 packages/editor/src/slate/editor/has-texts.ts create mode 100644 packages/editor/src/slate/editor/index.ts create mode 100644 packages/editor/src/slate/editor/insert-break.ts create mode 100644 packages/editor/src/slate/editor/insert-node.ts create mode 100644 packages/editor/src/slate/editor/insert-soft-break.ts create mode 100644 packages/editor/src/slate/editor/insert-text.ts create mode 100644 packages/editor/src/slate/editor/is-block.ts create mode 100644 packages/editor/src/slate/editor/is-edge.ts create mode 100644 packages/editor/src/slate/editor/is-editor.ts create mode 100644 packages/editor/src/slate/editor/is-empty.ts create mode 100644 packages/editor/src/slate/editor/is-end.ts create mode 100644 packages/editor/src/slate/editor/is-normalizing.ts create mode 100644 packages/editor/src/slate/editor/is-start.ts create mode 100644 packages/editor/src/slate/editor/last.ts create mode 100644 packages/editor/src/slate/editor/leaf.ts create mode 100644 packages/editor/src/slate/editor/levels.ts create mode 100644 packages/editor/src/slate/editor/marks.ts create mode 100644 packages/editor/src/slate/editor/next.ts create mode 100644 packages/editor/src/slate/editor/node.ts create mode 100644 packages/editor/src/slate/editor/nodes.ts create mode 100644 packages/editor/src/slate/editor/normalize.ts create mode 100644 packages/editor/src/slate/editor/parent.ts create mode 100644 packages/editor/src/slate/editor/path-ref.ts create mode 100644 packages/editor/src/slate/editor/path-refs.ts create mode 100644 packages/editor/src/slate/editor/path.ts create mode 100644 packages/editor/src/slate/editor/point-ref.ts create mode 100644 packages/editor/src/slate/editor/point-refs.ts create mode 100644 packages/editor/src/slate/editor/point.ts create mode 100644 packages/editor/src/slate/editor/positions.ts create mode 100644 packages/editor/src/slate/editor/previous.ts create mode 100644 packages/editor/src/slate/editor/range-ref.ts create mode 100644 packages/editor/src/slate/editor/range-refs.ts create mode 100644 packages/editor/src/slate/editor/range.ts create mode 100644 packages/editor/src/slate/editor/remove-mark.ts create mode 100644 packages/editor/src/slate/editor/set-normalizing.ts create mode 100644 packages/editor/src/slate/editor/should-merge-nodes-remove-prev-node.ts create mode 100644 packages/editor/src/slate/editor/start.ts create mode 100644 packages/editor/src/slate/editor/string.ts create mode 100644 packages/editor/src/slate/editor/unhang-range.ts create mode 100644 packages/editor/src/slate/editor/without-normalizing.ts create mode 100644 packages/editor/src/slate/index.ts create mode 100644 packages/editor/src/slate/interfaces/editor.ts create mode 100644 packages/editor/src/slate/interfaces/element.ts create mode 100644 packages/editor/src/slate/interfaces/index.ts create mode 100644 packages/editor/src/slate/interfaces/location.ts create mode 100644 packages/editor/src/slate/interfaces/node.ts create mode 100644 packages/editor/src/slate/interfaces/operation.ts create mode 100644 packages/editor/src/slate/interfaces/path-ref.ts create mode 100644 packages/editor/src/slate/interfaces/path.ts create mode 100644 packages/editor/src/slate/interfaces/point-ref.ts create mode 100644 packages/editor/src/slate/interfaces/point.ts create mode 100644 packages/editor/src/slate/interfaces/range-ref.ts create mode 100644 packages/editor/src/slate/interfaces/range.ts create mode 100644 packages/editor/src/slate/interfaces/scrubber.ts create mode 100644 packages/editor/src/slate/interfaces/text.ts create mode 100644 packages/editor/src/slate/interfaces/transforms/general.ts create mode 100644 packages/editor/src/slate/interfaces/transforms/index.ts create mode 100644 packages/editor/src/slate/interfaces/transforms/node.ts create mode 100644 packages/editor/src/slate/interfaces/transforms/selection.ts create mode 100644 packages/editor/src/slate/interfaces/transforms/text.ts create mode 100644 packages/editor/src/slate/transforms-node/index.ts create mode 100644 packages/editor/src/slate/transforms-node/insert-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/lift-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/merge-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/move-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/remove-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/set-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/split-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/unset-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/unwrap-nodes.ts create mode 100644 packages/editor/src/slate/transforms-node/wrap-nodes.ts create mode 100644 packages/editor/src/slate/transforms-selection/collapse.ts create mode 100644 packages/editor/src/slate/transforms-selection/deselect.ts create mode 100644 packages/editor/src/slate/transforms-selection/index.ts create mode 100644 packages/editor/src/slate/transforms-selection/move.ts create mode 100644 packages/editor/src/slate/transforms-selection/select.ts create mode 100644 packages/editor/src/slate/transforms-selection/set-point.ts create mode 100644 packages/editor/src/slate/transforms-selection/set-selection.ts create mode 100644 packages/editor/src/slate/transforms-text/delete-text.ts create mode 100644 packages/editor/src/slate/transforms-text/index.ts create mode 100644 packages/editor/src/slate/transforms-text/insert-fragment.ts create mode 100644 packages/editor/src/slate/types/custom-types.ts create mode 100644 packages/editor/src/slate/types/index.ts create mode 100644 packages/editor/src/slate/types/types.ts create mode 100644 packages/editor/src/slate/utils/deep-equal.ts create mode 100644 packages/editor/src/slate/utils/get-default-insert-location.ts create mode 100644 packages/editor/src/slate/utils/index.ts create mode 100644 packages/editor/src/slate/utils/is-object.ts create mode 100644 packages/editor/src/slate/utils/match-path.ts create mode 100644 packages/editor/src/slate/utils/modify.ts create mode 100644 packages/editor/src/slate/utils/string.ts create mode 100644 packages/editor/src/slate/utils/types.ts create mode 100644 packages/editor/src/slate/utils/weak-maps.ts diff --git a/packages/editor/src/slate-dom/custom-types.ts b/packages/editor/src/slate-dom/custom-types.ts new file mode 100644 index 000000000..546fd93a1 --- /dev/null +++ b/packages/editor/src/slate-dom/custom-types.ts @@ -0,0 +1,45 @@ +import {BaseRange, BaseText} from 'slate' +import {DOMEditor} from './plugin/dom-editor' + +declare module 'slate' { + interface CustomTypes { + Editor: DOMEditor + Text: BaseText & { + placeholder?: string + onPlaceholderResize?: (node: HTMLElement | null) => void + // FIXME: is unknown correct here? + [key: string]: unknown + } + Range: BaseRange & { + placeholder?: string + onPlaceholderResize?: (node: HTMLElement | null) => void + // FIXME: is unknown correct here? + [key: string]: unknown + } + } +} + +declare global { + interface Window { + MSStream: boolean + } + interface DocumentOrShadowRoot { + getSelection(): Selection | null + } + + interface CaretPosition { + readonly offsetNode: Node + readonly offset: number + getClientRect(): DOMRect | null + } + + interface Document { + caretPositionFromPoint(x: number, y: number): CaretPosition | null + } + + interface Node { + getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot + } +} + +export {} diff --git a/packages/editor/src/slate-dom/index.ts b/packages/editor/src/slate-dom/index.ts new file mode 100644 index 000000000..8f7474d9a --- /dev/null +++ b/packages/editor/src/slate-dom/index.ts @@ -0,0 +1,92 @@ +// Plugin +export {DOMEditor, type DOMEditorInterface} from './plugin/dom-editor' +export {withDOM} from './plugin/with-dom' + +// Utils +export {TRIPLE_CLICK} from './utils/constants' + +export { + applyStringDiff, + mergeStringDiffs, + normalizePoint, + normalizeRange, + normalizeStringDiff, + StringDiff, + targetRange, + TextDiff, + verifyDiffState, +} from './utils/diff-text' + +export { + closestShadowAware, + containsShadowAware, + DOMElement, + DOMNode, + DOMPoint, + DOMRange, + DOMSelection, + DOMStaticRange, + DOMText, + getActiveElement, + getDefaultView, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + isPlainTextOnlyPaste, + isTrackedMutation, + normalizeDOMPoint, +} from './utils/dom' + +export { + CAN_USE_DOM, + HAS_BEFORE_INPUT_SUPPORT, + IS_ANDROID, + IS_CHROME, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_IOS, + IS_WEBKIT, + IS_UC_MOBILE, + IS_WECHATBROWSER, +} from './utils/environment' + +export {default as Hotkeys} from './utils/hotkeys' + +export {Key} from './utils/key' + +export { + isElementDecorationsEqual, + isTextDecorationsEqual, + splitDecorationsByChild, +} from './utils/range-list' + +export { + EDITOR_TO_ELEMENT, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_PLACEHOLDER_ELEMENT, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_NODE_MAP_DIRTY, + IS_READ_ONLY, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, + PLACEHOLDER_SYMBOL, +} from './utils/weak-maps' diff --git a/packages/editor/src/slate-dom/plugin/dom-editor.ts b/packages/editor/src/slate-dom/plugin/dom-editor.ts new file mode 100644 index 000000000..93f97290a --- /dev/null +++ b/packages/editor/src/slate-dom/plugin/dom-editor.ts @@ -0,0 +1,1098 @@ +import { + BaseEditor, + Editor, + Element, + Node, + Path, + Point, + Range, + Scrubber, + Transforms, +} from 'slate' +import {TextDiff} from '../utils/diff-text' +import { + closestShadowAware, + containsShadowAware, + DOMElement, + DOMNode, + DOMPoint, + DOMRange, + DOMSelection, + DOMStaticRange, + DOMText, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + normalizeDOMPoint, +} from '../utils/dom' +import {IS_ANDROID, IS_CHROME, IS_FIREFOX} from '../utils/environment' +import {Key} from '../utils/key' +import { + EDITOR_TO_ELEMENT, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_READ_ONLY, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, +} from '../utils/weak-maps' + +/** + * A DOM-specific version of the `Editor` interface. + */ + +export interface DOMEditor extends BaseEditor { + hasEditableTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => target is DOMNode + hasRange: (editor: DOMEditor, range: Range) => boolean + hasSelectableTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => boolean + hasTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => target is DOMNode + insertData: (data: DataTransfer) => void + insertFragmentData: (data: DataTransfer) => boolean + insertTextData: (data: DataTransfer) => boolean + isTargetInsideNonReadonlyVoid: ( + editor: DOMEditor, + target: EventTarget | null, + ) => boolean + setFragmentData: ( + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut', + ) => void +} + +export interface DOMEditorInterface { + /** + * Experimental and android specific: Get pending diffs + */ + androidPendingDiffs: (editor: Editor) => TextDiff[] | undefined + + /** + * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. + */ + androidScheduleFlush: (editor: Editor) => void + + /** + * Blur the editor. + */ + blur: (editor: DOMEditor) => void + + /** + * Deselect the editor. + */ + deselect: (editor: DOMEditor) => void + + /** + * Find the DOM node that implements DocumentOrShadowRoot for the editor. + */ + findDocumentOrShadowRoot: (editor: DOMEditor) => Document | ShadowRoot + + /** + * Get the target range from a DOM `event`. + */ + findEventRange: (editor: DOMEditor, event: any) => Range + + /** + * Find a key for a Slate node. + */ + findKey: (editor: DOMEditor, node: Node) => Key + + /** + * Find the path of Slate node. + */ + findPath: (editor: DOMEditor, node: Node) => Path + + /** + * Focus the editor. + */ + focus: (editor: DOMEditor, options?: {retries: number}) => void + + /** + * Return the host window of the current editor. + */ + getWindow: (editor: DOMEditor) => Window + + /** + * Check if a DOM node is within the editor. + */ + hasDOMNode: ( + editor: DOMEditor, + target: DOMNode, + options?: {editable?: boolean}, + ) => boolean + + /** + * Check if the target is editable and in the editor. + */ + hasEditableTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => target is DOMNode + + /** + * + */ + hasRange: (editor: DOMEditor, range: Range) => boolean + + /** + * Check if the target can be selectable + */ + hasSelectableTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => boolean + + /** + * Check if the target is in the editor. + */ + hasTarget: ( + editor: DOMEditor, + target: EventTarget | null, + ) => target is DOMNode + + /** + * Insert data from a `DataTransfer` into the editor. + */ + insertData: (editor: DOMEditor, data: DataTransfer) => void + + /** + * Insert fragment data from a `DataTransfer` into the editor. + */ + insertFragmentData: (editor: DOMEditor, data: DataTransfer) => boolean + + /** + * Insert text data from a `DataTransfer` into the editor. + */ + insertTextData: (editor: DOMEditor, data: DataTransfer) => boolean + + /** + * Check if the user is currently composing inside the editor. + */ + isComposing: (editor: DOMEditor) => boolean + + /** + * Check if the editor is focused. + */ + isFocused: (editor: DOMEditor) => boolean + + /** + * Check if the editor is in read-only mode. + */ + isReadOnly: (editor: DOMEditor) => boolean + + /** + * Check if the target is inside void and in an non-readonly editor. + */ + isTargetInsideNonReadonlyVoid: ( + editor: DOMEditor, + target: EventTarget | null, + ) => boolean + + /** + * Sets data from the currently selected fragment on a `DataTransfer`. + */ + setFragmentData: ( + editor: DOMEditor, + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut', + ) => void + + /** + * Find the native DOM element from a Slate node. + */ + toDOMNode: (editor: DOMEditor, node: Node) => HTMLElement + + /** + * Find a native DOM selection point from a Slate point. + */ + toDOMPoint: (editor: DOMEditor, point: Point) => DOMPoint + + /** + * Find a native DOM range from a Slate `range`. + * + * Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit. + * + * there is no way to create a reverse DOM Range using Range.setStart/setEnd + * according to https://dom.spec.whatwg.org/#concept-range-bp-set. + */ + toDOMRange: (editor: DOMEditor, range: Range) => DOMRange + + /** + * Find a Slate node from a native DOM `element`. + */ + toSlateNode: (editor: DOMEditor, domNode: DOMNode) => Node + + /** + * Find a Slate point from a DOM selection's `domNode` and `domOffset`. + */ + toSlatePoint: ( + editor: DOMEditor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + /** + * The direction to search for Slate leaf nodes if `domPoint` is + * non-editable and non-void. + */ + searchDirection?: 'forward' | 'backward' + }, + ) => T extends true ? Point | null : Point + + /** + * Find a Slate range from a DOM range or selection. + */ + toSlateRange: ( + editor: DOMEditor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + }, + ) => T extends true ? Range | null : Range +} + +// eslint-disable-next-line no-redeclare +export const DOMEditor: DOMEditorInterface = { + androidPendingDiffs: (editor) => EDITOR_TO_PENDING_DIFFS.get(editor), + + androidScheduleFlush: (editor) => { + EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() + }, + + blur: (editor) => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + IS_FOCUSED.set(editor, false) + + if (root.activeElement === el) { + el.blur() + } + }, + + deselect: (editor) => { + const {selection} = editor + const root = DOMEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if (domSelection && domSelection.rangeCount > 0) { + domSelection.removeAllRanges() + } + + if (selection) { + Transforms.deselect(editor) + } + }, + + findDocumentOrShadowRoot: (editor) => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (root instanceof Document || root instanceof ShadowRoot) { + return root + } + + return el.ownerDocument + }, + + findEventRange: (editor, event) => { + if ('nativeEvent' in event) { + event = event.nativeEvent + } + + const {clientX: x, clientY: y, target} = event + + if (x == null || y == null) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + const node = DOMEditor.toSlateNode(editor, event.target) + const path = DOMEditor.findPath(editor, node) + + // If the drop target is inside a void node, move it into either the + // next or previous node, depending on which side the `x` and `y` + // coordinates are closest to. + if (Element.isElement(node) && Editor.isVoid(editor, node)) { + const rect = target.getBoundingClientRect() + const isPrev = editor.isInline(node) + ? x - rect.left < rect.left + rect.width - x + : y - rect.top < rect.top + rect.height - y + + const edge = Editor.point(editor, path, { + edge: isPrev ? 'start' : 'end', + }) + const point = isPrev + ? Editor.before(editor, edge) + : Editor.after(editor, edge) + + if (point) { + const range = Editor.range(editor, point) + return range + } + } + + // Else resolve a range from the caret position where the drop occured. + let domRange + const {document} = DOMEditor.getWindow(editor) + + // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) + if (document.caretRangeFromPoint) { + domRange = document.caretRangeFromPoint(x, y) + } else { + const position = document.caretPositionFromPoint(x, y) + + if (position) { + domRange = document.createRange() + domRange.setStart(position.offsetNode, position.offset) + domRange.setEnd(position.offsetNode, position.offset) + } + } + + if (!domRange) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + // Resolve a Slate range from the DOM range. + const range = DOMEditor.toSlateRange(editor, domRange, { + exactMatch: false, + suppressThrow: false, + }) + return range + }, + + findKey: (editor, node) => { + let key = NODE_TO_KEY.get(node) + + if (!key) { + key = new Key() + NODE_TO_KEY.set(node, key) + } + + return key + }, + + findPath: (editor, node) => { + const path: Path = [] + let child = node + + while (true) { + const parent = NODE_TO_PARENT.get(child) + + if (parent == null) { + if (Editor.isEditor(child)) { + return path + } else { + break + } + } + + const i = NODE_TO_INDEX.get(child) + + if (i == null) { + break + } + + path.unshift(i) + child = parent + } + + throw new Error( + `Unable to find the path for Slate node: ${Scrubber.stringify(node)}`, + ) + }, + + focus: (editor, options = {retries: 5}) => { + // Return if already focused + if (IS_FOCUSED.get(editor)) { + return + } + + // Return if no dom node is associated with the editor, which means the editor is not yet mounted + // or has been unmounted. This can happen especially, while retrying to focus the editor. + if (!EDITOR_TO_ELEMENT.get(editor)) { + return + } + + // Retry setting focus if the editor has pending operations. + // The DOM (selection) is unstable while changes are applied. + // Retry until retries are exhausted or editor is focused. + if (options.retries <= 0) { + throw new Error( + 'Could not set focus, editor seems stuck with pending operations', + ) + } + if (editor.operations.length > 0) { + setTimeout(() => { + DOMEditor.focus(editor, {retries: options.retries - 1}) + }, 10) + return + } + + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + if (root.activeElement !== el) { + // Ensure that the DOM selection state is set to the editor's selection + if (editor.selection && root instanceof Document) { + const domSelection = getSelection(root) + const domRange = DOMEditor.toDOMRange(editor, editor.selection) + domSelection?.removeAllRanges() + domSelection?.addRange(domRange) + } + // Create a new selection in the top of the document if missing + if (!editor.selection) { + Transforms.select(editor, Editor.start(editor, [])) + } + // IS_FOCUSED should be set before calling el.focus() to ensure that + // FocusedContext is updated to the correct value + IS_FOCUSED.set(editor, true) + el.focus({preventScroll: true}) + } + }, + + getWindow: (editor) => { + const window = EDITOR_TO_WINDOW.get(editor) + if (!window) { + throw new Error('Unable to find a host window element for this editor') + } + return window + }, + + hasDOMNode: (editor, target, options = {}) => { + const {editable = false} = options + const editorEl = DOMEditor.toDOMNode(editor, editor) + let targetEl + + // COMPAT: In Firefox, reading `target.nodeType` will throw an error if + // target is originating from an internal "restricted" element (e.g. a + // stepper arrow on a number input). (2018/05/04) + // https://github.com/ianstormtaylor/slate/issues/1819 + try { + targetEl = ( + isDOMElement(target) ? target : target.parentElement + ) as HTMLElement + } catch (err) { + if ( + err instanceof Error && + !err.message.includes('Permission denied to access property "nodeType"') + ) { + throw err + } + } + + if (!targetEl) { + return false + } + + return ( + closestShadowAware(targetEl, `[data-slate-editor]`) === editorEl && + (!editable || targetEl.isContentEditable + ? true + : (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined + // this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly) + closestShadowAware(targetEl, '[contenteditable="false"]') === + editorEl) || + !!targetEl.getAttribute('data-slate-zero-width')) + ) + }, + + hasEditableTarget: (editor, target): target is DOMNode => + isDOMNode(target) && DOMEditor.hasDOMNode(editor, target, {editable: true}), + + hasRange: (editor, range) => { + const {anchor, focus} = range + return ( + Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) + ) + }, + + hasSelectableTarget: (editor, target) => + DOMEditor.hasEditableTarget(editor, target) || + DOMEditor.isTargetInsideNonReadonlyVoid(editor, target), + + hasTarget: (editor, target): target is DOMNode => + isDOMNode(target) && DOMEditor.hasDOMNode(editor, target), + + insertData: (editor, data) => { + editor.insertData(data) + }, + + insertFragmentData: (editor, data) => editor.insertFragmentData(data), + + insertTextData: (editor, data) => editor.insertTextData(data), + + isComposing: (editor) => { + return !!IS_COMPOSING.get(editor) + }, + + isFocused: (editor) => !!IS_FOCUSED.get(editor), + + isReadOnly: (editor) => !!IS_READ_ONLY.get(editor), + + isTargetInsideNonReadonlyVoid: (editor, target) => { + if (IS_READ_ONLY.get(editor)) return false + + const slateNode = + DOMEditor.hasTarget(editor, target) && + DOMEditor.toSlateNode(editor, target) + return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) + }, + + setFragmentData: (editor, data, originEvent) => + editor.setFragmentData(data, originEvent), + + toDOMNode: (editor, node) => { + const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor) + const domNode = Editor.isEditor(node) + ? EDITOR_TO_ELEMENT.get(editor) + : KEY_TO_ELEMENT?.get(DOMEditor.findKey(editor, node)) + + if (!domNode) { + throw new Error( + `Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}`, + ) + } + + return domNode + }, + + toDOMPoint: (editor, point) => { + const [node] = Editor.node(editor, point.path) + const el = DOMEditor.toDOMNode(editor, node) + let domPoint: DOMPoint | undefined + + // If we're inside a void node, force the offset to 0, otherwise the zero + // width spacing character will result in an incorrect offset of 1 + if (Editor.void(editor, {at: point})) { + point = {path: point.path, offset: 0} + } + + // For each leaf, we need to isolate its content, which means filtering + // to its direct text and zero-width spans. (We have to filter out any + // other siblings that may have been rendered alongside them.) + const selector = `[data-slate-string], [data-slate-zero-width]` + const texts = Array.from(el.querySelectorAll(selector)) + let start = 0 + + for (let i = 0; i < texts.length; i++) { + const text = texts[i] + const domNode = text.childNodes[0] as HTMLElement + + if (domNode == null || domNode.textContent == null) { + continue + } + + const {length} = domNode.textContent + const attr = text.getAttribute('data-slate-length') + const trueLength = attr == null ? length : parseInt(attr, 10) + const end = start + trueLength + + // Prefer putting the selection inside the mark placeholder to ensure + // composed text is displayed with the correct marks. + const nextText = texts[i + 1] + if ( + point.offset === end && + nextText?.hasAttribute('data-slate-mark-placeholder') + ) { + const domText = nextText.childNodes[0] + + domPoint = [ + // COMPAT: If we don't explicity set the dom point to be on the actual + // dom text element, chrome will put the selection behind the actual dom + // text element, causing domRange.getBoundingClientRect() calls on a collapsed + // selection to return incorrect zero values (https://bugs.chromium.org/p/chromium/issues/detail?id=435438) + // which will cause issues when scrolling to it. + domText instanceof DOMText ? domText : nextText, + nextText.textContent?.startsWith('\uFEFF') ? 1 : 0, + ] + break + } + + if (point.offset <= end) { + const offset = Math.min(length, Math.max(0, point.offset - start)) + domPoint = [domNode, offset] + break + } + + start = end + } + + if (!domPoint) { + throw new Error( + `Cannot resolve a DOM point from Slate point: ${Scrubber.stringify( + point, + )}`, + ) + } + + return domPoint + }, + + toDOMRange: (editor, range) => { + const {anchor, focus} = range + const isBackward = Range.isBackward(range) + const domAnchor = DOMEditor.toDOMPoint(editor, anchor) + const domFocus = Range.isCollapsed(range) + ? domAnchor + : DOMEditor.toDOMPoint(editor, focus) + + const window = DOMEditor.getWindow(editor) + const domRange = window.document.createRange() + const [startNode, startOffset] = isBackward ? domFocus : domAnchor + const [endNode, endOffset] = isBackward ? domAnchor : domFocus + + // A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at + // zero-width node has an offset of 1 so we have to check if we are in a zero-width node and + // adjust the offset accordingly. + const startEl = ( + isDOMElement(startNode) ? startNode : startNode.parentElement + ) as HTMLElement + const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width') + const endEl = ( + isDOMElement(endNode) ? endNode : endNode.parentElement + ) as HTMLElement + const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width') + + domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset) + domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset) + return domRange + }, + + toSlateNode: (editor, domNode) => { + let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement + + if (domEl && !domEl.hasAttribute('data-slate-node')) { + domEl = domEl.closest(`[data-slate-node]`) + } + + const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null + + if (!node) { + throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`) + } + + return node + }, + + toSlatePoint: ( + editor: DOMEditor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + searchDirection?: 'forward' | 'backward' + }, + ): T extends true ? Point | null : Point => { + const {exactMatch, suppressThrow, searchDirection} = options + const [nearestNode, nearestOffset] = exactMatch + ? domPoint + : normalizeDOMPoint(domPoint) + const parentNode = nearestNode.parentNode as DOMElement + let textNode: DOMElement | null = null + let offset = 0 + + if (parentNode) { + const editorEl = DOMEditor.toDOMNode(editor, editor) + const potentialVoidNode = parentNode.closest('[data-slate-void="true"]') + // Need to ensure that the closest void node is actually a void node + // within this editor, and not a void node within some parent editor. This can happen + // if this editor is within a void node of another editor ("nested editors", like in + // the "Editable Voids" example on the docs site). + const voidNode = + potentialVoidNode && containsShadowAware(editorEl, potentialVoidNode) + ? potentialVoidNode + : null + const potentialNonEditableNode = parentNode.closest( + '[contenteditable="false"]', + ) + const nonEditableNode = + potentialNonEditableNode && + containsShadowAware(editorEl, potentialNonEditableNode) + ? potentialNonEditableNode + : null + let leafNode = parentNode.closest('[data-slate-leaf]') + let domNode: DOMElement | null = null + + // Calculate how far into the text node the `nearestNode` is, so that we + // can determine what the offset relative to the text node is. + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]') + + if (textNode) { + const window = DOMEditor.getWindow(editor) + const range = window.document.createRange() + range.setStart(textNode, 0) + range.setEnd(nearestNode, nearestOffset) + + const contents = range.cloneContents() + const removals = [ + ...Array.prototype.slice.call( + contents.querySelectorAll('[data-slate-zero-width]'), + ), + ...Array.prototype.slice.call( + contents.querySelectorAll('[contenteditable=false]'), + ), + ] + + removals.forEach((el) => { + // COMPAT: While composing at the start of a text node, some keyboards put + // the text content inside the zero width space. + if ( + IS_ANDROID && + !exactMatch && + el.hasAttribute('data-slate-zero-width') && + el.textContent.length > 0 && + el.textContext !== '\uFEFF' + ) { + if (el.textContent.startsWith('\uFEFF')) { + el.textContent = el.textContent.slice(1) + } + + return + } + + el!.parentNode!.removeChild(el) + }) + + // COMPAT: Edge has a bug where Range.prototype.toString() will + // convert \n into \r\n. The bug causes a loop when slate-dom + // attempts to reposition its cursor to match the native position. Use + // textContent.length instead. + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/ + offset = contents.textContent!.length + domNode = textNode + } + } else if (voidNode) { + // For void nodes, the element with the offset key will be a cousin, not an + // ancestor, so find it by going down from the nearest void parent and taking the + // first one that isn't inside a nested editor. + const leafNodes = voidNode.querySelectorAll('[data-slate-leaf]') + for (let index = 0; index < leafNodes.length; index++) { + const current = leafNodes[index] + if (DOMEditor.hasDOMNode(editor, current)) { + leafNode = current + break + } + } + + // COMPAT: In read-only editors the leaf is not rendered. + if (!leafNode) { + offset = 1 + } else { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + offset = domNode.textContent!.length + domNode.querySelectorAll('[data-slate-zero-width]').forEach((el) => { + offset -= el.textContent!.length + }) + } + } else if (nonEditableNode) { + // Find the edge of the nearest leaf in `searchDirection` + const getLeafNodes = (node: DOMElement | null | undefined) => + node + ? node.querySelectorAll( + // Exclude leaf nodes in nested editors + '[data-slate-leaf]:not(:scope [data-slate-editor] [data-slate-leaf])', + ) + : [] + const elementNode = nonEditableNode.closest( + '[data-slate-node="element"]', + ) + + if (searchDirection === 'backward' || !searchDirection) { + const leafNodes = [ + ...getLeafNodes(elementNode?.previousElementSibling), + ...getLeafNodes(elementNode), + ] + + leafNode = + leafNodes.findLast((leaf) => isBefore(nonEditableNode, leaf)) ?? + null + + if (leafNode) { + searchDirection === 'backward' + } + } + + if (searchDirection === 'forward' || !searchDirection) { + const leafNodes = [ + ...getLeafNodes(elementNode), + ...getLeafNodes(elementNode?.nextElementSibling), + ] + + leafNode = + leafNodes.find((leaf) => isAfter(nonEditableNode, leaf)) ?? null + + if (leafNode) { + searchDirection === 'forward' + } + } + + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + if (searchDirection === 'forward') { + offset = 0 + } else { + offset = domNode.textContent!.length + domNode + .querySelectorAll('[data-slate-zero-width]') + .forEach((el) => { + offset -= el.textContent!.length + }) + } + } + } + + if ( + domNode && + offset === domNode.textContent!.length && + // COMPAT: Android IMEs might remove the zero width space while composing, + // and we don't add it for line-breaks. + IS_ANDROID && + domNode.getAttribute('data-slate-zero-width') === 'z' && + domNode.textContent?.startsWith('\uFEFF') && + // COMPAT: If the parent node is a Slate zero-width space, editor is + // because the text node should have no characters. However, during IME + // composition the ASCII characters will be prepended to the zero-width + // space, so subtract 1 from the offset to account for the zero-width + // space character. + (parentNode.hasAttribute('data-slate-zero-width') || + // COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n' + // when the document ends with a new-line character. This results in the offset + // length being off by one, so we need to subtract one to account for this. + (IS_FIREFOX && domNode.textContent?.endsWith('\n\n'))) + ) { + offset-- + } + } + + if (IS_ANDROID && !textNode && !exactMatch) { + const node = parentNode.hasAttribute('data-slate-node') + ? parentNode + : parentNode.closest('[data-slate-node]') + + if (node && DOMEditor.hasDOMNode(editor, node, {editable: true})) { + const slateNode = DOMEditor.toSlateNode(editor, node) + let {path, offset} = Editor.start( + editor, + DOMEditor.findPath(editor, slateNode), + ) + + if (!node.querySelector('[data-slate-leaf]')) { + offset = nearestOffset + } + + return {path, offset} as T extends true ? Point | null : Point + } + } + + if (!textNode) { + if (suppressThrow) { + return null as T extends true ? Point | null : Point + } + throw new Error( + `Cannot resolve a Slate point from DOM point: ${domPoint}`, + ) + } + + // COMPAT: If someone is clicking from one Slate editor into another, + // the select event fires twice, once for the old editor's `element` + // first, and then afterwards for the correct `element`. (2017/03/03) + const slateNode = DOMEditor.toSlateNode(editor, textNode!) + const path = DOMEditor.findPath(editor, slateNode) + return {path, offset} as T extends true ? Point | null : Point + }, + + toSlateRange: ( + editor: DOMEditor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + }, + ): T extends true ? Range | null : Range => { + const {exactMatch, suppressThrow} = options + const el = isDOMSelection(domRange) + ? domRange.anchorNode + : domRange.startContainer + let anchorNode + let anchorOffset + let focusNode + let focusOffset + let isCollapsed + + if (el) { + if (isDOMSelection(domRange)) { + // COMPAT: In firefox the normal seletion way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domRange.rangeCount > 1) { + focusNode = domRange.focusNode // Focus node works fine + const firstRange = domRange.getRangeAt(0) + const lastRange = domRange.getRangeAt(domRange.rangeCount - 1) + + // Here we are in the contenteditable mode of a table in firefox + if ( + focusNode instanceof HTMLTableRowElement && + firstRange.startContainer instanceof HTMLTableRowElement && + lastRange.startContainer instanceof HTMLTableRowElement + ) { + // HTMLElement, becouse Element is a slate element + function getLastChildren(element: HTMLElement): HTMLElement { + if (element.childElementCount > 0) { + return getLastChildren(element.children[0]) + } else { + return element + } + } + + const firstNodeRow = firstRange.startContainer + const lastNodeRow = lastRange.startContainer + + // This should never fail as "The HTMLElement interface represents any HTML element." + const firstNode = getLastChildren( + firstNodeRow.children[firstRange.startOffset], + ) + const lastNode = getLastChildren( + lastNodeRow.children[lastRange.startOffset], + ) + + // Zero, as we allways take the right one as the anchor point + focusOffset = 0 + + if (lastNode.childNodes.length > 0) { + anchorNode = lastNode.childNodes[0] + } else { + anchorNode = lastNode + } + + if (firstNode.childNodes.length > 0) { + focusNode = firstNode.childNodes[0] + } else { + focusNode = firstNode + } + + if (lastNode instanceof HTMLElement) { + anchorOffset = (lastNode).innerHTML.length + } else { + // Fallback option + anchorOffset = 0 + } + } else { + // This is the read only mode of a firefox table + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + anchorOffset = lastRange.endOffset + focusOffset = firstRange.startOffset + } else { + // Left to right + anchorNode = firstRange.startContainer + anchorOffset = firstRange.endOffset + focusOffset = lastRange.startOffset + } + } + } else { + anchorNode = domRange.anchorNode + anchorOffset = domRange.anchorOffset + focusNode = domRange.focusNode + focusOffset = domRange.focusOffset + } + + // COMPAT: There's a bug in chrome that always returns `true` for + // `isCollapsed` for a Selection that comes from a ShadowRoot. + // (2020/08/08) + // https://bugs.chromium.org/p/chromium/issues/detail?id=447523 + // IsCollapsed might not work in firefox, but this will + if ((IS_CHROME && hasShadowRoot(anchorNode)) || IS_FIREFOX) { + isCollapsed = + domRange.anchorNode === domRange.focusNode && + domRange.anchorOffset === domRange.focusOffset + } else { + isCollapsed = domRange.isCollapsed + } + } else { + anchorNode = domRange.startContainer + anchorOffset = domRange.startOffset + focusNode = domRange.endContainer + focusOffset = domRange.endOffset + isCollapsed = domRange.collapsed + } + } + + if ( + anchorNode == null || + focusNode == null || + anchorOffset == null || + focusOffset == null + ) { + throw new Error( + `Cannot resolve a Slate range from DOM range: ${domRange}`, + ) + } + + // COMPAT: Firefox sometimes includes an extra \n (rendered by TextString + // when isTrailing is true) in the focusOffset, resulting in an invalid + // Slate point. (2023/11/01) + if ( + IS_FIREFOX && + focusNode.textContent?.endsWith('\n\n') && + focusOffset === focusNode.textContent.length + ) { + focusOffset-- + } + + const anchor = DOMEditor.toSlatePoint(editor, [anchorNode, anchorOffset], { + exactMatch, + suppressThrow, + }) + if (!anchor) { + return null as T extends true ? Range | null : Range + } + + const focusBeforeAnchor = + isBefore(anchorNode, focusNode) || + (anchorNode === focusNode && focusOffset < anchorOffset) + const focus = isCollapsed + ? anchor + : DOMEditor.toSlatePoint(editor, [focusNode, focusOffset], { + exactMatch, + suppressThrow, + searchDirection: focusBeforeAnchor ? 'forward' : 'backward', + }) + if (!focus) { + return null as T extends true ? Range | null : Range + } + + let range: Range = {anchor: anchor as Point, focus: focus as Point} + // if the selection is a hanging range that ends in a void + // and the DOM focus is an Element + // (meaning that the selection ends before the element) + // unhang the range to avoid mistakenly including the void + if ( + Range.isExpanded(range) && + Range.isForward(range) && + isDOMElement(focusNode) && + Editor.void(editor, {at: range.focus, mode: 'highest'}) + ) { + range = Editor.unhangRange(editor, range, {voids: true}) + } + + return range as unknown as T extends true ? Range | null : Range + }, +} diff --git a/packages/editor/src/slate-dom/plugin/with-dom.ts b/packages/editor/src/slate-dom/plugin/with-dom.ts new file mode 100644 index 000000000..52593f178 --- /dev/null +++ b/packages/editor/src/slate-dom/plugin/with-dom.ts @@ -0,0 +1,383 @@ +import { + BaseEditor, + Editor, + Element, + Node, + Operation, + Path, + PathRef, + Point, + Range, + Transforms, +} from 'slate' +import { + TextDiff, + transformPendingPoint, + transformPendingRange, + transformTextDiff, +} from '../utils/diff-text' +import {getPlainText, getSlateFragmentAttribute, isDOMText} from '../utils/dom' +import {Key} from '../utils/key' +import {findCurrentLineRange} from '../utils/lines' +import { + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + IS_NODE_MAP_DIRTY, + NODE_TO_KEY, +} from '../utils/weak-maps' +import {DOMEditor} from './dom-editor' + +/** + * `withDOM` adds DOM specific behaviors to the editor. + * + * If you are using TypeScript, you must extend Slate's CustomTypes to use + * this plugin. + * + * See https://docs.slatejs.org/concepts/11-typescript to learn how. + */ + +export const withDOM = ( + editor: T, + clipboardFormatKey = 'x-slate-fragment', +): T & DOMEditor => { + const e = editor as T & DOMEditor + const {apply, onChange, deleteBackward, addMark, removeMark} = e + + // The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to + // avoid collisions between editors in the DOM that share the same value. + EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap()) + + e.addMark = (key, value) => { + EDITOR_TO_SCHEDULE_FLUSH.get(e)?.() + + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + addMark(key, value) + } + + e.removeMark = (key) => { + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + removeMark(key) + } + + e.deleteBackward = (unit) => { + if (unit !== 'line') { + return deleteBackward(unit) + } + + if (e.selection && Range.isCollapsed(e.selection)) { + const parentBlockEntry = Editor.above(e, { + match: (n) => Element.isElement(n) && Editor.isBlock(e, n), + at: e.selection, + }) + + if (parentBlockEntry) { + const [, parentBlockPath] = parentBlockEntry + const parentElementRange = Editor.range( + e, + parentBlockPath, + e.selection.anchor, + ) + + const currentLineRange = findCurrentLineRange(e, parentElementRange) + + if (!Range.isCollapsed(currentLineRange)) { + Transforms.delete(e, {at: currentLineRange}) + } + } + } + } + + // This attempts to reset the NODE_TO_KEY entry to the correct value + // as apply() changes the object reference and hence invalidates the NODE_TO_KEY entry + e.apply = (op: Operation) => { + const matches: [Path, Key][] = [] + const pathRefMatches: [PathRef, Key][] = [] + + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(e) + if (pendingDiffs?.length) { + const transformed = pendingDiffs + .map((textDiff) => transformTextDiff(textDiff, op)) + .filter(Boolean) as TextDiff[] + + EDITOR_TO_PENDING_DIFFS.set(e, transformed) + } + + const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(e) + if (pendingSelection) { + EDITOR_TO_PENDING_SELECTION.set( + e, + transformPendingRange(e, pendingSelection, op), + ) + } + + const pendingAction = EDITOR_TO_PENDING_ACTION.get(e) + if (pendingAction?.at) { + const at = Point.isPoint(pendingAction?.at) + ? transformPendingPoint(e, pendingAction.at, op) + : transformPendingRange(e, pendingAction.at, op) + + EDITOR_TO_PENDING_ACTION.set(e, at ? {...pendingAction, at} : null) + } + + switch (op.type) { + case 'insert_text': + case 'remove_text': + case 'set_node': + case 'split_node': { + matches.push(...getMatches(e, op.path)) + break + } + + case 'set_selection': { + // Selection was manually set, don't restore the user selection after the change. + EDITOR_TO_USER_SELECTION.get(e)?.unref() + EDITOR_TO_USER_SELECTION.delete(e) + break + } + + case 'insert_node': + case 'remove_node': { + matches.push(...getMatches(e, Path.parent(op.path))) + break + } + + case 'merge_node': { + const prevPath = Path.previous(op.path) + matches.push(...getMatches(e, prevPath)) + break + } + + case 'move_node': { + const commonPath = Path.common( + Path.parent(op.path), + Path.parent(op.newPath), + ) + matches.push(...getMatches(e, commonPath)) + + let changedPath: Path + if (Path.isBefore(op.path, op.newPath)) { + matches.push(...getMatches(e, Path.parent(op.path))) + changedPath = op.newPath + } else { + matches.push(...getMatches(e, Path.parent(op.newPath))) + changedPath = op.path + } + + const changedNode = Node.get(editor, Path.parent(changedPath)) + const changedNodeKey = DOMEditor.findKey(e, changedNode) + const changedPathRef = Editor.pathRef(e, Path.parent(changedPath)) + pathRefMatches.push([changedPathRef, changedNodeKey]) + + break + } + } + + apply(op) + + switch (op.type) { + case 'insert_node': + case 'remove_node': + case 'merge_node': + case 'move_node': + case 'split_node': + case 'insert_text': + case 'remove_text': + case 'set_selection': { + // FIXME: Rename to something like IS_DOM_EDITOR_DESYNCED + // to better reflect reality, see #5792 + IS_NODE_MAP_DIRTY.set(e, true) + } + } + + for (const [path, key] of matches) { + const [node] = Editor.node(e, path) + NODE_TO_KEY.set(node, key) + } + + for (const [pathRef, key] of pathRefMatches) { + if (pathRef.current) { + const [node] = Editor.node(e, pathRef.current) + NODE_TO_KEY.set(node, key) + } + + pathRef.unref() + } + } + + e.setFragmentData = (data: Pick) => { + const {selection} = e + + if (!selection) { + return + } + + const [start, end] = Range.edges(selection) + const startVoid = Editor.void(e, {at: start.path}) + const endVoid = Editor.void(e, {at: end.path}) + + if (Range.isCollapsed(selection) && !startVoid) { + return + } + + // Create a fake selection so that we can add a Base64-encoded copy of the + // fragment to the HTML, to decode on future pastes. + const domRange = DOMEditor.toDOMRange(e, selection) + let contents = domRange.cloneContents() + let attach = contents.childNodes[0] as HTMLElement + + // Make sure attach is non-empty, since empty nodes will not get copied. + contents.childNodes.forEach((node) => { + if (node.textContent && node.textContent.trim() !== '') { + attach = node as HTMLElement + } + }) + + // COMPAT: If the end node is a void node, we need to move the end of the + // range from the void node's spacer span, to the end of the void node's + // content, since the spacer is before void's content in the DOM. + if (endVoid) { + const [voidNode] = endVoid + const r = domRange.cloneRange() + const domNode = DOMEditor.toDOMNode(e, voidNode) + r.setEndAfter(domNode) + contents = r.cloneContents() + } + + // COMPAT: If the start node is a void node, we need to attach the encoded + // fragment to the void node's content node instead of the spacer, because + // attaching it to empty `
/` nodes will end up having it erased by + // most browsers. (2018/04/27) + if (startVoid) { + attach = contents.querySelector('[data-slate-spacer]')! as HTMLElement + } + + // Remove any zero-width space spans from the cloned DOM so that they don't + // show up elsewhere when pasted. + Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach( + (zw) => { + const isNewline = zw.getAttribute('data-slate-zero-width') === 'n' + zw.textContent = isNewline ? '\n' : '' + }, + ) + + // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up + // in the HTML, and can be used for intra-Slate pasting. If it's a text + // node, wrap it in a `` so we have something to set an attribute on. + if (isDOMText(attach)) { + const span = attach.ownerDocument.createElement('span') + // COMPAT: In Chrome and Safari, if we don't add the `white-space` style + // then leading and trailing spaces will be ignored. (2017/09/21) + span.style.whiteSpace = 'pre' + span.appendChild(attach) + contents.appendChild(span) + attach = span + } + + const fragment = e.getFragment() + const string = JSON.stringify(fragment) + const encoded = window.btoa(encodeURIComponent(string)) + attach.setAttribute('data-slate-fragment', encoded) + data.setData(`application/${clipboardFormatKey}`, encoded) + + // Add the content to a
so that we can get its inner HTML. + const div = contents.ownerDocument.createElement('div') + div.appendChild(contents) + div.setAttribute('hidden', 'true') + contents.ownerDocument.body.appendChild(div) + data.setData('text/html', div.innerHTML) + data.setData('text/plain', getPlainText(div)) + contents.ownerDocument.body.removeChild(div) + return data + } + + e.insertData = (data: DataTransfer) => { + if (!e.insertFragmentData(data)) { + e.insertTextData(data) + } + } + + e.insertFragmentData = (data: DataTransfer): boolean => { + /** + * Checking copied fragment from application/x-slate-fragment or data-slate-fragment + */ + const fragment = + data.getData(`application/${clipboardFormatKey}`) || + getSlateFragmentAttribute(data) + + if (fragment) { + const decoded = decodeURIComponent(window.atob(fragment)) + const parsed = JSON.parse(decoded) as Node[] + e.insertFragment(parsed) + return true + } + return false + } + + e.insertTextData = (data: DataTransfer): boolean => { + const text = data.getData('text/plain') + + if (text) { + const lines = text.split(/\r\n|\r|\n/) + let split = false + + for (const line of lines) { + if (split) { + Transforms.splitNodes(e, {always: true}) + } + + e.insertText(line) + split = true + } + return true + } + return false + } + + e.onChange = (options) => { + const onContextChange = EDITOR_TO_ON_CHANGE.get(e) + + if (onContextChange) { + onContextChange(options) + } + + onChange(options) + } + + return e +} + +const getMatches = (e: Editor, path: Path) => { + const matches: [Path, Key][] = [] + for (const [n, p] of Editor.levels(e, {at: path})) { + const key = DOMEditor.findKey(e, n) + matches.push([p, key]) + } + return matches +} diff --git a/packages/editor/src/slate-dom/utils/constants.ts b/packages/editor/src/slate-dom/utils/constants.ts new file mode 100644 index 000000000..a8a13e355 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/constants.ts @@ -0,0 +1 @@ +export const TRIPLE_CLICK = 3 diff --git a/packages/editor/src/slate-dom/utils/diff-text.ts b/packages/editor/src/slate-dom/utils/diff-text.ts new file mode 100644 index 000000000..abde0762d --- /dev/null +++ b/packages/editor/src/slate-dom/utils/diff-text.ts @@ -0,0 +1,415 @@ +import {Editor, Element, Node, Operation, Path, Point, Range, Text} from 'slate' +import {EDITOR_TO_PENDING_DIFFS} from './weak-maps' + +export type StringDiff = { + start: number + end: number + text: string +} + +export type TextDiff = { + id: number + path: Path + diff: StringDiff +} + +/** + * Check whether a text diff was applied in a way we can perform the pending action on / + * recover the pending selection. + */ +export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { + const {path, diff} = textDiff + if (!Editor.hasPath(editor, path)) { + return false + } + + const node = Node.get(editor, path) + if (!Text.isText(node)) { + return false + } + + if (diff.start !== node.text.length || diff.text.length === 0) { + return ( + node.text.slice(diff.start, diff.start + diff.text.length) === diff.text + ) + } + + const nextPath = Path.next(path) + if (!Editor.hasPath(editor, nextPath)) { + return false + } + + const nextNode = Node.get(editor, nextPath) + return Text.isText(nextNode) && nextNode.text.startsWith(diff.text) +} + +export function applyStringDiff(text: string, ...diffs: StringDiff[]) { + return diffs.reduce( + (text, diff) => + text.slice(0, diff.start) + diff.text + text.slice(diff.end), + text, + ) +} + +function longestCommonPrefixLength(str: string, another: string) { + const length = Math.min(str.length, another.length) + + for (let i = 0; i < length; i++) { + if (str.charAt(i) !== another.charAt(i)) { + return i + } + } + + return length +} + +function longestCommonSuffixLength( + str: string, + another: string, + max: number, +): number { + const length = Math.min(str.length, another.length, max) + + for (let i = 0; i < length; i++) { + if ( + str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1) + ) { + return i + } + } + + return length +} + +/** + * Remove redundant changes from the diff so that it spans the minimal possible range + */ +export function normalizeStringDiff(targetText: string, diff: StringDiff) { + const {start, end, text} = diff + const removedText = targetText.slice(start, end) + + const prefixLength = longestCommonPrefixLength(removedText, text) + const max = Math.min( + removedText.length - prefixLength, + text.length - prefixLength, + ) + const suffixLength = longestCommonSuffixLength(removedText, text, max) + + const normalized: StringDiff = { + start: start + prefixLength, + end: end - suffixLength, + text: text.slice(prefixLength, text.length - suffixLength), + } + + if (normalized.start === normalized.end && normalized.text.length === 0) { + return null + } + + return normalized +} + +/** + * Return a string diff that is equivalent to applying b after a spanning the range of + * both changes + */ +export function mergeStringDiffs( + targetText: string, + a: StringDiff, + b: StringDiff, +): StringDiff | null { + const start = Math.min(a.start, b.start) + const overlap = Math.max( + 0, + Math.min(a.start + a.text.length, b.end) - b.start, + ) + + const applied = applyStringDiff(targetText, a, b) + const sliceEnd = Math.max( + b.start + b.text.length, + a.start + + a.text.length + + (a.start + a.text.length > b.start ? b.text.length : 0) - + overlap, + ) + + const text = applied.slice(start, sliceEnd) + const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start)) + return normalizeStringDiff(targetText, {start, end, text}) +} + +/** + * Get the slate range the text diff spans. + */ +export function targetRange(textDiff: TextDiff): Range { + const {path, diff} = textDiff + return { + anchor: {path, offset: diff.start}, + focus: {path, offset: diff.end}, + } +} + +/** + * Normalize a 'pending point' a.k.a a point based on the dom state before applying + * the pending diffs. Since the pending diffs might have been inserted with different + * marks we have to 'walk' the offset from the starting position to ensure we still + * have a valid point inside the document + */ +export function normalizePoint(editor: Editor, point: Point): Point | null { + let {path, offset} = point + if (!Editor.hasPath(editor, path)) { + return null + } + + let leaf = Node.get(editor, path) + if (!Text.isText(leaf)) { + return null + } + + const parentBlock = Editor.above(editor, { + match: (n) => Element.isElement(n) && Editor.isBlock(editor, n), + at: path, + }) + + if (!parentBlock) { + return null + } + + while (offset > leaf.text.length) { + const entry = Editor.next(editor, {at: path, match: Text.isText}) + if (!entry || !Path.isDescendant(entry[1], parentBlock[1])) { + return null + } + + offset -= leaf.text.length + leaf = entry[0] + path = entry[1] + } + + return {path, offset} +} + +/** + * Normalize a 'pending selection' to ensure it's valid in the current document state. + */ +export function normalizeRange(editor: Editor, range: Range): Range | null { + const anchor = normalizePoint(editor, range.anchor) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return {anchor, focus: anchor} + } + + const focus = normalizePoint(editor, range.focus) + if (!focus) { + return null + } + + return {anchor, focus} +} + +export function transformPendingPoint( + editor: Editor, + point: Point, + op: Operation, +): Point | null { + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + const textDiff = pendingDiffs?.find(({path}) => Path.equals(path, point.path)) + + if (!textDiff || point.offset <= textDiff.diff.start) { + return Point.transform(point, op, {affinity: 'backward'}) + } + + const {diff} = textDiff + // Point references location inside the diff => transform the point based on the location + // the diff will be applied to and add the offset inside the diff. + if (point.offset <= diff.start + diff.text.length) { + const anchor = {path: point.path, offset: diff.start} + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + + if (!transformed) { + return null + } + + return { + path: transformed.path, + offset: transformed.offset + point.offset - diff.start, + } + } + + // Point references location after the diff + const anchor = { + path: point.path, + offset: point.offset - diff.text.length + diff.end - diff.start, + } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + if (!transformed) { + return null + } + + if ( + op.type === 'split_node' && + Path.equals(op.path, point.path) && + anchor.offset < op.position && + diff.start < op.position + ) { + return transformed + } + + return { + path: transformed.path, + offset: transformed.offset + diff.text.length - diff.end + diff.start, + } +} + +export function transformPendingRange( + editor: Editor, + range: Range, + op: Operation, +): Range | null { + const anchor = transformPendingPoint(editor, range.anchor, op) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return {anchor, focus: anchor} + } + + const focus = transformPendingPoint(editor, range.focus, op) + if (!focus) { + return null + } + + return {anchor, focus} +} + +export function transformTextDiff( + textDiff: TextDiff, + op: Operation, +): TextDiff | null { + const {path, diff, id} = textDiff + + switch (op.type) { + case 'insert_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset <= diff.start) { + return { + diff: { + start: op.text.length + diff.start, + end: op.text.length + diff.end, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end + op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'remove_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset + op.text.length <= diff.start) { + return { + diff: { + start: diff.start - op.text.length, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'split_node': { + if (!Path.equals(op.path, path) || op.position >= diff.end) { + return { + diff, + id, + path: Path.transform(path, op, {affinity: 'backward'})!, + } + } + + if (op.position > diff.start) { + return { + diff: { + start: diff.start, + end: Math.min(op.position, diff.end), + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start - op.position, + end: diff.end - op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op, {affinity: 'forward'})!, + } + } + case 'merge_node': { + if (!Path.equals(op.path, path)) { + return { + diff, + id, + path: Path.transform(path, op)!, + } + } + + return { + diff: { + start: diff.start + op.position, + end: diff.end + op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op)!, + } + } + } + + const newPath = Path.transform(path, op) + if (!newPath) { + return null + } + + return { + diff, + path: newPath, + id, + } +} diff --git a/packages/editor/src/slate-dom/utils/dom.ts b/packages/editor/src/slate-dom/utils/dom.ts new file mode 100644 index 000000000..e30c8aa1f --- /dev/null +++ b/packages/editor/src/slate-dom/utils/dom.ts @@ -0,0 +1,426 @@ +import {DOMEditor} from '../plugin/dom-editor' + +/** + * Types. + */ + +// COMPAT: This is required to prevent TypeScript aliases from doing some very +// weird things for Slate's types with the same name as globals. (2019/11/27) +// https://github.com/microsoft/TypeScript/issues/35002 +import DOMNode = globalThis.Node +import DOMComment = globalThis.Comment +import DOMElement = globalThis.Element +import DOMText = globalThis.Text +import DOMRange = globalThis.Range +import DOMSelection = globalThis.Selection +import DOMStaticRange = globalThis.StaticRange + +export { + DOMNode, + DOMComment, + DOMElement, + DOMText, + DOMRange, + DOMSelection, + DOMStaticRange, +} + +declare global { + interface Window { + Selection: (typeof Selection)['constructor'] + DataTransfer: (typeof DataTransfer)['constructor'] + Node: (typeof Node)['constructor'] + } +} + +export type DOMPoint = [Node, number] + +/** + * Returns the host window of a DOM node + */ + +export const getDefaultView = (value: any): Window | null => { + return ( + (value && value.ownerDocument && value.ownerDocument.defaultView) || null + ) +} + +/** + * Check if a DOM node is a comment node. + */ + +export const isDOMComment = (value: any): value is DOMComment => { + return isDOMNode(value) && value.nodeType === 8 +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMElement = (value: any): value is DOMElement => { + return isDOMNode(value) && value.nodeType === 1 +} + +/** + * Check if a value is a DOM node. + */ + +export const isDOMNode = (value: any): value is DOMNode => { + const window = getDefaultView(value) + return !!window && value instanceof window.Node +} + +/** + * Check if a value is a DOM selection. + */ + +export const isDOMSelection = (value: any): value is DOMSelection => { + const window = value && value.anchorNode && getDefaultView(value.anchorNode) + return !!window && value instanceof window.Selection +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMText = (value: any): value is DOMText => { + return isDOMNode(value) && value.nodeType === 3 +} + +/** + * Checks whether a paste event is a plaintext-only event. + */ + +export const isPlainTextOnlyPaste = (event: ClipboardEvent) => { + return ( + event.clipboardData && + event.clipboardData.getData('text/plain') !== '' && + event.clipboardData.types.length === 1 + ) +} + +/** + * Normalize a DOM point so that it always refers to a text node. + */ + +export const normalizeDOMPoint = (domPoint: DOMPoint): DOMPoint => { + let [node, offset] = domPoint + + // If it's an element node, its offset refers to the index of its children + // including comment nodes, so try to find the right text child node. + if (isDOMElement(node) && node.childNodes.length) { + let isLast = offset === node.childNodes.length + let index = isLast ? offset - 1 : offset + ;[node, index] = getEditableChildAndIndex( + node, + index, + isLast ? 'backward' : 'forward', + ) + // If the editable child found is in front of input offset, we instead seek to its end + isLast = index < offset + + // If the node has children, traverse until we have a leaf node. Leaf nodes + // can be either text nodes, or other void DOM nodes. + while (isDOMElement(node) && node.childNodes.length) { + const i = isLast ? node.childNodes.length - 1 : 0 + node = getEditableChild(node, i, isLast ? 'backward' : 'forward') + } + + // Determine the new offset inside the text node. + offset = isLast && node.textContent != null ? node.textContent.length : 0 + } + + // Return the node and offset. + return [node, offset] +} + +/** + * Determines whether the active element is nested within a shadowRoot + */ + +export const hasShadowRoot = (node: Node | null) => { + let parent = node && node.parentNode + while (parent) { + if (parent.toString() === '[object ShadowRoot]') { + return true + } + parent = parent.parentNode + } + return false +} + +/** + * Get the nearest editable child and index at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChildAndIndex = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward', +): [DOMNode, number] => { + const {childNodes} = parent + let child = childNodes[index] + let i = index + let triedForward = false + let triedBackward = false + + // While the child is a comment node, or an element node with no children, + // keep iterating to find a sibling non-void, non-comment node. + while ( + isDOMComment(child) || + (isDOMElement(child) && child.childNodes.length === 0) || + (isDOMElement(child) && child.getAttribute('contenteditable') === 'false') + ) { + if (triedForward && triedBackward) { + break + } + + if (i >= childNodes.length) { + triedForward = true + i = index - 1 + direction = 'backward' + continue + } + + if (i < 0) { + triedBackward = true + i = index + 1 + direction = 'forward' + continue + } + + child = childNodes[i] + index = i + i += direction === 'forward' ? 1 : -1 + } + + return [child, index] +} + +/** + * Get the nearest editable child at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChild = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward', +): DOMNode => { + const [child] = getEditableChildAndIndex(parent, index, direction) + return child +} + +/** + * Get a plaintext representation of the content of a node, accounting for block + * elements which get a newline appended. + * + * The domNode must be attached to the DOM. + */ + +export const getPlainText = (domNode: DOMNode) => { + let text = '' + + if (isDOMText(domNode) && domNode.nodeValue) { + return domNode.nodeValue + } + + if (isDOMElement(domNode)) { + for (const childNode of Array.from(domNode.childNodes)) { + text += getPlainText(childNode) + } + + const display = getComputedStyle(domNode).getPropertyValue('display') + + if (display === 'block' || display === 'list' || domNode.tagName === 'BR') { + text += '\n' + } + } + + return text +} + +/** + * Get x-slate-fragment attribute from data-slate-fragment + */ +const catchSlateFragment = /data-slate-fragment="(.+?)"/m +export const getSlateFragmentAttribute = ( + dataTransfer: DataTransfer, +): string | void => { + const htmlData = dataTransfer.getData('text/html') + const [, fragment] = htmlData.match(catchSlateFragment) || [] + return fragment +} + +/** + * Get the x-slate-fragment attribute that exist in text/html data + * and append it to the DataTransfer object + */ +export const getClipboardData = ( + dataTransfer: DataTransfer, + clipboardFormatKey = 'x-slate-fragment', +): DataTransfer => { + if (!dataTransfer.getData(`application/${clipboardFormatKey}`)) { + const fragment = getSlateFragmentAttribute(dataTransfer) + if (fragment) { + const clipboardData = new DataTransfer() + dataTransfer.types.forEach((type) => { + clipboardData.setData(type, dataTransfer.getData(type)) + }) + clipboardData.setData(`application/${clipboardFormatKey}`, fragment) + return clipboardData + } + } + return dataTransfer +} + +/** + * Get the dom selection from Shadow Root if possible, otherwise from the document + */ +export const getSelection = (root: Document | ShadowRoot): Selection | null => { + if (root.getSelection != null) { + return root.getSelection() + } + return document.getSelection() +} + +/** + * Check whether a mutation originates from a editable element inside the editor. + */ + +export const isTrackedMutation = ( + editor: DOMEditor, + mutation: MutationRecord, + batch: MutationRecord[], +): boolean => { + const {target} = mutation + if (isDOMElement(target) && target.matches('[contentEditable="false"]')) { + return false + } + + const {document} = DOMEditor.getWindow(editor) + if (containsShadowAware(document, target)) { + return DOMEditor.hasDOMNode(editor, target, {editable: true}) + } + + const parentMutation = batch.find(({addedNodes, removedNodes}) => { + for (const node of addedNodes) { + if (node === target || containsShadowAware(node, target)) { + return true + } + } + + for (const node of removedNodes) { + if (node === target || containsShadowAware(node, target)) { + return true + } + } + }) + + if (!parentMutation || parentMutation === mutation) { + return false + } + + // Target add/remove is tracked. Track the mutation if we track the parent mutation. + return isTrackedMutation(editor, parentMutation, batch) +} + +/** + * Retrieves the deepest active element in the DOM, considering nested shadow DOMs. + */ +export const getActiveElement = () => { + let activeElement = document.activeElement + + while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement?.shadowRoot?.activeElement + } + + return activeElement +} + +/** + * @returns `true` if `otherNode` is before `node` in the document; otherwise, `false`. + */ +export const isBefore = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & + DOMNode.DOCUMENT_POSITION_PRECEDING, + ) + +/** + * @returns `true` if `otherNode` is after `node` in the document; otherwise, `false`. + */ +export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & + DOMNode.DOCUMENT_POSITION_FOLLOWING, + ) + +/** + * Shadow DOM-aware version of Element.closest() + * Traverses up the DOM tree, crossing shadow DOM boundaries + */ +export const closestShadowAware = ( + element: DOMElement | null | undefined, + selector: string, +): DOMElement | null => { + if (!element) { + return null + } + + let current: DOMElement | null = element + + while (current) { + if (current.matches && current.matches(selector)) { + return current + } + + if (current.parentElement) { + current = current.parentElement + } else if (current.parentNode && 'host' in current.parentNode) { + current = (current.parentNode as ShadowRoot).host as DOMElement + } else { + return null + } + } + + return null +} + +/** + * Shadow DOM-aware version of Node.contains() + * Checks if a node contains another node, crossing shadow DOM boundaries + */ +export const containsShadowAware = ( + parent: DOMNode | null | undefined, + child: DOMNode | null | undefined, +): boolean => { + if (!parent || !child) { + return false + } + + if (parent.contains(child)) { + return true + } + + let current: DOMNode | null = child + + while (current) { + if (current === parent) { + return true + } + + if (current.parentNode) { + if ('host' in current.parentNode) { + current = (current.parentNode as ShadowRoot).host + } else { + current = current.parentNode + } + } else { + return false + } + } + + return false +} diff --git a/packages/editor/src/slate-dom/utils/environment.ts b/packages/editor/src/slate-dom/utils/environment.ts new file mode 100644 index 000000000..f97233075 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/environment.ts @@ -0,0 +1,83 @@ +export const IS_IOS = + typeof navigator !== 'undefined' && + typeof window !== 'undefined' && + /iPad|iPhone|iPod/.test(navigator.userAgent) && + !window.MSStream + +export const IS_APPLE = + typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) + +export const IS_ANDROID = + typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) + +export const IS_FIREFOX = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) + +export const IS_WEBKIT = + typeof navigator !== 'undefined' && + /AppleWebKit(?!.*Chrome)/i.test(navigator.userAgent) + +// "modern" Edge was released at 79.x +export const IS_EDGE_LEGACY = + typeof navigator !== 'undefined' && + /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent) + +export const IS_CHROME = + typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent) + +// Native `beforeInput` events don't work well with react on Chrome 75 +// and older, Chrome 76+ can use `beforeInput` though. +export const IS_CHROME_LEGACY = + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])(?:\.)/i.test(navigator.userAgent) + +export const IS_ANDROID_CHROME_LEGACY = + IS_ANDROID && + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-5]?\d)(?:\.)/i.test(navigator.userAgent) + +// Firefox did not support `beforeInput` until `v87`. +export const IS_FIREFOX_LEGACY = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test( + navigator.userAgent, + ) + +// UC mobile browser +export const IS_UC_MOBILE = + typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent) + +// Wechat browser (not including mac wechat) +export const IS_WECHATBROWSER = + typeof navigator !== 'undefined' && + /.*Wechat/.test(navigator.userAgent) && + !/.*MacWechat/.test(navigator.userAgent) && // avoid lookbehind (buggy in safari < 16.4) + (!IS_CHROME || IS_CHROME_LEGACY) // wechat and low chrome is real wechat +// Check if DOM is available as React does internally. +// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js +export const CAN_USE_DOM = !!( + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined' +) + +// Check if the browser is Safari and older than 17 +export const IS_SAFARI_LEGACY = + typeof navigator !== 'undefined' && + /Safari/.test(navigator.userAgent) && + /Version\/(\d+)/.test(navigator.userAgent) && + (navigator.userAgent.match(/Version\/(\d+)/)?.[1] + ? parseInt(navigator.userAgent.match(/Version\/(\d+)/)?.[1]!, 10) < 17 + : false) + +// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event +// Chrome Legacy doesn't support `beforeinput` correctly +export const HAS_BEFORE_INPUT_SUPPORT = + (!IS_CHROME_LEGACY || !IS_ANDROID_CHROME_LEGACY) && + !IS_EDGE_LEGACY && + // globalThis is undefined in older browsers + typeof globalThis !== 'undefined' && + globalThis.InputEvent && + // @ts-ignore The `getTargetRanges` property isn't recognized. + typeof globalThis.InputEvent.prototype.getTargetRanges === 'function' diff --git a/packages/editor/src/slate-dom/utils/hotkeys.ts b/packages/editor/src/slate-dom/utils/hotkeys.ts new file mode 100644 index 000000000..c6c8e4d90 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/hotkeys.ts @@ -0,0 +1,97 @@ +import {isHotkey} from 'is-hotkey' +import {IS_APPLE} from './environment' + +/** + * Hotkey mappings for each platform. + */ + +const HOTKEYS = { + bold: 'mod+b', + compose: ['down', 'left', 'right', 'up', 'backspace', 'enter'], + moveBackward: 'left', + moveForward: 'right', + moveWordBackward: 'ctrl+left', + moveWordForward: 'ctrl+right', + deleteBackward: 'shift?+backspace', + deleteForward: 'shift?+delete', + extendBackward: 'shift+left', + extendForward: 'shift+right', + italic: 'mod+i', + insertSoftBreak: 'shift+enter', + splitBlock: 'enter', + undo: 'mod+z', +} + +const APPLE_HOTKEYS = { + moveLineBackward: 'opt+up', + moveLineForward: 'opt+down', + moveWordBackward: 'opt+left', + moveWordForward: 'opt+right', + deleteBackward: ['ctrl+backspace', 'ctrl+h'], + deleteForward: ['ctrl+delete', 'ctrl+d'], + deleteLineBackward: 'cmd+shift?+backspace', + deleteLineForward: ['cmd+shift?+delete', 'ctrl+k'], + deleteWordBackward: 'opt+shift?+backspace', + deleteWordForward: 'opt+shift?+delete', + extendLineBackward: 'opt+shift+up', + extendLineForward: 'opt+shift+down', + redo: 'cmd+shift+z', + transposeCharacter: 'ctrl+t', +} + +const WINDOWS_HOTKEYS = { + deleteWordBackward: 'ctrl+shift?+backspace', + deleteWordForward: 'ctrl+shift?+delete', + redo: ['ctrl+y', 'ctrl+shift+z'], +} + +/** + * Create a platform-aware hotkey checker. + */ + +const create = (key: string) => { + const generic = HOTKEYS[key] + const apple = APPLE_HOTKEYS[key] + const windows = WINDOWS_HOTKEYS[key] + const isGeneric = generic && isHotkey(generic) + const isApple = apple && isHotkey(apple) + const isWindows = windows && isHotkey(windows) + + return (event: KeyboardEvent) => { + if (isGeneric && isGeneric(event)) return true + if (IS_APPLE && isApple && isApple(event)) return true + if (!IS_APPLE && isWindows && isWindows(event)) return true + return false + } +} + +/** + * Hotkeys. + */ + +export default { + isBold: create('bold'), + isCompose: create('compose'), + isMoveBackward: create('moveBackward'), + isMoveForward: create('moveForward'), + isDeleteBackward: create('deleteBackward'), + isDeleteForward: create('deleteForward'), + isDeleteLineBackward: create('deleteLineBackward'), + isDeleteLineForward: create('deleteLineForward'), + isDeleteWordBackward: create('deleteWordBackward'), + isDeleteWordForward: create('deleteWordForward'), + isExtendBackward: create('extendBackward'), + isExtendForward: create('extendForward'), + isExtendLineBackward: create('extendLineBackward'), + isExtendLineForward: create('extendLineForward'), + isItalic: create('italic'), + isMoveLineBackward: create('moveLineBackward'), + isMoveLineForward: create('moveLineForward'), + isMoveWordBackward: create('moveWordBackward'), + isMoveWordForward: create('moveWordForward'), + isRedo: create('redo'), + isSoftBreak: create('insertSoftBreak'), + isSplitBlock: create('splitBlock'), + isTransposeCharacter: create('transposeCharacter'), + isUndo: create('undo'), +} diff --git a/packages/editor/src/slate-dom/utils/key.ts b/packages/editor/src/slate-dom/utils/key.ts new file mode 100644 index 000000000..0c8773312 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/key.ts @@ -0,0 +1,18 @@ +/** + * An auto-incrementing identifier for keys. + */ + +let n = 0 + +/** + * A class that keeps track of a key string. We use a full class here because we + * want to be able to use them as keys in `WeakMap` objects. + */ + +export class Key { + id: string + + constructor() { + this.id = `${n++}` + } +} diff --git a/packages/editor/src/slate-dom/utils/lines.ts b/packages/editor/src/slate-dom/utils/lines.ts new file mode 100644 index 000000000..c5aa4995b --- /dev/null +++ b/packages/editor/src/slate-dom/utils/lines.ts @@ -0,0 +1,75 @@ +/** + * Utilities for single-line deletion + */ + +import {Editor, Range} from 'slate' +import {DOMEditor} from '../plugin/dom-editor' + +const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { + const middle = (compareRect.top + compareRect.bottom) / 2 + + return rect.top <= middle && rect.bottom >= middle +} + +const areRangesSameLine = (editor: DOMEditor, range1: Range, range2: Range) => { + const rect1 = DOMEditor.toDOMRange(editor, range1).getBoundingClientRect() + const rect2 = DOMEditor.toDOMRange(editor, range2).getBoundingClientRect() + + return doRectsIntersect(rect1, rect2) && doRectsIntersect(rect2, rect1) +} + +/** + * A helper utility that returns the end portion of a `Range` + * which is located on a single line. + * + * @param {Editor} editor The editor object to compare against + * @param {Range} parentRange The parent range to compare against + * @returns {Range} A valid portion of the parentRange which is one a single line + */ +export const findCurrentLineRange = ( + editor: DOMEditor, + parentRange: Range, +): Range => { + const parentRangeBoundary = Editor.range(editor, Range.end(parentRange)) + const positions = Array.from(Editor.positions(editor, {at: parentRange})) + + let left = 0 + let right = positions.length + let middle = Math.floor(right / 2) + + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[left]), + parentRangeBoundary, + ) + ) { + return Editor.range(editor, positions[left], parentRangeBoundary) + } + + if (positions.length < 2) { + return Editor.range( + editor, + positions[positions.length - 1], + parentRangeBoundary, + ) + } + + while (middle !== positions.length && middle !== left) { + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[middle]), + parentRangeBoundary, + ) + ) { + right = middle + } else { + left = middle + } + + middle = Math.floor((left + right) / 2) + } + + return Editor.range(editor, positions[left], parentRangeBoundary) +} diff --git a/packages/editor/src/slate-dom/utils/range-list.ts b/packages/editor/src/slate-dom/utils/range-list.ts new file mode 100644 index 000000000..90c080c62 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/range-list.ts @@ -0,0 +1,161 @@ +import {Ancestor, DecoratedRange, Editor, Range} from 'slate' +import {DOMEditor} from '../plugin/dom-editor' +import {PLACEHOLDER_SYMBOL} from './weak-maps' + +export const shallowCompare = ( + obj1: {[key: string]: unknown}, + obj2: {[key: string]: unknown}, +) => + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every( + (key) => obj2.hasOwnProperty(key) && obj1[key] === obj2[key], + ) + +const isDecorationFlagsEqual = (range: Range, other: Range) => { + const {anchor: rangeAnchor, focus: rangeFocus, ...rangeOwnProps} = range + const {anchor: otherAnchor, focus: otherFocus, ...otherOwnProps} = other + + return ( + range[PLACEHOLDER_SYMBOL] === other[PLACEHOLDER_SYMBOL] && + shallowCompare(rangeOwnProps, otherOwnProps) + ) +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isElementDecorationsEqual = ( + list: Range[] | null, + another: Range[] | null, +): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i] + const other = another[i] + + if (!Range.equals(range, other) || !isDecorationFlagsEqual(range, other)) { + return false + } + } + + return true +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isTextDecorationsEqual = ( + list: Range[] | null, + another: Range[] | null, +): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i] + const other = another[i] + + // compare only offsets because paths doesn't matter for text + if ( + range.anchor.offset !== other.anchor.offset || + range.focus.offset !== other.focus.offset || + !isDecorationFlagsEqual(range, other) + ) { + return false + } + } + + return true +} + +/** + * Split and group decorations by each child of a node. + * + * @returns An array with length equal to that of `node.children`. Each index + * corresponds to a child of `node`, and the value is an array of decorations + * for that child. + */ + +export const splitDecorationsByChild = ( + editor: Editor, + node: Ancestor, + decorations: DecoratedRange[], +): DecoratedRange[][] => { + const decorationsByChild = Array.from( + node.children, + (): DecoratedRange[] => [], + ) + + if (decorations.length === 0) { + return decorationsByChild + } + + const path = DOMEditor.findPath(editor, node) + const level = path.length + const ancestorRange = Editor.range(editor, path) + + const cachedChildRanges = new Array(node.children.length) + + const getChildRange = (index: number) => { + const cachedRange = cachedChildRanges[index] + if (cachedRange) return cachedRange + const childRange = Editor.range(editor, [...path, index]) + cachedChildRanges[index] = childRange + return childRange + } + + for (const decoration of decorations) { + const decorationRange = Range.intersection(ancestorRange, decoration) + if (!decorationRange) continue + + const [startPoint, endPoint] = Range.edges(decorationRange) + const startIndex = startPoint.path[level] + const endIndex = endPoint.path[level] + + for (let i = startIndex; i <= endIndex; i++) { + const ds = decorationsByChild[i] + if (!ds) continue + + const childRange = getChildRange(i) + const childDecorationRange = Range.intersection(childRange, decoration) + if (!childDecorationRange) continue + + ds.push({ + ...decoration, + ...childDecorationRange, + }) + } + } + + return decorationsByChild +} diff --git a/packages/editor/src/slate-dom/utils/types.ts b/packages/editor/src/slate-dom/utils/types.ts new file mode 100644 index 000000000..c4d025c36 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/types.ts @@ -0,0 +1,3 @@ +export type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R + ? (...args: P) => R + : never diff --git a/packages/editor/src/slate-dom/utils/weak-maps.ts b/packages/editor/src/slate-dom/utils/weak-maps.ts new file mode 100644 index 000000000..d82af513a --- /dev/null +++ b/packages/editor/src/slate-dom/utils/weak-maps.ts @@ -0,0 +1,98 @@ +import { + Ancestor, + Editor, + Node, + Operation, + Point, + Range, + RangeRef, + Text, +} from 'slate' +import {TextDiff} from './diff-text' +import {Key} from './key' + +export type Action = {at?: Point | Range; run: () => void} + +/** + * Two weak maps that allow us rebuild a path given a node. They are populated + * at render time such that after a render occurs we can always backtrack. + */ +export const IS_NODE_MAP_DIRTY: WeakMap = new WeakMap() +export const NODE_TO_INDEX: WeakMap = new WeakMap() +export const NODE_TO_PARENT: WeakMap = new WeakMap() + +/** + * Weak maps that allow us to go between Slate nodes and DOM nodes. These + * are used to resolve DOM event-related logic into Slate actions. + */ +export const EDITOR_TO_WINDOW: WeakMap = new WeakMap() +export const EDITOR_TO_ELEMENT: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER_ELEMENT: WeakMap = + new WeakMap() +export const ELEMENT_TO_NODE: WeakMap = new WeakMap() +export const NODE_TO_ELEMENT: WeakMap = new WeakMap() +export const NODE_TO_KEY: WeakMap = new WeakMap() +export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap< + Editor, + WeakMap +> = new WeakMap() + +/** + * Weak maps for storing editor-related state. + */ + +export const IS_READ_ONLY: WeakMap = new WeakMap() +export const IS_FOCUSED: WeakMap = new WeakMap() +export const IS_COMPOSING: WeakMap = new WeakMap() + +export const EDITOR_TO_USER_SELECTION: WeakMap = + new WeakMap() + +/** + * Weak map for associating the context `onChange` context with the plugin. + */ + +export const EDITOR_TO_ON_CHANGE = new WeakMap< + Editor, + (options?: {operation?: Operation}) => void +>() + +/** + * Weak maps for saving pending state on composition stage. + */ + +export const EDITOR_TO_SCHEDULE_FLUSH: WeakMap void> = + new WeakMap() + +export const EDITOR_TO_PENDING_INSERTION_MARKS: WeakMap< + Editor, + Partial | null +> = new WeakMap() + +export const EDITOR_TO_USER_MARKS: WeakMap | null> = + new WeakMap() + +/** + * Android input handling specific weak-maps + */ + +export const EDITOR_TO_PENDING_DIFFS: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_ACTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_SELECTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_FORCE_RENDER: WeakMap void> = new WeakMap() + +/** + * Symbols. + */ + +export const PLACEHOLDER_SYMBOL = Symbol('placeholder') as unknown as string +export const MARK_PLACEHOLDER_SYMBOL = Symbol( + 'mark-placeholder', +) as unknown as string diff --git a/packages/editor/src/slate-react/@types/direction.d.ts b/packages/editor/src/slate-react/@types/direction.d.ts new file mode 100644 index 000000000..fc1d26dd4 --- /dev/null +++ b/packages/editor/src/slate-react/@types/direction.d.ts @@ -0,0 +1,4 @@ +declare module 'direction' { + function direction(text: string): 'neutral' | 'ltr' | 'rtl' + export default direction +} diff --git a/packages/editor/src/slate-react/chunking/children-helper.ts b/packages/editor/src/slate-react/chunking/children-helper.ts new file mode 100644 index 000000000..b85066dac --- /dev/null +++ b/packages/editor/src/slate-react/chunking/children-helper.ts @@ -0,0 +1,122 @@ +import {Descendant, Editor} from 'slate' +import {Key} from 'slate-dom' +import {ReactEditor} from '../plugin/react-editor' +import {ChunkLeaf} from './types' + +/** + * Traverse an array of children, providing helpers useful for reconciling the + * children array with a chunk tree + */ +export class ChildrenHelper { + private editor: Editor + private children: Descendant[] + + /** + * Sparse array of Slate node keys, each index corresponding to an index in + * the children array + * + * Fetching the key for a Slate node is expensive, so we cache them here. + */ + private cachedKeys: Array + + /** + * The index of the next node to be read in the children array + */ + public pointerIndex: number + + constructor(editor: Editor, children: Descendant[]) { + this.editor = editor + this.children = children + this.cachedKeys = new Array(children.length) + this.pointerIndex = 0 + } + + /** + * Read a given number of nodes, advancing the pointer by that amount + */ + public read(n: number): Descendant[] { + // PERF: If only one child was requested (the most common case), use array + // indexing instead of slice + if (n === 1) { + return [this.children[this.pointerIndex++]] + } + + const slicedChildren = this.remaining(n) + this.pointerIndex += n + + return slicedChildren + } + + /** + * Get the remaining children without advancing the pointer + * + * @param [maxChildren] Limit the number of children returned. + */ + public remaining(maxChildren?: number): Descendant[] { + if (maxChildren === undefined) { + return this.children.slice(this.pointerIndex) + } + + return this.children.slice( + this.pointerIndex, + this.pointerIndex + maxChildren, + ) + } + + /** + * Whether all children have been read + */ + public get reachedEnd() { + return this.pointerIndex >= this.children.length + } + + /** + * Determine whether a node with a given key appears in the unread part of the + * children array, and return its index relative to the current pointer if so + * + * Searching for the node object itself using indexOf is most efficient, but + * will fail to locate nodes that have been modified. In this case, nodes + * should be identified by their keys instead. + * + * Searching an array of keys using indexOf is very inefficient since fetching + * the keys for all children in advance is very slow. Insead, if the node + * search fails to return a value, fetch the keys of each remaining child one + * by one and compare it to the known key. + */ + public lookAhead(node: Descendant, key: Key) { + const elementResult = this.children.indexOf(node, this.pointerIndex) + if (elementResult > -1) return elementResult - this.pointerIndex + + for (let i = this.pointerIndex; i < this.children.length; i++) { + const candidateNode = this.children[i] + const candidateKey = this.findKey(candidateNode, i) + if (candidateKey === key) return i - this.pointerIndex + } + + return -1 + } + + /** + * Convert an array of Slate nodes to an array of chunk leaves, each + * containing the node and its key + */ + public toChunkLeaves(nodes: Descendant[], startIndex: number): ChunkLeaf[] { + return nodes.map((node, i) => ({ + type: 'leaf', + node, + key: this.findKey(node, startIndex + i), + index: startIndex + i, + })) + } + + /** + * Get the key for a Slate node, cached using the node's index + */ + private findKey(node: Descendant, index: number): Key { + const cachedKey = this.cachedKeys[index] + if (cachedKey) return cachedKey + const key = ReactEditor.findKey(this.editor, node) + this.cachedKeys[index] = key + return key + } +} diff --git a/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts b/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts new file mode 100644 index 000000000..32031ce96 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts @@ -0,0 +1,574 @@ +import {Path} from 'slate' +import {Key} from 'slate-dom' +import { + Chunk, + ChunkAncestor, + ChunkDescendant, + ChunkLeaf, + ChunkTree, +} from './types' + +type SavedPointer = + | 'start' + | { + chunk: ChunkAncestor + node: ChunkDescendant + } + +export interface ChunkTreeHelperOptions { + chunkSize: number + debug?: boolean +} + +/** + * Traverse and modify a chunk tree + */ +export class ChunkTreeHelper { + /** + * The root of the chunk tree + */ + private root: ChunkTree + + /** + * The ideal size of a chunk + */ + private chunkSize: number + + /** + * Whether debug mode is enabled + * + * If enabled, the pointer state will be checked for internal consistency + * after each mutating operation. + */ + private debug: boolean + + /** + * Whether the traversal has reached the end of the chunk tree + * + * When this is true, the pointerChunk and pointerIndex point to the last + * top-level node in the chunk tree, although pointerNode returns null. + */ + private reachedEnd: boolean + + /** + * The chunk containing the current node + */ + private pointerChunk: ChunkAncestor + + /** + * The index of the current node within pointerChunk + * + * Can be -1 to indicate that the pointer is before the start of the tree. + */ + private pointerIndex: number + + /** + * Similar to a Slate path; tracks the path of pointerChunk relative to the + * root. + * + * Used to move the pointer from the current chunk to the parent chunk more + * efficiently. + */ + private pointerIndexStack: number[] + + /** + * Indexing the current chunk's children has a slight time cost, which adds up + * when traversing very large trees, so the current node is cached. + * + * A value of undefined means that the current node is not cached. This + * property must be set to undefined whenever the pointer is moved, unless + * the pointer is guaranteed to point to the same node that it did previously. + */ + private cachedPointerNode: ChunkDescendant | null | undefined + + constructor( + chunkTree: ChunkTree, + {chunkSize, debug}: ChunkTreeHelperOptions, + ) { + this.root = chunkTree + this.chunkSize = chunkSize + // istanbul ignore next + this.debug = debug ?? false + this.pointerChunk = chunkTree + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.validateState() + } + + /** + * Move the pointer to the next leaf in the chunk tree + */ + public readLeaf(): ChunkLeaf | null { + // istanbul ignore next + if (this.reachedEnd) return null + + // Get the next sibling or aunt node + while (true) { + if (this.pointerIndex + 1 < this.pointerSiblings.length) { + this.pointerIndex++ + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.reachedEnd = true + return null + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the next sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(false) + + return this.pointerNode as ChunkLeaf + } + + /** + * Move the pointer to the previous leaf in the chunk tree + */ + public returnToPreviousLeaf() { + // If we were at the end of the tree, descend into the end of the last + // chunk in the tree + if (this.reachedEnd) { + this.reachedEnd = false + this.enterChunkUntilLeaf(true) + return + } + + // Get the previous sibling or aunt node + while (true) { + if (this.pointerIndex >= 1) { + this.pointerIndex-- + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.pointerIndex = -1 + return + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the previous sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(true) + } + + /** + * Insert leaves before the current leaf, leaving the pointer unchanged + */ + public insertBefore(leaves: ChunkLeaf[]) { + this.returnToPreviousLeaf() + this.insertAfter(leaves) + this.readLeaf() + } + + /** + * Insert leaves after the current leaf, leaving the pointer on the last + * inserted leaf + * + * The insertion algorithm first checks for any chunk we're currently at the + * end of that can receive additional leaves. Next, it tries to insert leaves + * at the starts of any subsequent chunks. + * + * Any remaining leaves are passed to rawInsertAfter to be chunked and + * inserted at the highest possible level. + */ + public insertAfter(leaves: ChunkLeaf[]) { + // istanbul ignore next + if (leaves.length === 0) return + + let beforeDepth = 0 + let afterDepth = 0 + + // While at the end of a chunk, insert any leaves that will fit, and then + // exit the chunk + while ( + this.pointerChunk.type === 'chunk' && + this.pointerIndex === this.pointerSiblings.length - 1 + ) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(0, toInsertCount) + this.rawInsertAfter(leavesToInsert, beforeDepth) + } + + this.exitChunk() + beforeDepth++ + } + + if (leaves.length === 0) return + + // Save the pointer so that we can come back here after inserting leaves + // into the starts of subsequent blocks + const rawInsertPointer = this.savePointer() + + // If leaves are inserted into the start of a subsequent block, then we + // eventually need to restore the pointer to the last such inserted leaf + let finalPointer: SavedPointer | null = null + + // Move the pointer into the chunk containing the next leaf, if it exists + if (this.readLeaf()) { + // While at the start of a chunk, insert any leaves that will fit, and + // then exit the chunk + while (this.pointerChunk.type === 'chunk' && this.pointerIndex === 0) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(-toInsertCount, toInsertCount) + + // Insert the leaves at the start of the chunk + this.pointerIndex = -1 + this.cachedPointerNode = undefined + this.rawInsertAfter(leavesToInsert, afterDepth) + + // If this is the first batch of insertions at the start of a + // subsequent chunk, set the final pointer to the last inserted leaf + if (!finalPointer) { + finalPointer = this.savePointer() + } + } + + this.exitChunk() + afterDepth++ + } + } + + this.restorePointer(rawInsertPointer) + + // If there are leaves left to insert, insert them between the end of the + // previous chunk and the start of the first subsequent chunk, or wherever + // the pointer ended up after the first batch of insertions + const minDepth = Math.max(beforeDepth, afterDepth) + this.rawInsertAfter(leaves, minDepth) + + if (finalPointer) { + this.restorePointer(finalPointer) + } + + this.validateState() + } + + /** + * Remove the current node and decrement the pointer, deleting any ancestor + * chunk that becomes empty as a result + */ + public remove() { + this.pointerSiblings.splice(this.pointerIndex--, 1) + this.cachedPointerNode = undefined + + if ( + this.pointerSiblings.length === 0 && + this.pointerChunk.type === 'chunk' + ) { + this.exitChunk() + this.remove() + } else { + this.invalidateChunk() + } + + this.validateState() + } + + /** + * Add the current chunk and all ancestor chunks to the list of modified + * chunks + */ + public invalidateChunk() { + for (let c = this.pointerChunk; c.type === 'chunk'; c = c.parent) { + this.root.modifiedChunks.add(c) + } + } + + /** + * Whether the pointer is at the start of the tree + */ + private get atStart() { + return this.pointerChunk.type === 'root' && this.pointerIndex === -1 + } + + /** + * The siblings of the current node + */ + private get pointerSiblings(): ChunkDescendant[] { + return this.pointerChunk.children + } + + /** + * Get the current node (uncached) + * + * If the pointer is at the start or end of the document, returns null. + * + * Usually, the current node is a chunk leaf, although it can be a chunk + * while insertions are in progress. + */ + private getPointerNode(): ChunkDescendant | null { + if (this.reachedEnd || this.pointerIndex === -1) { + return null + } + + return this.pointerSiblings[this.pointerIndex] + } + + /** + * Cached getter for the current node + */ + private get pointerNode(): ChunkDescendant | null { + if (this.cachedPointerNode !== undefined) return this.cachedPointerNode + const pointerNode = this.getPointerNode() + this.cachedPointerNode = pointerNode + return pointerNode + } + + /** + * Get the path of a chunk relative to the root, returning null if the chunk + * is not connected to the root + */ + private getChunkPath(chunk: ChunkAncestor): number[] | null { + const path: number[] = [] + + for (let c = chunk; c.type === 'chunk'; c = c.parent) { + const index = c.parent.children.indexOf(c) + + // istanbul ignore next + if (index === -1) { + return null + } + + path.unshift(index) + } + + return path + } + + /** + * Save the current pointer to be restored later + */ + private savePointer(): SavedPointer { + if (this.atStart) return 'start' + + // istanbul ignore next + if (!this.pointerNode) { + throw new Error('Cannot save pointer when pointerNode is null') + } + + return { + chunk: this.pointerChunk, + node: this.pointerNode, + } + } + + /** + * Restore the pointer to a previous state + */ + private restorePointer(savedPointer: SavedPointer) { + if (savedPointer === 'start') { + this.pointerChunk = this.root + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.cachedPointerNode = undefined + return + } + + // Since nodes may have been inserted or removed prior to the saved + // pointer since it was saved, the index and index stack must be + // recomputed. This is slow, but this is fine since restoring a pointer is + // not a frequent operation. + + const {chunk, node} = savedPointer + const index = chunk.children.indexOf(node) + + // istanbul ignore next + if (index === -1) { + throw new Error( + 'Cannot restore point because saved node is no longer in saved chunk', + ) + } + + const indexStack = this.getChunkPath(chunk) + + // istanbul ignore next + if (!indexStack) { + throw new Error( + 'Cannot restore point because saved chunk is no longer connected to root', + ) + } + + this.pointerChunk = chunk + this.pointerIndex = index + this.pointerIndexStack = indexStack + this.reachedEnd = false + this.cachedPointerNode = node + this.validateState() + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunk(end: boolean) { + // istanbul ignore next + if (this.pointerNode?.type !== 'chunk') { + throw new Error('Cannot enter non-chunk') + } + + this.pointerIndexStack.push(this.pointerIndex) + this.pointerChunk = this.pointerNode + this.pointerIndex = end ? this.pointerSiblings.length - 1 : 0 + this.cachedPointerNode = undefined + this.validateState() + + // istanbul ignore next + if (this.pointerChunk.children.length === 0) { + throw new Error('Cannot enter empty chunk') + } + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * repeatedly until the current node is a leaf + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunkUntilLeaf(end: boolean) { + while (this.pointerNode?.type === 'chunk') { + this.enterChunk(end) + } + } + + /** + * Move the pointer to the parent chunk + */ + private exitChunk() { + // istanbul ignore next + if (this.pointerChunk.type === 'root') { + throw new Error('Cannot exit root') + } + + const previousPointerChunk = this.pointerChunk + this.pointerChunk = previousPointerChunk.parent + this.pointerIndex = this.pointerIndexStack.pop()! + this.cachedPointerNode = undefined + this.validateState() + } + + /** + * Insert leaves immediately after the current node, leaving the pointer on + * the last inserted leaf + * + * Leaves are chunked according to the number of nodes already in the parent + * plus the number of nodes being inserted, or the minimum depth if larger + */ + private rawInsertAfter(leaves: ChunkLeaf[], minDepth: number) { + if (leaves.length === 0) return + + const groupIntoChunks = ( + leaves: ChunkLeaf[], + parent: ChunkAncestor, + perChunk: number, + ): ChunkDescendant[] => { + if (perChunk === 1) return leaves + const chunks: Chunk[] = [] + + for (let i = 0; i < this.chunkSize; i++) { + const chunkNodes = leaves.slice(i * perChunk, (i + 1) * perChunk) + if (chunkNodes.length === 0) break + + const chunk: Chunk = { + type: 'chunk', + key: new Key(), + parent, + children: [], + } + + chunk.children = groupIntoChunks( + chunkNodes, + chunk, + perChunk / this.chunkSize, + ) + chunks.push(chunk) + } + + return chunks + } + + // Determine the chunking depth based on the number of existing nodes in + // the chunk and the number of nodes being inserted + const newTotal = this.pointerSiblings.length + leaves.length + let depthForTotal = 0 + + for (let i = this.chunkSize; i < newTotal; i *= this.chunkSize) { + depthForTotal++ + } + + // A depth of 0 means no chunking + const depth = Math.max(depthForTotal, minDepth) + const perTopLevelChunk = Math.pow(this.chunkSize, depth) + + const chunks = groupIntoChunks(leaves, this.pointerChunk, perTopLevelChunk) + this.pointerSiblings.splice(this.pointerIndex + 1, 0, ...chunks) + this.pointerIndex += chunks.length + this.cachedPointerNode = undefined + this.invalidateChunk() + this.validateState() + } + + /** + * If debug mode is enabled, ensure that the state is internally consistent + */ + // istanbul ignore next + private validateState() { + if (!this.debug) return + + const validateDescendant = (node: ChunkDescendant) => { + if (node.type === 'chunk') { + const {parent, children} = node + + if (!parent.children.includes(node)) { + throw new Error( + `Debug: Chunk ${node.key.id} has an incorrect parent property`, + ) + } + + children.forEach(validateDescendant) + } + } + + this.root.children.forEach(validateDescendant) + + if ( + this.cachedPointerNode !== undefined && + this.cachedPointerNode !== this.getPointerNode() + ) { + throw new Error( + 'Debug: The cached pointer is incorrect and has not been invalidated', + ) + } + + const actualIndexStack = this.getChunkPath(this.pointerChunk) + + if (!actualIndexStack) { + throw new Error('Debug: The pointer chunk is not connected to the root') + } + + if (!Path.equals(this.pointerIndexStack, actualIndexStack)) { + throw new Error( + `Debug: The cached index stack [${this.pointerIndexStack.join( + ', ', + )}] does not match the path of the pointer chunk [${actualIndexStack.join( + ', ', + )}]`, + ) + } + } +} diff --git a/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts b/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts new file mode 100644 index 000000000..46963b6f4 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts @@ -0,0 +1,47 @@ +import {Ancestor, Editor} from 'slate' +import {Key} from 'slate-dom' +import {ReactEditor} from '../plugin/react-editor' +import {reconcileChildren, ReconcileOptions} from './reconcile-children' +import {ChunkTree} from './types' + +export const KEY_TO_CHUNK_TREE = new WeakMap() + +/** + * Get or create the chunk tree for a Slate node + * + * If the reconcile option is provided, the chunk tree will be updated to + * match the current children of the node. The children are chunked + * automatically using the given chunk size. + */ +export const getChunkTreeForNode = ( + editor: Editor, + node: Ancestor, + // istanbul ignore next + options: { + reconcile?: Omit | false + } = {}, +) => { + const key = ReactEditor.findKey(editor, node) + let chunkTree = KEY_TO_CHUNK_TREE.get(key) + + if (!chunkTree) { + chunkTree = { + type: 'root', + movedNodeKeys: new Set(), + modifiedChunks: new Set(), + children: [], + } + + KEY_TO_CHUNK_TREE.set(key, chunkTree) + } + + if (options.reconcile) { + reconcileChildren(editor, { + chunkTree, + children: node.children, + ...options.reconcile, + }) + } + + return chunkTree +} diff --git a/packages/editor/src/slate-react/chunking/index.ts b/packages/editor/src/slate-react/chunking/index.ts new file mode 100644 index 000000000..ee8ea2a2d --- /dev/null +++ b/packages/editor/src/slate-react/chunking/index.ts @@ -0,0 +1,2 @@ +export * from './get-chunk-tree-for-node' +export * from './types' diff --git a/packages/editor/src/slate-react/chunking/reconcile-children.ts b/packages/editor/src/slate-react/chunking/reconcile-children.ts new file mode 100644 index 000000000..a415446f3 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/reconcile-children.ts @@ -0,0 +1,127 @@ +import {Descendant, Editor} from 'slate' +import {ChildrenHelper} from './children-helper' +import {ChunkTreeHelper, ChunkTreeHelperOptions} from './chunk-tree-helper' +import {ChunkLeaf, ChunkTree} from './types' + +export interface ReconcileOptions extends ChunkTreeHelperOptions { + chunkTree: ChunkTree + children: Descendant[] + chunkSize: number + rerenderChildren?: number[] + onInsert?: (node: Descendant, index: number) => void + onUpdate?: (node: Descendant, index: number) => void + onIndexChange?: (node: Descendant, index: number) => void + debug?: boolean +} + +/** + * Update the chunk tree to match the children array, inserting, removing and + * updating differing nodes + */ +export const reconcileChildren = ( + editor: Editor, + { + chunkTree, + children, + chunkSize, + rerenderChildren = [], + onInsert, + onUpdate, + onIndexChange, + debug, + }: ReconcileOptions, +) => { + chunkTree.modifiedChunks.clear() + + const chunkTreeHelper = new ChunkTreeHelper(chunkTree, {chunkSize, debug}) + const childrenHelper = new ChildrenHelper(editor, children) + + let treeLeaf: ChunkLeaf | null + + // Read leaves from the tree one by one, each one representing a single Slate + // node. Each leaf from the tree is compared to the current node in the + // children array to determine whether nodes have been inserted, removed or + // updated. + while ((treeLeaf = chunkTreeHelper.readLeaf())) { + // Check where the tree node appears in the children array. In the most + // common case (where no insertions or removals have occurred), this will be + // 0. If the node has been removed, this will be -1. If new nodes have been + // inserted before the node, or if the node has been moved to a later + // position in the same children array, this will be a positive number. + const lookAhead = childrenHelper.lookAhead(treeLeaf.node, treeLeaf.key) + + // If the node was moved, we want to remove it and insert it later, rather + // then re-inserting all intermediate nodes before it. + const wasMoved = lookAhead > 0 && chunkTree.movedNodeKeys.has(treeLeaf.key) + + // If the tree leaf was moved or removed, remove it + if (lookAhead === -1 || wasMoved) { + chunkTreeHelper.remove() + continue + } + + // Get the matching Slate node and any nodes that may have been inserted + // prior to it. Insert these into the chunk tree. + const insertedChildrenStartIndex = childrenHelper.pointerIndex + const insertedChildren = childrenHelper.read(lookAhead + 1) + const matchingChild = insertedChildren.pop()! + + if (insertedChildren.length) { + const leavesToInsert = childrenHelper.toChunkLeaves( + insertedChildren, + insertedChildrenStartIndex, + ) + + chunkTreeHelper.insertBefore(leavesToInsert) + + insertedChildren.forEach((node, relativeIndex) => { + onInsert?.(node, insertedChildrenStartIndex + relativeIndex) + }) + } + + const matchingChildIndex = childrenHelper.pointerIndex - 1 + + // Make sure the chunk tree contains the most recent version of the Slate + // node + if (treeLeaf.node !== matchingChild) { + treeLeaf.node = matchingChild + chunkTreeHelper.invalidateChunk() + onUpdate?.(matchingChild, matchingChildIndex) + } + + // Update the index if it has changed + if (treeLeaf.index !== matchingChildIndex) { + treeLeaf.index = matchingChildIndex + onIndexChange?.(matchingChild, matchingChildIndex) + } + + // Manually invalidate chunks containing specific children that we want to + // re-render + if (rerenderChildren.includes(matchingChildIndex)) { + chunkTreeHelper.invalidateChunk() + } + } + + // If there are still Slate nodes remaining from the children array that were + // not matched to nodes in the tree, insert them at the end of the tree + if (!childrenHelper.reachedEnd) { + const remainingChildren = childrenHelper.remaining() + + const leavesToInsert = childrenHelper.toChunkLeaves( + remainingChildren, + childrenHelper.pointerIndex, + ) + + // Move the pointer back to the final leaf in the tree, or the start of the + // tree if the tree is currently empty + chunkTreeHelper.returnToPreviousLeaf() + + chunkTreeHelper.insertAfter(leavesToInsert) + + remainingChildren.forEach((node, relativeIndex) => { + onInsert?.(node, childrenHelper.pointerIndex + relativeIndex) + }) + } + + chunkTree.movedNodeKeys.clear() +} diff --git a/packages/editor/src/slate-react/chunking/types.ts b/packages/editor/src/slate-react/chunking/types.ts new file mode 100644 index 000000000..3bbfd61c9 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/types.ts @@ -0,0 +1,52 @@ +import {Descendant} from 'slate' +import {Key} from 'slate-dom' + +export interface ChunkTree { + type: 'root' + children: ChunkDescendant[] + + /** + * The keys of any Slate nodes that have been moved using move_node since the + * last render + * + * Detecting when a node has been moved to a different position in the + * children array is impossible to do efficiently while reconciling the chunk + * tree. This interferes with the reconciliation logic since it is treated as + * if the intermediate nodes were inserted and removed, causing them to be + * re-chunked unnecessarily. + * + * This set is used to detect when a node has been moved so that this case + * can be handled correctly and efficiently. + */ + movedNodeKeys: Set + + /** + * The chunks whose descendants have been modified during the most recent + * reconciliation + * + * Used to determine when the otherwise memoized React components for each + * chunk should be re-rendered. + */ + modifiedChunks: Set +} + +export interface Chunk { + type: 'chunk' + key: Key + parent: ChunkAncestor + children: ChunkDescendant[] +} + +// A chunk leaf is unrelated to a Slate leaf; it is a leaf of the chunk tree, +// containing a single element that is a child of the Slate node the chunk tree +// belongs to. +export interface ChunkLeaf { + type: 'leaf' + key: Key + node: Descendant + index: number +} + +export type ChunkAncestor = ChunkTree | Chunk +export type ChunkDescendant = Chunk | ChunkLeaf +export type ChunkNode = ChunkTree | Chunk | ChunkLeaf diff --git a/packages/editor/src/slate-react/components/chunk-tree.tsx b/packages/editor/src/slate-react/components/chunk-tree.tsx new file mode 100644 index 000000000..47d0cb97d --- /dev/null +++ b/packages/editor/src/slate-react/components/chunk-tree.tsx @@ -0,0 +1,65 @@ +import React, {Fragment} from 'react' +import {Element} from 'slate' +import {Key} from 'slate-dom' +import { + Chunk as TChunk, + ChunkAncestor as TChunkAncestor, + ChunkTree as TChunkTree, +} from '../chunking' +import {RenderChunkProps} from './editable' + +const defaultRenderChunk = ({children}: RenderChunkProps) => children + +const ChunkAncestor = (props: { + root: TChunkTree + ancestor: C + renderElement: (node: Element, index: number, key: Key) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element +}) => { + const { + root, + ancestor, + renderElement, + renderChunk = defaultRenderChunk, + } = props + + return ancestor.children.map((chunkNode) => { + if (chunkNode.type === 'chunk') { + const key = chunkNode.key.id + + const renderedChunk = renderChunk({ + highest: ancestor === root, + lowest: chunkNode.children.some((c) => c.type === 'leaf'), + attributes: {'data-slate-chunk': true}, + children: ( + + ), + }) + + return {renderedChunk} + } + + // Only blocks containing no inlines are chunked + const element = chunkNode.node as Element + + return renderElement(element, chunkNode.index, chunkNode.key) + }) +} + +const ChunkTree = ChunkAncestor + +const MemoizedChunk = React.memo( + ChunkAncestor, + (prev, next) => + prev.root === next.root && + prev.renderElement === next.renderElement && + prev.renderChunk === next.renderChunk && + !next.root.modifiedChunks.has(next.ancestor), +) + +export default ChunkTree diff --git a/packages/editor/src/slate-react/components/editable.tsx b/packages/editor/src/slate-react/components/editable.tsx new file mode 100644 index 000000000..a608854d7 --- /dev/null +++ b/packages/editor/src/slate-react/components/editable.tsx @@ -0,0 +1,2058 @@ +import getDirection from 'direction' +import debounce from 'lodash/debounce' +import throttle from 'lodash/throttle' +import React, { + ForwardedRef, + forwardRef, + JSX, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import scrollIntoView from 'scroll-into-view-if-needed' +import { + DecoratedRange, + Editor, + Element, + LeafPosition, + Node, + NodeEntry, + Path, + Range, + Text, + Transforms, +} from 'slate' +import { + CAN_USE_DOM, + containsShadowAware, + DOMElement, + DOMRange, + DOMText, + EDITOR_TO_ELEMENT, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + getActiveElement, + getDefaultView, + getSelection, + HAS_BEFORE_INPUT_SUPPORT, + Hotkeys, + IS_ANDROID, + IS_CHROME, + IS_COMPOSING, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_FOCUSED, + IS_IOS, + IS_NODE_MAP_DIRTY, + IS_READ_ONLY, + IS_UC_MOBILE, + IS_WEBKIT, + IS_WECHATBROWSER, + isDOMElement, + isDOMNode, + isPlainTextOnlyPaste, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + PLACEHOLDER_SYMBOL, + TRIPLE_CLICK, +} from 'slate-dom' +import {AndroidInputManager} from '../hooks/android-input-manager/android-input-manager' +import {useAndroidInputManager} from '../hooks/android-input-manager/use-android-input-manager' +import useChildren from '../hooks/use-children' +import {ComposingContext} from '../hooks/use-composing' +import {DecorateContext, useDecorateContext} from '../hooks/use-decorations' +import {useIsomorphicLayoutEffect} from '../hooks/use-isomorphic-layout-effect' +import {ReadOnlyContext} from '../hooks/use-read-only' +import {useSlate} from '../hooks/use-slate' +import {useFlushDeferredSelectorsOnRender} from '../hooks/use-slate-selector' +import {useTrackUserInput} from '../hooks/use-track-user-input' +import {ReactEditor} from '../plugin/react-editor' +import {RestoreDOM} from './restore-dom/restore-dom' + +type DeferredOperation = () => void + +const Children = (props: Parameters[0]) => ( + {useChildren(props)} +) + +/** + * `RenderElementProps` are passed to the `renderElement` handler. + */ + +export interface RenderElementProps { + children: any + element: Element + attributes: { + 'data-slate-node': 'element' + 'data-slate-inline'?: true + 'data-slate-void'?: true + 'dir'?: 'rtl' + 'ref': any + } +} + +/** + * `RenderChunkProps` are passed to the `renderChunk` handler + */ +export interface RenderChunkProps { + highest: boolean + lowest: boolean + children: any + attributes: { + 'data-slate-chunk': true + } +} + +/** + * `RenderLeafProps` are passed to the `renderLeaf` handler. + */ + +export interface RenderLeafProps { + children: any + /** + * The leaf node with any applied decorations. + * If no decorations are applied, it will be identical to the `text` property. + */ + leaf: Text + text: Text + attributes: { + 'data-slate-leaf': true + } + /** + * The position of the leaf within the Text node, only present when the text node is split by decorations. + */ + leafPosition?: LeafPosition +} + +/** + * `RenderTextProps` are passed to the `renderText` handler. + */ +export interface RenderTextProps { + text: Text + children: any + attributes: { + 'data-slate-node': 'text' + 'ref': any + } +} + +/** + * `EditableProps` are passed to the `` component. + */ + +export type EditableProps = { + decorate?: (entry: NodeEntry) => DecoratedRange[] + onDOMBeforeInput?: (event: InputEvent) => void + placeholder?: string + readOnly?: boolean + role?: string + style?: React.CSSProperties + renderElement?: (props: RenderElementProps) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element + renderLeaf?: (props: RenderLeafProps) => JSX.Element + renderText?: (props: RenderTextProps) => JSX.Element + renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element + scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void + as?: React.ElementType + disableDefaultStyles?: boolean +} & React.TextareaHTMLAttributes + +/** + * Editable. + */ + +export const Editable = forwardRef( + (props: EditableProps, forwardedRef: ForwardedRef) => { + const defaultRenderPlaceholder = useCallback( + (props: RenderPlaceholderProps) => , + [], + ) + const { + autoFocus, + decorate = defaultDecorate, + onDOMBeforeInput: propsOnDOMBeforeInput, + placeholder, + readOnly = false, + renderElement, + renderChunk, + renderLeaf, + renderText, + renderPlaceholder = defaultRenderPlaceholder, + scrollSelectionIntoView = defaultScrollSelectionIntoView, + style: userStyle = {}, + as: Component = 'div', + disableDefaultStyles = false, + ...attributes + } = props + const editor = useSlate() + // Rerender editor when composition status changed + const [isComposing, setIsComposing] = useState(false) + const ref = useRef(null) + const deferredOperations = useRef([]) + const [placeholderHeight, setPlaceholderHeight] = useState< + number | undefined + >() + const processing = useRef(false) + + const {onUserInput, receivedUserInput} = useTrackUserInput() + + const [, forceRender] = useReducer((s) => s + 1, 0) + EDITOR_TO_FORCE_RENDER.set(editor, forceRender) + + // Update internal state on each render. + IS_READ_ONLY.set(editor, readOnly) + + // Keep track of some state for the event handler logic. + const state = useMemo( + () => ({ + isDraggingInternally: false, + isUpdatingSelection: false, + latestElement: null as DOMElement | null, + hasMarkPlaceholder: false, + }), + [], + ) + + // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it + // needs to be manually focused. + // + // If this stops working in Firefox, make sure nothing is causing this + // component to re-render during the initial mount. If the DOM selection is + // set by `useIsomorphicLayoutEffect` before `onDOMSelectionChange` updates + // `editor.selection`, the DOM selection can be removed accidentally. + useEffect(() => { + if (ref.current && autoFocus) { + ref.current.focus() + } + }, [autoFocus]) + + /** + * The AndroidInputManager object has a cyclical dependency on onDOMSelectionChange + * + * It is defined as a reference to simplify hook dependencies and clarify that + * it needs to be initialized. + */ + const androidInputManagerRef = useRef< + AndroidInputManager | null | undefined + >() + + // Listen on the native `selectionchange` event to be able to update any time + // the selection changes. This is required because React's `onSelect` is leaky + // and non-standard so it doesn't fire until after a selection has been + // released. This causes issues in situations where another change happens + // while a selection is being dragged. + const onDOMSelectionChange = useMemo( + () => + throttle(() => { + if (IS_NODE_MAP_DIRTY.get(editor)) { + onDOMSelectionChange() + return + } + + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (!processing.current && IS_WEBKIT && root instanceof ShadowRoot) { + processing.current = true + + const active = getActiveElement() + + if (active) { + document.execCommand('indent') + } else { + Transforms.deselect(editor) + } + + processing.current = false + return + } + + const androidInputManager = androidInputManagerRef.current + if ( + (IS_ANDROID || !ReactEditor.isComposing(editor)) && + (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && + !state.isDraggingInternally + ) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const {activeElement} = root + const el = ReactEditor.toDOMNode(editor, editor) + const domSelection = getSelection(root) + + if (activeElement === el) { + state.latestElement = activeElement + IS_FOCUSED.set(editor, true) + } else { + IS_FOCUSED.delete(editor) + } + + if (!domSelection) { + return Transforms.deselect(editor) + } + + const {anchorNode, focusNode} = domSelection + + const anchorNodeSelectable = + ReactEditor.hasEditableTarget(editor, anchorNode) || + ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) + + const focusNodeInEditor = ReactEditor.hasTarget(editor, focusNode) + + if (anchorNodeSelectable && focusNodeInEditor) { + const range = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + + if (range) { + if ( + !ReactEditor.isComposing(editor) && + !androidInputManager?.hasPendingChanges() && + !androidInputManager?.isFlushing() + ) { + Transforms.select(editor, range) + } else { + androidInputManager?.handleUserSelect(range) + } + } + } + + // Deselect the editor if the dom selection is not selectable in readonly mode + if (readOnly && (!anchorNodeSelectable || !focusNodeInEditor)) { + Transforms.deselect(editor) + } + } + }, 100), + [editor, readOnly, state], + ) + + const scheduleOnDOMSelectionChange = useMemo( + () => debounce(onDOMSelectionChange, 0), + [onDOMSelectionChange], + ) + + androidInputManagerRef.current = useAndroidInputManager({ + node: ref, + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + }) + + useIsomorphicLayoutEffect(() => { + // Update element-related weak maps with the DOM element ref. + let window + if (ref.current && (window = getDefaultView(ref.current))) { + EDITOR_TO_WINDOW.set(editor, window) + EDITOR_TO_ELEMENT.set(editor, ref.current) + NODE_TO_ELEMENT.set(editor, ref.current) + ELEMENT_TO_NODE.set(ref.current, editor) + } else { + NODE_TO_ELEMENT.delete(editor) + } + + // Make sure the DOM selection state is in sync. + const {selection} = editor + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if ( + !domSelection || + !ReactEditor.isFocused(editor) || + androidInputManagerRef.current?.hasPendingAction() + ) { + return + } + + const setDomSelection = (forceChange?: boolean) => { + const hasDomSelection = domSelection.type !== 'None' + + // If the DOM selection is properly unset, we're done. + if (!selection && !hasDomSelection) { + return + } + + // Get anchorNode and focusNode + const focusNode = domSelection.focusNode + let anchorNode + + // COMPAT: In firefox the normal selection way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domSelection.rangeCount > 1) { + const firstRange = domSelection.getRangeAt(0) + const lastRange = domSelection.getRangeAt(domSelection.rangeCount - 1) + + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + } else { + // Left to right + anchorNode = firstRange.startContainer + } + } else { + anchorNode = domSelection.anchorNode + } + + // verify that the dom selection is in the editor + const editorElement = EDITOR_TO_ELEMENT.get(editor)! + let hasDomSelectionInEditor = false + if ( + containsShadowAware(editorElement, anchorNode) && + containsShadowAware(editorElement, focusNode) + ) { + hasDomSelectionInEditor = true + } + + // If the DOM selection is in the editor and the editor selection is already correct, we're done. + if ( + hasDomSelection && + hasDomSelectionInEditor && + selection && + !forceChange + ) { + const slateRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: true, + + // domSelection is not necessarily a valid Slate range + // (e.g. when clicking on contentEditable:false element) + suppressThrow: true, + }) + + if (slateRange && Range.equals(slateRange, selection)) { + if (!state.hasMarkPlaceholder) { + return + } + + // Ensure selection is inside the mark placeholder + if ( + anchorNode?.parentElement?.hasAttribute( + 'data-slate-mark-placeholder', + ) + ) { + return + } + } + } + + // when is being controlled through external value + // then its children might just change - DOM responds to it on its own + // but Slate's value is not being updated through any operation + // and thus it doesn't transform selection on its own + if (selection && !ReactEditor.hasRange(editor, selection)) { + editor.selection = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + return + } + + // Otherwise the DOM selection is out of sync, so update it. + state.isUpdatingSelection = true + + let newDomRange: DOMRange | null = null + + try { + newDomRange = selection && ReactEditor.toDOMRange(editor, selection) + } catch (e) { + // Ignore, dom and state might be out of sync + } + + if (newDomRange) { + if (ReactEditor.isComposing(editor) && !IS_ANDROID) { + domSelection.collapseToEnd() + } else if (Range.isBackward(selection!)) { + domSelection.setBaseAndExtent( + newDomRange.endContainer, + newDomRange.endOffset, + newDomRange.startContainer, + newDomRange.startOffset, + ) + } else { + domSelection.setBaseAndExtent( + newDomRange.startContainer, + newDomRange.startOffset, + newDomRange.endContainer, + newDomRange.endOffset, + ) + } + scrollSelectionIntoView(editor, newDomRange) + } else { + domSelection.removeAllRanges() + } + + return newDomRange + } + + // In firefox if there is more then 1 range and we call setDomSelection we remove the ability to select more cells in a table + if (domSelection.rangeCount <= 1) { + setDomSelection() + } + + const ensureSelection = + androidInputManagerRef.current?.isFlushing() === 'action' + + if (!IS_ANDROID || !ensureSelection) { + setTimeout(() => { + state.isUpdatingSelection = false + }) + return + } + + let timeoutId: ReturnType | null = null + const animationFrameId = requestAnimationFrame(() => { + if (ensureSelection) { + const ensureDomSelection = (forceChange?: boolean) => { + try { + const el = ReactEditor.toDOMNode(editor, editor) + el.focus() + + setDomSelection(forceChange) + } catch (e) { + // Ignore, dom and state might be out of sync + } + } + + // Compat: Android IMEs try to force their selection by manually re-applying it even after we set it. + // This essentially would make setting the slate selection during an update meaningless, so we force it + // again here. We can't only do it in the setTimeout after the animation frame since that would cause a + // visible flicker. + ensureDomSelection() + + timeoutId = setTimeout(() => { + // COMPAT: While setting the selection in an animation frame visually correctly sets the selection, + // it doesn't update GBoards spellchecker state. We have to manually trigger a selection change after + // the animation frame to ensure it displays the correct state. + ensureDomSelection(true) + state.isUpdatingSelection = false + }) + } + }) + + return () => { + cancelAnimationFrame(animationFrameId) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }) + + // Listen on the native `beforeinput` event to get real "Level 2" events. This + // is required because React's `beforeinput` is fake and never really attaches + // to the real event sadly. (2019/11/01) + // https://github.com/facebook/react/issues/11211 + const onDOMBeforeInput = useCallback( + (event: InputEvent) => { + handleNativeHistoryEvents(editor, event) + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (processing?.current && IS_WEBKIT && root instanceof ShadowRoot) { + const ranges = event.getTargetRanges() + const range = ranges[0] + + const newRange = new window.Range() + + newRange.setStart(range.startContainer, range.startOffset) + newRange.setEnd(range.endContainer, range.endOffset) + + // Translate the DOM Range into a Slate Range + const slateRange = ReactEditor.toSlateRange(editor, newRange, { + exactMatch: false, + suppressThrow: false, + }) + + Transforms.select(editor, slateRange) + + event.preventDefault() + event.stopImmediatePropagation() + return + } + onUserInput() + + if ( + !readOnly && + ReactEditor.hasEditableTarget(editor, event.target) && + !isDOMEventHandled(event, propsOnDOMBeforeInput) + ) { + // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. + if (androidInputManagerRef.current) { + return androidInputManagerRef.current.handleDOMBeforeInput(event) + } + + // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before + // triggering a `beforeinput` expecting the change to be applied to the immediately before + // set selection. + scheduleOnDOMSelectionChange.flush() + onDOMSelectionChange.flush() + + const {selection} = editor + const {inputType: type} = event + const data = (event as any).dataTransfer || event.data || undefined + + const isCompositionChange = + type === 'insertCompositionText' || type === 'deleteCompositionText' + + // COMPAT: use composition change events as a hint to where we should insert + // composition text if we aren't composing to work around https://github.com/ianstormtaylor/slate/issues/5038 + if (isCompositionChange && ReactEditor.isComposing(editor)) { + return + } + + let native = false + if ( + type === 'insertText' && + selection && + Range.isCollapsed(selection) && + // Only use native character insertion for single characters a-z or space for now. + // Long-press events (hold a + press 4 = ä) to choose a special character otherwise + // causes duplicate inserts. + event.data && + event.data.length === 1 && + /[a-z ]/i.test(event.data) && + // Chrome has issues correctly editing the start of nodes: https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 + // When there is an inline element, e.g. a link, and you select + // right after it (the start of the next node). + selection.anchor.offset !== 0 + ) { + native = true + + // Skip native if there are marks, as + // `insertText` will insert a node, not just text. + if (editor.marks) { + native = false + } + + // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint) + if (!IS_NODE_MAP_DIRTY.get(editor)) { + // Chrome also has issues correctly editing the end of anchor elements: https://bugs.chromium.org/p/chromium/issues/detail?id=1259100 + // Therefore we don't allow native events to insert text at the end of anchor nodes. + const {anchor} = selection + + const [node, offset] = ReactEditor.toDOMPoint(editor, anchor) + const anchorNode = node.parentElement?.closest('a') + + const window = ReactEditor.getWindow(editor) + + if ( + native && + anchorNode && + ReactEditor.hasDOMNode(editor, anchorNode) + ) { + // Find the last text node inside the anchor. + const lastText = window?.document + .createTreeWalker(anchorNode, NodeFilter.SHOW_TEXT) + .lastChild() as DOMText | null + + if ( + lastText === node && + lastText.textContent?.length === offset + ) { + native = false + } + } + + // Chrome has issues with the presence of tab characters inside elements with whiteSpace = 'pre' + // causing abnormal insert behavior: https://bugs.chromium.org/p/chromium/issues/detail?id=1219139 + if ( + native && + node.parentElement && + window?.getComputedStyle(node.parentElement)?.whiteSpace === + 'pre' + ) { + const block = Editor.above(editor, { + at: anchor.path, + match: (n) => + Element.isElement(n) && Editor.isBlock(editor, n), + }) + + if (block && Node.string(block[0]).includes('\t')) { + native = false + } + } + } + } + // COMPAT: For the deleting forward/backward input types we don't want + // to change the selection because it is the range that will be deleted, + // and those commands determine that for themselves. + // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint via ReactEditor.toSlateRange) + if ( + (!type.startsWith('delete') || type.startsWith('deleteBy')) && + !IS_NODE_MAP_DIRTY.get(editor) + ) { + const [targetRange] = (event as any).getTargetRanges() + + if (targetRange) { + const range = ReactEditor.toSlateRange(editor, targetRange, { + exactMatch: false, + suppressThrow: false, + }) + + if (!selection || !Range.equals(selection, range)) { + native = false + + const selectionRef = + !isCompositionChange && + editor.selection && + Editor.rangeRef(editor, editor.selection) + + Transforms.select(editor, range) + + if (selectionRef) { + EDITOR_TO_USER_SELECTION.set(editor, selectionRef) + } + } + } + } + + // Composition change types occur while a user is composing text and can't be + // cancelled. Let them through and wait for the composition to end. + if (isCompositionChange) { + return + } + + if (!native) { + event.preventDefault() + } + + // COMPAT: If the selection is expanded, even if the command seems like + // a delete forward/backward command it should delete the selection. + if ( + selection && + Range.isExpanded(selection) && + type.startsWith('delete') + ) { + const direction = type.endsWith('Backward') ? 'backward' : 'forward' + Editor.deleteFragment(editor, {direction}) + return + } + + switch (type) { + case 'deleteByComposition': + case 'deleteByCut': + case 'deleteByDrag': { + Editor.deleteFragment(editor) + break + } + + case 'deleteContent': + case 'deleteContentForward': { + Editor.deleteForward(editor) + break + } + + case 'deleteContentBackward': { + Editor.deleteBackward(editor) + break + } + + case 'deleteEntireSoftLine': { + Editor.deleteBackward(editor, {unit: 'line'}) + Editor.deleteForward(editor, {unit: 'line'}) + break + } + + case 'deleteHardLineBackward': { + Editor.deleteBackward(editor, {unit: 'block'}) + break + } + + case 'deleteSoftLineBackward': { + Editor.deleteBackward(editor, {unit: 'line'}) + break + } + + case 'deleteHardLineForward': { + Editor.deleteForward(editor, {unit: 'block'}) + break + } + + case 'deleteSoftLineForward': { + Editor.deleteForward(editor, {unit: 'line'}) + break + } + + case 'deleteWordBackward': { + Editor.deleteBackward(editor, {unit: 'word'}) + break + } + + case 'deleteWordForward': { + Editor.deleteForward(editor, {unit: 'word'}) + break + } + + case 'insertLineBreak': + Editor.insertSoftBreak(editor) + break + + case 'insertParagraph': { + Editor.insertBreak(editor) + break + } + + case 'insertFromComposition': + case 'insertFromDrop': + case 'insertFromPaste': + case 'insertFromYank': + case 'insertReplacementText': + case 'insertText': { + if (type === 'insertFromComposition') { + // COMPAT: in Safari, `compositionend` is dispatched after the + // `beforeinput` for "insertFromComposition". But if we wait for it + // then we will abort because we're still composing and the selection + // won't be updated properly. + // https://www.w3.org/TR/input-events-2/ + if (ReactEditor.isComposing(editor)) { + setIsComposing(false) + IS_COMPOSING.set(editor, false) + } + } + + // use a weak comparison instead of 'instanceof' to allow + // programmatic access of paste events coming from external windows + // like cypress where cy.window does not work realibly + if (data?.constructor.name === 'DataTransfer') { + ReactEditor.insertData(editor, data) + } else if (typeof data === 'string') { + // Only insertText operations use the native functionality, for now. + // Potentially expand to single character deletes, as well. + if (native) { + deferredOperations.current.push(() => + Editor.insertText(editor, data), + ) + } else { + Editor.insertText(editor, data) + } + } + + break + } + } + + // Restore the actual user section if nothing manually set it. + const toRestore = EDITOR_TO_USER_SELECTION.get(editor)?.unref() + EDITOR_TO_USER_SELECTION.delete(editor) + + if ( + toRestore && + (!editor.selection || !Range.equals(editor.selection, toRestore)) + ) { + Transforms.select(editor, toRestore) + } + } + }, + [ + editor, + onDOMSelectionChange, + onUserInput, + propsOnDOMBeforeInput, + readOnly, + scheduleOnDOMSelectionChange, + ], + ) + + const callbackRef = useCallback( + (node: HTMLDivElement | null) => { + if (node == null) { + onDOMSelectionChange.cancel() + scheduleOnDOMSelectionChange.cancel() + + EDITOR_TO_ELEMENT.delete(editor) + NODE_TO_ELEMENT.delete(editor) + + if (ref.current && HAS_BEFORE_INPUT_SUPPORT) { + // @ts-ignore The `beforeinput` event isn't recognized. + ref.current.removeEventListener('beforeinput', onDOMBeforeInput) + } + } else { + // Attach a native DOM event handler for `beforeinput` events, because React's + // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose + // real `beforeinput` events sadly... (2019/11/04) + // https://github.com/facebook/react/issues/11211 + if (HAS_BEFORE_INPUT_SUPPORT) { + // @ts-ignore The `beforeinput` event isn't recognized. + node.addEventListener('beforeinput', onDOMBeforeInput) + } + } + + ref.current = node + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } else if (forwardedRef) { + forwardedRef.current = node + } + }, + [ + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + editor, + onDOMBeforeInput, + forwardedRef, + ], + ) + + useIsomorphicLayoutEffect(() => { + const window = ReactEditor.getWindow(editor) + + // COMPAT: In Chrome, `selectionchange` events can fire when and + //