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:
@@ -59,6 +59,7 @@ export { Key } from './utils/key'
|
||||
export {
|
||||
isElementDecorationsEqual,
|
||||
isTextDecorationsEqual,
|
||||
splitDecorationsByChild,
|
||||
} from './utils/range-list'
|
||||
|
||||
export {
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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",
|
||||
|
122
packages/slate-react/src/chunking/children-helper.ts
Normal file
122
packages/slate-react/src/chunking/children-helper.ts
Normal 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
|
||||
}
|
||||
}
|
574
packages/slate-react/src/chunking/chunk-tree-helper.ts
Normal file
574
packages/slate-react/src/chunking/chunk-tree-helper.ts
Normal 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(
|
||||
', '
|
||||
)}]`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
47
packages/slate-react/src/chunking/get-chunk-tree-for-node.ts
Normal file
47
packages/slate-react/src/chunking/get-chunk-tree-for-node.ts
Normal 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
|
||||
}
|
2
packages/slate-react/src/chunking/index.ts
Normal file
2
packages/slate-react/src/chunking/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './get-chunk-tree-for-node'
|
||||
export * from './types'
|
127
packages/slate-react/src/chunking/reconcile-children.ts
Normal file
127
packages/slate-react/src/chunking/reconcile-children.ts
Normal 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()
|
||||
}
|
52
packages/slate-react/src/chunking/types.ts
Normal file
52
packages/slate-react/src/chunking/types.ts
Normal 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
|
65
packages/slate-react/src/components/chunk-tree.tsx
Normal file
65
packages/slate-react/src/components/chunk-tree.tsx
Normal 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
|
@@ -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>
|
||||
|
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
@@ -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 = []
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
81
packages/slate-react/src/hooks/use-decorations.ts
Normal file
81
packages/slate-react/src/hooks/use-decorations.ts
Normal 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]
|
||||
)
|
||||
}
|
25
packages/slate-react/src/hooks/use-element.ts
Normal file
25
packages/slate-react/src/hooks/use-element.ts
Normal 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)
|
92
packages/slate-react/src/hooks/use-generic-selector.tsx
Normal file
92
packages/slate-react/src/hooks/use-generic-selector.tsx
Normal 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
|
||||
}
|
@@ -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,
|
||||
})
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 }
|
||||
}
|
||||
|
@@ -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'
|
||||
|
@@ -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 {}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
953
packages/slate-react/test/chunking.spec.ts
Normal file
953
packages/slate-react/test/chunking.spec.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
672
packages/slate-react/test/decorations.spec.tsx
Normal file
672
packages/slate-react/test/decorations.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
@@ -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'
|
||||
|
||||
|
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"extends": "../../../config/typescript/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@testing-library/jest-dom"]
|
||||
},
|
||||
"references": [{ "path": "../" }]
|
||||
}
|
||||
|
183
packages/slate-react/test/use-selected.spec.tsx
Normal file
183
packages/slate-react/test/use-selected.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
@@ -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', () => {
|
59
packages/slate-react/test/use-slate.spec.tsx
Normal file
59
packages/slate-react/test/use-slate.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
Reference in New Issue
Block a user