From 3243c7e34ac2602618c67c88b1b7df07fde1c2ec Mon Sep 17 00:00:00 2001 From: Ziad Beyens Date: Thu, 20 Apr 2023 13:55:43 +0200 Subject: [PATCH] Refactor editor methods and fix JSDoc (#5307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat * fix * docs * feat * Create two-books-bow.md * fix * feat * feat * fix * refactor * refactor * refactor * refactor * refactor * refactor * refactor * refactor * refactor * refactor * docs * docs * 🔀 * 🔀 --- .changeset/slate-react.md | 5 + .changeset/slate.md | 156 ++ .../slate-react/src/components/editable.tsx | 7 +- .../slate-react/src/components/element.tsx | 24 +- packages/slate-react/src/components/slate.tsx | 12 +- packages/slate-react/src/components/text.tsx | 3 +- .../slate-react/src/hooks/use-children.tsx | 16 +- .../src/hooks/use-mutation-observer.ts | 2 - .../src/hooks/use-slate-static.tsx | 2 +- .../src/hooks/use-track-user-input.ts | 2 +- .../slate-react/src/plugin/react-editor.ts | 710 ++++--- packages/slate-react/src/utils/lines.ts | 2 +- packages/slate-react/src/utils/types.ts | 3 + packages/slate/src/core/apply.ts | 78 + packages/slate/src/core/get-dirty-paths.ts | 83 + packages/slate/src/core/get-fragment.ts | 11 + packages/slate/src/core/index.ts | 5 + packages/slate/src/core/normalize-node.ts | 99 + packages/slate/src/core/should-normalize.ts | 17 + packages/slate/src/create-editor.ts | 568 ++--- packages/slate/src/editor/above.ts | 41 + packages/slate/src/editor/add-mark.ts | 52 + packages/slate/src/editor/after.ts | 27 + packages/slate/src/editor/before.ts | 28 + packages/slate/src/editor/delete-backward.ts | 15 + packages/slate/src/editor/delete-forward.ts | 15 + packages/slate/src/editor/delete-fragment.ts | 14 + packages/slate/src/editor/edges.ts | 5 + .../slate/src/editor/element-read-only.ts | 12 + packages/slate/src/editor/end.ts | 5 + packages/slate/src/editor/first.ts | 6 + packages/slate/src/editor/fragment.ts | 7 + packages/slate/src/editor/get-void.ts | 9 + packages/slate/src/editor/has-blocks.ts | 8 + packages/slate/src/editor/has-inlines.ts | 8 + packages/slate/src/editor/has-path.ts | 6 + packages/slate/src/editor/has-texts.ts | 6 + packages/slate/src/editor/index.ts | 54 + packages/slate/src/editor/insert-break.ts | 6 + packages/slate/src/editor/insert-node.ts | 6 + .../slate/src/editor/insert-soft-break.ts | 6 + packages/slate/src/editor/insert-text.ts | 21 + packages/slate/src/editor/is-block.ts | 5 + packages/slate/src/editor/is-edge.ts | 5 + packages/slate/src/editor/is-editor.ts | 44 + packages/slate/src/editor/is-empty.ts | 14 + packages/slate/src/editor/is-end.ts | 7 + packages/slate/src/editor/is-normalizing.ts | 7 + packages/slate/src/editor/is-start.ts | 12 + packages/slate/src/editor/last.ts | 6 + packages/slate/src/editor/leaf.ts | 8 + packages/slate/src/editor/levels.ts | 40 + packages/slate/src/editor/marks.ts | 61 + packages/slate/src/editor/next.ts | 36 + packages/slate/src/editor/node.ts | 8 + packages/slate/src/editor/nodes.ts | 124 ++ packages/slate/src/editor/normalize.ts | 93 + packages/slate/src/editor/parent.ts | 10 + packages/slate/src/editor/path-ref.ts | 25 + packages/slate/src/editor/path-refs.ts | 13 + packages/slate/src/editor/path.ts | 35 + packages/slate/src/editor/point-ref.ts | 25 + packages/slate/src/editor/point-refs.ts | 13 + packages/slate/src/editor/point.ts | 38 + packages/slate/src/editor/positions.ts | 190 ++ packages/slate/src/editor/previous.ts | 47 + packages/slate/src/editor/range-ref.ts | 25 + packages/slate/src/editor/range-refs.ts | 13 + packages/slate/src/editor/range.ts | 12 + packages/slate/src/editor/remove-mark.ts | 45 + packages/slate/src/editor/set-normalizing.ts | 9 + packages/slate/src/editor/start.ts | 5 + packages/slate/src/editor/string.ts | 31 + packages/slate/src/editor/unhang-range.ts | 53 + .../slate/src/editor/without-normalizing.ts | 15 + packages/slate/src/index.ts | 22 +- packages/slate/src/interfaces/editor.ts | 1823 +++++------------ packages/slate/src/interfaces/element.ts | 57 +- packages/slate/src/interfaces/index.ts | 14 + packages/slate/src/interfaces/location.ts | 14 +- packages/slate/src/interfaces/node.ts | 215 +- packages/slate/src/interfaces/operation.ts | 49 +- packages/slate/src/interfaces/path-ref.ts | 7 +- packages/slate/src/interfaces/path.ts | 226 +- packages/slate/src/interfaces/point-ref.ts | 9 +- packages/slate/src/interfaces/point.ts | 51 +- packages/slate/src/interfaces/range-ref.ts | 7 +- packages/slate/src/interfaces/range.ts | 119 +- packages/slate/src/interfaces/text.ts | 68 +- .../{ => interfaces}/transforms/general.ts | 9 +- .../src/{ => interfaces}/transforms/index.ts | 0 .../slate/src/interfaces/transforms/node.ts | 192 ++ .../src/interfaces/transforms/selection.ts | 75 + .../slate/src/interfaces/transforms/text.ts | 106 + packages/slate/src/transforms-node/index.ts | 10 + .../slate/src/transforms-node/insert-nodes.ts | 117 ++ .../slate/src/transforms-node/lift-nodes.ts | 61 + .../slate/src/transforms-node/merge-nodes.ts | 156 ++ .../slate/src/transforms-node/move-nodes.ts | 53 + .../slate/src/transforms-node/remove-nodes.ts | 42 + .../slate/src/transforms-node/set-nodes.ts | 121 ++ .../slate/src/transforms-node/split-nodes.ts | 140 ++ .../slate/src/transforms-node/unset-nodes.ts | 20 + .../slate/src/transforms-node/unwrap-nodes.ts | 61 + .../slate/src/transforms-node/wrap-nodes.ts | 105 + .../src/transforms-selection/collapse.ts | 25 + .../src/transforms-selection/deselect.ts | 13 + .../slate/src/transforms-selection/index.ts | 6 + .../slate/src/transforms-selection/move.ts | 48 + .../slate/src/transforms-selection/select.ts | 29 + .../src/transforms-selection/set-point.ts | 31 + .../src/transforms-selection/set-selection.ts | 39 + .../slate/src/transforms-text/delete-text.ts | 196 ++ packages/slate/src/transforms-text/index.ts | 2 + .../src/transforms-text/insert-fragment.ts | 232 +++ packages/slate/src/transforms/node.ts | 1057 ---------- packages/slate/src/transforms/selection.ts | 217 -- packages/slate/src/transforms/text.ts | 536 ----- .../src/{interfaces => types}/custom-types.ts | 0 packages/slate/src/types/index.ts | 2 + .../slate/src/{interfaces => types}/types.ts | 0 packages/slate/src/utils/index.ts | 5 + packages/slate/src/utils/match-path.ts | 11 + packages/slate/src/utils/types.ts | 17 + .../marks/mark-void-range-hanging.tsx | 2 +- 125 files changed, 5286 insertions(+), 4367 deletions(-) create mode 100644 .changeset/slate-react.md create mode 100644 .changeset/slate.md create mode 100644 packages/slate-react/src/utils/types.ts create mode 100644 packages/slate/src/core/apply.ts create mode 100644 packages/slate/src/core/get-dirty-paths.ts create mode 100644 packages/slate/src/core/get-fragment.ts create mode 100644 packages/slate/src/core/index.ts create mode 100644 packages/slate/src/core/normalize-node.ts create mode 100644 packages/slate/src/core/should-normalize.ts create mode 100644 packages/slate/src/editor/above.ts create mode 100644 packages/slate/src/editor/add-mark.ts create mode 100644 packages/slate/src/editor/after.ts create mode 100644 packages/slate/src/editor/before.ts create mode 100644 packages/slate/src/editor/delete-backward.ts create mode 100644 packages/slate/src/editor/delete-forward.ts create mode 100644 packages/slate/src/editor/delete-fragment.ts create mode 100644 packages/slate/src/editor/edges.ts create mode 100644 packages/slate/src/editor/element-read-only.ts create mode 100644 packages/slate/src/editor/end.ts create mode 100644 packages/slate/src/editor/first.ts create mode 100644 packages/slate/src/editor/fragment.ts create mode 100644 packages/slate/src/editor/get-void.ts create mode 100644 packages/slate/src/editor/has-blocks.ts create mode 100644 packages/slate/src/editor/has-inlines.ts create mode 100644 packages/slate/src/editor/has-path.ts create mode 100644 packages/slate/src/editor/has-texts.ts create mode 100644 packages/slate/src/editor/index.ts create mode 100644 packages/slate/src/editor/insert-break.ts create mode 100644 packages/slate/src/editor/insert-node.ts create mode 100644 packages/slate/src/editor/insert-soft-break.ts create mode 100644 packages/slate/src/editor/insert-text.ts create mode 100644 packages/slate/src/editor/is-block.ts create mode 100644 packages/slate/src/editor/is-edge.ts create mode 100644 packages/slate/src/editor/is-editor.ts create mode 100644 packages/slate/src/editor/is-empty.ts create mode 100644 packages/slate/src/editor/is-end.ts create mode 100644 packages/slate/src/editor/is-normalizing.ts create mode 100644 packages/slate/src/editor/is-start.ts create mode 100644 packages/slate/src/editor/last.ts create mode 100644 packages/slate/src/editor/leaf.ts create mode 100644 packages/slate/src/editor/levels.ts create mode 100644 packages/slate/src/editor/marks.ts create mode 100644 packages/slate/src/editor/next.ts create mode 100644 packages/slate/src/editor/node.ts create mode 100644 packages/slate/src/editor/nodes.ts create mode 100644 packages/slate/src/editor/normalize.ts create mode 100644 packages/slate/src/editor/parent.ts create mode 100644 packages/slate/src/editor/path-ref.ts create mode 100644 packages/slate/src/editor/path-refs.ts create mode 100644 packages/slate/src/editor/path.ts create mode 100644 packages/slate/src/editor/point-ref.ts create mode 100644 packages/slate/src/editor/point-refs.ts create mode 100644 packages/slate/src/editor/point.ts create mode 100644 packages/slate/src/editor/positions.ts create mode 100644 packages/slate/src/editor/previous.ts create mode 100644 packages/slate/src/editor/range-ref.ts create mode 100644 packages/slate/src/editor/range-refs.ts create mode 100644 packages/slate/src/editor/range.ts create mode 100644 packages/slate/src/editor/remove-mark.ts create mode 100644 packages/slate/src/editor/set-normalizing.ts create mode 100644 packages/slate/src/editor/start.ts create mode 100644 packages/slate/src/editor/string.ts create mode 100644 packages/slate/src/editor/unhang-range.ts create mode 100644 packages/slate/src/editor/without-normalizing.ts create mode 100644 packages/slate/src/interfaces/index.ts rename packages/slate/src/{ => interfaces}/transforms/general.ts (99%) rename packages/slate/src/{ => interfaces}/transforms/index.ts (100%) create mode 100644 packages/slate/src/interfaces/transforms/node.ts create mode 100644 packages/slate/src/interfaces/transforms/selection.ts create mode 100644 packages/slate/src/interfaces/transforms/text.ts create mode 100644 packages/slate/src/transforms-node/index.ts create mode 100644 packages/slate/src/transforms-node/insert-nodes.ts create mode 100644 packages/slate/src/transforms-node/lift-nodes.ts create mode 100644 packages/slate/src/transforms-node/merge-nodes.ts create mode 100644 packages/slate/src/transforms-node/move-nodes.ts create mode 100644 packages/slate/src/transforms-node/remove-nodes.ts create mode 100644 packages/slate/src/transforms-node/set-nodes.ts create mode 100644 packages/slate/src/transforms-node/split-nodes.ts create mode 100644 packages/slate/src/transforms-node/unset-nodes.ts create mode 100644 packages/slate/src/transforms-node/unwrap-nodes.ts create mode 100644 packages/slate/src/transforms-node/wrap-nodes.ts create mode 100644 packages/slate/src/transforms-selection/collapse.ts create mode 100644 packages/slate/src/transforms-selection/deselect.ts create mode 100644 packages/slate/src/transforms-selection/index.ts create mode 100644 packages/slate/src/transforms-selection/move.ts create mode 100644 packages/slate/src/transforms-selection/select.ts create mode 100644 packages/slate/src/transforms-selection/set-point.ts create mode 100644 packages/slate/src/transforms-selection/set-selection.ts create mode 100644 packages/slate/src/transforms-text/delete-text.ts create mode 100644 packages/slate/src/transforms-text/index.ts create mode 100644 packages/slate/src/transforms-text/insert-fragment.ts delete mode 100644 packages/slate/src/transforms/node.ts delete mode 100644 packages/slate/src/transforms/selection.ts delete mode 100644 packages/slate/src/transforms/text.ts rename packages/slate/src/{interfaces => types}/custom-types.ts (100%) create mode 100644 packages/slate/src/types/index.ts rename packages/slate/src/{interfaces => types}/types.ts (100%) create mode 100644 packages/slate/src/utils/index.ts create mode 100644 packages/slate/src/utils/match-path.ts create mode 100644 packages/slate/src/utils/types.ts diff --git a/.changeset/slate-react.md b/.changeset/slate-react.md new file mode 100644 index 000000000..00a410ae5 --- /dev/null +++ b/.changeset/slate-react.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Interface methods JSDoc should now work on IDEs. diff --git a/.changeset/slate.md b/.changeset/slate.md new file mode 100644 index 000000000..a2c1f53ff --- /dev/null +++ b/.changeset/slate.md @@ -0,0 +1,156 @@ +--- +'slate': minor +--- + +New Features: + +- All **`Editor`** and **`Transforms`** methods now call **`editor`** methods. For example: **`Transforms.insertBreak`** now calls **`editor.insertBreak`**. +- **`editor.setNodes`** now calls **`setNodes`**, an exported function that implements the default editor behavior. +- You can now override **`editor.setNodes`** with your own implementation. +- You can use either **`Editor.setNodes`** or **`editor.setNodes`** in your code, and both will use your overridden behavior. + +The **`editor`** object now has many more methods: + +```tsx +export interface BaseEditor { + // Core state. + + children: Descendant[] + selection: Selection + operations: Operation[] + marks: EditorMarks | null + + // Overrideable core methods. + + apply: (operation: Operation) => void + getDirtyPaths: (operation: Operation) => Path[] + getFragment: () => Descendant[] + isElementReadOnly: (element: Element) => boolean + isSelectable: (element: Element) => boolean + markableVoid: (element: Element) => boolean + normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void + onChange: (options?: { operation?: Operation }) => void + shouldNormalize: ({ + iteration, + dirtyPaths, + operation, + }: { + iteration: number + initialDirtyPathsLength: number + dirtyPaths: Path[] + operation?: Operation + }) => boolean + + // Overrideable core transforms. + + addMark: OmitFirstArg + collapse: OmitFirstArg + delete: OmitFirstArg + deleteBackward: (unit: TextUnit) => void + deleteForward: (unit: TextUnit) => void + deleteFragment: OmitFirstArg + deselect: OmitFirstArg + insertBreak: OmitFirstArg + insertFragment: OmitFirstArg + insertNode: OmitFirstArg + insertNodes: OmitFirstArg + insertSoftBreak: OmitFirstArg + insertText: OmitFirstArg + liftNodes: OmitFirstArg + mergeNodes: OmitFirstArg + move: OmitFirstArg + moveNodes: OmitFirstArg + normalize: OmitFirstArg + removeMark: OmitFirstArg + removeNodes: OmitFirstArg + select: OmitFirstArg + setNodes: ( + props: Partial, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + hanging?: boolean + split?: boolean + voids?: boolean + compare?: PropsCompare + merge?: PropsMerge + } + ) => void + setNormalizing: OmitFirstArg + setPoint: OmitFirstArg + setSelection: OmitFirstArg + splitNodes: OmitFirstArg + unsetNodes: OmitFirstArg + unwrapNodes: OmitFirstArg + withoutNormalizing: OmitFirstArg + wrapNodes: OmitFirstArg + + // Overrideable core queries. + + above: ( + options?: EditorAboveOptions + ) => NodeEntry | undefined + after: OmitFirstArg + before: OmitFirstArg + edges: OmitFirstArg + elementReadOnly: OmitFirstArg + end: OmitFirstArg + first: OmitFirstArg + fragment: OmitFirstArg + getMarks: OmitFirstArg + hasBlocks: OmitFirstArg + hasInlines: OmitFirstArg + hasPath: OmitFirstArg + hasTexts: OmitFirstArg + isBlock: OmitFirstArg + isEdge: OmitFirstArg + isEmpty: OmitFirstArg + isEnd: OmitFirstArg + isInline: OmitFirstArg + isNormalizing: OmitFirstArg + isStart: OmitFirstArg + isVoid: OmitFirstArg + last: OmitFirstArg + leaf: OmitFirstArg + levels: ( + options?: EditorLevelsOptions + ) => Generator, void, undefined> + next: ( + options?: EditorNextOptions + ) => NodeEntry | undefined + node: OmitFirstArg + nodes: ( + options?: EditorNodesOptions + ) => Generator, void, undefined> + parent: OmitFirstArg + path: OmitFirstArg + pathRef: OmitFirstArg + pathRefs: OmitFirstArg + point: OmitFirstArg + pointRef: OmitFirstArg + pointRefs: OmitFirstArg + positions: OmitFirstArg + previous: ( + options?: EditorPreviousOptions + ) => NodeEntry | undefined + range: OmitFirstArg + rangeRef: OmitFirstArg + rangeRefs: OmitFirstArg + start: OmitFirstArg + string: OmitFirstArg + unhangRange: OmitFirstArg + void: OmitFirstArg +} +``` + +Note: + +- None of these method implementations have changed. +- **`getMarks`** is an exception, as there is already **`editor.marks`** that stores the current marks. +- **`Transforms.insertText`** has not been moved to **`editor`** yet: there is already an **`editor.insertText`** method with extended behavior. This may change in a future release, but this release is trying to avoid any breaking changes. +- **`editor.insertText`** has a new argument (third): **`options?: TextInsertTextOptions`** to match **`Transforms.insertText`**. + +Bug Fixes: + +- Moving JSDoc's to the interface type to allow IDEs access to the interface methods. diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 334189714..4550c90b3 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -20,12 +20,14 @@ import { Text, Transforms, } from 'slate' -import { ReactEditor } from '../plugin/react-editor' +import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager' import useChildren from '../hooks/use-children' import { DecorateContext } from '../hooks/use-decorate' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' import { ReadOnlyContext } from '../hooks/use-read-only' import { useSlate } from '../hooks/use-slate' +import { useTrackUserInput } from '../hooks/use-track-user-input' +import { ReactEditor } from '../plugin/react-editor' import { TRIPLE_CLICK } from '../utils/constants' import { DOMElement, @@ -53,7 +55,6 @@ import { EDITOR_TO_ELEMENT, EDITOR_TO_FORCE_RENDER, EDITOR_TO_PENDING_INSERTION_MARKS, - EDITOR_TO_PLACEHOLDER_ELEMENT, EDITOR_TO_USER_MARKS, EDITOR_TO_USER_SELECTION, EDITOR_TO_WINDOW, @@ -66,8 +67,6 @@ import { PLACEHOLDER_SYMBOL, } from '../utils/weak-maps' import { RestoreDOM } from './restore-dom/restore-dom' -import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager' -import { useTrackUserInput } from '../hooks/use-track-user-input' import { AndroidInputManager } from '../hooks/android-input-manager/android-input-manager' type DeferredOperation = () => void diff --git a/packages/slate-react/src/components/element.tsx b/packages/slate-react/src/components/element.tsx index 3b54d9800..92d36e968 100644 --- a/packages/slate-react/src/components/element.tsx +++ b/packages/slate-react/src/components/element.tsx @@ -1,24 +1,24 @@ -import React, { useCallback } from 'react' import getDirection from 'direction' -import { Editor, Node, Range, Element as SlateElement } from 'slate' - -import Text from './text' +import React, { useCallback } from 'react' +import { Editor, Element as SlateElement, Node, Range } from 'slate' +import { ReactEditor, useReadOnly, useSlateStatic } from '..' import useChildren from '../hooks/use-children' -import { ReactEditor, useSlateStatic, useReadOnly } from '..' -import { - NODE_TO_ELEMENT, - ELEMENT_TO_NODE, - NODE_TO_PARENT, - NODE_TO_INDEX, - EDITOR_TO_KEY_TO_ELEMENT, -} from '../utils/weak-maps' import { isElementDecorationsEqual } from '../utils/range-list' +import { + EDITOR_TO_KEY_TO_ELEMENT, + ELEMENT_TO_NODE, + NODE_TO_ELEMENT, + NODE_TO_INDEX, + NODE_TO_PARENT, +} from '../utils/weak-maps' import { RenderElementProps, RenderLeafProps, RenderPlaceholderProps, } from './editable' +import Text from './text' + /** * Element. */ diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx index 3c1b2a56c..3dbb41887 100644 --- a/packages/slate-react/src/components/slate.tsx +++ b/packages/slate-react/src/components/slate.tsx @@ -1,16 +1,16 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react' -import { Editor, Node, Descendant, Scrubber } from 'slate' -import { ReactEditor } from '../plugin/react-editor' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Descendant, Editor, Node, Scrubber } from 'slate' import { FocusedContext } from '../hooks/use-focused' -import { EditorContext } from '../hooks/use-slate-static' +import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' import { SlateContext, SlateContextValue } from '../hooks/use-slate' import { useSelectorContext, SlateSelectorContext, } from '../hooks/use-slate-selector' -import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps' +import { EditorContext } from '../hooks/use-slate-static' +import { ReactEditor } from '../plugin/react-editor' import { IS_REACT_VERSION_17_OR_ABOVE } from '../utils/environment' -import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' +import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps' /** * A wrapper around the provider to handle `onChange` events, because the editor diff --git a/packages/slate-react/src/components/text.tsx b/packages/slate-react/src/components/text.tsx index cb8462b32..e2b46c4a6 100644 --- a/packages/slate-react/src/components/text.tsx +++ b/packages/slate-react/src/components/text.tsx @@ -1,7 +1,6 @@ -import React, { useRef, useCallback } from 'react' +import React, { useCallback, useRef } from 'react' import { Element, Range, Text as SlateText } from 'slate' import { ReactEditor, useSlateStatic } from '..' -import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' import { isTextDecorationsEqual } from '../utils/range-list' import { EDITOR_TO_KEY_TO_ELEMENT, diff --git a/packages/slate-react/src/hooks/use-children.tsx b/packages/slate-react/src/hooks/use-children.tsx index e9997b94a..c93ad7ac1 100644 --- a/packages/slate-react/src/hooks/use-children.tsx +++ b/packages/slate-react/src/hooks/use-children.tsx @@ -1,18 +1,18 @@ import React from 'react' -import { Editor, Range, Element, Ancestor, Descendant } from 'slate' - -import ElementComponent from '../components/element' -import TextComponent from '../components/text' -import { ReactEditor } from '../plugin/react-editor' -import { useSlateStatic } from './use-slate-static' -import { useDecorate } from './use-decorate' -import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps' +import { Ancestor, Descendant, Editor, Element, Range } from 'slate' import { RenderElementProps, RenderLeafProps, RenderPlaceholderProps, } from '../components/editable' + +import ElementComponent from '../components/element' +import TextComponent from '../components/text' +import { ReactEditor } from '../plugin/react-editor' +import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps' +import { useDecorate } from './use-decorate' import { SelectedContext } from './use-selected' +import { useSlateStatic } from './use-slate-static' /** * Children. diff --git a/packages/slate-react/src/hooks/use-mutation-observer.ts b/packages/slate-react/src/hooks/use-mutation-observer.ts index 6fa393090..df282c7df 100644 --- a/packages/slate-react/src/hooks/use-mutation-observer.ts +++ b/packages/slate-react/src/hooks/use-mutation-observer.ts @@ -1,7 +1,5 @@ import { RefObject, useEffect, useState } from 'react' import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect' -import { isDOMElement } from '../utils/dom' -import { ReactEditor } from '../plugin/react-editor' export function useMutationObserver( node: RefObject, diff --git a/packages/slate-react/src/hooks/use-slate-static.tsx b/packages/slate-react/src/hooks/use-slate-static.tsx index 4ced7c2d5..c45452bb1 100644 --- a/packages/slate-react/src/hooks/use-slate-static.tsx +++ b/packages/slate-react/src/hooks/use-slate-static.tsx @@ -1,6 +1,6 @@ import { createContext, useContext } from 'react' -import { ReactEditor } from '../plugin/react-editor' import { Editor } from 'slate' +import { ReactEditor } from '../plugin/react-editor' /** * A React context for sharing the editor object. diff --git a/packages/slate-react/src/hooks/use-track-user-input.ts b/packages/slate-react/src/hooks/use-track-user-input.ts index ae9b0cf25..cd66a3bfc 100644 --- a/packages/slate-react/src/hooks/use-track-user-input.ts +++ b/packages/slate-react/src/hooks/use-track-user-input.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { ReactEditor } from '../plugin/react-editor' import { useSlateStatic } from './use-slate-static' diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index f930db467..d5d72ca5e 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -1,30 +1,15 @@ import { BaseEditor, Editor, + Element, Node, Path, Point, Range, Scrubber, Transforms, - Element, } from 'slate' - -import { Key } from '../utils/key' -import { - EDITOR_TO_ELEMENT, - ELEMENT_TO_NODE, - IS_FOCUSED, - IS_READ_ONLY, - NODE_TO_INDEX, - NODE_TO_KEY, - NODE_TO_PARENT, - EDITOR_TO_WINDOW, - EDITOR_TO_KEY_TO_ELEMENT, - IS_COMPOSING, - EDITOR_TO_SCHEDULE_FLUSH, - EDITOR_TO_PENDING_DIFFS, -} from '../utils/weak-maps' +import { TextDiff } from '../utils/diff-text' import { DOMElement, DOMNode, @@ -32,73 +17,360 @@ import { DOMRange, DOMSelection, DOMStaticRange, - isDOMElement, - isDOMSelection, - isDOMNode, - normalizeDOMPoint, - hasShadowRoot, DOMText, + hasShadowRoot, + isDOMElement, + isDOMNode, + isDOMSelection, + normalizeDOMPoint, } from '../utils/dom' -import { IS_CHROME, IS_FIREFOX, IS_ANDROID } from '../utils/environment' +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 React and DOM-specific version of the `Editor` interface. */ export interface ReactEditor extends BaseEditor { - insertData: (data: DataTransfer) => void - insertFragmentData: (data: DataTransfer) => boolean - insertTextData: (data: DataTransfer) => boolean - setFragmentData: ( - data: DataTransfer, - originEvent?: 'drag' | 'copy' | 'cut' - ) => void - hasRange: (editor: ReactEditor, range: Range) => boolean - hasTarget: ( - editor: ReactEditor, - target: EventTarget | null - ) => target is DOMNode hasEditableTarget: ( editor: ReactEditor, target: EventTarget | null ) => target is DOMNode + hasRange: (editor: ReactEditor, range: Range) => boolean hasSelectableTarget: ( editor: ReactEditor, target: EventTarget | null ) => boolean + hasTarget: ( + editor: ReactEditor, + target: EventTarget | null + ) => target is DOMNode + insertData: (data: DataTransfer) => void + insertFragmentData: (data: DataTransfer) => boolean + insertTextData: (data: DataTransfer) => boolean isTargetInsideNonReadonlyVoid: ( editor: ReactEditor, target: EventTarget | null ) => boolean + setFragmentData: ( + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut' + ) => void } -// eslint-disable-next-line no-redeclare -export const ReactEditor = { +export interface ReactEditorInterface { /** - * Check if the user is currently composing inside the editor. + * Experimental and android specific: Get pending diffs */ - - isComposing(editor: ReactEditor): boolean { - return !!IS_COMPOSING.get(editor) - }, + androidPendingDiffs: (editor: Editor) => TextDiff[] | undefined /** - * Return the host window of the current editor. + * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. */ + androidScheduleFlush: (editor: Editor) => void - getWindow(editor: ReactEditor): Window { - const window = EDITOR_TO_WINDOW.get(editor) - if (!window) { - throw new Error('Unable to find a host window element for this editor') - } - return window - }, + /** + * Blur the editor. + */ + blur: (editor: ReactEditor) => void + + /** + * Deselect the editor. + */ + deselect: (editor: ReactEditor) => void + + /** + * Find the DOM node that implements DocumentOrShadowRoot for the editor. + */ + findDocumentOrShadowRoot: (editor: ReactEditor) => Document | ShadowRoot + + /** + * Get the target range from a DOM `event`. + */ + findEventRange: (editor: ReactEditor, event: any) => Range /** * Find a key for a Slate node. */ + findKey: (editor: ReactEditor, node: Node) => Key - findKey(editor: ReactEditor, node: Node): Key { + /** + * Find the path of Slate node. + */ + findPath: (editor: ReactEditor, node: Node) => Path + + /** + * Focus the editor. + */ + focus: (editor: ReactEditor) => void + + /** + * Return the host window of the current editor. + */ + getWindow: (editor: ReactEditor) => Window + + /** + * Check if a DOM node is within the editor. + */ + hasDOMNode: ( + editor: ReactEditor, + target: DOMNode, + options?: { editable?: boolean } + ) => boolean + + /** + * Check if the target is editable and in the editor. + */ + hasEditableTarget: ( + editor: ReactEditor, + target: EventTarget | null + ) => target is DOMNode + + /** + * + */ + hasRange: (editor: ReactEditor, range: Range) => boolean + + /** + * Check if the target can be selectable + */ + hasSelectableTarget: ( + editor: ReactEditor, + target: EventTarget | null + ) => boolean + + /** + * Check if the target is in the editor. + */ + hasTarget: ( + editor: ReactEditor, + target: EventTarget | null + ) => target is DOMNode + + /** + * Insert data from a `DataTransfer` into the editor. + */ + insertData: (editor: ReactEditor, data: DataTransfer) => void + + /** + * Insert fragment data from a `DataTransfer` into the editor. + */ + insertFragmentData: (editor: ReactEditor, data: DataTransfer) => boolean + + /** + * Insert text data from a `DataTransfer` into the editor. + */ + insertTextData: (editor: ReactEditor, data: DataTransfer) => boolean + + /** + * Check if the user is currently composing inside the editor. + */ + isComposing: (editor: ReactEditor) => boolean + + /** + * Check if the editor is focused. + */ + isFocused: (editor: ReactEditor) => boolean + + /** + * Check if the editor is in read-only mode. + */ + isReadOnly: (editor: ReactEditor) => boolean + + /** + * Check if the target is inside void and in an non-readonly editor. + */ + isTargetInsideNonReadonlyVoid: ( + editor: ReactEditor, + target: EventTarget | null + ) => boolean + + /** + * Sets data from the currently selected fragment on a `DataTransfer`. + */ + setFragmentData: ( + editor: ReactEditor, + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut' + ) => void + + /** + * Find the native DOM element from a Slate node. + */ + toDOMNode: (editor: ReactEditor, node: Node) => HTMLElement + + /** + * Find a native DOM selection point from a Slate point. + */ + toDOMPoint: (editor: ReactEditor, 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: ReactEditor, range: Range) => DOMRange + + /** + * Find a Slate node from a native DOM `element`. + */ + toSlateNode: (editor: ReactEditor, domNode: DOMNode) => Node + + /** + * Find a Slate point from a DOM selection's `domNode` and `domOffset`. + */ + toSlatePoint: ( + editor: ReactEditor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + } + ) => T extends true ? Point | null : Point + + /** + * Find a Slate range from a DOM range or selection. + */ + toSlateRange: ( + editor: ReactEditor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + } + ) => T extends true ? Range | null : Range +} + +// eslint-disable-next-line no-redeclare +export const ReactEditor: ReactEditorInterface = { + androidPendingDiffs: editor => EDITOR_TO_PENDING_DIFFS.get(editor), + + androidScheduleFlush: editor => { + EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() + }, + + blur: editor => { + const el = ReactEditor.toDOMNode(editor, editor) + const root = ReactEditor.findDocumentOrShadowRoot(editor) + IS_FOCUSED.set(editor, false) + + if (root.activeElement === el) { + el.blur() + } + }, + + deselect: editor => { + const { selection } = editor + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = root.getSelection() + + if (domSelection && domSelection.rangeCount > 0) { + domSelection.removeAllRanges() + } + + if (selection) { + Transforms.deselect(editor) + } + }, + + findDocumentOrShadowRoot: editor => { + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if ( + (root instanceof Document || root instanceof ShadowRoot) && + root.getSelection != null + ) { + 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 = ReactEditor.toSlateNode(editor, event.target) + const path = ReactEditor.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 } = ReactEditor.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 = ReactEditor.toSlateRange(editor, domRange, { + exactMatch: false, + suppressThrow: false, + }) + return range + }, + + findKey: (editor, node) => { let key = NODE_TO_KEY.get(node) if (!key) { @@ -109,11 +381,7 @@ export const ReactEditor = { return key }, - /** - * Find the path of Slate node. - */ - - findPath(editor: ReactEditor, node: Node): Path { + findPath: (editor, node) => { const path: Path = [] let child = node @@ -143,59 +411,7 @@ export const ReactEditor = { ) }, - /** - * Find the DOM node that implements DocumentOrShadowRoot for the editor. - */ - - findDocumentOrShadowRoot(editor: ReactEditor): Document | ShadowRoot { - const el = ReactEditor.toDOMNode(editor, editor) - const root = el.getRootNode() - - if ( - (root instanceof Document || root instanceof ShadowRoot) && - root.getSelection != null - ) { - return root - } - - return el.ownerDocument - }, - - /** - * Check if the editor is focused. - */ - - isFocused(editor: ReactEditor): boolean { - return !!IS_FOCUSED.get(editor) - }, - - /** - * Check if the editor is in read-only mode. - */ - - isReadOnly(editor: ReactEditor): boolean { - return !!IS_READ_ONLY.get(editor) - }, - - /** - * Blur the editor. - */ - - blur(editor: ReactEditor): void { - const el = ReactEditor.toDOMNode(editor, editor) - const root = ReactEditor.findDocumentOrShadowRoot(editor) - IS_FOCUSED.set(editor, false) - - if (root.activeElement === el) { - el.blur() - } - }, - - /** - * Focus the editor. - */ - - focus(editor: ReactEditor): void { + focus: editor => { const el = ReactEditor.toDOMNode(editor, editor) const root = ReactEditor.findDocumentOrShadowRoot(editor) IS_FOCUSED.set(editor, true) @@ -205,33 +421,15 @@ export const ReactEditor = { } }, - /** - * Deselect the editor. - */ - - deselect(editor: ReactEditor): void { - const { selection } = editor - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const domSelection = root.getSelection() - - if (domSelection && domSelection.rangeCount > 0) { - domSelection.removeAllRanges() - } - - if (selection) { - Transforms.deselect(editor) + 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 }, - /** - * Check if a DOM node is within the editor. - */ - - hasDOMNode( - editor: ReactEditor, - target: DOMNode, - options: { editable?: boolean } = {} - ): boolean { + hasDOMNode: (editor, target, options = {}) => { const { editable = false } = options const editorEl = ReactEditor.toDOMNode(editor, editor) let targetEl @@ -267,47 +465,53 @@ export const ReactEditor = { ) }, - /** - * Insert data from a `DataTransfer` into the editor. - */ + hasEditableTarget: (editor, target): target is DOMNode => + isDOMNode(target) && + ReactEditor.hasDOMNode(editor, target, { editable: true }), - insertData(editor: ReactEditor, data: DataTransfer): void { + hasRange: (editor, range) => { + const { anchor, focus } = range + return ( + Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) + ) + }, + + hasSelectableTarget: (editor, target) => + ReactEditor.hasEditableTarget(editor, target) || + ReactEditor.isTargetInsideNonReadonlyVoid(editor, target), + + hasTarget: (editor, target): target is DOMNode => + isDOMNode(target) && ReactEditor.hasDOMNode(editor, target), + + insertData: (editor, data) => { editor.insertData(data) }, - /** - * Insert fragment data from a `DataTransfer` into the editor. - */ + insertFragmentData: (editor, data) => editor.insertFragmentData(data), - insertFragmentData(editor: ReactEditor, data: DataTransfer): boolean { - return editor.insertFragmentData(data) + insertTextData: (editor, data) => editor.insertTextData(data), + + isComposing: editor => { + return !!IS_COMPOSING.get(editor) }, - /** - * Insert text data from a `DataTransfer` into the editor. - */ + isFocused: editor => !!IS_FOCUSED.get(editor), - insertTextData(editor: ReactEditor, data: DataTransfer): boolean { - return editor.insertTextData(data) + isReadOnly: editor => !!IS_READ_ONLY.get(editor), + + isTargetInsideNonReadonlyVoid: (editor, target) => { + if (IS_READ_ONLY.get(editor)) return false + + const slateNode = + ReactEditor.hasTarget(editor, target) && + ReactEditor.toSlateNode(editor, target) + return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) }, - /** - * Sets data from the currently selected fragment on a `DataTransfer`. - */ + setFragmentData: (editor, data, originEvent) => + editor.setFragmentData(data, originEvent), - setFragmentData( - editor: ReactEditor, - data: DataTransfer, - originEvent?: 'drag' | 'copy' | 'cut' - ): void { - editor.setFragmentData(data, originEvent) - }, - - /** - * Find the native DOM element from a Slate node. - */ - - toDOMNode(editor: ReactEditor, node: Node): HTMLElement { + toDOMNode: (editor, node) => { const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor) const domNode = Editor.isEditor(node) ? EDITOR_TO_ELEMENT.get(editor) @@ -322,11 +526,7 @@ export const ReactEditor = { return domNode }, - /** - * Find a native DOM selection point from a Slate point. - */ - - toDOMPoint(editor: ReactEditor, point: Point): DOMPoint { + toDOMPoint: (editor, point) => { const [node] = Editor.node(editor, point.path) const el = ReactEditor.toDOMNode(editor, node) let domPoint: DOMPoint | undefined @@ -398,16 +598,7 @@ export const ReactEditor = { return 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: ReactEditor, range: Range): DOMRange { + toDOMRange: (editor, range) => { const { anchor, focus } = range const isBackward = Range.isBackward(range) const domAnchor = ReactEditor.toDOMPoint(editor, anchor) @@ -437,11 +628,7 @@ export const ReactEditor = { return domRange }, - /** - * Find a Slate node from a native DOM `element`. - */ - - toSlateNode(editor: ReactEditor, domNode: DOMNode): Node { + toSlateNode: (editor, domNode) => { let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement if (domEl && !domEl.hasAttribute('data-slate-node')) { @@ -457,87 +644,14 @@ export const ReactEditor = { return node }, - /** - * Get the target range from a DOM `event`. - */ - - findEventRange(editor: ReactEditor, event: any): Range { - 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 = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.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 } = ReactEditor.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 = ReactEditor.toSlateRange(editor, domRange, { - exactMatch: false, - suppressThrow: false, - }) - return range - }, - - /** - * Find a Slate point from a DOM selection's `domNode` and `domOffset`. - */ - - toSlatePoint( + toSlatePoint: ( editor: ReactEditor, domPoint: DOMPoint, options: { exactMatch: boolean suppressThrow: T } - ): T extends true ? Point | null : Point { + ): T extends true ? Point | null : Point => { const { exactMatch, suppressThrow } = options const [nearestNode, nearestOffset] = exactMatch ? domPoint @@ -695,18 +809,14 @@ export const ReactEditor = { return { path, offset } as T extends true ? Point | null : Point }, - /** - * Find a Slate range from a DOM range or selection. - */ - - toSlateRange( + toSlateRange: ( editor: ReactEditor, domRange: DOMRange | DOMStaticRange | DOMSelection, options: { exactMatch: boolean suppressThrow: T } - ): T extends true ? Range | null : Range { + ): T extends true ? Range | null : Range => { const { exactMatch, suppressThrow } = options const el = isDOMSelection(domRange) ? domRange.anchorNode @@ -839,76 +949,4 @@ export const ReactEditor = { return (range as unknown) as T extends true ? Range | null : Range }, - - hasRange(editor: ReactEditor, range: Range): boolean { - const { anchor, focus } = range - return ( - Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) - ) - }, - - /** - * Check if the target is in the editor. - */ - hasTarget( - editor: ReactEditor, - target: EventTarget | null - ): target is DOMNode { - return isDOMNode(target) && ReactEditor.hasDOMNode(editor, target) - }, - - /** - * Check if the target is editable and in the editor. - */ - hasEditableTarget( - editor: ReactEditor, - target: EventTarget | null - ): target is DOMNode { - return ( - isDOMNode(target) && - ReactEditor.hasDOMNode(editor, target, { editable: true }) - ) - }, - - /** - * Check if the target can be selectable - */ - hasSelectableTarget( - editor: ReactEditor, - target: EventTarget | null - ): boolean { - return ( - ReactEditor.hasEditableTarget(editor, target) || - ReactEditor.isTargetInsideNonReadonlyVoid(editor, target) - ) - }, - - /** - * Check if the target is inside void and in an non-readonly editor. - */ - isTargetInsideNonReadonlyVoid( - editor: ReactEditor, - target: EventTarget | null - ): boolean { - if (IS_READ_ONLY.get(editor)) return false - - const slateNode = - ReactEditor.hasTarget(editor, target) && - ReactEditor.toSlateNode(editor, target) - return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) - }, - - /** - * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. - */ - androidScheduleFlush(editor: Editor) { - EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() - }, - - /** - * Experimental and android specific: Get pending diffs - */ - androidPendingDiffs(editor: Editor) { - return EDITOR_TO_PENDING_DIFFS.get(editor) - }, } diff --git a/packages/slate-react/src/utils/lines.ts b/packages/slate-react/src/utils/lines.ts index 8831999c1..7b0ef7013 100644 --- a/packages/slate-react/src/utils/lines.ts +++ b/packages/slate-react/src/utils/lines.ts @@ -2,7 +2,7 @@ * Utilities for single-line deletion */ -import { Range, Editor } from 'slate' +import { Editor, Range } from 'slate' import { ReactEditor } from '../plugin/react-editor' const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { diff --git a/packages/slate-react/src/utils/types.ts b/packages/slate-react/src/utils/types.ts new file mode 100644 index 000000000..c4d025c36 --- /dev/null +++ b/packages/slate-react/src/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/slate/src/core/apply.ts b/packages/slate/src/core/apply.ts new file mode 100644 index 000000000..c08df9572 --- /dev/null +++ b/packages/slate/src/core/apply.ts @@ -0,0 +1,78 @@ +import { PathRef } from '../interfaces/path-ref' +import { PointRef } from '../interfaces/point-ref' +import { RangeRef } from '../interfaces/range-ref' +import { DIRTY_PATH_KEYS, DIRTY_PATHS, FLUSHING } from '../utils/weak-maps' +import { Path } from '../interfaces/path' +import { Transforms } from '../interfaces/transforms' +import { WithEditorFirstArg } from '../utils/types' +import { Editor } from '../interfaces/editor' + +export const apply: WithEditorFirstArg = (editor, op) => { + for (const ref of Editor.pathRefs(editor)) { + PathRef.transform(ref, op) + } + + for (const ref of Editor.pointRefs(editor)) { + PointRef.transform(ref, op) + } + + for (const ref of Editor.rangeRefs(editor)) { + RangeRef.transform(ref, op) + } + + const oldDirtyPaths = DIRTY_PATHS.get(editor) || [] + const oldDirtyPathKeys = DIRTY_PATH_KEYS.get(editor) || new Set() + let dirtyPaths: Path[] + let dirtyPathKeys: Set + + const add = (path: Path | null) => { + if (path) { + const key = path.join(',') + + if (!dirtyPathKeys.has(key)) { + dirtyPathKeys.add(key) + dirtyPaths.push(path) + } + } + } + + if (Path.operationCanTransformPath(op)) { + dirtyPaths = [] + dirtyPathKeys = new Set() + for (const path of oldDirtyPaths) { + const newPath = Path.transform(path, op) + add(newPath) + } + } else { + dirtyPaths = oldDirtyPaths + dirtyPathKeys = oldDirtyPathKeys + } + + const newDirtyPaths = editor.getDirtyPaths(op) + for (const path of newDirtyPaths) { + add(path) + } + + DIRTY_PATHS.set(editor, dirtyPaths) + DIRTY_PATH_KEYS.set(editor, dirtyPathKeys) + Transforms.transform(editor, op) + editor.operations.push(op) + Editor.normalize(editor, { + operation: op, + }) + + // Clear any formats applied to the cursor if the selection changes. + if (op.type === 'set_selection') { + editor.marks = null + } + + if (!FLUSHING.get(editor)) { + FLUSHING.set(editor, true) + + Promise.resolve().then(() => { + FLUSHING.set(editor, false) + editor.onChange({ operation: op }) + editor.operations = [] + }) + } +} diff --git a/packages/slate/src/core/get-dirty-paths.ts b/packages/slate/src/core/get-dirty-paths.ts new file mode 100644 index 000000000..ce4e3a1f2 --- /dev/null +++ b/packages/slate/src/core/get-dirty-paths.ts @@ -0,0 +1,83 @@ +import { WithEditorFirstArg } from '../utils/types' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' +import { Node } from '../interfaces/node' +import { Editor } from '../interfaces/editor' + +/** + * Get the "dirty" paths generated from an operation. + */ +export const getDirtyPaths: WithEditorFirstArg = ( + editor, + op +) => { + switch (op.type) { + case 'insert_text': + case 'remove_text': + case 'set_node': { + const { path } = op + return Path.levels(path) + } + + case 'insert_node': { + const { node, path } = op + const levels = Path.levels(path) + const descendants = Text.isText(node) + ? [] + : Array.from(Node.nodes(node), ([, p]) => path.concat(p)) + + return [...levels, ...descendants] + } + + case 'merge_node': { + const { path } = op + const ancestors = Path.ancestors(path) + const previousPath = Path.previous(path) + return [...ancestors, previousPath] + } + + case 'move_node': { + const { path, newPath } = op + + if (Path.equals(path, newPath)) { + return [] + } + + const oldAncestors: Path[] = [] + const newAncestors: Path[] = [] + + for (const ancestor of Path.ancestors(path)) { + const p = Path.transform(ancestor, op) + oldAncestors.push(p!) + } + + for (const ancestor of Path.ancestors(newPath)) { + const p = Path.transform(ancestor, op) + newAncestors.push(p!) + } + + const newParent = newAncestors[newAncestors.length - 1] + const newIndex = newPath[newPath.length - 1] + const resultPath = newParent.concat(newIndex) + + return [...oldAncestors, ...newAncestors, resultPath] + } + + case 'remove_node': { + const { path } = op + const ancestors = Path.ancestors(path) + return [...ancestors] + } + + case 'split_node': { + const { path } = op + const levels = Path.levels(path) + const nextPath = Path.next(path) + return [...levels, nextPath] + } + + default: { + return [] + } + } +} diff --git a/packages/slate/src/core/get-fragment.ts b/packages/slate/src/core/get-fragment.ts new file mode 100644 index 000000000..0a65cc260 --- /dev/null +++ b/packages/slate/src/core/get-fragment.ts @@ -0,0 +1,11 @@ +import { Editor, Node } from '../interfaces' +import { WithEditorFirstArg } from '../utils' + +export const getFragment: WithEditorFirstArg = editor => { + const { selection } = editor + + if (selection) { + return Node.fragment(editor, selection) + } + return [] +} diff --git a/packages/slate/src/core/index.ts b/packages/slate/src/core/index.ts new file mode 100644 index 000000000..46fd43b53 --- /dev/null +++ b/packages/slate/src/core/index.ts @@ -0,0 +1,5 @@ +export * from './apply' +export * from './get-dirty-paths' +export * from './get-fragment' +export * from './normalize-node' +export * from './should-normalize' diff --git a/packages/slate/src/core/normalize-node.ts b/packages/slate/src/core/normalize-node.ts new file mode 100644 index 000000000..284f9c3cf --- /dev/null +++ b/packages/slate/src/core/normalize-node.ts @@ -0,0 +1,99 @@ +import { WithEditorFirstArg } from '../utils/types' +import { Text } from '../interfaces/text' +import { Element } from '../interfaces/element' +import { Transforms } from '../interfaces/transforms' +import { Descendant, Node } from '../interfaces/node' +import { Editor } from '../interfaces/editor' + +export const normalizeNode: WithEditorFirstArg = ( + editor, + entry +) => { + const [node, path] = entry + + // There are no core normalizations for text nodes. + if (Text.isText(node)) { + return + } + + // Ensure that block and inline nodes have at least one text child. + if (Element.isElement(node) && node.children.length === 0) { + const child = { text: '' } + Transforms.insertNodes(editor, child, { + at: path.concat(0), + voids: true, + }) + return + } + + // Determine whether the node should have block or inline children. + const shouldHaveInlines = Editor.isEditor(node) + ? false + : Element.isElement(node) && + (editor.isInline(node) || + node.children.length === 0 || + Text.isText(node.children[0]) || + editor.isInline(node.children[0])) + + // Since we'll be applying operations while iterating, keep track of an + // index that accounts for any added/removed nodes. + let n = 0 + + for (let i = 0; i < node.children.length; i++, n++) { + const currentNode = Node.get(editor, path) + if (Text.isText(currentNode)) continue + const child = currentNode.children[n] as Descendant + const prev = currentNode.children[n - 1] as Descendant + const isLast = i === node.children.length - 1 + const isInlineOrText = + Text.isText(child) || (Element.isElement(child) && editor.isInline(child)) + + // Only allow block nodes in the top-level children and parent blocks + // that only contain block nodes. Similarly, only allow inline nodes in + // other inline nodes, or parent blocks that only contain inlines and + // text. + if (isInlineOrText !== shouldHaveInlines) { + Transforms.removeNodes(editor, { at: path.concat(n), voids: true }) + n-- + } else if (Element.isElement(child)) { + // Ensure that inline nodes are surrounded by text nodes. + if (editor.isInline(child)) { + if (prev == null || !Text.isText(prev)) { + const newChild = { text: '' } + Transforms.insertNodes(editor, newChild, { + at: path.concat(n), + voids: true, + }) + n++ + } else if (isLast) { + const newChild = { text: '' } + Transforms.insertNodes(editor, newChild, { + at: path.concat(n + 1), + voids: true, + }) + n++ + } + } + } else { + // Merge adjacent text nodes that are empty or match. + if (prev != null && Text.isText(prev)) { + if (Text.equals(child, prev, { loose: true })) { + Transforms.mergeNodes(editor, { at: path.concat(n), voids: true }) + n-- + } else if (prev.text === '') { + Transforms.removeNodes(editor, { + at: path.concat(n - 1), + voids: true, + }) + n-- + } else if (child.text === '') { + Transforms.removeNodes(editor, { + at: path.concat(n), + voids: true, + }) + n-- + } + } + } + } +} diff --git a/packages/slate/src/core/should-normalize.ts b/packages/slate/src/core/should-normalize.ts new file mode 100644 index 000000000..2bed7d9d2 --- /dev/null +++ b/packages/slate/src/core/should-normalize.ts @@ -0,0 +1,17 @@ +import { WithEditorFirstArg } from '../utils/types' +import { Editor } from '../interfaces/editor' + +export const shouldNormalize: WithEditorFirstArg = ( + editor, + { iteration, initialDirtyPathsLength } +) => { + const maxIterations = initialDirtyPathsLength * 42 // HACK: better way? + + if (iteration > maxIterations) { + throw new Error( + `Could not completely normalize the editor after ${maxIterations} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.` + ) + } + + return true +} diff --git a/packages/slate/src/create-editor.ts b/packages/slate/src/create-editor.ts index 1b95b2939..ba2b921c2 100644 --- a/packages/slate/src/create-editor.ts +++ b/packages/slate/src/create-editor.ts @@ -1,24 +1,92 @@ import { - Descendant, + addMark, + deleteFragment, Editor, - Element, - Node, - Operation, - Path, - PathRef, - PointRef, - Range, - RangeRef, - Text, - Transforms, + getDirtyPaths, + getFragment, + insertBreak, + insertFragment, + insertNode, + insertSoftBreak, + insertText, + normalizeNode, + removeMark, + shouldNormalize, } from './' -import { TextUnit } from './interfaces/types' -import { DIRTY_PATH_KEYS, DIRTY_PATHS, FLUSHING } from './utils/weak-maps' +import { apply } from './core' +import { + above, + after, + before, + deleteBackward, + deleteForward, + edges, + elementReadOnly, + end, + first, + fragment, + getVoid, + hasBlocks, + hasInlines, + hasPath, + hasTexts, + isBlock, + isEdge, + isEmpty, + isEnd, + isNormalizing, + isStart, + last, + leaf, + levels, + marks, + next, + node, + nodes, + normalize, + parent, + path, + pathRef, + pathRefs, + point, + pointRef, + pointRefs, + positions, + previous, + range, + rangeRef, + rangeRefs, + setNormalizing, + start, + string, + unhangRange, + withoutNormalizing, +} from './editor' +import { deleteText } from './transforms-text' +import { + collapse, + deselect, + move, + select, + setPoint, + setSelection, +} from './transforms-selection' +import { + insertNodes, + liftNodes, + mergeNodes, + moveNodes, + removeNodes, + setNodes, + splitNodes, + unsetNodes, + unwrapNodes, + wrapNodes, +} from './transforms-node' /** * Create a new Slate `Editor` object. */ - export const createEditor = (): Editor => { const editor: Editor = { children: [], @@ -32,401 +100,87 @@ export const createEditor = (): Editor => { markableVoid: () => false, onChange: () => {}, - apply: (op: Operation) => { - for (const ref of Editor.pathRefs(editor)) { - PathRef.transform(ref, op) - } + // Core + apply: (...args) => apply(editor, ...args), - for (const ref of Editor.pointRefs(editor)) { - PointRef.transform(ref, op) - } + // Editor + addMark: (...args) => addMark(editor, ...args), + deleteBackward: (...args) => deleteBackward(editor, ...args), + deleteForward: (...args) => deleteForward(editor, ...args), + deleteFragment: (...args) => deleteFragment(editor, ...args), + getFragment: (...args) => getFragment(editor, ...args), + insertBreak: (...args) => insertBreak(editor, ...args), + insertSoftBreak: (...args) => insertSoftBreak(editor, ...args), + insertFragment: (...args) => insertFragment(editor, ...args), + insertNode: (...args) => insertNode(editor, ...args), + insertText: (...args) => insertText(editor, ...args), + normalizeNode: (...args) => normalizeNode(editor, ...args), + removeMark: (...args) => removeMark(editor, ...args), + getDirtyPaths: (...args) => getDirtyPaths(editor, ...args), + shouldNormalize: (...args) => shouldNormalize(editor, ...args), - for (const ref of Editor.rangeRefs(editor)) { - RangeRef.transform(ref, op) - } - - const oldDirtyPaths = DIRTY_PATHS.get(editor) || [] - const oldDirtyPathKeys = DIRTY_PATH_KEYS.get(editor) || new Set() - let dirtyPaths: Path[] - let dirtyPathKeys: Set - - const add = (path: Path | null) => { - if (path) { - const key = path.join(',') - - if (!dirtyPathKeys.has(key)) { - dirtyPathKeys.add(key) - dirtyPaths.push(path) - } - } - } - - if (Path.operationCanTransformPath(op)) { - dirtyPaths = [] - dirtyPathKeys = new Set() - for (const path of oldDirtyPaths) { - const newPath = Path.transform(path, op) - add(newPath) - } - } else { - dirtyPaths = oldDirtyPaths - dirtyPathKeys = oldDirtyPathKeys - } - - const newDirtyPaths = editor.getDirtyPaths(op) - for (const path of newDirtyPaths) { - add(path) - } - - DIRTY_PATHS.set(editor, dirtyPaths) - DIRTY_PATH_KEYS.set(editor, dirtyPathKeys) - Transforms.transform(editor, op) - editor.operations.push(op) - Editor.normalize(editor, { - operation: op, - }) - - // Clear any formats applied to the cursor if the selection changes. - if (op.type === 'set_selection') { - editor.marks = null - } - - if (!FLUSHING.get(editor)) { - FLUSHING.set(editor, true) - - Promise.resolve().then(() => { - FLUSHING.set(editor, false) - editor.onChange({ operation: op }) - editor.operations = [] - }) - } - }, - - addMark: (key: string, value: any) => { - const { selection, markableVoid } = editor - - if (selection) { - const match = (node: Node, path: Path) => { - if (!Text.isText(node)) { - return false // marks can only be applied to text - } - const [parentNode, parentPath] = Editor.parent(editor, path) - return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) - } - const expandedSelection = Range.isExpanded(selection) - let markAcceptingVoidSelected = false - if (!expandedSelection) { - const [selectedNode, selectedPath] = Editor.node(editor, selection) - if (selectedNode && match(selectedNode, selectedPath)) { - const [parentNode] = Editor.parent(editor, selectedPath) - markAcceptingVoidSelected = - parentNode && editor.markableVoid(parentNode) - } - } - if (expandedSelection || markAcceptingVoidSelected) { - Transforms.setNodes( - editor, - { [key]: value }, - { - match, - split: true, - voids: true, - } - ) - } else { - const marks = { - ...(Editor.marks(editor) || {}), - [key]: value, - } - - editor.marks = marks - if (!FLUSHING.get(editor)) { - editor.onChange() - } - } - } - }, - - deleteBackward: (unit: TextUnit) => { - const { selection } = editor - - if (selection && Range.isCollapsed(selection)) { - Transforms.delete(editor, { unit, reverse: true }) - } - }, - - deleteForward: (unit: TextUnit) => { - const { selection } = editor - - if (selection && Range.isCollapsed(selection)) { - Transforms.delete(editor, { unit }) - } - }, - - deleteFragment: (direction?: 'forward' | 'backward') => { - const { selection } = editor - - if (selection && Range.isExpanded(selection)) { - Transforms.delete(editor, { reverse: direction === 'backward' }) - } - }, - - getFragment: () => { - const { selection } = editor - - if (selection) { - return Node.fragment(editor, selection) - } - return [] - }, - - insertBreak: () => { - Transforms.splitNodes(editor, { always: true }) - }, - - insertSoftBreak: () => { - Transforms.splitNodes(editor, { always: true }) - }, - - insertFragment: (fragment: Node[]) => { - Transforms.insertFragment(editor, fragment) - }, - - insertNode: (node: Node) => { - Transforms.insertNodes(editor, node) - }, - - insertText: (text: string) => { - const { selection, marks } = editor - - if (selection) { - if (marks) { - const node = { text, ...marks } - Transforms.insertNodes(editor, node) - } else { - Transforms.insertText(editor, text) - } - - editor.marks = null - } - }, - - normalizeNode: entry => { - const [node, path] = entry - - // There are no core normalizations for text nodes. - if (Text.isText(node)) { - return - } - - // Ensure that block and inline nodes have at least one text child. - if (Element.isElement(node) && node.children.length === 0) { - const child = { text: '' } - Transforms.insertNodes(editor, child, { - at: path.concat(0), - voids: true, - }) - return - } - - // Determine whether the node should have block or inline children. - const shouldHaveInlines = Editor.isEditor(node) - ? false - : Element.isElement(node) && - (editor.isInline(node) || - node.children.length === 0 || - Text.isText(node.children[0]) || - editor.isInline(node.children[0])) - - // Since we'll be applying operations while iterating, keep track of an - // index that accounts for any added/removed nodes. - let n = 0 - - for (let i = 0; i < node.children.length; i++, n++) { - const currentNode = Node.get(editor, path) - if (Text.isText(currentNode)) continue - const child = currentNode.children[n] as Descendant - const prev = currentNode.children[n - 1] as Descendant - const isLast = i === node.children.length - 1 - const isInlineOrText = - Text.isText(child) || - (Element.isElement(child) && editor.isInline(child)) - - // Only allow block nodes in the top-level children and parent blocks - // that only contain block nodes. Similarly, only allow inline nodes in - // other inline nodes, or parent blocks that only contain inlines and - // text. - if (isInlineOrText !== shouldHaveInlines) { - Transforms.removeNodes(editor, { at: path.concat(n), voids: true }) - n-- - } else if (Element.isElement(child)) { - // Ensure that inline nodes are surrounded by text nodes. - if (editor.isInline(child)) { - if (prev == null || !Text.isText(prev)) { - const newChild = { text: '' } - Transforms.insertNodes(editor, newChild, { - at: path.concat(n), - voids: true, - }) - n++ - } else if (isLast) { - const newChild = { text: '' } - Transforms.insertNodes(editor, newChild, { - at: path.concat(n + 1), - voids: true, - }) - n++ - } - } - } else { - // Merge adjacent text nodes that are empty or match. - if (prev != null && Text.isText(prev)) { - if (Text.equals(child, prev, { loose: true })) { - Transforms.mergeNodes(editor, { at: path.concat(n), voids: true }) - n-- - } else if (prev.text === '') { - Transforms.removeNodes(editor, { - at: path.concat(n - 1), - voids: true, - }) - n-- - } else if (child.text === '') { - Transforms.removeNodes(editor, { - at: path.concat(n), - voids: true, - }) - n-- - } - } - } - } - }, - - removeMark: (key: string) => { - const { selection } = editor - - if (selection) { - const match = (node: Node, path: Path) => { - if (!Text.isText(node)) { - return false // marks can only be applied to text - } - const [parentNode, parentPath] = Editor.parent(editor, path) - return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) - } - const expandedSelection = Range.isExpanded(selection) - let markAcceptingVoidSelected = false - if (!expandedSelection) { - const [selectedNode, selectedPath] = Editor.node(editor, selection) - if (selectedNode && match(selectedNode, selectedPath)) { - const [parentNode] = Editor.parent(editor, selectedPath) - markAcceptingVoidSelected = - parentNode && editor.markableVoid(parentNode) - } - } - if (expandedSelection || markAcceptingVoidSelected) { - Transforms.unsetNodes(editor, key, { - match, - split: true, - voids: true, - }) - } else { - const marks = { ...(Editor.marks(editor) || {}) } - delete marks[key] - editor.marks = marks - if (!FLUSHING.get(editor)) { - editor.onChange() - } - } - } - }, - - /** - * Get the "dirty" paths generated from an operation. - */ - - getDirtyPaths: (op: Operation): Path[] => { - switch (op.type) { - case 'insert_text': - case 'remove_text': - case 'set_node': { - const { path } = op - return Path.levels(path) - } - - case 'insert_node': { - const { node, path } = op - const levels = Path.levels(path) - const descendants = Text.isText(node) - ? [] - : Array.from(Node.nodes(node), ([, p]) => path.concat(p)) - - return [...levels, ...descendants] - } - - case 'merge_node': { - const { path } = op - const ancestors = Path.ancestors(path) - const previousPath = Path.previous(path) - return [...ancestors, previousPath] - } - - case 'move_node': { - const { path, newPath } = op - - if (Path.equals(path, newPath)) { - return [] - } - - const oldAncestors: Path[] = [] - const newAncestors: Path[] = [] - - for (const ancestor of Path.ancestors(path)) { - const p = Path.transform(ancestor, op) - oldAncestors.push(p!) - } - - for (const ancestor of Path.ancestors(newPath)) { - const p = Path.transform(ancestor, op) - newAncestors.push(p!) - } - - const newParent = newAncestors[newAncestors.length - 1] - const newIndex = newPath[newPath.length - 1] - const resultPath = newParent.concat(newIndex) - - return [...oldAncestors, ...newAncestors, resultPath] - } - - case 'remove_node': { - const { path } = op - const ancestors = Path.ancestors(path) - return [...ancestors] - } - - case 'split_node': { - const { path } = op - const levels = Path.levels(path) - const nextPath = Path.next(path) - return [...levels, nextPath] - } - - default: { - return [] - } - } - }, - - shouldNormalize: ({ iteration, initialDirtyPathsLength }) => { - const maxIterations = initialDirtyPathsLength * 42 // HACK: better way? - - if (iteration > maxIterations) { - throw new Error( - `Could not completely normalize the editor after ${maxIterations} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.` - ) - } - - return true - }, + // Editor interface + above: (...args) => above(editor, ...args), + after: (...args) => after(editor, ...args), + before: (...args) => before(editor, ...args), + collapse: (...args) => collapse(editor, ...args), + delete: (...args) => deleteText(editor, ...args), + deselect: (...args) => deselect(editor, ...args), + edges: (...args) => edges(editor, ...args), + elementReadOnly: (...args) => elementReadOnly(editor, ...args), + end: (...args) => end(editor, ...args), + first: (...args) => first(editor, ...args), + fragment: (...args) => fragment(editor, ...args), + getMarks: (...args) => marks(editor, ...args), + hasBlocks: (...args) => hasBlocks(editor, ...args), + hasInlines: (...args) => hasInlines(editor, ...args), + hasPath: (...args) => hasPath(editor, ...args), + hasTexts: (...args) => hasTexts(editor, ...args), + insertNodes: (...args) => insertNodes(editor, ...args), + isBlock: (...args) => isBlock(editor, ...args), + isEdge: (...args) => isEdge(editor, ...args), + isEmpty: (...args) => isEmpty(editor, ...args), + isEnd: (...args) => isEnd(editor, ...args), + isNormalizing: (...args) => isNormalizing(editor, ...args), + isStart: (...args) => isStart(editor, ...args), + last: (...args) => last(editor, ...args), + leaf: (...args) => leaf(editor, ...args), + levels: (...args) => levels(editor, ...args), + liftNodes: (...args) => liftNodes(editor, ...args), + mergeNodes: (...args) => mergeNodes(editor, ...args), + move: (...args) => move(editor, ...args), + moveNodes: (...args) => moveNodes(editor, ...args), + next: (...args) => next(editor, ...args), + node: (...args) => node(editor, ...args), + nodes: (...args) => nodes(editor, ...args), + normalize: (...args) => normalize(editor, ...args), + parent: (...args) => parent(editor, ...args), + path: (...args) => path(editor, ...args), + pathRef: (...args) => pathRef(editor, ...args), + pathRefs: (...args) => pathRefs(editor, ...args), + point: (...args) => point(editor, ...args), + pointRef: (...args) => pointRef(editor, ...args), + pointRefs: (...args) => pointRefs(editor, ...args), + positions: (...args) => positions(editor, ...args), + previous: (...args) => previous(editor, ...args), + range: (...args) => range(editor, ...args), + rangeRef: (...args) => rangeRef(editor, ...args), + rangeRefs: (...args) => rangeRefs(editor, ...args), + removeNodes: (...args) => removeNodes(editor, ...args), + select: (...args) => select(editor, ...args), + setNodes: (...args) => setNodes(editor, ...args), + setNormalizing: (...args) => setNormalizing(editor, ...args), + setPoint: (...args) => setPoint(editor, ...args), + setSelection: (...args) => setSelection(editor, ...args), + splitNodes: (...args) => splitNodes(editor, ...args), + start: (...args) => start(editor, ...args), + string: (...args) => string(editor, ...args), + unhangRange: (...args) => unhangRange(editor, ...args), + unsetNodes: (...args) => unsetNodes(editor, ...args), + unwrapNodes: (...args) => unwrapNodes(editor, ...args), + void: (...args) => getVoid(editor, ...args), + withoutNormalizing: (...args) => withoutNormalizing(editor, ...args), + wrapNodes: (...args) => wrapNodes(editor, ...args), } return editor diff --git a/packages/slate/src/editor/above.ts b/packages/slate/src/editor/above.ts new file mode 100644 index 000000000..e616c1caa --- /dev/null +++ b/packages/slate/src/editor/above.ts @@ -0,0 +1,41 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Text } from '../interfaces/text' +import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' + +export const above: EditorInterface['above'] = (editor, options = {}) => { + const { + voids = false, + mode = 'lowest', + at = editor.selection, + match, + } = options + + if (!at) { + return + } + + const path = Editor.path(editor, at) + const reverse = mode === 'lowest' + + for (const [n, p] of Editor.levels(editor, { + at: path, + voids, + match, + reverse, + })) { + if (Text.isText(n)) continue + if (Range.isRange(at)) { + if ( + Path.isAncestor(p, at.anchor.path) && + Path.isAncestor(p, at.focus.path) + ) { + return [n, p] + } + } else { + if (!Path.equals(path, p)) { + return [n, p] + } + } + } +} diff --git a/packages/slate/src/editor/add-mark.ts b/packages/slate/src/editor/add-mark.ts new file mode 100644 index 000000000..3f96814a0 --- /dev/null +++ b/packages/slate/src/editor/add-mark.ts @@ -0,0 +1,52 @@ +import { Node } from '../interfaces/node' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { FLUSHING } from '../utils/weak-maps' +import { Editor, EditorInterface } from '../interfaces/editor' + +export const addMark: EditorInterface['addMark'] = (editor, key, value) => { + const { selection } = editor + + if (selection) { + const match = (node: Node, path: Path) => { + if (!Text.isText(node)) { + return false // marks can only be applied to text + } + const [parentNode, parentPath] = Editor.parent(editor, path) + return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) + } + const expandedSelection = Range.isExpanded(selection) + let markAcceptingVoidSelected = false + if (!expandedSelection) { + const [selectedNode, selectedPath] = Editor.node(editor, selection) + if (selectedNode && match(selectedNode, selectedPath)) { + const [parentNode] = Editor.parent(editor, selectedPath) + markAcceptingVoidSelected = + parentNode && editor.markableVoid(parentNode) + } + } + if (expandedSelection || markAcceptingVoidSelected) { + Transforms.setNodes( + editor, + { [key]: value }, + { + match, + split: true, + voids: true, + } + ) + } else { + const marks = { + ...(Editor.marks(editor) || {}), + [key]: value, + } + + editor.marks = marks + if (!FLUSHING.get(editor)) { + editor.onChange() + } + } + } +} diff --git a/packages/slate/src/editor/after.ts b/packages/slate/src/editor/after.ts new file mode 100644 index 000000000..85ed6413e --- /dev/null +++ b/packages/slate/src/editor/after.ts @@ -0,0 +1,27 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const after: EditorInterface['after'] = (editor, at, options = {}) => { + const anchor = Editor.point(editor, at, { edge: 'end' }) + const focus = Editor.end(editor, []) + const range = { anchor, focus } + const { distance = 1 } = options + let d = 0 + let target + + for (const p of Editor.positions(editor, { + ...options, + at: range, + })) { + if (d > distance) { + break + } + + if (d !== 0) { + target = p + } + + d++ + } + + return target +} diff --git a/packages/slate/src/editor/before.ts b/packages/slate/src/editor/before.ts new file mode 100644 index 000000000..6afdc407c --- /dev/null +++ b/packages/slate/src/editor/before.ts @@ -0,0 +1,28 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const before: EditorInterface['before'] = (editor, at, options = {}) => { + const anchor = Editor.start(editor, []) + const focus = Editor.point(editor, at, { edge: 'start' }) + const range = { anchor, focus } + const { distance = 1 } = options + let d = 0 + let target + + for (const p of Editor.positions(editor, { + ...options, + at: range, + reverse: true, + })) { + if (d > distance) { + break + } + + if (d !== 0) { + target = p + } + + d++ + } + + return target +} diff --git a/packages/slate/src/editor/delete-backward.ts b/packages/slate/src/editor/delete-backward.ts new file mode 100644 index 000000000..21ba30185 --- /dev/null +++ b/packages/slate/src/editor/delete-backward.ts @@ -0,0 +1,15 @@ +import { Editor } from '../interfaces/editor' +import { Transforms } from '../interfaces/transforms' +import { Range } from '../interfaces/range' +import { WithEditorFirstArg } from '../utils/types' + +export const deleteBackward: WithEditorFirstArg = ( + editor, + unit +) => { + const { selection } = editor + + if (selection && Range.isCollapsed(selection)) { + Transforms.delete(editor, { unit, reverse: true }) + } +} diff --git a/packages/slate/src/editor/delete-forward.ts b/packages/slate/src/editor/delete-forward.ts new file mode 100644 index 000000000..1a9013c9b --- /dev/null +++ b/packages/slate/src/editor/delete-forward.ts @@ -0,0 +1,15 @@ +import { Editor } from '../interfaces/editor' +import { Transforms } from '../interfaces/transforms' +import { Range } from '../interfaces/range' +import { WithEditorFirstArg } from '../utils/types' + +export const deleteForward: WithEditorFirstArg = ( + editor, + unit +) => { + const { selection } = editor + + if (selection && Range.isCollapsed(selection)) { + Transforms.delete(editor, { unit }) + } +} diff --git a/packages/slate/src/editor/delete-fragment.ts b/packages/slate/src/editor/delete-fragment.ts new file mode 100644 index 000000000..36ad9b27b --- /dev/null +++ b/packages/slate/src/editor/delete-fragment.ts @@ -0,0 +1,14 @@ +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { EditorInterface } from '../interfaces/editor' + +export const deleteFragment: EditorInterface['deleteFragment'] = ( + editor, + { direction = 'forward' } = {} +) => { + const { selection } = editor + + if (selection && Range.isExpanded(selection)) { + Transforms.delete(editor, { reverse: direction === 'backward' }) + } +} diff --git a/packages/slate/src/editor/edges.ts b/packages/slate/src/editor/edges.ts new file mode 100644 index 000000000..fa0b5ee3a --- /dev/null +++ b/packages/slate/src/editor/edges.ts @@ -0,0 +1,5 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const edges: EditorInterface['edges'] = (editor, at) => { + return [Editor.start(editor, at), Editor.end(editor, at)] +} diff --git a/packages/slate/src/editor/element-read-only.ts b/packages/slate/src/editor/element-read-only.ts new file mode 100644 index 000000000..9a7d7f79d --- /dev/null +++ b/packages/slate/src/editor/element-read-only.ts @@ -0,0 +1,12 @@ +import { Element } from '../interfaces/element' +import { Editor, EditorInterface } from '../interfaces/editor' + +export const elementReadOnly: EditorInterface['elementReadOnly'] = ( + editor, + options = {} +) => { + return Editor.above(editor, { + ...options, + match: n => Element.isElement(n) && Editor.isElementReadOnly(editor, n), + }) +} diff --git a/packages/slate/src/editor/end.ts b/packages/slate/src/editor/end.ts new file mode 100644 index 000000000..9f84a9ed1 --- /dev/null +++ b/packages/slate/src/editor/end.ts @@ -0,0 +1,5 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const end: EditorInterface['end'] = (editor, at) => { + return Editor.point(editor, at, { edge: 'end' }) +} diff --git a/packages/slate/src/editor/first.ts b/packages/slate/src/editor/first.ts new file mode 100644 index 000000000..952db2aca --- /dev/null +++ b/packages/slate/src/editor/first.ts @@ -0,0 +1,6 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const first: EditorInterface['first'] = (editor, at) => { + const path = Editor.path(editor, at, { edge: 'start' }) + return Editor.node(editor, path) +} diff --git a/packages/slate/src/editor/fragment.ts b/packages/slate/src/editor/fragment.ts new file mode 100644 index 000000000..a488ddf99 --- /dev/null +++ b/packages/slate/src/editor/fragment.ts @@ -0,0 +1,7 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' + +export const fragment: EditorInterface['fragment'] = (editor, at) => { + const range = Editor.range(editor, at) + return Node.fragment(editor, range) +} diff --git a/packages/slate/src/editor/get-void.ts b/packages/slate/src/editor/get-void.ts new file mode 100644 index 000000000..666e6daea --- /dev/null +++ b/packages/slate/src/editor/get-void.ts @@ -0,0 +1,9 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Element } from '../interfaces/element' + +export const getVoid: EditorInterface['void'] = (editor, options = {}) => { + return Editor.above(editor, { + ...options, + match: n => Element.isElement(n) && Editor.isVoid(editor, n), + }) +} diff --git a/packages/slate/src/editor/has-blocks.ts b/packages/slate/src/editor/has-blocks.ts new file mode 100644 index 000000000..922657ec8 --- /dev/null +++ b/packages/slate/src/editor/has-blocks.ts @@ -0,0 +1,8 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Element } from '../interfaces/element' + +export const hasBlocks: EditorInterface['hasBlocks'] = (editor, element) => { + return element.children.some( + n => Element.isElement(n) && Editor.isBlock(editor, n) + ) +} diff --git a/packages/slate/src/editor/has-inlines.ts b/packages/slate/src/editor/has-inlines.ts new file mode 100644 index 000000000..4dcb3d6cc --- /dev/null +++ b/packages/slate/src/editor/has-inlines.ts @@ -0,0 +1,8 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Text } from '../interfaces/text' + +export const hasInlines: EditorInterface['hasInlines'] = (editor, element) => { + return element.children.some( + n => Text.isText(n) || Editor.isInline(editor, n) + ) +} diff --git a/packages/slate/src/editor/has-path.ts b/packages/slate/src/editor/has-path.ts new file mode 100644 index 000000000..c291ee0e4 --- /dev/null +++ b/packages/slate/src/editor/has-path.ts @@ -0,0 +1,6 @@ +import { EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' + +export const hasPath: EditorInterface['hasPath'] = (editor, path) => { + return Node.has(editor, path) +} diff --git a/packages/slate/src/editor/has-texts.ts b/packages/slate/src/editor/has-texts.ts new file mode 100644 index 000000000..38a7a0606 --- /dev/null +++ b/packages/slate/src/editor/has-texts.ts @@ -0,0 +1,6 @@ +import { EditorInterface } from '../interfaces/editor' +import { Text } from '../interfaces/text' + +export const hasTexts: EditorInterface['hasTexts'] = (editor, element) => { + return element.children.every(n => Text.isText(n)) +} diff --git a/packages/slate/src/editor/index.ts b/packages/slate/src/editor/index.ts new file mode 100644 index 000000000..a946e316e --- /dev/null +++ b/packages/slate/src/editor/index.ts @@ -0,0 +1,54 @@ +export * from './above' +export * from './add-mark' +export * from './after' +export * from './before' +export * from './delete-backward' +export * from './delete-forward' +export * from './delete-fragment' +export * from './edges' +export * from './element-read-only' +export * from './end' +export * from './first' +export * from './fragment' +export * from './get-void' +export * from './has-blocks' +export * from './has-inlines' +export * from './has-path' +export * from './has-texts' +export * from './insert-break' +export * from './insert-node' +export * from './insert-soft-break' +export * from './insert-text' +export * from './is-block' +export * from './is-edge' +export * from './is-editor' +export * from './is-empty' +export * from './is-end' +export * from './is-normalizing' +export * from './is-start' +export * from './last' +export * from './leaf' +export * from './levels' +export * from './marks' +export * from './next' +export * from './node' +export * from './nodes' +export * from './normalize' +export * from './parent' +export * from './path-ref' +export * from './path-refs' +export * from './path' +export * from './point-ref' +export * from './point-refs' +export * from './point' +export * from './positions' +export * from './previous' +export * from './range-ref' +export * from './range-refs' +export * from './range' +export * from './remove-mark' +export * from './set-normalizing' +export * from './start' +export * from './string' +export * from './unhang-range' +export * from './without-normalizing' diff --git a/packages/slate/src/editor/insert-break.ts b/packages/slate/src/editor/insert-break.ts new file mode 100644 index 000000000..01c918df5 --- /dev/null +++ b/packages/slate/src/editor/insert-break.ts @@ -0,0 +1,6 @@ +import { Transforms } from '../interfaces/transforms' +import { EditorInterface } from '../interfaces/editor' + +export const insertBreak: EditorInterface['insertBreak'] = editor => { + Transforms.splitNodes(editor, { always: true }) +} diff --git a/packages/slate/src/editor/insert-node.ts b/packages/slate/src/editor/insert-node.ts new file mode 100644 index 000000000..de1636fda --- /dev/null +++ b/packages/slate/src/editor/insert-node.ts @@ -0,0 +1,6 @@ +import { Transforms } from '../interfaces/transforms' +import { EditorInterface } from '../interfaces/editor' + +export const insertNode: EditorInterface['insertNode'] = (editor, node) => { + Transforms.insertNodes(editor, node) +} diff --git a/packages/slate/src/editor/insert-soft-break.ts b/packages/slate/src/editor/insert-soft-break.ts new file mode 100644 index 000000000..e77c4931e --- /dev/null +++ b/packages/slate/src/editor/insert-soft-break.ts @@ -0,0 +1,6 @@ +import { Transforms } from '../interfaces/transforms' +import { EditorInterface } from '../interfaces/editor' + +export const insertSoftBreak: EditorInterface['insertSoftBreak'] = editor => { + Transforms.splitNodes(editor, { always: true }) +} diff --git a/packages/slate/src/editor/insert-text.ts b/packages/slate/src/editor/insert-text.ts new file mode 100644 index 000000000..b0833e6c1 --- /dev/null +++ b/packages/slate/src/editor/insert-text.ts @@ -0,0 +1,21 @@ +import { Transforms } from '../interfaces/transforms' +import { EditorInterface } from '../interfaces/editor' + +export const insertText: EditorInterface['insertText'] = ( + editor, + text, + options = {} +) => { + const { selection, marks } = editor + + if (selection) { + if (marks) { + const node = { text, ...marks } + Transforms.insertNodes(editor, node) + } else { + Transforms.insertText(editor, text, options) + } + + editor.marks = null + } +} diff --git a/packages/slate/src/editor/is-block.ts b/packages/slate/src/editor/is-block.ts new file mode 100644 index 000000000..1fb67c89f --- /dev/null +++ b/packages/slate/src/editor/is-block.ts @@ -0,0 +1,5 @@ +import { EditorInterface } from '../interfaces/editor' + +export const isBlock: EditorInterface['isBlock'] = (editor, value) => { + return !editor.isInline(value) +} diff --git a/packages/slate/src/editor/is-edge.ts b/packages/slate/src/editor/is-edge.ts new file mode 100644 index 000000000..ae96c8ce7 --- /dev/null +++ b/packages/slate/src/editor/is-edge.ts @@ -0,0 +1,5 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const isEdge: EditorInterface['isEdge'] = (editor, point, at) => { + return Editor.isStart(editor, point, at) || Editor.isEnd(editor, point, at) +} diff --git a/packages/slate/src/editor/is-editor.ts b/packages/slate/src/editor/is-editor.ts new file mode 100644 index 000000000..8196bcc35 --- /dev/null +++ b/packages/slate/src/editor/is-editor.ts @@ -0,0 +1,44 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { isPlainObject } from 'is-plain-object' +import { Range } from '../interfaces/range' +import { Node } from '../interfaces/node' +import { Operation } from '../interfaces/operation' + +const IS_EDITOR_CACHE = new WeakMap() + +export const isEditor: EditorInterface['isEditor'] = ( + value: any +): value is Editor => { + const cachedIsEditor = IS_EDITOR_CACHE.get(value) + if (cachedIsEditor !== undefined) { + return cachedIsEditor + } + + if (!isPlainObject(value)) { + return false + } + + const isEditor = + typeof value.addMark === 'function' && + typeof value.apply === 'function' && + typeof value.deleteFragment === 'function' && + typeof value.insertBreak === 'function' && + typeof value.insertSoftBreak === 'function' && + typeof value.insertFragment === 'function' && + typeof value.insertNode === 'function' && + typeof value.insertText === 'function' && + typeof value.isElementReadOnly === 'function' && + typeof value.isInline === 'function' && + typeof value.isSelectable === 'function' && + typeof value.isVoid === 'function' && + typeof value.normalizeNode === 'function' && + typeof value.onChange === 'function' && + typeof value.removeMark === 'function' && + typeof value.getDirtyPaths === 'function' && + (value.marks === null || isPlainObject(value.marks)) && + (value.selection === null || Range.isRange(value.selection)) && + Node.isNodeList(value.children) && + Operation.isOperationList(value.operations) + IS_EDITOR_CACHE.set(value, isEditor) + return isEditor +} diff --git a/packages/slate/src/editor/is-empty.ts b/packages/slate/src/editor/is-empty.ts new file mode 100644 index 000000000..99f66afb1 --- /dev/null +++ b/packages/slate/src/editor/is-empty.ts @@ -0,0 +1,14 @@ +import { EditorInterface } from '../interfaces/editor' +import { Text } from '../interfaces/text' + +export const isEmpty: EditorInterface['isEmpty'] = (editor, element) => { + const { children } = element + const [first] = children + return ( + children.length === 0 || + (children.length === 1 && + Text.isText(first) && + first.text === '' && + !editor.isVoid(element)) + ) +} diff --git a/packages/slate/src/editor/is-end.ts b/packages/slate/src/editor/is-end.ts new file mode 100644 index 000000000..427ac096e --- /dev/null +++ b/packages/slate/src/editor/is-end.ts @@ -0,0 +1,7 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Point } from '../interfaces/point' + +export const isEnd: EditorInterface['isEnd'] = (editor, point, at) => { + const end = Editor.end(editor, at) + return Point.equals(point, end) +} diff --git a/packages/slate/src/editor/is-normalizing.ts b/packages/slate/src/editor/is-normalizing.ts new file mode 100644 index 000000000..bfff586af --- /dev/null +++ b/packages/slate/src/editor/is-normalizing.ts @@ -0,0 +1,7 @@ +import { EditorInterface } from '../interfaces/editor' +import { NORMALIZING } from '../utils/weak-maps' + +export const isNormalizing: EditorInterface['isNormalizing'] = editor => { + const isNormalizing = NORMALIZING.get(editor) + return isNormalizing === undefined ? true : isNormalizing +} diff --git a/packages/slate/src/editor/is-start.ts b/packages/slate/src/editor/is-start.ts new file mode 100644 index 000000000..4d3e23d2d --- /dev/null +++ b/packages/slate/src/editor/is-start.ts @@ -0,0 +1,12 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Point } from '../interfaces/point' + +export const isStart: EditorInterface['isStart'] = (editor, point, at) => { + // PERF: If the offset isn't `0` we know it's not the start. + if (point.offset !== 0) { + return false + } + + const start = Editor.start(editor, at) + return Point.equals(point, start) +} diff --git a/packages/slate/src/editor/last.ts b/packages/slate/src/editor/last.ts new file mode 100644 index 000000000..399b0713f --- /dev/null +++ b/packages/slate/src/editor/last.ts @@ -0,0 +1,6 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const last: EditorInterface['last'] = (editor, at) => { + const path = Editor.path(editor, at, { edge: 'end' }) + return Editor.node(editor, path) +} diff --git a/packages/slate/src/editor/leaf.ts b/packages/slate/src/editor/leaf.ts new file mode 100644 index 000000000..dbddf4a4f --- /dev/null +++ b/packages/slate/src/editor/leaf.ts @@ -0,0 +1,8 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' + +export const leaf: EditorInterface['leaf'] = (editor, at, options = {}) => { + const path = Editor.path(editor, at, options) + const node = Node.leaf(editor, path) + return [node, path] +} diff --git a/packages/slate/src/editor/levels.ts b/packages/slate/src/editor/levels.ts new file mode 100644 index 000000000..5472be4ea --- /dev/null +++ b/packages/slate/src/editor/levels.ts @@ -0,0 +1,40 @@ +import { Node, NodeEntry } from '../interfaces/node' +import { Editor, EditorLevelsOptions } from '../interfaces/editor' +import { Element } from '../interfaces/element' + +export function* levels( + editor: Editor, + options: EditorLevelsOptions = {} +): Generator, void, undefined> { + const { at = editor.selection, reverse = false, voids = false } = options + let { match } = options + + if (match == null) { + match = () => true + } + + if (!at) { + return + } + + const levels: NodeEntry[] = [] + const path = Editor.path(editor, at) + + for (const [n, p] of Node.levels(editor, path)) { + if (!match(n, p)) { + continue + } + + levels.push([n, p]) + + if (!voids && Element.isElement(n) && Editor.isVoid(editor, n)) { + break + } + } + + if (reverse) { + levels.reverse() + } + + yield* levels +} diff --git a/packages/slate/src/editor/marks.ts b/packages/slate/src/editor/marks.ts new file mode 100644 index 000000000..c995c940d --- /dev/null +++ b/packages/slate/src/editor/marks.ts @@ -0,0 +1,61 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { NodeEntry } from '../interfaces/node' +import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' +import { Element } from '../interfaces/element' + +export const marks: EditorInterface['marks'] = (editor, options = {}) => { + const { marks, selection } = editor + + if (!selection) { + return null + } + + if (marks) { + return marks + } + + if (Range.isExpanded(selection)) { + const [match] = Editor.nodes(editor, { match: Text.isText }) + + if (match) { + const [node] = match as NodeEntry + const { text, ...rest } = node + return rest + } else { + return {} + } + } + + const { anchor } = selection + const { path } = anchor + let [node] = Editor.leaf(editor, path) + + if (anchor.offset === 0) { + const prev = Editor.previous(editor, { at: path, match: Text.isText }) + const markedVoid = Editor.above(editor, { + match: n => + Element.isElement(n) && + Editor.isVoid(editor, n) && + editor.markableVoid(n), + }) + if (!markedVoid) { + const block = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + }) + + if (prev && block) { + const [prevNode, prevPath] = prev + const [, blockPath] = block + + if (Path.isAncestor(blockPath, prevPath)) { + node = prevNode as Text + } + } + } + } + + const { text, ...rest } = node + return rest +} diff --git a/packages/slate/src/editor/next.ts b/packages/slate/src/editor/next.ts new file mode 100644 index 000000000..a378bba5f --- /dev/null +++ b/packages/slate/src/editor/next.ts @@ -0,0 +1,36 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Span } from '../interfaces/location' +import { Path } from '../interfaces/path' + +export const next: EditorInterface['next'] = (editor, options = {}) => { + const { mode = 'lowest', voids = false } = options + let { match, at = editor.selection } = options + + if (!at) { + return + } + + const pointAfterLocation = Editor.after(editor, at, { voids }) + + if (!pointAfterLocation) return + + const [, to] = Editor.last(editor, []) + + const span: Span = [pointAfterLocation.path, to] + + if (Path.isPath(at) && at.length === 0) { + throw new Error(`Cannot get the next node from the root node!`) + } + + if (match == null) { + if (Path.isPath(at)) { + const [parent] = Editor.parent(editor, at) + match = n => parent.children.includes(n) + } else { + match = () => true + } + } + + const [next] = Editor.nodes(editor, { at: span, match, mode, voids }) + return next +} diff --git a/packages/slate/src/editor/node.ts b/packages/slate/src/editor/node.ts new file mode 100644 index 000000000..61dc1731c --- /dev/null +++ b/packages/slate/src/editor/node.ts @@ -0,0 +1,8 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' + +export const node: EditorInterface['node'] = (editor, at, options = {}) => { + const path = Editor.path(editor, at, options) + const node = Node.get(editor, path) + return [node, path] +} diff --git a/packages/slate/src/editor/nodes.ts b/packages/slate/src/editor/nodes.ts new file mode 100644 index 000000000..2476d0a21 --- /dev/null +++ b/packages/slate/src/editor/nodes.ts @@ -0,0 +1,124 @@ +import { Node, NodeEntry } from '../interfaces/node' +import { Editor, EditorNodesOptions } from '../interfaces/editor' +import { Span } from '../interfaces/location' +import { Element } from '../interfaces/element' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' + +export function* nodes( + editor: Editor, + options: EditorNodesOptions = {} +): Generator, void, undefined> { + const { + at = editor.selection, + mode = 'all', + universal = false, + reverse = false, + voids = false, + ignoreNonSelectable = false, + } = options + let { match } = options + + if (!match) { + match = () => true + } + + if (!at) { + return + } + + let from + let to + + if (Span.isSpan(at)) { + from = at[0] + to = at[1] + } else { + const first = Editor.path(editor, at, { edge: 'start' }) + const last = Editor.path(editor, at, { edge: 'end' }) + from = reverse ? last : first + to = reverse ? first : last + } + + const nodeEntries = Node.nodes(editor, { + reverse, + from, + to, + pass: ([node]) => { + if (!Element.isElement(node)) return false + if ( + !voids && + (Editor.isVoid(editor, node) || Editor.isElementReadOnly(editor, node)) + ) + return true + if (ignoreNonSelectable && !Editor.isSelectable(editor, node)) return true + return false + }, + }) + + const matches: NodeEntry[] = [] + let hit: NodeEntry | undefined + + for (const [node, path] of nodeEntries) { + if ( + ignoreNonSelectable && + Element.isElement(node) && + !Editor.isSelectable(editor, node) + ) { + continue + } + + const isLower = hit && Path.compare(path, hit[1]) === 0 + + // In highest mode any node lower than the last hit is not a match. + if (mode === 'highest' && isLower) { + continue + } + + if (!match(node, path)) { + // If we've arrived at a leaf text node that is not lower than the last + // hit, then we've found a branch that doesn't include a match, which + // means the match is not universal. + if (universal && !isLower && Text.isText(node)) { + return + } else { + continue + } + } + + // If there's a match and it's lower than the last, update the hit. + if (mode === 'lowest' && isLower) { + hit = [node, path] + continue + } + + // In lowest mode we emit the last hit, once it's guaranteed lowest. + const emit: NodeEntry | undefined = + mode === 'lowest' ? hit : [node, path] + + if (emit) { + if (universal) { + matches.push(emit) + } else { + yield emit + } + } + + hit = [node, path] + } + + // Since lowest is always emitting one behind, catch up at the end. + if (mode === 'lowest' && hit) { + if (universal) { + matches.push(hit) + } else { + yield hit + } + } + + // Universal defers to ensure that the match occurs in every branch, so we + // yield all of the matches after iterating. + if (universal) { + yield* matches + } +} diff --git a/packages/slate/src/editor/normalize.ts b/packages/slate/src/editor/normalize.ts new file mode 100644 index 000000000..57f8a60b4 --- /dev/null +++ b/packages/slate/src/editor/normalize.ts @@ -0,0 +1,93 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { DIRTY_PATH_KEYS, DIRTY_PATHS } from '../utils/weak-maps' +import { Path } from '../interfaces/path' +import { Node } from '../interfaces/node' +import { Element } from '../interfaces/element' + +export const normalize: EditorInterface['normalize'] = ( + editor, + options = {} +) => { + const { force = false, operation } = options + const getDirtyPaths = (editor: Editor) => { + return DIRTY_PATHS.get(editor) || [] + } + + const getDirtyPathKeys = (editor: Editor) => { + return DIRTY_PATH_KEYS.get(editor) || new Set() + } + + const popDirtyPath = (editor: Editor): Path => { + const path = getDirtyPaths(editor).pop()! + const key = path.join(',') + getDirtyPathKeys(editor).delete(key) + return path + } + + if (!Editor.isNormalizing(editor)) { + return + } + + if (force) { + const allPaths = Array.from(Node.nodes(editor), ([, p]) => p) + const allPathKeys = new Set(allPaths.map(p => p.join(','))) + DIRTY_PATHS.set(editor, allPaths) + DIRTY_PATH_KEYS.set(editor, allPathKeys) + } + + if (getDirtyPaths(editor).length === 0) { + return + } + + Editor.withoutNormalizing(editor, () => { + /* + Fix dirty elements with no children. + editor.normalizeNode() does fix this, but some normalization fixes also require it to work. + Running an initial pass avoids the catch-22 race condition. + */ + for (const dirtyPath of getDirtyPaths(editor)) { + if (Node.has(editor, dirtyPath)) { + const entry = Editor.node(editor, dirtyPath) + const [node, _] = entry + + /* + The default normalizer inserts an empty text node in this scenario, but it can be customised. + So there is some risk here. + + As long as the normalizer only inserts child nodes for this case it is safe to do in any order; + by definition adding children to an empty node can't cause other paths to change. + */ + if (Element.isElement(node) && node.children.length === 0) { + editor.normalizeNode(entry, { operation }) + } + } + } + + let dirtyPaths = getDirtyPaths(editor) + const initialDirtyPathsLength = dirtyPaths.length + let iteration = 0 + + while (dirtyPaths.length !== 0) { + if ( + !editor.shouldNormalize({ + dirtyPaths, + iteration, + initialDirtyPathsLength, + operation, + }) + ) { + return + } + + const dirtyPath = popDirtyPath(editor) + + // If the node doesn't exist in the tree, it does not need to be normalized. + if (Node.has(editor, dirtyPath)) { + const entry = Editor.node(editor, dirtyPath) + editor.normalizeNode(entry, { operation }) + } + iteration++ + dirtyPaths = getDirtyPaths(editor) + } + }) +} diff --git a/packages/slate/src/editor/parent.ts b/packages/slate/src/editor/parent.ts new file mode 100644 index 000000000..c58c0a964 --- /dev/null +++ b/packages/slate/src/editor/parent.ts @@ -0,0 +1,10 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { Ancestor, NodeEntry } from '../interfaces/node' + +export const parent: EditorInterface['parent'] = (editor, at, options = {}) => { + const path = Editor.path(editor, at, options) + const parentPath = Path.parent(path) + const entry = Editor.node(editor, parentPath) + return entry as NodeEntry +} diff --git a/packages/slate/src/editor/path-ref.ts b/packages/slate/src/editor/path-ref.ts new file mode 100644 index 000000000..7e6f6faf4 --- /dev/null +++ b/packages/slate/src/editor/path-ref.ts @@ -0,0 +1,25 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { PathRef } from '../interfaces/path-ref' + +export const pathRef: EditorInterface['pathRef'] = ( + editor, + path, + options = {} +) => { + const { affinity = 'forward' } = options + const ref: PathRef = { + current: path, + affinity, + unref() { + const { current } = ref + const pathRefs = Editor.pathRefs(editor) + pathRefs.delete(ref) + ref.current = null + return current + }, + } + + const refs = Editor.pathRefs(editor) + refs.add(ref) + return ref +} diff --git a/packages/slate/src/editor/path-refs.ts b/packages/slate/src/editor/path-refs.ts new file mode 100644 index 000000000..a4cb820b8 --- /dev/null +++ b/packages/slate/src/editor/path-refs.ts @@ -0,0 +1,13 @@ +import { EditorInterface } from '../interfaces/editor' +import { PATH_REFS } from '../utils/weak-maps' + +export const pathRefs: EditorInterface['pathRefs'] = editor => { + let refs = PATH_REFS.get(editor) + + if (!refs) { + refs = new Set() + PATH_REFS.set(editor, refs) + } + + return refs +} diff --git a/packages/slate/src/editor/path.ts b/packages/slate/src/editor/path.ts new file mode 100644 index 000000000..2409a16d0 --- /dev/null +++ b/packages/slate/src/editor/path.ts @@ -0,0 +1,35 @@ +import { EditorInterface, Node, Path, Point, Range } from '../interfaces' + +export const path: EditorInterface['path'] = (editor, at, options = {}) => { + const { depth, edge } = options + + if (Path.isPath(at)) { + if (edge === 'start') { + const [, firstPath] = Node.first(editor, at) + at = firstPath + } else if (edge === 'end') { + const [, lastPath] = Node.last(editor, at) + at = lastPath + } + } + + if (Range.isRange(at)) { + if (edge === 'start') { + at = Range.start(at) + } else if (edge === 'end') { + at = Range.end(at) + } else { + at = Path.common(at.anchor.path, at.focus.path) + } + } + + if (Point.isPoint(at)) { + at = at.path + } + + if (depth != null) { + at = at.slice(0, depth) + } + + return at +} diff --git a/packages/slate/src/editor/point-ref.ts b/packages/slate/src/editor/point-ref.ts new file mode 100644 index 000000000..cdda25658 --- /dev/null +++ b/packages/slate/src/editor/point-ref.ts @@ -0,0 +1,25 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { PointRef } from '../interfaces/point-ref' + +export const pointRef: EditorInterface['pointRef'] = ( + editor, + point, + options = {} +) => { + const { affinity = 'forward' } = options + const ref: PointRef = { + current: point, + affinity, + unref() { + const { current } = ref + const pointRefs = Editor.pointRefs(editor) + pointRefs.delete(ref) + ref.current = null + return current + }, + } + + const refs = Editor.pointRefs(editor) + refs.add(ref) + return ref +} diff --git a/packages/slate/src/editor/point-refs.ts b/packages/slate/src/editor/point-refs.ts new file mode 100644 index 000000000..302de93a2 --- /dev/null +++ b/packages/slate/src/editor/point-refs.ts @@ -0,0 +1,13 @@ +import { EditorInterface } from '../interfaces/editor' +import { POINT_REFS } from '../utils/weak-maps' + +export const pointRefs: EditorInterface['pointRefs'] = editor => { + let refs = POINT_REFS.get(editor) + + if (!refs) { + refs = new Set() + POINT_REFS.set(editor, refs) + } + + return refs +} diff --git a/packages/slate/src/editor/point.ts b/packages/slate/src/editor/point.ts new file mode 100644 index 000000000..1ce04a30e --- /dev/null +++ b/packages/slate/src/editor/point.ts @@ -0,0 +1,38 @@ +import { EditorInterface } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { Node } from '../interfaces/node' +import { Text } from '../interfaces/text' +import { Range } from '../interfaces/range' + +export const point: EditorInterface['point'] = (editor, at, options = {}) => { + const { edge = 'start' } = options + + if (Path.isPath(at)) { + let path + + if (edge === 'end') { + const [, lastPath] = Node.last(editor, at) + path = lastPath + } else { + const [, firstPath] = Node.first(editor, at) + path = firstPath + } + + const node = Node.get(editor, path) + + if (!Text.isText(node)) { + throw new Error( + `Cannot get the ${edge} point in the node at path [${at}] because it has no ${edge} text node.` + ) + } + + return { path, offset: edge === 'end' ? node.text.length : 0 } + } + + if (Range.isRange(at)) { + const [start, end] = Range.edges(at) + return edge === 'start' ? start : end + } + + return at +} diff --git a/packages/slate/src/editor/positions.ts b/packages/slate/src/editor/positions.ts new file mode 100644 index 000000000..c65bd294e --- /dev/null +++ b/packages/slate/src/editor/positions.ts @@ -0,0 +1,190 @@ +import { Editor, EditorPositionsOptions } from '../interfaces/editor' +import { Point } from '../interfaces/point' +import { Range } from '../interfaces/range' +import { Element } from '../interfaces/element' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' +import { + getCharacterDistance, + getWordDistance, + splitByCharacterDistance, +} from '../utils/string' + +export function* positions( + editor: Editor, + options: EditorPositionsOptions = {} +): Generator { + const { + at = editor.selection, + unit = 'offset', + reverse = false, + voids = false, + ignoreNonSelectable = false, + } = options + + if (!at) { + return + } + + /** + * Algorithm notes: + * + * Each step `distance` is dynamic depending on the underlying text + * and the `unit` specified. Each step, e.g., a line or word, may + * span multiple text nodes, so we iterate through the text both on + * two levels in step-sync: + * + * `leafText` stores the text on a text leaf level, and is advanced + * through using the counters `leafTextOffset` and `leafTextRemaining`. + * + * `blockText` stores the text on a block level, and is shortened + * by `distance` every time it is advanced. + * + * We only maintain a window of one blockText and one leafText because + * a block node always appears before all of its leaf nodes. + */ + + const range = Editor.range(editor, at) + const [start, end] = Range.edges(range) + const first = reverse ? end : start + let isNewBlock = false + let blockText = '' + let distance = 0 // Distance for leafText to catch up to blockText. + let leafTextRemaining = 0 + let leafTextOffset = 0 + + // Iterate through all nodes in range, grabbing entire textual content + // of block nodes in blockText, and text nodes in leafText. + // Exploits the fact that nodes are sequenced in such a way that we first + // encounter the block node, then all of its text nodes, so when iterating + // through the blockText and leafText we just need to remember a window of + // one block node and leaf node, respectively. + for (const [node, path] of Editor.nodes(editor, { + at, + reverse, + voids, + ignoreNonSelectable, + })) { + /* + * ELEMENT NODE - Yield position(s) for voids, collect blockText for blocks + */ + if (Element.isElement(node)) { + // Void nodes are a special case, so by default we will always + // yield their first point. If the `voids` option is set to true, + // then we will iterate over their content. + if (!voids && (editor.isVoid(node) || editor.isElementReadOnly(node))) { + yield Editor.start(editor, path) + continue + } + + // Inline element nodes are ignored as they don't themselves + // contribute to `blockText` or `leafText` - their parent and + // children do. + if (editor.isInline(node)) continue + + // Block element node - set `blockText` to its text content. + if (Editor.hasInlines(editor, node)) { + // We always exhaust block nodes before encountering a new one: + // console.assert(blockText === '', + // `blockText='${blockText}' - `+ + // `not exhausted before new block node`, path) + + // Ensure range considered is capped to `range`, in the + // start/end edge cases where block extends beyond range. + // Equivalent to this, but presumably more performant: + // blockRange = Editor.range(editor, ...Editor.edges(editor, path)) + // blockRange = Range.intersection(range, blockRange) // intersect + // blockText = Editor.string(editor, blockRange, { voids }) + const e = Path.isAncestor(path, end.path) + ? end + : Editor.end(editor, path) + const s = Path.isAncestor(path, start.path) + ? start + : Editor.start(editor, path) + + blockText = Editor.string(editor, { anchor: s, focus: e }, { voids }) + isNewBlock = true + } + } + + /* + * TEXT LEAF NODE - Iterate through text content, yielding + * positions every `distance` offset according to `unit`. + */ + if (Text.isText(node)) { + const isFirst = Path.equals(path, first.path) + + // Proof that we always exhaust text nodes before encountering a new one: + // console.assert(leafTextRemaining <= 0, + // `leafTextRemaining=${leafTextRemaining} - `+ + // `not exhausted before new leaf text node`, path) + + // Reset `leafText` counters for new text node. + if (isFirst) { + leafTextRemaining = reverse + ? first.offset + : node.text.length - first.offset + leafTextOffset = first.offset // Works for reverse too. + } else { + leafTextRemaining = node.text.length + leafTextOffset = reverse ? leafTextRemaining : 0 + } + + // Yield position at the start of node (potentially). + if (isFirst || isNewBlock || unit === 'offset') { + yield { path, offset: leafTextOffset } + isNewBlock = false + } + + // Yield positions every (dynamically calculated) `distance` offset. + while (true) { + // If `leafText` has caught up with `blockText` (distance=0), + // and if blockText is exhausted, break to get another block node, + // otherwise advance blockText forward by the new `distance`. + if (distance === 0) { + if (blockText === '') break + distance = calcDistance(blockText, unit, reverse) + // Split the string at the previously found distance and use the + // remaining string for the next iteration. + blockText = splitByCharacterDistance(blockText, distance, reverse)[1] + } + + // Advance `leafText` by the current `distance`. + leafTextOffset = reverse + ? leafTextOffset - distance + : leafTextOffset + distance + leafTextRemaining = leafTextRemaining - distance + + // If `leafText` is exhausted, break to get a new leaf node + // and set distance to the overflow amount, so we'll (maybe) + // catch up to blockText in the next leaf text node. + if (leafTextRemaining < 0) { + distance = -leafTextRemaining + break + } + + // Successfully walked `distance` offsets through `leafText` + // to catch up with `blockText`, so we can reset `distance` + // and yield this position in this node. + distance = 0 + yield { path, offset: leafTextOffset } + } + } + } + // Proof that upon completion, we've exahusted both leaf and block text: + // console.assert(leafTextRemaining <= 0, "leafText wasn't exhausted") + // console.assert(blockText === '', "blockText wasn't exhausted") + + // Helper: + // Return the distance in offsets for a step of size `unit` on given string. + function calcDistance(text: string, unit: string, reverse?: boolean) { + if (unit === 'character') { + return getCharacterDistance(text, reverse) + } else if (unit === 'word') { + return getWordDistance(text, reverse) + } else if (unit === 'line' || unit === 'block') { + return text.length + } + return 1 + } +} diff --git a/packages/slate/src/editor/previous.ts b/packages/slate/src/editor/previous.ts new file mode 100644 index 000000000..6b662689d --- /dev/null +++ b/packages/slate/src/editor/previous.ts @@ -0,0 +1,47 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Span } from '../interfaces/location' +import { Path } from '../interfaces/path' + +export const previous: EditorInterface['previous'] = (editor, options = {}) => { + const { mode = 'lowest', voids = false } = options + let { match, at = editor.selection } = options + + if (!at) { + return + } + + const pointBeforeLocation = Editor.before(editor, at, { voids }) + + if (!pointBeforeLocation) { + return + } + + const [, to] = Editor.first(editor, []) + + // The search location is from the start of the document to the path of + // the point before the location passed in + const span: Span = [pointBeforeLocation.path, to] + + if (Path.isPath(at) && at.length === 0) { + throw new Error(`Cannot get the previous node from the root node!`) + } + + if (match == null) { + if (Path.isPath(at)) { + const [parent] = Editor.parent(editor, at) + match = n => parent.children.includes(n) + } else { + match = () => true + } + } + + const [previous] = Editor.nodes(editor, { + reverse: true, + at: span, + match, + mode, + voids, + }) + + return previous +} diff --git a/packages/slate/src/editor/range-ref.ts b/packages/slate/src/editor/range-ref.ts new file mode 100644 index 000000000..942cc776a --- /dev/null +++ b/packages/slate/src/editor/range-ref.ts @@ -0,0 +1,25 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { RangeRef } from '../interfaces/range-ref' + +export const rangeRef: EditorInterface['rangeRef'] = ( + editor, + range, + options = {} +) => { + const { affinity = 'forward' } = options + const ref: RangeRef = { + current: range, + affinity, + unref() { + const { current } = ref + const rangeRefs = Editor.rangeRefs(editor) + rangeRefs.delete(ref) + ref.current = null + return current + }, + } + + const refs = Editor.rangeRefs(editor) + refs.add(ref) + return ref +} diff --git a/packages/slate/src/editor/range-refs.ts b/packages/slate/src/editor/range-refs.ts new file mode 100644 index 000000000..ce9ab46b1 --- /dev/null +++ b/packages/slate/src/editor/range-refs.ts @@ -0,0 +1,13 @@ +import { EditorInterface } from '../interfaces/editor' +import { RANGE_REFS } from '../utils/weak-maps' + +export const rangeRefs: EditorInterface['rangeRefs'] = editor => { + let refs = RANGE_REFS.get(editor) + + if (!refs) { + refs = new Set() + RANGE_REFS.set(editor, refs) + } + + return refs +} diff --git a/packages/slate/src/editor/range.ts b/packages/slate/src/editor/range.ts new file mode 100644 index 000000000..3f8d51494 --- /dev/null +++ b/packages/slate/src/editor/range.ts @@ -0,0 +1,12 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Range } from '../interfaces/range' + +export const range: EditorInterface['range'] = (editor, at, to) => { + if (Range.isRange(at) && !to) { + return at + } + + const start = Editor.start(editor, at) + const end = Editor.end(editor, to || at) + return { anchor: start, focus: end } +} diff --git a/packages/slate/src/editor/remove-mark.ts b/packages/slate/src/editor/remove-mark.ts new file mode 100644 index 000000000..eea2eb8dd --- /dev/null +++ b/packages/slate/src/editor/remove-mark.ts @@ -0,0 +1,45 @@ +import { Node } from '../interfaces/node' +import { Path } from '../interfaces/path' +import { Text } from '../interfaces/text' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { FLUSHING } from '../utils/weak-maps' +import { Editor, EditorInterface } from '../interfaces/editor' + +export const removeMark: EditorInterface['removeMark'] = (editor, key) => { + const { selection } = editor + + if (selection) { + const match = (node: Node, path: Path) => { + if (!Text.isText(node)) { + return false // marks can only be applied to text + } + const [parentNode, parentPath] = Editor.parent(editor, path) + return !editor.isVoid(parentNode) || editor.markableVoid(parentNode) + } + const expandedSelection = Range.isExpanded(selection) + let markAcceptingVoidSelected = false + if (!expandedSelection) { + const [selectedNode, selectedPath] = Editor.node(editor, selection) + if (selectedNode && match(selectedNode, selectedPath)) { + const [parentNode] = Editor.parent(editor, selectedPath) + markAcceptingVoidSelected = + parentNode && editor.markableVoid(parentNode) + } + } + if (expandedSelection || markAcceptingVoidSelected) { + Transforms.unsetNodes(editor, key, { + match, + split: true, + voids: true, + }) + } else { + const marks = { ...(Editor.marks(editor) || {}) } + delete marks[key] + editor.marks = marks + if (!FLUSHING.get(editor)) { + editor.onChange() + } + } + } +} diff --git a/packages/slate/src/editor/set-normalizing.ts b/packages/slate/src/editor/set-normalizing.ts new file mode 100644 index 000000000..50805ceb7 --- /dev/null +++ b/packages/slate/src/editor/set-normalizing.ts @@ -0,0 +1,9 @@ +import { EditorInterface } from '../interfaces/editor' +import { NORMALIZING } from '../utils/weak-maps' + +export const setNormalizing: EditorInterface['setNormalizing'] = ( + editor, + isNormalizing +) => { + NORMALIZING.set(editor, isNormalizing) +} diff --git a/packages/slate/src/editor/start.ts b/packages/slate/src/editor/start.ts new file mode 100644 index 000000000..0a7f7c3fc --- /dev/null +++ b/packages/slate/src/editor/start.ts @@ -0,0 +1,5 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const start: EditorInterface['start'] = (editor, at) => { + return Editor.point(editor, at, { edge: 'start' }) +} diff --git a/packages/slate/src/editor/string.ts b/packages/slate/src/editor/string.ts new file mode 100644 index 000000000..fdcd010ba --- /dev/null +++ b/packages/slate/src/editor/string.ts @@ -0,0 +1,31 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Range } from '../interfaces/range' +import { Text } from '../interfaces/text' +import { Path } from '../interfaces/path' + +export const string: EditorInterface['string'] = (editor, at, options = {}) => { + const { voids = false } = options + const range = Editor.range(editor, at) + const [start, end] = Range.edges(range) + let text = '' + + for (const [node, path] of Editor.nodes(editor, { + at: range, + match: Text.isText, + voids, + })) { + let t = node.text + + if (Path.equals(path, end.path)) { + t = t.slice(0, end.offset) + } + + if (Path.equals(path, start.path)) { + t = t.slice(start.offset) + } + + text += t + } + + return text +} diff --git a/packages/slate/src/editor/unhang-range.ts b/packages/slate/src/editor/unhang-range.ts new file mode 100644 index 000000000..84b1b6d95 --- /dev/null +++ b/packages/slate/src/editor/unhang-range.ts @@ -0,0 +1,53 @@ +import { Editor, EditorInterface } from '../interfaces/editor' +import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' +import { Element } from '../interfaces/element' +import { Text } from '../interfaces/text' + +export const unhangRange: EditorInterface['unhangRange'] = ( + editor, + range, + options = {} +) => { + const { voids = false } = options + let [start, end] = Range.edges(range) + + // PERF: exit early if we can guarantee that the range isn't hanging. + if ( + start.offset !== 0 || + end.offset !== 0 || + Range.isCollapsed(range) || + Path.hasPrevious(end.path) + ) { + return range + } + + const endBlock = Editor.above(editor, { + at: end, + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + voids, + }) + const blockPath = endBlock ? endBlock[1] : [] + const first = Editor.start(editor, start) + const before = { anchor: first, focus: end } + let skip = true + + for (const [node, path] of Editor.nodes(editor, { + at: before, + match: Text.isText, + reverse: true, + voids, + })) { + if (skip) { + skip = false + continue + } + + if (node.text !== '' || Path.isBefore(path, blockPath)) { + end = { path, offset: node.text.length } + break + } + } + + return { anchor: start, focus: end } +} diff --git a/packages/slate/src/editor/without-normalizing.ts b/packages/slate/src/editor/without-normalizing.ts new file mode 100644 index 000000000..0e5716050 --- /dev/null +++ b/packages/slate/src/editor/without-normalizing.ts @@ -0,0 +1,15 @@ +import { Editor, EditorInterface } from '../interfaces/editor' + +export const withoutNormalizing: EditorInterface['withoutNormalizing'] = ( + editor, + fn +) => { + const value = Editor.isNormalizing(editor) + Editor.setNormalizing(editor, false) + try { + fn() + } finally { + Editor.setNormalizing(editor, value) + } + Editor.normalize(editor) +} diff --git a/packages/slate/src/index.ts b/packages/slate/src/index.ts index 73c70a952..5d30415b9 100644 --- a/packages/slate/src/index.ts +++ b/packages/slate/src/index.ts @@ -1,16 +1,8 @@ +export * from './core' export * from './create-editor' -export * from './interfaces/custom-types' -export * from './interfaces/editor' -export * from './interfaces/element' -export * from './interfaces/location' -export * from './interfaces/node' -export * from './interfaces/operation' -export * from './interfaces/path' -export * from './interfaces/path-ref' -export * from './interfaces/point' -export * from './interfaces/point-ref' -export * from './interfaces/range' -export * from './interfaces/range-ref' -export * from './interfaces/scrubber' -export * from './interfaces/text' -export * from './transforms' +export * from './editor' +export * from './interfaces' +export * from './transforms-node' +export * from './transforms-selection' +export * from './transforms-text' +export * from './types' diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index fc0e9e33e..ccd9e2b65 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -1,7 +1,7 @@ -import { isPlainObject } from 'is-plain-object' - import { Ancestor, + Descendant, + Element, ExtendedType, Location, Node, @@ -15,22 +15,8 @@ import { RangeRef, Span, Text, + Transforms, } from '..' -import { - getCharacterDistance, - getWordDistance, - splitByCharacterDistance, -} from '../utils/string' -import { - DIRTY_PATH_KEYS, - DIRTY_PATHS, - NORMALIZING, - PATH_REFS, - POINT_REFS, - RANGE_REFS, -} from '../utils/weak-maps' -import { Element } from './element' -import { Descendant } from './node' import { LeafEdge, MaximizeMode, @@ -39,48 +25,33 @@ import { TextDirection, TextUnit, TextUnitAdjustment, -} from './types' - -export type BaseSelection = Range | null - -export type Selection = ExtendedType<'Selection', BaseSelection> - -export type EditorMarks = Omit +} from '../types/types' +import { OmitFirstArg } from '../utils/types' +import { isEditor } from '../editor/is-editor' +import { TextInsertTextOptions } from './transforms/text' /** * The `Editor` interface stores all the state of a Slate editor. It is extended * by plugins that wish to add their own helpers and implement new behaviors. */ - export interface BaseEditor { + // Core state. + children: Descendant[] selection: Selection operations: Operation[] marks: EditorMarks | null - // Schema-specific node behaviors. + // Overrideable core methods. + + apply: (operation: Operation) => void + getDirtyPaths: (operation: Operation) => Path[] + getFragment: () => Descendant[] isElementReadOnly: (element: Element) => boolean - isInline: (element: Element) => boolean isSelectable: (element: Element) => boolean - isVoid: (element: Element) => boolean markableVoid: (element: Element) => boolean normalizeNode: (entry: NodeEntry, options?: { operation?: Operation }) => void onChange: (options?: { operation?: Operation }) => void - - // Overrideable core actions. - addMark: (key: string, value: any) => void - apply: (operation: Operation) => void - deleteBackward: (unit: TextUnit) => void - deleteForward: (unit: TextUnit) => void - deleteFragment: (direction?: TextDirection) => void - getFragment: () => Descendant[] - insertBreak: () => void - insertSoftBreak: () => void - insertFragment: (fragment: Node[]) => void - insertNode: (node: Node) => void - insertText: (text: string) => void - removeMark: (key: string) => void - getDirtyPaths: (operation: Operation) => Path[] shouldNormalize: ({ iteration, dirtyPaths, @@ -91,10 +62,117 @@ export interface BaseEditor { dirtyPaths: Path[] operation?: Operation }) => boolean + + // Overrideable core transforms. + + addMark: OmitFirstArg + collapse: OmitFirstArg + delete: OmitFirstArg + deleteBackward: (unit: TextUnit) => void + deleteForward: (unit: TextUnit) => void + deleteFragment: OmitFirstArg + deselect: OmitFirstArg + insertBreak: OmitFirstArg + insertFragment: OmitFirstArg + insertNode: OmitFirstArg + insertNodes: OmitFirstArg + insertSoftBreak: OmitFirstArg + insertText: OmitFirstArg + liftNodes: OmitFirstArg + mergeNodes: OmitFirstArg + move: OmitFirstArg + moveNodes: OmitFirstArg + normalize: OmitFirstArg + removeMark: OmitFirstArg + removeNodes: OmitFirstArg + select: OmitFirstArg + setNodes: ( + props: Partial, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + hanging?: boolean + split?: boolean + voids?: boolean + compare?: PropsCompare + merge?: PropsMerge + } + ) => void + setNormalizing: OmitFirstArg + setPoint: OmitFirstArg + setSelection: OmitFirstArg + splitNodes: OmitFirstArg + unsetNodes: OmitFirstArg + unwrapNodes: OmitFirstArg + withoutNormalizing: OmitFirstArg + wrapNodes: OmitFirstArg + + // Overrideable core queries. + + above: ( + options?: EditorAboveOptions + ) => NodeEntry | undefined + after: OmitFirstArg + before: OmitFirstArg + edges: OmitFirstArg + elementReadOnly: OmitFirstArg + end: OmitFirstArg + first: OmitFirstArg + fragment: OmitFirstArg + getMarks: OmitFirstArg + hasBlocks: OmitFirstArg + hasInlines: OmitFirstArg + hasPath: OmitFirstArg + hasTexts: OmitFirstArg + isBlock: OmitFirstArg + isEdge: OmitFirstArg + isEmpty: OmitFirstArg + isEnd: OmitFirstArg + isInline: OmitFirstArg + isNormalizing: OmitFirstArg + isStart: OmitFirstArg + isVoid: OmitFirstArg + last: OmitFirstArg + leaf: OmitFirstArg + levels: ( + options?: EditorLevelsOptions + ) => Generator, void, undefined> + next: ( + options?: EditorNextOptions + ) => NodeEntry | undefined + node: OmitFirstArg + nodes: ( + options?: EditorNodesOptions + ) => Generator, void, undefined> + parent: OmitFirstArg + path: OmitFirstArg + pathRef: OmitFirstArg + pathRefs: OmitFirstArg + point: OmitFirstArg + pointRef: OmitFirstArg + pointRefs: OmitFirstArg + positions: OmitFirstArg + previous: ( + options?: EditorPreviousOptions + ) => NodeEntry | undefined + range: OmitFirstArg + rangeRef: OmitFirstArg + rangeRefs: OmitFirstArg + start: OmitFirstArg + string: OmitFirstArg + unhangRange: OmitFirstArg + void: OmitFirstArg } export type Editor = ExtendedType<'Editor', BaseEditor> +export type BaseSelection = Range | null + +export type Selection = ExtendedType<'Selection', BaseSelection> + +export type EditorMarks = Omit + export interface EditorAboveOptions { at?: Location match?: NodeMatch @@ -223,183 +301,13 @@ export interface EditorVoidOptions { } export interface EditorInterface { + /** + * Get the ancestor above a location in the document. + */ above: ( editor: Editor, options?: EditorAboveOptions ) => NodeEntry | undefined - addMark: (editor: Editor, key: string, value: any) => void - after: ( - editor: Editor, - at: Location, - options?: EditorAfterOptions - ) => Point | undefined - before: ( - editor: Editor, - at: Location, - options?: EditorBeforeOptions - ) => Point | undefined - deleteBackward: ( - editor: Editor, - options?: EditorDirectedDeletionOptions - ) => void - deleteForward: ( - editor: Editor, - options?: EditorDirectedDeletionOptions - ) => void - deleteFragment: ( - editor: Editor, - options?: EditorFragmentDeletionOptions - ) => void - edges: (editor: Editor, at: Location) => [Point, Point] - elementReadOnly: ( - editor: Editor, - options?: EditorElementReadOnlyOptions - ) => NodeEntry | undefined - end: (editor: Editor, at: Location) => Point - first: (editor: Editor, at: Location) => NodeEntry - fragment: (editor: Editor, at: Location) => Descendant[] - hasBlocks: (editor: Editor, element: Element) => boolean - hasInlines: (editor: Editor, element: Element) => boolean - hasPath: (editor: Editor, path: Path) => boolean - hasTexts: (editor: Editor, element: Element) => boolean - insertBreak: (editor: Editor) => void - insertSoftBreak: (editor: Editor) => void - insertFragment: (editor: Editor, fragment: Node[]) => void - insertNode: (editor: Editor, node: Node) => void - insertText: (editor: Editor, text: string) => void - isBlock: (editor: Editor, value: Element) => boolean - isEditor: (value: any) => value is Editor - isEnd: (editor: Editor, point: Point, at: Location) => boolean - isEdge: (editor: Editor, point: Point, at: Location) => boolean - isElementReadOnly: (editor: Editor, element: Element) => boolean - isEmpty: (editor: Editor, element: Element) => boolean - isInline: (editor: Editor, value: Element) => boolean - isNormalizing: (editor: Editor) => boolean - isSelectable: (editor: Editor, element: Element) => boolean - isStart: (editor: Editor, point: Point, at: Location) => boolean - isVoid: (editor: Editor, value: Element) => boolean - last: (editor: Editor, at: Location) => NodeEntry - leaf: ( - editor: Editor, - at: Location, - options?: EditorLeafOptions - ) => NodeEntry - levels: ( - editor: Editor, - options?: EditorLevelsOptions - ) => Generator, void, undefined> - marks: (editor: Editor) => Omit | null - next: ( - editor: Editor, - options?: EditorNextOptions - ) => NodeEntry | undefined - node: (editor: Editor, at: Location, options?: EditorNodeOptions) => NodeEntry - nodes: ( - editor: Editor, - options?: EditorNodesOptions - ) => Generator, void, undefined> - normalize: (editor: Editor, options?: EditorNormalizeOptions) => void - parent: ( - editor: Editor, - at: Location, - options?: EditorParentOptions - ) => NodeEntry - path: (editor: Editor, at: Location, options?: EditorPathOptions) => Path - pathRef: ( - editor: Editor, - path: Path, - options?: EditorPathRefOptions - ) => PathRef - pathRefs: (editor: Editor) => Set - point: (editor: Editor, at: Location, options?: EditorPointOptions) => Point - pointRef: ( - editor: Editor, - point: Point, - options?: EditorPointRefOptions - ) => PointRef - pointRefs: (editor: Editor) => Set - positions: ( - editor: Editor, - options?: EditorPositionsOptions - ) => Generator - previous: ( - editor: Editor, - options?: EditorPreviousOptions - ) => NodeEntry | undefined - range: (editor: Editor, at: Location, to?: Location) => Range - rangeRef: ( - editor: Editor, - range: Range, - options?: EditorRangeRefOptions - ) => RangeRef - rangeRefs: (editor: Editor) => Set - removeMark: (editor: Editor, key: string) => void - setNormalizing: (editor: Editor, isNormalizing: boolean) => void - start: (editor: Editor, at: Location) => Point - string: ( - editor: Editor, - at: Location, - options?: EditorStringOptions - ) => string - unhangRange: ( - editor: Editor, - range: Range, - options?: EditorUnhangRangeOptions - ) => Range - void: ( - editor: Editor, - options?: EditorVoidOptions - ) => NodeEntry | undefined - withoutNormalizing: (editor: Editor, fn: () => void) => void -} - -const IS_EDITOR_CACHE = new WeakMap() - -// eslint-disable-next-line no-redeclare -export const Editor: EditorInterface = { - /** - * Get the ancestor above a location in the document. - */ - - above( - editor: Editor, - options: EditorAboveOptions = {} - ): NodeEntry | undefined { - const { - voids = false, - mode = 'lowest', - at = editor.selection, - match, - } = options - - if (!at) { - return - } - - const path = Editor.path(editor, at) - const reverse = mode === 'lowest' - - for (const [n, p] of Editor.levels(editor, { - at: path, - voids, - match, - reverse, - })) { - if (Text.isText(n)) continue - if (Range.isRange(at)) { - if ( - Path.isAncestor(p, at.anchor.path) && - Path.isAncestor(p, at.focus.path) - ) { - return [n, p] - } - } else { - if (!Path.equals(path, p)) { - return [n, p] - } - } - } - }, /** * Add a custom property to the leaf text nodes in the current selection. @@ -407,947 +315,290 @@ export const Editor: EditorInterface = { * If the selection is currently collapsed, the marks will be added to the * `editor.marks` property instead, and applied when text is inserted next. */ - - addMark(editor: Editor, key: string, value: any): void { - editor.addMark(key, value) - }, + addMark: (editor: Editor, key: string, value: any) => void /** * Get the point after a location. */ - - after( + after: ( editor: Editor, at: Location, - options: EditorAfterOptions = {} - ): Point | undefined { - const anchor = Editor.point(editor, at, { edge: 'end' }) - const focus = Editor.end(editor, []) - const range = { anchor, focus } - const { distance = 1 } = options - let d = 0 - let target - - for (const p of Editor.positions(editor, { - ...options, - at: range, - })) { - if (d > distance) { - break - } - - if (d !== 0) { - target = p - } - - d++ - } - - return target - }, + options?: EditorAfterOptions + ) => Point | undefined /** * Get the point before a location. */ - - before( + before: ( editor: Editor, at: Location, - options: EditorBeforeOptions = {} - ): Point | undefined { - const anchor = Editor.start(editor, []) - const focus = Editor.point(editor, at, { edge: 'start' }) - const range = { anchor, focus } - const { distance = 1 } = options - let d = 0 - let target - - for (const p of Editor.positions(editor, { - ...options, - at: range, - reverse: true, - })) { - if (d > distance) { - break - } - - if (d !== 0) { - target = p - } - - d++ - } - - return target - }, + options?: EditorBeforeOptions + ) => Point | undefined /** * Delete content in the editor backward from the current selection. */ - - deleteBackward( + deleteBackward: ( editor: Editor, - options: EditorDirectedDeletionOptions = {} - ): void { - const { unit = 'character' } = options - editor.deleteBackward(unit) - }, + options?: EditorDirectedDeletionOptions + ) => void /** * Delete content in the editor forward from the current selection. */ - - deleteForward( + deleteForward: ( editor: Editor, - options: EditorDirectedDeletionOptions = {} - ): void { - const { unit = 'character' } = options - editor.deleteForward(unit) - }, + options?: EditorDirectedDeletionOptions + ) => void /** * Delete the content in the current selection. */ - - deleteFragment( + deleteFragment: ( editor: Editor, - options: EditorFragmentDeletionOptions = {} - ): void { - const { direction = 'forward' } = options - editor.deleteFragment(direction) - }, + options?: EditorFragmentDeletionOptions + ) => void /** * Get the start and end points of a location. */ - - edges(editor: Editor, at: Location): [Point, Point] { - return [Editor.start(editor, at), Editor.end(editor, at)] - }, + edges: (editor: Editor, at: Location) => [Point, Point] /** * Match a read-only element in the current branch of the editor. */ - - elementReadOnly( + elementReadOnly: ( editor: Editor, - options: EditorElementReadOnlyOptions = {} - ): NodeEntry | undefined { - return Editor.above(editor, { - ...options, - match: n => Element.isElement(n) && Editor.isElementReadOnly(editor, n), - }) - }, + options?: EditorElementReadOnlyOptions + ) => NodeEntry | undefined /** * Get the end point of a location. */ - - end(editor: Editor, at: Location): Point { - return Editor.point(editor, at, { edge: 'end' }) - }, + end: (editor: Editor, at: Location) => Point /** * Get the first node at a location. */ - - first(editor: Editor, at: Location): NodeEntry { - const path = Editor.path(editor, at, { edge: 'start' }) - return Editor.node(editor, path) - }, + first: (editor: Editor, at: Location) => NodeEntry /** * Get the fragment at a location. */ + fragment: (editor: Editor, at: Location) => Descendant[] - fragment(editor: Editor, at: Location): Descendant[] { - const range = Editor.range(editor, at) - const fragment = Node.fragment(editor, range) - return fragment - }, /** * Check if a node has block children. */ - - hasBlocks(editor: Editor, element: Element): boolean { - return element.children.some( - n => Element.isElement(n) && Editor.isBlock(editor, n) - ) - }, + hasBlocks: (editor: Editor, element: Element) => boolean /** * Check if a node has inline and text children. */ + hasInlines: (editor: Editor, element: Element) => boolean - hasInlines(editor: Editor, element: Element): boolean { - return element.children.some( - n => Text.isText(n) || Editor.isInline(editor, n) - ) - }, + hasPath: (editor: Editor, path: Path) => boolean /** * Check if a node has text children. */ - - hasTexts(editor: Editor, element: Element): boolean { - return element.children.every(n => Text.isText(n)) - }, + hasTexts: (editor: Editor, element: Element) => boolean /** * Insert a block break at the current selection. * * If the selection is currently expanded, it will be deleted first. */ - - insertBreak(editor: Editor): void { - editor.insertBreak() - }, - - /** - * Insert a soft break at the current selection. - * - * If the selection is currently expanded, it will be deleted first. - */ - - insertSoftBreak(editor: Editor): void { - editor.insertSoftBreak() - }, + insertBreak: (editor: Editor) => void /** * Insert a fragment at the current selection. * * If the selection is currently expanded, it will be deleted first. */ - - insertFragment(editor: Editor, fragment: Node[]): void { - editor.insertFragment(fragment) - }, + insertFragment: (editor: Editor, fragment: Node[]) => void /** * Insert a node at the current selection. * * If the selection is currently expanded, it will be deleted first. */ + insertNode: (editor: Editor, node: Node) => void - insertNode(editor: Editor, node: Node): void { - editor.insertNode(node) - }, + /** + * Insert a soft break at the current selection. + * + * If the selection is currently expanded, it will be deleted first. + */ + insertSoftBreak: (editor: Editor) => void /** * Insert text at the current selection. * * If the selection is currently expanded, it will be deleted first. */ - - insertText(editor: Editor, text: string): void { - editor.insertText(text) - }, + insertText: ( + editor: Editor, + text: string, + options?: TextInsertTextOptions + ) => void /** * Check if a value is a block `Element` object. */ - - isBlock(editor: Editor, value: Element): boolean { - return !editor.isInline(value) - }, - - /** - * Check if a value is an `Editor` object. - */ - - isEditor(value: any): value is Editor { - const cachedIsEditor = IS_EDITOR_CACHE.get(value) - if (cachedIsEditor !== undefined) { - return cachedIsEditor - } - - if (!isPlainObject(value)) { - return false - } - - const isEditor = - typeof value.addMark === 'function' && - typeof value.apply === 'function' && - typeof value.deleteBackward === 'function' && - typeof value.deleteForward === 'function' && - typeof value.deleteFragment === 'function' && - typeof value.insertBreak === 'function' && - typeof value.insertSoftBreak === 'function' && - typeof value.insertFragment === 'function' && - typeof value.insertNode === 'function' && - typeof value.insertText === 'function' && - typeof value.isElementReadOnly === 'function' && - typeof value.isInline === 'function' && - typeof value.isSelectable === 'function' && - typeof value.isVoid === 'function' && - typeof value.normalizeNode === 'function' && - typeof value.onChange === 'function' && - typeof value.removeMark === 'function' && - typeof value.getDirtyPaths === 'function' && - (value.marks === null || isPlainObject(value.marks)) && - (value.selection === null || Range.isRange(value.selection)) && - Node.isNodeList(value.children) && - Operation.isOperationList(value.operations) - IS_EDITOR_CACHE.set(value, isEditor) - return isEditor - }, - - /** - * Check if a point is the end point of a location. - */ - - isEnd(editor: Editor, point: Point, at: Location): boolean { - const end = Editor.end(editor, at) - return Point.equals(point, end) - }, + isBlock: (editor: Editor, value: Element) => boolean /** * Check if a point is an edge of a location. */ - - isEdge(editor: Editor, point: Point, at: Location): boolean { - return Editor.isStart(editor, point, at) || Editor.isEnd(editor, point, at) - }, + isEdge: (editor: Editor, point: Point, at: Location) => boolean /** - * Check if an element is empty, accounting for void nodes. + * Check if a value is an `Editor` object. */ - - isEmpty(editor: Editor, element: Element): boolean { - const { children } = element - const [first] = children - return ( - children.length === 0 || - (children.length === 1 && - Text.isText(first) && - first.text === '' && - !editor.isVoid(element)) - ) - }, - - /** - * Check if a value is an inline `Element` object. - */ - - isInline(editor: Editor, value: Element): boolean { - return editor.isInline(value) - }, + isEditor: (value: any) => value is Editor /** * Check if a value is a read-only `Element` object. */ + isElementReadOnly: (editor: Editor, element: Element) => boolean - isElementReadOnly(editor: Editor, value: Element): boolean { - return editor.isElementReadOnly(value) - }, + /** + * Check if an element is empty, accounting for void nodes. + */ + isEmpty: (editor: Editor, element: Element) => boolean + + /** + * Check if a point is the end point of a location. + */ + isEnd: (editor: Editor, point: Point, at: Location) => boolean + + /** + * Check if a value is an inline `Element` object. + */ + isInline: (editor: Editor, value: Element) => boolean /** * Check if the editor is currently normalizing after each operation. */ - - isNormalizing(editor: Editor): boolean { - const isNormalizing = NORMALIZING.get(editor) - return isNormalizing === undefined ? true : isNormalizing - }, + isNormalizing: (editor: Editor) => boolean /** * Check if a value is a selectable `Element` object. */ - - isSelectable(editor: Editor, value: Element): boolean { - return editor.isSelectable(value) - }, + isSelectable: (editor: Editor, element: Element) => boolean /** * Check if a point is the start point of a location. */ - - isStart(editor: Editor, point: Point, at: Location): boolean { - // PERF: If the offset isn't `0` we know it's not the start. - if (point.offset !== 0) { - return false - } - - const start = Editor.start(editor, at) - return Point.equals(point, start) - }, + isStart: (editor: Editor, point: Point, at: Location) => boolean /** * Check if a value is a void `Element` object. */ - - isVoid(editor: Editor, value: Element): boolean { - return editor.isVoid(value) - }, + isVoid: (editor: Editor, value: Element) => boolean /** * Get the last node at a location. */ - - last(editor: Editor, at: Location): NodeEntry { - const path = Editor.path(editor, at, { edge: 'end' }) - return Editor.node(editor, path) - }, + last: (editor: Editor, at: Location) => NodeEntry /** * Get the leaf text node at a location. */ - - leaf( + leaf: ( editor: Editor, at: Location, - options: EditorLeafOptions = {} - ): NodeEntry { - const path = Editor.path(editor, at, options) - const node = Node.leaf(editor, path) - return [node, path] - }, + options?: EditorLeafOptions + ) => NodeEntry /** * Iterate through all of the levels at a location. */ - - *levels( + levels: ( editor: Editor, - options: EditorLevelsOptions = {} - ): Generator, void, undefined> { - const { at = editor.selection, reverse = false, voids = false } = options - let { match } = options - - if (match == null) { - match = () => true - } - - if (!at) { - return - } - - const levels: NodeEntry[] = [] - const path = Editor.path(editor, at) - - for (const [n, p] of Node.levels(editor, path)) { - if (!match(n, p)) { - continue - } - - levels.push([n, p]) - - if (!voids && Element.isElement(n) && Editor.isVoid(editor, n)) { - break - } - } - - if (reverse) { - levels.reverse() - } - - yield* levels - }, + options?: EditorLevelsOptions + ) => Generator, void, undefined> /** * Get the marks that would be added to text at the current selection. */ - - marks(editor: Editor): Omit | null { - const { marks, selection } = editor - - if (!selection) { - return null - } - - if (marks) { - return marks - } - - if (Range.isExpanded(selection)) { - const [match] = Editor.nodes(editor, { match: Text.isText }) - - if (match) { - const [node] = match as NodeEntry - const { text, ...rest } = node - return rest - } else { - return {} - } - } - - const { anchor } = selection - const { path } = anchor - let [node] = Editor.leaf(editor, path) - - if (anchor.offset === 0) { - const prev = Editor.previous(editor, { at: path, match: Text.isText }) - const markedVoid = Editor.above(editor, { - match: n => - Element.isElement(n) && - Editor.isVoid(editor, n) && - editor.markableVoid(n), - }) - if (!markedVoid) { - const block = Editor.above(editor, { - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - }) - - if (prev && block) { - const [prevNode, prevPath] = prev - const [, blockPath] = block - - if (Path.isAncestor(blockPath, prevPath)) { - node = prevNode as Text - } - } - } - } - - const { text, ...rest } = node - return rest - }, + marks: (editor: Editor) => Omit | null /** * Get the matching node in the branch of the document after a location. */ - - next( + next: ( editor: Editor, - options: EditorNextOptions = {} - ): NodeEntry | undefined { - const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection } = options - - if (!at) { - return - } - - const pointAfterLocation = Editor.after(editor, at, { voids }) - - if (!pointAfterLocation) return - - const [, to] = Editor.last(editor, []) - - const span: Span = [pointAfterLocation.path, to] - - if (Path.isPath(at) && at.length === 0) { - throw new Error(`Cannot get the next node from the root node!`) - } - - if (match == null) { - if (Path.isPath(at)) { - const [parent] = Editor.parent(editor, at) - match = n => parent.children.includes(n) - } else { - match = () => true - } - } - - const [next] = Editor.nodes(editor, { at: span, match, mode, voids }) - return next - }, + options?: EditorNextOptions + ) => NodeEntry | undefined /** * Get the node at a location. */ - - node( - editor: Editor, - at: Location, - options: EditorNodeOptions = {} - ): NodeEntry { - const path = Editor.path(editor, at, options) - const node = Node.get(editor, path) - return [node, path] - }, + node: (editor: Editor, at: Location, options?: EditorNodeOptions) => NodeEntry /** * Iterate through all of the nodes in the Editor. */ - - *nodes( + nodes: ( editor: Editor, - options: EditorNodesOptions = {} - ): Generator, void, undefined> { - const { - at = editor.selection, - mode = 'all', - universal = false, - reverse = false, - voids = false, - ignoreNonSelectable = false, - } = options - let { match } = options + options?: EditorNodesOptions + ) => Generator, void, undefined> - if (!match) { - match = () => true - } - - if (!at) { - return - } - - let from - let to - - if (Span.isSpan(at)) { - from = at[0] - to = at[1] - } else { - const first = Editor.path(editor, at, { edge: 'start' }) - const last = Editor.path(editor, at, { edge: 'end' }) - from = reverse ? last : first - to = reverse ? first : last - } - - const nodeEntries = Node.nodes(editor, { - reverse, - from, - to, - pass: ([node]) => { - if (!Element.isElement(node)) return false - if ( - !voids && - (Editor.isVoid(editor, node) || - Editor.isElementReadOnly(editor, node)) - ) - return true - if (ignoreNonSelectable && !Editor.isSelectable(editor, node)) - return true - return false - }, - }) - - const matches: NodeEntry[] = [] - let hit: NodeEntry | undefined - - for (const [node, path] of nodeEntries) { - if ( - ignoreNonSelectable && - Element.isElement(node) && - !Editor.isSelectable(editor, node) - ) { - continue - } - - const isLower = hit && Path.compare(path, hit[1]) === 0 - - // In highest mode any node lower than the last hit is not a match. - if (mode === 'highest' && isLower) { - continue - } - - if (!match(node, path)) { - // If we've arrived at a leaf text node that is not lower than the last - // hit, then we've found a branch that doesn't include a match, which - // means the match is not universal. - if (universal && !isLower && Text.isText(node)) { - return - } else { - continue - } - } - - // If there's a match and it's lower than the last, update the hit. - if (mode === 'lowest' && isLower) { - hit = [node, path] - continue - } - - // In lowest mode we emit the last hit, once it's guaranteed lowest. - const emit: NodeEntry | undefined = - mode === 'lowest' ? hit : [node, path] - - if (emit) { - if (universal) { - matches.push(emit) - } else { - yield emit - } - } - - hit = [node, path] - } - - // Since lowest is always emitting one behind, catch up at the end. - if (mode === 'lowest' && hit) { - if (universal) { - matches.push(hit) - } else { - yield hit - } - } - - // Universal defers to ensure that the match occurs in every branch, so we - // yield all of the matches after iterating. - if (universal) { - yield* matches - } - }, /** * Normalize any dirty objects in the editor. */ - - normalize(editor: Editor, options: EditorNormalizeOptions = {}): void { - const { force = false, operation } = options - const getDirtyPaths = (editor: Editor) => { - return DIRTY_PATHS.get(editor) || [] - } - - const getDirtyPathKeys = (editor: Editor) => { - return DIRTY_PATH_KEYS.get(editor) || new Set() - } - - const popDirtyPath = (editor: Editor): Path => { - const path = getDirtyPaths(editor).pop()! - const key = path.join(',') - getDirtyPathKeys(editor).delete(key) - return path - } - - if (!Editor.isNormalizing(editor)) { - return - } - - if (force) { - const allPaths = Array.from(Node.nodes(editor), ([, p]) => p) - const allPathKeys = new Set(allPaths.map(p => p.join(','))) - DIRTY_PATHS.set(editor, allPaths) - DIRTY_PATH_KEYS.set(editor, allPathKeys) - } - - if (getDirtyPaths(editor).length === 0) { - return - } - - Editor.withoutNormalizing(editor, () => { - /* - Fix dirty elements with no children. - editor.normalizeNode() does fix this, but some normalization fixes also require it to work. - Running an initial pass avoids the catch-22 race condition. - */ - for (const dirtyPath of getDirtyPaths(editor)) { - if (Node.has(editor, dirtyPath)) { - const entry = Editor.node(editor, dirtyPath) - const [node, _] = entry - - /* - The default normalizer inserts an empty text node in this scenario, but it can be customised. - So there is some risk here. - - As long as the normalizer only inserts child nodes for this case it is safe to do in any order; - by definition adding children to an empty node can't cause other paths to change. - */ - if (Element.isElement(node) && node.children.length === 0) { - editor.normalizeNode(entry, { operation }) - } - } - } - - let dirtyPaths = getDirtyPaths(editor) - const initialDirtyPathsLength = dirtyPaths.length - let iteration = 0 - - while (dirtyPaths.length !== 0) { - if ( - !editor.shouldNormalize({ - dirtyPaths, - iteration, - initialDirtyPathsLength, - operation, - }) - ) { - return - } - - const dirtyPath = popDirtyPath(editor) - - // If the node doesn't exist in the tree, it does not need to be normalized. - if (Node.has(editor, dirtyPath)) { - const entry = Editor.node(editor, dirtyPath) - editor.normalizeNode(entry, { operation }) - } - iteration++ - dirtyPaths = getDirtyPaths(editor) - } - }) - }, + normalize: (editor: Editor, options?: EditorNormalizeOptions) => void /** * Get the parent node of a location. */ - - parent( + parent: ( editor: Editor, at: Location, - options: EditorParentOptions = {} - ): NodeEntry { - const path = Editor.path(editor, at, options) - const parentPath = Path.parent(path) - const entry = Editor.node(editor, parentPath) - return entry as NodeEntry - }, + options?: EditorParentOptions + ) => NodeEntry /** * Get the path of a location. */ - - path(editor: Editor, at: Location, options: EditorPathOptions = {}): Path { - const { depth, edge } = options - - if (Path.isPath(at)) { - if (edge === 'start') { - const [, firstPath] = Node.first(editor, at) - at = firstPath - } else if (edge === 'end') { - const [, lastPath] = Node.last(editor, at) - at = lastPath - } - } - - if (Range.isRange(at)) { - if (edge === 'start') { - at = Range.start(at) - } else if (edge === 'end') { - at = Range.end(at) - } else { - at = Path.common(at.anchor.path, at.focus.path) - } - } - - if (Point.isPoint(at)) { - at = at.path - } - - if (depth != null) { - at = at.slice(0, depth) - } - - return at - }, - - hasPath(editor: Editor, path: Path): boolean { - return Node.has(editor, path) - }, + path: (editor: Editor, at: Location, options?: EditorPathOptions) => Path /** * Create a mutable ref for a `Path` object, which will stay in sync as new * operations are applied to the editor. */ - - pathRef( + pathRef: ( editor: Editor, path: Path, - options: EditorPathRefOptions = {} - ): PathRef { - const { affinity = 'forward' } = options - const ref: PathRef = { - current: path, - affinity, - unref() { - const { current } = ref - const pathRefs = Editor.pathRefs(editor) - pathRefs.delete(ref) - ref.current = null - return current - }, - } - - const refs = Editor.pathRefs(editor) - refs.add(ref) - return ref - }, + options?: EditorPathRefOptions + ) => PathRef /** * Get the set of currently tracked path refs of the editor. */ - - pathRefs(editor: Editor): Set { - let refs = PATH_REFS.get(editor) - - if (!refs) { - refs = new Set() - PATH_REFS.set(editor, refs) - } - - return refs - }, + pathRefs: (editor: Editor) => Set /** * Get the start or end point of a location. */ - - point(editor: Editor, at: Location, options: EditorPointOptions = {}): Point { - const { edge = 'start' } = options - - if (Path.isPath(at)) { - let path - - if (edge === 'end') { - const [, lastPath] = Node.last(editor, at) - path = lastPath - } else { - const [, firstPath] = Node.first(editor, at) - path = firstPath - } - - const node = Node.get(editor, path) - - if (!Text.isText(node)) { - throw new Error( - `Cannot get the ${edge} point in the node at path [${at}] because it has no ${edge} text node.` - ) - } - - return { path, offset: edge === 'end' ? node.text.length : 0 } - } - - if (Range.isRange(at)) { - const [start, end] = Range.edges(at) - return edge === 'start' ? start : end - } - - return at - }, + point: (editor: Editor, at: Location, options?: EditorPointOptions) => Point /** * Create a mutable ref for a `Point` object, which will stay in sync as new * operations are applied to the editor. */ - - pointRef( + pointRef: ( editor: Editor, point: Point, - options: EditorPointRefOptions = {} - ): PointRef { - const { affinity = 'forward' } = options - const ref: PointRef = { - current: point, - affinity, - unref() { - const { current } = ref - const pointRefs = Editor.pointRefs(editor) - pointRefs.delete(ref) - ref.current = null - return current - }, - } - - const refs = Editor.pointRefs(editor) - refs.add(ref) - return ref - }, + options?: EditorPointRefOptions + ) => PointRef /** * Get the set of currently tracked point refs of the editor. */ - - pointRefs(editor: Editor): Set { - let refs = POINT_REFS.get(editor) - - if (!refs) { - refs = new Set() - POINT_REFS.set(editor, refs) - } - - return refs - }, + pointRefs: (editor: Editor) => Set /** * Return all the positions in `at` range where a `Point` can be placed. @@ -1361,297 +612,38 @@ export const Editor: EditorInterface = { * will not happen inside their content unless you pass in true for the * `voids` option, then iteration will occur. */ - - *positions( + positions: ( editor: Editor, - options: EditorPositionsOptions = {} - ): Generator { - const { - at = editor.selection, - unit = 'offset', - reverse = false, - voids = false, - ignoreNonSelectable = false, - } = options - - if (!at) { - return - } - - /** - * Algorithm notes: - * - * Each step `distance` is dynamic depending on the underlying text - * and the `unit` specified. Each step, e.g., a line or word, may - * span multiple text nodes, so we iterate through the text both on - * two levels in step-sync: - * - * `leafText` stores the text on a text leaf level, and is advanced - * through using the counters `leafTextOffset` and `leafTextRemaining`. - * - * `blockText` stores the text on a block level, and is shortened - * by `distance` every time it is advanced. - * - * We only maintain a window of one blockText and one leafText because - * a block node always appears before all of its leaf nodes. - */ - - const range = Editor.range(editor, at) - const [start, end] = Range.edges(range) - const first = reverse ? end : start - let isNewBlock = false - let blockText = '' - let distance = 0 // Distance for leafText to catch up to blockText. - let leafTextRemaining = 0 - let leafTextOffset = 0 - - // Iterate through all nodes in range, grabbing entire textual content - // of block nodes in blockText, and text nodes in leafText. - // Exploits the fact that nodes are sequenced in such a way that we first - // encounter the block node, then all of its text nodes, so when iterating - // through the blockText and leafText we just need to remember a window of - // one block node and leaf node, respectively. - for (const [node, path] of Editor.nodes(editor, { - at, - reverse, - voids, - ignoreNonSelectable, - })) { - /* - * ELEMENT NODE - Yield position(s) for voids, collect blockText for blocks - */ - if (Element.isElement(node)) { - // Void nodes are a special case, so by default we will always - // yield their first point. If the `voids` option is set to true, - // then we will iterate over their content. - if (!voids && (editor.isVoid(node) || editor.isElementReadOnly(node))) { - yield Editor.start(editor, path) - continue - } - - // Inline element nodes are ignored as they don't themselves - // contribute to `blockText` or `leafText` - their parent and - // children do. - if (editor.isInline(node)) continue - - // Block element node - set `blockText` to its text content. - if (Editor.hasInlines(editor, node)) { - // We always exhaust block nodes before encountering a new one: - // console.assert(blockText === '', - // `blockText='${blockText}' - `+ - // `not exhausted before new block node`, path) - - // Ensure range considered is capped to `range`, in the - // start/end edge cases where block extends beyond range. - // Equivalent to this, but presumably more performant: - // blockRange = Editor.range(editor, ...Editor.edges(editor, path)) - // blockRange = Range.intersection(range, blockRange) // intersect - // blockText = Editor.string(editor, blockRange, { voids }) - const e = Path.isAncestor(path, end.path) - ? end - : Editor.end(editor, path) - const s = Path.isAncestor(path, start.path) - ? start - : Editor.start(editor, path) - - blockText = Editor.string(editor, { anchor: s, focus: e }, { voids }) - isNewBlock = true - } - } - - /* - * TEXT LEAF NODE - Iterate through text content, yielding - * positions every `distance` offset according to `unit`. - */ - if (Text.isText(node)) { - const isFirst = Path.equals(path, first.path) - - // Proof that we always exhaust text nodes before encountering a new one: - // console.assert(leafTextRemaining <= 0, - // `leafTextRemaining=${leafTextRemaining} - `+ - // `not exhausted before new leaf text node`, path) - - // Reset `leafText` counters for new text node. - if (isFirst) { - leafTextRemaining = reverse - ? first.offset - : node.text.length - first.offset - leafTextOffset = first.offset // Works for reverse too. - } else { - leafTextRemaining = node.text.length - leafTextOffset = reverse ? leafTextRemaining : 0 - } - - // Yield position at the start of node (potentially). - if (isFirst || isNewBlock || unit === 'offset') { - yield { path, offset: leafTextOffset } - isNewBlock = false - } - - // Yield positions every (dynamically calculated) `distance` offset. - while (true) { - // If `leafText` has caught up with `blockText` (distance=0), - // and if blockText is exhausted, break to get another block node, - // otherwise advance blockText forward by the new `distance`. - if (distance === 0) { - if (blockText === '') break - distance = calcDistance(blockText, unit, reverse) - // Split the string at the previously found distance and use the - // remaining string for the next iteration. - blockText = splitByCharacterDistance( - blockText, - distance, - reverse - )[1] - } - - // Advance `leafText` by the current `distance`. - leafTextOffset = reverse - ? leafTextOffset - distance - : leafTextOffset + distance - leafTextRemaining = leafTextRemaining - distance - - // If `leafText` is exhausted, break to get a new leaf node - // and set distance to the overflow amount, so we'll (maybe) - // catch up to blockText in the next leaf text node. - if (leafTextRemaining < 0) { - distance = -leafTextRemaining - break - } - - // Successfully walked `distance` offsets through `leafText` - // to catch up with `blockText`, so we can reset `distance` - // and yield this position in this node. - distance = 0 - yield { path, offset: leafTextOffset } - } - } - } - // Proof that upon completion, we've exahusted both leaf and block text: - // console.assert(leafTextRemaining <= 0, "leafText wasn't exhausted") - // console.assert(blockText === '', "blockText wasn't exhausted") - - // Helper: - // Return the distance in offsets for a step of size `unit` on given string. - function calcDistance(text: string, unit: string, reverse?: boolean) { - if (unit === 'character') { - return getCharacterDistance(text, reverse) - } else if (unit === 'word') { - return getWordDistance(text, reverse) - } else if (unit === 'line' || unit === 'block') { - return text.length - } - return 1 - } - }, + options?: EditorPositionsOptions + ) => Generator /** * Get the matching node in the branch of the document before a location. */ - - previous( + previous: ( editor: Editor, - options: EditorPreviousOptions = {} - ): NodeEntry | undefined { - const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection } = options - - if (!at) { - return - } - - const pointBeforeLocation = Editor.before(editor, at, { voids }) - - if (!pointBeforeLocation) { - return - } - - const [, to] = Editor.first(editor, []) - - // The search location is from the start of the document to the path of - // the point before the location passed in - const span: Span = [pointBeforeLocation.path, to] - - if (Path.isPath(at) && at.length === 0) { - throw new Error(`Cannot get the previous node from the root node!`) - } - - if (match == null) { - if (Path.isPath(at)) { - const [parent] = Editor.parent(editor, at) - match = n => parent.children.includes(n) - } else { - match = () => true - } - } - - const [previous] = Editor.nodes(editor, { - reverse: true, - at: span, - match, - mode, - voids, - }) - - return previous - }, + options?: EditorPreviousOptions + ) => NodeEntry | undefined /** * Get a range of a location. */ - - range(editor: Editor, at: Location, to?: Location): Range { - if (Range.isRange(at) && !to) { - return at - } - - const start = Editor.start(editor, at) - const end = Editor.end(editor, to || at) - return { anchor: start, focus: end } - }, + range: (editor: Editor, at: Location, to?: Location) => Range /** * Create a mutable ref for a `Range` object, which will stay in sync as new * operations are applied to the editor. */ - - rangeRef( + rangeRef: ( editor: Editor, range: Range, - options: EditorRangeRefOptions = {} - ): RangeRef { - const { affinity = 'forward' } = options - const ref: RangeRef = { - current: range, - affinity, - unref() { - const { current } = ref - const rangeRefs = Editor.rangeRefs(editor) - rangeRefs.delete(ref) - ref.current = null - return current - }, - } - - const refs = Editor.rangeRefs(editor) - refs.add(ref) - return ref - }, + options?: EditorRangeRefOptions + ) => RangeRef /** * Get the set of currently tracked range refs of the editor. */ - - rangeRefs(editor: Editor): Set { - let refs = RANGE_REFS.get(editor) - - if (!refs) { - refs = new Set() - RANGE_REFS.set(editor, refs) - } - - return refs - }, + rangeRefs: (editor: Editor) => Set /** * Remove a custom property from all of the leaf text nodes in the current @@ -1660,10 +652,7 @@ export const Editor: EditorInterface = { * If the selection is currently collapsed, the removal will be stored on * `editor.marks` and applied to the text inserted next. */ - - removeMark(editor: Editor, key: string): void { - editor.removeMark(key) - }, + removeMark: (editor: Editor, key: string) => void /** * Manually set if the editor should currently be normalizing. @@ -1671,17 +660,12 @@ export const Editor: EditorInterface = { * Note: Using this incorrectly can leave the editor in an invalid state. * */ - setNormalizing(editor: Editor, isNormalizing: boolean): void { - NORMALIZING.set(editor, isNormalizing) - }, + setNormalizing: (editor: Editor, isNormalizing: boolean) => void /** * Get the start point of a location. */ - - start(editor: Editor, at: Location): Point { - return Editor.point(editor, at, { edge: 'start' }) - }, + start: (editor: Editor, at: Location) => Point /** * Get the text string content of a location. @@ -1689,117 +673,276 @@ export const Editor: EditorInterface = { * Note: by default the text of void nodes is considered to be an empty * string, regardless of content, unless you pass in true for the voids option */ - - string( + string: ( editor: Editor, at: Location, - options: EditorStringOptions = {} - ): string { - const { voids = false } = options - const range = Editor.range(editor, at) - const [start, end] = Range.edges(range) - let text = '' - - for (const [node, path] of Editor.nodes(editor, { - at: range, - match: Text.isText, - voids, - })) { - let t = node.text - - if (Path.equals(path, end.path)) { - t = t.slice(0, end.offset) - } - - if (Path.equals(path, start.path)) { - t = t.slice(start.offset) - } - - text += t - } - - return text - }, + options?: EditorStringOptions + ) => string /** * Convert a range into a non-hanging one. */ - - unhangRange( + unhangRange: ( editor: Editor, range: Range, - options: EditorUnhangRangeOptions = {} - ): Range { - const { voids = false } = options - let [start, end] = Range.edges(range) - - // PERF: exit early if we can guarantee that the range isn't hanging. - if ( - start.offset !== 0 || - end.offset !== 0 || - Range.isCollapsed(range) || - Path.hasPrevious(end.path) - ) { - return range - } - - const endBlock = Editor.above(editor, { - at: end, - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - voids, - }) - const blockPath = endBlock ? endBlock[1] : [] - const first = Editor.start(editor, start) - const before = { anchor: first, focus: end } - let skip = true - - for (const [node, path] of Editor.nodes(editor, { - at: before, - match: Text.isText, - reverse: true, - voids, - })) { - if (skip) { - skip = false - continue - } - - if (node.text !== '' || Path.isBefore(path, blockPath)) { - end = { path, offset: node.text.length } - break - } - } - - return { anchor: start, focus: end } - }, + options?: EditorUnhangRangeOptions + ) => Range /** * Match a void node in the current branch of the editor. */ - - void( + void: ( editor: Editor, - options: EditorVoidOptions = {} - ): NodeEntry | undefined { - return Editor.above(editor, { - ...options, - match: n => Element.isElement(n) && Editor.isVoid(editor, n), - }) - }, + options?: EditorVoidOptions + ) => NodeEntry | undefined /** * Call a function, deferring normalization until after it completes. */ + withoutNormalizing: (editor: Editor, fn: () => void) => void +} - withoutNormalizing(editor: Editor, fn: () => void): void { - const value = Editor.isNormalizing(editor) - Editor.setNormalizing(editor, false) - try { - fn() - } finally { - Editor.setNormalizing(editor, value) - } - Editor.normalize(editor) +// eslint-disable-next-line no-redeclare +export const Editor: EditorInterface = { + above(editor, options) { + return editor.above(options) + }, + + addMark(editor, key, value) { + editor.addMark(key, value) + }, + + after(editor, at, options) { + return editor.after(at, options) + }, + + before(editor, at, options) { + return editor.before(at, options) + }, + + deleteBackward(editor, options = {}) { + const { unit = 'character' } = options + editor.deleteBackward(unit) + }, + + deleteForward(editor, options = {}) { + const { unit = 'character' } = options + editor.deleteForward(unit) + }, + + deleteFragment(editor, options) { + editor.deleteFragment(options) + }, + + edges(editor, at) { + return editor.edges(at) + }, + + elementReadOnly(editor: Editor, options: EditorElementReadOnlyOptions = {}) { + return editor.elementReadOnly(options) + }, + + end(editor, at) { + return editor.end(at) + }, + + first(editor, at) { + return editor.first(at) + }, + + fragment(editor, at) { + return editor.fragment(at) + }, + + hasBlocks(editor, element) { + return editor.hasBlocks(element) + }, + + hasInlines(editor, element) { + return editor.hasInlines(element) + }, + + hasPath(editor, path) { + return editor.hasPath(path) + }, + + hasTexts(editor, element) { + return editor.hasTexts(element) + }, + + insertBreak(editor) { + editor.insertBreak() + }, + + insertFragment(editor, fragment) { + editor.insertFragment(fragment) + }, + + insertNode(editor, node) { + editor.insertNode(node) + }, + + insertSoftBreak(editor) { + editor.insertSoftBreak() + }, + + insertText(editor, text) { + editor.insertText(text) + }, + + isBlock(editor, value) { + return editor.isBlock(value) + }, + + isEdge(editor, point, at) { + return editor.isEdge(point, at) + }, + + isEditor(value: any): value is Editor { + return isEditor(value) + }, + + isElementReadOnly(editor, element) { + return editor.isElementReadOnly(element) + }, + + isEmpty(editor, element) { + return editor.isEmpty(element) + }, + + isEnd(editor, point, at) { + return editor.isEnd(point, at) + }, + + isInline(editor, value) { + return editor.isInline(value) + }, + + isNormalizing(editor) { + return editor.isNormalizing() + }, + + isSelectable(editor: Editor, value: Element) { + return editor.isSelectable(value) + }, + + isStart(editor, point, at) { + return editor.isStart(point, at) + }, + + isVoid(editor, value) { + return editor.isVoid(value) + }, + + last(editor, at) { + return editor.last(at) + }, + + leaf(editor, at, options) { + return editor.leaf(at, options) + }, + + levels(editor, options) { + return editor.levels(options) + }, + + marks(editor) { + return editor.getMarks() + }, + + next( + editor: Editor, + options?: EditorNextOptions + ): NodeEntry | undefined { + return editor.next(options) + }, + + node(editor, at, options) { + return editor.node(at, options) + }, + + nodes(editor, options) { + return editor.nodes(options) + }, + + normalize(editor, options) { + editor.normalize(options) + }, + + parent(editor, at, options) { + return editor.parent(at, options) + }, + + path(editor, at, options) { + return editor.path(at, options) + }, + + pathRef(editor, path, options) { + return editor.pathRef(path, options) + }, + + pathRefs(editor) { + return editor.pathRefs() + }, + + point(editor, at, options) { + return editor.point(at, options) + }, + + pointRef(editor, point, options) { + return editor.pointRef(point, options) + }, + + pointRefs(editor) { + return editor.pointRefs() + }, + + positions(editor, options) { + return editor.positions(options) + }, + + previous(editor, options) { + return editor.previous(options) + }, + + range(editor, at, to) { + return editor.range(at, to) + }, + + rangeRef(editor, range, options) { + return editor.rangeRef(range, options) + }, + + rangeRefs(editor) { + return editor.rangeRefs() + }, + + removeMark(editor, key) { + editor.removeMark(key) + }, + + setNormalizing(editor, isNormalizing) { + editor.setNormalizing(isNormalizing) + }, + + start(editor, at) { + return editor.start(at) + }, + + string(editor, at, options) { + return editor.string(at, options) + }, + + unhangRange(editor, range, options) { + return editor.unhangRange(range, options) + }, + + void(editor, options) { + return editor.void(options) + }, + + withoutNormalizing(editor, fn: () => void) { + editor.withoutNormalizing(fn) }, } diff --git a/packages/slate/src/interfaces/element.ts b/packages/slate/src/interfaces/element.ts index fca893fc3..a2c0f3644 100644 --- a/packages/slate/src/interfaces/element.ts +++ b/packages/slate/src/interfaces/element.ts @@ -1,5 +1,5 @@ import { isPlainObject } from 'is-plain-object' -import { Editor, Node, Path, Descendant, ExtendedType, Ancestor } from '..' +import { Ancestor, Descendant, Editor, ExtendedType, Node, Path } from '..' /** * `Element` objects are a type of node in a Slate document that contain other @@ -14,15 +14,42 @@ export interface BaseElement { export type Element = ExtendedType<'Element', BaseElement> export interface ElementInterface { + /** + * Check if a value implements the 'Ancestor' interface. + */ isAncestor: (value: any) => value is Ancestor + + /** + * Check if a value implements the `Element` interface. + */ isElement: (value: any) => value is Element + + /** + * Check if a value is an array of `Element` objects. + */ isElementList: (value: any) => value is Element[] + + /** + * Check if a set of props is a partial of Element. + */ isElementProps: (props: any) => props is Partial + + /** + * Check if a value implements the `Element` interface and has elementKey with selected value. + * Default it check to `type` key value + */ isElementType: ( value: any, elementVal: string, elementKey?: string ) => value is T + + /** + * Check if an element matches set of properties. + * + * Note: this checks custom properties, and it does not ensure that any + * children are equivalent. + */ matches: (element: Element, props: Partial) => boolean } @@ -39,40 +66,20 @@ const isElement = (value: any): value is Element => { // eslint-disable-next-line no-redeclare export const Element: ElementInterface = { - /** - * Check if a value implements the 'Ancestor' interface. - */ - isAncestor(value: any): value is Ancestor { return isPlainObject(value) && Node.isNodeList(value.children) }, - /** - * Check if a value implements the `Element` interface. - */ - isElement, - /** - * Check if a value is an array of `Element` objects. - */ isElementList(value: any): value is Element[] { return Array.isArray(value) && value.every(val => Element.isElement(val)) }, - /** - * Check if a set of props is a partial of Element. - */ - isElementProps(props: any): props is Partial { return (props as Partial).children !== undefined }, - /** - * Check if a value implements the `Element` interface and has elementKey with selected value. - * Default it check to `type` key value - */ - isElementType: ( value: any, elementVal: string, @@ -81,13 +88,6 @@ export const Element: ElementInterface = { return isElement(value) && value[elementKey] === elementVal }, - /** - * Check if an element matches set of properties. - * - * Note: this checks custom properties, and it does not ensure that any - * children are equivalent. - */ - matches(element: Element, props: Partial): boolean { for (const key in props) { if (key === 'children') { @@ -107,5 +107,4 @@ export const Element: ElementInterface = { * `ElementEntry` objects refer to an `Element` and the `Path` where it can be * found inside a root node. */ - export type ElementEntry = [Element, Path] diff --git a/packages/slate/src/interfaces/index.ts b/packages/slate/src/interfaces/index.ts new file mode 100644 index 000000000..922b45cce --- /dev/null +++ b/packages/slate/src/interfaces/index.ts @@ -0,0 +1,14 @@ +export * from './editor' +export * from './element' +export * from './location' +export * from './node' +export * from './operation' +export * from './path-ref' +export * from './path' +export * from './point-ref' +export * from './point' +export * from './range-ref' +export * from './range' +export * from './scrubber' +export * from './text' +export * from './transforms/index' diff --git a/packages/slate/src/interfaces/location.ts b/packages/slate/src/interfaces/location.ts index aaead6326..df16d8baa 100644 --- a/packages/slate/src/interfaces/location.ts +++ b/packages/slate/src/interfaces/location.ts @@ -12,15 +12,14 @@ import { Path, Point, Range } from '..' export type Location = Path | Point | Range export interface LocationInterface { + /** + * Check if a value implements the `Location` interface. + */ isLocation: (value: any) => value is Location } // eslint-disable-next-line no-redeclare export const Location: LocationInterface = { - /** - * Check if a value implements the `Location` interface. - */ - isLocation(value: any): value is Location { return Path.isPath(value) || Point.isPoint(value) || Range.isRange(value) }, @@ -34,15 +33,14 @@ export const Location: LocationInterface = { export type Span = [Path, Path] export interface SpanInterface { + /** + * Check if a value implements the `Span` interface. + */ isSpan: (value: any) => value is Span } // eslint-disable-next-line no-redeclare export const Span: SpanInterface = { - /** - * Check if a value implements the `Span` interface. - */ - isSpan(value: any): value is Span { return ( Array.isArray(value) && value.length === 2 && value.every(Path.isPath) diff --git a/packages/slate/src/interfaces/node.ts b/packages/slate/src/interfaces/node.ts index a645bca5f..05bea24e4 100644 --- a/packages/slate/src/interfaces/node.ts +++ b/packages/slate/src/interfaces/node.ts @@ -1,5 +1,5 @@ import { produce } from 'immer' -import { Editor, Path, Range, Text, Scrubber } from '..' +import { Editor, Path, Range, Scrubber, Text } from '..' import { Element, ElementEntry } from './element' /** @@ -51,49 +51,155 @@ export interface NodeTextsOptions { } export interface NodeInterface { + /** + * Get the node at a specific path, asserting that it's an ancestor node. + */ ancestor: (root: Node, path: Path) => Ancestor + + /** + * Return a generator of all the ancestor nodes above a specific path. + * + * By default the order is top-down, from highest to lowest ancestor in + * the tree, but you can pass the `reverse: true` option to go bottom-up. + */ ancestors: ( root: Node, path: Path, options?: NodeAncestorsOptions ) => Generator, void, undefined> + + /** + * Get the child of a node at a specific index. + */ child: (root: Node, index: number) => Descendant + + /** + * Iterate over the children of a node at a specific path. + */ children: ( root: Node, path: Path, options?: NodeChildrenOptions ) => Generator, void, undefined> + + /** + * Get an entry for the common ancesetor node of two paths. + */ common: (root: Node, path: Path, another: Path) => NodeEntry + + /** + * Get the node at a specific path, asserting that it's a descendant node. + */ descendant: (root: Node, path: Path) => Descendant + + /** + * Return a generator of all the descendant node entries inside a root node. + */ descendants: ( root: Node, options?: NodeDescendantsOptions ) => Generator, void, undefined> + + /** + * Return a generator of all the element nodes inside a root node. Each iteration + * will return an `ElementEntry` tuple consisting of `[Element, Path]`. If the + * root node is an element it will be included in the iteration as well. + */ elements: ( root: Node, options?: NodeElementsOptions ) => Generator + + /** + * Extract props from a Node. + */ extractProps: (node: Node) => NodeProps + + /** + * Get the first node entry in a root node from a path. + */ first: (root: Node, path: Path) => NodeEntry + + /** + * Get the sliced fragment represented by a range inside a root node. + */ fragment: (root: Node, range: Range) => Descendant[] + + /** + * Get the descendant node referred to by a specific path. If the path is an + * empty array, it refers to the root node itself. + */ get: (root: Node, path: Path) => Node + + /** + * Check if a descendant node exists at a specific path. + */ has: (root: Node, path: Path) => boolean + + /** + * Check if a value implements the `Node` interface. + */ isNode: (value: any) => value is Node + + /** + * Check if a value is a list of `Node` objects. + */ isNodeList: (value: any) => value is Node[] + + /** + * Get the last node entry in a root node from a path. + */ last: (root: Node, path: Path) => NodeEntry + + /** + * Get the node at a specific path, ensuring it's a leaf text node. + */ leaf: (root: Node, path: Path) => Text + + /** + * Return a generator of the in a branch of the tree, from a specific path. + * + * By default the order is top-down, from highest to lowest node in the tree, + * but you can pass the `reverse: true` option to go bottom-up. + */ levels: ( root: Node, path: Path, options?: NodeLevelsOptions ) => Generator + + /** + * Check if a node matches a set of props. + */ matches: (node: Node, props: Partial) => boolean + + /** + * Return a generator of all the node entries of a root node. Each entry is + * returned as a `[Node, Path]` tuple, with the path referring to the node's + * position inside the root node. + */ nodes: ( root: Node, options?: NodeNodesOptions ) => Generator + + /** + * Get the parent of a node at a specific path. + */ parent: (root: Node, path: Path) => Ancestor + + /** + * Get the concatenated text string of a node's content. + * + * Note that this will not include spaces or line breaks between block nodes. + * It is not a user-facing string, but a string for performing offset-related + * computations for a node. + */ string: (node: Node) => string + + /** + * Return a generator of all leaf text nodes in a root node. + */ texts: ( root: Node, options?: NodeTextsOptions @@ -104,10 +210,6 @@ const IS_NODE_LIST_CACHE = new WeakMap() // eslint-disable-next-line no-redeclare export const Node: NodeInterface = { - /** - * Get the node at a specific path, asserting that it's an ancestor node. - */ - ancestor(root: Node, path: Path): Ancestor { const node = Node.get(root, path) @@ -122,13 +224,6 @@ export const Node: NodeInterface = { return node }, - /** - * Return a generator of all the ancestor nodes above a specific path. - * - * By default the order is top-down, from highest to lowest ancestor in - * the tree, but you can pass the `reverse: true` option to go bottom-up. - */ - *ancestors( root: Node, path: Path, @@ -141,10 +236,6 @@ export const Node: NodeInterface = { } }, - /** - * Get the child of a node at a specific index. - */ - child(root: Node, index: number): Descendant { if (Text.isText(root)) { throw new Error( @@ -165,10 +256,6 @@ export const Node: NodeInterface = { return c }, - /** - * Iterate over the children of a node at a specific path. - */ - *children( root: Node, path: Path, @@ -187,20 +274,12 @@ export const Node: NodeInterface = { } }, - /** - * Get an entry for the common ancesetor node of two paths. - */ - common(root: Node, path: Path, another: Path): NodeEntry { const p = Path.common(path, another) const n = Node.get(root, p) return [n, p] }, - /** - * Get the node at a specific path, asserting that it's a descendant node. - */ - descendant(root: Node, path: Path): Descendant { const node = Node.get(root, path) @@ -215,10 +294,6 @@ export const Node: NodeInterface = { return node }, - /** - * Return a generator of all the descendant node entries inside a root node. - */ - *descendants( root: Node, options: NodeDescendantsOptions = {} @@ -232,12 +307,6 @@ export const Node: NodeInterface = { } }, - /** - * Return a generator of all the element nodes inside a root node. Each iteration - * will return an `ElementEntry` tuple consisting of `[Element, Path]`. If the - * root node is an element it will be included in the iteration as well. - */ - *elements( root: Node, options: NodeElementsOptions = {} @@ -249,10 +318,6 @@ export const Node: NodeInterface = { } }, - /** - * Extract props from a Node. - */ - extractProps(node: Node): NodeProps { if (Element.isAncestor(node)) { const { children, ...properties } = node @@ -265,10 +330,6 @@ export const Node: NodeInterface = { } }, - /** - * Get the first node entry in a root node from a path. - */ - first(root: Node, path: Path): NodeEntry { const p = path.slice() let n = Node.get(root, p) @@ -285,10 +346,6 @@ export const Node: NodeInterface = { return [n, p] }, - /** - * Get the sliced fragment represented by a range inside a root node. - */ - fragment(root: Node, range: Range): Descendant[] { if (Text.isText(root)) { throw new Error( @@ -331,11 +388,6 @@ export const Node: NodeInterface = { return newRoot.children }, - /** - * Get the descendant node referred to by a specific path. If the path is an - * empty array, it refers to the root node itself. - */ - get(root: Node, path: Path): Node { let node = root @@ -356,10 +408,6 @@ export const Node: NodeInterface = { return node }, - /** - * Check if a descendant node exists at a specific path. - */ - has(root: Node, path: Path): boolean { let node = root @@ -376,20 +424,12 @@ export const Node: NodeInterface = { return true }, - /** - * Check if a value implements the `Node` interface. - */ - isNode(value: any): value is Node { return ( Text.isText(value) || Element.isElement(value) || Editor.isEditor(value) ) }, - /** - * Check if a value is a list of `Node` objects. - */ - isNodeList(value: any): value is Node[] { if (!Array.isArray(value)) { return false @@ -403,10 +443,6 @@ export const Node: NodeInterface = { return isNodeList }, - /** - * Get the last node entry in a root node from a path. - */ - last(root: Node, path: Path): NodeEntry { const p = path.slice() let n = Node.get(root, p) @@ -424,10 +460,6 @@ export const Node: NodeInterface = { return [n, p] }, - /** - * Get the node at a specific path, ensuring it's a leaf text node. - */ - leaf(root: Node, path: Path): Text { const node = Node.get(root, path) @@ -442,13 +474,6 @@ export const Node: NodeInterface = { return node }, - /** - * Return a generator of the in a branch of the tree, from a specific path. - * - * By default the order is top-down, from highest to lowest node in the tree, - * but you can pass the `reverse: true` option to go bottom-up. - */ - *levels( root: Node, path: Path, @@ -460,10 +485,6 @@ export const Node: NodeInterface = { } }, - /** - * Check if a node matches a set of props. - */ - matches(node: Node, props: Partial): boolean { return ( (Element.isElement(node) && @@ -475,12 +496,6 @@ export const Node: NodeInterface = { ) }, - /** - * Return a generator of all the node entries of a root node. Each entry is - * returned as a `[Node, Path]` tuple, with the path referring to the node's - * position inside the root node. - */ - *nodes( root: Node, options: NodeNodesOptions = {} @@ -550,10 +565,6 @@ export const Node: NodeInterface = { } }, - /** - * Get the parent of a node at a specific path. - */ - parent(root: Node, path: Path): Ancestor { const parentPath = Path.parent(path) const p = Node.get(root, parentPath) @@ -567,14 +578,6 @@ export const Node: NodeInterface = { return p }, - /** - * Get the concatenated text string of a node's content. - * - * Note that this will not include spaces or line breaks between block nodes. - * It is not a user-facing string, but a string for performing offset-related - * computations for a node. - */ - string(node: Node): string { if (Text.isText(node)) { return node.text @@ -583,10 +586,6 @@ export const Node: NodeInterface = { } }, - /** - * Return a generator of all leaf text nodes in a root node. - */ - *texts( root: Node, options: NodeTextsOptions = {} diff --git a/packages/slate/src/interfaces/operation.ts b/packages/slate/src/interfaces/operation.ts index 85871f113..0b2a6cd3f 100644 --- a/packages/slate/src/interfaces/operation.ts +++ b/packages/slate/src/interfaces/operation.ts @@ -139,28 +139,44 @@ export type BaseOperation = NodeOperation | SelectionOperation | TextOperation export type Operation = ExtendedType<'Operation', BaseOperation> export interface OperationInterface { + /** + * Check if a value is a `NodeOperation` object. + */ isNodeOperation: (value: any) => value is NodeOperation + + /** + * Check if a value is an `Operation` object. + */ isOperation: (value: any) => value is Operation + + /** + * Check if a value is a list of `Operation` objects. + */ isOperationList: (value: any) => value is Operation[] + + /** + * Check if a value is a `SelectionOperation` object. + */ isSelectionOperation: (value: any) => value is SelectionOperation + + /** + * Check if a value is a `TextOperation` object. + */ isTextOperation: (value: any) => value is TextOperation + + /** + * Invert an operation, returning a new operation that will exactly undo the + * original when applied. + */ inverse: (op: Operation) => Operation } // eslint-disable-next-line no-redeclare export const Operation: OperationInterface = { - /** - * Check if a value is a `NodeOperation` object. - */ - isNodeOperation(value: any): value is NodeOperation { return Operation.isOperation(value) && value.type.endsWith('_node') }, - /** - * Check if a value is an `Operation` object. - */ - isOperation(value: any): value is Operation { if (!isPlainObject(value)) { return false @@ -215,37 +231,20 @@ export const Operation: OperationInterface = { } }, - /** - * Check if a value is a list of `Operation` objects. - */ - isOperationList(value: any): value is Operation[] { return ( Array.isArray(value) && value.every(val => Operation.isOperation(val)) ) }, - /** - * Check if a value is a `SelectionOperation` object. - */ - isSelectionOperation(value: any): value is SelectionOperation { return Operation.isOperation(value) && value.type.endsWith('_selection') }, - /** - * Check if a value is a `TextOperation` object. - */ - isTextOperation(value: any): value is TextOperation { return Operation.isOperation(value) && value.type.endsWith('_text') }, - /** - * Invert an operation, returning a new operation that will exactly undo the - * original when applied. - */ - inverse(op: Operation): Operation { switch (op.type) { case 'insert_node': { diff --git a/packages/slate/src/interfaces/path-ref.ts b/packages/slate/src/interfaces/path-ref.ts index 47e29b773..fe566d53b 100644 --- a/packages/slate/src/interfaces/path-ref.ts +++ b/packages/slate/src/interfaces/path-ref.ts @@ -13,15 +13,14 @@ export interface PathRef { } export interface PathRefInterface { + /** + * Transform the path ref's current value by an operation. + */ transform: (ref: PathRef, op: Operation) => void } // eslint-disable-next-line no-redeclare export const PathRef: PathRefInterface = { - /** - * Transform the path ref's current value by an operation. - */ - transform(ref: PathRef, op: Operation): void { const { current, affinity } = ref diff --git a/packages/slate/src/interfaces/path.ts b/packages/slate/src/interfaces/path.ts index 729097c22..2db9eac94 100644 --- a/packages/slate/src/interfaces/path.ts +++ b/packages/slate/src/interfaces/path.ts @@ -2,11 +2,11 @@ import { InsertNodeOperation, MergeNodeOperation, MoveNodeOperation, + Operation, RemoveNodeOperation, SplitNodeOperation, - Operation, } from '..' -import { TextDirection } from './types' +import { TextDirection } from '../types/types' /** * `Path` arrays are a list of indexes that describe a node's exact position in @@ -29,25 +29,120 @@ export interface PathTransformOptions { } export interface PathInterface { + /** + * Get a list of ancestor paths for a given path. + * + * The paths are sorted from shallowest to deepest ancestor. However, if the + * `reverse: true` option is passed, they are reversed. + */ ancestors: (path: Path, options?: PathAncestorsOptions) => Path[] + + /** + * Get the common ancestor path of two paths. + */ common: (path: Path, another: Path) => Path + + /** + * Compare a path to another, returning an integer indicating whether the path + * was before, at, or after the other. + * + * Note: Two paths of unequal length can still receive a `0` result if one is + * directly above or below the other. If you want exact matching, use + * [[Path.equals]] instead. + */ compare: (path: Path, another: Path) => -1 | 0 | 1 + + /** + * Check if a path ends after one of the indexes in another. + */ endsAfter: (path: Path, another: Path) => boolean + + /** + * Check if a path ends at one of the indexes in another. + */ endsAt: (path: Path, another: Path) => boolean + + /** + * Check if a path ends before one of the indexes in another. + */ endsBefore: (path: Path, another: Path) => boolean + + /** + * Check if a path is exactly equal to another. + */ equals: (path: Path, another: Path) => boolean + + /** + * Check if the path of previous sibling node exists + */ hasPrevious: (path: Path) => boolean + + /** + * Check if a path is after another. + */ isAfter: (path: Path, another: Path) => boolean + + /** + * Check if a path is an ancestor of another. + */ isAncestor: (path: Path, another: Path) => boolean + + /** + * Check if a path is before another. + */ isBefore: (path: Path, another: Path) => boolean + + /** + * Check if a path is a child of another. + */ isChild: (path: Path, another: Path) => boolean + + /** + * Check if a path is equal to or an ancestor of another. + */ isCommon: (path: Path, another: Path) => boolean + + /** + * Check if a path is a descendant of another. + */ isDescendant: (path: Path, another: Path) => boolean + + /** + * Check if a path is the parent of another. + */ isParent: (path: Path, another: Path) => boolean + + /** + * Check is a value implements the `Path` interface. + */ isPath: (value: any) => value is Path + + /** + * Check if a path is a sibling of another. + */ isSibling: (path: Path, another: Path) => boolean + + /** + * Get a list of paths at every level down to a path. Note: this is the same + * as `Path.ancestors`, but including the path itself. + * + * The paths are sorted from shallowest to deepest. However, if the `reverse: + * true` option is passed, they are reversed. + */ levels: (path: Path, options?: PathLevelsOptions) => Path[] + + /** + * Given a path, get the path to the next sibling node. + */ next: (path: Path) => Path + + /** + * Returns whether this operation can affect paths or not. Used as an + * optimization when updating dirty paths during normalization + * + * NOTE: This *must* be kept in sync with the implementation of 'transform' + * below + */ operationCanTransformPath: ( operation: Operation ) => operation is @@ -56,9 +151,25 @@ export interface PathInterface { | MergeNodeOperation | SplitNodeOperation | MoveNodeOperation + + /** + * Given a path, return a new path referring to the parent node above it. + */ parent: (path: Path) => Path + + /** + * Given a path, get the path to the previous sibling node. + */ previous: (path: Path) => Path + + /** + * Get a path relative to an ancestor. + */ relative: (path: Path, ancestor: Path) => Path + + /** + * Transform a path by an operation. + */ transform: ( path: Path, operation: Operation, @@ -68,13 +179,6 @@ export interface PathInterface { // eslint-disable-next-line no-redeclare export const Path: PathInterface = { - /** - * Get a list of ancestor paths for a given path. - * - * The paths are sorted from shallowest to deepest ancestor. However, if the - * `reverse: true` option is passed, they are reversed. - */ - ancestors(path: Path, options: PathAncestorsOptions = {}): Path[] { const { reverse = false } = options let paths = Path.levels(path, options) @@ -88,10 +192,6 @@ export const Path: PathInterface = { return paths }, - /** - * Get the common ancestor path of two paths. - */ - common(path: Path, another: Path): Path { const common: Path = [] @@ -109,15 +209,6 @@ export const Path: PathInterface = { return common }, - /** - * Compare a path to another, returning an integer indicating whether the path - * was before, at, or after the other. - * - * Note: Two paths of unequal length can still receive a `0` result if one is - * directly above or below the other. If you want exact matching, use - * [[Path.equals]] instead. - */ - compare(path: Path, another: Path): -1 | 0 | 1 { const min = Math.min(path.length, another.length) @@ -129,10 +220,6 @@ export const Path: PathInterface = { return 0 }, - /** - * Check if a path ends after one of the indexes in another. - */ - endsAfter(path: Path, another: Path): boolean { const i = path.length - 1 const as = path.slice(0, i) @@ -142,10 +229,6 @@ export const Path: PathInterface = { return Path.equals(as, bs) && av > bv }, - /** - * Check if a path ends at one of the indexes in another. - */ - endsAt(path: Path, another: Path): boolean { const i = path.length const as = path.slice(0, i) @@ -153,10 +236,6 @@ export const Path: PathInterface = { return Path.equals(as, bs) }, - /** - * Check if a path ends before one of the indexes in another. - */ - endsBefore(path: Path, another: Path): boolean { const i = path.length - 1 const as = path.slice(0, i) @@ -166,88 +245,48 @@ export const Path: PathInterface = { return Path.equals(as, bs) && av < bv }, - /** - * Check if a path is exactly equal to another. - */ - equals(path: Path, another: Path): boolean { return ( path.length === another.length && path.every((n, i) => n === another[i]) ) }, - /** - * Check if the path of previous sibling node exists - */ - hasPrevious(path: Path): boolean { return path[path.length - 1] > 0 }, - /** - * Check if a path is after another. - */ - isAfter(path: Path, another: Path): boolean { return Path.compare(path, another) === 1 }, - /** - * Check if a path is an ancestor of another. - */ - isAncestor(path: Path, another: Path): boolean { return path.length < another.length && Path.compare(path, another) === 0 }, - /** - * Check if a path is before another. - */ - isBefore(path: Path, another: Path): boolean { return Path.compare(path, another) === -1 }, - /** - * Check if a path is a child of another. - */ - isChild(path: Path, another: Path): boolean { return ( path.length === another.length + 1 && Path.compare(path, another) === 0 ) }, - /** - * Check if a path is equal to or an ancestor of another. - */ - isCommon(path: Path, another: Path): boolean { return path.length <= another.length && Path.compare(path, another) === 0 }, - /** - * Check if a path is a descendant of another. - */ - isDescendant(path: Path, another: Path): boolean { return path.length > another.length && Path.compare(path, another) === 0 }, - /** - * Check if a path is the parent of another. - */ - isParent(path: Path, another: Path): boolean { return ( path.length + 1 === another.length && Path.compare(path, another) === 0 ) }, - /** - * Check is a value implements the `Path` interface. - */ - isPath(value: any): value is Path { return ( Array.isArray(value) && @@ -255,10 +294,6 @@ export const Path: PathInterface = { ) }, - /** - * Check if a path is a sibling of another. - */ - isSibling(path: Path, another: Path): boolean { if (path.length !== another.length) { return false @@ -271,14 +306,6 @@ export const Path: PathInterface = { return al !== bl && Path.equals(as, bs) }, - /** - * Get a list of paths at every level down to a path. Note: this is the same - * as `Path.ancestors`, but including the path itself. - * - * The paths are sorted from shallowest to deepest. However, if the `reverse: - * true` option is passed, they are reversed. - */ - levels(path: Path, options: PathLevelsOptions = {}): Path[] { const { reverse = false } = options const list: Path[] = [] @@ -294,10 +321,6 @@ export const Path: PathInterface = { return list }, - /** - * Given a path, get the path to the next sibling node. - */ - next(path: Path): Path { if (path.length === 0) { throw new Error( @@ -309,13 +332,6 @@ export const Path: PathInterface = { return path.slice(0, -1).concat(last + 1) }, - /** - * Returns whether this operation can affect paths or not. Used as an - * optimization when updating dirty paths during normalization - * - * NOTE: This *must* be kept in sync with the implementation of 'transform' - * below - */ operationCanTransformPath( operation: Operation ): operation is @@ -336,10 +352,6 @@ export const Path: PathInterface = { } }, - /** - * Given a path, return a new path referring to the parent node above it. - */ - parent(path: Path): Path { if (path.length === 0) { throw new Error(`Cannot get the parent path of the root path [${path}].`) @@ -348,10 +360,6 @@ export const Path: PathInterface = { return path.slice(0, -1) }, - /** - * Given a path, get the path to the previous sibling node. - */ - previous(path: Path): Path { if (path.length === 0) { throw new Error( @@ -370,10 +378,6 @@ export const Path: PathInterface = { return path.slice(0, -1).concat(last - 1) }, - /** - * Get a path relative to an ancestor. - */ - relative(path: Path, ancestor: Path): Path { if (!Path.isAncestor(ancestor, path) && !Path.equals(path, ancestor)) { throw new Error( @@ -384,10 +388,6 @@ export const Path: PathInterface = { return path.slice(ancestor.length) }, - /** - * Transform a path by an operation. - */ - transform( path: Path | null, operation: Operation, diff --git a/packages/slate/src/interfaces/point-ref.ts b/packages/slate/src/interfaces/point-ref.ts index c7db0551b..5eead25d0 100644 --- a/packages/slate/src/interfaces/point-ref.ts +++ b/packages/slate/src/interfaces/point-ref.ts @@ -1,5 +1,5 @@ import { Operation, Point } from '..' -import { TextDirection } from './types' +import { TextDirection } from '../types/types' /** * `PointRef` objects keep a specific point in a document synced over time as new @@ -14,15 +14,14 @@ export interface PointRef { } export interface PointRefInterface { + /** + * Transform the point ref's current value by an operation. + */ transform: (ref: PointRef, op: Operation) => void } // eslint-disable-next-line no-redeclare export const PointRef: PointRefInterface = { - /** - * Transform the point ref's current value by an operation. - */ - transform(ref: PointRef, op: Operation): void { const { current, affinity } = ref diff --git a/packages/slate/src/interfaces/point.ts b/packages/slate/src/interfaces/point.ts index 408670e3b..2d8fa81e8 100644 --- a/packages/slate/src/interfaces/point.ts +++ b/packages/slate/src/interfaces/point.ts @@ -1,7 +1,7 @@ import { isPlainObject } from 'is-plain-object' import { produce } from 'immer' import { ExtendedType, Operation, Path } from '..' -import { TextDirection } from './types' +import { TextDirection } from '../types/types' /** * `Point` objects refer to a specific location in a text node in a Slate @@ -22,11 +22,35 @@ export interface PointTransformOptions { } export interface PointInterface { + /** + * Compare a point to another, returning an integer indicating whether the + * point was before, at, or after the other. + */ compare: (point: Point, another: Point) => -1 | 0 | 1 + + /** + * Check if a point is after another. + */ isAfter: (point: Point, another: Point) => boolean + + /** + * Check if a point is before another. + */ isBefore: (point: Point, another: Point) => boolean + + /** + * Check if a point is exactly equal to another. + */ equals: (point: Point, another: Point) => boolean + + /** + * Check if a value implements the `Point` interface. + */ isPoint: (value: any) => value is Point + + /** + * Transform a point by an operation. + */ transform: ( point: Point, op: Operation, @@ -36,11 +60,6 @@ export interface PointInterface { // eslint-disable-next-line no-redeclare export const Point: PointInterface = { - /** - * Compare a point to another, returning an integer indicating whether the - * point was before, at, or after the other. - */ - compare(point: Point, another: Point): -1 | 0 | 1 { const result = Path.compare(point.path, another.path) @@ -53,26 +72,14 @@ export const Point: PointInterface = { return result }, - /** - * Check if a point is after another. - */ - isAfter(point: Point, another: Point): boolean { return Point.compare(point, another) === 1 }, - /** - * Check if a point is before another. - */ - isBefore(point: Point, another: Point): boolean { return Point.compare(point, another) === -1 }, - /** - * Check if a point is exactly equal to another. - */ - equals(point: Point, another: Point): boolean { // PERF: ensure the offsets are equal first since they are cheaper to check. return ( @@ -80,10 +87,6 @@ export const Point: PointInterface = { ) }, - /** - * Check if a value implements the `Point` interface. - */ - isPoint(value: any): value is Point { return ( isPlainObject(value) && @@ -92,10 +95,6 @@ export const Point: PointInterface = { ) }, - /** - * Transform a point by an operation. - */ - transform( point: Point | null, op: Operation, diff --git a/packages/slate/src/interfaces/range-ref.ts b/packages/slate/src/interfaces/range-ref.ts index 2cae5e47e..3447297ef 100644 --- a/packages/slate/src/interfaces/range-ref.ts +++ b/packages/slate/src/interfaces/range-ref.ts @@ -13,15 +13,14 @@ export interface RangeRef { } export interface RangeRefInterface { + /** + * Transform the range ref's current value by an operation. + */ transform: (ref: RangeRef, op: Operation) => void } // eslint-disable-next-line no-redeclare export const RangeRef: RangeRefInterface = { - /** - * Transform the range ref's current value by an operation. - */ - transform(ref: RangeRef, op: Operation): void { const { current, affinity } = ref diff --git a/packages/slate/src/interfaces/range.ts b/packages/slate/src/interfaces/range.ts index 821b4cf3a..9933c35ea 100644 --- a/packages/slate/src/interfaces/range.ts +++ b/packages/slate/src/interfaces/range.ts @@ -1,7 +1,7 @@ import { produce } from 'immer' import { isPlainObject } from 'is-plain-object' import { ExtendedType, Operation, Path, Point, PointEntry } from '..' -import { RangeDirection } from './types' +import { RangeDirection } from '../types/types' /** * `Range` objects are a set of points that refer to a specific span of a Slate @@ -25,18 +25,76 @@ export interface RangeTransformOptions { } export interface RangeInterface { + /** + * Get the start and end points of a range, in the order in which they appear + * in the document. + */ edges: (range: Range, options?: RangeEdgesOptions) => [Point, Point] + + /** + * Get the end point of a range. + */ end: (range: Range) => Point + + /** + * Check if a range is exactly equal to another. + */ equals: (range: Range, another: Range) => boolean + + /** + * Check if a range includes a path, a point or part of another range. + */ includes: (range: Range, target: Path | Point | Range) => boolean + + /** + * Get the intersection of a range with another. + */ intersection: (range: Range, another: Range) => Range | null + + /** + * Check if a range is backward, meaning that its anchor point appears in the + * document _after_ its focus point. + */ isBackward: (range: Range) => boolean + + /** + * Check if a range is collapsed, meaning that both its anchor and focus + * points refer to the exact same position in the document. + */ isCollapsed: (range: Range) => boolean + + /** + * Check if a range is expanded. + * + * This is the opposite of [[Range.isCollapsed]] and is provided for legibility. + */ isExpanded: (range: Range) => boolean + + /** + * Check if a range is forward. + * + * This is the opposite of [[Range.isBackward]] and is provided for legibility. + */ isForward: (range: Range) => boolean + + /** + * Check if a value implements the [[Range]] interface. + */ isRange: (value: any) => value is Range + + /** + * Iterate through all of the point entries in a range. + */ points: (range: Range) => Generator + + /** + * Get the start point of a range. + */ start: (range: Range) => Point + + /** + * Transform a range by an operation. + */ transform: ( range: Range, op: Operation, @@ -46,11 +104,6 @@ export interface RangeInterface { // eslint-disable-next-line no-redeclare export const Range: RangeInterface = { - /** - * Get the start and end points of a range, in the order in which they appear - * in the document. - */ - edges(range: Range, options: RangeEdgesOptions = {}): [Point, Point] { const { reverse = false } = options const { anchor, focus } = range @@ -59,19 +112,11 @@ export const Range: RangeInterface = { : [focus, anchor] }, - /** - * Get the end point of a range. - */ - end(range: Range): Point { const [, end] = Range.edges(range) return end }, - /** - * Check if a range is exactly equal to another. - */ - equals(range: Range, another: Range): boolean { return ( Point.equals(range.anchor, another.anchor) && @@ -79,10 +124,6 @@ export const Range: RangeInterface = { ) }, - /** - * Check if a range includes a path, a point or part of another range. - */ - includes(range: Range, target: Path | Point | Range): boolean { if (Range.isRange(target)) { if ( @@ -112,10 +153,6 @@ export const Range: RangeInterface = { return isAfterStart && isBeforeEnd }, - /** - * Get the intersection of a range with another. - */ - intersection(range: Range, another: Range): Range | null { const { anchor, focus, ...rest } = range const [s1, e1] = Range.edges(range) @@ -130,50 +167,24 @@ export const Range: RangeInterface = { } }, - /** - * Check if a range is backward, meaning that its anchor point appears in the - * document _after_ its focus point. - */ - isBackward(range: Range): boolean { const { anchor, focus } = range return Point.isAfter(anchor, focus) }, - /** - * Check if a range is collapsed, meaning that both its anchor and focus - * points refer to the exact same position in the document. - */ - isCollapsed(range: Range): boolean { const { anchor, focus } = range return Point.equals(anchor, focus) }, - /** - * Check if a range is expanded. - * - * This is the opposite of [[Range.isCollapsed]] and is provided for legibility. - */ - isExpanded(range: Range): boolean { return !Range.isCollapsed(range) }, - /** - * Check if a range is forward. - * - * This is the opposite of [[Range.isBackward]] and is provided for legibility. - */ - isForward(range: Range): boolean { return !Range.isBackward(range) }, - /** - * Check if a value implements the [[Range]] interface. - */ - isRange(value: any): value is Range { return ( isPlainObject(value) && @@ -182,28 +193,16 @@ export const Range: RangeInterface = { ) }, - /** - * Iterate through all of the point entries in a range. - */ - *points(range: Range): Generator { yield [range.anchor, 'anchor'] yield [range.focus, 'focus'] }, - /** - * Get the start point of a range. - */ - start(range: Range): Point { const [start] = Range.edges(range) return start }, - /** - * Transform a range by an operation. - */ - transform( range: Range | null, op: Operation, diff --git a/packages/slate/src/interfaces/text.ts b/packages/slate/src/interfaces/text.ts index 02c8f6ccc..b3ed0051c 100644 --- a/packages/slate/src/interfaces/text.ts +++ b/packages/slate/src/interfaces/text.ts @@ -1,6 +1,6 @@ import { isPlainObject } from 'is-plain-object' import { Range } from '..' -import { ExtendedType } from './custom-types' +import { ExtendedType } from '../types/custom-types' import { isDeepEqual } from '../utils/deep-equal' /** @@ -20,22 +20,45 @@ export interface TextEqualsOptions { } export interface TextInterface { - equals: (text: Text, another: Text, options?: TextEqualsOptions) => boolean - isText: (value: any) => value is Text - isTextList: (value: any) => value is Text[] - isTextProps: (props: any) => props is Partial - matches: (text: Text, props: Partial) => boolean - decorations: (node: Text, decorations: Range[]) => Text[] -} - -// eslint-disable-next-line no-redeclare -export const Text: TextInterface = { /** * Check if two text nodes are equal. * * When loose is set, the text is not compared. This is * used to check whether sibling text nodes can be merged. */ + equals: (text: Text, another: Text, options?: TextEqualsOptions) => boolean + + /** + * Check if a value implements the `Text` interface. + */ + isText: (value: any) => value is Text + + /** + * Check if a value is a list of `Text` objects. + */ + isTextList: (value: any) => value is Text[] + + /** + * Check if some props are a partial of Text. + */ + isTextProps: (props: any) => props is Partial + + /** + * Check if an text matches set of properties. + * + * Note: this is for matching custom properties, and it does not ensure that + * the `text` property are two nodes equal. + */ + matches: (text: Text, props: Partial) => boolean + + /** + * Get the leaves for a text node given decorations. + */ + decorations: (node: Text, decorations: Range[]) => Text[] +} + +// eslint-disable-next-line no-redeclare +export const Text: TextInterface = { equals(text: Text, another: Text, options: TextEqualsOptions = {}): boolean { const { loose = false } = options @@ -51,37 +74,18 @@ export const Text: TextInterface = { ) }, - /** - * Check if a value implements the `Text` interface. - */ - isText(value: any): value is Text { return isPlainObject(value) && typeof value.text === 'string' }, - /** - * Check if a value is a list of `Text` objects. - */ - isTextList(value: any): value is Text[] { return Array.isArray(value) && value.every(val => Text.isText(val)) }, - /** - * Check if some props are a partial of Text. - */ - isTextProps(props: any): props is Partial { return (props as Partial).text !== undefined }, - /** - * Check if an text matches set of properties. - * - * Note: this is for matching custom properties, and it does not ensure that - * the `text` property are two nodes equal. - */ - matches(text: Text, props: Partial): boolean { for (const key in props) { if (key === 'text') { @@ -96,10 +100,6 @@ export const Text: TextInterface = { return true }, - /** - * Get the leaves for a text node given decorations. - */ - decorations(node: Text, decorations: Range[]): Text[] { let leaves: Text[] = [{ ...node }] diff --git a/packages/slate/src/transforms/general.ts b/packages/slate/src/interfaces/transforms/general.ts similarity index 99% rename from packages/slate/src/transforms/general.ts rename to packages/slate/src/interfaces/transforms/general.ts index 37c26855a..c98750321 100644 --- a/packages/slate/src/transforms/general.ts +++ b/packages/slate/src/interfaces/transforms/general.ts @@ -13,9 +13,12 @@ import { Scrubber, Selection, Text, -} from '..' +} from '../../index' export interface GeneralTransforms { + /** + * Transform the editor by an operation. + */ transform: (editor: Editor, op: Operation) => void } @@ -315,10 +318,6 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => { // eslint-disable-next-line no-redeclare export const GeneralTransforms: GeneralTransforms = { - /** - * Transform the editor by an operation. - */ - transform(editor: Editor, op: Operation): void { editor.children = createDraft(editor.children) let selection = editor.selection && createDraft(editor.selection) diff --git a/packages/slate/src/transforms/index.ts b/packages/slate/src/interfaces/transforms/index.ts similarity index 100% rename from packages/slate/src/transforms/index.ts rename to packages/slate/src/interfaces/transforms/index.ts diff --git a/packages/slate/src/interfaces/transforms/node.ts b/packages/slate/src/interfaces/transforms/node.ts new file mode 100644 index 000000000..5c3c8ab5c --- /dev/null +++ b/packages/slate/src/interfaces/transforms/node.ts @@ -0,0 +1,192 @@ +import { Editor, Element, Location, Node, Path } from '../../index' +import { NodeMatch, PropsCompare, PropsMerge } from '../editor' +import { MaximizeMode, RangeMode } from '../../types/types' + +export interface NodeTransforms { + /** + * Insert nodes at a specific location in the Editor. + */ + insertNodes: ( + editor: Editor, + nodes: Node | Node[], + options?: { + at?: Location + match?: NodeMatch + mode?: RangeMode + hanging?: boolean + select?: boolean + voids?: boolean + } + ) => void + + /** + * Lift nodes at a specific location upwards in the document tree, splitting + * their parent in two if necessary. + */ + liftNodes: ( + editor: Editor, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + voids?: boolean + } + ) => void + + /** + * Merge a node at a location with the previous node of the same depth, + * removing any empty containing nodes after the merge if necessary. + */ + mergeNodes: ( + editor: Editor, + options?: { + at?: Location + match?: NodeMatch + mode?: RangeMode + hanging?: boolean + voids?: boolean + } + ) => void + + /** + * Move the nodes at a location to a new location. + */ + moveNodes: ( + editor: Editor, + options: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + to: Path + voids?: boolean + } + ) => void + + /** + * Remove the nodes at a specific location in the document. + */ + removeNodes: ( + editor: Editor, + options?: { + at?: Location + match?: NodeMatch + mode?: RangeMode + hanging?: boolean + voids?: boolean + } + ) => void + + /** + * Set new properties on the nodes at a location. + */ + setNodes: ( + editor: Editor, + props: Partial, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + hanging?: boolean + split?: boolean + voids?: boolean + compare?: PropsCompare + merge?: PropsMerge + } + ) => void + + /** + * Split the nodes at a specific location. + */ + splitNodes: ( + editor: Editor, + options?: { + at?: Location + match?: NodeMatch + mode?: RangeMode + always?: boolean + height?: number + voids?: boolean + } + ) => void + + /** + * Unset properties on the nodes at a location. + */ + unsetNodes: ( + editor: Editor, + props: string | string[], + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + hanging?: boolean + split?: boolean + voids?: boolean + } + ) => void + + /** + * Unwrap the nodes at a location from a parent node, splitting the parent if + * necessary to ensure that only the content in the range is unwrapped. + */ + unwrapNodes: ( + editor: Editor, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + split?: boolean + voids?: boolean + } + ) => void + + /** + * Wrap the nodes at a location in a new container node, splitting the edges + * of the range first to ensure that only the content in the range is wrapped. + */ + wrapNodes: ( + editor: Editor, + element: Element, + options?: { + at?: Location + match?: NodeMatch + mode?: MaximizeMode + split?: boolean + voids?: boolean + } + ) => void +} + +// eslint-disable-next-line no-redeclare +export const NodeTransforms: NodeTransforms = { + insertNodes(editor, nodes, options) { + editor.insertNodes(nodes, options) + }, + liftNodes(editor, options) { + editor.liftNodes(options) + }, + mergeNodes(editor, options) { + editor.mergeNodes(options) + }, + moveNodes(editor, options) { + editor.moveNodes(options) + }, + removeNodes(editor, options) { + editor.removeNodes(options) + }, + setNodes(editor, props, options) { + editor.setNodes(props, options) + }, + splitNodes(editor, options) { + editor.splitNodes(options) + }, + unsetNodes(editor, props, options) { + editor.unsetNodes(props, options) + }, + unwrapNodes(editor, options) { + editor.unwrapNodes(options) + }, + wrapNodes(editor, element, options) { + editor.wrapNodes(element, options) + }, +} diff --git a/packages/slate/src/interfaces/transforms/selection.ts b/packages/slate/src/interfaces/transforms/selection.ts new file mode 100644 index 000000000..07fb29d85 --- /dev/null +++ b/packages/slate/src/interfaces/transforms/selection.ts @@ -0,0 +1,75 @@ +import { Editor, Location, Point, Range } from '../../index' +import { MoveUnit, SelectionEdge } from '../../types/types' + +export interface SelectionCollapseOptions { + edge?: SelectionEdge +} + +export interface SelectionMoveOptions { + distance?: number + unit?: MoveUnit + reverse?: boolean + edge?: SelectionEdge +} + +export interface SelectionSetPointOptions { + edge?: SelectionEdge +} + +export interface SelectionTransforms { + /** + * Collapse the selection. + */ + collapse: (editor: Editor, options?: SelectionCollapseOptions) => void + + /** + * Unset the selection. + */ + deselect: (editor: Editor) => void + + /** + * Move the selection's point forward or backward. + */ + move: (editor: Editor, options?: SelectionMoveOptions) => void + + /** + * Set the selection to a new value. + */ + select: (editor: Editor, target: Location) => void + + /** + * Set new properties on one of the selection's points. + */ + setPoint: ( + editor: Editor, + props: Partial, + options?: SelectionSetPointOptions + ) => void + + /** + * Set new properties on the selection. + */ + setSelection: (editor: Editor, props: Partial) => void +} + +// eslint-disable-next-line no-redeclare +export const SelectionTransforms: SelectionTransforms = { + collapse(editor, options) { + editor.collapse(options) + }, + deselect(editor) { + editor.deselect() + }, + move(editor, options) { + editor.move(options) + }, + select(editor, target) { + editor.select(target) + }, + setPoint(editor, props, options) { + editor.setPoint(props, options) + }, + setSelection(editor, props) { + editor.setSelection(props) + }, +} diff --git a/packages/slate/src/interfaces/transforms/text.ts b/packages/slate/src/interfaces/transforms/text.ts new file mode 100644 index 000000000..60a40ad9a --- /dev/null +++ b/packages/slate/src/interfaces/transforms/text.ts @@ -0,0 +1,106 @@ +import { Editor, Location, Node, Path, Range, Transforms } from '../../index' +import { TextUnit } from '../../types/types' + +export interface TextDeleteOptions { + at?: Location + distance?: number + unit?: TextUnit + reverse?: boolean + hanging?: boolean + voids?: boolean +} + +export interface TextInsertFragmentOptions { + at?: Location + hanging?: boolean + voids?: boolean +} + +export interface TextInsertTextOptions { + at?: Location + voids?: boolean +} + +export interface TextTransforms { + /** + * Delete content in the editor. + */ + delete: (editor: Editor, options?: TextDeleteOptions) => void + + /** + * Insert a fragment at a specific location in the editor. + */ + insertFragment: ( + editor: Editor, + fragment: Node[], + options?: TextInsertFragmentOptions + ) => void + + /** + * Insert a string of text in the Editor. + */ + insertText: ( + editor: Editor, + text: string, + options?: TextInsertTextOptions + ) => void +} + +// eslint-disable-next-line no-redeclare +export const TextTransforms: TextTransforms = { + delete(editor, options) { + editor.delete(options) + }, + insertFragment(editor, fragment, options) { + editor.insertFragment(fragment, options) + }, + insertText( + editor: Editor, + text: string, + options: TextInsertTextOptions = {} + ): void { + Editor.withoutNormalizing(editor, () => { + const { voids = false } = options + let { at = editor.selection } = options + + if (!at) { + return + } + + if (Path.isPath(at)) { + at = Editor.range(editor, at) + } + + if (Range.isRange(at)) { + if (Range.isCollapsed(at)) { + at = at.anchor + } else { + const end = Range.end(at) + if (!voids && Editor.void(editor, { at: end })) { + return + } + const start = Range.start(at) + const startRef = Editor.pointRef(editor, start) + const endRef = Editor.pointRef(editor, end) + Transforms.delete(editor, { at, voids }) + const startPoint = startRef.unref() + const endPoint = endRef.unref() + + at = startPoint || endPoint! + Transforms.setSelection(editor, { anchor: at, focus: at }) + } + } + + if ( + (!voids && Editor.void(editor, { at })) || + Editor.elementReadOnly(editor, { at }) + ) { + return + } + + const { path, offset } = at + if (text.length > 0) + editor.apply({ type: 'insert_text', path, offset, text }) + }) + }, +} diff --git a/packages/slate/src/transforms-node/index.ts b/packages/slate/src/transforms-node/index.ts new file mode 100644 index 000000000..7277ea30a --- /dev/null +++ b/packages/slate/src/transforms-node/index.ts @@ -0,0 +1,10 @@ +export * from './insert-nodes' +export * from './lift-nodes' +export * from './merge-nodes' +export * from './move-nodes' +export * from './remove-nodes' +export * from './set-nodes' +export * from './split-nodes' +export * from './unset-nodes' +export * from './unwrap-nodes' +export * from './wrap-nodes' diff --git a/packages/slate/src/transforms-node/insert-nodes.ts b/packages/slate/src/transforms-node/insert-nodes.ts new file mode 100644 index 000000000..133471099 --- /dev/null +++ b/packages/slate/src/transforms-node/insert-nodes.ts @@ -0,0 +1,117 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Node } from '../interfaces/node' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { Point } from '../interfaces/point' +import { Text } from '../interfaces/text' +import { Element } from '../interfaces/element' +import { Path } from '../interfaces/path' + +export const insertNodes: NodeTransforms['insertNodes'] = ( + editor, + nodes, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { hanging = false, voids = false, mode = 'lowest' } = options + let { at, match, select } = options + + if (Node.isNode(nodes)) { + nodes = [nodes] + } + + if (nodes.length === 0) { + return + } + + const [node] = nodes + + // By default, use the selection as the target location. But if there is + // no selection, insert at the end of the document since that is such a + // common use case when inserting from a non-selected state. + if (!at) { + if (editor.selection) { + at = editor.selection + } else if (editor.children.length > 0) { + at = Editor.end(editor, []) + } else { + at = [0] + } + + select = true + } + + if (select == null) { + select = false + } + + if (Range.isRange(at)) { + if (!hanging) { + at = Editor.unhangRange(editor, at, { voids }) + } + + if (Range.isCollapsed(at)) { + at = at.anchor + } else { + const [, end] = Range.edges(at) + const pointRef = Editor.pointRef(editor, end) + Transforms.delete(editor, { at }) + at = pointRef.unref()! + } + } + + if (Point.isPoint(at)) { + if (match == null) { + if (Text.isText(node)) { + match = n => Text.isText(n) + } else if (editor.isInline(node)) { + match = n => Text.isText(n) || Editor.isInline(editor, n) + } else { + match = n => Element.isElement(n) && Editor.isBlock(editor, n) + } + } + + const [entry] = Editor.nodes(editor, { + at: at.path, + match, + mode, + voids, + }) + + if (entry) { + const [, matchPath] = entry + const pathRef = Editor.pathRef(editor, matchPath) + const isAtEnd = Editor.isEnd(editor, at, matchPath) + Transforms.splitNodes(editor, { at, match, mode, voids }) + const path = pathRef.unref()! + at = isAtEnd ? Path.next(path) : path + } else { + return + } + } + + const parentPath = Path.parent(at) + let index = at[at.length - 1] + + if (!voids && Editor.void(editor, { at: parentPath })) { + return + } + + for (const node of nodes) { + const path = parentPath.concat(index) + index++ + editor.apply({ type: 'insert_node', path, node }) + at = Path.next(at) + } + at = Path.previous(at) + + if (select) { + const point = Editor.end(editor, at) + + if (point) { + Transforms.select(editor, point) + } + } + }) +} diff --git a/packages/slate/src/transforms-node/lift-nodes.ts b/packages/slate/src/transforms-node/lift-nodes.ts new file mode 100644 index 000000000..ac00d359c --- /dev/null +++ b/packages/slate/src/transforms-node/lift-nodes.ts @@ -0,0 +1,61 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' +import { Ancestor, NodeEntry } from '../interfaces/node' +import { Transforms } from '../interfaces/transforms' + +export const liftNodes: NodeTransforms['liftNodes'] = ( + editor, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { at = editor.selection, mode = 'lowest', voids = false } = options + let { match } = options + + if (match == null) { + match = Path.isPath(at) + ? matchPath(editor, at) + : n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + if (!at) { + return + } + + const matches = Editor.nodes(editor, { at, match, mode, voids }) + const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) + + for (const pathRef of pathRefs) { + const path = pathRef.unref()! + + if (path.length < 2) { + throw new Error( + `Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.` + ) + } + + const parentNodeEntry = Editor.node(editor, Path.parent(path)) + const [parent, parentPath] = parentNodeEntry as NodeEntry + const index = path[path.length - 1] + const { length } = parent.children + + if (length === 1) { + const toPath = Path.next(parentPath) + Transforms.moveNodes(editor, { at: path, to: toPath, voids }) + Transforms.removeNodes(editor, { at: parentPath, voids }) + } else if (index === 0) { + Transforms.moveNodes(editor, { at: path, to: parentPath, voids }) + } else if (index === length - 1) { + const toPath = Path.next(parentPath) + Transforms.moveNodes(editor, { at: path, to: toPath, voids }) + } else { + const splitPath = Path.next(path) + const toPath = Path.next(parentPath) + Transforms.splitNodes(editor, { at: splitPath, voids }) + Transforms.moveNodes(editor, { at: path, to: toPath, voids }) + } + } + }) +} diff --git a/packages/slate/src/transforms-node/merge-nodes.ts b/packages/slate/src/transforms-node/merge-nodes.ts new file mode 100644 index 000000000..aa66e8040 --- /dev/null +++ b/packages/slate/src/transforms-node/merge-nodes.ts @@ -0,0 +1,156 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { Element } from '../interfaces/element' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { Text } from '../interfaces/text' +import { Scrubber } from '../interfaces/scrubber' +import { Node } from '../interfaces/node' + +const hasSingleChildNest = (editor: Editor, node: Node): boolean => { + if (Element.isElement(node)) { + const element = node as Element + if (Editor.isVoid(editor, node)) { + return true + } else if (element.children.length === 1) { + return hasSingleChildNest(editor, element.children[0]) + } else { + return false + } + } else if (Editor.isEditor(node)) { + return false + } else { + return true + } +} + +export const mergeNodes: NodeTransforms['mergeNodes'] = ( + editor, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + let { match, at = editor.selection } = options + const { hanging = false, voids = false, mode = 'lowest' } = options + + if (!at) { + return + } + + if (match == null) { + if (Path.isPath(at)) { + const [parent] = Editor.parent(editor, at) + match = n => parent.children.includes(n) + } else { + match = n => Element.isElement(n) && Editor.isBlock(editor, n) + } + } + + if (!hanging && Range.isRange(at)) { + at = Editor.unhangRange(editor, at, { voids }) + } + + if (Range.isRange(at)) { + if (Range.isCollapsed(at)) { + at = at.anchor + } else { + const [, end] = Range.edges(at) + const pointRef = Editor.pointRef(editor, end) + Transforms.delete(editor, { at }) + at = pointRef.unref()! + + if (options.at == null) { + Transforms.select(editor, at) + } + } + } + + const [current] = Editor.nodes(editor, { at, match, voids, mode }) + const prev = Editor.previous(editor, { at, match, voids, mode }) + + if (!current || !prev) { + return + } + + const [node, path] = current + const [prevNode, prevPath] = prev + + if (path.length === 0 || prevPath.length === 0) { + return + } + + const newPath = Path.next(prevPath) + const commonPath = Path.common(path, prevPath) + const isPreviousSibling = Path.isSibling(path, prevPath) + const levels = Array.from(Editor.levels(editor, { at: path }), ([n]) => n) + .slice(commonPath.length) + .slice(0, -1) + + // Determine if the merge will leave an ancestor of the path empty as a + // result, in which case we'll want to remove it after merging. + const emptyAncestor = Editor.above(editor, { + at: path, + mode: 'highest', + match: n => levels.includes(n) && hasSingleChildNest(editor, n), + }) + + const emptyRef = emptyAncestor && Editor.pathRef(editor, emptyAncestor[1]) + let properties + let position + + // Ensure that the nodes are equivalent, and figure out what the position + // and extra properties of the merge will be. + if (Text.isText(node) && Text.isText(prevNode)) { + const { text, ...rest } = node + position = prevNode.text.length + properties = rest as Partial + } else if (Element.isElement(node) && Element.isElement(prevNode)) { + const { children, ...rest } = node + position = prevNode.children.length + properties = rest as Partial + } else { + throw new Error( + `Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${Scrubber.stringify( + node + )} ${Scrubber.stringify(prevNode)}` + ) + } + + // If the node isn't already the next sibling of the previous node, move + // it so that it is before merging. + if (!isPreviousSibling) { + Transforms.moveNodes(editor, { at: path, to: newPath, voids }) + } + + // If there was going to be an empty ancestor of the node that was merged, + // we remove it from the tree. + if (emptyRef) { + Transforms.removeNodes(editor, { at: emptyRef.current!, voids }) + } + + // If the target node that we're merging with is empty, remove it instead + // of merging the two. This is a common rich text editor behavior to + // prevent losing formatting when deleting entire nodes when you have a + // hanging selection. + // if prevNode is first child in parent,don't remove it. + if ( + (Element.isElement(prevNode) && Editor.isEmpty(editor, prevNode)) || + (Text.isText(prevNode) && + prevNode.text === '' && + prevPath[prevPath.length - 1] !== 0) + ) { + Transforms.removeNodes(editor, { at: prevPath, voids }) + } else { + editor.apply({ + type: 'merge_node', + path: newPath, + position, + properties, + }) + } + + if (emptyRef) { + emptyRef.unref() + } + }) +} diff --git a/packages/slate/src/transforms-node/move-nodes.ts b/packages/slate/src/transforms-node/move-nodes.ts new file mode 100644 index 000000000..138cd288c --- /dev/null +++ b/packages/slate/src/transforms-node/move-nodes.ts @@ -0,0 +1,53 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' + +export const moveNodes: NodeTransforms['moveNodes'] = (editor, options) => { + Editor.withoutNormalizing(editor, () => { + const { + to, + at = editor.selection, + mode = 'lowest', + voids = false, + } = options + let { match } = options + + if (!at) { + return + } + + if (match == null) { + match = Path.isPath(at) + ? matchPath(editor, at) + : n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + const toRef = Editor.pathRef(editor, to) + const targets = Editor.nodes(editor, { at, match, mode, voids }) + const pathRefs = Array.from(targets, ([, p]) => Editor.pathRef(editor, p)) + + for (const pathRef of pathRefs) { + const path = pathRef.unref()! + const newPath = toRef.current! + + if (path.length !== 0) { + editor.apply({ type: 'move_node', path, newPath }) + } + + if ( + toRef.current && + Path.isSibling(newPath, path) && + Path.isAfter(newPath, path) + ) { + // When performing a sibling move to a later index, the path at the destination is shifted + // to before the insertion point instead of after. To ensure our group of nodes are inserted + // in the correct order we increment toRef to account for that + toRef.current = Path.next(toRef.current) + } + } + + toRef.unref() + }) +} diff --git a/packages/slate/src/transforms-node/remove-nodes.ts b/packages/slate/src/transforms-node/remove-nodes.ts new file mode 100644 index 000000000..746e13aa9 --- /dev/null +++ b/packages/slate/src/transforms-node/remove-nodes.ts @@ -0,0 +1,42 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' +import { Range } from '../interfaces/range' + +export const removeNodes: NodeTransforms['removeNodes'] = ( + editor, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { hanging = false, voids = false, mode = 'lowest' } = options + let { at = editor.selection, match } = options + + if (!at) { + return + } + + if (match == null) { + match = Path.isPath(at) + ? matchPath(editor, at) + : n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + if (!hanging && Range.isRange(at)) { + at = Editor.unhangRange(editor, at, { voids }) + } + + const depths = Editor.nodes(editor, { at, match, mode, voids }) + const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p)) + + for (const pathRef of pathRefs) { + const path = pathRef.unref()! + + if (path) { + const [node] = Editor.node(editor, path) + editor.apply({ type: 'remove_node', path, node }) + } + } + }) +} diff --git a/packages/slate/src/transforms-node/set-nodes.ts b/packages/slate/src/transforms-node/set-nodes.ts new file mode 100644 index 000000000..bb342f91c --- /dev/null +++ b/packages/slate/src/transforms-node/set-nodes.ts @@ -0,0 +1,121 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' +import { Node } from '../interfaces/node' + +export const setNodes: NodeTransforms['setNodes'] = ( + editor, + props: Partial, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + let { match, at = editor.selection, compare, merge } = options + const { + hanging = false, + mode = 'lowest', + split = false, + voids = false, + } = options + + if (!at) { + return + } + + if (match == null) { + match = Path.isPath(at) + ? matchPath(editor, at) + : n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + if (!hanging && Range.isRange(at)) { + at = Editor.unhangRange(editor, at, { voids }) + } + + if (split && Range.isRange(at)) { + if ( + Range.isCollapsed(at) && + Editor.leaf(editor, at.anchor)[0].text.length > 0 + ) { + // If the range is collapsed in a non-empty node and 'split' is true, there's nothing to + // set that won't get normalized away + return + } + const rangeRef = Editor.rangeRef(editor, at, { affinity: 'inward' }) + const [start, end] = Range.edges(at) + const splitMode = mode === 'lowest' ? 'lowest' : 'highest' + const endAtEndOfNode = Editor.isEnd(editor, end, end.path) + Transforms.splitNodes(editor, { + at: end, + match, + mode: splitMode, + voids, + always: !endAtEndOfNode, + }) + const startAtStartOfNode = Editor.isStart(editor, start, start.path) + Transforms.splitNodes(editor, { + at: start, + match, + mode: splitMode, + voids, + always: !startAtStartOfNode, + }) + at = rangeRef.unref()! + + if (options.at == null) { + Transforms.select(editor, at) + } + } + + if (!compare) { + compare = (prop, nodeProp) => prop !== nodeProp + } + + for (const [node, path] of Editor.nodes(editor, { + at, + match, + mode, + voids, + })) { + const properties: Partial = {} + const newProperties: Partial = {} + + // You can't set properties on the editor node. + if (path.length === 0) { + continue + } + + let hasChanges = false + + for (const k in props) { + if (k === 'children' || k === 'text') { + continue + } + + if (compare(props[k], node[k])) { + hasChanges = true + // Omit new properties from the old properties list + if (node.hasOwnProperty(k)) properties[k] = node[k] + // Omit properties that have been removed from the new properties list + if (merge) { + if (props[k] != null) newProperties[k] = merge(node[k], props[k]) + } else { + if (props[k] != null) newProperties[k] = props[k] + } + } + } + + if (hasChanges) { + editor.apply({ + type: 'set_node', + path, + properties, + newProperties, + }) + } + } + }) +} diff --git a/packages/slate/src/transforms-node/split-nodes.ts b/packages/slate/src/transforms-node/split-nodes.ts new file mode 100644 index 000000000..b1d8a40ee --- /dev/null +++ b/packages/slate/src/transforms-node/split-nodes.ts @@ -0,0 +1,140 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Element } from '../interfaces/element' +import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' +import { PointRef } from '../interfaces/point-ref' +import { Transforms } from '../interfaces/transforms' +import { Node } from '../interfaces/node' +import { Point } from '../interfaces/point' + +/** + * Convert a range into a point by deleting it's content. + */ +const deleteRange = (editor: Editor, range: Range): Point | null => { + if (Range.isCollapsed(range)) { + return range.anchor + } else { + const [, end] = Range.edges(range) + const pointRef = Editor.pointRef(editor, end) + Transforms.delete(editor, { at: range }) + return pointRef.unref() + } +} + +export const splitNodes: NodeTransforms['splitNodes'] = ( + editor, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { mode = 'lowest', voids = false } = options + let { match, at = editor.selection, height = 0, always = false } = options + + if (match == null) { + match = n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + if (Range.isRange(at)) { + at = deleteRange(editor, at) + } + + // If the target is a path, the default height-skipping and position + // counters need to account for us potentially splitting at a non-leaf. + if (Path.isPath(at)) { + const path = at + const point = Editor.point(editor, path) + const [parent] = Editor.parent(editor, path) + match = n => n === parent + height = point.path.length - path.length + 1 + at = point + always = true + } + + if (!at) { + return + } + + const beforeRef = Editor.pointRef(editor, at, { + affinity: 'backward', + }) + let afterRef: PointRef | undefined + try { + const [highest] = Editor.nodes(editor, { at, match, mode, voids }) + + if (!highest) { + return + } + + const voidMatch = Editor.void(editor, { at, mode: 'highest' }) + const nudge = 0 + + if (!voids && voidMatch) { + const [voidNode, voidPath] = voidMatch + + if (Element.isElement(voidNode) && editor.isInline(voidNode)) { + let after = Editor.after(editor, voidPath) + + if (!after) { + const text = { text: '' } + const afterPath = Path.next(voidPath) + Transforms.insertNodes(editor, text, { at: afterPath, voids }) + after = Editor.point(editor, afterPath)! + } + + at = after + always = true + } + + const siblingHeight = at.path.length - voidPath.length + height = siblingHeight + 1 + always = true + } + + afterRef = Editor.pointRef(editor, at) + const depth = at.path.length - height + const [, highestPath] = highest + const lowestPath = at.path.slice(0, depth) + let position = height === 0 ? at.offset : at.path[depth] + nudge + + for (const [node, path] of Editor.levels(editor, { + at: lowestPath, + reverse: true, + voids, + })) { + let split = false + + if ( + path.length < highestPath.length || + path.length === 0 || + (!voids && Element.isElement(node) && Editor.isVoid(editor, node)) + ) { + break + } + + const point = beforeRef.current! + const isEnd = Editor.isEnd(editor, point, path) + + if (always || !beforeRef || !Editor.isEdge(editor, point, path)) { + split = true + const properties = Node.extractProps(node) + editor.apply({ + type: 'split_node', + path, + position, + properties, + }) + } + + position = path[path.length - 1] + (split || isEnd ? 1 : 0) + } + + if (options.at == null) { + const point = afterRef.current || Editor.end(editor, []) + Transforms.select(editor, point) + } + } finally { + beforeRef.unref() + afterRef?.unref() + } + }) +} diff --git a/packages/slate/src/transforms-node/unset-nodes.ts b/packages/slate/src/transforms-node/unset-nodes.ts new file mode 100644 index 000000000..f8cd9644a --- /dev/null +++ b/packages/slate/src/transforms-node/unset-nodes.ts @@ -0,0 +1,20 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Transforms } from '../interfaces/transforms' + +export const unsetNodes: NodeTransforms['unsetNodes'] = ( + editor, + props, + options = {} +) => { + if (!Array.isArray(props)) { + props = [props] + } + + const obj = {} + + for (const key of props) { + obj[key] = null + } + + Transforms.setNodes(editor, obj, options) +} diff --git a/packages/slate/src/transforms-node/unwrap-nodes.ts b/packages/slate/src/transforms-node/unwrap-nodes.ts new file mode 100644 index 000000000..17401ece5 --- /dev/null +++ b/packages/slate/src/transforms-node/unwrap-nodes.ts @@ -0,0 +1,61 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' + +export const unwrapNodes: NodeTransforms['unwrapNodes'] = ( + editor, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { mode = 'lowest', split = false, voids = false } = options + let { at = editor.selection, match } = options + + if (!at) { + return + } + + if (match == null) { + match = Path.isPath(at) + ? matchPath(editor, at) + : n => Element.isElement(n) && Editor.isBlock(editor, n) + } + + if (Path.isPath(at)) { + at = Editor.range(editor, at) + } + + const rangeRef = Range.isRange(at) ? Editor.rangeRef(editor, at) : null + const matches = Editor.nodes(editor, { at, match, mode, voids }) + const pathRefs = Array.from( + matches, + ([, p]) => Editor.pathRef(editor, p) + // unwrapNode will call liftNode which does not support splitting the node when nested. + // If we do not reverse the order and call it from top to the bottom, it will remove all blocks + // that wrap target node. So we reverse the order. + ).reverse() + + for (const pathRef of pathRefs) { + const path = pathRef.unref()! + const [node] = Editor.node(editor, path) + let range = Editor.range(editor, path) + + if (split && rangeRef) { + range = Range.intersection(rangeRef.current!, range)! + } + + Transforms.liftNodes(editor, { + at: range, + match: n => Element.isAncestor(node) && node.children.includes(n), + voids, + }) + } + + if (rangeRef) { + rangeRef.unref() + } + }) +} diff --git a/packages/slate/src/transforms-node/wrap-nodes.ts b/packages/slate/src/transforms-node/wrap-nodes.ts new file mode 100644 index 000000000..af2e15760 --- /dev/null +++ b/packages/slate/src/transforms-node/wrap-nodes.ts @@ -0,0 +1,105 @@ +import { NodeTransforms } from '../interfaces/transforms/node' +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { matchPath } from '../utils/match-path' +import { Element } from '../interfaces/element' +import { Text } from '../interfaces/text' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' + +export const wrapNodes: NodeTransforms['wrapNodes'] = ( + editor, + element, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { mode = 'lowest', split = false, voids = false } = options + let { match, at = editor.selection } = options + + if (!at) { + return + } + + if (match == null) { + if (Path.isPath(at)) { + match = matchPath(editor, at) + } else if (editor.isInline(element)) { + match = n => + (Element.isElement(n) && Editor.isInline(editor, n)) || Text.isText(n) + } else { + match = n => Element.isElement(n) && Editor.isBlock(editor, n) + } + } + + if (split && Range.isRange(at)) { + const [start, end] = Range.edges(at) + const rangeRef = Editor.rangeRef(editor, at, { + affinity: 'inward', + }) + Transforms.splitNodes(editor, { at: end, match, voids }) + Transforms.splitNodes(editor, { at: start, match, voids }) + at = rangeRef.unref()! + + if (options.at == null) { + Transforms.select(editor, at) + } + } + + const roots = Array.from( + Editor.nodes(editor, { + at, + match: editor.isInline(element) + ? n => Element.isElement(n) && Editor.isBlock(editor, n) + : n => Editor.isEditor(n), + mode: 'lowest', + voids, + }) + ) + + for (const [, rootPath] of roots) { + const a = Range.isRange(at) + ? Range.intersection(at, Editor.range(editor, rootPath)) + : at + + if (!a) { + continue + } + + const matches = Array.from( + Editor.nodes(editor, { at: a, match, mode, voids }) + ) + + if (matches.length > 0) { + const [first] = matches + const last = matches[matches.length - 1] + const [, firstPath] = first + const [, lastPath] = last + + if (firstPath.length === 0 && lastPath.length === 0) { + // if there's no matching parent - usually means the node is an editor - don't do anything + continue + } + + const commonPath = Path.equals(firstPath, lastPath) + ? Path.parent(firstPath) + : Path.common(firstPath, lastPath) + + const range = Editor.range(editor, firstPath, lastPath) + const commonNodeEntry = Editor.node(editor, commonPath) + const [commonNode] = commonNodeEntry + const depth = commonPath.length + 1 + const wrapperPath = Path.next(lastPath.slice(0, depth)) + const wrapper = { ...element, children: [] } + Transforms.insertNodes(editor, wrapper, { at: wrapperPath, voids }) + + Transforms.moveNodes(editor, { + at: range, + match: n => + Element.isAncestor(commonNode) && commonNode.children.includes(n), + to: wrapperPath.concat(0), + voids, + }) + } + } + }) +} diff --git a/packages/slate/src/transforms-selection/collapse.ts b/packages/slate/src/transforms-selection/collapse.ts new file mode 100644 index 000000000..cf8d17fd8 --- /dev/null +++ b/packages/slate/src/transforms-selection/collapse.ts @@ -0,0 +1,25 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' +import { Transforms } from '../interfaces/transforms' +import { Range } from '../interfaces/range' + +export const collapse: SelectionTransforms['collapse'] = ( + editor, + options = {} +) => { + const { edge = 'anchor' } = options + const { selection } = editor + + if (!selection) { + return + } else if (edge === 'anchor') { + Transforms.select(editor, selection.anchor) + } else if (edge === 'focus') { + Transforms.select(editor, selection.focus) + } else if (edge === 'start') { + const [start] = Range.edges(selection) + Transforms.select(editor, start) + } else if (edge === 'end') { + const [, end] = Range.edges(selection) + Transforms.select(editor, end) + } +} diff --git a/packages/slate/src/transforms-selection/deselect.ts b/packages/slate/src/transforms-selection/deselect.ts new file mode 100644 index 000000000..994e73dc7 --- /dev/null +++ b/packages/slate/src/transforms-selection/deselect.ts @@ -0,0 +1,13 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' + +export const deselect: SelectionTransforms['deselect'] = editor => { + const { selection } = editor + + if (selection) { + editor.apply({ + type: 'set_selection', + properties: selection, + newProperties: null, + }) + } +} diff --git a/packages/slate/src/transforms-selection/index.ts b/packages/slate/src/transforms-selection/index.ts new file mode 100644 index 000000000..adbdfa946 --- /dev/null +++ b/packages/slate/src/transforms-selection/index.ts @@ -0,0 +1,6 @@ +export * from './collapse' +export * from './deselect' +export * from './move' +export * from './select' +export * from './set-point' +export * from './set-selection' diff --git a/packages/slate/src/transforms-selection/move.ts b/packages/slate/src/transforms-selection/move.ts new file mode 100644 index 000000000..b1c61db38 --- /dev/null +++ b/packages/slate/src/transforms-selection/move.ts @@ -0,0 +1,48 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' +import { Range } from '../interfaces/range' +import { Editor } from '../interfaces/editor' +import { Transforms } from '../interfaces/transforms' + +export const move: SelectionTransforms['move'] = (editor, options = {}) => { + const { selection } = editor + const { distance = 1, unit = 'character', reverse = false } = options + let { edge = null } = options + + if (!selection) { + return + } + + if (edge === 'start') { + edge = Range.isBackward(selection) ? 'focus' : 'anchor' + } + + if (edge === 'end') { + edge = Range.isBackward(selection) ? 'anchor' : 'focus' + } + + const { anchor, focus } = selection + const opts = { distance, unit, ignoreNonSelectable: true } + const props: Partial = {} + + if (edge == null || edge === 'anchor') { + const point = reverse + ? Editor.before(editor, anchor, opts) + : Editor.after(editor, anchor, opts) + + if (point) { + props.anchor = point + } + } + + if (edge == null || edge === 'focus') { + const point = reverse + ? Editor.before(editor, focus, opts) + : Editor.after(editor, focus, opts) + + if (point) { + props.focus = point + } + } + + Transforms.setSelection(editor, props) +} diff --git a/packages/slate/src/transforms-selection/select.ts b/packages/slate/src/transforms-selection/select.ts new file mode 100644 index 000000000..e3e105f14 --- /dev/null +++ b/packages/slate/src/transforms-selection/select.ts @@ -0,0 +1,29 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' +import { Editor } from '../interfaces/editor' +import { Transforms } from '../interfaces/transforms' +import { Range } from '../interfaces/range' +import { Scrubber } from '../interfaces/scrubber' + +export const select: SelectionTransforms['select'] = (editor, target) => { + const { selection } = editor + target = Editor.range(editor, target) + + if (selection) { + Transforms.setSelection(editor, target) + return + } + + if (!Range.isRange(target)) { + throw new Error( + `When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${Scrubber.stringify( + target + )}` + ) + } + + editor.apply({ + type: 'set_selection', + properties: selection, + newProperties: target, + }) +} diff --git a/packages/slate/src/transforms-selection/set-point.ts b/packages/slate/src/transforms-selection/set-point.ts new file mode 100644 index 000000000..e05e5fb15 --- /dev/null +++ b/packages/slate/src/transforms-selection/set-point.ts @@ -0,0 +1,31 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' +import { Range } from '../interfaces/range' +import { Transforms } from '../interfaces/transforms' + +export const setPoint: SelectionTransforms['setPoint'] = ( + editor, + props, + options = {} +) => { + const { selection } = editor + let { edge = 'both' } = options + + if (!selection) { + return + } + + if (edge === 'start') { + edge = Range.isBackward(selection) ? 'focus' : 'anchor' + } + + if (edge === 'end') { + edge = Range.isBackward(selection) ? 'anchor' : 'focus' + } + + const { anchor, focus } = selection + const point = edge === 'anchor' ? anchor : focus + + Transforms.setSelection(editor, { + [edge === 'anchor' ? 'anchor' : 'focus']: { ...point, ...props }, + }) +} diff --git a/packages/slate/src/transforms-selection/set-selection.ts b/packages/slate/src/transforms-selection/set-selection.ts new file mode 100644 index 000000000..f18cba926 --- /dev/null +++ b/packages/slate/src/transforms-selection/set-selection.ts @@ -0,0 +1,39 @@ +import { SelectionTransforms } from '../interfaces/transforms/selection' +import { Range } from '../interfaces/range' +import { Point } from '../interfaces/point' + +export const setSelection: SelectionTransforms['setSelection'] = ( + editor, + props +) => { + const { selection } = editor + const oldProps: Partial | null = {} + const newProps: Partial = {} + + if (!selection) { + return + } + + for (const k in props) { + if ( + (k === 'anchor' && + props.anchor != null && + !Point.equals(props.anchor, selection.anchor)) || + (k === 'focus' && + props.focus != null && + !Point.equals(props.focus, selection.focus)) || + (k !== 'anchor' && k !== 'focus' && props[k] !== selection[k]) + ) { + oldProps[k] = selection[k] + newProps[k] = props[k] + } + } + + if (Object.keys(oldProps).length > 0) { + editor.apply({ + type: 'set_selection', + properties: oldProps, + newProperties: newProps, + }) + } +} diff --git a/packages/slate/src/transforms-text/delete-text.ts b/packages/slate/src/transforms-text/delete-text.ts new file mode 100644 index 000000000..ce2aa62c4 --- /dev/null +++ b/packages/slate/src/transforms-text/delete-text.ts @@ -0,0 +1,196 @@ +import { TextTransforms } from '../interfaces/transforms/text' +import { Editor } from '../interfaces/editor' +import { Range } from '../interfaces/range' +import { Point } from '../interfaces/point' +import { Path } from '../interfaces/path' +import { Transforms } from '../interfaces/transforms' +import { Element } from '../interfaces/element' +import { NodeEntry } from '../interfaces/node' + +export const deleteText: TextTransforms['delete'] = (editor, options = {}) => { + Editor.withoutNormalizing(editor, () => { + const { + reverse = false, + unit = 'character', + distance = 1, + voids = false, + } = options + let { at = editor.selection, hanging = false } = options + + if (!at) { + return + } + + let isCollapsed = false + if (Range.isRange(at) && Range.isCollapsed(at)) { + isCollapsed = true + at = at.anchor + } + + if (Point.isPoint(at)) { + const furthestVoid = Editor.void(editor, { at, mode: 'highest' }) + + if (!voids && furthestVoid) { + const [, voidPath] = furthestVoid + at = voidPath + } else { + const opts = { unit, distance } + const target = reverse + ? Editor.before(editor, at, opts) || Editor.start(editor, []) + : Editor.after(editor, at, opts) || Editor.end(editor, []) + at = { anchor: at, focus: target } + hanging = true + } + } + + if (Path.isPath(at)) { + Transforms.removeNodes(editor, { at, voids }) + return + } + + if (Range.isCollapsed(at)) { + return + } + + if (!hanging) { + const [, end] = Range.edges(at) + const endOfDoc = Editor.end(editor, []) + + if (!Point.equals(end, endOfDoc)) { + at = Editor.unhangRange(editor, at, { voids }) + } + } + + let [start, end] = Range.edges(at) + const startBlock = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + at: start, + voids, + }) + const endBlock = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + at: end, + voids, + }) + const isAcrossBlocks = + startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1]) + const isSingleText = Path.equals(start.path, end.path) + const startNonEditable = voids + ? null + : Editor.void(editor, { at: start, mode: 'highest' }) ?? + Editor.elementReadOnly(editor, { at: start, mode: 'highest' }) + const endNonEditable = voids + ? null + : Editor.void(editor, { at: end, mode: 'highest' }) ?? + Editor.elementReadOnly(editor, { at: end, mode: 'highest' }) + + // If the start or end points are inside an inline void, nudge them out. + if (startNonEditable) { + const before = Editor.before(editor, start) + + if (before && startBlock && Path.isAncestor(startBlock[1], before.path)) { + start = before + } + } + + if (endNonEditable) { + const after = Editor.after(editor, end) + + if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) { + end = after + } + } + + // Get the highest nodes that are completely inside the range, as well as + // the start and end nodes. + const matches: NodeEntry[] = [] + let lastPath: Path | undefined + + for (const entry of Editor.nodes(editor, { at, voids })) { + const [node, path] = entry + + if (lastPath && Path.compare(path, lastPath) === 0) { + continue + } + + if ( + (!voids && + Element.isElement(node) && + (Editor.isVoid(editor, node) || + Editor.isElementReadOnly(editor, node))) || + (!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path)) + ) { + matches.push(entry) + lastPath = path + } + } + + const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) + const startRef = Editor.pointRef(editor, start) + const endRef = Editor.pointRef(editor, end) + + let removedText = '' + + if (!isSingleText && !startNonEditable) { + const point = startRef.current! + const [node] = Editor.leaf(editor, point) + const { path } = point + const { offset } = start + const text = node.text.slice(offset) + if (text.length > 0) { + editor.apply({ type: 'remove_text', path, offset, text }) + removedText = text + } + } + + pathRefs + .reverse() + .map(r => r.unref()) + .filter((r): r is Path => r !== null) + .forEach(p => Transforms.removeNodes(editor, { at: p, voids })) + + if (!endNonEditable) { + const point = endRef.current! + const [node] = Editor.leaf(editor, point) + const { path } = point + const offset = isSingleText ? start.offset : 0 + const text = node.text.slice(offset, end.offset) + if (text.length > 0) { + editor.apply({ type: 'remove_text', path, offset, text }) + removedText = text + } + } + + if (!isSingleText && isAcrossBlocks && endRef.current && startRef.current) { + Transforms.mergeNodes(editor, { + at: endRef.current, + hanging: true, + voids, + }) + } + + // For Thai script, deleting N character(s) backward should delete + // N code point(s) instead of an entire grapheme cluster. + // Therefore, the remaining code points should be inserted back. + if ( + isCollapsed && + reverse && + unit === 'character' && + removedText.length > 1 && + removedText.match(/[\u0E00-\u0E7F]+/) + ) { + Transforms.insertText( + editor, + removedText.slice(0, removedText.length - distance) + ) + } + + const startUnref = startRef.unref() + const endUnref = endRef.unref() + const point = reverse ? startUnref || endUnref : endUnref || startUnref + + if (options.at == null && point) { + Transforms.select(editor, point) + } + }) +} diff --git a/packages/slate/src/transforms-text/index.ts b/packages/slate/src/transforms-text/index.ts new file mode 100644 index 000000000..1bcc63758 --- /dev/null +++ b/packages/slate/src/transforms-text/index.ts @@ -0,0 +1,2 @@ +export * from './delete-text' +export * from './insert-fragment' diff --git a/packages/slate/src/transforms-text/insert-fragment.ts b/packages/slate/src/transforms-text/insert-fragment.ts new file mode 100644 index 000000000..ff8e2208a --- /dev/null +++ b/packages/slate/src/transforms-text/insert-fragment.ts @@ -0,0 +1,232 @@ +import { Transforms } from '../interfaces/transforms' +import { Editor } from '../interfaces/editor' +import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' +import { Element } from '../interfaces/element' +import { Node, NodeEntry } from '../interfaces/node' +import { Text } from '../interfaces/text' +import { TextTransforms } from '../interfaces/transforms/text' + +export const insertFragment: TextTransforms['insertFragment'] = ( + editor, + fragment, + options = {} +) => { + Editor.withoutNormalizing(editor, () => { + const { hanging = false, voids = false } = options + let { at = editor.selection } = options + + if (!fragment.length) { + return + } + + if (!at) { + return + } else if (Range.isRange(at)) { + if (!hanging) { + at = Editor.unhangRange(editor, at, { voids }) + } + + if (Range.isCollapsed(at)) { + at = at.anchor + } else { + const [, end] = Range.edges(at) + + if (!voids && Editor.void(editor, { at: end })) { + return + } + + const pointRef = Editor.pointRef(editor, end) + Transforms.delete(editor, { at }) + at = pointRef.unref()! + } + } else if (Path.isPath(at)) { + at = Editor.start(editor, at) + } + + if (!voids && Editor.void(editor, { at })) { + return + } + + // If the insert point is at the edge of an inline node, move it outside + // instead since it will need to be split otherwise. + const inlineElementMatch = Editor.above(editor, { + at, + match: n => Element.isElement(n) && Editor.isInline(editor, n), + mode: 'highest', + voids, + }) + + if (inlineElementMatch) { + const [, inlinePath] = inlineElementMatch + + if (Editor.isEnd(editor, at, inlinePath)) { + const after = Editor.after(editor, inlinePath)! + at = after + } else if (Editor.isStart(editor, at, inlinePath)) { + const before = Editor.before(editor, inlinePath)! + at = before + } + } + + const blockMatch = Editor.above(editor, { + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + at, + voids, + })! + const [, blockPath] = blockMatch + const isBlockStart = Editor.isStart(editor, at, blockPath) + const isBlockEnd = Editor.isEnd(editor, at, blockPath) + const isBlockEmpty = isBlockStart && isBlockEnd + const mergeStart = !isBlockStart || (isBlockStart && isBlockEnd) + const mergeEnd = !isBlockEnd + const [, firstPath] = Node.first({ children: fragment }, []) + const [, lastPath] = Node.last({ children: fragment }, []) + + const matches: NodeEntry[] = [] + const matcher = ([n, p]: NodeEntry) => { + const isRoot = p.length === 0 + if (isRoot) { + return false + } + + if (isBlockEmpty) { + return true + } + + if ( + mergeStart && + Path.isAncestor(p, firstPath) && + Element.isElement(n) && + !editor.isVoid(n) && + !editor.isInline(n) + ) { + return false + } + + if ( + mergeEnd && + Path.isAncestor(p, lastPath) && + Element.isElement(n) && + !editor.isVoid(n) && + !editor.isInline(n) + ) { + return false + } + + return true + } + + for (const entry of Node.nodes({ children: fragment }, { pass: matcher })) { + if (matcher(entry)) { + matches.push(entry) + } + } + + const starts = [] + const middles = [] + const ends = [] + let starting = true + let hasBlocks = false + + for (const [node] of matches) { + if (Element.isElement(node) && !editor.isInline(node)) { + starting = false + hasBlocks = true + middles.push(node) + } else if (starting) { + starts.push(node) + } else { + ends.push(node) + } + } + + const [inlineMatch] = Editor.nodes(editor, { + at, + match: n => Text.isText(n) || Editor.isInline(editor, n), + mode: 'highest', + voids, + })! + + const [, inlinePath] = inlineMatch + const isInlineStart = Editor.isStart(editor, at, inlinePath) + const isInlineEnd = Editor.isEnd(editor, at, inlinePath) + + const middleRef = Editor.pathRef( + editor, + isBlockEnd && !ends.length ? Path.next(blockPath) : blockPath + ) + + const endRef = Editor.pathRef( + editor, + isInlineEnd ? Path.next(inlinePath) : inlinePath + ) + + Transforms.splitNodes(editor, { + at, + match: n => + hasBlocks + ? Element.isElement(n) && Editor.isBlock(editor, n) + : Text.isText(n) || Editor.isInline(editor, n), + mode: hasBlocks ? 'lowest' : 'highest', + always: + hasBlocks && + (!isBlockStart || starts.length > 0) && + (!isBlockEnd || ends.length > 0), + voids, + }) + + const startRef = Editor.pathRef( + editor, + !isInlineStart || (isInlineStart && isInlineEnd) + ? Path.next(inlinePath) + : inlinePath + ) + + Transforms.insertNodes(editor, starts, { + at: startRef.current!, + match: n => Text.isText(n) || Editor.isInline(editor, n), + mode: 'highest', + voids, + }) + + if (isBlockEmpty && !starts.length && middles.length && !ends.length) { + Transforms.delete(editor, { at: blockPath, voids }) + } + + Transforms.insertNodes(editor, middles, { + at: middleRef.current!, + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + mode: 'lowest', + voids, + }) + + Transforms.insertNodes(editor, ends, { + at: endRef.current!, + match: n => Text.isText(n) || Editor.isInline(editor, n), + mode: 'highest', + voids, + }) + + if (!options.at) { + let path + + if (ends.length > 0 && endRef.current) { + path = Path.previous(endRef.current) + } else if (middles.length > 0 && middleRef.current) { + path = Path.previous(middleRef.current) + } else if (startRef.current) { + path = Path.previous(startRef.current) + } + + if (path) { + const end = Editor.end(editor, path) + Transforms.select(editor, end) + } + } + + startRef.unref() + middleRef.unref() + endRef.unref() + }) +} diff --git a/packages/slate/src/transforms/node.ts b/packages/slate/src/transforms/node.ts deleted file mode 100644 index 8cd63f4c8..000000000 --- a/packages/slate/src/transforms/node.ts +++ /dev/null @@ -1,1057 +0,0 @@ -import { - Ancestor, - Editor, - Element, - Location, - Node, - NodeEntry, - Path, - Point, - Range, - Scrubber, - Text, - Transforms, -} from '..' -import { NodeMatch, PropsCompare, PropsMerge } from '../interfaces/editor' -import { PointRef } from '../interfaces/point-ref' -import { RangeMode, MaximizeMode } from '../interfaces/types' - -export interface NodeTransforms { - insertNodes: ( - editor: Editor, - nodes: Node | Node[], - options?: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - select?: boolean - voids?: boolean - } - ) => void - liftNodes: ( - editor: Editor, - options?: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - voids?: boolean - } - ) => void - mergeNodes: ( - editor: Editor, - options?: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - voids?: boolean - } - ) => void - moveNodes: ( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - to: Path - voids?: boolean - } - ) => void - removeNodes: ( - editor: Editor, - options?: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - voids?: boolean - } - ) => void - setNodes: ( - editor: Editor, - props: Partial, - options?: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - hanging?: boolean - split?: boolean - voids?: boolean - compare?: PropsCompare - merge?: PropsMerge - } - ) => void - splitNodes: ( - editor: Editor, - options?: { - at?: Location - match?: NodeMatch - mode?: RangeMode - always?: boolean - height?: number - voids?: boolean - } - ) => void - unsetNodes: ( - editor: Editor, - props: string | string[], - options?: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - hanging?: boolean - split?: boolean - voids?: boolean - } - ) => void - unwrapNodes: ( - editor: Editor, - options?: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - split?: boolean - voids?: boolean - } - ) => void - wrapNodes: ( - editor: Editor, - element: Element, - options?: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - split?: boolean - voids?: boolean - } - ) => void -} - -// eslint-disable-next-line no-redeclare -export const NodeTransforms: NodeTransforms = { - /** - * Insert nodes at a specific location in the Editor. - */ - - insertNodes( - editor: Editor, - nodes: Node | Node[], - options: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - select?: boolean - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { hanging = false, voids = false, mode = 'lowest' } = options - let { at, match, select } = options - - if (Node.isNode(nodes)) { - nodes = [nodes] - } - - if (nodes.length === 0) { - return - } - - const [node] = nodes - - // By default, use the selection as the target location. But if there is - // no selection, insert at the end of the document since that is such a - // common use case when inserting from a non-selected state. - if (!at) { - if (editor.selection) { - at = editor.selection - } else if (editor.children.length > 0) { - at = Editor.end(editor, []) - } else { - at = [0] - } - - select = true - } - - if (select == null) { - select = false - } - - if (Range.isRange(at)) { - if (!hanging) { - at = Editor.unhangRange(editor, at, { voids }) - } - - if (Range.isCollapsed(at)) { - at = at.anchor - } else { - const [, end] = Range.edges(at) - const pointRef = Editor.pointRef(editor, end) - Transforms.delete(editor, { at }) - at = pointRef.unref()! - } - } - - if (Point.isPoint(at)) { - if (match == null) { - if (Text.isText(node)) { - match = n => Text.isText(n) - } else if (editor.isInline(node)) { - match = n => Text.isText(n) || Editor.isInline(editor, n) - } else { - match = n => Element.isElement(n) && Editor.isBlock(editor, n) - } - } - - const [entry] = Editor.nodes(editor, { - at: at.path, - match, - mode, - voids, - }) - - if (entry) { - const [, matchPath] = entry - const pathRef = Editor.pathRef(editor, matchPath) - const isAtEnd = Editor.isEnd(editor, at, matchPath) - Transforms.splitNodes(editor, { at, match, mode, voids }) - const path = pathRef.unref()! - at = isAtEnd ? Path.next(path) : path - } else { - return - } - } - - const parentPath = Path.parent(at) - let index = at[at.length - 1] - - if (!voids && Editor.void(editor, { at: parentPath })) { - return - } - - for (const node of nodes) { - const path = parentPath.concat(index) - index++ - editor.apply({ type: 'insert_node', path, node }) - at = Path.next(at) - } - at = Path.previous(at) - - if (select) { - const point = Editor.end(editor, at) - - if (point) { - Transforms.select(editor, point) - } - } - }) - }, - - /** - * Lift nodes at a specific location upwards in the document tree, splitting - * their parent in two if necessary. - */ - - liftNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { at = editor.selection, mode = 'lowest', voids = false } = options - let { match } = options - - if (match == null) { - match = Path.isPath(at) - ? matchPath(editor, at) - : n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - if (!at) { - return - } - - const matches = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) - - for (const pathRef of pathRefs) { - const path = pathRef.unref()! - - if (path.length < 2) { - throw new Error( - `Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.` - ) - } - - const parentNodeEntry = Editor.node(editor, Path.parent(path)) - const [parent, parentPath] = parentNodeEntry as NodeEntry - const index = path[path.length - 1] - const { length } = parent.children - - if (length === 1) { - const toPath = Path.next(parentPath) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) - Transforms.removeNodes(editor, { at: parentPath, voids }) - } else if (index === 0) { - Transforms.moveNodes(editor, { at: path, to: parentPath, voids }) - } else if (index === length - 1) { - const toPath = Path.next(parentPath) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) - } else { - const splitPath = Path.next(path) - const toPath = Path.next(parentPath) - Transforms.splitNodes(editor, { at: splitPath, voids }) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) - } - } - }) - }, - - /** - * Merge a node at a location with the previous node of the same depth, - * removing any empty containing nodes after the merge if necessary. - */ - - mergeNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - let { match, at = editor.selection } = options - const { hanging = false, voids = false, mode = 'lowest' } = options - - if (!at) { - return - } - - if (match == null) { - if (Path.isPath(at)) { - const [parent] = Editor.parent(editor, at) - match = n => parent.children.includes(n) - } else { - match = n => Element.isElement(n) && Editor.isBlock(editor, n) - } - } - - if (!hanging && Range.isRange(at)) { - at = Editor.unhangRange(editor, at, { voids }) - } - - if (Range.isRange(at)) { - if (Range.isCollapsed(at)) { - at = at.anchor - } else { - const [, end] = Range.edges(at) - const pointRef = Editor.pointRef(editor, end) - Transforms.delete(editor, { at }) - at = pointRef.unref()! - - if (options.at == null) { - Transforms.select(editor, at) - } - } - } - - const [current] = Editor.nodes(editor, { at, match, voids, mode }) - const prev = Editor.previous(editor, { at, match, voids, mode }) - - if (!current || !prev) { - return - } - - const [node, path] = current - const [prevNode, prevPath] = prev - - if (path.length === 0 || prevPath.length === 0) { - return - } - - const newPath = Path.next(prevPath) - const commonPath = Path.common(path, prevPath) - const isPreviousSibling = Path.isSibling(path, prevPath) - const levels = Array.from(Editor.levels(editor, { at: path }), ([n]) => n) - .slice(commonPath.length) - .slice(0, -1) - - // Determine if the merge will leave an ancestor of the path empty as a - // result, in which case we'll want to remove it after merging. - const emptyAncestor = Editor.above(editor, { - at: path, - mode: 'highest', - match: n => levels.includes(n) && hasSingleChildNest(editor, n), - }) - - const emptyRef = emptyAncestor && Editor.pathRef(editor, emptyAncestor[1]) - let properties - let position - - // Ensure that the nodes are equivalent, and figure out what the position - // and extra properties of the merge will be. - if (Text.isText(node) && Text.isText(prevNode)) { - const { text, ...rest } = node - position = prevNode.text.length - properties = rest as Partial - } else if (Element.isElement(node) && Element.isElement(prevNode)) { - const { children, ...rest } = node - position = prevNode.children.length - properties = rest as Partial - } else { - throw new Error( - `Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${Scrubber.stringify( - node - )} ${Scrubber.stringify(prevNode)}` - ) - } - - // If the node isn't already the next sibling of the previous node, move - // it so that it is before merging. - if (!isPreviousSibling) { - Transforms.moveNodes(editor, { at: path, to: newPath, voids }) - } - - // If there was going to be an empty ancestor of the node that was merged, - // we remove it from the tree. - if (emptyRef) { - Transforms.removeNodes(editor, { at: emptyRef.current!, voids }) - } - - // If the target node that we're merging with is empty, remove it instead - // of merging the two. This is a common rich text editor behavior to - // prevent losing formatting when deleting entire nodes when you have a - // hanging selection. - // if prevNode is first child in parent,don't remove it. - if ( - (Element.isElement(prevNode) && Editor.isEmpty(editor, prevNode)) || - (Text.isText(prevNode) && - prevNode.text === '' && - prevPath[prevPath.length - 1] !== 0) - ) { - Transforms.removeNodes(editor, { at: prevPath, voids }) - } else { - editor.apply({ - type: 'merge_node', - path: newPath, - position, - properties, - }) - } - - if (emptyRef) { - emptyRef.unref() - } - }) - }, - - /** - * Move the nodes at a location to a new location. - */ - - moveNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - to: Path - voids?: boolean - } - ): void { - Editor.withoutNormalizing(editor, () => { - const { - to, - at = editor.selection, - mode = 'lowest', - voids = false, - } = options - let { match } = options - - if (!at) { - return - } - - if (match == null) { - match = Path.isPath(at) - ? matchPath(editor, at) - : n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - const toRef = Editor.pathRef(editor, to) - const targets = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from(targets, ([, p]) => Editor.pathRef(editor, p)) - - for (const pathRef of pathRefs) { - const path = pathRef.unref()! - const newPath = toRef.current! - - if (path.length !== 0) { - editor.apply({ type: 'move_node', path, newPath }) - } - - if ( - toRef.current && - Path.isSibling(newPath, path) && - Path.isAfter(newPath, path) - ) { - // When performing a sibling move to a later index, the path at the destination is shifted - // to before the insertion point instead of after. To ensure our group of nodes are inserted - // in the correct order we increment toRef to account for that - toRef.current = Path.next(toRef.current) - } - } - - toRef.unref() - }) - }, - - /** - * Remove the nodes at a specific location in the document. - */ - - removeNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: RangeMode - hanging?: boolean - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { hanging = false, voids = false, mode = 'lowest' } = options - let { at = editor.selection, match } = options - - if (!at) { - return - } - - if (match == null) { - match = Path.isPath(at) - ? matchPath(editor, at) - : n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - if (!hanging && Range.isRange(at)) { - at = Editor.unhangRange(editor, at, { voids }) - } - - const depths = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p)) - - for (const pathRef of pathRefs) { - const path = pathRef.unref()! - - if (path) { - const [node] = Editor.node(editor, path) - editor.apply({ type: 'remove_node', path, node }) - } - } - }) - }, - - /** - * Set new properties on the nodes at a location. - */ - - setNodes( - editor: Editor, - props: Partial, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - hanging?: boolean - split?: boolean - voids?: boolean - compare?: PropsCompare - merge?: PropsMerge - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - let { match, at = editor.selection, compare, merge } = options - const { - hanging = false, - mode = 'lowest', - split = false, - voids = false, - } = options - - if (!at) { - return - } - - if (match == null) { - match = Path.isPath(at) - ? matchPath(editor, at) - : n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - if (!hanging && Range.isRange(at)) { - at = Editor.unhangRange(editor, at, { voids }) - } - - if (split && Range.isRange(at)) { - if ( - Range.isCollapsed(at) && - Editor.leaf(editor, at.anchor)[0].text.length > 0 - ) { - // If the range is collapsed in a non-empty node and 'split' is true, there's nothing to - // set that won't get normalized away - return - } - const rangeRef = Editor.rangeRef(editor, at, { affinity: 'inward' }) - const [start, end] = Range.edges(at) - const splitMode = mode === 'lowest' ? 'lowest' : 'highest' - const endAtEndOfNode = Editor.isEnd(editor, end, end.path) - Transforms.splitNodes(editor, { - at: end, - match, - mode: splitMode, - voids, - always: !endAtEndOfNode, - }) - const startAtStartOfNode = Editor.isStart(editor, start, start.path) - Transforms.splitNodes(editor, { - at: start, - match, - mode: splitMode, - voids, - always: !startAtStartOfNode, - }) - at = rangeRef.unref()! - - if (options.at == null) { - Transforms.select(editor, at) - } - } - - if (!compare) { - compare = (prop, nodeProp) => prop !== nodeProp - } - - for (const [node, path] of Editor.nodes(editor, { - at, - match, - mode, - voids, - })) { - const properties: Partial = {} - const newProperties: Partial = {} - - // You can't set properties on the editor node. - if (path.length === 0) { - continue - } - - let hasChanges = false - - for (const k in props) { - if (k === 'children' || k === 'text') { - continue - } - - if (compare(props[k], node[k])) { - hasChanges = true - // Omit new properties from the old properties list - if (node.hasOwnProperty(k)) properties[k] = node[k] - // Omit properties that have been removed from the new properties list - if (merge) { - if (props[k] != null) newProperties[k] = merge(node[k], props[k]) - } else { - if (props[k] != null) newProperties[k] = props[k] - } - } - } - - if (hasChanges) { - editor.apply({ - type: 'set_node', - path, - properties, - newProperties, - }) - } - } - }) - }, - - /** - * Split the nodes at a specific location. - */ - - splitNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: RangeMode - always?: boolean - height?: number - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection, height = 0, always = false } = options - - if (match == null) { - match = n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - if (Range.isRange(at)) { - at = deleteRange(editor, at) - } - - // If the target is a path, the default height-skipping and position - // counters need to account for us potentially splitting at a non-leaf. - if (Path.isPath(at)) { - const path = at - const point = Editor.point(editor, path) - const [parent] = Editor.parent(editor, path) - match = n => n === parent - height = point.path.length - path.length + 1 - at = point - always = true - } - - if (!at) { - return - } - - const beforeRef = Editor.pointRef(editor, at, { - affinity: 'backward', - }) - let afterRef: PointRef | undefined - try { - const [highest] = Editor.nodes(editor, { at, match, mode, voids }) - - if (!highest) { - return - } - - const voidMatch = Editor.void(editor, { at, mode: 'highest' }) - const nudge = 0 - - if (!voids && voidMatch) { - const [voidNode, voidPath] = voidMatch - - if (Element.isElement(voidNode) && editor.isInline(voidNode)) { - let after = Editor.after(editor, voidPath) - - if (!after) { - const text = { text: '' } - const afterPath = Path.next(voidPath) - Transforms.insertNodes(editor, text, { at: afterPath, voids }) - after = Editor.point(editor, afterPath)! - } - - at = after - always = true - } - - const siblingHeight = at.path.length - voidPath.length - height = siblingHeight + 1 - always = true - } - - afterRef = Editor.pointRef(editor, at) - const depth = at.path.length - height - const [, highestPath] = highest - const lowestPath = at.path.slice(0, depth) - let position = height === 0 ? at.offset : at.path[depth] + nudge - - for (const [node, path] of Editor.levels(editor, { - at: lowestPath, - reverse: true, - voids, - })) { - let split = false - - if ( - path.length < highestPath.length || - path.length === 0 || - (!voids && Element.isElement(node) && Editor.isVoid(editor, node)) - ) { - break - } - - const point = beforeRef.current! - const isEnd = Editor.isEnd(editor, point, path) - - if (always || !beforeRef || !Editor.isEdge(editor, point, path)) { - split = true - const properties = Node.extractProps(node) - editor.apply({ - type: 'split_node', - path, - position, - properties, - }) - } - - position = path[path.length - 1] + (split || isEnd ? 1 : 0) - } - - if (options.at == null) { - const point = afterRef.current || Editor.end(editor, []) - Transforms.select(editor, point) - } - } finally { - beforeRef.unref() - afterRef?.unref() - } - }) - }, - - /** - * Unset properties on the nodes at a location. - */ - - unsetNodes( - editor: Editor, - props: string | string[], - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - hanging?: boolean - split?: boolean - voids?: boolean - } = {} - ): void { - if (!Array.isArray(props)) { - props = [props] - } - - const obj = {} - - for (const key of props) { - obj[key] = null - } - - Transforms.setNodes(editor, obj, options) - }, - - /** - * Unwrap the nodes at a location from a parent node, splitting the parent if - * necessary to ensure that only the content in the range is unwrapped. - */ - - unwrapNodes( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - split?: boolean - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { mode = 'lowest', split = false, voids = false } = options - let { at = editor.selection, match } = options - - if (!at) { - return - } - - if (match == null) { - match = Path.isPath(at) - ? matchPath(editor, at) - : n => Element.isElement(n) && Editor.isBlock(editor, n) - } - - if (Path.isPath(at)) { - at = Editor.range(editor, at) - } - - const rangeRef = Range.isRange(at) ? Editor.rangeRef(editor, at) : null - const matches = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from( - matches, - ([, p]) => Editor.pathRef(editor, p) - // unwrapNode will call liftNode which does not support splitting the node when nested. - // If we do not reverse the order and call it from top to the bottom, it will remove all blocks - // that wrap target node. So we reverse the order. - ).reverse() - - for (const pathRef of pathRefs) { - const path = pathRef.unref()! - const [node] = Editor.node(editor, path) - let range = Editor.range(editor, path) - - if (split && rangeRef) { - range = Range.intersection(rangeRef.current!, range)! - } - - Transforms.liftNodes(editor, { - at: range, - match: n => Element.isAncestor(node) && node.children.includes(n), - voids, - }) - } - - if (rangeRef) { - rangeRef.unref() - } - }) - }, - - /** - * Wrap the nodes at a location in a new container node, splitting the edges - * of the range first to ensure that only the content in the range is wrapped. - */ - - wrapNodes( - editor: Editor, - element: Element, - options: { - at?: Location - match?: NodeMatch - mode?: MaximizeMode - split?: boolean - voids?: boolean - } = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { mode = 'lowest', split = false, voids = false } = options - let { match, at = editor.selection } = options - - if (!at) { - return - } - - if (match == null) { - if (Path.isPath(at)) { - match = matchPath(editor, at) - } else if (editor.isInline(element)) { - match = n => - (Element.isElement(n) && Editor.isInline(editor, n)) || - Text.isText(n) - } else { - match = n => Element.isElement(n) && Editor.isBlock(editor, n) - } - } - - if (split && Range.isRange(at)) { - const [start, end] = Range.edges(at) - const rangeRef = Editor.rangeRef(editor, at, { - affinity: 'inward', - }) - Transforms.splitNodes(editor, { at: end, match, voids }) - Transforms.splitNodes(editor, { at: start, match, voids }) - at = rangeRef.unref()! - - if (options.at == null) { - Transforms.select(editor, at) - } - } - - const roots = Array.from( - Editor.nodes(editor, { - at, - match: editor.isInline(element) - ? n => Element.isElement(n) && Editor.isBlock(editor, n) - : n => Editor.isEditor(n), - mode: 'lowest', - voids, - }) - ) - - for (const [, rootPath] of roots) { - const a = Range.isRange(at) - ? Range.intersection(at, Editor.range(editor, rootPath)) - : at - - if (!a) { - continue - } - - const matches = Array.from( - Editor.nodes(editor, { at: a, match, mode, voids }) - ) - - if (matches.length > 0) { - const [first] = matches - const last = matches[matches.length - 1] - const [, firstPath] = first - const [, lastPath] = last - - if (firstPath.length === 0 && lastPath.length === 0) { - // if there's no matching parent - usually means the node is an editor - don't do anything - continue - } - - const commonPath = Path.equals(firstPath, lastPath) - ? Path.parent(firstPath) - : Path.common(firstPath, lastPath) - - const range = Editor.range(editor, firstPath, lastPath) - const commonNodeEntry = Editor.node(editor, commonPath) - const [commonNode] = commonNodeEntry - const depth = commonPath.length + 1 - const wrapperPath = Path.next(lastPath.slice(0, depth)) - const wrapper = { ...element, children: [] } - Transforms.insertNodes(editor, wrapper, { at: wrapperPath, voids }) - - Transforms.moveNodes(editor, { - at: range, - match: n => - Element.isAncestor(commonNode) && commonNode.children.includes(n), - to: wrapperPath.concat(0), - voids, - }) - } - } - }) - }, -} - -const hasSingleChildNest = (editor: Editor, node: Node): boolean => { - if (Element.isElement(node)) { - const element = node as Element - if (Editor.isVoid(editor, node)) { - return true - } else if (element.children.length === 1) { - return hasSingleChildNest(editor, element.children[0]) - } else { - return false - } - } else if (Editor.isEditor(node)) { - return false - } else { - return true - } -} - -/** - * Convert a range into a point by deleting it's content. - */ - -const deleteRange = (editor: Editor, range: Range): Point | null => { - if (Range.isCollapsed(range)) { - return range.anchor - } else { - const [, end] = Range.edges(range) - const pointRef = Editor.pointRef(editor, end) - Transforms.delete(editor, { at: range }) - return pointRef.unref() - } -} - -const matchPath = (editor: Editor, path: Path): ((node: Node) => boolean) => { - const [node] = Editor.node(editor, path) - return n => n === node -} diff --git a/packages/slate/src/transforms/selection.ts b/packages/slate/src/transforms/selection.ts deleted file mode 100644 index 8acc5b97d..000000000 --- a/packages/slate/src/transforms/selection.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Editor, Location, Point, Range, Scrubber, Transforms } from '..' -import { SelectionEdge, MoveUnit } from '../interfaces/types' - -export interface SelectionCollapseOptions { - edge?: SelectionEdge -} - -export interface SelectionMoveOptions { - distance?: number - unit?: MoveUnit - reverse?: boolean - edge?: SelectionEdge -} - -export interface SelectionSetPointOptions { - edge?: SelectionEdge -} - -export interface SelectionTransforms { - collapse: (editor: Editor, options?: SelectionCollapseOptions) => void - deselect: (editor: Editor) => void - move: (editor: Editor, options?: SelectionMoveOptions) => void - select: (editor: Editor, target: Location) => void - setPoint: ( - editor: Editor, - props: Partial, - options?: SelectionSetPointOptions - ) => void - setSelection: (editor: Editor, props: Partial) => void -} - -// eslint-disable-next-line no-redeclare -export const SelectionTransforms: SelectionTransforms = { - /** - * Collapse the selection. - */ - - collapse(editor: Editor, options: SelectionCollapseOptions = {}): void { - const { edge = 'anchor' } = options - const { selection } = editor - - if (!selection) { - return - } else if (edge === 'anchor') { - Transforms.select(editor, selection.anchor) - } else if (edge === 'focus') { - Transforms.select(editor, selection.focus) - } else if (edge === 'start') { - const [start] = Range.edges(selection) - Transforms.select(editor, start) - } else if (edge === 'end') { - const [, end] = Range.edges(selection) - Transforms.select(editor, end) - } - }, - - /** - * Unset the selection. - */ - - deselect(editor: Editor): void { - const { selection } = editor - - if (selection) { - editor.apply({ - type: 'set_selection', - properties: selection, - newProperties: null, - }) - } - }, - - /** - * Move the selection's point forward or backward. - */ - - move(editor: Editor, options: SelectionMoveOptions = {}): void { - const { selection } = editor - const { distance = 1, unit = 'character', reverse = false } = options - let { edge = null } = options - - if (!selection) { - return - } - - if (edge === 'start') { - edge = Range.isBackward(selection) ? 'focus' : 'anchor' - } - - if (edge === 'end') { - edge = Range.isBackward(selection) ? 'anchor' : 'focus' - } - - const { anchor, focus } = selection - const opts = { distance, unit, ignoreNonSelectable: true } - const props: Partial = {} - - if (edge == null || edge === 'anchor') { - const point = reverse - ? Editor.before(editor, anchor, opts) - : Editor.after(editor, anchor, opts) - - if (point) { - props.anchor = point - } - } - - if (edge == null || edge === 'focus') { - const point = reverse - ? Editor.before(editor, focus, opts) - : Editor.after(editor, focus, opts) - - if (point) { - props.focus = point - } - } - - Transforms.setSelection(editor, props) - }, - - /** - * Set the selection to a new value. - */ - - select(editor: Editor, target: Location): void { - const { selection } = editor - target = Editor.range(editor, target) - - if (selection) { - Transforms.setSelection(editor, target) - return - } - - if (!Range.isRange(target)) { - throw new Error( - `When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${Scrubber.stringify( - target - )}` - ) - } - - editor.apply({ - type: 'set_selection', - properties: selection, - newProperties: target, - }) - }, - - /** - * Set new properties on one of the selection's points. - */ - - setPoint( - editor: Editor, - props: Partial, - options: SelectionSetPointOptions = {} - ): void { - const { selection } = editor - let { edge = 'both' } = options - - if (!selection) { - return - } - - if (edge === 'start') { - edge = Range.isBackward(selection) ? 'focus' : 'anchor' - } - - if (edge === 'end') { - edge = Range.isBackward(selection) ? 'anchor' : 'focus' - } - - const { anchor, focus } = selection - const point = edge === 'anchor' ? anchor : focus - - Transforms.setSelection(editor, { - [edge === 'anchor' ? 'anchor' : 'focus']: { ...point, ...props }, - }) - }, - - /** - * Set new properties on the selection. - */ - - setSelection(editor: Editor, props: Partial): void { - const { selection } = editor - const oldProps: Partial | null = {} - const newProps: Partial = {} - - if (!selection) { - return - } - - for (const k in props) { - if ( - (k === 'anchor' && - props.anchor != null && - !Point.equals(props.anchor, selection.anchor)) || - (k === 'focus' && - props.focus != null && - !Point.equals(props.focus, selection.focus)) || - (k !== 'anchor' && k !== 'focus' && props[k] !== selection[k]) - ) { - oldProps[k] = selection[k] - newProps[k] = props[k] - } - } - - if (Object.keys(oldProps).length > 0) { - editor.apply({ - type: 'set_selection', - properties: oldProps, - newProperties: newProps, - }) - } - }, -} diff --git a/packages/slate/src/transforms/text.ts b/packages/slate/src/transforms/text.ts deleted file mode 100644 index 04a497775..000000000 --- a/packages/slate/src/transforms/text.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { - Editor, - Element, - Location, - Node, - NodeEntry, - Path, - Text, - Point, - Range, - Transforms, -} from '..' -import { TextUnit } from '../interfaces/types' - -export interface TextDeleteOptions { - at?: Location - distance?: number - unit?: TextUnit - reverse?: boolean - hanging?: boolean - voids?: boolean -} - -export interface TextInsertFragmentOptions { - at?: Location - hanging?: boolean - voids?: boolean -} - -export interface TextInsertTextOptions { - at?: Location - voids?: boolean -} - -export interface TextTransforms { - delete: (editor: Editor, options?: TextDeleteOptions) => void - insertFragment: ( - editor: Editor, - fragment: Node[], - options?: TextInsertFragmentOptions - ) => void - insertText: ( - editor: Editor, - text: string, - options?: TextInsertTextOptions - ) => void -} - -// eslint-disable-next-line no-redeclare -export const TextTransforms: TextTransforms = { - /** - * Delete content in the editor. - */ - - delete(editor: Editor, options: TextDeleteOptions = {}): void { - Editor.withoutNormalizing(editor, () => { - const { - reverse = false, - unit = 'character', - distance = 1, - voids = false, - } = options - let { at = editor.selection, hanging = false } = options - - if (!at) { - return - } - - let isCollapsed = false - if (Range.isRange(at) && Range.isCollapsed(at)) { - isCollapsed = true - at = at.anchor - } - - if (Point.isPoint(at)) { - const furthestVoid = Editor.void(editor, { at, mode: 'highest' }) - - if (!voids && furthestVoid) { - const [, voidPath] = furthestVoid - at = voidPath - } else { - const opts = { unit, distance } - const target = reverse - ? Editor.before(editor, at, opts) || Editor.start(editor, []) - : Editor.after(editor, at, opts) || Editor.end(editor, []) - at = { anchor: at, focus: target } - hanging = true - } - } - - if (Path.isPath(at)) { - Transforms.removeNodes(editor, { at, voids }) - return - } - - if (Range.isCollapsed(at)) { - return - } - - if (!hanging) { - const [, end] = Range.edges(at) - const endOfDoc = Editor.end(editor, []) - - if (!Point.equals(end, endOfDoc)) { - at = Editor.unhangRange(editor, at, { voids }) - } - } - - let [start, end] = Range.edges(at) - const startBlock = Editor.above(editor, { - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - at: start, - voids, - }) - const endBlock = Editor.above(editor, { - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - at: end, - voids, - }) - const isAcrossBlocks = - startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1]) - const isSingleText = Path.equals(start.path, end.path) - const startNonEditable = voids - ? null - : Editor.void(editor, { at: start, mode: 'highest' }) ?? - Editor.elementReadOnly(editor, { at: start, mode: 'highest' }) - const endNonEditable = voids - ? null - : Editor.void(editor, { at: end, mode: 'highest' }) ?? - Editor.elementReadOnly(editor, { at: end, mode: 'highest' }) - - // If the start or end points are inside an inline void, nudge them out. - if (startNonEditable) { - const before = Editor.before(editor, start) - - if ( - before && - startBlock && - Path.isAncestor(startBlock[1], before.path) - ) { - start = before - } - } - - if (endNonEditable) { - const after = Editor.after(editor, end) - - if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) { - end = after - } - } - - // Get the highest nodes that are completely inside the range, as well as - // the start and end nodes. - const matches: NodeEntry[] = [] - let lastPath: Path | undefined - - for (const entry of Editor.nodes(editor, { at, voids })) { - const [node, path] = entry - - if (lastPath && Path.compare(path, lastPath) === 0) { - continue - } - - if ( - (!voids && - Element.isElement(node) && - (Editor.isVoid(editor, node) || - Editor.isElementReadOnly(editor, node))) || - (!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path)) - ) { - matches.push(entry) - lastPath = path - } - } - - const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) - const startRef = Editor.pointRef(editor, start) - const endRef = Editor.pointRef(editor, end) - - let removedText = '' - - if (!isSingleText && !startNonEditable) { - const point = startRef.current! - const [node] = Editor.leaf(editor, point) - const { path } = point - const { offset } = start - const text = node.text.slice(offset) - if (text.length > 0) { - editor.apply({ type: 'remove_text', path, offset, text }) - removedText = text - } - } - - pathRefs - .reverse() - .map(r => r.unref()) - .filter((r): r is Path => r !== null) - .forEach(p => Transforms.removeNodes(editor, { at: p, voids })) - - if (!endNonEditable) { - const point = endRef.current! - const [node] = Editor.leaf(editor, point) - const { path } = point - const offset = isSingleText ? start.offset : 0 - const text = node.text.slice(offset, end.offset) - if (text.length > 0) { - editor.apply({ type: 'remove_text', path, offset, text }) - removedText = text - } - } - - if ( - !isSingleText && - isAcrossBlocks && - endRef.current && - startRef.current - ) { - Transforms.mergeNodes(editor, { - at: endRef.current, - hanging: true, - voids, - }) - } - - // For Thai script, deleting N character(s) backward should delete - // N code point(s) instead of an entire grapheme cluster. - // Therefore, the remaining code points should be inserted back. - if ( - isCollapsed && - reverse && - unit === 'character' && - removedText.length > 1 && - removedText.match(/[\u0E00-\u0E7F]+/) - ) { - Transforms.insertText( - editor, - removedText.slice(0, removedText.length - distance) - ) - } - - const startUnref = startRef.unref() - const endUnref = endRef.unref() - const point = reverse ? startUnref || endUnref : endUnref || startUnref - - if (options.at == null && point) { - Transforms.select(editor, point) - } - }) - }, - - /** - * Insert a fragment at a specific location in the editor. - */ - - insertFragment( - editor: Editor, - fragment: Node[], - options: TextInsertFragmentOptions = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { hanging = false, voids = false } = options - let { at = editor.selection } = options - - if (!fragment.length) { - return - } - - if (!at) { - return - } else if (Range.isRange(at)) { - if (!hanging) { - at = Editor.unhangRange(editor, at, { voids }) - } - - if (Range.isCollapsed(at)) { - at = at.anchor - } else { - const [, end] = Range.edges(at) - - if (!voids && Editor.void(editor, { at: end })) { - return - } - - const pointRef = Editor.pointRef(editor, end) - Transforms.delete(editor, { at }) - at = pointRef.unref()! - } - } else if (Path.isPath(at)) { - at = Editor.start(editor, at) - } - - if (!voids && Editor.void(editor, { at })) { - return - } - - // If the insert point is at the edge of an inline node, move it outside - // instead since it will need to be split otherwise. - const inlineElementMatch = Editor.above(editor, { - at, - match: n => Element.isElement(n) && Editor.isInline(editor, n), - mode: 'highest', - voids, - }) - - if (inlineElementMatch) { - const [, inlinePath] = inlineElementMatch - - if (Editor.isEnd(editor, at, inlinePath)) { - const after = Editor.after(editor, inlinePath)! - at = after - } else if (Editor.isStart(editor, at, inlinePath)) { - const before = Editor.before(editor, inlinePath)! - at = before - } - } - - const blockMatch = Editor.above(editor, { - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - at, - voids, - })! - const [, blockPath] = blockMatch - const isBlockStart = Editor.isStart(editor, at, blockPath) - const isBlockEnd = Editor.isEnd(editor, at, blockPath) - const isBlockEmpty = isBlockStart && isBlockEnd - const mergeStart = !isBlockStart || (isBlockStart && isBlockEnd) - const mergeEnd = !isBlockEnd - const [, firstPath] = Node.first({ children: fragment }, []) - const [, lastPath] = Node.last({ children: fragment }, []) - - const matches: NodeEntry[] = [] - const matcher = ([n, p]: NodeEntry) => { - const isRoot = p.length === 0 - if (isRoot) { - return false - } - - if (isBlockEmpty) { - return true - } - - if ( - mergeStart && - Path.isAncestor(p, firstPath) && - Element.isElement(n) && - !editor.isVoid(n) && - !editor.isInline(n) - ) { - return false - } - - if ( - mergeEnd && - Path.isAncestor(p, lastPath) && - Element.isElement(n) && - !editor.isVoid(n) && - !editor.isInline(n) - ) { - return false - } - - return true - } - - for (const entry of Node.nodes( - { children: fragment }, - { pass: matcher } - )) { - if (matcher(entry)) { - matches.push(entry) - } - } - - const starts = [] - const middles = [] - const ends = [] - let starting = true - let hasBlocks = false - - for (const [node] of matches) { - if (Element.isElement(node) && !editor.isInline(node)) { - starting = false - hasBlocks = true - middles.push(node) - } else if (starting) { - starts.push(node) - } else { - ends.push(node) - } - } - - const [inlineMatch] = Editor.nodes(editor, { - at, - match: n => Text.isText(n) || Editor.isInline(editor, n), - mode: 'highest', - voids, - })! - - const [, inlinePath] = inlineMatch - const isInlineStart = Editor.isStart(editor, at, inlinePath) - const isInlineEnd = Editor.isEnd(editor, at, inlinePath) - - const middleRef = Editor.pathRef( - editor, - isBlockEnd && !ends.length ? Path.next(blockPath) : blockPath - ) - - const endRef = Editor.pathRef( - editor, - isInlineEnd ? Path.next(inlinePath) : inlinePath - ) - - Transforms.splitNodes(editor, { - at, - match: n => - hasBlocks - ? Element.isElement(n) && Editor.isBlock(editor, n) - : Text.isText(n) || Editor.isInline(editor, n), - mode: hasBlocks ? 'lowest' : 'highest', - always: - hasBlocks && - (!isBlockStart || starts.length > 0) && - (!isBlockEnd || ends.length > 0), - voids, - }) - - const startRef = Editor.pathRef( - editor, - !isInlineStart || (isInlineStart && isInlineEnd) - ? Path.next(inlinePath) - : inlinePath - ) - - Transforms.insertNodes(editor, starts, { - at: startRef.current!, - match: n => Text.isText(n) || Editor.isInline(editor, n), - mode: 'highest', - voids, - }) - - if (isBlockEmpty && !starts.length && middles.length && !ends.length) { - Transforms.delete(editor, { at: blockPath, voids }) - } - - Transforms.insertNodes(editor, middles, { - at: middleRef.current!, - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - mode: 'lowest', - voids, - }) - - Transforms.insertNodes(editor, ends, { - at: endRef.current!, - match: n => Text.isText(n) || Editor.isInline(editor, n), - mode: 'highest', - voids, - }) - - if (!options.at) { - let path - - if (ends.length > 0 && endRef.current) { - path = Path.previous(endRef.current) - } else if (middles.length > 0 && middleRef.current) { - path = Path.previous(middleRef.current) - } else if (startRef.current) { - path = Path.previous(startRef.current) - } - - if (path) { - const end = Editor.end(editor, path) - Transforms.select(editor, end) - } - } - - startRef.unref() - middleRef.unref() - endRef.unref() - }) - }, - - /** - * Insert a string of text in the Editor. - */ - - insertText( - editor: Editor, - text: string, - options: TextInsertTextOptions = {} - ): void { - Editor.withoutNormalizing(editor, () => { - const { voids = false } = options - let { at = editor.selection } = options - - if (!at) { - return - } - - if (Path.isPath(at)) { - at = Editor.range(editor, at) - } - - if (Range.isRange(at)) { - if (Range.isCollapsed(at)) { - at = at.anchor - } else { - const end = Range.end(at) - if (!voids && Editor.void(editor, { at: end })) { - return - } - const start = Range.start(at) - const startRef = Editor.pointRef(editor, start) - const endRef = Editor.pointRef(editor, end) - Transforms.delete(editor, { at, voids }) - const startPoint = startRef.unref() - const endPoint = endRef.unref() - - at = startPoint || endPoint! - Transforms.setSelection(editor, { anchor: at, focus: at }) - } - } - - if ( - (!voids && Editor.void(editor, { at })) || - Editor.elementReadOnly(editor, { at }) - ) { - return - } - - const { path, offset } = at - if (text.length > 0) - editor.apply({ type: 'insert_text', path, offset, text }) - }) - }, -} diff --git a/packages/slate/src/interfaces/custom-types.ts b/packages/slate/src/types/custom-types.ts similarity index 100% rename from packages/slate/src/interfaces/custom-types.ts rename to packages/slate/src/types/custom-types.ts diff --git a/packages/slate/src/types/index.ts b/packages/slate/src/types/index.ts new file mode 100644 index 000000000..02112c691 --- /dev/null +++ b/packages/slate/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './custom-types' +export * from './types' diff --git a/packages/slate/src/interfaces/types.ts b/packages/slate/src/types/types.ts similarity index 100% rename from packages/slate/src/interfaces/types.ts rename to packages/slate/src/types/types.ts diff --git a/packages/slate/src/utils/index.ts b/packages/slate/src/utils/index.ts new file mode 100644 index 000000000..a8ad6e699 --- /dev/null +++ b/packages/slate/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './deep-equal' +export * from './match-path' +export * from './string' +export * from './types' +export * from './weak-maps' diff --git a/packages/slate/src/utils/match-path.ts b/packages/slate/src/utils/match-path.ts new file mode 100644 index 000000000..5aeda77d9 --- /dev/null +++ b/packages/slate/src/utils/match-path.ts @@ -0,0 +1,11 @@ +import { Editor } from '../interfaces/editor' +import { Path } from '../interfaces/path' +import { Node } from '../interfaces/node' + +export const matchPath = ( + editor: Editor, + path: Path +): ((node: Node) => boolean) => { + const [node] = Editor.node(editor, path) + return n => n === node +} diff --git a/packages/slate/src/utils/types.ts b/packages/slate/src/utils/types.ts new file mode 100644 index 000000000..b2ece341b --- /dev/null +++ b/packages/slate/src/utils/types.ts @@ -0,0 +1,17 @@ +import { Editor } from '../interfaces/editor' + +export type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R + ? (...args: P) => R + : never + +export type OmitFirstArgWithSpecificGeneric = F extends ( + x: any, + ...args: infer P +) => infer R + ? (...args: P) => R + : never + +export type WithEditorFirstArg any> = ( + editor: Editor, + ...args: Parameters +) => ReturnType diff --git a/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx b/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx index 50f738442..7eec6b2ad 100644 --- a/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx +++ b/packages/slate/test/transforms/setNodes/marks/mark-void-range-hanging.tsx @@ -1,6 +1,6 @@ /** @jsx jsx */ // Apply a mark across a range containing text with other marks and some voids that support marks -import { Editor, Transforms } from 'slate' +import { Editor } from 'slate' import { jsx } from '../../..' export const run = editor => {