1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-30 10:29:48 +02:00

Refactor editor methods and fix JSDoc (#5307)

* 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

* 🔀

* 🔀
This commit is contained in:
Ziad Beyens
2023-04-20 13:55:43 +02:00
committed by GitHub
parent 161af4c70d
commit 3243c7e34a
125 changed files with 5286 additions and 4367 deletions

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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<HTMLElement>,

View File

@@ -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.

View File

@@ -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'

View File

@@ -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: <T extends boolean>(
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: <T extends boolean>(
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<T extends boolean>(
toSlatePoint: <T extends boolean>(
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<T extends boolean>(
toSlateRange: <T extends boolean>(
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)
},
}

View File

@@ -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) => {

View File

@@ -0,0 +1,3 @@
export type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => infer R
? (...args: P) => R
: never

View File

@@ -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['apply']> = (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<string>
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 = []
})
}
}

View File

@@ -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['getDirtyPaths']> = (
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 []
}
}
}

View File

@@ -0,0 +1,11 @@
import { Editor, Node } from '../interfaces'
import { WithEditorFirstArg } from '../utils'
export const getFragment: WithEditorFirstArg<Editor['getFragment']> = editor => {
const { selection } = editor
if (selection) {
return Node.fragment(editor, selection)
}
return []
}

View File

@@ -0,0 +1,5 @@
export * from './apply'
export * from './get-dirty-paths'
export * from './get-fragment'
export * from './normalize-node'
export * from './should-normalize'

View File

@@ -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['normalizeNode']> = (
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--
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
import { WithEditorFirstArg } from '../utils/types'
import { Editor } from '../interfaces/editor'
export const shouldNormalize: WithEditorFirstArg<Editor['shouldNormalize']> = (
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
}

View File

@@ -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<string>
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

View File

@@ -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]
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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['deleteBackward']> = (
editor,
unit
) => {
const { selection } = editor
if (selection && Range.isCollapsed(selection)) {
Transforms.delete(editor, { unit, reverse: true })
}
}

View File

@@ -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['deleteForward']> = (
editor,
unit
) => {
const { selection } = editor
if (selection && Range.isCollapsed(selection)) {
Transforms.delete(editor, { unit })
}
}

View File

@@ -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' })
}
}

View File

@@ -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)]
}

View File

@@ -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),
})
}

View File

@@ -0,0 +1,5 @@
import { Editor, EditorInterface } from '../interfaces/editor'
export const end: EditorInterface['end'] = (editor, at) => {
return Editor.point(editor, at, { edge: 'end' })
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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),
})
}

View File

@@ -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)
)
}

View File

@@ -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)
)
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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'

View File

@@ -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 })
}

View File

@@ -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)
}

View File

@@ -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 })
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,5 @@
import { EditorInterface } from '../interfaces/editor'
export const isBlock: EditorInterface['isBlock'] = (editor, value) => {
return !editor.isInline(value)
}

View File

@@ -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)
}

View File

@@ -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<object, boolean>()
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
}

View File

@@ -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))
)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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]
}

View File

@@ -0,0 +1,40 @@
import { Node, NodeEntry } from '../interfaces/node'
import { Editor, EditorLevelsOptions } from '../interfaces/editor'
import { Element } from '../interfaces/element'
export function* levels<T extends Node>(
editor: Editor,
options: EditorLevelsOptions<T> = {}
): Generator<NodeEntry<T>, 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<T>[] = []
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
}

View File

@@ -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<Text>
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
}

View File

@@ -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
}

View File

@@ -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]
}

View File

@@ -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<T extends Node>(
editor: Editor,
options: EditorNodesOptions<T> = {}
): Generator<NodeEntry<T>, 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<T>[] = []
let hit: NodeEntry<T> | 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<T> | 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
}
}

View File

@@ -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)
}
})
}

View File

@@ -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<Ancestor>
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<Point, void, undefined> {
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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,5 @@
import { Editor, EditorInterface } from '../interfaces/editor'
export const start: EditorInterface['start'] = (editor, at) => {
return Editor.point(editor, at, { edge: 'start' })
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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)
}

View File

@@ -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'

File diff suppressed because it is too large Load Diff

View File

@@ -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<Element>
/**
* Check if a value implements the `Element` interface and has elementKey with selected value.
* Default it check to `type` key value
*/
isElementType: <T extends Element>(
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<Element>) => 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<Element> {
return (props as Partial<Element>).children !== undefined
},
/**
* Check if a value implements the `Element` interface and has elementKey with selected value.
* Default it check to `type` key value
*/
isElementType: <T extends Element>(
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<Element>): 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]

View File

@@ -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'

View File

@@ -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)

View File

@@ -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<NodeEntry<Ancestor>, 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<NodeEntry<Descendant>, 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<NodeEntry<Descendant>, 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<ElementEntry, void, undefined>
/**
* 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<NodeEntry, void, undefined>
/**
* Check if a node matches a set of props.
*/
matches: (node: Node, props: Partial<Node>) => 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<NodeEntry, void, undefined>
/**
* 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<any[], boolean>()
// 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<Node>): 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 = {}

View File

@@ -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': {

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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<PointEntry, void, undefined>
/**
* 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<PointEntry, void, undefined> {
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,

View File

@@ -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<Text>
matches: (text: Text, props: Partial<Text>) => 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<Text>
/**
* 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<Text>) => 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<Text> {
return (props as Partial<Text>).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<Text>): 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 }]

View File

@@ -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)

View File

@@ -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: <T extends Node>(
editor: Editor,
nodes: Node | Node[],
options?: {
at?: Location
match?: NodeMatch<T>
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: <T extends Node>(
editor: Editor,
options?: {
at?: Location
match?: NodeMatch<T>
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: <T extends Node>(
editor: Editor,
options?: {
at?: Location
match?: NodeMatch<T>
mode?: RangeMode
hanging?: boolean
voids?: boolean
}
) => void
/**
* Move the nodes at a location to a new location.
*/
moveNodes: <T extends Node>(
editor: Editor,
options: {
at?: Location
match?: NodeMatch<T>
mode?: MaximizeMode
to: Path
voids?: boolean
}
) => void
/**
* Remove the nodes at a specific location in the document.
*/
removeNodes: <T extends Node>(
editor: Editor,
options?: {
at?: Location
match?: NodeMatch<T>
mode?: RangeMode
hanging?: boolean
voids?: boolean
}
) => void
/**
* Set new properties on the nodes at a location.
*/
setNodes: <T extends Node>(
editor: Editor,
props: Partial<T>,
options?: {
at?: Location
match?: NodeMatch<T>
mode?: MaximizeMode
hanging?: boolean
split?: boolean
voids?: boolean
compare?: PropsCompare
merge?: PropsMerge
}
) => void
/**
* Split the nodes at a specific location.
*/
splitNodes: <T extends Node>(
editor: Editor,
options?: {
at?: Location
match?: NodeMatch<T>
mode?: RangeMode
always?: boolean
height?: number
voids?: boolean
}
) => void
/**
* Unset properties on the nodes at a location.
*/
unsetNodes: <T extends Node>(
editor: Editor,
props: string | string[],
options?: {
at?: Location
match?: NodeMatch<T>
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: <T extends Node>(
editor: Editor,
options?: {
at?: Location
match?: NodeMatch<T>
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: <T extends Node>(
editor: Editor,
element: Element,
options?: {
at?: Location
match?: NodeMatch<T>
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)
},
}

View File

@@ -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<Point>,
options?: SelectionSetPointOptions
) => void
/**
* Set new properties on the selection.
*/
setSelection: (editor: Editor, props: Partial<Range>) => 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)
},
}

View File

@@ -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 })
})
},
}

View File

@@ -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'

View File

@@ -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)
}
}
})
}

View File

@@ -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<Ancestor>
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 })
}
}
})
}

View File

@@ -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<Text>
} else if (Element.isElement(node) && Element.isElement(prevNode)) {
const { children, ...rest } = node
position = prevNode.children.length
properties = rest as Partial<Element>
} 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()
}
})
}

View File

@@ -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()
})
}

View File

@@ -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 })
}
}
})
}

View File

@@ -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<Node>,
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<Node> = {}
const newProperties: Partial<Node> = {}
// 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,
})
}
}
})
}

View File

@@ -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()
}
})
}

Some files were not shown because too many files have changed in this diff Show More