1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-09-02 19:52:32 +02:00

Experimental chunking optimisation and other performance improvements (#5871)

* Chunking optimization

* Fix comments

* Remove redundant `insertionsMinusRemovals` variable

* Fix typo

* Unblock Netlify builds

* Add placeholder

* Upgrade Playwright (fixes crash when debugging)

* Fix `autoFocus` not working

* Fix huge document test

* Fix the previous issue without changing `useSlateSelector`

* Retry `test:integration`

* Re-implement `useSlateWithV`

* Retry `test:integration`

* Update docs

* Update JS examples to match TS examples

* Upload Playwright's `test-results` directory in CI to access traces

* Change trace mode to `retain-on-first-failure`

* Fix: `Locator.fill(text)` is flaky on Editable

* Add changesets

* Increase minimum `slate-dom` version

* Update changeset

* Update 09-performance.md

* Deprecate the `useSlateWithV` hook

* Fix errors and improve clarity in 09-performance.md

* Minimum `slate-dom` version is now 0.116

* Update `yarn.lock`
This commit is contained in:
Joe Anderson
2025-06-07 00:42:11 +01:00
committed by GitHub
parent 583d28fe13
commit fb87646e86
65 changed files with 5234 additions and 876 deletions

View File

@@ -59,6 +59,7 @@ export { Key } from './utils/key'
export {
isElementDecorationsEqual,
isTextDecorationsEqual,
splitDecorationsByChild,
} from './utils/range-list'
export {

View File

@@ -1,5 +1,6 @@
import { Range } from 'slate'
import { Ancestor, DecoratedRange, Editor, Range } from 'slate'
import { PLACEHOLDER_SYMBOL } from './weak-maps'
import { DOMEditor } from '../plugin/dom-editor'
export const shallowCompare = (
obj1: { [key: string]: unknown },
@@ -29,9 +30,17 @@ const isDecorationFlagsEqual = (range: Range, other: Range) => {
*/
export const isElementDecorationsEqual = (
list: Range[],
another: Range[]
list: Range[] | null,
another: Range[] | null
): boolean => {
if (list === another) {
return true
}
if (!list || !another) {
return false
}
if (list.length !== another.length) {
return false
}
@@ -57,9 +66,17 @@ export const isElementDecorationsEqual = (
*/
export const isTextDecorationsEqual = (
list: Range[],
another: Range[]
list: Range[] | null,
another: Range[] | null
): boolean => {
if (list === another) {
return true
}
if (!list || !another) {
return false
}
if (list.length !== another.length) {
return false
}
@@ -80,3 +97,65 @@ export const isTextDecorationsEqual = (
return true
}
/**
* Split and group decorations by each child of a node.
*
* @returns An array with length equal to that of `node.children`. Each index
* corresponds to a child of `node`, and the value is an array of decorations
* for that child.
*/
export const splitDecorationsByChild = (
editor: Editor,
node: Ancestor,
decorations: DecoratedRange[]
): DecoratedRange[][] => {
const decorationsByChild = Array.from(
node.children,
(): DecoratedRange[] => []
)
if (decorations.length === 0) {
return decorationsByChild
}
const path = DOMEditor.findPath(editor, node)
const level = path.length
const ancestorRange = Editor.range(editor, path)
const cachedChildRanges = new Array<Range | undefined>(node.children.length)
const getChildRange = (index: number) => {
const cachedRange = cachedChildRanges[index]
if (cachedRange) return cachedRange
const childRange = Editor.range(editor, [...path, index])
cachedChildRanges[index] = childRange
return childRange
}
for (const decoration of decorations) {
const decorationRange = Range.intersection(ancestorRange, decoration)
if (!decorationRange) continue
const [startPoint, endPoint] = Range.edges(decorationRange)
const startIndex = startPoint.path[level]
const endIndex = endPoint.path[level]
for (let i = startIndex; i <= endIndex; i++) {
const ds = decorationsByChild[i]
if (!ds) continue
const childRange = getChildRange(i)
const childDecorationRange = Range.intersection(childRange, decoration)
if (!childDecorationRange) continue
ds.push({
...decoration,
...childDecorationRange,
})
}
}
return decorationsByChild
}

View File

@@ -23,6 +23,7 @@
},
"devDependencies": {
"@babel/runtime": "^7.23.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.0.0",
"@types/is-hotkey": "^0.1.8",
"@types/jest": "29.5.6",
@@ -42,7 +43,7 @@
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"slate": ">=0.114.0",
"slate-dom": ">=0.110.2"
"slate-dom": ">=0.116.0"
},
"umdGlobals": {
"react": "React",

View File

@@ -0,0 +1,122 @@
import { Editor, Descendant } from 'slate'
import { Key } from 'slate-dom'
import { ChunkLeaf } from './types'
import { ReactEditor } from '../plugin/react-editor'
/**
* Traverse an array of children, providing helpers useful for reconciling the
* children array with a chunk tree
*/
export class ChildrenHelper {
private editor: Editor
private children: Descendant[]
/**
* Sparse array of Slate node keys, each index corresponding to an index in
* the children array
*
* Fetching the key for a Slate node is expensive, so we cache them here.
*/
private cachedKeys: Array<Key | undefined>
/**
* The index of the next node to be read in the children array
*/
public pointerIndex: number
constructor(editor: Editor, children: Descendant[]) {
this.editor = editor
this.children = children
this.cachedKeys = new Array(children.length)
this.pointerIndex = 0
}
/**
* Read a given number of nodes, advancing the pointer by that amount
*/
public read(n: number): Descendant[] {
// PERF: If only one child was requested (the most common case), use array
// indexing instead of slice
if (n === 1) {
return [this.children[this.pointerIndex++]]
}
const slicedChildren = this.remaining(n)
this.pointerIndex += n
return slicedChildren
}
/**
* Get the remaining children without advancing the pointer
*
* @param [maxChildren] Limit the number of children returned.
*/
public remaining(maxChildren?: number): Descendant[] {
if (maxChildren === undefined) {
return this.children.slice(this.pointerIndex)
}
return this.children.slice(
this.pointerIndex,
this.pointerIndex + maxChildren
)
}
/**
* Whether all children have been read
*/
public get reachedEnd() {
return this.pointerIndex >= this.children.length
}
/**
* Determine whether a node with a given key appears in the unread part of the
* children array, and return its index relative to the current pointer if so
*
* Searching for the node object itself using indexOf is most efficient, but
* will fail to locate nodes that have been modified. In this case, nodes
* should be identified by their keys instead.
*
* Searching an array of keys using indexOf is very inefficient since fetching
* the keys for all children in advance is very slow. Insead, if the node
* search fails to return a value, fetch the keys of each remaining child one
* by one and compare it to the known key.
*/
public lookAhead(node: Descendant, key: Key) {
const elementResult = this.children.indexOf(node, this.pointerIndex)
if (elementResult > -1) return elementResult - this.pointerIndex
for (let i = this.pointerIndex; i < this.children.length; i++) {
const candidateNode = this.children[i]
const candidateKey = this.findKey(candidateNode, i)
if (candidateKey === key) return i - this.pointerIndex
}
return -1
}
/**
* Convert an array of Slate nodes to an array of chunk leaves, each
* containing the node and its key
*/
public toChunkLeaves(nodes: Descendant[], startIndex: number): ChunkLeaf[] {
return nodes.map((node, i) => ({
type: 'leaf',
node,
key: this.findKey(node, startIndex + i),
index: startIndex + i,
}))
}
/**
* Get the key for a Slate node, cached using the node's index
*/
private findKey(node: Descendant, index: number): Key {
const cachedKey = this.cachedKeys[index]
if (cachedKey) return cachedKey
const key = ReactEditor.findKey(this.editor, node)
this.cachedKeys[index] = key
return key
}
}

View File

@@ -0,0 +1,574 @@
import { Path } from 'slate'
import { Key } from 'slate-dom'
import {
Chunk,
ChunkTree,
ChunkLeaf,
ChunkDescendant,
ChunkAncestor,
} from './types'
type SavedPointer =
| 'start'
| {
chunk: ChunkAncestor
node: ChunkDescendant
}
export interface ChunkTreeHelperOptions {
chunkSize: number
debug?: boolean
}
/**
* Traverse and modify a chunk tree
*/
export class ChunkTreeHelper {
/**
* The root of the chunk tree
*/
private root: ChunkTree
/**
* The ideal size of a chunk
*/
private chunkSize: number
/**
* Whether debug mode is enabled
*
* If enabled, the pointer state will be checked for internal consistency
* after each mutating operation.
*/
private debug: boolean
/**
* Whether the traversal has reached the end of the chunk tree
*
* When this is true, the pointerChunk and pointerIndex point to the last
* top-level node in the chunk tree, although pointerNode returns null.
*/
private reachedEnd: boolean
/**
* The chunk containing the current node
*/
private pointerChunk: ChunkAncestor
/**
* The index of the current node within pointerChunk
*
* Can be -1 to indicate that the pointer is before the start of the tree.
*/
private pointerIndex: number
/**
* Similar to a Slate path; tracks the path of pointerChunk relative to the
* root.
*
* Used to move the pointer from the current chunk to the parent chunk more
* efficiently.
*/
private pointerIndexStack: number[]
/**
* Indexing the current chunk's children has a slight time cost, which adds up
* when traversing very large trees, so the current node is cached.
*
* A value of undefined means that the current node is not cached. This
* property must be set to undefined whenever the pointer is moved, unless
* the pointer is guaranteed to point to the same node that it did previously.
*/
private cachedPointerNode: ChunkDescendant | null | undefined
constructor(
chunkTree: ChunkTree,
{ chunkSize, debug }: ChunkTreeHelperOptions
) {
this.root = chunkTree
this.chunkSize = chunkSize
// istanbul ignore next
this.debug = debug ?? false
this.pointerChunk = chunkTree
this.pointerIndex = -1
this.pointerIndexStack = []
this.reachedEnd = false
this.validateState()
}
/**
* Move the pointer to the next leaf in the chunk tree
*/
public readLeaf(): ChunkLeaf | null {
// istanbul ignore next
if (this.reachedEnd) return null
// Get the next sibling or aunt node
while (true) {
if (this.pointerIndex + 1 < this.pointerSiblings.length) {
this.pointerIndex++
this.cachedPointerNode = undefined
break
} else if (this.pointerChunk.type === 'root') {
this.reachedEnd = true
return null
} else {
this.exitChunk()
}
}
this.validateState()
// If the next sibling or aunt is a chunk, descend into it
this.enterChunkUntilLeaf(false)
return this.pointerNode as ChunkLeaf
}
/**
* Move the pointer to the previous leaf in the chunk tree
*/
public returnToPreviousLeaf() {
// If we were at the end of the tree, descend into the end of the last
// chunk in the tree
if (this.reachedEnd) {
this.reachedEnd = false
this.enterChunkUntilLeaf(true)
return
}
// Get the previous sibling or aunt node
while (true) {
if (this.pointerIndex >= 1) {
this.pointerIndex--
this.cachedPointerNode = undefined
break
} else if (this.pointerChunk.type === 'root') {
this.pointerIndex = -1
return
} else {
this.exitChunk()
}
}
this.validateState()
// If the previous sibling or aunt is a chunk, descend into it
this.enterChunkUntilLeaf(true)
}
/**
* Insert leaves before the current leaf, leaving the pointer unchanged
*/
public insertBefore(leaves: ChunkLeaf[]) {
this.returnToPreviousLeaf()
this.insertAfter(leaves)
this.readLeaf()
}
/**
* Insert leaves after the current leaf, leaving the pointer on the last
* inserted leaf
*
* The insertion algorithm first checks for any chunk we're currently at the
* end of that can receive additional leaves. Next, it tries to insert leaves
* at the starts of any subsequent chunks.
*
* Any remaining leaves are passed to rawInsertAfter to be chunked and
* inserted at the highest possible level.
*/
public insertAfter(leaves: ChunkLeaf[]) {
// istanbul ignore next
if (leaves.length === 0) return
let beforeDepth = 0
let afterDepth = 0
// While at the end of a chunk, insert any leaves that will fit, and then
// exit the chunk
while (
this.pointerChunk.type === 'chunk' &&
this.pointerIndex === this.pointerSiblings.length - 1
) {
const remainingCapacity = this.chunkSize - this.pointerSiblings.length
const toInsertCount = Math.min(remainingCapacity, leaves.length)
if (toInsertCount > 0) {
const leavesToInsert = leaves.splice(0, toInsertCount)
this.rawInsertAfter(leavesToInsert, beforeDepth)
}
this.exitChunk()
beforeDepth++
}
if (leaves.length === 0) return
// Save the pointer so that we can come back here after inserting leaves
// into the starts of subsequent blocks
const rawInsertPointer = this.savePointer()
// If leaves are inserted into the start of a subsequent block, then we
// eventually need to restore the pointer to the last such inserted leaf
let finalPointer: SavedPointer | null = null
// Move the pointer into the chunk containing the next leaf, if it exists
if (this.readLeaf()) {
// While at the start of a chunk, insert any leaves that will fit, and
// then exit the chunk
while (this.pointerChunk.type === 'chunk' && this.pointerIndex === 0) {
const remainingCapacity = this.chunkSize - this.pointerSiblings.length
const toInsertCount = Math.min(remainingCapacity, leaves.length)
if (toInsertCount > 0) {
const leavesToInsert = leaves.splice(-toInsertCount, toInsertCount)
// Insert the leaves at the start of the chunk
this.pointerIndex = -1
this.cachedPointerNode = undefined
this.rawInsertAfter(leavesToInsert, afterDepth)
// If this is the first batch of insertions at the start of a
// subsequent chunk, set the final pointer to the last inserted leaf
if (!finalPointer) {
finalPointer = this.savePointer()
}
}
this.exitChunk()
afterDepth++
}
}
this.restorePointer(rawInsertPointer)
// If there are leaves left to insert, insert them between the end of the
// previous chunk and the start of the first subsequent chunk, or wherever
// the pointer ended up after the first batch of insertions
const minDepth = Math.max(beforeDepth, afterDepth)
this.rawInsertAfter(leaves, minDepth)
if (finalPointer) {
this.restorePointer(finalPointer)
}
this.validateState()
}
/**
* Remove the current node and decrement the pointer, deleting any ancestor
* chunk that becomes empty as a result
*/
public remove() {
this.pointerSiblings.splice(this.pointerIndex--, 1)
this.cachedPointerNode = undefined
if (
this.pointerSiblings.length === 0 &&
this.pointerChunk.type === 'chunk'
) {
this.exitChunk()
this.remove()
} else {
this.invalidateChunk()
}
this.validateState()
}
/**
* Add the current chunk and all ancestor chunks to the list of modified
* chunks
*/
public invalidateChunk() {
for (let c = this.pointerChunk; c.type === 'chunk'; c = c.parent) {
this.root.modifiedChunks.add(c)
}
}
/**
* Whether the pointer is at the start of the tree
*/
private get atStart() {
return this.pointerChunk.type === 'root' && this.pointerIndex === -1
}
/**
* The siblings of the current node
*/
private get pointerSiblings(): ChunkDescendant[] {
return this.pointerChunk.children
}
/**
* Get the current node (uncached)
*
* If the pointer is at the start or end of the document, returns null.
*
* Usually, the current node is a chunk leaf, although it can be a chunk
* while insertions are in progress.
*/
private getPointerNode(): ChunkDescendant | null {
if (this.reachedEnd || this.pointerIndex === -1) {
return null
}
return this.pointerSiblings[this.pointerIndex]
}
/**
* Cached getter for the current node
*/
private get pointerNode(): ChunkDescendant | null {
if (this.cachedPointerNode !== undefined) return this.cachedPointerNode
const pointerNode = this.getPointerNode()
this.cachedPointerNode = pointerNode
return pointerNode
}
/**
* Get the path of a chunk relative to the root, returning null if the chunk
* is not connected to the root
*/
private getChunkPath(chunk: ChunkAncestor): number[] | null {
const path: number[] = []
for (let c = chunk; c.type === 'chunk'; c = c.parent) {
const index = c.parent.children.indexOf(c)
// istanbul ignore next
if (index === -1) {
return null
}
path.unshift(index)
}
return path
}
/**
* Save the current pointer to be restored later
*/
private savePointer(): SavedPointer {
if (this.atStart) return 'start'
// istanbul ignore next
if (!this.pointerNode) {
throw new Error('Cannot save pointer when pointerNode is null')
}
return {
chunk: this.pointerChunk,
node: this.pointerNode,
}
}
/**
* Restore the pointer to a previous state
*/
private restorePointer(savedPointer: SavedPointer) {
if (savedPointer === 'start') {
this.pointerChunk = this.root
this.pointerIndex = -1
this.pointerIndexStack = []
this.reachedEnd = false
this.cachedPointerNode = undefined
return
}
// Since nodes may have been inserted or removed prior to the saved
// pointer since it was saved, the index and index stack must be
// recomputed. This is slow, but this is fine since restoring a pointer is
// not a frequent operation.
const { chunk, node } = savedPointer
const index = chunk.children.indexOf(node)
// istanbul ignore next
if (index === -1) {
throw new Error(
'Cannot restore point because saved node is no longer in saved chunk'
)
}
const indexStack = this.getChunkPath(chunk)
// istanbul ignore next
if (!indexStack) {
throw new Error(
'Cannot restore point because saved chunk is no longer connected to root'
)
}
this.pointerChunk = chunk
this.pointerIndex = index
this.pointerIndexStack = indexStack
this.reachedEnd = false
this.cachedPointerNode = node
this.validateState()
}
/**
* Assuming the current node is a chunk, move the pointer into that chunk
*
* @param end If true, place the pointer on the last node of the chunk.
* Otherwise, place the pointer on the first node.
*/
private enterChunk(end: boolean) {
// istanbul ignore next
if (this.pointerNode?.type !== 'chunk') {
throw new Error('Cannot enter non-chunk')
}
this.pointerIndexStack.push(this.pointerIndex)
this.pointerChunk = this.pointerNode
this.pointerIndex = end ? this.pointerSiblings.length - 1 : 0
this.cachedPointerNode = undefined
this.validateState()
// istanbul ignore next
if (this.pointerChunk.children.length === 0) {
throw new Error('Cannot enter empty chunk')
}
}
/**
* Assuming the current node is a chunk, move the pointer into that chunk
* repeatedly until the current node is a leaf
*
* @param end If true, place the pointer on the last node of the chunk.
* Otherwise, place the pointer on the first node.
*/
private enterChunkUntilLeaf(end: boolean) {
while (this.pointerNode?.type === 'chunk') {
this.enterChunk(end)
}
}
/**
* Move the pointer to the parent chunk
*/
private exitChunk() {
// istanbul ignore next
if (this.pointerChunk.type === 'root') {
throw new Error('Cannot exit root')
}
const previousPointerChunk = this.pointerChunk
this.pointerChunk = previousPointerChunk.parent
this.pointerIndex = this.pointerIndexStack.pop()!
this.cachedPointerNode = undefined
this.validateState()
}
/**
* Insert leaves immediately after the current node, leaving the pointer on
* the last inserted leaf
*
* Leaves are chunked according to the number of nodes already in the parent
* plus the number of nodes being inserted, or the minimum depth if larger
*/
private rawInsertAfter(leaves: ChunkLeaf[], minDepth: number) {
if (leaves.length === 0) return
const groupIntoChunks = (
leaves: ChunkLeaf[],
parent: ChunkAncestor,
perChunk: number
): ChunkDescendant[] => {
if (perChunk === 1) return leaves
const chunks: Chunk[] = []
for (let i = 0; i < this.chunkSize; i++) {
const chunkNodes = leaves.slice(i * perChunk, (i + 1) * perChunk)
if (chunkNodes.length === 0) break
const chunk: Chunk = {
type: 'chunk',
key: new Key(),
parent,
children: [],
}
chunk.children = groupIntoChunks(
chunkNodes,
chunk,
perChunk / this.chunkSize
)
chunks.push(chunk)
}
return chunks
}
// Determine the chunking depth based on the number of existing nodes in
// the chunk and the number of nodes being inserted
const newTotal = this.pointerSiblings.length + leaves.length
let depthForTotal = 0
for (let i = this.chunkSize; i < newTotal; i *= this.chunkSize) {
depthForTotal++
}
// A depth of 0 means no chunking
const depth = Math.max(depthForTotal, minDepth)
const perTopLevelChunk = Math.pow(this.chunkSize, depth)
const chunks = groupIntoChunks(leaves, this.pointerChunk, perTopLevelChunk)
this.pointerSiblings.splice(this.pointerIndex + 1, 0, ...chunks)
this.pointerIndex += chunks.length
this.cachedPointerNode = undefined
this.invalidateChunk()
this.validateState()
}
/**
* If debug mode is enabled, ensure that the state is internally consistent
*/
// istanbul ignore next
private validateState() {
if (!this.debug) return
const validateDescendant = (node: ChunkDescendant) => {
if (node.type === 'chunk') {
const { parent, children } = node
if (!parent.children.includes(node)) {
throw new Error(
`Debug: Chunk ${node.key.id} has an incorrect parent property`
)
}
children.forEach(validateDescendant)
}
}
this.root.children.forEach(validateDescendant)
if (
this.cachedPointerNode !== undefined &&
this.cachedPointerNode !== this.getPointerNode()
) {
throw new Error(
'Debug: The cached pointer is incorrect and has not been invalidated'
)
}
const actualIndexStack = this.getChunkPath(this.pointerChunk)
if (!actualIndexStack) {
throw new Error('Debug: The pointer chunk is not connected to the root')
}
if (!Path.equals(this.pointerIndexStack, actualIndexStack)) {
throw new Error(
`Debug: The cached index stack [${this.pointerIndexStack.join(
', '
)}] does not match the path of the pointer chunk [${actualIndexStack.join(
', '
)}]`
)
}
}
}

View File

@@ -0,0 +1,47 @@
import { Ancestor, Editor } from 'slate'
import { Key } from 'slate-dom'
import { ChunkTree } from './types'
import { ReconcileOptions, reconcileChildren } from './reconcile-children'
import { ReactEditor } from '../plugin/react-editor'
export const KEY_TO_CHUNK_TREE = new WeakMap<Key, ChunkTree>()
/**
* Get or create the chunk tree for a Slate node
*
* If the reconcile option is provided, the chunk tree will be updated to
* match the current children of the node. The children are chunked
* automatically using the given chunk size.
*/
export const getChunkTreeForNode = (
editor: Editor,
node: Ancestor,
// istanbul ignore next
options: {
reconcile?: Omit<ReconcileOptions, 'chunkTree' | 'children'> | false
} = {}
) => {
const key = ReactEditor.findKey(editor, node)
let chunkTree = KEY_TO_CHUNK_TREE.get(key)
if (!chunkTree) {
chunkTree = {
type: 'root',
movedNodeKeys: new Set(),
modifiedChunks: new Set(),
children: [],
}
KEY_TO_CHUNK_TREE.set(key, chunkTree)
}
if (options.reconcile) {
reconcileChildren(editor, {
chunkTree,
children: node.children,
...options.reconcile,
})
}
return chunkTree
}

View File

@@ -0,0 +1,2 @@
export * from './get-chunk-tree-for-node'
export * from './types'

View File

@@ -0,0 +1,127 @@
import { Editor, Descendant } from 'slate'
import { ChunkTree, ChunkLeaf } from './types'
import { ChunkTreeHelper, ChunkTreeHelperOptions } from './chunk-tree-helper'
import { ChildrenHelper } from './children-helper'
export interface ReconcileOptions extends ChunkTreeHelperOptions {
chunkTree: ChunkTree
children: Descendant[]
chunkSize: number
rerenderChildren?: number[]
onInsert?: (node: Descendant, index: number) => void
onUpdate?: (node: Descendant, index: number) => void
onIndexChange?: (node: Descendant, index: number) => void
debug?: boolean
}
/**
* Update the chunk tree to match the children array, inserting, removing and
* updating differing nodes
*/
export const reconcileChildren = (
editor: Editor,
{
chunkTree,
children,
chunkSize,
rerenderChildren = [],
onInsert,
onUpdate,
onIndexChange,
debug,
}: ReconcileOptions
) => {
chunkTree.modifiedChunks.clear()
const chunkTreeHelper = new ChunkTreeHelper(chunkTree, { chunkSize, debug })
const childrenHelper = new ChildrenHelper(editor, children)
let treeLeaf: ChunkLeaf | null
// Read leaves from the tree one by one, each one representing a single Slate
// node. Each leaf from the tree is compared to the current node in the
// children array to determine whether nodes have been inserted, removed or
// updated.
while ((treeLeaf = chunkTreeHelper.readLeaf())) {
// Check where the tree node appears in the children array. In the most
// common case (where no insertions or removals have occurred), this will be
// 0. If the node has been removed, this will be -1. If new nodes have been
// inserted before the node, or if the node has been moved to a later
// position in the same children array, this will be a positive number.
const lookAhead = childrenHelper.lookAhead(treeLeaf.node, treeLeaf.key)
// If the node was moved, we want to remove it and insert it later, rather
// then re-inserting all intermediate nodes before it.
const wasMoved = lookAhead > 0 && chunkTree.movedNodeKeys.has(treeLeaf.key)
// If the tree leaf was moved or removed, remove it
if (lookAhead === -1 || wasMoved) {
chunkTreeHelper.remove()
continue
}
// Get the matching Slate node and any nodes that may have been inserted
// prior to it. Insert these into the chunk tree.
const insertedChildrenStartIndex = childrenHelper.pointerIndex
const insertedChildren = childrenHelper.read(lookAhead + 1)
const matchingChild = insertedChildren.pop()!
if (insertedChildren.length) {
const leavesToInsert = childrenHelper.toChunkLeaves(
insertedChildren,
insertedChildrenStartIndex
)
chunkTreeHelper.insertBefore(leavesToInsert)
insertedChildren.forEach((node, relativeIndex) => {
onInsert?.(node, insertedChildrenStartIndex + relativeIndex)
})
}
const matchingChildIndex = childrenHelper.pointerIndex - 1
// Make sure the chunk tree contains the most recent version of the Slate
// node
if (treeLeaf.node !== matchingChild) {
treeLeaf.node = matchingChild
chunkTreeHelper.invalidateChunk()
onUpdate?.(matchingChild, matchingChildIndex)
}
// Update the index if it has changed
if (treeLeaf.index !== matchingChildIndex) {
treeLeaf.index = matchingChildIndex
onIndexChange?.(matchingChild, matchingChildIndex)
}
// Manually invalidate chunks containing specific children that we want to
// re-render
if (rerenderChildren.includes(matchingChildIndex)) {
chunkTreeHelper.invalidateChunk()
}
}
// If there are still Slate nodes remaining from the children array that were
// not matched to nodes in the tree, insert them at the end of the tree
if (!childrenHelper.reachedEnd) {
const remainingChildren = childrenHelper.remaining()
const leavesToInsert = childrenHelper.toChunkLeaves(
remainingChildren,
childrenHelper.pointerIndex
)
// Move the pointer back to the final leaf in the tree, or the start of the
// tree if the tree is currently empty
chunkTreeHelper.returnToPreviousLeaf()
chunkTreeHelper.insertAfter(leavesToInsert)
remainingChildren.forEach((node, relativeIndex) => {
onInsert?.(node, childrenHelper.pointerIndex + relativeIndex)
})
}
chunkTree.movedNodeKeys.clear()
}

View File

@@ -0,0 +1,52 @@
import { Descendant } from 'slate'
import { Key } from 'slate-dom'
export interface ChunkTree {
type: 'root'
children: ChunkDescendant[]
/**
* The keys of any Slate nodes that have been moved using move_node since the
* last render
*
* Detecting when a node has been moved to a different position in the
* children array is impossible to do efficiently while reconciling the chunk
* tree. This interferes with the reconciliation logic since it is treated as
* if the intermediate nodes were inserted and removed, causing them to be
* re-chunked unnecessarily.
*
* This set is used to detect when a node has been moved so that this case
* can be handled correctly and efficiently.
*/
movedNodeKeys: Set<Key>
/**
* The chunks whose descendants have been modified during the most recent
* reconciliation
*
* Used to determine when the otherwise memoized React components for each
* chunk should be re-rendered.
*/
modifiedChunks: Set<Chunk>
}
export interface Chunk {
type: 'chunk'
key: Key
parent: ChunkAncestor
children: ChunkDescendant[]
}
// A chunk leaf is unrelated to a Slate leaf; it is a leaf of the chunk tree,
// containing a single element that is a child of the Slate node the chunk tree
// belongs to.
export interface ChunkLeaf {
type: 'leaf'
key: Key
node: Descendant
index: number
}
export type ChunkAncestor = ChunkTree | Chunk
export type ChunkDescendant = Chunk | ChunkLeaf
export type ChunkNode = ChunkTree | Chunk | ChunkLeaf

View File

@@ -0,0 +1,65 @@
import React, { Fragment } from 'react'
import { Element } from 'slate'
import { Key } from 'slate-dom'
import { RenderChunkProps } from './editable'
import {
Chunk as TChunk,
ChunkAncestor as TChunkAncestor,
ChunkTree as TChunkTree,
} from '../chunking'
const defaultRenderChunk = ({ children }: RenderChunkProps) => children
const ChunkAncestor = <C extends TChunkAncestor>(props: {
root: TChunkTree
ancestor: C
renderElement: (node: Element, index: number, key: Key) => JSX.Element
renderChunk?: (props: RenderChunkProps) => JSX.Element
}) => {
const {
root,
ancestor,
renderElement,
renderChunk = defaultRenderChunk,
} = props
return ancestor.children.map(chunkNode => {
if (chunkNode.type === 'chunk') {
const key = chunkNode.key.id
const renderedChunk = renderChunk({
highest: ancestor === root,
lowest: chunkNode.children.some(c => c.type === 'leaf'),
attributes: { 'data-slate-chunk': true },
children: (
<MemoizedChunk
root={root}
ancestor={chunkNode}
renderElement={renderElement}
renderChunk={renderChunk}
/>
),
})
return <Fragment key={key}>{renderedChunk}</Fragment>
}
// Only blocks containing no inlines are chunked
const element = chunkNode.node as Element
return renderElement(element, chunkNode.index, chunkNode.key)
})
}
const ChunkTree = ChunkAncestor<TChunkTree>
const MemoizedChunk = React.memo(
ChunkAncestor<TChunk>,
(prev, next) =>
prev.root === next.root &&
prev.renderElement === next.renderElement &&
prev.renderChunk === next.renderChunk &&
!next.root.modifiedChunks.has(next.ancestor)
)
export default ChunkTree

View File

@@ -27,7 +27,7 @@ import {
} from 'slate'
import { useAndroidInputManager } from '../hooks/android-input-manager/use-android-input-manager'
import useChildren from '../hooks/use-children'
import { DecorateContext } from '../hooks/use-decorate'
import { DecorateContext, useDecorateContext } from '../hooks/use-decorations'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import { ReadOnlyContext } from '../hooks/use-read-only'
import { useSlate } from '../hooks/use-slate'
@@ -77,6 +77,7 @@ import {
import { RestoreDOM } from './restore-dom/restore-dom'
import { AndroidInputManager } from '../hooks/android-input-manager/android-input-manager'
import { ComposingContext } from '../hooks/use-composing'
import { useFlushDeferredSelectorsOnRender } from '../hooks/use-slate-selector'
type DeferredOperation = () => void
@@ -100,6 +101,18 @@ export interface RenderElementProps {
}
}
/**
* `RenderChunkProps` are passed to the `renderChunk` handler
*/
export interface RenderChunkProps {
highest: boolean
lowest: boolean
children: any
attributes: {
'data-slate-chunk': true
}
}
/**
* `RenderLeafProps` are passed to the `renderLeaf` handler.
*/
@@ -145,6 +158,7 @@ export type EditableProps = {
role?: string
style?: React.CSSProperties
renderElement?: (props: RenderElementProps) => JSX.Element
renderChunk?: (props: RenderChunkProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
renderText?: (props: RenderTextProps) => JSX.Element
renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element
@@ -170,6 +184,7 @@ export const Editable = forwardRef(
placeholder,
readOnly = false,
renderElement,
renderChunk,
renderLeaf,
renderText,
renderPlaceholder = defaultRenderPlaceholder,
@@ -210,6 +225,11 @@ export const Editable = forwardRef(
// The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it
// needs to be manually focused.
//
// If this stops working in Firefox, make sure nothing is causing this
// component to re-render during the initial mount. If the DOM selection is
// set by `useIsomorphicLayoutEffect` before `onDOMSelectionChange` updates
// `editor.selection`, the DOM selection can be removed accidentally.
useEffect(() => {
if (ref.current && autoFocus) {
ref.current.focus()
@@ -920,6 +940,7 @@ export const Editable = forwardRef(
}, [scheduleOnDOMSelectionChange, state])
const decorations = decorate([editor, []])
const decorateContext = useDecorateContext(decorate)
const showPlaceholder =
placeholder &&
@@ -999,10 +1020,12 @@ export const Editable = forwardRef(
})
})
useFlushDeferredSelectorsOnRender()
return (
<ReadOnlyContext.Provider value={readOnly}>
<ComposingContext.Provider value={isComposing}>
<DecorateContext.Provider value={decorate}>
<DecorateContext.Provider value={decorateContext}>
<RestoreDOM node={ref} receivedUserInput={receivedUserInput}>
<Component
role={readOnly ? undefined : 'textbox'}
@@ -1852,10 +1875,10 @@ export const Editable = forwardRef(
decorations={decorations}
node={editor}
renderElement={renderElement}
renderChunk={renderChunk}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
renderText={renderText}
selection={editor.selection}
/>
</Component>
</RestoreDOM>

View File

@@ -1,13 +1,7 @@
import getDirection from 'direction'
import React, { useCallback } from 'react'
import { JSX } from 'react'
import {
Editor,
Element as SlateElement,
Node,
Range,
DecoratedRange,
} from 'slate'
import { Editor, Element as SlateElement, Node, DecoratedRange } from 'slate'
import { ReactEditor, useReadOnly, useSlateStatic } from '..'
import useChildren from '../hooks/use-children'
import { isElementDecorationsEqual } from 'slate-dom'
@@ -19,6 +13,7 @@ import {
NODE_TO_PARENT,
} from 'slate-dom'
import {
RenderChunkProps,
RenderElementProps,
RenderLeafProps,
RenderPlaceholderProps,
@@ -26,6 +21,11 @@ import {
} from './editable'
import Text from './text'
import { useDecorations } from '../hooks/use-decorations'
const defaultRenderElement = (props: RenderElementProps) => (
<DefaultElement {...props} />
)
/**
* Element.
@@ -35,23 +35,24 @@ const Element = (props: {
decorations: DecoratedRange[]
element: SlateElement
renderElement?: (props: RenderElementProps) => JSX.Element
renderChunk?: (props: RenderChunkProps) => JSX.Element
renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
renderText?: (props: RenderTextProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
selection: Range | null
}) => {
const {
decorations,
decorations: parentDecorations,
element,
renderElement = (p: RenderElementProps) => <DefaultElement {...p} />,
renderElement = defaultRenderElement,
renderChunk,
renderPlaceholder,
renderLeaf,
renderText,
selection,
} = props
const editor = useSlateStatic()
const readOnly = useReadOnly()
const isInline = editor.isInline(element)
const decorations = useDecorations(element, parentDecorations)
const key = ReactEditor.findKey(editor, element)
const ref = useCallback(
(ref: HTMLElement | null) => {
@@ -72,10 +73,10 @@ const Element = (props: {
decorations,
node: element,
renderElement,
renderChunk,
renderPlaceholder,
renderLeaf,
renderText,
selection,
})
// Attributes that the developer must mix into the element in their
@@ -149,14 +150,11 @@ const MemoizedElement = React.memo(Element, (prev, next) => {
return (
prev.element === next.element &&
prev.renderElement === next.renderElement &&
prev.renderChunk === next.renderChunk &&
prev.renderText === next.renderText &&
prev.renderLeaf === next.renderLeaf &&
prev.renderPlaceholder === next.renderPlaceholder &&
isElementDecorationsEqual(prev.decorations, next.decorations) &&
(prev.selection === next.selection ||
(!!prev.selection &&
!!next.selection &&
Range.equals(prev.selection, next.selection)))
isElementDecorationsEqual(prev.decorations, next.decorations)
)
})

View File

@@ -9,11 +9,7 @@ import { JSX } from 'react'
import { Element, LeafPosition, Text } from 'slate'
import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'
import String from './string'
import {
PLACEHOLDER_SYMBOL,
EDITOR_TO_PLACEHOLDER_ELEMENT,
EDITOR_TO_FORCE_RENDER,
} from 'slate-dom'
import { PLACEHOLDER_SYMBOL, EDITOR_TO_PLACEHOLDER_ELEMENT } from 'slate-dom'
import { RenderLeafProps, RenderPlaceholderProps } from './editable'
import { useSlateStatic } from '../hooks/use-slate-static'
import { IS_WEBKIT, IS_ANDROID } from 'slate-dom'
@@ -43,6 +39,8 @@ function clearTimeoutRef(timeoutRef: MutableRefObject<TimerId>) {
}
}
const defaultRenderLeaf = (props: RenderLeafProps) => <DefaultLeaf {...props} />
/**
* Individual leaves in a text node with unique formatting.
*/
@@ -61,7 +59,7 @@ const Leaf = (props: {
text,
parent,
renderPlaceholder,
renderLeaf = (props: RenderLeafProps) => <DefaultLeaf {...props} />,
renderLeaf = defaultRenderLeaf,
leafPosition,
} = props

View File

@@ -3,7 +3,6 @@ import { Descendant, Editor, Node, Operation, Scrubber, Selection } from 'slate'
import { EDITOR_TO_ON_CHANGE } from 'slate-dom'
import { FocusedContext } from '../hooks/use-focused'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
import { SlateContext, SlateContextValue } from '../hooks/use-slate'
import {
useSelectorContext,
SlateSelectorContext,
@@ -35,7 +34,8 @@ export const Slate = (props: {
...rest
} = props
const [context, setContext] = React.useState<SlateContextValue>(() => {
// Run once on first mount, but before `useEffect` or render
React.useState(() => {
if (!Node.isNodeList(initialValue)) {
throw new Error(
`[Slate] initialValue is invalid! Expected a list of elements but got: ${Scrubber.stringify(
@@ -43,18 +43,19 @@ export const Slate = (props: {
)}`
)
}
if (!Editor.isEditor(editor)) {
throw new Error(
`[Slate] editor is invalid! You passed: ${Scrubber.stringify(editor)}`
)
}
editor.children = initialValue
Object.assign(editor, rest)
return { v: 0, editor }
})
const { selectorContext, onChange: handleSelectorChange } =
useSelectorContext(editor)
useSelectorContext()
const onContextChange = useCallback(
(options?: { operation?: Operation }) => {
@@ -70,11 +71,7 @@ export const Slate = (props: {
onValueChange?.(editor.children)
}
setContext(prevContext => ({
v: prevContext.v + 1,
editor,
}))
handleSelectorChange(editor)
handleSelectorChange()
},
[editor, handleSelectorChange, onChange, onSelectionChange, onValueChange]
)
@@ -117,13 +114,11 @@ export const Slate = (props: {
return (
<SlateSelectorContext.Provider value={selectorContext}>
<SlateContext.Provider value={context}>
<EditorContext.Provider value={context.editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateContext.Provider>
<EditorContext.Provider value={editor}>
<FocusedContext.Provider value={isFocused}>
{children}
</FocusedContext.Provider>
</EditorContext.Provider>
</SlateSelectorContext.Provider>
)
}

View File

@@ -13,6 +13,9 @@ import {
RenderTextProps,
} from './editable'
import Leaf from './leaf'
import { useDecorations } from '../hooks/use-decorations'
const defaultRenderText = (props: RenderTextProps) => <DefaultText {...props} />
/**
* Text.
@@ -28,16 +31,18 @@ const Text = (props: {
text: SlateText
}) => {
const {
decorations,
decorations: parentDecorations,
isLast,
parent,
renderPlaceholder,
renderLeaf,
renderText = (props: RenderTextProps) => <DefaultText {...props} />,
renderText = defaultRenderText,
text,
} = props
const editor = useSlateStatic()
const ref = useRef<HTMLSpanElement | null>(null)
const decorations = useDecorations(text, parentDecorations)
const decoratedLeaves = SlateText.decorations(text, decorations)
const key = ReactEditor.findKey(editor, text)
const children = []

View File

@@ -1,13 +1,8 @@
import React from 'react'
import {
Ancestor,
Descendant,
Editor,
Element,
Range,
DecoratedRange,
} from 'slate'
import React, { useCallback, useRef } from 'react'
import { Ancestor, Editor, Element, DecoratedRange, Text } from 'slate'
import { Key, isElementDecorationsEqual } from 'slate-dom'
import {
RenderChunkProps,
RenderElementProps,
RenderLeafProps,
RenderPlaceholderProps,
@@ -17,10 +12,16 @@ import {
import ElementComponent from '../components/element'
import TextComponent from '../components/text'
import { ReactEditor } from '../plugin/react-editor'
import { IS_NODE_MAP_DIRTY, NODE_TO_INDEX, NODE_TO_PARENT } from 'slate-dom'
import { useDecorate } from './use-decorate'
import { SelectedContext } from './use-selected'
import {
IS_NODE_MAP_DIRTY,
NODE_TO_INDEX,
NODE_TO_PARENT,
splitDecorationsByChild,
} from 'slate-dom'
import { useSlateStatic } from './use-slate-static'
import { getChunkTreeForNode } from '../chunking'
import ChunkTree from '../components/chunk-tree'
import { ElementContext } from './use-element'
/**
* Children.
@@ -30,81 +31,160 @@ const useChildren = (props: {
decorations: DecoratedRange[]
node: Ancestor
renderElement?: (props: RenderElementProps) => JSX.Element
renderChunk?: (props: RenderChunkProps) => JSX.Element
renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
renderText?: (props: RenderTextProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
selection: Range | null
}) => {
const {
decorations,
node,
renderElement,
renderChunk,
renderPlaceholder,
renderText,
renderLeaf,
selection,
} = props
const decorate = useDecorate()
const editor = useSlateStatic()
IS_NODE_MAP_DIRTY.set(editor as ReactEditor, false)
const path = ReactEditor.findPath(editor, node)
const children = []
const isLeafBlock =
Element.isElement(node) &&
!editor.isInline(node) &&
Editor.hasInlines(editor, node)
for (let i = 0; i < node.children.length; i++) {
const p = path.concat(i)
const n = node.children[i] as Descendant
const key = ReactEditor.findKey(editor, n)
const range = Editor.range(editor, p)
const sel = selection && Range.intersection(range, selection)
const ds = decorate([n, p])
const isEditor = Editor.isEditor(node)
const isBlock = !isEditor && Element.isElement(node) && !editor.isInline(node)
const isLeafBlock = isBlock && Editor.hasInlines(editor, node)
const chunkSize = isLeafBlock ? null : editor.getChunkSize(node)
const chunking = !!chunkSize
for (const dec of decorations) {
const d = Range.intersection(dec, range)
const { decorationsByChild, childrenToRedecorate } = useDecorationsByChild(
editor,
node,
decorations
)
if (d) {
ds.push(d)
}
}
// Update the index and parent of each child.
// PERF: If chunking is enabled, this is done while traversing the chunk tree
// instead to eliminate unnecessary weak map operations.
if (!chunking) {
node.children.forEach((n, i) => {
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
})
}
if (Element.isElement(n)) {
children.push(
<SelectedContext.Provider key={`provider-${key.id}`} value={!!sel}>
const renderElementComponent = useCallback(
(n: Element, i: number, cachedKey?: Key) => {
const key = cachedKey ?? ReactEditor.findKey(editor, n)
return (
<ElementContext.Provider key={`provider-${key.id}`} value={n}>
<ElementComponent
decorations={ds}
decorations={decorationsByChild[i]}
element={n}
key={key.id}
renderElement={renderElement}
renderChunk={renderChunk}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
renderText={renderText}
selection={sel}
/>
</SelectedContext.Provider>
</ElementContext.Provider>
)
} else {
children.push(
<TextComponent
decorations={ds}
key={key.id}
isLast={isLeafBlock && i === node.children.length - 1}
parent={node}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
renderText={renderText}
text={n}
/>
)
}
},
[
editor,
decorationsByChild,
renderElement,
renderChunk,
renderPlaceholder,
renderLeaf,
renderText,
]
)
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
const renderTextComponent = (n: Text, i: number) => {
const key = ReactEditor.findKey(editor, n)
return (
<TextComponent
decorations={decorationsByChild[i]}
key={key.id}
isLast={i === node.children.length - 1}
parent={node}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
renderText={renderText}
text={n}
/>
)
}
return children
if (!chunking) {
return node.children.map((n, i) =>
Text.isText(n) ? renderTextComponent(n, i) : renderElementComponent(n, i)
)
}
const chunkTree = getChunkTreeForNode(editor, node, {
reconcile: {
chunkSize,
rerenderChildren: childrenToRedecorate,
onInsert: (n, i) => {
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
},
onUpdate: (n, i) => {
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
},
onIndexChange: (n, i) => {
NODE_TO_INDEX.set(n, i)
},
},
})
return (
<ChunkTree
root={chunkTree}
ancestor={chunkTree}
renderElement={renderElementComponent}
renderChunk={renderChunk}
/>
)
}
const useDecorationsByChild = (
editor: Editor,
node: Ancestor,
decorations: DecoratedRange[]
) => {
const decorationsByChild = splitDecorationsByChild(editor, node, decorations)
// The value we return is a mutable array of `DecoratedRange[]` arrays. This
// lets us avoid passing an immutable array of decorations for each child into
// `ChunkTree` using props. Each `DecoratedRange[]` is only updated if the
// decorations at that index have changed, which speeds up the equality check
// for the `decorations` prop in the memoized `Element` and `Text` components.
const mutableDecorationsByChild = useRef(decorationsByChild).current
// Track the list of child indices whose decorations have changed, so that we
// can tell the chunk tree to re-render these children.
const childrenToRedecorate: number[] = []
// Resize the mutable array to match the latest result
mutableDecorationsByChild.length = decorationsByChild.length
for (let i = 0; i < decorationsByChild.length; i++) {
const decorations = decorationsByChild[i]
const previousDecorations: DecoratedRange[] | null =
mutableDecorationsByChild[i] ?? null
if (!isElementDecorationsEqual(previousDecorations, decorations)) {
mutableDecorationsByChild[i] = decorations
childrenToRedecorate.push(i)
}
}
return { decorationsByChild: mutableDecorationsByChild, childrenToRedecorate }
}
export default useChildren

View File

@@ -1,18 +0,0 @@
import { createContext, useContext } from 'react'
import { DecoratedRange, NodeEntry } from 'slate'
/**
* A React context for sharing the `decorate` prop of the editable.
*/
export const DecorateContext = createContext<
(entry: NodeEntry) => DecoratedRange[]
>(() => [])
/**
* Get the current `decorate` prop of the editable.
*/
export const useDecorate = (): ((entry: NodeEntry) => DecoratedRange[]) => {
return useContext(DecorateContext)
}

View File

@@ -0,0 +1,81 @@
import { createContext, useCallback, useContext, useMemo, useRef } from 'react'
import { DecoratedRange, Descendant, NodeEntry, Text } from 'slate'
import { isTextDecorationsEqual, isElementDecorationsEqual } from 'slate-dom'
import { useSlateStatic } from './use-slate-static'
import { ReactEditor } from '../plugin/react-editor'
import { useGenericSelector } from './use-generic-selector'
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
type Callback = () => void
/**
* A React context for sharing the `decorate` prop of the editable and
* subscribing to changes on this prop.
*/
export const DecorateContext = createContext<{
decorate: (entry: NodeEntry) => DecoratedRange[]
addEventListener: (callback: Callback) => () => void
}>({} as any)
export const useDecorations = (
node: Descendant,
parentDecorations: DecoratedRange[]
): DecoratedRange[] => {
const editor = useSlateStatic()
const { decorate, addEventListener } = useContext(DecorateContext)
// Not memoized since we want nodes to be decorated on each render
const selector = () => {
const path = ReactEditor.findPath(editor, node)
return decorate([node, path])
}
const equalityFn = Text.isText(node)
? isTextDecorationsEqual
: isElementDecorationsEqual
const [decorations, update] = useGenericSelector(selector, equalityFn)
useIsomorphicLayoutEffect(() => {
const unsubscribe = addEventListener(update)
update()
return unsubscribe
}, [addEventListener, update])
return useMemo(
() => [...decorations, ...parentDecorations],
[decorations, parentDecorations]
)
}
export const useDecorateContext = (
decorateProp: (entry: NodeEntry) => DecoratedRange[]
) => {
const eventListeners = useRef(new Set<Callback>())
const latestDecorate = useRef(decorateProp)
useIsomorphicLayoutEffect(() => {
latestDecorate.current = decorateProp
eventListeners.current.forEach(listener => listener())
}, [decorateProp])
const decorate = useCallback(
(entry: NodeEntry) => latestDecorate.current(entry),
[]
)
const addEventListener = useCallback((callback: Callback) => {
eventListeners.current.add(callback)
return () => {
eventListeners.current.delete(callback)
}
}, [])
return useMemo(
() => ({ decorate, addEventListener }),
[decorate, addEventListener]
)
}

View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import { Element } from 'slate'
export const ElementContext = createContext<Element | null>(null)
/**
* Get the current element.
*/
export const useElement = (): Element => {
const context = useContext(ElementContext)
if (!context) {
throw new Error(
'The `useElement` hook must be used inside `renderElement`.'
)
}
return context
}
/**
* Get the current element, or return null if not inside `renderElement`.
*/
export const useElementIf = () => useContext(ElementContext)

View File

@@ -0,0 +1,92 @@
import { useCallback, useReducer, useRef } from 'react'
/**
* Create a selector that updates when an `update` function is called, and
* which only causes the component to render when the result of `selector`
* differs from the previous result according to `equalityFn`.
*
* If `selector` is memoized using `useCallback`, then it will only be called
* when it changes or when `update` is called. Otherwise, `selector` will be
* called every time the component renders.
*
* @example
* const [state, update] = useGenericSelector(selector, equalityFn)
*
* useIsomorphicLayoutEffect(() => {
* return addEventListener(update)
* }, [addEventListener, update])
*
* return state
*/
export function useGenericSelector<T>(
selector: () => T,
equalityFn: (a: T | null, b: T) => boolean
): [state: T, update: () => void] {
const [, forceRender] = useReducer(s => s + 1, 0)
const latestSubscriptionCallbackError = useRef<Error | undefined>()
const latestSelector = useRef<() => T>(() => null as any)
const latestSelectedState = useRef<T | null>(null)
let selectedState: T
try {
if (
selector !== latestSelector.current ||
latestSubscriptionCallbackError.current
) {
const selectorResult = selector()
if (equalityFn(latestSelectedState.current, selectorResult)) {
selectedState = latestSelectedState.current as T
} else {
selectedState = selectorResult
}
} else {
selectedState = latestSelectedState.current as T
}
} catch (err) {
if (latestSubscriptionCallbackError.current && isError(err)) {
err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n`
}
throw err
}
latestSelector.current = selector
latestSelectedState.current = selectedState
latestSubscriptionCallbackError.current = undefined
const update = useCallback(() => {
try {
const newSelectedState = latestSelector.current()
if (equalityFn(latestSelectedState.current, newSelectedState)) {
return
}
latestSelectedState.current = newSelectedState
} catch (err) {
// we ignore all errors here, since when the component
// is re-rendered, the selectors are called again, and
// will throw again, if neither props nor store state
// changed
if (err instanceof Error) {
latestSubscriptionCallbackError.current = err
} else {
latestSubscriptionCallbackError.current = new Error(String(err))
}
}
forceRender()
// don't rerender on equalityFn change since we want to be able to define it inline
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return [selectedState, update]
}
function isError(error: any): error is Error {
return error instanceof Error
}

View File

@@ -1,15 +1,37 @@
import { createContext, useContext } from 'react'
/**
* A React context for sharing the `selected` state of an element.
*/
export const SelectedContext = createContext(false)
import { useCallback } from 'react'
import { Editor, Range } from 'slate'
import { useElementIf } from './use-element'
import { useSlateSelector } from './use-slate-selector'
import { ReactEditor } from '../plugin/react-editor'
/**
* Get the current `selected` state of an element.
*/
export const useSelected = (): boolean => {
return useContext(SelectedContext)
const element = useElementIf()
// Breaking the rules of hooks is fine here since `!element` will remain true
// or false for the entire lifetime of the component this hook is called from.
// TODO: Decide if we want to throw an error instead when calling
// `useSelected` outside of an element (potentially a breaking change).
if (!element) return false
// eslint-disable-next-line react-hooks/rules-of-hooks
const selector = useCallback(
(editor: Editor) => {
if (!editor.selection) return false
const path = ReactEditor.findPath(editor, element)
const range = Editor.range(editor, path)
return !!Range.intersection(range, editor.selection)
},
[element]
)
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSlateSelector(selector, undefined, {
// Defer the selector until after `Editable` has rendered so that the path
// will be accurate.
deferred: true,
})
}

View File

@@ -1,120 +1,78 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
} from 'react'
import { createContext, useCallback, useContext, useMemo, useRef } from 'react'
import { Editor } from 'slate'
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
import { useSlateStatic } from './use-slate-static'
import { useGenericSelector } from './use-generic-selector'
function isError(error: any): error is Error {
return error instanceof Error
type Callback = () => void
export interface SlateSelectorOptions {
/**
* If true, defer calling the selector function until after `Editable` has
* finished rendering. This ensures that `ReactEditor.findPath` won't return
* an outdated path if called inside the selector.
*/
deferred?: boolean
}
type EditorChangeHandler = (editor: Editor) => void
/**
* A React context for sharing the editor selector context in a way to control rerenders
* A React context for sharing the editor selector context in a way to control
* re-renders.
*/
export const SlateSelectorContext = createContext<{
getSlate: () => Editor
addEventListener: (callback: EditorChangeHandler) => () => void
addEventListener: (
callback: Callback,
options?: SlateSelectorOptions
) => () => void
flushDeferred: () => void
}>({} as any)
const refEquality = (a: any, b: any) => a === b
/**
* use redux style selectors to prevent rerendering on every keystroke.
* Bear in mind rerendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function.
* Use redux style selectors to prevent re-rendering on every keystroke.
*
* Example:
* ```
* const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection));
* ```
* Bear in mind re-rendering can only prevented if the returned value is a value
* type or for reference types (e.g. objects and arrays) add a custom equality
* function.
*
* If `selector` is memoized using `useCallback`, then it will only be called
* when it or the editor state changes. Otherwise, `selector` will be called
* every time the component renders.
*
* @example
* const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection))
*/
export function useSlateSelector<T>(
selector: (editor: Editor) => T,
equalityFn: (a: T, b: T) => boolean = refEquality
) {
const [, forceRender] = useReducer(s => s + 1, 0)
equalityFn: (a: T | null, b: T) => boolean = refEquality,
{ deferred }: SlateSelectorOptions = {}
): T {
const context = useContext(SlateSelectorContext)
if (!context) {
throw new Error(
`The \`useSlateSelector\` hook must be used inside the <Slate> component's context.`
)
}
const { getSlate, addEventListener } = context
const { addEventListener } = context
const latestSubscriptionCallbackError = useRef<Error | undefined>()
const latestSelector = useRef<(editor: Editor) => T>(() => null as any)
const latestSelectedState = useRef<T>(null as any as T)
let selectedState: T
try {
if (
selector !== latestSelector.current ||
latestSubscriptionCallbackError.current
) {
const selectorResult = selector(getSlate())
if (equalityFn(latestSelectedState.current, selectorResult)) {
selectedState = latestSelectedState.current
} else {
selectedState = selectorResult
}
} else {
selectedState = latestSelectedState.current
}
} catch (err) {
if (latestSubscriptionCallbackError.current && isError(err)) {
err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n`
}
throw err
}
useIsomorphicLayoutEffect(() => {
latestSelector.current = selector
latestSelectedState.current = selectedState
latestSubscriptionCallbackError.current = undefined
})
useIsomorphicLayoutEffect(
() => {
function checkForUpdates() {
try {
const newSelectedState = latestSelector.current(getSlate())
if (equalityFn(newSelectedState, latestSelectedState.current)) {
return
}
latestSelectedState.current = newSelectedState
} catch (err) {
// we ignore all errors here, since when the component
// is re-rendered, the selectors are called again, and
// will throw again, if neither props nor store state
// changed
if (err instanceof Error) {
latestSubscriptionCallbackError.current = err
} else {
latestSubscriptionCallbackError.current = new Error(String(err))
}
}
forceRender()
}
const unsubscribe = addEventListener(checkForUpdates)
checkForUpdates()
return () => unsubscribe()
},
// don't rerender on equalityFn change since we want to be able to define it inline
[addEventListener, getSlate]
const editor = useSlateStatic()
const genericSelector = useCallback(
() => selector(editor),
[editor, selector]
)
const [selectedState, update] = useGenericSelector(
genericSelector,
equalityFn
)
useIsomorphicLayoutEffect(() => {
const unsubscribe = addEventListener(update, { deferred })
update()
return unsubscribe
}, [addEventListener, update, deferred])
return selectedState
}
@@ -122,33 +80,49 @@ export function useSlateSelector<T>(
/**
* Create selector context with editor updating on every editor change
*/
export function useSelectorContext(editor: Editor) {
const eventListeners = useRef<EditorChangeHandler[]>([]).current
const slateRef = useRef<{
editor: Editor
}>({
editor,
}).current
const onChange = useCallback(
(editor: Editor) => {
slateRef.editor = editor
eventListeners.forEach((listener: EditorChangeHandler) =>
listener(editor)
)
export function useSelectorContext() {
const eventListeners = useRef(new Set<Callback>())
const deferredEventListeners = useRef(new Set<Callback>())
const onChange = useCallback(() => {
eventListeners.current.forEach(listener => listener())
}, [])
const flushDeferred = useCallback(() => {
deferredEventListeners.current.forEach(listener => listener())
deferredEventListeners.current.clear()
}, [])
const addEventListener = useCallback(
(
callbackProp: Callback,
{ deferred = false }: SlateSelectorOptions = {}
) => {
const callback = deferred
? () => deferredEventListeners.current.add(callbackProp)
: callbackProp
eventListeners.current.add(callback)
return () => {
eventListeners.current.delete(callback)
}
},
[eventListeners, slateRef]
[]
)
const selectorContext = useMemo(
() => ({
addEventListener,
flushDeferred,
}),
[addEventListener, flushDeferred]
)
const selectorContext = useMemo(() => {
return {
getSlate: () => slateRef.editor,
addEventListener: (callback: EditorChangeHandler) => {
eventListeners.push(callback)
return () => {
eventListeners.splice(eventListeners.indexOf(callback), 1)
}
},
}
}, [eventListeners, slateRef])
return { selectorContext, onChange }
}
export function useFlushDeferredSelectorsOnRender() {
const { flushDeferred } = useContext(SlateSelectorContext)
useIsomorphicLayoutEffect(flushDeferred)
}

View File

@@ -1,47 +1,64 @@
import { createContext, useContext } from 'react'
import { MutableRefObject, useContext, useMemo, useReducer } from 'react'
import { Editor } from 'slate'
import { ReactEditor } from '../plugin/react-editor'
import { SlateSelectorContext } from './use-slate-selector'
import { useSlateStatic } from './use-slate-static'
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
/**
* A React context for sharing the editor object, in a way that re-renders the
* context whenever changes occur.
*/
export interface SlateContextValue {
v: number
editor: ReactEditor
}
export const SlateContext = createContext<{
v: number
editor: ReactEditor
} | null>(null)
/**
* Get the current editor object from the React context.
* Get the current editor object and re-render whenever it changes.
*/
export const useSlate = (): Editor => {
const context = useContext(SlateContext)
const { addEventListener } = useContext(SlateSelectorContext)
const [, forceRender] = useReducer(s => s + 1, 0)
if (!context) {
if (!addEventListener) {
throw new Error(
`The \`useSlate\` hook must be used inside the <Slate> component's context.`
)
}
const { editor } = context
return editor
useIsomorphicLayoutEffect(
() => addEventListener(forceRender),
[addEventListener]
)
return useSlateStatic()
}
const EDITOR_TO_V = new WeakMap<Editor, MutableRefObject<number>>()
const getEditorVersionRef = (editor: Editor): MutableRefObject<number> => {
let v = EDITOR_TO_V.get(editor)
if (v) {
return v
}
v = { current: 0 }
EDITOR_TO_V.set(editor, v)
// Register the `onChange` handler exactly once per editor
const { onChange } = editor
editor.onChange = options => {
v!.current++
onChange(options)
}
return v
}
/**
* Get the current editor object and its version, which increments on every
* change.
*
* @deprecated The `v` counter is no longer used except for this hook, and may
* be removed in a future version.
*/
export const useSlateWithV = (): { editor: Editor; v: number } => {
const context = useContext(SlateContext)
if (!context) {
throw new Error(
`The \`useSlate\` hook must be used inside the <Slate> component's context.`
)
}
return context
const editor = useSlate()
const vRef = useMemo(() => getEditorVersionRef(editor), [editor])
return { editor, v: vRef.current }
}

View File

@@ -2,6 +2,7 @@
export {
Editable,
RenderElementProps,
RenderChunkProps,
RenderLeafProps,
RenderPlaceholderProps,
DefaultPlaceholder,
@@ -14,6 +15,7 @@ export { Slate } from './components/slate'
// Hooks
export { useEditor } from './hooks/use-editor'
export { useElement, useElementIf } from './hooks/use-element'
export { useSlateStatic } from './hooks/use-slate-static'
export { useComposing } from './hooks/use-composing'
export { useFocused } from './hooks/use-focused'

View File

@@ -1,10 +1,18 @@
import { Ancestor } from 'slate'
import { DOMEditor, type DOMEditorInterface } from 'slate-dom'
/**
* A React and DOM-specific version of the `Editor` interface.
*/
export interface ReactEditor extends DOMEditor {}
export interface ReactEditor extends DOMEditor {
/**
* Determines the chunk size used by the children chunking optimization. If
* null is returned (which is the default), the chunking optimization is
* disabled.
*/
getChunkSize: (node: Ancestor) => number | null
}
export interface ReactEditorInterface extends DOMEditorInterface {}

View File

@@ -1,8 +1,9 @@
import ReactDOM from 'react-dom'
import { BaseEditor } from 'slate'
import { BaseEditor, Node } from 'slate'
import { withDOM } from 'slate-dom'
import { ReactEditor } from './react-editor'
import { REACT_MAJOR_VERSION } from '../utils/environment'
import { getChunkTreeForNode } from '../chunking'
/**
* `withReact` adds React and DOM specific behaviors to the editor.
@@ -20,7 +21,9 @@ export const withReact = <T extends BaseEditor>(
e = withDOM(e, clipboardFormatKey)
const { onChange } = e
const { onChange, apply } = e
e.getChunkSize = () => null
e.onChange = options => {
// COMPAT: React < 18 doesn't batch `setState` hook calls, which means
@@ -38,5 +41,24 @@ export const withReact = <T extends BaseEditor>(
})
}
// On move_node, if the chunking optimization is enabled for the parent of the
// node being moved, add the moved node to the movedNodeKeys set of the
// parent's chunk tree.
e.apply = operation => {
if (operation.type === 'move_node') {
const parent = Node.parent(e, operation.path)
const chunking = !!e.getChunkSize(parent)
if (chunking) {
const node = Node.get(e, operation.path)
const chunkTree = getChunkTreeForNode(e, parent)
const key = ReactEditor.findKey(e, node)
chunkTree.movedNodeKeys.add(key)
}
}
apply(operation)
}
return e
}

View File

@@ -0,0 +1,953 @@
import {
Descendant,
Editor,
Element,
Node,
Transforms,
createEditor,
} from 'slate'
import { Key } from 'slate-dom'
import { ReactEditor, withReact } from '../src'
import {
Chunk,
ChunkAncestor,
ChunkDescendant,
ChunkLeaf,
ChunkNode,
ChunkTree,
KEY_TO_CHUNK_TREE,
getChunkTreeForNode,
} from '../src/chunking'
import { ReconcileOptions } from '../src/chunking/reconcile-children'
const block = (text: string): Element => ({ children: [{ text }] })
const blocks = (count: number) =>
Array.from(
{
length: count,
},
(_, i) => block(i.toString())
)
const reconcileEditor = (
editor: ReactEditor,
options: Omit<ReconcileOptions, 'chunkTree' | 'children' | 'chunkSize'> = {}
) =>
getChunkTreeForNode(editor, editor, {
reconcile: {
chunkSize: 3,
debug: true,
...options,
},
})
type TreeShape = string | TreeShape[]
const getTreeShape = (chunkNode: ChunkNode): TreeShape => {
if (chunkNode.type === 'leaf') {
return Node.string(chunkNode.node)
}
return chunkNode.children.map(getTreeShape)
}
const getChildrenAndTreeForShape = (
editor: ReactEditor,
treeShape: TreeShape[]
): { children: Descendant[]; chunkTree: ChunkTree } => {
const children: Descendant[] = []
const shapeToNode = (
ts: TreeShape,
parent: ChunkAncestor
): ChunkDescendant => {
if (Array.isArray(ts)) {
const chunk: Chunk = {
type: 'chunk',
key: new Key(),
parent,
children: [],
}
chunk.children = ts.map(child => shapeToNode(child, chunk))
return chunk
}
const node = block(ts)
const index = children.length
children.push(node)
return {
type: 'leaf',
key: ReactEditor.findKey(editor, node),
node,
index,
}
}
const chunkTree: ChunkTree = {
type: 'root',
modifiedChunks: new Set(),
movedNodeKeys: new Set(),
children: [],
}
chunkTree.children = treeShape.map(child => shapeToNode(child, chunkTree))
return { children, chunkTree }
}
const withChunking = (editor: ReactEditor) => {
editor.getChunkSize = node => (Editor.isEditor(node) ? 3 : null)
return editor
}
const createEditorWithShape = (treeShape: TreeShape[]) => {
const editor = withChunking(withReact(createEditor()))
const { children, chunkTree } = getChildrenAndTreeForShape(editor, treeShape)
editor.children = children
const key = ReactEditor.findKey(editor, editor)
KEY_TO_CHUNK_TREE.set(key, chunkTree)
return editor
}
// https://stackoverflow.com/a/29450606
const createPRNG = (seed: number) => {
const mask = 0xffffffff
let m_w = (123456789 + seed) & mask
let m_z = (987654321 - seed) & mask
return () => {
m_z = (36969 * (m_z & 65535) + (m_z >>> 16)) & mask
m_w = (18000 * (m_w & 65535) + (m_w >>> 16)) & mask
let result = ((m_z << 16) + (m_w & 65535)) >>> 0
result /= 4294967296
return result
}
}
describe('getChunkTreeForNode', () => {
describe('chunking initial value', () => {
const getShapeForInitialCount = (count: number) => {
const editor = withChunking(withReact(createEditor()))
editor.children = blocks(count)
const chunkTree = reconcileEditor(editor)
return getTreeShape(chunkTree)
}
it('returns empty tree for 0 children', () => {
expect(getShapeForInitialCount(0)).toEqual([])
})
it('returns flat tree for 1 child', () => {
expect(getShapeForInitialCount(1)).toEqual(['0'])
})
it('returns flat tree for 3 children', () => {
expect(getShapeForInitialCount(3)).toEqual(['0', '1', '2'])
})
it('returns 1 layer of chunking for 4 children', () => {
expect(getShapeForInitialCount(4)).toEqual([['0', '1', '2'], ['3']])
})
it('returns 1 layer of chunking for 9 children', () => {
expect(getShapeForInitialCount(9)).toEqual([
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
])
})
it('returns 2 layers of chunking for 10 children', () => {
expect(getShapeForInitialCount(10)).toEqual([
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[['9']],
])
})
it('returns 2 layers of chunking for 27 children', () => {
expect(getShapeForInitialCount(27)).toEqual([
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[
['18', '19', '20'],
['21', '22', '23'],
['24', '25', '26'],
],
])
})
it('returns 3 layers of chunking for 28 children', () => {
expect(getShapeForInitialCount(28)).toEqual([
[
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[
['18', '19', '20'],
['21', '22', '23'],
['24', '25', '26'],
],
],
[[['27']]],
])
})
it('calls onInsert for initial children', () => {
const editor = withChunking(withReact(createEditor()))
editor.children = blocks(3)
const onInsert = jest.fn()
reconcileEditor(editor, { onInsert })
expect(onInsert.mock.calls).toEqual([
[editor.children[0], 0],
[editor.children[1], 1],
[editor.children[2], 2],
])
})
it('sets the index of each chunk leaf', () => {
const editor = withChunking(withReact(createEditor()))
editor.children = blocks(9)
const chunkTree = reconcileEditor(editor)
const chunks = chunkTree.children as Chunk[]
const leaves = chunks.map(chunk => chunk.children)
expect(leaves).toMatchObject([
[{ index: 0 }, { index: 1 }, { index: 2 }],
[{ index: 3 }, { index: 4 }, { index: 5 }],
[{ index: 6 }, { index: 7 }, { index: 8 }],
])
})
})
describe('inserting nodes', () => {
describe('in empty editor', () => {
it('inserts a single node', () => {
const editor = createEditorWithShape([])
Transforms.insertNodes(editor, block('x'), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['x'])
})
it('inserts 27 nodes with 2 layers of chunking', () => {
const editor = createEditorWithShape([])
Transforms.insertNodes(editor, blocks(27), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[
['18', '19', '20'],
['21', '22', '23'],
['24', '25', '26'],
],
])
})
it('inserts 28 nodes with 3 layers of chunking', () => {
const editor = createEditorWithShape([])
Transforms.insertNodes(editor, blocks(28), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
[
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[
['18', '19', '20'],
['21', '22', '23'],
['24', '25', '26'],
],
],
[[['27']]],
])
})
it('inserts nodes one by one', () => {
const editor = createEditorWithShape([])
let chunkTree: ChunkTree
blocks(31).forEach((node, i) => {
Transforms.insertNodes(editor, node, { at: [i] })
chunkTree = reconcileEditor(editor)
})
expect(getTreeShape(chunkTree!)).toEqual([
'0',
'1',
'2',
['3', '4', '5'],
['6', '7', '8'],
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
['18', '19', '20'],
[
['21', '22', '23'],
['24', '25', '26'],
['27', '28', '29'],
],
[['30']],
])
})
it('inserts nodes one by one in reverse order', () => {
const editor = createEditorWithShape([])
let chunkTree: ChunkTree
blocks(31)
.reverse()
.forEach(node => {
Transforms.insertNodes(editor, node, { at: [0] })
chunkTree = reconcileEditor(editor)
})
expect(getTreeShape(chunkTree!)).toEqual([
[['0']],
[
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
],
['10', '11', '12'],
['13', '14', '15'],
['16', '17', '18'],
['19', '20', '21'],
['22', '23', '24'],
['25', '26', '27'],
'28',
'29',
'30',
])
})
})
describe('at end of editor', () => {
it('inserts a single node at the top level', () => {
const editor = createEditorWithShape(['0', ['1', '2', ['3', '4', '5']]])
Transforms.insertNodes(editor, block('x'), { at: [6] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'0',
['1', '2', ['3', '4', '5']],
[['x']],
])
})
it('inserts a single node into a chunk', () => {
const editor = createEditorWithShape(['0', ['1', ['2', '3', '4']]])
Transforms.insertNodes(editor, block('x'), { at: [5] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'0',
['1', ['2', '3', '4'], ['x']],
])
})
it('inserts a single node into a nested chunk', () => {
const editor = createEditorWithShape(['0', ['1', '2', ['3', '4']]])
Transforms.insertNodes(editor, block('x'), { at: [5] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'0',
['1', '2', ['3', '4', 'x']],
])
})
it('inserts 25 nodes after 2 nodes with 2 layers of chunking', () => {
const editor = createEditorWithShape(['a', 'b'])
Transforms.insertNodes(editor, blocks(25), { at: [2] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'a',
'b',
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[['18', '19', '20'], ['21', '22', '23'], ['24']],
])
})
it('inserts 25 nodes after 3 nodes with 3 layers of chunking', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(25), { at: [3] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'a',
'b',
'c',
[
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[
['9', '10', '11'],
['12', '13', '14'],
['15', '16', '17'],
],
[['18', '19', '20'], ['21', '22', '23'], ['24']],
],
])
})
it('inserts many nodes at the ends of multiple nested chunks', () => {
const editor = createEditorWithShape(['a', ['b', ['c']]])
Transforms.insertNodes(editor, blocks(12), { at: [3] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'a',
['b', ['c', '0', '1'], ['2']],
[
['3', '4', '5'],
['6', '7', '8'],
['9', '10', '11'],
],
])
})
it('calls onInsert for inserted nodes', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(2), { at: [3] })
const onInsert = jest.fn()
reconcileEditor(editor, { onInsert })
expect(onInsert.mock.calls).toEqual([
[editor.children[3], 3],
[editor.children[4], 4],
])
})
it('sets the index of inserted leaves', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(2), { at: [3] })
const chunkTree = reconcileEditor(editor)
const chunk = chunkTree.children[3] as Chunk
expect(chunk.children).toMatchObject([{ index: 3 }, { index: 4 }])
})
})
describe('at start of editor', () => {
it('inserts a single node at the top level', () => {
const editor = createEditorWithShape(['0', '1'])
Transforms.insertNodes(editor, block('x'), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['x', '0', '1'])
})
it('inserts many nodes at the starts of multiple nested chunks', () => {
const editor = createEditorWithShape([[['a'], 'b'], 'c'])
Transforms.insertNodes(editor, blocks(12), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7', '8'],
],
[['9'], ['10', '11', 'a'], 'b'],
'c',
])
})
})
describe('in the middle of editor', () => {
describe('at the top level', () => {
it('inserts a single node', () => {
const editor = createEditorWithShape(['0', '1'])
Transforms.insertNodes(editor, block('x'), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '1'])
})
it('inserts nodes at the start of subsequent sibling chunks', () => {
const editor = createEditorWithShape(['a', [['b', 'c'], 'd'], 'e'])
Transforms.insertNodes(editor, blocks(3), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
'a',
[['0']],
[['1'], ['2', 'b', 'c'], 'd'],
'e',
])
})
it('calls onInsert for inserted nodes', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(2), { at: [1] })
const onInsert = jest.fn()
reconcileEditor(editor, { onInsert })
expect(onInsert.mock.calls).toEqual([
[editor.children[1], 1],
[editor.children[2], 2],
])
})
it('calls onIndexChange for subsequent nodes', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(2), { at: [1] })
const onIndexChange = jest.fn()
reconcileEditor(editor, { onIndexChange })
expect(onIndexChange.mock.calls).toEqual([
[editor.children[3], 3],
[editor.children[4], 4],
])
})
it('updates the index of subsequent leaves', () => {
const editor = createEditorWithShape(['a', 'b', 'c'])
Transforms.insertNodes(editor, blocks(3), { at: [1] })
const chunkTree = reconcileEditor(editor)
const subsequentLeaves = chunkTree.children.slice(2)
expect(subsequentLeaves).toMatchObject([{ index: 4 }, { index: 5 }])
})
})
describe('in the middle of a chunk', () => {
it('inserts a single node', () => {
const editor = createEditorWithShape([[['0', '1']]])
Transforms.insertNodes(editor, block('x'), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([[['0', 'x', '1']]])
})
it('inserts 8 nodes between 2 nodes', () => {
const editor = createEditorWithShape([[['a', 'b']]])
Transforms.insertNodes(editor, blocks(8), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
[
[
'a',
[
['0', '1', '2'],
['3', '4', '5'],
['6', '7'],
],
'b',
],
],
])
})
it('inserts nodes at the start of subsequent sibling chunks', () => {
const editor = createEditorWithShape([['a', [['b', 'c'], 'd'], 'e']])
Transforms.insertNodes(editor, blocks(3), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
['a', [['0']], [['1'], ['2', 'b', 'c'], 'd'], 'e'],
])
})
})
describe('at the end of a chunk', () => {
it('inserts 2 nodes in 2 adjacent shallow chunks', () => {
const editor = createEditorWithShape([['a', 'b'], ['c']])
Transforms.insertNodes(editor, blocks(2), { at: [2] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
['a', 'b', '0'],
['1', 'c'],
])
})
it('inserts nodes in many adjacent nested chunks', () => {
const editor = createEditorWithShape([
[
['a', ['b', ['c']]],
[[['d'], 'e'], 'f'],
],
])
Transforms.insertNodes(editor, blocks(17), { at: [3] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([
[
['a', ['b', ['c', '0', '1'], ['2']], [['3']]],
[
[
['4', '5', '6'],
['7', '8', '9'],
['10', '11', '12'],
],
],
[[['13']], [['14'], ['15', '16', 'd'], 'e'], 'f'],
],
])
})
})
})
})
describe('removing nodes', () => {
it('removes a node', () => {
const editor = createEditorWithShape(['0', [['1']], '2'])
Transforms.removeNodes(editor, { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', '2'])
})
it('removes multiple consecutive nodes', () => {
const editor = createEditorWithShape(['0', ['1', '2', '3'], '4'])
Transforms.removeNodes(editor, { at: [3] })
Transforms.removeNodes(editor, { at: [2] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', ['1'], '4'])
})
it('removes multiple non-consecutive nodes', () => {
const editor = createEditorWithShape(['0', ['1', '2', '3'], '4'])
Transforms.removeNodes(editor, { at: [3] })
Transforms.removeNodes(editor, { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', ['2'], '4'])
})
it('calls onIndexChange for subsequent nodes', () => {
const editor = createEditorWithShape(['a', 'b', 'c', 'd'])
Transforms.removeNodes(editor, { at: [1] })
const onIndexChange = jest.fn()
reconcileEditor(editor, { onIndexChange })
expect(onIndexChange.mock.calls).toEqual([
[editor.children[1], 1],
[editor.children[2], 2],
])
})
it('updates the index of subsequent leaves', () => {
const editor = createEditorWithShape(['a', 'b', 'c', 'd'])
Transforms.removeNodes(editor, { at: [1] })
const chunkTree = reconcileEditor(editor)
const subsequentLeaves = chunkTree.children.slice(1)
expect(subsequentLeaves).toMatchObject([{ index: 1 }, { index: 2 }])
})
})
describe('removing and inserting nodes', () => {
it('removes and inserts a node from the start', () => {
const editor = createEditorWithShape(['0', [['1']], '2'])
Transforms.removeNodes(editor, { at: [0] })
Transforms.insertNodes(editor, block('x'), { at: [0] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual([[['x', '1']], '2'])
})
it('removes and inserts a node from the middle', () => {
const editor = createEditorWithShape(['0', [['1']], '2'])
Transforms.removeNodes(editor, { at: [1] })
Transforms.insertNodes(editor, block('x'), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '2'])
})
it('removes and inserts a node from the end', () => {
const editor = createEditorWithShape(['0', [['1']], '2'])
Transforms.removeNodes(editor, { at: [2] })
Transforms.insertNodes(editor, block('x'), { at: [2] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', [['1', 'x']]])
})
it('removes 2 nodes and inserts 1 node', () => {
const editor = createEditorWithShape(['0', ['1', '2'], '2'])
Transforms.removeNodes(editor, { at: [2] })
Transforms.removeNodes(editor, { at: [1] })
Transforms.insertNodes(editor, block('x'), { at: [1] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', 'x', '2'])
})
it('removes 1 nodes and inserts 2 node', () => {
const editor = createEditorWithShape(['0', ['1'], '2'])
Transforms.removeNodes(editor, { at: [1] })
Transforms.insertNodes(editor, block('x'), { at: [1] })
Transforms.insertNodes(editor, block('y'), { at: [2] })
const chunkTree = reconcileEditor(editor)
expect(getTreeShape(chunkTree)).toEqual(['0', ['x', 'y'], '2'])
})
it('calls onIndexChange for nodes until insertions equal removals', () => {
const editor = createEditorWithShape([
'a',
// Insert 2 here
'b',
'c',
'd', // Remove
'e',
'f',
'g', // Remove
'h',
])
Transforms.removeNodes(editor, { at: [6] })
Transforms.removeNodes(editor, { at: [3] })
Transforms.insertNodes(editor, blocks(2), { at: [1] })
const onIndexChange = jest.fn()
reconcileEditor(editor, { onIndexChange })
expect(onIndexChange.mock.calls).toEqual([
[editor.children[3], 3],
[editor.children[4], 4],
[editor.children[5], 5],
[editor.children[6], 6],
])
})
})
describe('updating nodes', () => {
it('replaces updated Slate nodes in the chunk tree', () => {
const editor = createEditorWithShape(['0', ['1'], '2'])
Transforms.setNodes(editor, { updated: true } as any, { at: [1] })
const chunkTree = reconcileEditor(editor)
const chunk = chunkTree.children[1] as Chunk
const leaf = chunk.children[0] as ChunkLeaf
expect(leaf.node).toMatchObject({ updated: true })
})
it('invalidates ancestor chunks of updated Slate nodes', () => {
const editor = createEditorWithShape(['0', [['1']], '2'])
Transforms.insertText(editor, 'x', { at: [1, 0] })
const chunkTree = reconcileEditor(editor)
const outerChunk = chunkTree.children[1] as Chunk
const innerChunk = outerChunk.children[0]
expect(getTreeShape(chunkTree)).toEqual(['0', [['x']], '2'])
expect(chunkTree.modifiedChunks).toEqual(
new Set([outerChunk, innerChunk])
)
})
it('calls onUpdate for updated Slate nodes', () => {
const editor = createEditorWithShape(['0', '1', '2', '3'])
Transforms.setNodes(editor, { updated: true } as any, { at: [1] })
Transforms.setNodes(editor, { updated: true } as any, { at: [2] })
const onUpdate = jest.fn()
reconcileEditor(editor, { onUpdate })
expect(onUpdate.mock.calls).toEqual([
[editor.children[1], 1],
[editor.children[2], 2],
])
})
})
describe('moving nodes', () => {
it('moves a node down', () => {
const editor = createEditorWithShape([['0'], ['1'], ['2'], ['3'], ['4']])
// Move 1 to after 3
Transforms.moveNodes(editor, { at: [1], to: [3] })
const onInsert = jest.fn()
const onIndexChange = jest.fn()
const chunkTree = reconcileEditor(editor, { onInsert, onIndexChange })
expect(getTreeShape(chunkTree)).toEqual([['0'], ['2'], ['3', '1'], ['4']])
expect(onInsert.mock.calls).toEqual([[editor.children[3], 3]])
expect(onIndexChange.mock.calls).toEqual([
[editor.children[1], 1],
[editor.children[2], 2],
])
expect(chunkTree.movedNodeKeys.size).toBe(0)
})
it('moves a node up', () => {
const editor = createEditorWithShape([['0'], ['1'], ['2'], ['3'], ['4']])
// Move 3 to after 0
Transforms.moveNodes(editor, { at: [3], to: [1] })
const onInsert = jest.fn()
const onIndexChange = jest.fn()
const chunkTree = reconcileEditor(editor, { onInsert, onIndexChange })
expect(getTreeShape(chunkTree)).toEqual([['0', '3'], ['1'], ['2'], ['4']])
expect(onInsert.mock.calls).toEqual([[editor.children[1], 1]])
expect(onIndexChange.mock.calls).toEqual([
[editor.children[2], 2],
[editor.children[3], 3],
])
expect(chunkTree.movedNodeKeys.size).toBe(0)
})
})
describe('manual rerendering', () => {
it('invalidates specific child indices', () => {
const editor = createEditorWithShape([
['0'],
['1', ['2'], '3'],
['4'],
'5',
])
reconcileEditor(editor)
const chunkTree = reconcileEditor(editor, { rerenderChildren: [2, 4] })
const twoOuterChunk = chunkTree.children[1] as Chunk
const twoInnerChunk = twoOuterChunk.children[1]
const fourChunk = chunkTree.children[2]
expect(chunkTree.modifiedChunks).toEqual(
new Set([twoOuterChunk, twoInnerChunk, fourChunk])
)
})
})
describe('random testing', () => {
it('remains correct after random operations', () => {
// Hard code a value here to reproduce a test failure
const seed = Math.floor(10000000 * Math.random())
const random = createPRNG(seed)
const duration = 250
const startTime = performance.now()
const endTime = startTime + duration
let iteration = 0
try {
while (performance.now() < endTime) {
iteration++
const editor = withChunking(withReact(createEditor()))
const randomPosition = (includeEnd: boolean) =>
Math.floor(
random() * (editor.children.length + (includeEnd ? 1 : 0))
)
for (let i = 0; i < 30; i++) {
const randomValue = random()
if (randomValue < 0.33) {
reconcileEditor(editor)
} else if (randomValue < 0.66) {
Transforms.insertNodes(editor, block(i.toString()), {
at: [randomPosition(true)],
})
} else if (randomValue < 0.8) {
if (editor.children.length > 0) {
Transforms.removeNodes(editor, { at: [randomPosition(false)] })
}
} else {
if (editor.children.length > 0) {
Transforms.setNodes(editor, { updated: i } as any, {
at: [randomPosition(false)],
})
}
}
}
const chunkTree = reconcileEditor(editor)
const chunkTreeSlateNodes: Descendant[] = []
const flattenTree = (node: ChunkNode) => {
if (node.type === 'leaf') {
chunkTreeSlateNodes.push(node.node)
} else {
node.children.forEach(flattenTree)
}
}
flattenTree(chunkTree)
expect(chunkTreeSlateNodes).toEqual(editor.children)
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`Random testing encountered an error or test failure on iteration ${iteration}. To reproduce this failure reliably, use the random seed: ${seed}`
)
throw e
}
})
})
})

View File

@@ -0,0 +1,672 @@
import React from 'react'
import {
DecoratedRange,
Node,
NodeEntry,
Path,
createEditor as slateCreateEditor,
Editor,
Text,
Transforms,
} from 'slate'
import { act, render } from '@testing-library/react'
import {
Slate,
withReact,
Editable,
RenderLeafProps,
ReactEditor,
} from '../src'
const renderLeaf = ({ leaf, attributes, children }: RenderLeafProps) => {
const decorations = Object.keys(Node.extractProps(leaf)).sort()
return (
<span {...attributes} data-decorations={JSON.stringify(decorations)}>
{children}
</span>
)
}
interface DecorateConfig {
path: Path
decorations: (node: Node) => (DecoratedRange & Record<string, unknown>)[]
}
const decoratePaths =
(editor: ReactEditor, configs: DecorateConfig[]) =>
([node, path]: NodeEntry): DecoratedRange[] => {
// Validate that decorate was called with a node matching the path
if (Node.get(editor, path) !== node) {
throw new Error('decorate was called with an incorrect node entry')
}
const matchingConfig = configs.find(({ path: p }) => Path.equals(path, p))
if (!matchingConfig) return []
return matchingConfig.decorations(node)
}
const getDecoratedLeaves = (
editor: ReactEditor,
path: Path
): { text: string; decorations: string[] }[] => {
const text = ReactEditor.toDOMNode(editor, Node.leaf(editor, path))
const leaves = Array.from(text.children) as HTMLElement[]
return leaves.map(leaf => ({
text: leaf.textContent!,
decorations: JSON.parse(leaf.dataset.decorations!),
}))
}
// Pad children arrays with additional nodes to test whether decorations work
// correctly on chunked children
const otherNodes = () =>
Array.from({ length: 7 }, () => ({ children: [{ text: '' }] }))
describe('decorations', () => {
const withChunking = (chunking: boolean) => {
const createEditor = () => {
const editor = withReact(slateCreateEditor())
if (chunking) {
editor.getChunkSize = () => 2
}
return editor
}
describe('decorating initial value', () => {
it('decorates part of a single text node', () => {
const editor = createEditor()
const initialValue = [
{ children: [{ text: 'Hello world!' }] },
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0, 0],
decorations: () => [
{
anchor: { path: [0, 0], offset: 6 },
focus: { path: [0, 0], offset: 11 },
bold: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'Hello ', decorations: [] },
{ text: 'world', decorations: ['bold'] },
{ text: '!', decorations: [] },
])
})
it('decorates an entire text node', () => {
const editor = createEditor()
const initialValue = [
{
children: [{ text: 'before' }, { text: 'bold' }, { text: 'after' }],
},
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0, 1],
decorations: () => [
{
...Editor.range(editor, [0, 1]),
bold: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'before', decorations: [] },
])
expect(getDecoratedLeaves(editor, [0, 1])).toEqual([
{ text: 'bold', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [0, 2])).toEqual([
{ text: 'after', decorations: [] },
])
})
it('applies multiple overlapping decorations in a single text node', () => {
const editor = createEditor()
const initialValue = [
{ children: [{ text: 'Hello world!' }] },
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0, 0],
decorations: () => [
{
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 11 },
bold: true,
},
{
anchor: { path: [0, 0], offset: 6 },
focus: { path: [0, 0], offset: 12 },
italic: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'Hello ', decorations: ['bold'] },
{ text: 'world', decorations: ['bold', 'italic'] },
{ text: '!', decorations: ['italic'] },
])
})
it('passes down decorations from the parent element', () => {
const editor = createEditor()
const initialValue = [
{
children: [
{ text: 'before' },
{ text: 'middle' },
{ text: 'after' },
],
},
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0],
decorations: () => [
{
anchor: { path: [0, 0], offset: 2 },
focus: { path: [0, 2], offset: 2 },
bold: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'be', decorations: [] },
{ text: 'fore', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [0, 1])).toEqual([
{ text: 'middle', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [0, 2])).toEqual([
{ text: 'af', decorations: ['bold'] },
{ text: 'ter', decorations: [] },
])
})
it('passes decorations down from the editor', () => {
const editor = createEditor()
const initialValue = [
{
children: [{ text: '0.0' }, { text: '0.1' }, { text: '0.2' }],
},
{
children: [{ text: '1.0' }],
},
{
children: [{ text: '2.0' }],
},
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [],
decorations: () => [
{
anchor: { path: [0, 1], offset: 0 },
focus: { path: [1, 0], offset: 3 },
bold: true,
},
],
},
{
path: [0],
decorations: () => [
{
...Editor.range(editor, [0, 2]),
italic: true,
},
],
},
{
path: [1, 0],
decorations: () => [
{
...Editor.range(editor, [1, 0]),
underline: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: '0.0', decorations: [] },
])
expect(getDecoratedLeaves(editor, [0, 1])).toEqual([
{ text: '0.1', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [0, 2])).toEqual([
{ text: '0.2', decorations: ['bold', 'italic'] },
])
expect(getDecoratedLeaves(editor, [1, 0])).toEqual([
{ text: '1.0', decorations: ['bold', 'underline'] },
])
expect(getDecoratedLeaves(editor, [2, 0])).toEqual([
{ text: '2.0', decorations: [] },
])
})
})
describe('redecorating', () => {
it('redecorates all nodes when the decorate function changes', () => {
const editor = createEditor()
const initialValue = [
{
children: [{ text: '0.0' }, { text: '0.1' }, { text: '0.2' }],
},
{
children: [{ text: '1.0' }, { text: '1.1' }, { text: '1.2' }],
},
...otherNodes(),
]
const decorate1 = decoratePaths(editor, [
{
path: [],
decorations: () => [
{
...Editor.range(editor, [0, 0]),
bold: true,
},
{
...Editor.range(editor, [0, 1]),
italic: true,
},
],
},
{
path: [1, 0],
decorations: () => [
{
...Editor.range(editor, [1, 0]),
bold: true,
},
],
},
{
path: [1, 1],
decorations: () => [
{
...Editor.range(editor, [1, 1]),
italic: true,
},
],
},
])
const decorate2 = decoratePaths(editor, [
{
path: [0],
decorations: () => [
{
...Editor.range(editor, [0, 1]),
underline: true,
},
{
...Editor.range(editor, [0, 2]),
bold: true,
},
],
},
{
path: [1, 1],
decorations: () => [
{
...Editor.range(editor, [1, 1]),
underline: true,
},
],
},
{
path: [1, 2],
decorations: () => [
{
...Editor.range(editor, [1, 2]),
bold: true,
},
],
},
])
const { rerender } = render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate1} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: '0.0', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [0, 1])).toEqual([
{ text: '0.1', decorations: ['italic'] },
])
expect(getDecoratedLeaves(editor, [0, 2])).toEqual([
{ text: '0.2', decorations: [] },
])
expect(getDecoratedLeaves(editor, [1, 0])).toEqual([
{ text: '1.0', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [1, 1])).toEqual([
{ text: '1.1', decorations: ['italic'] },
])
expect(getDecoratedLeaves(editor, [1, 2])).toEqual([
{ text: '1.2', decorations: [] },
])
rerender(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate2} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: '0.0', decorations: [] },
])
expect(getDecoratedLeaves(editor, [0, 1])).toEqual([
{ text: '0.1', decorations: ['underline'] },
])
expect(getDecoratedLeaves(editor, [0, 2])).toEqual([
{ text: '0.2', decorations: ['bold'] },
])
expect(getDecoratedLeaves(editor, [1, 0])).toEqual([
{ text: '1.0', decorations: [] },
])
expect(getDecoratedLeaves(editor, [1, 1])).toEqual([
{ text: '1.1', decorations: ['underline'] },
])
expect(getDecoratedLeaves(editor, [1, 2])).toEqual([
{ text: '1.2', decorations: ['bold'] },
])
})
it('redecorates undecorated nodes when they change', async () => {
const editor = createEditor()
const initialValue = [
{ children: [{ text: 'The quick brown fox' }] },
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0, 0],
decorations: node =>
Text.isText(node) && node.text.includes('box')
? [
{
...Editor.range(editor, [0, 0]),
bold: true,
},
]
: [],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'The quick brown fox', decorations: [] },
])
await act(async () => {
Transforms.insertText(editor, 'b', {
at: {
anchor: { path: [0, 0], offset: 16 },
focus: { path: [0, 0], offset: 17 },
},
})
})
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'The quick brown box', decorations: ['bold'] },
])
})
it('redecorates decorated nodes when they change', async () => {
const editor = createEditor()
const initialValue = [
{ children: [{ text: 'The quick brown box' }] },
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [0, 0],
decorations: node =>
Text.isText(node) && node.text.includes('box')
? [
{
...Editor.range(editor, [0, 0]),
bold: true,
},
]
: [],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'The quick brown box', decorations: ['bold'] },
])
await act(async () => {
Transforms.insertText(editor, 'f', {
at: {
anchor: { path: [0, 0], offset: 16 },
focus: { path: [0, 0], offset: 17 },
},
})
})
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'The quick brown fox', decorations: [] },
])
})
it('passes down new decorations from changed ancestors', async () => {
const editor = createEditor()
const initialValue = [
{
children: [
{ children: [{ text: 'Hello world!' }] },
...otherNodes(),
],
},
]
const decorate = decoratePaths(editor, [
{
path: [0],
decorations: node =>
'bold' in node
? [
{
...Editor.range(editor, [0, 0, 0]),
bold: true,
},
]
: [],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0, 0])).toEqual([
{ text: 'Hello world!', decorations: [] },
])
await act(async () => {
Transforms.setNodes(editor, { bold: true } as any, {
at: [0],
})
})
expect(getDecoratedLeaves(editor, [0, 0, 0])).toEqual([
{ text: 'Hello world!', decorations: ['bold'] },
])
})
it('does not redecorate unchanged nodes when their paths change', async () => {
const editor = createEditor()
const initialValue = [
{ children: [{ text: 'A' }] },
{ children: [{ text: 'B' }] },
...otherNodes(),
]
const decorate = decoratePaths(editor, [
{
path: [1, 0],
decorations: () => [
{
...Editor.range(editor, [1, 0]),
bold: true,
},
],
},
])
render(
<Slate editor={editor} initialValue={initialValue}>
<Editable decorate={decorate} renderLeaf={renderLeaf} />
</Slate>
)
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: 'A', decorations: [] },
])
expect(getDecoratedLeaves(editor, [1, 0])).toEqual([
{ text: 'B', decorations: ['bold'] },
])
await act(async () => {
Transforms.insertNodes(
editor,
{ children: [{ text: '0' }] },
{
at: [0],
}
)
})
expect(getDecoratedLeaves(editor, [0, 0])).toEqual([
{ text: '0', decorations: [] },
])
// A does not become bold even though it now matches the decoration
expect(getDecoratedLeaves(editor, [1, 0])).toEqual([
{ text: 'A', decorations: [] },
])
// B remains bold even though it no longer matches the decoration
expect(getDecoratedLeaves(editor, [2, 0])).toEqual([
{ text: 'B', decorations: ['bold'] },
])
})
})
}
describe('without chunking', () => {
withChunking(false)
})
describe('with chunking', () => {
withChunking(true)
})
})

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'
import { createEditor, Text, Transforms } from 'slate'
import React from 'react'
import { createEditor, Transforms } from 'slate'
import { act, render } from '@testing-library/react'
import { Slate, withReact, Editable, ReactEditor } from '../src'

View File

@@ -1,4 +1,7 @@
{
"extends": "../../../config/typescript/tsconfig.json",
"compilerOptions": {
"types": ["@testing-library/jest-dom"]
},
"references": [{ "path": "../" }]
}

View File

@@ -0,0 +1,183 @@
import React from 'react'
import { createEditor, Transforms } from 'slate'
import { render, act } from '@testing-library/react'
import {
Slate,
withReact,
Editable,
useSelected,
RenderElementProps,
ReactEditor,
} from '../src'
let editor: ReactEditor
let elementSelectedRenders: Record<string, boolean[] | undefined>
const clearRenders = () =>
Object.values(elementSelectedRenders).forEach(selectedRenders => {
if (selectedRenders) {
selectedRenders.length = 0
}
})
const initialValue = () => [
{
id: '0',
children: [
{ id: '0.0', children: [{ text: '' }] },
{ id: '0.1', children: [{ text: '' }] },
{ id: '0.2', children: [{ text: '' }] },
],
},
{ id: '1', children: [{ text: '' }] },
{ id: '2', children: [{ text: '' }] },
]
describe('useSelected', () => {
const withChunking = (chunking: boolean) => {
beforeEach(() => {
editor = withReact(createEditor())
if (chunking) {
editor.getChunkSize = () => 3
}
elementSelectedRenders = {}
const renderElement = ({
element,
attributes,
children,
}: RenderElementProps) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const selected = useSelected()
const { id } = element as any
let selectedRenders = elementSelectedRenders[id]
if (!selectedRenders) {
selectedRenders = []
elementSelectedRenders[id] = selectedRenders
}
selectedRenders.push(selected)
return <div {...attributes}>{children}</div>
}
render(
<Slate editor={editor} initialValue={initialValue()}>
<Editable renderElement={renderElement} />
</Slate>
)
})
it('returns false initially', () => {
expect(elementSelectedRenders).toEqual({
'0': [false],
'0.0': [false],
'0.1': [false],
'0.2': [false],
'1': [false],
'2': [false],
})
})
it('re-renders elements when it becomes true or false', async () => {
clearRenders()
await act(async () => {
Transforms.select(editor, [0, 0])
})
expect(elementSelectedRenders).toEqual({
'0': [true],
'0.0': [true],
'0.1': [],
'0.2': [],
'1': [],
'2': [],
})
clearRenders()
await act(async () => {
Transforms.select(editor, [2])
})
expect(elementSelectedRenders).toEqual({
'0': [false],
'0.0': [false],
'0.1': [],
'0.2': [],
'1': [],
'2': [true],
})
})
it('returns true for elements in the middle of the selection', async () => {
clearRenders()
await act(async () => {
Transforms.select(editor, {
anchor: { path: [2, 0], offset: 0 },
focus: { path: [0, 1, 0], offset: 0 },
})
})
expect(elementSelectedRenders).toEqual({
'0': [true],
'0.0': [],
'0.1': [true],
'0.2': [true],
'1': [true],
'2': [true],
})
})
it('remains true when the path changes', async () => {
clearRenders()
await act(async () => {
Transforms.select(editor, { path: [2, 0], offset: 0 })
})
expect(elementSelectedRenders).toEqual({
'0': [],
'0.0': [],
'0.1': [],
'0.2': [],
'1': [],
'2': [true],
})
clearRenders()
await act(async () => {
Transforms.insertNodes(
editor,
{ id: 'new', children: [{ text: '' }] } as any,
{ at: [2] }
)
})
expect(elementSelectedRenders).toEqual({
'0': [],
'0.0': [],
'0.1': [],
'0.2': [],
'1': [],
new: [false],
'2': [], // Remains true, no rerender
})
})
}
describe('without chunking', () => {
withChunking(false)
})
describe('with chunking', () => {
withChunking(true)
})
})

View File

@@ -1,14 +1,7 @@
/* eslint-disable no-console */
import React, { useEffect } from 'react'
import { createEditor, Editor, Text, Transforms } from 'slate'
import { act, render, renderHook } from '@testing-library/react'
import {
Slate,
withReact,
Editable,
ReactEditor,
useSlateSelector,
} from '../src'
import React from 'react'
import { createEditor, Transforms } from 'slate'
import { act, renderHook } from '@testing-library/react'
import { Slate, withReact, Editable, useSlateSelector } from '../src'
import _ from 'lodash'
describe('useSlateSelector', () => {

View File

@@ -0,0 +1,59 @@
/* eslint-disable import/no-deprecated */
import React from 'react'
import { Transforms, createEditor } from 'slate'
import { render, act } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Slate, withReact, Editable, useSlateWithV } from '../src'
describe('useSlateWithV', () => {
const ShowVersion = () => {
const { v } = useSlateWithV()
return <>V = {v}</>
}
it('tracks a global `v` counter for the editor', async () => {
const editor = withReact(createEditor())
const initialValue = [{ type: 'block', children: [{ text: 'test' }] }]
const { getByText, rerender } = render(
<Slate editor={editor} initialValue={initialValue}>
<Editable />
<p>
First: <ShowVersion key={1} />
</p>
<p>
Second: <ShowVersion key={2} />
</p>
</Slate>
)
expect(getByText('First: V = 0')).toBeInTheDocument()
expect(getByText('Second: V = 0')).toBeInTheDocument()
await act(async () => {
Transforms.insertText(editor, '!', { at: { path: [0, 0], offset: 4 } })
})
expect(getByText('First: V = 1')).toBeInTheDocument()
expect(getByText('Second: V = 1')).toBeInTheDocument()
rerender(
<Slate editor={editor} initialValue={initialValue}>
<Editable />
<p>
First: <ShowVersion key={1} />
</p>
<p>
Second: <ShowVersion key={2} />
</p>
<p>
Third: <ShowVersion key={3} />
</p>
</Slate>
)
expect(getByText('First: V = 1')).toBeInTheDocument()
expect(getByText('Second: V = 1')).toBeInTheDocument()
expect(getByText('Third: V = 1')).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,3 @@
import { produce } from 'immer'
import { ExtendedType, Operation, Path, isObject } from '..'
import { TextDirection } from '../types/types'
@@ -99,81 +98,85 @@ export const Point: PointInterface = {
op: Operation,
options: PointTransformOptions = {}
): Point | null {
return produce(point, p => {
if (p === null) {
return null
if (point === null) {
return null
}
const { affinity = 'forward' } = options
let { path, offset } = point
switch (op.type) {
case 'insert_node':
case 'move_node': {
path = Path.transform(path, op, options)!
break
}
const { affinity = 'forward' } = options
const { path, offset } = p
switch (op.type) {
case 'insert_node':
case 'move_node': {
p.path = Path.transform(path, op, options)!
break
case 'insert_text': {
if (
Path.equals(op.path, path) &&
(op.offset < offset ||
(op.offset === offset && affinity === 'forward'))
) {
offset += op.text.length
}
case 'insert_text': {
if (
Path.equals(op.path, path) &&
(op.offset < offset ||
(op.offset === offset && affinity === 'forward'))
) {
p.offset += op.text.length
}
break
}
break
case 'merge_node': {
if (Path.equals(op.path, path)) {
offset += op.position
}
case 'merge_node': {
if (Path.equals(op.path, path)) {
p.offset += op.position
}
path = Path.transform(path, op, options)!
break
}
p.path = Path.transform(path, op, options)!
break
case 'remove_text': {
if (Path.equals(op.path, path) && op.offset <= offset) {
offset -= Math.min(offset - op.offset, op.text.length)
}
case 'remove_text': {
if (Path.equals(op.path, path) && op.offset <= offset) {
p.offset -= Math.min(offset - op.offset, op.text.length)
}
break
}
break
case 'remove_node': {
if (Path.equals(op.path, path) || Path.isAncestor(op.path, path)) {
return null
}
case 'remove_node': {
if (Path.equals(op.path, path) || Path.isAncestor(op.path, path)) {
path = Path.transform(path, op, options)!
break
}
case 'split_node': {
if (Path.equals(op.path, path)) {
if (op.position === offset && affinity == null) {
return null
}
} else if (
op.position < offset ||
(op.position === offset && affinity === 'forward')
) {
offset -= op.position
p.path = Path.transform(path, op, options)!
break
path = Path.transform(path, op, {
...options,
affinity: 'forward',
})!
}
} else {
path = Path.transform(path, op, options)!
}
case 'split_node': {
if (Path.equals(op.path, path)) {
if (op.position === offset && affinity == null) {
return null
} else if (
op.position < offset ||
(op.position === offset && affinity === 'forward')
) {
p.offset -= op.position
p.path = Path.transform(path, op, {
...options,
affinity: 'forward',
})!
}
} else {
p.path = Path.transform(path, op, options)!
}
break
}
break
}
})
default:
return point
}
return { path, offset }
},
}

View File

@@ -1,4 +1,3 @@
import { produce } from 'immer'
import { ExtendedType, Operation, Path, Point, PointEntry, isObject } from '..'
import { RangeDirection } from '../types/types'
@@ -220,47 +219,47 @@ export const Range: RangeInterface = {
op: Operation,
options: RangeTransformOptions = {}
): Range | null {
return produce(range, r => {
if (r === null) {
return null
}
const { affinity = 'inward' } = options
let affinityAnchor: 'forward' | 'backward' | null
let affinityFocus: 'forward' | 'backward' | null
if (range === null) {
return null
}
if (affinity === 'inward') {
// If the range is collapsed, make sure to use the same affinity to
// avoid the two points passing each other and expanding in the opposite
// direction
const isCollapsed = Range.isCollapsed(r)
if (Range.isForward(r)) {
affinityAnchor = 'forward'
affinityFocus = isCollapsed ? affinityAnchor : 'backward'
} else {
affinityAnchor = 'backward'
affinityFocus = isCollapsed ? affinityAnchor : 'forward'
}
} else if (affinity === 'outward') {
if (Range.isForward(r)) {
affinityAnchor = 'backward'
affinityFocus = 'forward'
} else {
affinityAnchor = 'forward'
affinityFocus = 'backward'
}
const { affinity = 'inward' } = options
let affinityAnchor: 'forward' | 'backward' | null
let affinityFocus: 'forward' | 'backward' | null
if (affinity === 'inward') {
// If the range is collapsed, make sure to use the same affinity to
// avoid the two points passing each other and expanding in the opposite
// direction
const isCollapsed = Range.isCollapsed(range)
if (Range.isForward(range)) {
affinityAnchor = 'forward'
affinityFocus = isCollapsed ? affinityAnchor : 'backward'
} else {
affinityAnchor = affinity
affinityFocus = affinity
affinityAnchor = 'backward'
affinityFocus = isCollapsed ? affinityAnchor : 'forward'
}
const anchor = Point.transform(r.anchor, op, { affinity: affinityAnchor })
const focus = Point.transform(r.focus, op, { affinity: affinityFocus })
if (!anchor || !focus) {
return null
} else if (affinity === 'outward') {
if (Range.isForward(range)) {
affinityAnchor = 'backward'
affinityFocus = 'forward'
} else {
affinityAnchor = 'forward'
affinityFocus = 'backward'
}
r.anchor = anchor
r.focus = focus
} else {
affinityAnchor = affinity
affinityFocus = affinity
}
const anchor = Point.transform(range.anchor, op, {
affinity: affinityAnchor,
})
const focus = Point.transform(range.focus, op, { affinity: affinityFocus })
if (!anchor || !focus) {
return null
}
return { anchor, focus }
},
}

View File

@@ -1,4 +1,3 @@
import { createDraft, finishDraft, isDraft } from 'immer'
import {
Ancestor,
Descendant,
@@ -22,224 +21,320 @@ export interface GeneralTransforms {
transform: (editor: Editor, op: Operation) => void
}
const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
switch (op.type) {
case 'insert_node': {
const { path, node } = op
const parent = Node.parent(editor, path)
const index = path[path.length - 1]
const insertChildren = <T>(xs: T[], index: number, ...newValues: T[]) => [
...xs.slice(0, index),
...newValues,
...xs.slice(index),
]
if (index > parent.children.length) {
throw new Error(
`Cannot apply an "insert_node" operation at path [${path}] because the destination is past the end of the node.`
)
}
const replaceChildren = <T>(
xs: T[],
index: number,
removeCount: number,
...newValues: T[]
) => [...xs.slice(0, index), ...newValues, ...xs.slice(index + removeCount)]
parent.children.splice(index, 0, node)
const removeChildren = replaceChildren
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
/**
* Replace a descendant with a new node, replacing all ancestors
*/
const modifyDescendant = <N extends Descendant>(
editor: Editor,
path: Path,
f: (node: N) => N
) => {
if (path.length === 0) {
throw new Error('Cannot modify the editor')
}
break
const node = Node.get(editor, path) as N
const slicedPath = path.slice()
let modifiedNode: Node = f(node)
while (slicedPath.length > 1) {
const index = slicedPath.pop()!
const ancestorNode = Node.get(editor, slicedPath) as Ancestor
modifiedNode = {
...ancestorNode,
children: replaceChildren(ancestorNode.children, index, 1, modifiedNode),
}
}
case 'insert_text': {
const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after
const index = slicedPath.pop()!
editor.children = replaceChildren(editor.children, index, 1, modifiedNode)
}
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
}
case 'merge_node': {
const { path } = op
const node = Node.get(editor, path)
const prevPath = Path.previous(path)
const prev = Node.get(editor, prevPath)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]
if (Text.isText(node) && Text.isText(prev)) {
prev.text += node.text
} else if (!Text.isText(node) && !Text.isText(prev)) {
prev.children.push(...node.children)
} else {
/**
* Replace the children of a node, replacing all ancestors
*/
const modifyChildren = (
editor: Editor,
path: Path,
f: (children: Descendant[]) => Descendant[]
) => {
if (path.length === 0) {
editor.children = f(editor.children)
} else {
modifyDescendant<Element>(editor, path, node => {
if (Text.isText(node)) {
throw new Error(
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify(
`Cannot get the element at path [${path}] because it refers to a leaf node: ${Scrubber.stringify(
node
)} ${Scrubber.stringify(prev)}`
)}`
)
}
parent.children.splice(index, 1)
return { ...node, children: f(node.children) }
})
}
}
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
/**
* Replace a leaf, replacing all ancestors
*/
const modifyLeaf = (editor: Editor, path: Path, f: (leaf: Text) => Text) =>
modifyDescendant(editor, path, node => {
if (!Text.isText(node)) {
throw new Error(
`Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${Scrubber.stringify(
node
)}`
)
}
case 'move_node': {
const { path, newPath } = op
return f(node)
})
if (Path.isAncestor(path, newPath)) {
throw new Error(
`Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.`
)
// eslint-disable-next-line no-redeclare
export const GeneralTransforms: GeneralTransforms = {
transform(editor: Editor, op: Operation): void {
let transformSelection = false
switch (op.type) {
case 'insert_node': {
const { path, node } = op
modifyChildren(editor, Path.parent(path), children => {
const index = path[path.length - 1]
if (index > children.length) {
throw new Error(
`Cannot apply an "insert_node" operation at path [${path}] because the destination is past the end of the node.`
)
}
return insertChildren(children, index, node)
})
transformSelection = true
break
}
const node = Node.get(editor, path)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]
case 'insert_text': {
const { path, offset, text } = op
if (text.length === 0) break
// This is tricky, but since the `path` and `newPath` both refer to
// the same snapshot in time, there's a mismatch. After either
// removing the original position, the second step's path can be out
// of date. So instead of using the `op.newPath` directly, we
// transform `op.path` to ascertain what the `newPath` would be after
// the operation was applied.
parent.children.splice(index, 1)
const truePath = Path.transform(path, op)!
const newParent = Node.get(editor, Path.parent(truePath)) as Ancestor
const newIndex = truePath[truePath.length - 1]
modifyLeaf(editor, path, node => {
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
newParent.children.splice(newIndex, 0, node)
return {
...node,
text: before + text + after,
}
})
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
transformSelection = true
break
}
break
}
case 'merge_node': {
const { path } = op
const index = path[path.length - 1]
const prevPath = Path.previous(path)
const prevIndex = prevPath[prevPath.length - 1]
case 'remove_node': {
const { path } = op
const index = path[path.length - 1]
const parent = Node.parent(editor, path)
parent.children.splice(index, 1)
modifyChildren(editor, Path.parent(path), children => {
const node = children[index]
const prev = children[prevIndex]
let newNode: Descendant
// Transform all the points in the value, but if the point was in the
// node that was removed we need to update the range or remove it.
if (selection) {
for (const [point, key] of Range.points(selection)) {
const result = Point.transform(point, op)
if (selection != null && result != null) {
selection[key] = result
if (Text.isText(node) && Text.isText(prev)) {
newNode = { ...prev, text: prev.text + node.text }
} else if (!Text.isText(node) && !Text.isText(prev)) {
newNode = { ...prev, children: prev.children.concat(node.children) }
} else {
let prev: NodeEntry<Text> | undefined
let next: NodeEntry<Text> | undefined
throw new Error(
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify(
node
)} ${Scrubber.stringify(prev)}`
)
}
for (const [n, p] of Node.texts(editor)) {
if (Path.compare(p, path) === -1) {
prev = [n, p]
} else {
next = [n, p]
break
}
}
return replaceChildren(children, prevIndex, 2, newNode)
})
let preferNext = false
if (prev && next) {
if (Path.equals(next[1], path)) {
preferNext = !Path.hasPrevious(next[1])
} else {
preferNext =
Path.common(prev[1], path).length <
Path.common(next[1], path).length
}
}
transformSelection = true
break
}
if (prev && !preferNext) {
point.path = prev[1]
point.offset = prev[0].text.length
} else if (next) {
point.path = next[1]
point.offset = 0
case 'move_node': {
const { path, newPath } = op
const index = path[path.length - 1]
if (Path.isAncestor(path, newPath)) {
throw new Error(
`Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.`
)
}
const node = Node.get(editor, path)
modifyChildren(editor, Path.parent(path), children =>
removeChildren(children, index, 1)
)
// This is tricky, but since the `path` and `newPath` both refer to
// the same snapshot in time, there's a mismatch. After either
// removing the original position, the second step's path can be out
// of date. So instead of using the `op.newPath` directly, we
// transform `op.path` to ascertain what the `newPath` would be after
// the operation was applied.
const truePath = Path.transform(path, op)!
const newIndex = truePath[truePath.length - 1]
modifyChildren(editor, Path.parent(truePath), children =>
insertChildren(children, newIndex, node)
)
transformSelection = true
break
}
case 'remove_node': {
const { path } = op
const index = path[path.length - 1]
modifyChildren(editor, Path.parent(path), children =>
removeChildren(children, index, 1)
)
// Transform all the points in the value, but if the point was in the
// node that was removed we need to update the range or remove it.
if (editor.selection) {
let selection: Selection = { ...editor.selection }
for (const [point, key] of Range.points(selection)) {
const result = Point.transform(point, op)
if (selection != null && result != null) {
selection[key] = result
} else {
selection = null
let prev: NodeEntry<Text> | undefined
let next: NodeEntry<Text> | undefined
for (const [n, p] of Node.texts(editor)) {
if (Path.compare(p, path) === -1) {
prev = [n, p]
} else {
next = [n, p]
break
}
}
let preferNext = false
if (prev && next) {
if (Path.equals(next[1], path)) {
preferNext = !Path.hasPrevious(next[1])
} else {
preferNext =
Path.common(prev[1], path).length <
Path.common(next[1], path).length
}
}
if (prev && !preferNext) {
selection![key] = { path: prev[1], offset: prev[0].text.length }
} else if (next) {
selection![key] = { path: next[1], offset: 0 }
} else {
selection = null
}
}
}
}
}
break
}
case 'remove_text': {
const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset + text.length)
node.text = before + after
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
}
case 'set_node': {
const { path, properties, newProperties } = op
if (path.length === 0) {
throw new Error(`Cannot set properties on the root node!`)
}
const node = Node.get(editor, path)
for (const key in newProperties) {
if (key === 'children' || key === 'text') {
throw new Error(`Cannot set the "${key}" property of nodes!`)
editor.selection = selection
}
const value = newProperties[<keyof Node>key]
if (value == null) {
delete node[<keyof Node>key]
} else {
node[<keyof Node>key] = value
}
break
}
// properties that were previously defined, but are now missing, must be deleted
for (const key in properties) {
if (!newProperties.hasOwnProperty(key)) {
delete node[<keyof Node>key]
}
case 'remove_text': {
const { path, offset, text } = op
if (text.length === 0) break
modifyLeaf(editor, path, node => {
const before = node.text.slice(0, offset)
const after = node.text.slice(offset + text.length)
return {
...node,
text: before + after,
}
})
transformSelection = true
break
}
break
}
case 'set_node': {
const { path, properties, newProperties } = op
case 'set_selection': {
const { newProperties } = op
if (path.length === 0) {
throw new Error(`Cannot set properties on the root node!`)
}
if (newProperties == null) {
selection = newProperties
} else {
if (selection == null) {
modifyDescendant(editor, path, node => {
const newNode = { ...node }
for (const key in newProperties) {
if (key === 'children' || key === 'text') {
throw new Error(`Cannot set the "${key}" property of nodes!`)
}
const value = newProperties[<keyof Node>key]
if (value == null) {
delete newNode[<keyof Node>key]
} else {
newNode[<keyof Node>key] = value
}
}
// properties that were previously defined, but are now missing, must be deleted
for (const key in properties) {
if (!newProperties.hasOwnProperty(key)) {
delete newNode[<keyof Node>key]
}
}
return newNode
})
break
}
case 'set_selection': {
const { newProperties } = op
if (newProperties == null) {
editor.selection = null
break
}
if (editor.selection == null) {
if (!Range.isRange(newProperties)) {
throw new Error(
`Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify(
@@ -248,9 +343,12 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
)
}
selection = { ...newProperties }
editor.selection = { ...newProperties }
break
}
const selection = { ...editor.selection }
for (const key in newProperties) {
const value = newProperties[<keyof Range>key]
@@ -264,76 +362,67 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
selection[<keyof Range>key] = value
}
}
editor.selection = selection
break
}
break
case 'split_node': {
const { path, position, properties } = op
const index = path[path.length - 1]
if (path.length === 0) {
throw new Error(
`Cannot apply a "split_node" operation at path [${path}] because the root node cannot be split.`
)
}
modifyChildren(editor, Path.parent(path), children => {
const node = children[index]
let newNode: Descendant
let nextNode: Descendant
if (Text.isText(node)) {
const before = node.text.slice(0, position)
const after = node.text.slice(position)
newNode = {
...node,
text: before,
}
nextNode = {
...(properties as Partial<Text>),
text: after,
}
} else {
const before = node.children.slice(0, position)
const after = node.children.slice(position)
newNode = {
...node,
children: before,
}
nextNode = {
...(properties as Partial<Element>),
children: after,
}
}
return replaceChildren(children, index, 1, newNode, nextNode)
})
transformSelection = true
break
}
}
case 'split_node': {
const { path, position, properties } = op
if (transformSelection && editor.selection) {
const selection = { ...editor.selection }
if (path.length === 0) {
throw new Error(
`Cannot apply a "split_node" operation at path [${path}] because the root node cannot be split.`
)
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
const node = Node.get(editor, path)
const parent = Node.parent(editor, path)
const index = path[path.length - 1]
let newNode: Descendant
if (Text.isText(node)) {
const before = node.text.slice(0, position)
const after = node.text.slice(position)
node.text = before
newNode = {
...(properties as Partial<Text>),
text: after,
}
} else {
const before = node.children.slice(0, position)
const after = node.children.slice(position)
node.children = before
newNode = {
...(properties as Partial<Element>),
children: after,
}
}
parent.children.splice(index + 1, 0, newNode)
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
}
}
return selection
}
// eslint-disable-next-line no-redeclare
export const GeneralTransforms: GeneralTransforms = {
transform(editor: Editor, op: Operation): void {
editor.children = createDraft(editor.children)
let selection = editor.selection && createDraft(editor.selection)
try {
selection = applyToDraft(editor, selection, op)
} finally {
editor.children = finishDraft(editor.children)
if (selection) {
editor.selection = isDraft(selection)
? (finishDraft(selection) as Range)
: selection
} else {
editor.selection = null
}
editor.selection = selection
}
},
}