From af6790407945cb8630ebfab784453356c44b2c09 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 22 Apr 2026 08:17:56 +0200 Subject: [PATCH 01/49] v2 --- .gitignore | 2 +- bun.lock | 1 + .../src/chunking/children-helper.ts | 122 - .../src/chunking/chunk-tree-helper.ts | 574 -- .../src/chunking/get-chunk-tree-for-node.ts | 47 - packages/slate-react/src/chunking/index.ts | 2 - .../src/chunking/reconcile-children.ts | 130 - packages/slate-react/src/chunking/types.ts | 52 - .../slate-react/src/components/chunk-tree.tsx | 75 - .../slate-react/src/components/editable.tsx | 2 +- packages/slate/src/core/apply.ts | 84 +- packages/slate/src/core/index.ts | 1 + packages/slate/src/core/normalize-node.ts | 514 +- packages/slate/src/core/public-state.ts | 492 ++ packages/slate/src/core/should-normalize.ts | 16 +- packages/slate/src/create-editor.ts | 22 +- packages/slate/src/editor/above.ts | 2 +- packages/slate/src/editor/add-mark.ts | 18 +- packages/slate/src/editor/after.ts | 12 + packages/slate/src/editor/before.ts | 12 + packages/slate/src/editor/bookmark.ts | 100 + packages/slate/src/editor/fragment.ts | 6 + packages/slate/src/editor/index.ts | 2 + packages/slate/src/editor/insert-break.ts | 95 +- packages/slate/src/editor/insert-text.ts | 10 +- packages/slate/src/editor/levels.ts | 6 +- packages/slate/src/editor/marks.ts | 4 +- packages/slate/src/editor/next.ts | 2 +- packages/slate/src/editor/nodes.ts | 2 +- packages/slate/src/editor/normalize.ts | 204 +- packages/slate/src/editor/positions.ts | 398 +- packages/slate/src/editor/previous.ts | 2 +- packages/slate/src/editor/project-range.ts | 5 + packages/slate/src/editor/range-ref.ts | 141 +- packages/slate/src/editor/remove-mark.ts | 17 +- packages/slate/src/editor/unhang-range.ts | 8 + .../slate/src/editor/without-normalizing.ts | 6 +- packages/slate/src/index.ts | 1 + packages/slate/src/interfaces/bookmark.ts | 13 + packages/slate/src/interfaces/editor.ts | 162 +- packages/slate/src/interfaces/index.ts | 1 + packages/slate/src/interfaces/range-ref.ts | 18 +- .../src/interfaces/transforms/general.ts | 129 +- .../slate/src/interfaces/transforms/index.ts | 5 + .../slate/src/interfaces/transforms/node.ts | 1 + .../slate/src/interfaces/transforms/text.ts | 65 +- packages/slate/src/range-projection.ts | 373 ++ packages/slate/src/selection-operation.ts | 42 + packages/slate/src/text-units.ts | 1 + .../slate/src/transforms-node/insert-nodes.ts | 75 +- .../slate/src/transforms-node/lift-nodes.ts | 174 +- .../slate/src/transforms-node/merge-nodes.ts | 2 +- .../slate/src/transforms-node/move-nodes.ts | 66 +- .../slate/src/transforms-node/remove-nodes.ts | 2 +- .../slate/src/transforms-node/set-nodes.ts | 7 +- .../slate/src/transforms-node/split-nodes.ts | 56 +- .../slate/src/transforms-node/unwrap-nodes.ts | 260 +- .../slate/src/transforms-node/wrap-nodes.ts | 217 +- .../src/transforms-selection/collapse.ts | 3 +- .../src/transforms-selection/deselect.ts | 3 +- .../slate/src/transforms-selection/move.ts | 3 +- .../slate/src/transforms-selection/select.ts | 3 +- .../src/transforms-selection/set-point.ts | 3 +- .../src/transforms-selection/set-selection.ts | 36 +- .../slate/src/transforms-text/delete-text.ts | 1751 ++++- .../src/utils/get-default-insert-location.ts | 6 +- packages/slate/src/utils/modify.ts | 3 + packages/slate/src/utils/runtime-ids.ts | 100 + packages/slate/src/utils/weak-maps.ts | 1 + .../slate/test/accessor-transaction.test.ts | 321 + packages/slate/test/bookmark-contract.ts | 267 + packages/slate/test/clipboard-contract.ts | 172 + packages/slate/test/extension-contract.ts | 341 + packages/slate/test/headless-contract.ts | 115 + packages/slate/test/index.spec.ts | 60 +- packages/slate/test/interfaces-contract.ts | 85 + .../test/legacy-editor-nodes-fixtures.ts | 22 + packages/slate/test/legacy-fixture-utils.ts | 282 + .../slate/test/legacy-interfaces-fixtures.ts | 32 + .../slate/test/legacy-transforms-fixtures.ts | 39 + packages/slate/test/normalization-contract.ts | 377 ++ packages/slate/test/operations-contract.ts | 529 ++ packages/slate/test/query-contract.ts | 2805 ++++++++ packages/slate/test/range-ref-contract.ts | 169 + packages/slate/test/snapshot-contract.ts | 5793 +++++++++++++++++ packages/slate/test/surface-contract.ts | 374 ++ packages/slate/test/text-units-contract.ts | 38 + packages/slate/test/transaction-contract.ts | 277 + packages/slate/test/transforms-contract.ts | 323 + site/pages/examples/[example].tsx | 6 + 90 files changed, 17328 insertions(+), 1869 deletions(-) delete mode 100644 packages/slate-react/src/chunking/children-helper.ts delete mode 100644 packages/slate-react/src/chunking/chunk-tree-helper.ts delete mode 100644 packages/slate-react/src/chunking/get-chunk-tree-for-node.ts delete mode 100644 packages/slate-react/src/chunking/index.ts delete mode 100644 packages/slate-react/src/chunking/reconcile-children.ts delete mode 100644 packages/slate-react/src/chunking/types.ts delete mode 100644 packages/slate-react/src/components/chunk-tree.tsx create mode 100644 packages/slate/src/core/public-state.ts create mode 100644 packages/slate/src/editor/bookmark.ts create mode 100644 packages/slate/src/editor/project-range.ts create mode 100644 packages/slate/src/interfaces/bookmark.ts create mode 100644 packages/slate/src/range-projection.ts create mode 100644 packages/slate/src/selection-operation.ts create mode 100644 packages/slate/src/text-units.ts create mode 100644 packages/slate/src/utils/runtime-ids.ts create mode 100644 packages/slate/test/accessor-transaction.test.ts create mode 100644 packages/slate/test/bookmark-contract.ts create mode 100644 packages/slate/test/clipboard-contract.ts create mode 100644 packages/slate/test/extension-contract.ts create mode 100644 packages/slate/test/headless-contract.ts create mode 100644 packages/slate/test/interfaces-contract.ts create mode 100644 packages/slate/test/legacy-editor-nodes-fixtures.ts create mode 100644 packages/slate/test/legacy-fixture-utils.ts create mode 100644 packages/slate/test/legacy-interfaces-fixtures.ts create mode 100644 packages/slate/test/legacy-transforms-fixtures.ts create mode 100644 packages/slate/test/normalization-contract.ts create mode 100644 packages/slate/test/operations-contract.ts create mode 100644 packages/slate/test/query-contract.ts create mode 100644 packages/slate/test/range-ref-contract.ts create mode 100644 packages/slate/test/snapshot-contract.ts create mode 100644 packages/slate/test/surface-contract.ts create mode 100644 packages/slate/test/text-units-contract.ts create mode 100644 packages/slate/test/transaction-contract.ts create mode 100644 packages/slate/test/transforms-contract.ts diff --git a/.gitignore b/.gitignore index 8e01656f3d..81e4df2fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ dist/ lib/ node_modules/ site/out/ -tmp/ +*tmp/ test-results/ coverage .DS_Store diff --git a/bun.lock b/bun.lock index 22a0abf73e..f3bfeb16bc 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "devDependencies": { "@types/is-hotkey": "^0.1.8", "@types/node": "^20.8.7", + "jsdom": "20.0.3", "slate": "workspace:*", }, "peerDependencies": { diff --git a/packages/slate-react/src/chunking/children-helper.ts b/packages/slate-react/src/chunking/children-helper.ts deleted file mode 100644 index 4956daa856..0000000000 --- a/packages/slate-react/src/chunking/children-helper.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { Descendant, Editor } from 'slate' -import type { Key } from 'slate-dom' -import { ReactEditor } from '../plugin/react-editor' -import type { ChunkLeaf } from './types' - -/** - * Traverse an array of children, providing helpers useful for reconciling the - * children array with a chunk tree - */ -export class ChildrenHelper { - private readonly editor: Editor - private readonly 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 readonly cachedKeys: Array - - /** - * The index of the next node to be read in the children array - */ - 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 - */ - 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. - */ - 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 - */ - 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. - */ - 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 - */ - toChunkLeaves(nodes: Descendant[], startIndex: number): ChunkLeaf[] { - return nodes.map((node, i) => ({ - type: 'leaf', - node, - key: this.findKey(node, startIndex + i), - index: startIndex + i, - })) - } - - /** - * Get the key for a Slate node, cached using the node's index - */ - private findKey(node: Descendant, index: number): Key { - const cachedKey = this.cachedKeys[index] - if (cachedKey) return cachedKey - const key = ReactEditor.findKey(this.editor, node) - this.cachedKeys[index] = key - return key - } -} diff --git a/packages/slate-react/src/chunking/chunk-tree-helper.ts b/packages/slate-react/src/chunking/chunk-tree-helper.ts deleted file mode 100644 index 076e198367..0000000000 --- a/packages/slate-react/src/chunking/chunk-tree-helper.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { Path } from 'slate' -import { Key } from 'slate-dom' -import type { - Chunk, - ChunkAncestor, - ChunkDescendant, - ChunkLeaf, - ChunkTree, -} from './types' - -type SavedPointer = - | 'start' - | { - chunk: ChunkAncestor - node: ChunkDescendant - } - -export interface ChunkTreeHelperOptions { - chunkSize: number - debug?: boolean -} - -/** - * Traverse and modify a chunk tree - */ -export class ChunkTreeHelper { - /** - * The root of the chunk tree - */ - private readonly root: ChunkTree - - /** - * The ideal size of a chunk - */ - private readonly chunkSize: number - - /** - * Whether debug mode is enabled - * - * If enabled, the pointer state will be checked for internal consistency - * after each mutating operation. - */ - private readonly 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 - */ - 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 - } - if (this.pointerChunk.type === 'root') { - this.reachedEnd = true - return null - } - 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 - */ - 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 - } - if (this.pointerChunk.type === 'root') { - this.pointerIndex = -1 - return - } - 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 - */ - 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. - */ - 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 - */ - 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 - */ - 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 = this.chunkSize ** depth - - const chunks = groupIntoChunks(leaves, this.pointerChunk, perTopLevelChunk) - this.pointerSiblings.splice(this.pointerIndex + 1, 0, ...chunks) - this.pointerIndex += chunks.length - this.cachedPointerNode = undefined - this.invalidateChunk() - this.validateState() - } - - /** - * If debug mode is enabled, ensure that the state is internally consistent - */ - // istanbul ignore next - private validateState() { - if (!this.debug) return - - const validateDescendant = (node: ChunkDescendant) => { - if (node.type === 'chunk') { - const { parent, children } = node - - if (!parent.children.includes(node)) { - throw new Error( - `Debug: Chunk ${node.key.id} has an incorrect parent property` - ) - } - - children.forEach(validateDescendant) - } - } - - this.root.children.forEach(validateDescendant) - - if ( - this.cachedPointerNode !== undefined && - this.cachedPointerNode !== this.getPointerNode() - ) { - throw new Error( - 'Debug: The cached pointer is incorrect and has not been invalidated' - ) - } - - const actualIndexStack = this.getChunkPath(this.pointerChunk) - - if (!actualIndexStack) { - throw new Error('Debug: The pointer chunk is not connected to the root') - } - - if (!Path.equals(this.pointerIndexStack, actualIndexStack)) { - throw new Error( - `Debug: The cached index stack [${this.pointerIndexStack.join( - ', ' - )}] does not match the path of the pointer chunk [${actualIndexStack.join( - ', ' - )}]` - ) - } - } -} diff --git a/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts b/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts deleted file mode 100644 index d69d44c2af..0000000000 --- a/packages/slate-react/src/chunking/get-chunk-tree-for-node.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Ancestor, Editor } from 'slate' -import type { Key } from 'slate-dom' -import { ReactEditor } from '../plugin/react-editor' -import { type ReconcileOptions, reconcileChildren } from './reconcile-children' -import type { ChunkTree } from './types' - -export const KEY_TO_CHUNK_TREE = new WeakMap() - -/** - * Get or create the chunk tree for a Slate node - * - * If the reconcile option is provided, the chunk tree will be updated to - * match the current children of the node. The children are chunked - * automatically using the given chunk size. - */ -export const getChunkTreeForNode = ( - editor: Editor, - node: Ancestor, - // istanbul ignore next - options: { - reconcile?: Omit | false - } = {} -) => { - const key = ReactEditor.findKey(editor, node) - let chunkTree = KEY_TO_CHUNK_TREE.get(key) - - if (!chunkTree) { - chunkTree = { - type: 'root', - movedNodeKeys: new Set(), - modifiedChunks: new Set(), - children: [], - } - - KEY_TO_CHUNK_TREE.set(key, chunkTree) - } - - if (options.reconcile) { - reconcileChildren(editor, { - chunkTree, - children: node.children, - ...options.reconcile, - }) - } - - return chunkTree -} diff --git a/packages/slate-react/src/chunking/index.ts b/packages/slate-react/src/chunking/index.ts deleted file mode 100644 index ee8ea2a2d6..0000000000 --- a/packages/slate-react/src/chunking/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './get-chunk-tree-for-node' -export * from './types' diff --git a/packages/slate-react/src/chunking/reconcile-children.ts b/packages/slate-react/src/chunking/reconcile-children.ts deleted file mode 100644 index 9438ce6403..0000000000 --- a/packages/slate-react/src/chunking/reconcile-children.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Descendant, Editor } from 'slate' -import { ChildrenHelper } from './children-helper' -import { - ChunkTreeHelper, - type ChunkTreeHelperOptions, -} from './chunk-tree-helper' -import type { ChunkLeaf, ChunkTree } from './types' - -export interface ReconcileOptions extends ChunkTreeHelperOptions { - chunkTree: ChunkTree - children: Descendant[] - chunkSize: number - rerenderChildren?: number[] - onInsert?: (node: Descendant, index: number) => void - onUpdate?: (node: Descendant, index: number) => void - onIndexChange?: (node: Descendant, index: number) => void - debug?: boolean -} - -/** - * Update the chunk tree to match the children array, inserting, removing and - * updating differing nodes - */ -export const reconcileChildren = ( - editor: Editor, - { - chunkTree, - children, - chunkSize, - rerenderChildren = [], - onInsert, - onUpdate, - onIndexChange, - debug, - }: ReconcileOptions -) => { - 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 (true) { - treeLeaf = chunkTreeHelper.readLeaf() - if (!treeLeaf) break - // Check where the tree node appears in the children array. In the most - // common case (where no insertions or removals have occurred), this will be - // 0. If the node has been removed, this will be -1. If new nodes have been - // inserted before the node, or if the node has been moved to a later - // position in the same children array, this will be a positive number. - const lookAhead = childrenHelper.lookAhead(treeLeaf.node, treeLeaf.key) - - // If the node was moved, we want to remove it and insert it later, rather - // then re-inserting all intermediate nodes before it. - const wasMoved = lookAhead > 0 && chunkTree.movedNodeKeys.has(treeLeaf.key) - - // If the tree leaf was moved or removed, remove it - if (lookAhead === -1 || wasMoved) { - chunkTreeHelper.remove() - continue - } - - // Get the matching Slate node and any nodes that may have been inserted - // prior to it. Insert these into the chunk tree. - const insertedChildrenStartIndex = childrenHelper.pointerIndex - const insertedChildren = childrenHelper.read(lookAhead + 1) - const matchingChild = insertedChildren.pop()! - - if (insertedChildren.length) { - const leavesToInsert = childrenHelper.toChunkLeaves( - insertedChildren, - insertedChildrenStartIndex - ) - - chunkTreeHelper.insertBefore(leavesToInsert) - - insertedChildren.forEach((node, relativeIndex) => { - onInsert?.(node, insertedChildrenStartIndex + relativeIndex) - }) - } - - const matchingChildIndex = childrenHelper.pointerIndex - 1 - - // Make sure the chunk tree contains the most recent version of the Slate - // node - if (treeLeaf.node !== matchingChild) { - treeLeaf.node = matchingChild - chunkTreeHelper.invalidateChunk() - onUpdate?.(matchingChild, matchingChildIndex) - } - - // Update the index if it has changed - if (treeLeaf.index !== matchingChildIndex) { - treeLeaf.index = matchingChildIndex - onIndexChange?.(matchingChild, matchingChildIndex) - } - - // Manually invalidate chunks containing specific children that we want to - // re-render - if (rerenderChildren.includes(matchingChildIndex)) { - chunkTreeHelper.invalidateChunk() - } - } - - // If there are still Slate nodes remaining from the children array that were - // not matched to nodes in the tree, insert them at the end of the tree - if (!childrenHelper.reachedEnd) { - const remainingChildren = childrenHelper.remaining() - - const leavesToInsert = childrenHelper.toChunkLeaves( - remainingChildren, - childrenHelper.pointerIndex - ) - - // Move the pointer back to the final leaf in the tree, or the start of the - // tree if the tree is currently empty - chunkTreeHelper.returnToPreviousLeaf() - - chunkTreeHelper.insertAfter(leavesToInsert) - - remainingChildren.forEach((node, relativeIndex) => { - onInsert?.(node, childrenHelper.pointerIndex + relativeIndex) - }) - } - - chunkTree.movedNodeKeys.clear() -} diff --git a/packages/slate-react/src/chunking/types.ts b/packages/slate-react/src/chunking/types.ts deleted file mode 100644 index d09729deab..0000000000 --- a/packages/slate-react/src/chunking/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Descendant } from 'slate' -import type { Key } from 'slate-dom' - -export interface ChunkTree { - type: 'root' - children: ChunkDescendant[] - - /** - * The keys of any Slate nodes that have been moved using move_node since the - * last render - * - * Detecting when a node has been moved to a different position in the - * children array is impossible to do efficiently while reconciling the chunk - * tree. This interferes with the reconciliation logic since it is treated as - * if the intermediate nodes were inserted and removed, causing them to be - * re-chunked unnecessarily. - * - * This set is used to detect when a node has been moved so that this case - * can be handled correctly and efficiently. - */ - movedNodeKeys: Set - - /** - * The chunks whose descendants have been modified during the most recent - * reconciliation - * - * Used to determine when the otherwise memoized React components for each - * chunk should be re-rendered. - */ - modifiedChunks: Set -} - -export interface Chunk { - type: 'chunk' - key: Key - parent: ChunkAncestor - children: ChunkDescendant[] -} - -// A chunk leaf is unrelated to a Slate leaf; it is a leaf of the chunk tree, -// containing a single element that is a child of the Slate node the chunk tree -// belongs to. -export interface ChunkLeaf { - type: 'leaf' - key: Key - node: Descendant - index: number -} - -export type ChunkAncestor = ChunkTree | Chunk -export type ChunkDescendant = Chunk | ChunkLeaf -export type ChunkNode = ChunkTree | Chunk | ChunkLeaf diff --git a/packages/slate-react/src/components/chunk-tree.tsx b/packages/slate-react/src/components/chunk-tree.tsx deleted file mode 100644 index 0141a73eaf..0000000000 --- a/packages/slate-react/src/components/chunk-tree.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { type ComponentProps, Fragment, useEffect } from 'react' -import type { Element } from 'slate' -import type { Key } from 'slate-dom' -import type { - Chunk as TChunk, - ChunkAncestor as TChunkAncestor, - ChunkTree as TChunkTree, -} from '../chunking' -import type { RenderChunkProps } from './editable' - -const defaultRenderChunk = ({ children }: RenderChunkProps) => children - -const ChunkAncestor = (props: { - root: TChunkTree - ancestor: C - renderElement: (node: Element, index: number, key: Key) => React.JSX.Element - renderChunk?: (props: RenderChunkProps) => React.JSX.Element -}) => { - const { - root, - ancestor, - renderElement, - renderChunk = defaultRenderChunk, - } = props - - return ancestor.children.map((chunkNode) => { - if (chunkNode.type === 'chunk') { - const key = chunkNode.key.id - - const renderedChunk = renderChunk({ - highest: ancestor === root, - lowest: chunkNode.children.some((c) => c.type === 'leaf'), - attributes: { 'data-slate-chunk': true }, - children: ( - - ), - }) - - return {renderedChunk} - } - - // Only blocks containing no inlines are chunked - const element = chunkNode.node as Element - - return renderElement(element, chunkNode.index, chunkNode.key) - }) -} - -const ChunkTree = (props: ComponentProps>) => { - // Clear the set of modified chunks only when React finishes rendering. The - // timing of this is important in strict mode because if the chunks are - // cleared during rendering (such as in reconcileChildren), strict mode's - // second render won't include them. - useEffect(() => { - props.root.modifiedChunks.clear() - }) - - return -} - -const MemoizedChunk = React.memo( - ChunkAncestor, - (prev, next) => - prev.root === next.root && - prev.renderElement === next.renderElement && - prev.renderChunk === next.renderChunk && - !next.root.modifiedChunks.has(next.ancestor) -) - -export default ChunkTree diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 1b8257aabb..319cfd14f5 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -671,6 +671,7 @@ export const Editable = (props: EditableProps) => { } } } + // COMPAT: For the deleting forward/backward input types we don't want // to change the selection because it is the range that will be deleted, // and those commands determine that for themselves. @@ -875,7 +876,6 @@ export const Editable = (props: EditableProps) => { // not expose the real event on this path. node.addEventListener('beforeinput', onDOMBeforeInput) } - ref.current = node if (typeof forwardedRef === 'function') { forwardedRef(node) diff --git a/packages/slate/src/core/apply.ts b/packages/slate/src/core/apply.ts index a9dcf29aaa..d5a961ac06 100644 --- a/packages/slate/src/core/apply.ts +++ b/packages/slate/src/core/apply.ts @@ -1,3 +1,5 @@ +import { transformBookmarks } from '../editor/bookmark' +import { allRangeRefs, publishRangeRefDrafts } from '../editor/range-ref' import { Editor } from '../interfaces/editor' import { Path } from '../interfaces/path' import { PathRef } from '../interfaces/path-ref' @@ -5,11 +7,70 @@ import { PointRef } from '../interfaces/point-ref' import { RangeRef } from '../interfaces/range-ref' import { Transforms } from '../interfaces/transforms' import type { WithEditorFirstArg } from '../utils/types' -import { FLUSHING } from '../utils/weak-maps' import { isBatchingDirtyPaths } from './batch-dirty-paths' +import { + buildSnapshotChange, + canUseTextFastPath, + getSnapshot, + hasListeners, + incrementVersion, + isInTransaction, + markTransactionChanged, + notifyListeners, + setCurrentMarks, + withTransaction, +} from './public-state' import { updateDirtyPaths } from './update-dirty-paths' export const apply: WithEditorFirstArg = (editor, op) => { + if ( + !isInTransaction(editor) && + (op.type === 'insert_text' || op.type === 'remove_text') && + canUseTextFastPath(editor) + ) { + const previousSnapshot = hasListeners(editor) ? getSnapshot(editor) : null + + for (const ref of Editor.pointRefs(editor)) { + PointRef.transform(ref, op) + } + + for (const ref of allRangeRefs(editor)) { + RangeRef.transform(ref, op) + } + + transformBookmarks(editor, op) + + if (!isBatchingDirtyPaths(editor)) { + updateDirtyPaths(editor, editor.getDirtyPaths(op)) + } + + Transforms.transform(editor, op) + editor.operations.push(op) + publishRangeRefDrafts(editor) + incrementVersion(editor) + + notifyListeners( + editor, + previousSnapshot + ? buildSnapshotChange({ + nextSnapshot: getSnapshot(editor), + operations: [op], + previousSnapshot, + reason: null, + }) + : undefined + ) + + return + } + + if (!isInTransaction(editor)) { + withTransaction(editor, () => { + apply(editor, op) + }) + return + } + for (const ref of Editor.pathRefs(editor)) { PathRef.transform(ref, op) } @@ -18,10 +79,12 @@ export const apply: WithEditorFirstArg = (editor, op) => { PointRef.transform(ref, op) } - for (const ref of Editor.rangeRefs(editor)) { + for (const ref of allRangeRefs(editor)) { RangeRef.transform(ref, op) } + transformBookmarks(editor, op) + // update dirty paths if (!isBatchingDirtyPaths(editor)) { const transform = Path.operationCanTransformPath(op) @@ -32,22 +95,11 @@ export const apply: WithEditorFirstArg = (editor, op) => { Transforms.transform(editor, op) editor.operations.push(op) - Editor.normalize(editor, { - operation: op, - }) // Clear any formats applied to the cursor if the selection changes. - if (op.type === 'set_selection') { - editor.marks = null + if (op.type === 'set_selection' && !isInTransaction(editor)) { + setCurrentMarks(editor, null) } - if (!FLUSHING.get(editor)) { - FLUSHING.set(editor, true) - - Promise.resolve().then(() => { - FLUSHING.set(editor, false) - editor.onChange({ operation: op }) - editor.operations = [] - }) - } + markTransactionChanged(editor) } diff --git a/packages/slate/src/core/index.ts b/packages/slate/src/core/index.ts index 46fd43b535..6e798633a5 100644 --- a/packages/slate/src/core/index.ts +++ b/packages/slate/src/core/index.ts @@ -2,4 +2,5 @@ export * from './apply' export * from './get-dirty-paths' export * from './get-fragment' export * from './normalize-node' +export * from './public-state' export * from './should-normalize' diff --git a/packages/slate/src/core/normalize-node.ts b/packages/slate/src/core/normalize-node.ts index deebcfc81c..8728efc1e2 100644 --- a/packages/slate/src/core/normalize-node.ts +++ b/packages/slate/src/core/normalize-node.ts @@ -1,133 +1,431 @@ import { - type Ancestor, type Descendant, - type Editor, - type Element, + Editor, + Element, Node, - type Path, + type NodeEntry, + type Operation, Text, - Transforms, } from '../interfaces' -import type { WithEditorFirstArg } from '../utils/types' +import { + insertNodes, + mergeNodes, + removeNodes, + wrapNodes, +} from '../transforms-node' + +const resolveFallbackElement = ( + fallbackElement: NormalizeNodeOptions['fallbackElement'] +) => + typeof fallbackElement === 'function' ? fallbackElement() : fallbackElement + +type NormalizeNodeOptions = { + operation?: Operation + fallbackElement?: Element | (() => Element) + explicit?: boolean + force?: boolean +} + +const shouldHaveInlineChildren = (editor: Editor, node: Editor | Element) => { + if (Node.isEditor(node)) { + return false + } + + const firstChild = node.children[0] + + return ( + editor.isInline(node) || + Text.isText(firstChild) || + (Element.isElement(firstChild) && editor.isInline(firstChild)) + ) +} -export const normalizeNode: WithEditorFirstArg = ( - editor, - entry, - options +const isInlineChild = (editor: Editor, node: Descendant) => + Element.isElement(node) && editor.isInline(node) + +const isTextChild = ( + node: Descendant +): node is Extract => Text.isText(node) + +const collectInlineCompatibleDescendants = ( + editor: Editor, + node: Descendant +): Descendant[] => { + if (Text.isText(node) || isInlineChild(editor, node)) { + return [node] + } + + return node.children.flatMap((child) => + collectInlineCompatibleDescendants(editor, child) + ) +} + +const normalizeExplicitInlineChildren = ( + editor: Editor, + node: Editor | Element, + path: readonly number[] ) => { - const [node, path] = entry as [{}, Path] // node is not yet normalized, treat as hostile + let didMutate = false + let currentNode: Editor | Element = node - // There are no core normalizations for text nodes. - if (Node.isText(node as Node)) { - return + const refreshNode = () => { + currentNode = Editor.node(editor, [...path])[0] as Editor | Element + } + + while (true) { + let mutatedThisRound = false + + for (let index = currentNode.children.length - 1; index >= 0; index -= 1) { + const child = currentNode.children[index]! + + if (isTextChild(child) || isInlineChild(editor, child)) { + continue + } + + const replacement = collectInlineCompatibleDescendants(editor, child) + + removeNodes(editor, { at: [...path, index], voids: true }) + + if (replacement.length > 0) { + insertNodes(editor, replacement, { at: [...path, index], voids: true }) + } + + mutatedThisRound = true + didMutate = true + } + + if (mutatedThisRound) { + refreshNode() + continue + } + + const skippedIndexes = new Set() + + for (let index = currentNode.children.length - 1; index > 0; index -= 1) { + if (skippedIndexes.has(index)) { + continue + } + + const child = currentNode.children[index]! + + if (!isTextChild(child)) { + continue + } + + const prevIndex = index - 1 + + if (skippedIndexes.has(prevIndex)) { + continue + } + + const prev = currentNode.children[prevIndex]! + + if (!isTextChild(prev)) { + continue + } + + if (child.text === '') { + removeNodes(editor, { at: [...path, index], voids: true }) + skippedIndexes.add(index) + mutatedThisRound = true + didMutate = true + continue + } + + if (prev.text === '') { + removeNodes(editor, { at: [...path, prevIndex], voids: true }) + skippedIndexes.add(prevIndex) + mutatedThisRound = true + didMutate = true + continue + } + + if (Text.equals(child, prev, { loose: true })) { + mergeNodes(editor, { at: [...path, index], voids: true }) + skippedIndexes.add(index) + mutatedThisRound = true + didMutate = true + } + } + + if (mutatedThisRound) { + refreshNode() + continue + } + + const spacerInsertions = new Set() + + for (const [index, child] of currentNode.children.entries()) { + if (!isInlineChild(editor, child)) { + continue + } + + const prev = currentNode.children[index - 1] + const next = currentNode.children[index + 1] + + if (!prev || !isTextChild(prev)) { + spacerInsertions.add(index) + } + + if (!next || !isTextChild(next)) { + spacerInsertions.add(index + 1) + } + } + + if (spacerInsertions.size === 0) { + return didMutate + } + + for (const index of Array.from(spacerInsertions).sort((a, b) => b - a)) { + insertNodes(editor, { text: '' }, { at: [...path, index], voids: true }) + } + + refreshNode() + didMutate = true + } +} + +const isDirectChildPath = ( + parentPath: readonly number[], + childPath: readonly number[] +) => + childPath.length === parentPath.length + 1 && + parentPath.every((segment, index) => segment === childPath[index]) + +const insertsDirectBlockOnlyChild = ( + editor: Editor, + path: readonly number[], + operation?: Operation +) => + operation?.type === 'insert_node' && + isDirectChildPath(path, operation.path) && + !Text.isText(operation.node) && + !isInlineChild(editor, operation.node) + +const getBlockOnlyChildIndexesToValidate = ( + path: readonly number[], + operation?: import('../interfaces').Operation +) => { + if (!operation) { + return null + } + + switch (operation.type) { + case 'set_node': + case 'insert_node': + return isDirectChildPath(path, operation.path) + ? [operation.path[path.length]] + : null + case 'remove_node': + return isDirectChildPath(path, operation.path) ? [] : null + case 'move_node': { + const removesFromParent = isDirectChildPath(path, operation.path) + const insertsIntoParent = isDirectChildPath(path, operation.newPath) + + if (!removesFromParent && !insertsIntoParent) { + return null + } + + if (removesFromParent && insertsIntoParent) { + return [] + } + + return insertsIntoParent ? [operation.newPath[path.length]] : [] + } + default: + return null } +} + +export const normalizeNode = ( + editor: Editor, + entry: NodeEntry, + options: NormalizeNodeOptions = {} +) => { + const { fallbackElement } = options + const [node, path] = entry - if (!('children' in node)) { - // If the node is not a text node, and doesn't have a `children` field, - // then we have an invalid node that will upset slate. - // - // eg: `{ type: 'some_node' }`. - // - // To prevent slate from breaking, we can add the `children` field, - // and now that it is valid, we can to many more operations easily, - // such as extend normalizers to fix erronous structure. - ;(node as Element).children = [] + if (Text.isText(node)) { + return } - let element = node as Ancestor // we will have to refetch the element any time we modify its children since it clones to a new immutable reference when we do - // Ensure that elements have at least one child. - if (element !== editor && element.children.length === 0) { - const child = { text: '' } - Transforms.insertNodes(editor, child, { at: path.concat(0), voids: true }) - element = Node.get(editor, path) as Element + if (!Node.isEditor(node) && node.children.length === 0) { + insertNodes(editor, { text: '' }, { at: [...path, 0] }) + return } - // Determine whether the node should have only block or only inline children. - // - The editor should have only block children. - // - Inline elements should have only inline children. - // - Elements that begin with a text child or an inline element child should have only inline children. - // - All other elements should have only block children. - const shouldHaveInlines = - !(element === editor) && - (editor.isInline(element) || - Node.isText(element.children[0]) || - editor.isInline(element.children[0])) - - if (shouldHaveInlines) { - // Since we'll be applying operations while iterating, we also modify `n` when adding/removing nodes. - for (let n = 0; n < element.children.length; n++) { - const child = element.children[n] - const prev = element.children[n - 1] as Descendant | undefined - - if (Node.isText(child)) { - if (prev != null && Node.isText(prev)) { - // Merge adjacent text nodes that are empty or match. - if (child.text === '') { - Transforms.removeNodes(editor, { - at: path.concat(n), - voids: true, - }) - element = Node.get(editor, path) as Element - n-- - } else if (prev.text === '') { - Transforms.removeNodes(editor, { - at: path.concat(n - 1), - voids: true, - }) - element = Node.get(editor, path) as Element - n-- - } else if (Text.equals(child, prev, { loose: true })) { - Transforms.mergeNodes(editor, { at: path.concat(n), voids: true }) - element = Node.get(editor, path) as Element - n-- - } + const directChildIndexes = getBlockOnlyChildIndexesToValidate( + path, + options.operation + ) + const allowBroadBlockOnlyScan = + Array.isArray(directChildIndexes) && directChildIndexes.length === 0 + + if (shouldHaveInlineChildren(editor, node)) { + if ( + options.explicit && + normalizeExplicitInlineChildren(editor, node, path) + ) { + return + } + + for (const [index, child] of node.children.entries()) { + const prev = node.children[index - 1] + const next = node.children[index + 1] + const touchesDirectChildCleanup = + !options.explicit && + Array.isArray(directChildIndexes) && + insertsDirectBlockOnlyChild(editor, path, options.operation) && + (directChildIndexes.includes(index) || + directChildIndexes.includes(index - 1)) + const canCanonicalizeAdjacentText = + (options.explicit || options.operation != null) && + !touchesDirectChildCleanup + + if (Text.isText(child) && Text.isText(prev)) { + if ( + canCanonicalizeAdjacentText && + child.text === '' && + (!next || Text.isText(next)) + ) { + removeNodes(editor, { at: [...path, index], voids: true }) + return } - } else if (editor.isInline(child)) { - // Ensure that inline nodes are surrounded by text nodes. - if (prev == null || !Node.isText(prev)) { - const newChild = { text: '' } - Transforms.insertNodes(editor, newChild, { - at: path.concat(n), - voids: true, - }) - element = Node.get(editor, path) as Element - n++ + + if ( + canCanonicalizeAdjacentText && + prev.text === '' && + (!node.children[index - 2] || Text.isText(node.children[index - 2]!)) + ) { + removeNodes(editor, { at: [...path, index - 1], voids: true }) + return } - if (n === element.children.length - 1) { - const newChild = { text: '' } - Transforms.insertNodes(editor, newChild, { - at: path.concat(n + 1), - voids: true, - }) - element = Node.get(editor, path) as Element - n++ + + if ( + canCanonicalizeAdjacentText && + Text.equals(child, prev, { loose: true }) + ) { + mergeNodes(editor, { at: [...path, index], voids: true }) + return } - } else { - // Allow only inline nodes to be in other inline nodes, or in parent blocks that only - // contain inlines and text. - Transforms.unwrapNodes(editor, { at: path.concat(n), voids: true }) - element = Node.get(editor, path) as Element - n-- } - } - } else { - // Since we'll be applying operations while iterating, we also modify `n` when adding/removing nodes - for (let n = 0; n < element.children.length; n++) { - const child = element.children[n] - - // Allow only block nodes in the top-level children and parent blocks that only contain block nodes - if (Node.isText(child) || editor.isInline(child)) { - if (options?.fallbackElement) { - Transforms.wrapNodes(editor, options.fallbackElement(), { - at: path.concat(n), + + if ( + touchesDirectChildCleanup && + Text.isText(child) && + Text.isText(prev) + ) { + if (child.text === '') { + removeNodes(editor, { at: [...path, index], voids: true }) + return + } + + if (prev.text === '') { + removeNodes(editor, { at: [...path, index - 1], voids: true }) + return + } + } + + if ( + Array.isArray(directChildIndexes) && + directChildIndexes.includes(index) && + !Text.isText(child) && + !isInlineChild(editor, child) + ) { + const replacement = collectInlineCompatibleDescendants(editor, child) + + removeNodes(editor, { at: [...path, index], voids: true }) + + if (replacement.length > 0) { + insertNodes(editor, replacement, { + at: [...path, index], voids: true, }) - } else { - Transforms.removeNodes(editor, { at: path.concat(n), voids: true }) } - element = Node.get(editor, path) as Ancestor - n-- + + return } + + if (!isInlineChild(editor, child)) { + continue + } + + if (!prev || !Text.isText(prev)) { + insertNodes(editor, { text: '' }, { at: [...path, index], voids: true }) + return + } + + if (!next || !Text.isText(next)) { + insertNodes( + editor, + { text: '' }, + { at: [...path, index + 1], voids: true } + ) + return + } + } + + return + } + + if (Array.isArray(directChildIndexes)) { + if (directChildIndexes.length === 0) { + if (!fallbackElement && options.operation) { + // A direct-child remove/move can expose additional invalid siblings. + // Fall through to the broader scan instead of exiting early. + } else if (!fallbackElement) { + return + } + } + + for (const index of directChildIndexes) { + const child = node.children[index] + + if (!child || (!Text.isText(child) && !isInlineChild(editor, child))) { + continue + } + + if (!fallbackElement) { + removeNodes(editor, { at: [...path, index] }) + return + } + + const wrapper = resolveFallbackElement(fallbackElement) + + if (!wrapper) { + return + } + + wrapNodes(editor, wrapper, { + at: [...path, index], + }) + return } + + if (!fallbackElement && !allowBroadBlockOnlyScan) { + return + } + } + + if (!fallbackElement && options.operation && !allowBroadBlockOnlyScan) { + return + } + + for (const [index, child] of node.children.entries()) { + if (!Text.isText(child) && !isInlineChild(editor, child)) { + continue + } + + const wrapper = resolveFallbackElement(fallbackElement) + + if (!wrapper) { + removeNodes(editor, { at: [...path, index] }) + return + } + + wrapNodes(editor, wrapper, { + at: [...path, index], + }) + return } } diff --git a/packages/slate/src/core/public-state.ts b/packages/slate/src/core/public-state.ts new file mode 100644 index 0000000000..cf3fec0402 --- /dev/null +++ b/packages/slate/src/core/public-state.ts @@ -0,0 +1,492 @@ +import { publishRangeRefDrafts, resetRangeRefDrafts } from '../editor/range-ref' +import type { + Editor, + EditorMarks, + EditorSnapshot, + RuntimeId, + Selection, + SnapshotChange, + SnapshotIndex, + SnapshotInput, + SnapshotListener, +} from '../interfaces/editor' +import type { Descendant } from '../interfaces/node' +import type { Operation } from '../interfaces/operation' +import type { Path } from '../interfaces/path' +import { + getOrCreateRuntimeId, + seedRuntimeIds, + seedRuntimeIdsFromIndex, +} from '../utils/runtime-ids' + +type TransactionSnapshot = { + children: Descendant[] + marks: EditorMarks | null + operations: Operation[] + previousSnapshot: EditorSnapshot + reason: 'replace' | null + selection: Selection +} + +const CHILDREN = new WeakMap() +const CURRENT_MARKS = new WeakMap() +const CURRENT_SELECTION = new WeakMap() +const LISTENERS = new WeakMap>() +const PUBLIC_MARKS = new WeakMap() +const PUBLIC_SELECTION = new WeakMap() +const SNAPSHOT_CACHE = new WeakMap() +const SNAPSHOT_VERSION = new WeakMap() +const MUTATION_VERSION = new WeakMap() +const DEFAULT_IS_NORMALIZING = new WeakMap() +const DEFAULT_NORMALIZE_NODE = new WeakMap() +const DEFAULT_SHOULD_NORMALIZE = new WeakMap< + Editor, + Editor['shouldNormalize'] +>() +const TRANSACTION_CHANGED = new WeakMap() +const TRANSACTION_DEPTH = new WeakMap() +const TRANSACTION_SNAPSHOT = new WeakMap() + +const cloneValue = (value: T): T => structuredClone(value) +const cloneFrozen = (value: T): T => deepFreeze(cloneValue(value)) + +const deepFreeze = (value: T): T => { + if (value == null || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + + Object.freeze(value) + + for (const key of Object.keys(value)) { + deepFreeze((value as Record)[key]) + } + + return value +} + +const pathKey = (path: Path) => path.join('.') + +const setPublicMarks = (editor: Editor, marks: EditorMarks | null) => { + PUBLIC_MARKS.set(editor, cloneValue(marks ?? null)) +} + +const setPublicSelection = (editor: Editor, selection: Selection) => { + PUBLIC_SELECTION.set(editor, cloneValue(selection ?? null)) +} + +const buildSnapshotIndex = ( + editor: Editor, + children: readonly Descendant[], + parentPath: Path = [] +): SnapshotIndex => { + const idToPath = {} as Record + const pathToId = {} as Record + + const visit = (nodes: readonly Descendant[], pathPrefix: Path) => { + nodes.forEach((node, index) => { + const path = Object.freeze([...pathPrefix, index]) as Path + const id = getOrCreateRuntimeId(node, editor) + + idToPath[id] = path + pathToId[pathKey(path)] = id + + if ('children' in node && Array.isArray(node.children)) { + visit(node.children, path) + } + }) + } + + visit(children, parentPath) + + return Object.freeze({ + idToPath: Object.freeze(idToPath), + pathToId: Object.freeze(pathToId), + }) +} + +const getVersion = (editor: Editor) => SNAPSHOT_VERSION.get(editor) ?? 0 +export const getMutationVersion = (editor: Editor) => + MUTATION_VERSION.get(editor) ?? 0 + +const setVersion = (editor: Editor, version: number) => { + SNAPSHOT_VERSION.set(editor, version) + SNAPSHOT_CACHE.delete(editor) +} + +const bumpMutationVersion = (editor: Editor) => { + MUTATION_VERSION.set(editor, getMutationVersion(editor) + 1) +} + +export const isInTransaction = (editor: Editor) => + (TRANSACTION_DEPTH.get(editor) ?? 0) > 0 + +export const markTransactionChanged = (editor: Editor) => { + if (isInTransaction(editor)) { + TRANSACTION_CHANGED.set(editor, true) + } +} + +export const getChildren = (editor: Editor): Descendant[] => + CHILDREN.get(editor) ?? + (Array.isArray((editor as Partial).children) + ? ((editor as Partial).children as Descendant[]) + : []) + +export const setChildren = (editor: Editor, children: Descendant[]) => { + CHILDREN.set(editor, children) + bumpMutationVersion(editor) + SNAPSHOT_CACHE.delete(editor) + markTransactionChanged(editor) +} + +export const getCurrentMarks = (editor: Editor): EditorMarks | null => + cloneValue( + CURRENT_MARKS.get(editor) ?? + ((editor as Partial).marks as EditorMarks | null | undefined) ?? + null + ) + +export const setCurrentMarks = (editor: Editor, marks: EditorMarks | null) => { + const cloned = cloneValue(marks ?? null) + CURRENT_MARKS.set(editor, cloned) + bumpMutationVersion(editor) + setPublicMarks(editor, cloned) + SNAPSHOT_CACHE.delete(editor) + markTransactionChanged(editor) +} + +export const getCurrentSelection = (editor: Editor): Selection => + cloneValue( + CURRENT_SELECTION.get(editor) ?? + ((editor as Partial).selection as Selection | undefined) ?? + null + ) + +export const setCurrentSelection = (editor: Editor, selection: Selection) => { + const cloned = cloneValue(selection ?? null) + CURRENT_SELECTION.set(editor, cloned) + bumpMutationVersion(editor) + setPublicSelection(editor, cloned) + SNAPSHOT_CACHE.delete(editor) + markTransactionChanged(editor) +} + +export const getSnapshot = (editor: Editor): EditorSnapshot => { + const cached = SNAPSHOT_CACHE.get(editor) + + if (cached) { + return cached + } + + const liveChildren = getChildren(editor) + const children = cloneFrozen(liveChildren) + const selection = cloneFrozen(getCurrentSelection(editor)) + const marks = cloneFrozen(getCurrentMarks(editor)) + + const snapshot = Object.freeze({ + children, + index: buildSnapshotIndex(editor, liveChildren), + marks, + selection, + version: getVersion(editor), + }) + + SNAPSHOT_CACHE.set(editor, snapshot) + + return snapshot +} + +const uniqPaths = (paths: Path[]) => { + const seen = new Set() + return paths.filter((path) => { + const key = pathKey(path) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +export const buildSnapshotChange = ({ + nextSnapshot, + operations, + previousSnapshot, + reason, +}: { + nextSnapshot: EditorSnapshot + operations: Operation[] + previousSnapshot: EditorSnapshot + reason: 'replace' | null +}): SnapshotChange => { + const previousChildren = JSON.stringify(previousSnapshot.children) + const nextChildren = JSON.stringify(nextSnapshot.children) + const previousSelection = JSON.stringify(previousSnapshot.selection) + const nextSelection = JSON.stringify(nextSnapshot.selection) + const previousMarks = JSON.stringify(previousSnapshot.marks) + const nextMarks = JSON.stringify(nextSnapshot.marks) + + const classes = + reason === 'replace' + ? (['replace'] as const) + : operations.length === 0 && previousMarks !== nextMarks + ? (['mark'] as const) + : operations.length > 0 && + operations.every((op) => op.type === 'set_selection') + ? (['selection'] as const) + : operations.length > 0 && + operations.every( + (op) => op.type === 'insert_text' || op.type === 'remove_text' + ) + ? (['text'] as const) + : (['structural'] as const) + + const dirtyPaths = + classes[0] === 'text' + ? uniqPaths( + operations.flatMap((op) => + 'path' in op && Array.isArray(op.path) + ? [[], op.path.slice(0, -1), op.path] + : [] + ) + ) + : [] + + const touchedRuntimeIds = + classes[0] === 'replace' + ? null + : classes[0] === 'selection' || classes[0] === 'mark' + ? [] + : uniqPaths( + operations.flatMap((op) => + 'path' in op && Array.isArray(op.path) ? [op.path] : [] + ) + ).map( + (path) => + previousSnapshot.index.pathToId[pathKey(path)] ?? + nextSnapshot.index.pathToId[pathKey(path)] + ) + + return { + childrenChanged: previousChildren !== nextChildren, + classes, + dirtyPaths, + dirtyScope: + classes[0] === 'replace' + ? 'all' + : classes[0] === 'selection' || classes[0] === 'mark' + ? 'none' + : 'paths', + marksChanged: previousMarks !== nextMarks, + operations: Object.freeze([...operations]), + replaceEpoch: reason === 'replace' ? 1 : 0, + selectionChanged: previousSelection !== nextSelection, + touchedRuntimeIds: + touchedRuntimeIds == null + ? null + : Object.freeze(touchedRuntimeIds.filter(Boolean) as RuntimeId[]), + } +} + +export const notifyListeners = (editor: Editor, change?: SnapshotChange) => { + editor.onChange() + + const listeners = LISTENERS.get(editor) + + if (!listeners || listeners.size === 0) { + return + } + + const snapshot = getSnapshot(editor) + + for (const listener of listeners) { + listener(snapshot, change) + } +} + +export const incrementVersion = (editor: Editor) => { + setVersion(editor, getVersion(editor) + 1) +} + +export const canUseTextFastPath = (editor: Editor) => + editor.normalizeNode === DEFAULT_NORMALIZE_NODE.get(editor) && + editor.shouldNormalize === DEFAULT_SHOULD_NORMALIZE.get(editor) && + editor.isNormalizing === DEFAULT_IS_NORMALIZING.get(editor) + +export const hasListeners = (editor: Editor) => + (LISTENERS.get(editor)?.size ?? 0) > 0 + +const restoreTransactionSnapshot = ( + editor: Editor, + transactionSnapshot: TransactionSnapshot +) => { + const restoredChildren = cloneValue(transactionSnapshot.children) + seedRuntimeIdsFromIndex( + restoredChildren, + editor, + transactionSnapshot.previousSnapshot.index + ) + CHILDREN.set(editor, restoredChildren) + setCurrentSelection(editor, transactionSnapshot.selection) + setCurrentMarks(editor, transactionSnapshot.marks) + editor.operations = cloneValue(transactionSnapshot.operations) +} + +export const withTransaction = ( + editor: Editor, + fn: () => void, + options: { skipNormalize?: boolean } = {} +) => { + const depth = TRANSACTION_DEPTH.get(editor) ?? 0 + const isOuter = depth === 0 + + if (isOuter) { + TRANSACTION_SNAPSHOT.set(editor, { + children: cloneValue(getChildren(editor)), + marks: cloneValue(getCurrentMarks(editor)), + operations: cloneValue(editor.operations), + previousSnapshot: getSnapshot(editor), + reason: null, + selection: cloneValue(getCurrentSelection(editor)), + }) + TRANSACTION_CHANGED.set(editor, false) + } + + TRANSACTION_DEPTH.set(editor, depth + 1) + + try { + fn() + + if ( + isOuter && + (TRANSACTION_CHANGED.get(editor) ?? false) && + editor.isNormalizing() && + !options.skipNormalize + ) { + editor.normalize({ + explicit: false, + force: editor.operations.length === 0, + operation: editor.operations.at(-1), + }) + } + } catch (error) { + if (isOuter) { + const snapshot = TRANSACTION_SNAPSHOT.get(editor) + + if (snapshot) { + restoreTransactionSnapshot(editor, snapshot) + } + resetRangeRefDrafts(editor) + TRANSACTION_SNAPSHOT.delete(editor) + TRANSACTION_CHANGED.delete(editor) + } + throw error + } finally { + const nextDepth = (TRANSACTION_DEPTH.get(editor) ?? 1) - 1 + TRANSACTION_DEPTH.set(editor, nextDepth) + + if (isOuter) { + const changed = TRANSACTION_CHANGED.get(editor) ?? false + + const snapshot = TRANSACTION_SNAPSHOT.get(editor) + TRANSACTION_SNAPSHOT.delete(editor) + TRANSACTION_CHANGED.delete(editor) + + if (changed) { + publishRangeRefDrafts(editor) + setVersion(editor, getVersion(editor) + 1) + notifyListeners( + editor, + snapshot + ? buildSnapshotChange({ + nextSnapshot: getSnapshot(editor), + operations: editor.operations, + previousSnapshot: snapshot.previousSnapshot, + reason: snapshot.reason, + }) + : undefined + ) + } + } + } +} + +export const replaceSnapshot = (editor: Editor, input: SnapshotInput) => { + withTransaction(editor, () => { + const transaction = TRANSACTION_SNAPSHOT.get(editor) + const existingIndex = buildSnapshotIndex(editor, getChildren(editor)) + const nextChildren = cloneValue([...input.children]) + + if (transaction) { + transaction.reason = 'replace' + } + + seedRuntimeIdsFromIndex(nextChildren, editor, existingIndex) + setChildren(editor, nextChildren) + setCurrentSelection(editor, input.selection ?? null) + setCurrentMarks(editor, input.marks ?? null) + }) +} + +export const subscribe = (editor: Editor, listener: SnapshotListener) => { + const listeners = LISTENERS.get(editor) ?? new Set() + listeners.add(listener) + LISTENERS.set(editor, listeners) + + return () => { + listeners.delete(listener) + } +} + +export const initializePublicState = (editor: Editor) => { + const initialChildren = Array.isArray((editor as Editor).children) + ? (editor as Editor).children + : [] + + CHILDREN.set(editor, initialChildren) + seedRuntimeIds(initialChildren, editor) + CURRENT_SELECTION.set(editor, cloneValue(editor.selection ?? null)) + CURRENT_MARKS.set(editor, cloneValue(editor.marks ?? null)) + DEFAULT_IS_NORMALIZING.set(editor, editor.isNormalizing) + DEFAULT_NORMALIZE_NODE.set(editor, editor.normalizeNode) + DEFAULT_SHOULD_NORMALIZE.set(editor, editor.shouldNormalize) + LISTENERS.set(editor, new Set()) + MUTATION_VERSION.set(editor, 0) + setPublicSelection(editor, editor.selection ?? null) + setPublicMarks(editor, editor.marks ?? null) + SNAPSHOT_CACHE.delete(editor) + setVersion(editor, 0) + + Object.defineProperty(editor, 'children', { + configurable: true, + enumerable: true, + get() { + return editor.getChildren() + }, + set(children: Descendant[]) { + editor.setChildren(children) + }, + }) + + Object.defineProperty(editor, 'selection', { + configurable: true, + enumerable: true, + get() { + return PUBLIC_SELECTION.get(editor) ?? null + }, + set(selection: Selection) { + setPublicSelection(editor, selection) + }, + }) + + Object.defineProperty(editor, 'marks', { + configurable: true, + enumerable: true, + get() { + return PUBLIC_MARKS.get(editor) ?? null + }, + set(marks: EditorMarks | null) { + setPublicMarks(editor, marks) + }, + }) +} diff --git a/packages/slate/src/core/should-normalize.ts b/packages/slate/src/core/should-normalize.ts index f92c9241cc..efb3c60d3c 100644 --- a/packages/slate/src/core/should-normalize.ts +++ b/packages/slate/src/core/should-normalize.ts @@ -2,16 +2,6 @@ import type { Editor } from '../interfaces/editor' import type { WithEditorFirstArg } from '../utils/types' export const shouldNormalize: WithEditorFirstArg = ( - editor, - { iteration, initialDirtyPathsLength } -) => { - const maxIterations = initialDirtyPathsLength * 42 // HACK: better way? - - if (iteration > maxIterations) { - throw new Error( - `Could not completely normalize the editor after ${maxIterations} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.` - ) - } - - return true -} + _editor, + _options +) => true diff --git a/packages/slate/src/create-editor.ts b/packages/slate/src/create-editor.ts index 059c6fefe6..192995bd59 100644 --- a/packages/slate/src/create-editor.ts +++ b/packages/slate/src/create-editor.ts @@ -2,8 +2,11 @@ import { addMark, deleteFragment, type Editor, + getChildren, getDirtyPaths, getFragment, + getSnapshot, + initializePublicState, insertBreak, insertFragment, insertNode, @@ -11,13 +14,18 @@ import { insertText, normalizeNode, removeMark, + replaceSnapshot, + setChildren, shouldNormalize, + subscribe, + withTransaction, } from './' import { apply } from './core' import { above, after, before, + bookmark, deleteBackward, deleteForward, edges, @@ -53,6 +61,7 @@ import { pointRefs, positions, previous, + projectRange, range, rangeRef, rangeRefs, @@ -95,7 +104,7 @@ export const createEditor = (): Editor => { selection: null, marks: null, isElementReadOnly: () => false, - isInline: () => false, + isInline: (element) => 'type' in element && element.type === 'link', isSelectable: () => true, isVoid: () => false, markableVoid: () => false, @@ -106,10 +115,13 @@ export const createEditor = (): Editor => { // Editor addMark: (...args) => addMark(editor, ...args), + bookmark: (...args) => bookmark(editor, ...args), deleteBackward: (...args) => deleteBackward(editor, ...args), deleteForward: (...args) => deleteForward(editor, ...args), deleteFragment: (...args) => deleteFragment(editor, ...args), + getChildren: (...args) => getChildren(editor, ...args), getFragment: (...args) => getFragment(editor, ...args), + getSnapshot: (...args) => getSnapshot(editor, ...args), insertBreak: (...args) => insertBreak(editor, ...args), insertSoftBreak: (...args) => insertSoftBreak(editor, ...args), insertFragment: (...args) => insertFragment(editor, ...args), @@ -164,11 +176,15 @@ export const createEditor = (): Editor => { pointRefs: (...args) => pointRefs(editor, ...args), positions: (...args) => positions(editor, ...args), previous: (...args) => previous(editor, ...args), + projectRange: (...args) => projectRange(editor, ...args), range: (...args) => range(editor, ...args), rangeRef: (...args) => rangeRef(editor, ...args), rangeRefs: (...args) => rangeRefs(editor, ...args), removeNodes: (...args) => removeNodes(editor, ...args), select: (...args) => select(editor, ...args), + replace: (...args) => replaceSnapshot(editor, ...args), + reset: (...args) => replaceSnapshot(editor, ...args), + setChildren: (...args) => setChildren(editor, ...args), setNodes: (...args) => setNodes(editor, ...args), setNormalizing: (...args) => setNormalizing(editor, ...args), setPoint: (...args) => setPoint(editor, ...args), @@ -176,15 +192,19 @@ export const createEditor = (): Editor => { splitNodes: (...args) => splitNodes(editor, ...args), start: (...args) => start(editor, ...args), string: (...args) => string(editor, ...args), + subscribe: (...args) => subscribe(editor, ...args), unhangRange: (...args) => unhangRange(editor, ...args), unsetNodes: (...args) => unsetNodes(editor, ...args), unwrapNodes: (...args) => unwrapNodes(editor, ...args), void: (...args) => getVoid(editor, ...args), withoutNormalizing: (...args) => withoutNormalizing(editor, ...args), + withTransaction: (...args) => withTransaction(editor, ...args), wrapNodes: (...args) => wrapNodes(editor, ...args), shouldMergeNodesRemovePrevNode: (...args) => shouldMergeNodesRemovePrevNode(editor, ...args), } + initializePublicState(editor) + return editor } diff --git a/packages/slate/src/editor/above.ts b/packages/slate/src/editor/above.ts index d65b1761ba..e2e2e0d4a9 100644 --- a/packages/slate/src/editor/above.ts +++ b/packages/slate/src/editor/above.ts @@ -6,7 +6,7 @@ export const above: EditorInterface['above'] = (editor, options = {}) => { const { voids = false, mode = 'lowest', - at = editor.selection, + at = Editor.getSnapshot(editor).selection, match, } = options diff --git a/packages/slate/src/editor/add-mark.ts b/packages/slate/src/editor/add-mark.ts index 55e73980b4..8eeb2b78eb 100644 --- a/packages/slate/src/editor/add-mark.ts +++ b/packages/slate/src/editor/add-mark.ts @@ -1,12 +1,17 @@ +import { + getCurrentMarks, + getCurrentSelection, + setCurrentMarks, + withTransaction, +} from '../core/public-state' import { Editor, type EditorInterface } from '../interfaces/editor' import { Node } from '../interfaces/node' import type { Path } from '../interfaces/path' import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' -import { FLUSHING } from '../utils/weak-maps' export const addMark: EditorInterface['addMark'] = (editor, key, value) => { - const { selection } = editor + const selection = getCurrentSelection(editor) if (selection) { const match = (node: Node, path: Path) => { @@ -38,14 +43,13 @@ export const addMark: EditorInterface['addMark'] = (editor, key, value) => { ) } else { const marks = { - ...(Editor.marks(editor) || {}), + ...(getCurrentMarks(editor) || {}), [key]: value, } - editor.marks = marks - if (!FLUSHING.get(editor)) { - editor.onChange() - } + withTransaction(editor, () => { + setCurrentMarks(editor, marks) + }) } } } diff --git a/packages/slate/src/editor/after.ts b/packages/slate/src/editor/after.ts index a73b24d032..e881bf1558 100644 --- a/packages/slate/src/editor/after.ts +++ b/packages/slate/src/editor/after.ts @@ -1,4 +1,5 @@ import { Editor, type EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' import type { Point } from '../interfaces/point' export const after: EditorInterface['after'] = (editor, at, options = {}) => { @@ -13,6 +14,17 @@ export const after: EditorInterface['after'] = (editor, at, options = {}) => { ...options, at: range, })) { + const insideNonSelectable = Editor.above(editor, { + at: p, + match: (node) => Node.isElement(node) && !editor.isSelectable(node), + mode: 'highest', + voids: true, + }) + + if (insideNonSelectable) { + continue + } + if (d > distance) { break } diff --git a/packages/slate/src/editor/before.ts b/packages/slate/src/editor/before.ts index dcf55f39d6..b01b6d0782 100644 --- a/packages/slate/src/editor/before.ts +++ b/packages/slate/src/editor/before.ts @@ -1,4 +1,5 @@ import { Editor, type EditorInterface } from '../interfaces/editor' +import { Node } from '../interfaces/node' import type { Point } from '../interfaces/point' export const before: EditorInterface['before'] = (editor, at, options = {}) => { @@ -14,6 +15,17 @@ export const before: EditorInterface['before'] = (editor, at, options = {}) => { at: range, reverse: true, })) { + const insideNonSelectable = Editor.above(editor, { + at: p, + match: (node) => Node.isElement(node) && !editor.isSelectable(node), + mode: 'highest', + voids: true, + }) + + if (insideNonSelectable) { + continue + } + if (d > distance) { break } diff --git a/packages/slate/src/editor/bookmark.ts b/packages/slate/src/editor/bookmark.ts new file mode 100644 index 0000000000..b69b778eae --- /dev/null +++ b/packages/slate/src/editor/bookmark.ts @@ -0,0 +1,100 @@ +import type { + Bookmark, + BookmarkAffinity, + BookmarkOptions, +} from '../interfaces/bookmark' +import type { Editor } from '../interfaces/editor' +import type { Operation } from '../interfaces/operation' +import { Range } from '../interfaces/range' + +type InternalBookmark = { + affinity: BookmarkAffinity + current: import('../interfaces').Range | null +} + +const BOOKMARKS = new WeakMap>() + +const getBookmarks = (editor: Editor) => { + let bookmarks = BOOKMARKS.get(editor) + + if (!bookmarks) { + bookmarks = new Set() + BOOKMARKS.set(editor, bookmarks) + } + + return bookmarks +} + +const cloneRange = (range: import('../interfaces').Range | null) => + range + ? { + anchor: { + path: [...range.anchor.path], + offset: range.anchor.offset, + }, + focus: { + path: [...range.focus.path], + offset: range.focus.offset, + }, + } + : null + +export const bookmark = ( + editor: Editor, + range: import('../interfaces').Range, + options: BookmarkOptions = {} +): Bookmark => { + const affinity = options.affinity ?? 'inward' + const state: InternalBookmark = { + affinity, + current: cloneRange(range), + } + + const bookmarkValue: Bookmark = { + affinity, + resolve() { + const latest = cloneRange(state.current) + + if (latest == null) { + getBookmarks(editor).delete(state) + } + + return latest + }, + unref() { + getBookmarks(editor).delete(state) + const latest = cloneRange(state.current) + state.current = null + return latest + }, + } + + getBookmarks(editor).add(state) + + return bookmarkValue +} + +export const transformBookmarks = (editor: Editor, op: Operation) => { + const bookmarks = BOOKMARKS.get(editor) + + if (!bookmarks) { + return + } + + for (const bookmarkState of bookmarks) { + if (bookmarkState.current == null) { + bookmarks.delete(bookmarkState) + continue + } + + const next = Range.transform(bookmarkState.current, op, { + affinity: bookmarkState.affinity, + }) + + bookmarkState.current = next + + if (next == null) { + bookmarks.delete(bookmarkState) + } + } +} diff --git a/packages/slate/src/editor/fragment.ts b/packages/slate/src/editor/fragment.ts index f235c04867..1a9ecda521 100644 --- a/packages/slate/src/editor/fragment.ts +++ b/packages/slate/src/editor/fragment.ts @@ -1,7 +1,13 @@ import { Editor, type EditorInterface } from '../interfaces/editor' import { Node } from '../interfaces/node' +import { Range } from '../interfaces/range' export const fragment: EditorInterface['fragment'] = (editor, at) => { const range = Editor.range(editor, at) + + if (Range.isCollapsed(range)) { + return [] + } + return Node.fragment(editor, range) } diff --git a/packages/slate/src/editor/index.ts b/packages/slate/src/editor/index.ts index 855af5dfcb..128863e60e 100644 --- a/packages/slate/src/editor/index.ts +++ b/packages/slate/src/editor/index.ts @@ -2,6 +2,7 @@ export * from './above' export * from './add-mark' export * from './after' export * from './before' +export * from './bookmark' export * from './delete-backward' export * from './delete-forward' export * from './delete-fragment' @@ -43,6 +44,7 @@ export * from './point-ref' export * from './point-refs' export * from './positions' export * from './previous' +export * from './project-range' export * from './range' export * from './range-ref' export * from './range-refs' diff --git a/packages/slate/src/editor/insert-break.ts b/packages/slate/src/editor/insert-break.ts index 4a314f4e83..aa284326f5 100644 --- a/packages/slate/src/editor/insert-break.ts +++ b/packages/slate/src/editor/insert-break.ts @@ -1,6 +1,97 @@ +import { getCurrentSelection, withTransaction } from '../core/public-state' import type { EditorInterface } from '../interfaces/editor' -import { Transforms } from '../interfaces/transforms' +import { Node } from '../interfaces/node' +import type { Path } from '../interfaces/path' +import { Range } from '../interfaces/range' export const insertBreak: EditorInterface['insertBreak'] = (editor) => { - Transforms.splitNodes(editor, { always: true }) + const selection = getCurrentSelection(editor) + + if (!selection) { + return + } + + if (!Range.isCollapsed(selection)) { + editor.deleteFragment() + } + + const collapsed = getCurrentSelection(editor) + + if (!collapsed || !Range.isCollapsed(collapsed)) { + return + } + + const point = collapsed.anchor + + withTransaction(editor, () => { + const [node] = editor.node(point.path) + const nodeProps = Node.extractProps(node) + + if (!Node.isText(node)) { + throw new Error( + 'Editor.insertBreak currently supports only text-node selections' + ) + } + + let nextPath = [...point.path.slice(0, -1), point.path.at(-1)! + 1] as Path + + if (point.offset === 0) { + editor.apply({ + type: 'insert_node', + path: point.path, + node: { + text: '', + ...nodeProps, + }, + }) + } else if (point.offset === node.text.length) { + editor.apply({ + type: 'insert_node', + path: nextPath, + node: { + text: '', + ...nodeProps, + }, + }) + } else { + editor.apply({ + type: 'split_node', + path: point.path, + position: point.offset, + properties: nodeProps, + }) + } + + for (let depth = point.path.length - 1; depth > 0; depth -= 1) { + const parentPath = point.path.slice(0, depth) as Path + const position = nextPath[depth]! + const [parentNode] = editor.node(parentPath) + + if (Node.isText(parentNode)) { + throw new Error( + 'Editor.insertBreak currently expects element ancestors' + ) + } + + editor.apply({ + type: 'split_node', + path: parentPath, + position, + properties: Node.extractProps(parentNode), + }) + + nextPath = [...parentPath.slice(0, -1), parentPath.at(-1)! + 1] as Path + } + + const firstPoint = editor.start(nextPath) + + editor.apply({ + type: 'set_selection', + properties: getCurrentSelection(editor), + newProperties: { + anchor: firstPoint, + focus: firstPoint, + }, + }) + }) } diff --git a/packages/slate/src/editor/insert-text.ts b/packages/slate/src/editor/insert-text.ts index 6745973082..fd8ff6b702 100644 --- a/packages/slate/src/editor/insert-text.ts +++ b/packages/slate/src/editor/insert-text.ts @@ -1,3 +1,8 @@ +import { + getCurrentMarks, + getCurrentSelection, + setCurrentMarks, +} from '../core/public-state' import type { EditorInterface } from '../interfaces/editor' import { Transforms } from '../interfaces/transforms' @@ -6,7 +11,8 @@ export const insertText: EditorInterface['insertText'] = ( text, options = {} ) => { - const { selection, marks } = editor + const selection = getCurrentSelection(editor) + const marks = getCurrentMarks(editor) if (selection) { if (marks) { @@ -19,6 +25,6 @@ export const insertText: EditorInterface['insertText'] = ( Transforms.insertText(editor, text, options) } - editor.marks = null + setCurrentMarks(editor, null) } } diff --git a/packages/slate/src/editor/levels.ts b/packages/slate/src/editor/levels.ts index d81903d7e6..cceeedaff8 100644 --- a/packages/slate/src/editor/levels.ts +++ b/packages/slate/src/editor/levels.ts @@ -5,7 +5,11 @@ export function* levels( editor: Editor, options: EditorLevelsOptions = {} ): Generator, void, undefined> { - const { at = editor.selection, reverse = false, voids = false } = options + const { + at = Editor.getSnapshot(editor).selection, + reverse = false, + voids = false, + } = options let { match } = options if (match == null) { diff --git a/packages/slate/src/editor/marks.ts b/packages/slate/src/editor/marks.ts index b9207cbd71..044e918b7d 100644 --- a/packages/slate/src/editor/marks.ts +++ b/packages/slate/src/editor/marks.ts @@ -1,3 +1,4 @@ +import { getCurrentMarks, getCurrentSelection } from '../core/public-state' import type { Point } from '../interfaces' import { Editor, type EditorInterface } from '../interfaces/editor' import { Node } from '../interfaces/node' @@ -5,7 +6,8 @@ import { Path } from '../interfaces/path' import { Range } from '../interfaces/range' export const marks: EditorInterface['marks'] = (editor, options = {}) => { - const { marks, selection } = editor + const marks = getCurrentMarks(editor) + const selection = getCurrentSelection(editor) if (!selection) { return null diff --git a/packages/slate/src/editor/next.ts b/packages/slate/src/editor/next.ts index 30499b1404..7429537f5f 100644 --- a/packages/slate/src/editor/next.ts +++ b/packages/slate/src/editor/next.ts @@ -3,7 +3,7 @@ import { Location, type Span } from '../interfaces/location' export const next: EditorInterface['next'] = (editor, options = {}) => { const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection } = options + let { match, at = Editor.getSnapshot(editor).selection } = options if (!at) { return diff --git a/packages/slate/src/editor/nodes.ts b/packages/slate/src/editor/nodes.ts index 8456d39ffe..f17669809f 100644 --- a/packages/slate/src/editor/nodes.ts +++ b/packages/slate/src/editor/nodes.ts @@ -8,7 +8,7 @@ export function* nodes( options: EditorNodesOptions = {} ): Generator, void, undefined> { const { - at = editor.selection, + at = Editor.getSnapshot(editor).selection, mode = 'all', universal = false, reverse = false, diff --git a/packages/slate/src/editor/normalize.ts b/packages/slate/src/editor/normalize.ts index f0912948a9..3c0640d271 100644 --- a/packages/slate/src/editor/normalize.ts +++ b/packages/slate/src/editor/normalize.ts @@ -1,92 +1,162 @@ +import { batchDirtyPaths } from '../core/batch-dirty-paths' +import { + getChildren, + getCurrentMarks, + getCurrentSelection, + getMutationVersion, + isInTransaction, + withTransaction, +} from '../core/public-state' import { Editor, type EditorInterface } from '../interfaces/editor' -import { Node } from '../interfaces/node' -import type { Path } from '../interfaces/path' +import { Node, type NodeEntry } from '../interfaces/node' +import type { Operation } from '../interfaces/operation' import { DIRTY_PATH_KEYS, DIRTY_PATHS } from '../utils/weak-maps' export const normalize: EditorInterface['normalize'] = ( editor, options = {} ) => { - const { force = false, operation } = options + const { + explicit = true, + force = explicit, + operation, + } = options as { + explicit?: boolean + force?: boolean + operation?: Operation + } const getDirtyPaths = (editor: Editor) => { return DIRTY_PATHS.get(editor) || [] } - const getDirtyPathKeys = (editor: Editor) => { - return DIRTY_PATH_KEYS.get(editor) || new Set() + const clearDirtyPaths = (editor: Editor) => { + DIRTY_PATHS.set(editor, []) + DIRTY_PATH_KEYS.set(editor, new Set()) } - const popDirtyPath = (editor: Editor): Path => { - const path = getDirtyPaths(editor).pop()! - const key = path.join(',') - getDirtyPathKeys(editor).delete(key) - return path - } + const createPassSignature = () => + JSON.stringify({ + children: getChildren(editor), + marks: getCurrentMarks(editor), + selection: getCurrentSelection(editor), + }) - if (!Editor.isNormalizing(editor)) { - return - } + const collectNormalizeEntries = (): NodeEntry[] => + Array.from(Node.nodes(editor), ([node, path]) => [node, path] as NodeEntry) - if (force) { - const allPaths = Array.from(Node.nodes(editor), ([, p]) => p) - const allPathKeys = new Set(allPaths.map((p) => p.join(','))) - DIRTY_PATHS.set(editor, allPaths) - DIRTY_PATH_KEYS.set(editor, allPathKeys) - } + const collectDirtyNormalizeEntries = (): NodeEntry[] => + getDirtyPaths(editor) + .filter((path) => Node.has(editor, path)) + .map((path) => Editor.node(editor, path)) - if (getDirtyPaths(editor).length === 0) { - return - } + const runNormalizePasses = () => { + if (!Editor.isNormalizing(editor)) { + return + } - Editor.withoutNormalizing(editor, () => { - /* - Fix dirty elements with no children. - editor.normalizeNode() does fix this, but some normalization fixes also require it to work. - Running an initial pass avoids the catch-22 race condition. - */ - for (const dirtyPath of getDirtyPaths(editor)) { - if (Node.has(editor, dirtyPath)) { - const entry = Editor.node(editor, dirtyPath) - const [node, _] = entry - - /* - The default normalizer inserts an empty text node in this scenario, but it can be customised. - So there is some risk here. - - As long as the normalizer only inserts child nodes for this case it is safe to do in any order; - by definition adding children to an empty node can't cause other paths to change. - */ - if (Node.isElement(node) && node.children.length === 0) { - editor.normalizeNode(entry, { operation, force }) - } - } + if (force) { + const allPaths = Array.from(Node.nodes(editor), ([, p]) => p) + const allPathKeys = new Set(allPaths.map((p) => p.join(','))) + DIRTY_PATHS.set(editor, allPaths) + DIRTY_PATH_KEYS.set(editor, allPathKeys) + } + + if (getDirtyPaths(editor).length === 0) { + return } - let dirtyPaths = getDirtyPaths(editor) - const initialDirtyPathsLength = dirtyPaths.length - let iteration = 0 - - while (dirtyPaths.length !== 0) { - if ( - !editor.shouldNormalize({ - dirtyPaths, - iteration, - initialDirtyPathsLength, - operation, + const wasNormalizing = Editor.isNormalizing(editor) + Editor.setNormalizing(editor, false) + + try { + const initialEntryCount = force + ? collectNormalizeEntries().length + : getDirtyPaths(editor).length + const maxIterations = Math.max(8, initialEntryCount * 4) + const seenSignatures = new Set() + let iteration = 0 + + while (true) { + const entries = force + ? collectNormalizeEntries() + : collectDirtyNormalizeEntries() + + if (entries.length === 0) { + clearDirtyPaths(editor) + return + } + + const signature = JSON.stringify({ + state: createPassSignature(), + entries: entries.map(([, path]) => path), }) - ) { - return - } - const dirtyPath = popDirtyPath(editor) + if (seenSignatures.has(signature)) { + throw new Error( + `normalizeNode revisited an earlier draft state after ${iteration} passes without reaching fixpoint` + ) + } + + seenSignatures.add(signature) + + if ( + !editor.shouldNormalize({ + explicit, + iteration, + operation, + }) + ) { + return + } + let changed = false + + for (const entry of entries) { + const beforeMutation = getMutationVersion(editor) + editor.normalizeNode(entry, { explicit, operation }) + const afterMutation = getMutationVersion(editor) + + if (beforeMutation !== afterMutation) { + changed = true + + if (!explicit) { + break + } + } + } + + if (!changed) { + clearDirtyPaths(editor) + return + } + + iteration += 1 - // If the node doesn't exist in the tree, it does not need to be normalized. - if (Node.has(editor, dirtyPath)) { - const entry = Editor.node(editor, dirtyPath) - editor.normalizeNode(entry, { operation, force }) + if (iteration > maxIterations) { + throw new Error( + `normalizeNode exhausted derived pass budget (${maxIterations}) without reaching fixpoint` + ) + } } - iteration++ - dirtyPaths = getDirtyPaths(editor) + } finally { + Editor.setNormalizing(editor, wasNormalizing) } - }) + } + + if (explicit && !isInTransaction(editor)) { + withTransaction( + editor, + () => { + normalize(editor, options) + }, + { skipNormalize: true } + ) + return + } + + if (force) { + batchDirtyPaths(editor, runNormalizePasses, () => {}) + return + } + + runNormalizePasses() } diff --git a/packages/slate/src/editor/positions.ts b/packages/slate/src/editor/positions.ts index e15ed28517..581e525a64 100644 --- a/packages/slate/src/editor/positions.ts +++ b/packages/slate/src/editor/positions.ts @@ -1,220 +1,244 @@ +import { getCurrentSelection } from '../core/public-state' import { Editor, type EditorPositionsOptions } from '../interfaces/editor' import { Node } from '../interfaces/node' import { Path } from '../interfaces/path' import type { Point } from '../interfaces/point' import { Range } from '../interfaces/range' -import { - getCharacterDistance, - getWordDistance, - splitByCharacterDistance, -} from '../utils/string' +import { projectRangeInSnapshot } from '../range-projection' +import { getCharacterDistance, getWordDistance } from '../utils/string' + +type PositionSegment = { + path: Path + start: number + end: number + text: string +} + +const comparePoints = (left: Point, right: Point) => { + const pathComparison = Path.compare(left.path, right.path) + + if (pathComparison !== 0) { + return pathComparison + } + + if (left.offset === right.offset) { + return 0 + } + + return left.offset < right.offset ? -1 : 1 +} + +const isPathInsideVoid = (editor: Editor, path: Path) => { + for (let depth = path.length - 1; depth > 0; depth -= 1) { + const [ancestor] = Editor.node(editor, path.slice(0, depth)) + + if (Node.isElement(ancestor) && editor.isVoid(ancestor)) { + return true + } + } + + return false +} + +const getPositionSegments = (editor: Editor, range: Range): PositionSegment[] => + projectRangeInSnapshot(Editor.getSnapshot(editor), range).map((segment) => ({ + path: segment.path, + start: segment.start, + end: segment.end, + text: Editor.string(editor, segment.path).slice(segment.start, segment.end), + })) + +const mapLogicalOffsetToPoint = ( + segments: PositionSegment[], + logicalOffset: number, + boundary: 'backward' | 'forward' = 'backward' +): Point => { + let consumed = 0 + + for (const segment of segments) { + const length = segment.text.length + const end = consumed + length + + if (logicalOffset < end) { + return { + path: segment.path, + offset: segment.start + (logicalOffset - consumed), + } + } + + if (logicalOffset === end) { + if (segment === segments.at(-1) || boundary === 'backward') { + return { + path: segment.path, + offset: segment.end, + } + } + + const next = segments[segments.indexOf(segment) + 1]! + + return { + path: next.path, + offset: next.start, + } + } + + consumed = end + } + + const last = segments.at(-1) + + if (!last) { + throw new Error('Cannot map a logical offset without text segments') + } + + return { + path: last.path, + offset: last.end, + } +} + +const groupPositionSegmentsByBlock = (segments: PositionSegment[]) => { + const groups: PositionSegment[][] = [] + + for (const segment of segments) { + const blockIndex = segment.path[0] + const lastGroup = groups.at(-1) + + if (!lastGroup || lastGroup[0]?.path[0] !== blockIndex) { + groups.push([segment]) + continue + } + + lastGroup.push(segment) + } + + return groups +} + +const collectBlockBoundaryPoints = ( + segments: PositionSegment[], + reverse = false +): Point[] => { + const points: Point[] = [] + const groups = new Map() + + segments.forEach((segment) => { + const blockIndex = segment.path[0] ?? 0 + const group = groups.get(blockIndex) ?? [] + group.push(segment) + groups.set(blockIndex, group) + }) + + const ordered = Array.from(groups.entries()).sort((left, right) => + reverse ? right[0] - left[0] : left[0] - right[0] + ) + + ordered.forEach(([, group]) => { + const first = group[0] + const last = group.at(-1) + + if (!first || !last) { + return + } + + const blockPoints = reverse + ? [ + { path: last.path, offset: last.end }, + { path: first.path, offset: first.start }, + ] + : [ + { path: first.path, offset: first.start }, + { path: last.path, offset: last.end }, + ] + + blockPoints.forEach((point) => { + const previous = points.at(-1) + + if (!previous || comparePoints(previous, point) !== 0) { + points.push(point) + } + }) + }) + + return points +} export function* positions( editor: Editor, options: EditorPositionsOptions = {} ): Generator { const { - at = editor.selection, + at = getCurrentSelection(editor) ?? [], unit = 'offset', reverse = false, voids = false, } = options - if (!at) { - return - } - - /** - * Algorithm notes: - * - * Each step `distance` is dynamic depending on the underlying text - * and the `unit` specified. Each step, e.g., a line or word, may - * span multiple text nodes, so we iterate through the text both on - * two levels in step-sync: - * - * `leafText` stores the text on a text leaf level, and is advanced - * through using the counters `leafTextOffset` and `leafTextRemaining`. - * - * `blockText` stores the text on a block level, and is shortened - * by `distance` every time it is advanced. - * - * We only maintain a window of one blockText and one leafText because - * a block node always appears before all of its leaf nodes. - */ - const range = Editor.range(editor, at) const [start, end] = Range.edges(range) - const first = reverse ? end : start - let isNewBlock = false - let blockText = '' - let distance = 0 // Distance for leafText to catch up to blockText. - let leafTextRemaining = 0 - let leafTextOffset = 0 - const skippedPaths: Path[] = [] - - // Iterate through all nodes in range, grabbing entire textual content - // of block nodes in blockText, and text nodes in leafText. - // Exploits the fact that nodes are sequenced in such a way that we first - // encounter the block node, then all of its text nodes, so when iterating - // through the blockText and leafText we just need to remember a window of - // one block node and leaf node, respectively. - for (const [node, path] of Editor.nodes(editor, { - at, - reverse, - voids, - })) { - // If the node is inside a skipped ancestor, do not return any points, but - // still process its content so that the iteration state remains correct. - const hasSkippedAncestor = skippedPaths.some((p) => - Path.isAncestor(p, path) - ) - - function* maybeYield(point: Point) { - if (!hasSkippedAncestor) { - yield point - } - } - /* - * ELEMENT NODE - Yield position(s) for voids, collect blockText for blocks - */ - if (Node.isElement(node)) { - if (!editor.isSelectable(node)) { - /** - * If the node is not selectable, skip it and its descendants - */ - skippedPaths.push(path) - if (reverse) { - if (Path.hasPrevious(path)) { - yield* maybeYield(Editor.end(editor, Path.previous(path))) - } - continue - } - const nextPath = Path.next(path) - if (Editor.hasPath(editor, nextPath)) { - yield* maybeYield(Editor.start(editor, nextPath)) - } - continue - } + if (comparePoints(start, end) === 0) { + yield { path: [...start.path], offset: start.offset } + return + } - // Void nodes are a special case, so by default we will always - // yield their first point. If the `voids` option is set to true, - // then we will iterate over their content. - if (!voids && (editor.isVoid(node) || editor.isElementReadOnly(node))) { - yield* maybeYield(Editor.start(editor, path)) - continue - } + const segments = getPositionSegments(editor, range) + .filter((segment) => segment.end >= segment.start) + .filter((segment) => voids || !isPathInsideVoid(editor, segment.path)) - // Inline element nodes are ignored as they don't themselves - // contribute to `blockText` or `leafText` - their parent and - // children do. - if (editor.isInline(node)) continue - - // Block element node - set `blockText` to its text content. - if (Editor.hasInlines(editor, node)) { - // We always exhaust block nodes before encountering a new one: - // console.assert(blockText === '', - // `blockText='${blockText}' - `+ - // `not exhausted before new block node`, path) - - // Ensure range considered is capped to `range`, in the - // start/end edge cases where block extends beyond range. - // Equivalent to this, but presumably more performant: - // blockRange = Editor.range(editor, ...Editor.edges(editor, path)) - // blockRange = Range.intersection(range, blockRange) // intersect - // blockText = Editor.string(editor, blockRange, { voids }) - const e = Path.isAncestor(path, end.path) - ? end - : Editor.end(editor, path) - const s = Path.isAncestor(path, start.path) - ? start - : Editor.start(editor, path) - - blockText = Editor.string(editor, { anchor: s, focus: e }, { voids }) - isNewBlock = true - } - } + if (segments.length === 0) { + return + } - /* - * TEXT LEAF NODE - Iterate through text content, yielding - * positions every `distance` offset according to `unit`. - */ - if (Node.isText(node)) { - const isFirst = Path.equals(path, first.path) - - // Proof that we always exhaust text nodes before encountering a new one: - // console.assert(leafTextRemaining <= 0, - // `leafTextRemaining=${leafTextRemaining} - `+ - // `not exhausted before new leaf text node`, path) - - // Reset `leafText` counters for new text node. - if (isFirst) { - leafTextRemaining = reverse - ? first.offset - : node.text.length - first.offset - leafTextOffset = first.offset // Works for reverse too. - } else { - leafTextRemaining = node.text.length - leafTextOffset = reverse ? leafTextRemaining : 0 - } + if (unit === 'block' || unit === 'line') { + yield* collectBlockBoundaryPoints(segments, reverse) + return + } - // Yield position at the start of node (potentially). - if (isFirst || isNewBlock || unit === 'offset') { - yield* maybeYield({ path, offset: leafTextOffset }) - isNewBlock = false - } + if (unit === 'offset') { + const orderedSegments = reverse ? [...segments].reverse() : segments - // Yield positions every (dynamically calculated) `distance` offset. - while (true) { - // If `leafText` has caught up with `blockText` (distance=0), - // and if blockText is exhausted, break to get another block node, - // otherwise advance blockText forward by the new `distance`. - if (distance === 0) { - if (blockText === '') break - distance = calcDistance(blockText, unit, reverse) - // Split the string at the previously found distance and use the - // remaining string for the next iteration. - blockText = splitByCharacterDistance(blockText, distance, reverse)[1] + for (const segment of orderedSegments) { + if (reverse) { + for (let offset = segment.end; offset >= segment.start; offset -= 1) { + yield { path: segment.path, offset } } - - // Advance `leafText` by the current `distance`. - leafTextOffset = reverse - ? leafTextOffset - distance - : leafTextOffset + distance - leafTextRemaining -= distance - - // If `leafText` is exhausted, break to get a new leaf node - // and set distance to the overflow amount, so we'll (maybe) - // catch up to blockText in the next leaf text node. - if (leafTextRemaining < 0) { - distance = -leafTextRemaining - break + } else { + for (let offset = segment.start; offset <= segment.end; offset += 1) { + yield { path: segment.path, offset } } - - // Successfully walked `distance` offsets through `leafText` - // to catch up with `blockText`, so we can reset `distance` - // and yield this position in this node. - distance = 0 - yield* maybeYield({ path, offset: leafTextOffset }) } } + + return } - // Proof that upon completion, we've exahusted both leaf and block text: - // console.assert(leafTextRemaining <= 0, "leafText wasn't exhausted") - // console.assert(blockText === '', "blockText wasn't exhausted") - - // Helper: - // Return the distance in offsets for a step of size `unit` on given string. - function calcDistance(text: string, unit: string, reverse?: boolean) { - if (unit === 'character') { - return getCharacterDistance(text, reverse) - } - if (unit === 'word') { - return getWordDistance(text, reverse) + + const orderedGroups = reverse + ? groupPositionSegmentsByBlock(segments).reverse() + : groupPositionSegmentsByBlock(segments) + + for (const group of orderedGroups) { + const text = group.map((segment) => segment.text).join('') + const logicalPositions = [reverse ? text.length : 0] + let consumed = 0 + + while (consumed < text.length) { + const remaining = reverse + ? text.slice(0, text.length - consumed) + : text.slice(consumed) + const distance = + unit === 'character' + ? getCharacterDistance(remaining, reverse) + : getWordDistance(remaining, reverse) + + consumed = Math.min(text.length, consumed + distance) + logicalPositions.push(reverse ? text.length - consumed : consumed) } - if (unit === 'line' || unit === 'block') { - return text.length + + const boundary = reverse ? 'forward' : 'backward' + + for (const position of logicalPositions) { + yield mapLogicalOffsetToPoint(group, position, boundary) } - return 1 } } diff --git a/packages/slate/src/editor/previous.ts b/packages/slate/src/editor/previous.ts index 9069a0d290..2fef45d70d 100644 --- a/packages/slate/src/editor/previous.ts +++ b/packages/slate/src/editor/previous.ts @@ -3,7 +3,7 @@ import { Location, type Span } from '../interfaces/location' export const previous: EditorInterface['previous'] = (editor, options = {}) => { const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection } = options + let { match, at = Editor.getSnapshot(editor).selection } = options if (!at) { return diff --git a/packages/slate/src/editor/project-range.ts b/packages/slate/src/editor/project-range.ts new file mode 100644 index 0000000000..7d18af210f --- /dev/null +++ b/packages/slate/src/editor/project-range.ts @@ -0,0 +1,5 @@ +import type { EditorInterface } from '../interfaces/editor' +import { projectRangeInSnapshot } from '../range-projection' + +export const projectRange: EditorInterface['projectRange'] = (editor, range) => + projectRangeInSnapshot(editor.getSnapshot(), range) diff --git a/packages/slate/src/editor/range-ref.ts b/packages/slate/src/editor/range-ref.ts index 677e2181f5..8004e33159 100644 --- a/packages/slate/src/editor/range-ref.ts +++ b/packages/slate/src/editor/range-ref.ts @@ -1,25 +1,140 @@ -import { Editor, type EditorInterface } from '../interfaces/editor' +import type { EditorInterface } from '../interfaces/editor' +import type { Range } from '../interfaces/range' import type { RangeRef } from '../interfaces/range-ref' +import { ALL_RANGE_REFS, RANGE_REFS } from '../utils/weak-maps' -export const rangeRef: EditorInterface['rangeRef'] = ( - editor, - range, - options = {} +type InternalRangeRef = RangeRef & { + __draftCurrent?: Range | null + __visibility?: 'public' | 'internal' +} + +const cloneRange = (range: Range | null) => + range + ? { + anchor: { + path: [...range.anchor.path], + offset: range.anchor.offset, + }, + focus: { + path: [...range.focus.path], + offset: range.focus.offset, + }, + } + : null + +const getAllRangeRefs = ( + editor: Parameters[0] +) => { + let refs = ALL_RANGE_REFS.get(editor) + + if (!refs) { + refs = new Set() + ALL_RANGE_REFS.set(editor, refs) + } + + return refs +} + +const getPublicRangeRefs = ( + editor: Parameters[0] +) => { + let refs = RANGE_REFS.get(editor) + + if (!refs) { + refs = new Set() + RANGE_REFS.set(editor, refs) + } + + return refs +} + +const createRangeRef = ( + editor: Parameters[0], + range: Range, + options: { + affinity?: RangeRef['affinity'] + visibility?: 'public' | 'internal' + } = {} ) => { - const { affinity = 'forward' } = options - const ref: RangeRef = { - current: range, + const { affinity = 'inward', visibility = 'public' } = options + const ref: InternalRangeRef = { + current: cloneRange(range), affinity, unref() { - const { current } = ref - const rangeRefs = Editor.rangeRefs(editor) - rangeRefs.delete(ref) + const current = cloneRange(ref.__draftCurrent ?? ref.current) + + getAllRangeRefs(editor).delete(ref) + + if (ref.__visibility === 'public') { + getPublicRangeRefs(editor).delete(ref) + } + + ref.__draftCurrent = null ref.current = null + return current }, } - const refs = Editor.rangeRefs(editor) - refs.add(ref) + ref.__visibility = visibility + + getAllRangeRefs(editor).add(ref) + + if (visibility === 'public') { + getPublicRangeRefs(editor).add(ref) + } + return ref } + +export const createInternalRangeRef = ( + editor: Parameters[0], + range: Range, + options: { affinity?: RangeRef['affinity'] } = {} +) => createRangeRef(editor, range, { ...options, visibility: 'internal' }) + +export const rangeRef: EditorInterface['rangeRef'] = ( + editor, + range, + options = {} +) => createRangeRef(editor, range, { ...options, visibility: 'public' }) + +export const allRangeRefs = ( + editor: Parameters[0] +) => getAllRangeRefs(editor) + +export const publishRangeRefDrafts = ( + editor: Parameters[0] +) => { + for (const ref of getAllRangeRefs(editor)) { + const internalRef = ref as InternalRangeRef + + if (internalRef.__visibility !== 'public') { + continue + } + + if (internalRef.__draftCurrent !== undefined) { + internalRef.current = cloneRange(internalRef.__draftCurrent) + internalRef.__draftCurrent = undefined + } + + if (internalRef.current == null) { + getAllRangeRefs(editor).delete(internalRef) + getPublicRangeRefs(editor).delete(internalRef) + } + } +} + +export const resetRangeRefDrafts = ( + editor: Parameters[0] +) => { + for (const ref of getAllRangeRefs(editor)) { + const internalRef = ref as InternalRangeRef + + if (internalRef.__visibility !== 'public') { + continue + } + + internalRef.__draftCurrent = undefined + } +} diff --git a/packages/slate/src/editor/remove-mark.ts b/packages/slate/src/editor/remove-mark.ts index 5e11c25a8f..f795266b31 100644 --- a/packages/slate/src/editor/remove-mark.ts +++ b/packages/slate/src/editor/remove-mark.ts @@ -1,12 +1,16 @@ +import { + getCurrentMarks, + getCurrentSelection, + withTransaction, +} from '../core/public-state' import { Editor, type EditorInterface } from '../interfaces/editor' import { Node } from '../interfaces/node' import type { Path } from '../interfaces/path' import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' -import { FLUSHING } from '../utils/weak-maps' export const removeMark: EditorInterface['removeMark'] = (editor, key) => { - const { selection } = editor + const selection = getCurrentSelection(editor) if (selection) { const match = (node: Node, path: Path) => { @@ -33,12 +37,11 @@ export const removeMark: EditorInterface['removeMark'] = (editor, key) => { voids: true, }) } else { - const marks = { ...(Editor.marks(editor) || {}) } + const marks = { ...(getCurrentMarks(editor) || {}) } delete marks[key] - editor.marks = marks - if (!FLUSHING.get(editor)) { - editor.onChange() - } + withTransaction(editor, (tx) => { + tx.setMarks(marks) + }) } } } diff --git a/packages/slate/src/editor/unhang-range.ts b/packages/slate/src/editor/unhang-range.ts index 644f9d0ac6..db97adfb81 100644 --- a/packages/slate/src/editor/unhang-range.ts +++ b/packages/slate/src/editor/unhang-range.ts @@ -42,6 +42,14 @@ export const unhangRange: EditorInterface['unhangRange'] = ( continue } + if ( + node.text === '' && + voids && + Editor.void(editor, { at: path, mode: 'highest' }) + ) { + continue + } + if (node.text !== '' || Path.isBefore(path, blockPath)) { end = { path, offset: node.text.length } break diff --git a/packages/slate/src/editor/without-normalizing.ts b/packages/slate/src/editor/without-normalizing.ts index 378ee36855..62cf9dd65a 100644 --- a/packages/slate/src/editor/without-normalizing.ts +++ b/packages/slate/src/editor/without-normalizing.ts @@ -11,5 +11,9 @@ export const withoutNormalizing: EditorInterface['withoutNormalizing'] = ( } finally { Editor.setNormalizing(editor, value) } - Editor.normalize(editor) + Editor.normalize(editor, { + explicit: false, + force: true, + operation: editor.operations.at(-1), + }) } diff --git a/packages/slate/src/index.ts b/packages/slate/src/index.ts index 1bac5ed102..814744b6ca 100644 --- a/packages/slate/src/index.ts +++ b/packages/slate/src/index.ts @@ -2,6 +2,7 @@ export * from './core' export * from './create-editor' export * from './editor' export * from './interfaces' +export * from './text-units' export * from './transforms-node' export * from './transforms-selection' export * from './transforms-text' diff --git a/packages/slate/src/interfaces/bookmark.ts b/packages/slate/src/interfaces/bookmark.ts new file mode 100644 index 0000000000..cda7b1d2ab --- /dev/null +++ b/packages/slate/src/interfaces/bookmark.ts @@ -0,0 +1,13 @@ +import type { Range } from './range' + +export type BookmarkAffinity = 'backward' | 'forward' | 'inward' + +export type Bookmark = { + affinity: BookmarkAffinity + resolve(): Range | null + unref(): Range | null +} + +export type BookmarkOptions = { + affinity?: BookmarkAffinity +} diff --git a/packages/slate/src/interfaces/editor.ts b/packages/slate/src/interfaces/editor.ts index ffec83cbc5..5882fb2c17 100644 --- a/packages/slate/src/interfaces/editor.ts +++ b/packages/slate/src/interfaces/editor.ts @@ -1,5 +1,7 @@ import type { Ancestor, + Bookmark, + BookmarkOptions, Descendant, Element, ExtendedType, @@ -49,8 +51,10 @@ export interface BaseEditor { // Overrideable core methods. apply: (operation: Operation) => void + getChildren: () => Descendant[] getDirtyPaths: (operation: Operation) => Path[] getFragment: () => Descendant[] + getSnapshot: () => EditorSnapshot isElementReadOnly: (element: Element) => boolean isSelectable: (element: Element) => boolean markableVoid: (element: Element) => boolean @@ -58,25 +62,26 @@ export interface BaseEditor { entry: NodeEntry, options?: { operation?: Operation - fallbackElement?: () => Element + fallbackElement?: Element | (() => Element) + explicit?: boolean force?: boolean } ) => void onChange: (options?: { operation?: Operation }) => void shouldNormalize: ({ + explicit, iteration, - dirtyPaths, operation, }: { + explicit?: boolean iteration: number - initialDirtyPathsLength: number - dirtyPaths: Path[] operation?: Operation }) => boolean // Overrideable core transforms. addMark: OmitFirstArg + bookmark: (range: Range, options?: BookmarkOptions) => Bookmark collapse: OmitFirstArg delete: OmitFirstArg deleteBackward: (unit: TextUnit) => void @@ -167,13 +172,19 @@ export interface BaseEditor { previous: ( options?: EditorPreviousOptions ) => NodeEntry | undefined + projectRange: (range: Range) => readonly ProjectedRangeSegment[] range: OmitFirstArg rangeRef: OmitFirstArg rangeRefs: OmitFirstArg + replace: (input: SnapshotInput) => void + reset: (input: SnapshotInput) => void + setChildren: (children: Descendant[]) => void start: OmitFirstArg string: OmitFirstArg + subscribe: (listener: SnapshotListener) => () => void unhangRange: OmitFirstArg void: OmitFirstArg + withTransaction: (fn: () => void) => void shouldMergeNodesRemovePrevNode: OmitFirstArg< typeof Editor.shouldMergeNodesRemovePrevNode > @@ -187,6 +198,60 @@ export type Selection = ExtendedType<'Selection', BaseSelection> export type EditorMarks = Omit +export type RuntimeId = string + +export type SnapshotIndex = { + idToPath: Record + pathToId: Record +} + +export type ProjectedRangeSegment = { + path: Path + runtimeId: RuntimeId + start: number + end: number +} + +export type EditorSnapshot = { + children: Descendant[] + index: SnapshotIndex + marks: EditorMarks | null + selection: Selection + version: number +} + +export type SnapshotChangeClass = + | 'text' + | 'selection' + | 'mark' + | 'structural' + | 'replace' + +export type SnapshotDirtyScope = 'none' | 'paths' | 'all' + +export type SnapshotInput = { + children: readonly Descendant[] + selection?: Selection + marks?: EditorMarks | null +} + +export type SnapshotListener = ( + snapshot: EditorSnapshot, + change?: SnapshotChange +) => void + +export type SnapshotChange = { + childrenChanged: boolean + classes: readonly SnapshotChangeClass[] + dirtyPaths: readonly Path[] + dirtyScope: SnapshotDirtyScope + marksChanged: boolean + operations: readonly Operation[] + replaceEpoch: number + selectionChanged: boolean + touchedRuntimeIds: readonly RuntimeId[] | null +} + export interface EditorAboveOptions { at?: Location match?: NodeMatch @@ -259,6 +324,7 @@ export interface EditorNodesOptions { } export interface EditorNormalizeOptions { + explicit?: boolean force?: boolean operation?: Operation } @@ -334,6 +400,15 @@ export interface EditorInterface { */ addMark: (editor: Editor, key: string, value: any) => void + /** + * Create a hidden, op-rebased bookmark for a range. + */ + bookmark: ( + editor: Editor, + range: Range, + options?: BookmarkOptions + ) => Bookmark + /** * Get the point after a location. */ @@ -399,11 +474,31 @@ export interface EditorInterface { */ first: (editor: Editor, at: Location) => NodeEntry + /** + * Get the current children through the public accessor seam. + */ + getChildren: (editor: Editor) => Descendant[] + /** * Get the fragment at a location. */ fragment: (editor: Editor, at: Location) => Descendant[] + /** + * Get the current dirty-path derivation for an operation. + */ + getDirtyPaths: (editor: Editor, operation: Operation) => Path[] + + /** + * Get the fragment at the current selection. + */ + getFragment: (editor: Editor) => Descendant[] + + /** + * Get the current immutable snapshot of editor state. + */ + getSnapshot: (editor: Editor) => EditorSnapshot + /** * Check if a node has block children. */ @@ -622,6 +717,11 @@ export interface EditorInterface { */ pointRefs: (editor: Editor) => Set + projectRange: ( + editor: Editor, + range: Range + ) => readonly ProjectedRangeSegment[] + /** * Return all the positions in `at` range where a `Point` can be placed. * @@ -667,6 +767,12 @@ export interface EditorInterface { */ rangeRefs: (editor: Editor) => Set + replace: (editor: Editor, input: SnapshotInput) => void + + reset: (editor: Editor, input: SnapshotInput) => void + + setChildren: (editor: Editor, children: Descendant[]) => void + /** * Remove a custom property from all of the leaf text nodes in the current * selection. @@ -701,6 +807,8 @@ export interface EditorInterface { options?: EditorStringOptions ) => string + subscribe: (editor: Editor, listener: SnapshotListener) => () => void + /** * Convert a range into a non-hanging one. */ @@ -718,6 +826,8 @@ export interface EditorInterface { options?: EditorVoidOptions ) => NodeEntry | undefined + withTransaction: (editor: Editor, fn: () => void) => void + /** * Call a function, deferring normalization until after it completes. */ @@ -743,6 +853,10 @@ export const Editor: EditorInterface = { editor.addMark(key, value) }, + bookmark(editor, range, options) { + return editor.bookmark(range, options) + }, + after(editor, at, options) { return editor.after(at, options) }, @@ -785,6 +899,22 @@ export const Editor: EditorInterface = { return editor.fragment(at) }, + getFragment(editor) { + return editor.getFragment() + }, + + getChildren(editor) { + return editor.getChildren() + }, + + getDirtyPaths(editor, operation) { + return editor.getDirtyPaths(operation) + }, + + getSnapshot(editor) { + return editor.getSnapshot() + }, + hasBlocks(editor, element) { return editor.hasBlocks(element) }, @@ -926,6 +1056,10 @@ export const Editor: EditorInterface = { return editor.pointRefs() }, + projectRange(editor, range) { + return editor.projectRange(range) + }, + positions(editor, options) { return editor.positions(options) }, @@ -946,10 +1080,22 @@ export const Editor: EditorInterface = { return editor.rangeRefs() }, + replace(editor, input) { + editor.replace(input) + }, + + reset(editor, input) { + editor.reset(input) + }, + removeMark(editor, key) { editor.removeMark(key) }, + setChildren(editor, children) { + editor.setChildren(children) + }, + setNormalizing(editor, isNormalizing) { editor.setNormalizing(isNormalizing) }, @@ -962,6 +1108,10 @@ export const Editor: EditorInterface = { return editor.string(at, options) }, + subscribe(editor, listener) { + return editor.subscribe(listener) + }, + unhangRange(editor, range, options) { return editor.unhangRange(range, options) }, @@ -970,6 +1120,10 @@ export const Editor: EditorInterface = { return editor.void(options) }, + withTransaction(editor, fn: () => void) { + editor.withTransaction(fn) + }, + withoutNormalizing(editor, fn: () => void) { editor.withoutNormalizing(fn) }, diff --git a/packages/slate/src/interfaces/index.ts b/packages/slate/src/interfaces/index.ts index d5bba56542..e784b6365b 100644 --- a/packages/slate/src/interfaces/index.ts +++ b/packages/slate/src/interfaces/index.ts @@ -1,3 +1,4 @@ +export * from './bookmark' export * from './editor' export * from './element' export * from './location' diff --git a/packages/slate/src/interfaces/range-ref.ts b/packages/slate/src/interfaces/range-ref.ts index fdf69592a6..13ff993a48 100644 --- a/packages/slate/src/interfaces/range-ref.ts +++ b/packages/slate/src/interfaces/range-ref.ts @@ -22,16 +22,26 @@ export interface RangeRefInterface { // eslint-disable-next-line no-redeclare export const RangeRef: RangeRefInterface = { transform(ref: RangeRef, op: Operation): void { - const { current, affinity } = ref + const internalRef = ref as RangeRef & { + __draftCurrent?: Range | null + __visibility?: 'public' | 'internal' + } + const current = internalRef.__draftCurrent ?? ref.current + const { affinity } = ref if (current == null) { return } - const path = Range.transform(current, op, { affinity }) - ref.current = path + const next = Range.transform(current, op, { affinity }) + + if (internalRef.__visibility === 'public') { + internalRef.__draftCurrent = next + } else { + ref.current = next + } - if (path == null) { + if (next == null) { ref.unref() } }, diff --git a/packages/slate/src/interfaces/transforms/general.ts b/packages/slate/src/interfaces/transforms/general.ts index dd7d9457fc..a505f01be2 100644 --- a/packages/slate/src/interfaces/transforms/general.ts +++ b/packages/slate/src/interfaces/transforms/general.ts @@ -1,3 +1,7 @@ +import { + getCurrentSelection, + setCurrentSelection, +} from '../../core/public-state' import { type Descendant, type Editor, @@ -19,6 +23,7 @@ import { removeChildren, replaceChildren, } from '../../utils/modify' +import { inheritRuntimeId } from '../../utils/runtime-ids' /** * The set of properties that cannot be set using set_node. @@ -41,13 +46,23 @@ export interface GeneralTransforms { /** * Transform the editor by an operation. */ + applyBatch: (editor: Editor, operations: Operation[]) => void transform: (editor: Editor, op: Operation) => void } // eslint-disable-next-line no-redeclare export const GeneralTransforms: GeneralTransforms = { + applyBatch(editor: Editor, operations: Operation[]): void { + editor.withTransaction(() => { + for (const operation of operations) { + editor.apply(operation) + } + }) + }, + transform(editor: Editor, op: Operation): void { let transformSelection = false + let selectionTransformOp = op switch (op.type) { case 'insert_node': { @@ -120,10 +135,20 @@ export const GeneralTransforms: GeneralTransforms = { ) } + inheritRuntimeId(newNode, prev) + return replaceChildren(children, prevIndex, 2, newNode) }) - transformSelection = true + if (getCurrentSelection(editor)) { + const selection = getCurrentSelection(editor) + + if (selection) { + const nextSelection = Range.transform(selection, op) + + setCurrentSelection(editor, nextSelection) + } + } break } @@ -131,6 +156,10 @@ export const GeneralTransforms: GeneralTransforms = { const { path, newPath } = op const index = path.at(-1)! + if (Path.equals(path, newPath)) { + break + } + if (Path.isAncestor(path, newPath)) { throw new Error( `Cannot move a path [${path}] to new path [${newPath}] because the destination is inside itself.` @@ -147,10 +176,25 @@ export const GeneralTransforms: GeneralTransforms = { // 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)! + // `newPath` is expressed against the pre-removal tree. When the moved + // node is before that destination, compute the effective post-removal + // insertion path first. + const sameParentForwardMove = + path.length === newPath.length && + path.at(-1) != null && + newPath.at(-1) != null && + Path.equals(path.slice(0, -1), newPath.slice(0, -1)) && + path.at(-1)! < newPath.at(-1)! + + const truePath = sameParentForwardMove + ? newPath + : Path.transform(newPath, { + type: 'remove_node', + path, + node, + })! const newIndex = truePath.at(-1)! + selectionTransformOp = { ...op, newPath: truePath } modifyChildren(editor, Path.parent(truePath), (children) => insertChildren(children, newIndex, node) @@ -170,8 +214,10 @@ export const GeneralTransforms: GeneralTransforms = { // 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 } + const currentSelection = getCurrentSelection(editor) + + if (currentSelection) { + let selection: Selection = { ...currentSelection } for (const [point, key] of Range.points(selection)) { const result = Point.transform(point, op) @@ -214,8 +260,8 @@ export const GeneralTransforms: GeneralTransforms = { } } - if (!selection || !Range.equals(selection, editor.selection)) { - editor.selection = selection + if (!selection || !Range.equals(selection, currentSelection)) { + setCurrentSelection(editor, selection) } } @@ -253,6 +299,9 @@ export const GeneralTransforms: GeneralTransforms = { for (const key in newProperties) { if (!Object.hasOwn(newProperties, key)) continue if (NON_SETTABLE_NODE_PROPERTIES.includes(key)) { + if (key === 'children') { + throw new Error('set_node does not update child content') + } throw new Error(`Cannot set the "${key}" property of nodes!`) } @@ -293,24 +342,26 @@ export const GeneralTransforms: GeneralTransforms = { const { newProperties } = op if (newProperties == null) { - editor.selection = null + setCurrentSelection(editor, null) break } - if (editor.selection == null) { + const currentSelection = getCurrentSelection(editor) + + if (currentSelection == null) { if (!(newProperties.anchor && newProperties.focus)) { throw new Error( - `Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify( + `set_selection patch requires an existing selection or a full range. Received: ${Scrubber.stringify( newProperties - )} when there is no current selection.` + )}` ) } - editor.selection = { ...(newProperties as Range) } + setCurrentSelection(editor, newProperties as Range) break } - const selection = { ...editor.selection } + const selection = { ...currentSelection } for (const key in newProperties) { if (!Object.hasOwn(newProperties, key)) continue @@ -320,18 +371,12 @@ export const GeneralTransforms: GeneralTransforms = { ) } - const value = newProperties[key] - - // Make sure we're not setting `then` to a function, since this will - // cause the selection to be treated as a Promise-like object, which - // can cause unexpected behaviour when returning the selection from - // async functions. - if (key === 'then' && typeof value === 'function') { - throw new Error( - 'Cannot set the "then" property of the selection to a function' - ) + if (key !== 'anchor' && key !== 'focus') { + continue } + const value = newProperties[key] + if (value == null) { if (key === 'anchor' || key === 'focus') { throw new Error(`Cannot remove the "${key}" selection property`) @@ -343,7 +388,7 @@ export const GeneralTransforms: GeneralTransforms = { } } - editor.selection = selection + setCurrentSelection(editor, selection) break } @@ -374,6 +419,7 @@ export const GeneralTransforms: GeneralTransforms = { text: before, } nextNode = { + ...properties, text: after, } } else { @@ -384,10 +430,19 @@ export const GeneralTransforms: GeneralTransforms = { children: before, } nextNode = { + ...(Object.hasOwn(properties, 'type') && + typeof (properties as { type?: unknown }).type === 'string' + ? { type: (properties as { type: string }).type } + : Object.hasOwn(node, 'type') + ? { type: (node as { type?: unknown }).type } + : {}), + ...properties, children: after, } } + inheritRuntimeId(newNode, node) + for (const key in properties) { if (!Object.hasOwn(properties, key)) continue if (NON_SETTABLE_NODE_PROPERTIES.includes(key)) { @@ -414,20 +469,32 @@ export const GeneralTransforms: GeneralTransforms = { return replaceChildren(children, index, 1, newNode, nextNode) }) - transformSelection = true + if (getCurrentSelection(editor)) { + const selection = getCurrentSelection(editor) + + if (selection) { + const nextSelection = Range.transform(selection, op, { + affinity: 'inward', + }) + + setCurrentSelection(editor, nextSelection) + } + } break } } - if (transformSelection && editor.selection) { - const selection = { ...editor.selection } + const currentSelection = getCurrentSelection(editor) + + if (transformSelection && currentSelection) { + const selection = { ...currentSelection } for (const [point, key] of Range.points(selection)) { - selection[key] = Point.transform(point, op)! + selection[key] = Point.transform(point, selectionTransformOp)! } - if (!Range.equals(selection, editor.selection)) { - editor.selection = selection + if (!Range.equals(selection, currentSelection)) { + setCurrentSelection(editor, selection) } } }, diff --git a/packages/slate/src/interfaces/transforms/index.ts b/packages/slate/src/interfaces/transforms/index.ts index aa137bf794..a4e0d2c0c1 100644 --- a/packages/slate/src/interfaces/transforms/index.ts +++ b/packages/slate/src/interfaces/transforms/index.ts @@ -1,3 +1,8 @@ +export { GeneralTransforms } from './general' +export { NodeTransforms } from './node' +export { SelectionTransforms } from './selection' +export { TextTransforms } from './text' + import { GeneralTransforms } from './general' import { NodeTransforms } from './node' import { SelectionTransforms } from './selection' diff --git a/packages/slate/src/interfaces/transforms/node.ts b/packages/slate/src/interfaces/transforms/node.ts index c788723473..e3ac2273a1 100644 --- a/packages/slate/src/interfaces/transforms/node.ts +++ b/packages/slate/src/interfaces/transforms/node.ts @@ -109,6 +109,7 @@ export interface NodeTransforms { mode?: RangeMode always?: boolean height?: number + position?: number voids?: boolean } ) => void diff --git a/packages/slate/src/interfaces/transforms/text.ts b/packages/slate/src/interfaces/transforms/text.ts index ece4e05e81..c846c49fbf 100644 --- a/packages/slate/src/interfaces/transforms/text.ts +++ b/packages/slate/src/interfaces/transforms/text.ts @@ -23,6 +23,10 @@ export interface TextInsertTextOptions { voids?: boolean } +export interface TextRemoveTextOptions { + at?: { path: number[]; offset: number } +} + export interface TextTransforms { /** * Delete content in the editor. @@ -48,6 +52,15 @@ export interface TextTransforms { text: string, options?: TextInsertTextOptions ) => void + + /** + * Remove a string of text at a point or the current selection anchor. + */ + removeText: ( + editor: Editor, + text: string, + options?: TextRemoveTextOptions + ) => void } // eslint-disable-next-line no-redeclare @@ -63,8 +76,31 @@ export const TextTransforms: TextTransforms = { text: string, options: TextInsertTextOptions = {} ): void { + const { voids = false } = options + const defaultAt = options.at ?? getDefaultInsertLocation(editor) + const preflightAt = (() => { + if (Location.isPath(defaultAt)) { + return Editor.range(editor, defaultAt) + } + + if (Location.isRange(defaultAt) && Range.isCollapsed(defaultAt)) { + return defaultAt.anchor + } + + return defaultAt + })() + + if ( + Location.isPoint(preflightAt) && + ((!voids && Editor.void(editor, { at: preflightAt })) || + Editor.elementReadOnly(editor, { at: preflightAt })) + ) { + return + } + Editor.withoutNormalizing(editor, () => { - const { voids = false } = options + const preserveNullSelection = + options.at != null && editor.selection == null let { at = getDefaultInsertLocation(editor) } = options if (Location.isPath(at)) { @@ -87,7 +123,12 @@ export const TextTransforms: TextTransforms = { const endPoint = endRef.unref() at = startPoint || endPoint! - Transforms.setSelection(editor, { anchor: at, focus: at }) + + if (options.at == null) { + Transforms.setSelection(editor, { anchor: at, focus: at }) + } else if (preserveNullSelection) { + Transforms.deselect(editor) + } } } @@ -103,4 +144,24 @@ export const TextTransforms: TextTransforms = { editor.apply({ type: 'insert_text', path, offset, text }) }) }, + removeText( + editor: Editor, + text: string, + options: TextRemoveTextOptions = {} + ) { + const point = options.at ?? Editor.getSnapshot(editor).selection?.anchor + + if (!point) { + throw new Error( + 'removeText requires a location when the editor has no selection' + ) + } + + editor.apply({ + type: 'remove_text', + path: point.path, + offset: point.offset, + text, + }) + }, } diff --git a/packages/slate/src/range-projection.ts b/packages/slate/src/range-projection.ts new file mode 100644 index 0000000000..17dba2deb3 --- /dev/null +++ b/packages/slate/src/range-projection.ts @@ -0,0 +1,373 @@ +import type { + Descendant, + EditorSnapshot, + Path, + Point, + ProjectedRangeSegment, + Range, + RuntimeId, + Text, +} from './interfaces' + +type TextEntry = { + readonly path: Path + readonly runtimeId: RuntimeId + readonly text: string +} + +type RangeProjectionIndex = { + readonly textEntries: readonly TextEntry[] + readonly textIndexByPath: Readonly> +} + +const pathKey = (path: Path) => path.join('.') + +const isText = (value: Descendant): value is Text => + typeof (value as Text).text === 'string' + +const clonePath = (path: Path): Path => Object.freeze([...path]) as Path + +const comparePaths = (left: Path, right: Path): number => { + const length = Math.min(left.length, right.length) + + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) { + return left[index]! < right[index]! ? -1 : 1 + } + } + + if (left.length === right.length) { + return 0 + } + + return left.length < right.length ? -1 : 1 +} + +const comparePoints = (left: Point, right: Point): number => { + const pathComparison = comparePaths(left.path, right.path) + + if (pathComparison !== 0) { + return pathComparison + } + + if (left.offset === right.offset) { + return 0 + } + + return left.offset < right.offset ? -1 : 1 +} + +const getRangeEdges = (range: Range): [Point, Point] => + comparePoints(range.anchor, range.focus) <= 0 + ? [range.anchor, range.focus] + : [range.focus, range.anchor] + +const rangeProjectionIndexCache = new WeakMap< + EditorSnapshot, + RangeProjectionIndex +>() + +const collectTextEntries = ( + snapshot: EditorSnapshot, + children: readonly Descendant[] = snapshot.children, + parentPath: Path = [] +): TextEntry[] => { + const entries: TextEntry[] = [] + + children.forEach((node, index) => { + const path = [...parentPath, index] as Path + + if (isText(node)) { + const runtimeId = snapshot.index.pathToId[pathKey(path)] + + if (!runtimeId) { + throw new Error(`Missing runtime id for text path ${pathKey(path)}`) + } + + entries.push({ + path: clonePath(path), + runtimeId, + text: node.text, + }) + return + } + + entries.push(...collectTextEntries(snapshot, node.children, path)) + }) + + return entries +} + +const getTextEntryAtPath = ( + snapshot: EditorSnapshot, + path: Path +): TextEntry | null => { + let current: Descendant | undefined + let children = snapshot.children + + for (const segment of path) { + current = children[segment] + + if (!current) { + return null + } + + if (isText(current)) { + children = [] + continue + } + + children = current.children + } + + if (!current || !isText(current)) { + return null + } + + const runtimeId = snapshot.index.pathToId[pathKey(path)] + + if (!runtimeId) { + throw new Error(`Missing runtime id for text path ${pathKey(path)}`) + } + + return { + path: clonePath(path), + runtimeId, + text: current.text, + } +} + +const getTopLevelBlockTextEntries = ( + snapshot: EditorSnapshot, + blockIndex: number +): readonly TextEntry[] => { + const block = snapshot.children[blockIndex] + + if (!block) { + return Object.freeze([]) as readonly TextEntry[] + } + + if (isText(block)) { + const runtimeId = snapshot.index.pathToId[pathKey([blockIndex])] + + if (!runtimeId) { + throw new Error(`Missing runtime id for text path ${blockIndex}`) + } + + return Object.freeze([ + { + path: Object.freeze([blockIndex]) as Path, + runtimeId, + text: block.text, + }, + ]) + } + + return Object.freeze( + collectTextEntries(snapshot, block.children, [blockIndex]) + ) +} + +const getRangeProjectionIndex = ( + snapshot: EditorSnapshot +): RangeProjectionIndex => { + const cached = rangeProjectionIndexCache.get(snapshot) + + if (cached) { + return cached + } + + const textEntries = Object.freeze(collectTextEntries(snapshot)) + const textIndexByPath = Object.freeze( + textEntries.reduce( + (acc, entry, index) => { + acc[pathKey(entry.path)] = index + return acc + }, + Object.create(null) as Record + ) + ) + const index = Object.freeze({ + textEntries, + textIndexByPath, + }) + + rangeProjectionIndexCache.set(snapshot, index) + + return index +} + +const assertValidPoint = (entry: TextEntry, point: Point) => { + if (point.offset < 0 || point.offset > entry.text.length) { + throw new Error( + `Point offset ${point.offset} is outside text bounds for ${pathKey(entry.path)}` + ) + } +} + +export const projectRangeInSnapshot = ( + snapshot: EditorSnapshot, + range: Range +): readonly ProjectedRangeSegment[] => { + const [start, end] = getRangeEdges(range) + + if (comparePaths(start.path, end.path) === 0) { + const entry = getTextEntryAtPath(snapshot, start.path) + + if (!entry) { + throw new Error('Cannot project a range outside the committed snapshot') + } + + assertValidPoint(entry, start) + assertValidPoint(entry, end) + + return Object.freeze([ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: start.offset, + end: end.offset, + }), + ]) + } + + if (start.path[0] != null && start.path[0] === end.path[0]) { + const entries = getTopLevelBlockTextEntries(snapshot, start.path[0]) + const startEntry = entries.find( + (entry) => comparePaths(entry.path, start.path) === 0 + ) + const endEntry = entries.find( + (entry) => comparePaths(entry.path, end.path) === 0 + ) + + if (!startEntry || !endEntry) { + throw new Error('Cannot project a range outside the committed snapshot') + } + + assertValidPoint(startEntry, start) + assertValidPoint(endEntry, end) + + return Object.freeze( + entries.flatMap((entry) => { + const comparedToStart = comparePaths(entry.path, start.path) + const comparedToEnd = comparePaths(entry.path, end.path) + + if (comparedToStart < 0 || comparedToEnd > 0) { + return [] + } + + if (comparedToStart === 0 && comparedToEnd === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: start.offset, + end: end.offset, + }), + ] + } + + if (comparedToStart === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: start.offset, + end: entry.text.length, + }), + ] + } + + if (comparedToEnd === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: 0, + end: end.offset, + }), + ] + } + + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: 0, + end: entry.text.length, + }), + ] + }) + ) + } + + const index = getRangeProjectionIndex(snapshot) + const startIndex = index.textIndexByPath[pathKey(start.path)] + const endIndex = index.textIndexByPath[pathKey(end.path)] + const startEntry = + startIndex == null ? null : (index.textEntries[startIndex] ?? null) + const endEntry = + endIndex == null ? null : (index.textEntries[endIndex] ?? null) + + if (!startEntry || !endEntry) { + throw new Error('Cannot project a range outside the committed snapshot') + } + + assertValidPoint(startEntry, start) + assertValidPoint(endEntry, end) + + const segments = index.textEntries + .slice(startIndex, endIndex + 1) + .flatMap((entry) => { + const comparedToStart = comparePaths(entry.path, start.path) + const comparedToEnd = comparePaths(entry.path, end.path) + + if (comparedToStart < 0 || comparedToEnd > 0) { + return [] + } + + if (comparedToStart === 0 && comparedToEnd === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: start.offset, + end: end.offset, + }), + ] + } + + if (comparedToStart === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: start.offset, + end: entry.text.length, + }), + ] + } + + if (comparedToEnd === 0) { + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: 0, + end: end.offset, + }), + ] + } + + return [ + Object.freeze({ + runtimeId: entry.runtimeId, + path: entry.path, + start: 0, + end: entry.text.length, + }), + ] + }) + + return Object.freeze(segments) +} diff --git a/packages/slate/src/selection-operation.ts b/packages/slate/src/selection-operation.ts new file mode 100644 index 0000000000..3cafc1581f --- /dev/null +++ b/packages/slate/src/selection-operation.ts @@ -0,0 +1,42 @@ +import type { SetSelectionOperation } from './interfaces/operation' +import type { Range } from './interfaces/range' + +const clonePoint = (point: Range['anchor']) => ({ + path: [...point.path], + offset: point.offset, +}) + +const cloneRange = (range: Range | null): Range | null => + range + ? { + anchor: clonePoint(range.anchor), + focus: clonePoint(range.focus), + } + : null + +export const createSetSelectionOperation = ( + previous: Range | null, + next: Range | null +): SetSelectionOperation => { + if (previous == null) { + return { + type: 'set_selection', + properties: null, + newProperties: cloneRange(next)!, + } + } + + if (next == null) { + return { + type: 'set_selection', + properties: cloneRange(previous)!, + newProperties: null, + } + } + + return { + type: 'set_selection', + properties: cloneRange(previous)!, + newProperties: cloneRange(next)!, + } +} diff --git a/packages/slate/src/text-units.ts b/packages/slate/src/text-units.ts new file mode 100644 index 0000000000..94d273d5ba --- /dev/null +++ b/packages/slate/src/text-units.ts @@ -0,0 +1 @@ +export { getCharacterDistance, getWordDistance } from './utils/string' diff --git a/packages/slate/src/transforms-node/insert-nodes.ts b/packages/slate/src/transforms-node/insert-nodes.ts index ce03371b69..29cf782129 100644 --- a/packages/slate/src/transforms-node/insert-nodes.ts +++ b/packages/slate/src/transforms-node/insert-nodes.ts @@ -1,11 +1,14 @@ import { batchDirtyPaths } from '../core/batch-dirty-paths' import { updateDirtyPaths } from '../core/update-dirty-paths' -import { type BaseInsertNodeOperation, Location } from '../interfaces' -import { Editor } from '../interfaces/editor' -import { Node } from '../interfaces/node' -import { Path } from '../interfaces/path' -import { Range } from '../interfaces/range' -import { Transforms } from '../interfaces/transforms' +import { + type BaseInsertNodeOperation, + Editor, + Location, + Node, + Path, + Range, + Transforms, +} from '../interfaces' import type { NodeTransforms } from '../interfaces/transforms/node' import { getDefaultInsertLocation } from '../utils' @@ -22,13 +25,14 @@ export const insertNodes: NodeTransforms['insertNodes'] = ( batchDirty = true, } = options let { at, match, select } = options - const targetNodes = Node.isNode(nodes) ? [nodes] : nodes - if (targetNodes.length === 0) { + const nextNodes = Node.isNode(nodes) ? [nodes] : nodes + + if (nextNodes.length === 0) { return } - const [node] = targetNodes + const [node] = nextNodes if (!at) { at = getDefaultInsertLocation(editor) @@ -74,16 +78,16 @@ export const insertNodes: NodeTransforms['insertNodes'] = ( voids, }) - if (entry) { - const [, matchPath] = entry - const pathRef = Editor.pathRef(editor, matchPath) - const isAtEnd = Editor.isEnd(editor, at, matchPath) - Transforms.splitNodes(editor, { at, match, mode, voids }) - const path = pathRef.unref()! - at = isAtEnd ? Path.next(path) : path - } else { + if (!entry) { return } + + const [, matchPath] = entry + const pathRef = Editor.pathRef(editor, matchPath) + const isAtEnd = Editor.isEnd(editor, at, matchPath) + Transforms.splitNodes(editor, { at, match, mode, voids }) + const path = pathRef.unref()! + at = isAtEnd ? Path.next(path) : path } const parentPath = Path.parent(at) @@ -94,56 +98,59 @@ export const insertNodes: NodeTransforms['insertNodes'] = ( } if (batchDirty) { - // PERF: batch update dirty paths - // batched ops used to transform existing dirty paths const batchedOps: BaseInsertNodeOperation[] = [] const newDirtyPaths: Path[] = Path.levels(parentPath) + batchDirtyPaths( editor, () => { - for (const node of targetNodes as Node[]) { + for (const child of nextNodes as Node[]) { const path = parentPath.concat(index) index++ const op: BaseInsertNodeOperation = { type: 'insert_node', path, - node, + node: child, } + editor.apply(op) at = Path.next(at as Path) - batchedOps.push(op) - if (Node.isText(node)) { + + if (Node.isText(child)) { newDirtyPaths.push(path) } else { newDirtyPaths.push( - ...Array.from(Node.nodes(node), ([, p]) => path.concat(p)) + ...Array.from(Node.nodes(child), ([, childPath]) => + path.concat(childPath) + ) ) } } }, () => { - updateDirtyPaths(editor, newDirtyPaths, (p) => { - let newPath: Path | null = p + updateDirtyPaths(editor, newDirtyPaths, (path) => { + let nextPath: Path | null = path + for (const op of batchedOps) { - if (Path.operationCanTransformPath(op)) { - newPath = Path.transform(newPath, op) - if (!newPath) { - return null - } + nextPath = Path.transform(nextPath, op) + + if (!nextPath) { + return null } } - return newPath + + return nextPath }) } ) } else { - for (const node of targetNodes as Node[]) { + for (const child of nextNodes as Node[]) { const path = parentPath.concat(index) index++ - editor.apply({ type: 'insert_node', path, node }) + editor.apply({ type: 'insert_node', path, node: child }) at = Path.next(at as Path) } } diff --git a/packages/slate/src/transforms-node/lift-nodes.ts b/packages/slate/src/transforms-node/lift-nodes.ts index f6546da7fc..50fa6ab9c7 100644 --- a/packages/slate/src/transforms-node/lift-nodes.ts +++ b/packages/slate/src/transforms-node/lift-nodes.ts @@ -1,6 +1,6 @@ -import { Location } from '../interfaces' +import { getCurrentSelection, withTransaction } from '../core/public-state' +import { Location, Node, Range } from '../interfaces' import { Editor } from '../interfaces/editor' -import { type Ancestor, Node, type NodeEntry } from '../interfaces/node' import { Path } from '../interfaces/path' import { Transforms } from '../interfaces/transforms' import type { NodeTransforms } from '../interfaces/transforms/node' @@ -10,52 +10,152 @@ export const liftNodes: NodeTransforms['liftNodes'] = ( editor, options = {} ) => { - Editor.withoutNormalizing(editor, () => { - const { at = editor.selection, mode = 'lowest', voids = false } = options - let { match } = options + const liftNodeAtPath = (path: Path) => { + const [node] = Editor.node(editor, path) + + if (Node.isText(node)) { + throw new Error('liftNodes currently supports only element nodes') + } + + if (path.length < 2) { + throw new Error('liftNodes requires a path with depth of at least 2') + } - if (!at) { + const parentPath = path.slice(0, -1) + const [parent] = Editor.node(editor, parentPath) + + if (Node.isText(parent)) { + throw new Error('liftNodes requires an element parent') + } + + const index = path.at(-1)! + const childCount = parent.children.length + + if (childCount === 1) { + Transforms.moveNodes(editor, { + at: path, + to: [...parentPath.slice(0, -1), parentPath.at(-1)! + 1], + }) + Transforms.removeNodes(editor, { at: parentPath }) return } - if (match == null) { - match = Location.isPath(at) - ? matchPath(editor, at) - : (n) => Node.isElement(n) && Editor.isBlock(editor, n) + if (index === 0) { + Transforms.moveNodes(editor, { + at: path, + to: parentPath, + }) + return } - const matches = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) + if (index === childCount - 1) { + Transforms.moveNodes(editor, { + at: path, + to: [...parentPath.slice(0, -1), parentPath.at(-1)! + 1], + }) + return + } + + editor.apply({ + type: 'split_node', + path: parentPath, + position: index + 1, + properties: Path.equals(parentPath, []) ? {} : Node.extractProps(parent), + }) - for (const pathRef of pathRefs) { - const path = pathRef.unref()! + Transforms.moveNodes(editor, { + at: path, + to: [...parentPath.slice(0, -1), parentPath.at(-1)! + 1], + }) + } + + withTransaction(editor, () => { + const target = options.at ?? getCurrentSelection(editor) + const selectionBefore = getCurrentSelection(editor) + const mode = options.mode ?? 'lowest' + const voids = options.voids ?? false + let { match } = options - if (path.length < 2) { - throw new Error( - `Cannot lift node at a path [${path}] because it has a depth of less than \`2\`.` - ) + if (!target) { + return + } + + if (match != null || !Location.isRange(target)) { + if (match == null) { + match = Location.isPath(target) + ? matchPath(editor, target) + : (node) => !Node.isText(node) && Editor.isBlock(editor, node) } - const parentNodeEntry = Editor.node(editor, Path.parent(path)) - const [parent, parentPath] = parentNodeEntry as NodeEntry - const index = path.at(-1)! - const { length } = parent.children - - if (length === 1) { - const toPath = Path.next(parentPath) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) - Transforms.removeNodes(editor, { at: parentPath, voids }) - } else if (index === 0) { - Transforms.moveNodes(editor, { at: path, to: parentPath, voids }) - } else if (index === length - 1) { - const toPath = Path.next(parentPath) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) - } else { - const splitPath = Path.next(path) - const toPath = Path.next(parentPath) - Transforms.splitNodes(editor, { at: splitPath, voids }) - Transforms.moveNodes(editor, { at: path, to: toPath, voids }) + if (Location.isPath(target) && options.match == null) { + liftNodeAtPath(target) + + if (selectionBefore == null) { + editor.deselect() + } + + return + } + + const pathRefs = Array.from( + Editor.nodes(editor, { at: target, match, mode, voids }), + ([, path]) => Editor.pathRef(editor, path) + ) + + for (const pathRef of pathRefs) { + const path = pathRef.unref() + + if (path) { + liftNodeAtPath(path) + } } + + return + } + + const [start, end] = Range.edges(target) + const startChildPath = start.path.slice(0, -1) + const endChildPath = end.path.slice(0, -1) + const startParentPath = startChildPath.slice(0, -1) + const endParentPath = endChildPath.slice(0, -1) + + if ( + startParentPath.length !== 1 || + endParentPath.length !== 1 || + Path.compare(startParentPath, endParentPath) !== 0 + ) { + throw new Error( + 'liftNodes currently supports only top-level wrapper-child ranges' + ) } + + const startIndex = startChildPath.at(-1) + const endIndex = endChildPath.at(-1) + + if (startIndex == null || endIndex == null) { + throw new Error( + 'liftNodes currently supports only top-level wrapper-child ranges' + ) + } + + const wrapperIndex = startParentPath[0]! + const selectedBaseIndex = wrapperIndex + (startIndex > 0 ? 1 : 0) + + for (let childIndex = endIndex; childIndex >= startIndex; childIndex -= 1) { + liftNodeAtPath([...startParentPath, childIndex]) + } + + const mapPoint = (point: typeof start) => ({ + path: [ + selectedBaseIndex + (point.path[1]! - startIndex), + ...point.path.slice(2), + ], + offset: point.offset, + }) + + editor.select({ + anchor: mapPoint(start), + focus: mapPoint(end), + }) }) } diff --git a/packages/slate/src/transforms-node/merge-nodes.ts b/packages/slate/src/transforms-node/merge-nodes.ts index 3ae68cd7b9..6a95426d31 100644 --- a/packages/slate/src/transforms-node/merge-nodes.ts +++ b/packages/slate/src/transforms-node/merge-nodes.ts @@ -24,7 +24,7 @@ export const mergeNodes: NodeTransforms['mergeNodes'] = ( options = {} ) => { Editor.withoutNormalizing(editor, () => { - let { match, at = editor.selection } = options + let { match, at = Editor.getSnapshot(editor).selection } = options const { hanging = false, voids = false, mode = 'lowest' } = options if (!at) { diff --git a/packages/slate/src/transforms-node/move-nodes.ts b/packages/slate/src/transforms-node/move-nodes.ts index 24499742e4..bd3210e7a0 100644 --- a/packages/slate/src/transforms-node/move-nodes.ts +++ b/packages/slate/src/transforms-node/move-nodes.ts @@ -1,14 +1,14 @@ +import { getCurrentSelection, withTransaction } from '../core/public-state' import { Location, Node } from '../interfaces' import { Editor } from '../interfaces/editor' import { Path } from '../interfaces/path' import type { NodeTransforms } from '../interfaces/transforms/node' -import { matchPath } from '../utils/match-path' export const moveNodes: NodeTransforms['moveNodes'] = (editor, options) => { - Editor.withoutNormalizing(editor, () => { + withTransaction(editor, () => { const { to, - at = editor.selection, + at = getCurrentSelection(editor), mode = 'lowest', voids = false, } = options @@ -19,31 +19,67 @@ export const moveNodes: NodeTransforms['moveNodes'] = (editor, options) => { } if (match == null) { - match = Location.isPath(at) - ? matchPath(editor, at) - : (n) => Node.isElement(n) && Editor.isBlock(editor, n) + if (Location.isPath(at)) { + if (at.length !== 0) { + const sameParentForwardMove = + at.length === to.length && + at.at(-1) != null && + to.at(-1) != null && + Path.equals(at.slice(0, -1), to.slice(0, -1)) && + at.at(-1)! < to.at(-1)! + + const effectiveTo = sameParentForwardMove + ? [ + ...to.slice(0, -1), + Math.min( + to.at(-1)!, + ( + Editor.node(editor, at.slice(0, -1) as Path)[0] as { + children: unknown[] + } + ).children.length - 1 + ), + ] + : to + + editor.apply({ + type: 'move_node', + path: at, + newPath: effectiveTo, + }) + } + + return + } + + match = (n) => Node.isElement(n) && Editor.isBlock(editor, n) } const toRef = Editor.pathRef(editor, to) - const targets = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from(targets, ([, p]) => Editor.pathRef(editor, p)) + const pathRefs = Array.from( + Editor.nodes(editor, { at, match, mode, voids }), + ([, path]) => Editor.pathRef(editor, path) + ) for (const pathRef of pathRefs) { - const path = pathRef.unref()! - const newPath = toRef.current! + const path = pathRef.unref() + const newPath = toRef.current - if (path.length !== 0) { - editor.apply({ type: 'move_node', path, newPath }) + if (!path || !newPath || path.length === 0) { + continue } + editor.apply({ + type: 'move_node', + path, + newPath, + }) + if ( toRef.current && Path.isSibling(newPath, path) && Path.isAfter(newPath, path) ) { - // When performing a sibling move to a later index, the path at the destination is shifted - // to before the insertion point instead of after. To ensure our group of nodes are inserted - // in the correct order we increment toRef to account for that toRef.current = Path.next(toRef.current) } } diff --git a/packages/slate/src/transforms-node/remove-nodes.ts b/packages/slate/src/transforms-node/remove-nodes.ts index f234395c80..cdf4e4d3e2 100644 --- a/packages/slate/src/transforms-node/remove-nodes.ts +++ b/packages/slate/src/transforms-node/remove-nodes.ts @@ -9,7 +9,7 @@ export const removeNodes: NodeTransforms['removeNodes'] = ( ) => { Editor.withoutNormalizing(editor, () => { const { hanging = false, voids = false, mode = 'lowest' } = options - let { at = editor.selection, match } = options + let { at = Editor.getSnapshot(editor).selection, match } = options if (!at) { return diff --git a/packages/slate/src/transforms-node/set-nodes.ts b/packages/slate/src/transforms-node/set-nodes.ts index bf0cd29efd..dde2308d5a 100644 --- a/packages/slate/src/transforms-node/set-nodes.ts +++ b/packages/slate/src/transforms-node/set-nodes.ts @@ -1,3 +1,4 @@ +import { createInternalRangeRef } from '../editor/range-ref' import { Location } from '../interfaces' import { Editor } from '../interfaces/editor' import { Node } from '../interfaces/node' @@ -14,7 +15,7 @@ export const setNodes: NodeTransforms['setNodes'] = ( ) => { Editor.withoutNormalizing(editor, () => { const { - at: optionAt = editor.selection, + at: optionAt = Editor.getSnapshot(editor).selection, compare: optionCompare, hanging = false, match: optionMatch, @@ -51,7 +52,9 @@ export const setNodes: NodeTransforms['setNodes'] = ( // set that won't get normalized away return } - const rangeRef = Editor.rangeRef(editor, at, { affinity: 'inward' }) + const rangeRef = createInternalRangeRef(editor, at, { + affinity: 'inward', + }) const [start, end] = Range.edges(at) const splitMode = mode === 'lowest' ? 'lowest' : 'highest' const endAtEndOfNode = Editor.isEnd(editor, end, end.path) diff --git a/packages/slate/src/transforms-node/split-nodes.ts b/packages/slate/src/transforms-node/split-nodes.ts index 62b4da305a..9c5b7cfdeb 100644 --- a/packages/slate/src/transforms-node/split-nodes.ts +++ b/packages/slate/src/transforms-node/split-nodes.ts @@ -1,3 +1,4 @@ +import { getCurrentSelection } from '../core/public-state' import { Location } from '../interfaces' import { Editor } from '../interfaces/editor' import { Node } from '../interfaces/node' @@ -8,13 +9,11 @@ import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' import type { NodeTransforms } from '../interfaces/transforms/node' -/** - * Convert a range into a point by deleting it's content. - */ const deleteRange = (editor: Editor, range: Range): Point | null => { if (Range.isCollapsed(range)) { return range.anchor } + const [, end] = Range.edges(range) const pointRef = Editor.pointRef(editor, end) Transforms.delete(editor, { at: range }) @@ -27,9 +26,16 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( ) => { Editor.withoutNormalizing(editor, () => { const { mode = 'lowest', voids = false } = options - let { match, at = editor.selection, height = 0, always = false } = options - - if (!at) return + let { + match, + at = getCurrentSelection(editor), + height = 0, + always = false, + } = options + + if (!at) { + return + } if (match == null) { match = (n) => Node.isElement(n) && Editor.isBlock(editor, n) @@ -37,25 +43,45 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( if (Location.isRange(at)) { at = deleteRange(editor, at) - if (!at) return + if (!at) { + return + } } - // If the target is a path, the default height-skipping and position - // counters need to account for us potentially splitting at a non-leaf. if (Location.isPath(at)) { + if (options.position != null) { + const path = at + const [node] = Editor.node(editor, path) + + editor.apply({ + type: 'split_node', + path, + position: options.position, + properties: Node.extractProps(node), + }) + + return + } + const path = at const point = Editor.point(editor, path) const [parent] = Editor.parent(editor, path) + match = (n) => n === parent height = point.path.length - path.length + 1 at = point always = true } + if (!Location.isPoint(at)) { + return + } + const beforeRef = Editor.pointRef(editor, at, { affinity: 'backward', }) let afterRef: PointRef | undefined + try { const [highest] = Editor.nodes(editor, { at, match, mode, voids }) @@ -64,7 +90,6 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( } const voidMatch = Editor.void(editor, { at, mode: 'highest' }) - const nudge = 0 if (!voids && voidMatch) { const [voidNode, voidPath] = voidMatch @@ -88,11 +113,11 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( always = true } - afterRef = Editor.pointRef(editor, at) + afterRef = Editor.pointRef(editor, at, { affinity: 'forward' }) const depth = at.path.length - height const [, highestPath] = highest const lowestPath = at.path.slice(0, depth) - let position = height === 0 ? at.offset : at.path[depth] + nudge + let position = height === 0 ? at.offset : at.path[depth]! for (const [node, path] of Editor.levels(editor, { at: lowestPath, @@ -112,14 +137,13 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( const point = beforeRef.current! const isEnd = Editor.isEnd(editor, point, path) - if (always || !beforeRef || !Editor.isEdge(editor, point, path)) { + if (always || !Editor.isEdge(editor, point, path)) { split = true - const properties = Node.extractProps(node) editor.apply({ type: 'split_node', path, position, - properties, + properties: Node.extractProps(node), }) } @@ -128,7 +152,7 @@ export const splitNodes: NodeTransforms['splitNodes'] = ( if (options.at == null) { const point = afterRef.current || Editor.end(editor, []) - Transforms.select(editor, point) + editor.select(point) } } finally { beforeRef.unref() diff --git a/packages/slate/src/transforms-node/unwrap-nodes.ts b/packages/slate/src/transforms-node/unwrap-nodes.ts index 458e85a1d1..ffa1f6ddfc 100644 --- a/packages/slate/src/transforms-node/unwrap-nodes.ts +++ b/packages/slate/src/transforms-node/unwrap-nodes.ts @@ -1,60 +1,252 @@ -import { Location, Node } from '../interfaces' +import { getCurrentSelection, withTransaction } from '../core/public-state' +import { createInternalRangeRef } from '../editor/range-ref' +import { Location, Node, Range } from '../interfaces' import { Editor } from '../interfaces/editor' -import { Range } from '../interfaces/range' +import { Path } from '../interfaces/path' +import type { Point } from '../interfaces/point' import { Transforms } from '../interfaces/transforms' import type { NodeTransforms } from '../interfaces/transforms/node' import { matchPath } from '../utils/match-path' +const comparePoints = (left: Point, right: Point) => { + const pathComparison = Path.compare(left.path, right.path) + + if (pathComparison !== 0) { + return pathComparison + } + + if (left.offset === right.offset) { + return 0 + } + + return left.offset < right.offset ? -1 : 1 +} + +const mergeAdjacentTextRuns = (editor: Editor) => { + const textPaths = Array.from( + Editor.nodes(editor, { + at: [], + reverse: true, + match: (node) => Node.isText(node), + voids: true, + }), + ([, path]) => path + ) + + textPaths.forEach((path) => { + if (!editor.hasPath(path) || path.length === 0 || path.at(-1) === 0) { + return + } + + const previousPath = Path.previous(path) + + if (!editor.hasPath(previousPath)) { + return + } + + const [node] = Editor.node(editor, path) + const [previous] = Editor.node(editor, previousPath) + + if ( + Node.isText(node) && + Node.isText(previous) && + JSON.stringify(Node.extractProps(node)) === + JSON.stringify(Node.extractProps(previous)) + ) { + editor.mergeNodes({ at: path }) + } + }) +} + export const unwrapNodes: NodeTransforms['unwrapNodes'] = ( editor, options = {} ) => { - Editor.withoutNormalizing(editor, () => { - const { mode = 'lowest', split = false, voids = false } = options - let { at = editor.selection, match } = options + const unwrapNodeAtPath = (path: Path) => { + const [node] = Editor.node(editor, path) + + if (Node.isText(node)) { + throw new Error('unwrapNodes currently supports only element nodes') + } + + const parentPath = path.slice(0, -1) + const index = path.at(-1) + + if (index == null) { + throw new Error('unwrapNodes requires a non-root path') + } + + const childCount = node.children.length + + for (let moved = 0; moved < childCount; moved += 1) { + const wrapperIndex = index + moved + + Transforms.moveNodes(editor, { + at: [...parentPath, wrapperIndex, 0], + to: [...parentPath, wrapperIndex], + }) + } + + Transforms.removeNodes(editor, { + at: [...parentPath, index + childCount], + }) + } + + withTransaction(editor, () => { + let target = options.at ?? getCurrentSelection(editor) + const mode = options.mode ?? 'lowest' + const split = options.split ?? false + const voids = options.voids ?? false + let { match } = options - if (!at) { + if (!target) { return } - if (match == null) { - match = Location.isPath(at) - ? matchPath(editor, at) - : (n) => Node.isElement(n) && Editor.isBlock(editor, n) + const wantsGenericBehavior = + match != null || mode !== 'lowest' || split || voids + + if (wantsGenericBehavior) { + if (match == null) { + match = Location.isPath(target) + ? matchPath(editor, target) + : (node) => Node.isElement(node) && editor.isBlock(node) + } + + if (Location.isPath(target)) { + target = Editor.range(editor, target) + } + + const rangeRef = Location.isRange(target) + ? createInternalRangeRef(editor, target) + : null + const pathRefs = Array.from( + Editor.nodes(editor, { at: target, match, mode, voids }), + ([, path]) => Editor.pathRef(editor, path) + ).reverse() + + for (const pathRef of pathRefs) { + const path = pathRef.unref() + + if (!path) { + continue + } + + const [node] = Editor.node(editor, path) + let range = Editor.range(editor, path) + + if (!split && !Node.isText(node) && node.children.some(Node.isText)) { + unwrapNodeAtPath(path) + editor.normalize() + continue + } + + if (split && rangeRef?.current) { + const liveRange = getCurrentSelection(editor) ?? rangeRef.current + const intersection = Range.intersection(liveRange, range) + + if (!intersection) { + continue + } + + range = intersection + } + + Transforms.liftNodes(editor, { + at: range, + match: (candidate, candidatePath) => + !Node.isText(node) && + !Node.isText(candidate) && + candidatePath.length === path.length + 1 && + Path.equals(candidatePath.slice(0, -1), path), + voids, + }) + } + + mergeAdjacentTextRuns(editor) + rangeRef?.unref() + return + } + + if (Array.isArray(target)) { + unwrapNodeAtPath(target) + return + } + + if (!Location.isRange(target)) { + throw new Error( + 'unwrapNodes currently supports only exact paths or ranges' + ) + } + + const [start, end] = + comparePoints(target.anchor, target.focus) <= 0 + ? [target.anchor, target.focus] + : [target.focus, target.anchor] + + if (start.path.length < 2 || end.path.length < 2) { + throw new Error( + 'unwrapNodes currently supports only top-level wrapper block ranges' + ) } - if (Location.isPath(at)) { - at = Editor.range(editor, at) + const startWrapperPath = start.path.slice(0, -2) + const endWrapperPath = end.path.slice(0, -2) + + if (startWrapperPath.length !== 1 || endWrapperPath.length !== 1) { + throw new Error( + 'unwrapNodes currently supports only top-level wrapper block ranges' + ) } - const rangeRef = Location.isRange(at) ? Editor.rangeRef(editor, at) : null - const matches = Editor.nodes(editor, { at, match, mode, voids }) - const pathRefs = Array.from( - matches, - ([, p]) => Editor.pathRef(editor, p) - // unwrapNode will call liftNode which does not support splitting the node when nested. - // If we do not reverse the order and call it from top to the bottom, it will remove all blocks - // that wrap target node. So we reverse the order. - ).reverse() + const startWrapperIndex = startWrapperPath[0]! + const endWrapperIndex = endWrapperPath[0]! + const wrapperChildCounts: number[] = [] - for (const pathRef of pathRefs) { - const path = pathRef.unref()! - const [node] = Editor.node(editor, path) - let range = Editor.range(editor, path) + for ( + let wrapperIndex = startWrapperIndex; + wrapperIndex <= endWrapperIndex; + wrapperIndex += 1 + ) { + const [wrapperNode] = Editor.node(editor, [wrapperIndex]) - if (split && rangeRef) { - range = Range.intersection(rangeRef.current!, range)! + if ( + Node.isText(wrapperNode) || + wrapperNode.children.some((child) => Node.isText(child)) + ) { + throw new Error( + 'unwrapNodes currently supports only top-level wrapper blocks with element children' + ) } - Transforms.liftNodes(editor, { - at: range, - match: (n) => !Node.isText(node) && node.children.includes(n), - voids, - }) + wrapperChildCounts.push(wrapperNode.children.length) } - if (rangeRef) { - rangeRef.unref() + for ( + let wrapperIndex = endWrapperIndex; + wrapperIndex >= startWrapperIndex; + wrapperIndex -= 1 + ) { + unwrapNodeAtPath([wrapperIndex]) } + + const mapPoint = (point: Point) => ({ + path: [ + startWrapperIndex + + wrapperChildCounts + .slice(0, point.path[0]! - startWrapperIndex) + .reduce((total, count) => total + count, 0) + + point.path[1]!, + ...point.path.slice(2), + ], + offset: point.offset, + }) + + editor.select({ + anchor: mapPoint(start), + focus: mapPoint(end), + }) + + mergeAdjacentTextRuns(editor) }) } diff --git a/packages/slate/src/transforms-node/wrap-nodes.ts b/packages/slate/src/transforms-node/wrap-nodes.ts index 9176e07664..f7e2e80e4d 100644 --- a/packages/slate/src/transforms-node/wrap-nodes.ts +++ b/packages/slate/src/transforms-node/wrap-nodes.ts @@ -1,10 +1,14 @@ -import { Location, Node, type Point } from '../interfaces' +import { getCurrentSelection } from '../core/public-state' +import { createInternalRangeRef } from '../editor/range-ref' +import { Location, Node, type Point, Range } from '../interfaces' import { Editor } from '../interfaces/editor' import { Path } from '../interfaces/path' -import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' import type { NodeTransforms } from '../interfaces/transforms/node' import { matchPath } from '../utils/match-path' +import { insertNodes } from './insert-nodes' +import { moveNodes } from './move-nodes' +import { splitNodes } from './split-nodes' export const wrapNodes: NodeTransforms['wrapNodes'] = ( editor, @@ -12,117 +16,214 @@ export const wrapNodes: NodeTransforms['wrapNodes'] = ( options = {} ) => { Editor.withoutNormalizing(editor, () => { - const { mode = 'lowest', split = false, voids = false } = options - let { match, at = editor.selection } = options + let target = options.at ?? getCurrentSelection(editor) + const mode = options.mode ?? 'lowest' + const split = options.split ?? false + const voids = options.voids ?? false + let { match } = options + const wrapper = { + ...element, + children: [], + } - if (!at) { + if (!target) { return } if (match == null) { - if (Location.isPath(at)) { - match = matchPath(editor, at) + if (Location.isPath(target)) { + match = matchPath(editor, target) } else if (editor.isInline(element)) { - match = (n) => - (Node.isElement(n) && Editor.isInline(editor, n)) || Node.isText(n) + match = (node) => + (Node.isElement(node) && editor.isInline(node)) || Node.isText(node) } else { - match = (n) => Node.isElement(n) && Editor.isBlock(editor, n) + match = (node) => Node.isElement(node) && Editor.isBlock(editor, node) } } - if (split && Location.isRange(at)) { - const [start, end] = Range.edges(at) + if (Location.isPath(target) && options.match == null && !split) { + insertNodes(editor, wrapper, { at: target }) + moveNodes(editor, { + at: [...target.slice(0, -1), target.at(-1)! + 1], + to: [...target, 0], + }) + return + } - const rangeRef = Editor.rangeRef(editor, at, { + if (split && Location.isRange(target)) { + const [start, end] = Range.edges(target) + const rangeRef = createInternalRangeRef(editor, target, { affinity: 'inward', }) - - // Always split if we're in the middle of a block, to ensure that text - // node boundaries are handled correctly. const isAtBlockEdge = (point: Point) => { const blockAbove = Editor.above(editor, { at: point, - match: (n) => Node.isElement(n) && Editor.isBlock(editor, n), + match: (node) => Node.isElement(node) && Editor.isBlock(editor, node), }) + return blockAbove && Editor.isEdge(editor, point, blockAbove[1]) } + const shouldAlwaysSplit = (point: Point) => !isAtBlockEdge(point) - Transforms.splitNodes(editor, { + splitNodes(editor, { at: end, match, voids, - always: !isAtBlockEdge(end), + always: shouldAlwaysSplit(end), }) - Transforms.splitNodes(editor, { + splitNodes(editor, { at: start, match, voids, - always: !isAtBlockEdge(start), + always: shouldAlwaysSplit(start), }) - at = rangeRef.unref()! + target = rangeRef.unref() ?? target + + if (Location.isRange(target)) { + let [nextStart, nextEnd] = Range.edges(target) + const [startLeaf] = Editor.leaf(editor, nextStart) + const [endLeaf] = Editor.leaf(editor, nextEnd) + + if ( + Node.isText(startLeaf) && + nextStart.offset === startLeaf.text.length + ) { + nextStart = + Editor.after(editor, nextStart, { + distance: 1, + unit: 'offset', + }) ?? nextStart + } + + if (Node.isText(endLeaf) && nextEnd.offset === 0) { + nextEnd = + Editor.before(editor, nextEnd, { + distance: 1, + unit: 'offset', + }) ?? nextEnd + } + + target = { anchor: nextStart, focus: nextEnd } + } if (options.at == null) { - Transforms.select(editor, at) + editor.select(target) } } const roots = Array.from( Editor.nodes(editor, { - at, + at: target, match: editor.isInline(element) - ? (n) => Node.isElement(n) && Editor.isBlock(editor, n) - : (n) => Node.isEditor(n), + ? (node) => Node.isElement(node) && Editor.isBlock(editor, node) + : (node) => Node.isEditor(node), mode: 'lowest', voids, }) ) + let nextSelection = Location.isRange(target) + ? { + anchor: target.anchor, + focus: target.focus, + } + : null for (const [, rootPath] of roots) { - const a = Location.isRange(at) - ? Range.intersection(at, Editor.range(editor, rootPath)) - : at + const scopedTarget = Location.isRange(target) + ? Range.intersection(target, Editor.range(editor, rootPath)) + : target - if (!a) { + if (!scopedTarget) { continue } const matches = Array.from( - Editor.nodes(editor, { at: a, match, mode, voids }) + Editor.nodes(editor, { at: scopedTarget, match, mode, voids }) + ) + + if (matches.length === 0) { + continue + } + + const [first] = matches + const last = matches.at(-1)! + const [, firstPath] = first + const [, lastPath] = last + + if (firstPath.length === 0 && lastPath.length === 0) { + continue + } + + const commonPath = Path.equals(firstPath, lastPath) + ? Path.parent(firstPath) + : Path.common(firstPath, lastPath) + const depth = commonPath.length + 1 + const wrapperPath = Path.next(lastPath.slice(0, depth)) + const firstChildIndex = firstPath[commonPath.length]! + const lastChildIndex = lastPath[commonPath.length]! + const movePaths = Array.from( + { length: lastChildIndex - firstChildIndex + 1 }, + (_, offset) => [...commonPath, firstChildIndex + offset] ) + const pathRefs = movePaths.map((path) => Editor.pathRef(editor, path)) - if (matches.length > 0) { - const [first] = matches - const last = matches.at(-1)! - const [, firstPath] = first - const [, lastPath] = last + Transforms.insertNodes(editor, { ...wrapper }, { at: wrapperPath, voids }) + const wrapperRef = Editor.pathRef(editor, wrapperPath) - if (firstPath.length === 0 && lastPath.length === 0) { - // if there's no matching parent - usually means the node is an editor - don't do anything - continue - } + try { + pathRefs.forEach((pathRef, index) => { + const path = pathRef.current + const currentWrapperPath = wrapperRef.current + + if (!path || !currentWrapperPath) { + return + } - const commonPath = Path.equals(firstPath, lastPath) - ? Path.parent(firstPath) - : Path.common(firstPath, lastPath) - - const range = Editor.range(editor, firstPath, lastPath) - const commonNodeEntry = Editor.node(editor, commonPath) - const [commonNode] = commonNodeEntry - const depth = commonPath.length + 1 - const wrapperPath = Path.next(lastPath.slice(0, depth)) - const wrapper = { ...element, children: [] } - Transforms.insertNodes(editor, wrapper, { at: wrapperPath, voids }) - - Transforms.moveNodes(editor, { - at: range, - match: (n) => - !Node.isText(commonNode) && commonNode.children.includes(n), - to: wrapperPath.concat(0), - voids, + moveNodes(editor, { + at: path, + to: currentWrapperPath.concat(index), + }) }) + + if (nextSelection && wrapperRef.current) { + const mapPoint = (point: Point) => { + const matchIndex = movePaths.findIndex((path) => + Path.equals(path, point.path.slice(0, path.length)) + ) + + if (matchIndex < 0) { + return point + } + + const basePath = movePaths[matchIndex]! + + return { + path: [ + ...wrapperRef.current!, + matchIndex, + ...point.path.slice(basePath.length), + ], + offset: point.offset, + } + } + + nextSelection = { + anchor: mapPoint(nextSelection.anchor), + focus: mapPoint(nextSelection.focus), + } + } + } finally { + wrapperRef.unref() + for (const pathRef of pathRefs) { + pathRef.unref() + } } } + + if (nextSelection) { + editor.select(nextSelection) + } }) } diff --git a/packages/slate/src/transforms-selection/collapse.ts b/packages/slate/src/transforms-selection/collapse.ts index d1417b5117..ca5bba0115 100644 --- a/packages/slate/src/transforms-selection/collapse.ts +++ b/packages/slate/src/transforms-selection/collapse.ts @@ -1,3 +1,4 @@ +import { getCurrentSelection } from '../core/public-state' import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' import type { SelectionTransforms } from '../interfaces/transforms/selection' @@ -7,7 +8,7 @@ export const collapse: SelectionTransforms['collapse'] = ( options = {} ) => { const { edge = 'anchor' } = options - const { selection } = editor + const selection = getCurrentSelection(editor) if (!selection) { return diff --git a/packages/slate/src/transforms-selection/deselect.ts b/packages/slate/src/transforms-selection/deselect.ts index cfe59508ec..fd35599758 100644 --- a/packages/slate/src/transforms-selection/deselect.ts +++ b/packages/slate/src/transforms-selection/deselect.ts @@ -1,7 +1,8 @@ +import { getCurrentSelection } from '../core/public-state' import type { SelectionTransforms } from '../interfaces/transforms/selection' export const deselect: SelectionTransforms['deselect'] = (editor) => { - const { selection } = editor + const selection = getCurrentSelection(editor) if (selection) { editor.apply({ diff --git a/packages/slate/src/transforms-selection/move.ts b/packages/slate/src/transforms-selection/move.ts index 4f07feb3c0..6f76cf0172 100644 --- a/packages/slate/src/transforms-selection/move.ts +++ b/packages/slate/src/transforms-selection/move.ts @@ -1,10 +1,11 @@ +import { getCurrentSelection } from '../core/public-state' import { Editor } from '../interfaces/editor' import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' import type { SelectionTransforms } from '../interfaces/transforms/selection' export const move: SelectionTransforms['move'] = (editor, options = {}) => { - const { selection } = editor + const selection = getCurrentSelection(editor) const { distance = 1, unit = 'character', reverse = false } = options let { edge = null } = options diff --git a/packages/slate/src/transforms-selection/select.ts b/packages/slate/src/transforms-selection/select.ts index 48fb2a87a4..bbe0e7e759 100644 --- a/packages/slate/src/transforms-selection/select.ts +++ b/packages/slate/src/transforms-selection/select.ts @@ -1,3 +1,4 @@ +import { getCurrentSelection } from '../core/public-state' import { Location } from '../interfaces' import { Editor } from '../interfaces/editor' import { Scrubber } from '../interfaces/scrubber' @@ -5,7 +6,7 @@ import { Transforms } from '../interfaces/transforms' import type { SelectionTransforms } from '../interfaces/transforms/selection' export const select: SelectionTransforms['select'] = (editor, target) => { - const { selection } = editor + const selection = getCurrentSelection(editor) const range = Editor.range(editor, target) if (selection) { diff --git a/packages/slate/src/transforms-selection/set-point.ts b/packages/slate/src/transforms-selection/set-point.ts index 5a78425c8a..dab9949478 100644 --- a/packages/slate/src/transforms-selection/set-point.ts +++ b/packages/slate/src/transforms-selection/set-point.ts @@ -1,3 +1,4 @@ +import { getCurrentSelection } from '../core/public-state' import { Range } from '../interfaces/range' import { Transforms } from '../interfaces/transforms' import type { SelectionTransforms } from '../interfaces/transforms/selection' @@ -7,7 +8,7 @@ export const setPoint: SelectionTransforms['setPoint'] = ( props, options = {} ) => { - const { selection } = editor + const selection = getCurrentSelection(editor) let { edge = 'both' } = options if (!selection) { diff --git a/packages/slate/src/transforms-selection/set-selection.ts b/packages/slate/src/transforms-selection/set-selection.ts index 72140d691e..31a3ab93ab 100644 --- a/packages/slate/src/transforms-selection/set-selection.ts +++ b/packages/slate/src/transforms-selection/set-selection.ts @@ -15,37 +15,38 @@ export const setSelection: SelectionTransforms['setSelection'] = ( return } - for (const k in props) { - if (NON_SETTABLE_SELECTION_PROPERTIES.includes(k)) { + for (const key in props) { + if (NON_SETTABLE_SELECTION_PROPERTIES.includes(key)) { continue } - const value = Object.hasOwn(selection, k) - ? selection[k] + const value = Object.hasOwn(selection, key) + ? selection[key] : undefined + const newValue = props[key] - const newValue = props[k] - - if (compareSelectionProps(k, value, newValue)) { - oldProps[k] = selection[k] - newProps[k] = props[k] + if (compareSelectionProps(key, value, newValue)) { + oldProps[key] = selection[key] + newProps[key] = props[key] } } - if (Object.keys(oldProps).length > 0) { - editor.apply({ - type: 'set_selection', - properties: oldProps, - newProperties: newProps, - }) + if (Object.keys(oldProps).length === 0) { + return } + + editor.apply({ + type: 'set_selection', + properties: oldProps, + newProperties: newProps, + }) } -function compareSelectionProps( +const compareSelectionProps = ( key: keyof Range, value: unknown, newValue: unknown -) { +) => { if ( (key === 'anchor' || key === 'focus') && Point.isPoint(value) && @@ -53,5 +54,6 @@ function compareSelectionProps( ) { return !Point.equals(value, newValue) } + return value !== newValue } diff --git a/packages/slate/src/transforms-text/delete-text.ts b/packages/slate/src/transforms-text/delete-text.ts index 0e84c3e369..28ddfdefd1 100644 --- a/packages/slate/src/transforms-text/delete-text.ts +++ b/packages/slate/src/transforms-text/delete-text.ts @@ -1,211 +1,1658 @@ -import { Location } from '../interfaces' -import { Editor } from '../interfaces/editor' -import { Node, type NodeEntry } from '../interfaces/node' -import { Path } from '../interfaces/path' -import { Point } from '../interfaces/point' -import { Range } from '../interfaces/range' -import { Transforms } from '../interfaces/transforms' +import { getCurrentSelection, withTransaction } from '../core/public-state' +import { + Location, + Node as NodeApi, + type Path, + Path as PathApi, + Point as PointApi, + Range as RangeApi, + type Element as SlateElement, +} from '../interfaces' +import { type Editor, Editor as EditorApi } from '../interfaces/editor' import type { TextTransforms } from '../interfaces/transforms/text' +import { createSetSelectionOperation } from '../selection-operation' +import { mergeNodes } from '../transforms-node' -const COMPLEX_SCRIPT_RE = +const COMPLEX_SCRIPT_CHARACTER_REGEX = /[\u0980-\u09FF\u0E00-\u0E7F\u1000-\u109F\u0900-\u097F\u1780-\u17FF\u0D00-\u0D7F\u0B00-\u0B7F\u0A00-\u0A7F\u0B80-\u0BFF\u0C00-\u0C7F]+/ -export const deleteText: TextTransforms['delete'] = (editor, options = {}) => { - Editor.withoutNormalizing(editor, () => { - const { - reverse = false, - unit = 'character', - distance = 1, - voids = false, - } = options - let { at = editor.selection, hanging = false } = options - - if (!at) { +const getCurrentNode = (editor: Editor, path: Path) => + EditorApi.node(editor, path)[0] + +const isTextNode = ( + node: ReturnType +): node is import('../interfaces').Text => 'text' in node + +const getHighestNonEditable = ( + editor: Editor, + at: Path | import('../interfaces').Point +) => + EditorApi.above(editor, { + at, + match: (node) => + NodeApi.isElement(node) && + (editor.isVoid(node) || editor.isElementReadOnly(node)), + mode: 'highest', + }) + +const pathContainsPoint = ( + path: readonly number[], + point: import('../interfaces').Point +) => + PathApi.equals(path as Path, point.path) || + PathApi.isAncestor(path as Path, point.path) + +const valuesEqual = (left: unknown, right: unknown): boolean => { + if (left === right) { + return true + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((entry, index) => valuesEqual(entry, right[index])) + ) + } + + if (left && typeof left === 'object' && right && typeof right === 'object') { + const leftEntries = Object.entries(left) + const rightEntries = Object.entries(right) + + return ( + leftEntries.length === rightEntries.length && + leftEntries.every( + ([key, entry]) => + Object.hasOwn(right, key) && + valuesEqual(entry, (right as Record)[key]) + ) + ) + } + + return false +} + +const textPropsEqual = ( + left: import('../interfaces').Text, + right: import('../interfaces').Text +) => + valuesEqual( + Object.fromEntries(Object.entries(left).filter(([key]) => key !== 'text')), + Object.fromEntries(Object.entries(right).filter(([key]) => key !== 'text')) + ) + +const canMergeAdjacentTextNodes = ( + left: ReturnType, + right: ReturnType +) => isTextNode(left) && isTextNode(right) && textPropsEqual(left, right) + +const maybeMergeAdjacentTextAt = ( + editor: Editor, + path: Path | null | undefined +) => { + if ( + !path || + !editor.hasPath(path) || + path.length === 0 || + path.at(-1) === 0 + ) { + return + } + + const previousPath = PathApi.previous(path) + + if (!editor.hasPath(previousPath)) { + return + } + + const node = getCurrentNode(editor, path) + const previous = getCurrentNode(editor, previousPath) + + if (!canMergeAdjacentTextNodes(previous, node)) { + return + } + + mergeNodes(editor, { at: path }) +} + +const hasSingleChildNest = ( + editor: Editor, + node: import('../interfaces').Node | null | undefined +): boolean => { + if (node === editor || node == null) { + return false + } + + if (NodeApi.isText(node)) { + return true + } + + if (NodeApi.isElement(node) && editor.isVoid(node)) { + return true + } + + return ( + NodeApi.isElement(node) && + node.children.length === 1 && + hasSingleChildNest(editor, node.children[0]) + ) +} + +const cleanupEmptyAncestors = (editor: Editor, path: Path) => { + const refs = Array.from({ length: path.length - 1 }, (_, index) => + editor.pathRef(path.slice(0, index + 1) as Path) + ) + + refs + .reverse() + .map((ref) => ref.unref()) + .filter((entry): entry is Path => entry !== null) + .forEach((entry) => { + if (!editor.hasPath(entry)) { + return + } + + const node = getCurrentNode(editor, entry) + + if (NodeApi.isElement(node) && node.children.length === 0) { + editor.removeNodes({ at: entry }) + } + }) +} + +const mergeAdjacentTextRuns = (editor: Editor) => { + if (editor.children.length === 0) { + return + } + + const textPaths = Array.from( + EditorApi.nodes(editor, { + at: [], + reverse: true, + match: (node): node is import('../interfaces').Text => + NodeApi.isText(node), + voids: true, + }), + ([, path]) => path + ) + + textPaths.forEach((path) => { + if (!editor.hasPath(path) || path.length === 0 || path.at(-1) === 0) { return } - let isCollapsed = false - if (Location.isRange(at) && Range.isCollapsed(at)) { - isCollapsed = true - at = at.anchor + const previousPath = PathApi.previous(path) + + if (!editor.hasPath(previousPath)) { + return } - if (Location.isPoint(at)) { - const furthestVoid = Editor.void(editor, { at, mode: 'highest' }) + const node = getCurrentNode(editor, path) + const previous = getCurrentNode(editor, previousPath) - if (!voids && furthestVoid) { - const [, voidPath] = furthestVoid - at = voidPath - } else { - const opts = { unit, distance } - const target = reverse - ? Editor.before(editor, at, opts) || Editor.start(editor, []) - : Editor.after(editor, at, opts) || Editor.end(editor, []) - at = { anchor: at, focus: target } - hanging = true - } + if (canMergeAdjacentTextNodes(previous, node)) { + mergeNodes(editor, { at: path }) } + }) +} - if (Location.isPath(at)) { - Transforms.removeNodes(editor, { at, voids }) +const removeEmptyStructuralArtifacts = ( + editor: Editor, + preservePath?: Path | null +) => { + const elementPaths = Array.from( + EditorApi.nodes(editor, { + at: [], + reverse: true, + match: (node) => NodeApi.isElement(node), + voids: true, + }), + ([, path]) => path + ) + + elementPaths.forEach((path) => { + if (!editor.hasPath(path) || path.length === 0) { return } - if (Range.isCollapsed(at)) { + if (preservePath && PathApi.equals(path, preservePath)) { return } - if (!hanging) { - const [, end] = Range.edges(at) - const endOfDoc = Editor.end(editor, []) + const node = getCurrentNode(editor, path) - if (!Point.equals(end, endOfDoc)) { - at = Editor.unhangRange(editor, at, { voids }) - } + if ( + NodeApi.isElement(node) && + (editor.isVoid(node) || editor.isElementReadOnly(node)) + ) { + return } - let [start, end] = Range.edges(at) - const startBlock = Editor.above(editor, { - match: (n) => Node.isElement(n) && Editor.isBlock(editor, n), - at: start, - voids, - }) - const endBlock = Editor.above(editor, { - match: (n) => Node.isElement(n) && Editor.isBlock(editor, n), - at: end, - voids, - }) - const isAcrossBlocks = - startBlock && endBlock && !Path.equals(startBlock[1], endBlock[1]) - const isSingleText = Path.equals(start.path, end.path) - const startNonEditable = voids - ? null - : (Editor.void(editor, { at: start, mode: 'highest' }) ?? - Editor.elementReadOnly(editor, { at: start, mode: 'highest' })) - const endNonEditable = voids - ? null - : (Editor.void(editor, { at: end, mode: 'highest' }) ?? - Editor.elementReadOnly(editor, { at: end, mode: 'highest' })) + if (NodeApi.isElement(node) && editor.isInline(node)) { + return + } - // If the start or end points are inside an inline void, nudge them out. - if (startNonEditable) { - const before = Editor.before(editor, start) + const isTopLevelBlock = + NodeApi.isElement(node) && path.length === 1 && editor.isBlock(node) + + if ( + NodeApi.isElement(node) && + NodeApi.string(node) === '' && + hasSingleChildNest(editor, node) && + (!isTopLevelBlock || editor.children.length > 1) + ) { + const parentPath = path.slice(0, -1) as Path + const parent = + parentPath.length === 0 ? editor : getCurrentNode(editor, parentPath) - if (before && startBlock && Path.isAncestor(startBlock[1], before.path)) { - start = before + if (NodeApi.isElement(parent) && parent.children.length === 1) { + return } + + editor.removeNodes({ at: path }) } + }) +} + +const restorePreservedEmptyStartBlock = ( + editor: Editor, + preservePath: Path | null | undefined, + preservedBlock: SlateElement | null | undefined +) => { + if (!preservePath || !preservedBlock) { + return + } + + const shouldRestore = + (editor.children.length === 1 && + NodeApi.string(editor.children[0]!) !== '') || + !editor.hasPath(preservePath) || + NodeApi.string(getCurrentNode(editor, preservePath)) !== '' + + if (!shouldRestore) { + return + } + + editor.insertNodes(preservedBlock, { + at: preservePath, + }) +} + +type DeleteOptions = NonNullable[0]> +type DeleteUnit = NonNullable +type DeletePoint = import('../interfaces').Point +type DeleteRange = import('../interfaces').Range +type DeletePathTarget = { + kind: 'path' + path: Path + fallbackPoint?: DeletePoint + initialAt: DeleteOptions['at'] +} +type DeleteRangePlan = { + kind: 'range' + initialAt: DeleteOptions['at'] + reverse: boolean + unit: DeleteUnit + distance: number + voids: boolean + isCollapsed: boolean + start: DeletePoint + end: DeletePoint + effectiveRange: DeleteRange + isSingleText: boolean + isAcrossBlocks: boolean + startNonEditable: ReturnType + endNonEditable: ReturnType + preserveEndBlock: boolean + preserveEmptyStartBlockPath: Path | null + preservedEmptyStartBlock: SlateElement | null + effectiveEndBlockPath: Path | null + removedInteriorElementSiblingStructure: boolean +} + +const getLivePoint = ( + editor: Editor, + point: DeletePoint | null | undefined +) => { + if (!point || !editor.hasPath(point.path)) { + return null + } + + return point +} + +const resolveRemovalEndPoint = ( + editor: Editor, + plan: DeleteRangePlan, + startPoint: DeletePoint | null | undefined, + endPoint: DeletePoint | null | undefined +) => { + const liveEndPoint = getLivePoint(editor, endPoint) + + if (liveEndPoint) { + return liveEndPoint + } + + const liveStartPoint = getLivePoint(editor, startPoint) + + if (!liveStartPoint) { + return editor.children.length > 0 ? EditorApi.start(editor, []) : null + } + + const nextPoint = EditorApi.after(editor, liveStartPoint, { + distance: 1, + unit: 'offset', + voids: true, + }) + + if (nextPoint) { + return nextPoint + } + + if (!plan.isAcrossBlocks) { + return liveStartPoint + } + + return null +} + +const shouldMergeAcrossBlocks = (plan: DeleteRangePlan) => + plan.startNonEditable == null && plan.endNonEditable == null + +const resolveMergePoint = ( + editor: Editor, + plan: DeleteRangePlan, + startPoint: DeletePoint | null | undefined, + endPoint: DeletePoint | null | undefined +) => { + const liveEndPoint = getLivePoint(editor, endPoint) + + if (liveEndPoint) { + return liveEndPoint + } + + const liveStartPoint = getLivePoint(editor, startPoint) + + if (!liveStartPoint) { + return null + } + + return ( + EditorApi.after(editor, liveStartPoint, { + distance: 1, + unit: 'offset', + voids: plan.voids, + }) ?? null + ) +} + +const movePointToFollowingInline = ( + editor: Editor, + point: DeletePoint | null | undefined +) => { + const livePoint = getLivePoint(editor, point) + + if (!livePoint || livePoint.path.length < 2) { + return livePoint + } + + const currentNode = getCurrentNode(editor, livePoint.path) + + if ( + !isTextNode(currentNode) || + livePoint.offset !== currentNode.text.length + ) { + return livePoint + } + + const parentPath = livePoint.path.slice(0, -1) as Path + + if (!editor.hasPath(parentPath)) { + return livePoint + } + + const parent = getCurrentNode(editor, parentPath) + + if (!NodeApi.isElement(parent) || !editor.isInline(parent)) { + return livePoint + } + + const nextSiblingPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (!nextSiblingPath || !editor.hasPath(nextSiblingPath)) { + return livePoint + } - if (endNonEditable) { - const after = Editor.after(editor, end) + const nextSibling = getCurrentNode(editor, nextSiblingPath) - if (after && endBlock && Path.isAncestor(endBlock[1], after.path)) { - end = after + if (NodeApi.isElement(nextSibling) && editor.isInline(nextSibling)) { + return EditorApi.start(editor, nextSiblingPath) + } + + if (!isTextNode(nextSibling) || nextSibling.text !== '') { + return livePoint + } + + const nextInlinePath = + nextSiblingPath.at(-1) == null ? null : PathApi.next(nextSiblingPath) + + if (!nextInlinePath || !editor.hasPath(nextInlinePath)) { + return livePoint + } + + const nextInline = getCurrentNode(editor, nextInlinePath) + + if (!NodeApi.isElement(nextInline) || !editor.isInline(nextInline)) { + return livePoint + } + + return EditorApi.start(editor, nextInlinePath) +} + +const moveLeadingSpacerPointIntoFollowingInline = ( + editor: Editor, + point: DeletePoint | null | undefined +) => { + const livePoint = getLivePoint(editor, point) + + if (!livePoint || livePoint.offset !== 0 || livePoint.path.length === 0) { + return livePoint + } + + if ((livePoint.path.at(-1) ?? 0) !== 0) { + return livePoint + } + + const currentNode = getCurrentNode(editor, livePoint.path) + + if (!isTextNode(currentNode) || currentNode.text !== '') { + return livePoint + } + + const nextSiblingPath = PathApi.next(livePoint.path as Path) + + if (!editor.hasPath(nextSiblingPath)) { + return livePoint + } + + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if ( + NodeApi.isElement(nextSibling) && + editor.isInline(nextSibling) && + !editor.isVoid(nextSibling) + ) { + return EditorApi.start(editor, nextSiblingPath) + } + + return livePoint +} + +const moveTrailingTextPointIntoFollowingInline = ( + editor: Editor, + point: DeletePoint | null | undefined +) => { + const livePoint = getLivePoint(editor, point) + + if (!livePoint) { + return livePoint + } + + const currentNode = getCurrentNode(editor, livePoint.path) + + if ( + !isTextNode(currentNode) || + livePoint.offset !== currentNode.text.length || + currentNode.text.length === 0 + ) { + return livePoint + } + + const nextSiblingPath = PathApi.next(livePoint.path as Path) + + if (!editor.hasPath(nextSiblingPath)) { + return livePoint + } + + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if ( + NodeApi.isElement(nextSibling) && + editor.isInline(nextSibling) && + !editor.isVoid(nextSibling) + ) { + return EditorApi.start(editor, nextSiblingPath) + } + + return livePoint +} + +const shouldKeepSplitTextAfterInteriorElementRemoval = ( + editor: Editor, + start: DeletePoint, + end: DeletePoint, + isAcrossBlocks: boolean +) => + !isAcrossBlocks && + start.path.length === end.path.length && + PathApi.equals( + start.path.slice(0, -1) as Path, + end.path.slice(0, -1) as Path + ) && + Math.abs((start.path.at(-1) ?? 0) - (end.path.at(-1) ?? 0)) > 1 && + (() => { + const parentPath = start.path.slice(0, -1) as Path + + if (!editor.hasPath(parentPath)) { + return false + } + + const parent = getCurrentNode(editor, parentPath) + + if (!NodeApi.isElement(parent)) { + return false + } + + const from = Math.min(start.path.at(-1) ?? 0, end.path.at(-1) ?? 0) + 1 + const to = Math.max(start.path.at(-1) ?? 0, end.path.at(-1) ?? 0) + + for (let index = from; index < to; index += 1) { + const child = parent.children[index] + + if (child && NodeApi.isElement(child)) { + return true } } - // Get the highest nodes that are completely inside the range, as well as - // the start and end nodes. - const matches: NodeEntry[] = [] - let lastPath: Path | undefined + return false + })() + +const shouldPreserveEmptyStartBlockForHangingRange = ( + editor: Editor, + start: DeletePoint, + isSingleText: boolean, + isAcrossBlocks: boolean, + preserveEndBlock: boolean, + originalHangingBlockRange: boolean, + effectiveStartBlock: readonly [import('../interfaces').Node, Path] | undefined +) => + !isSingleText && + isAcrossBlocks && + (preserveEndBlock || originalHangingBlockRange) && + effectiveStartBlock && + NodeApi.isElement(effectiveStartBlock[0]) && + !editor.isVoid(effectiveStartBlock[0]) && + PointApi.equals(start, EditorApi.start(editor, effectiveStartBlock[1])) + ? effectiveStartBlock[1] + : null + +const resolveDeleteTarget = ( + editor: Editor, + options: DeleteOptions = {} +): DeletePathTarget | DeleteRangePlan | null => { + const { + reverse = false, + unit = 'character', + distance = 1, + voids = false, + } = options + let { at = getCurrentSelection(editor), hanging = false } = options + const initialAt = at ?? undefined + + if (!at) { + return null + } + + let isCollapsed = false + + if (Location.isRange(at) && RangeApi.isCollapsed(at)) { + isCollapsed = true + at = at.anchor + } + + if (Location.isPoint(at)) { + isCollapsed = true + const nonEditable = voids ? undefined : getHighestNonEditable(editor, at) + + if (nonEditable) { + at = nonEditable[1] + } else { + const target = getCollapsedDeleteTarget(editor, at, { + reverse, + distance, + unit, + voids, + }) + + at = { anchor: at, focus: target } + hanging = true + } + } + + if (Location.isPath(at)) { + const selection = getCurrentSelection(editor) + const selectionInside = + selection && + (pathContainsPoint(at, selection.anchor) || + pathContainsPoint(at, selection.focus)) + const fallbackPoint = selectionInside + ? (EditorApi.before(editor, at, { voids: true }) ?? + EditorApi.after(editor, at, { voids: true })) + : undefined + + return { + kind: 'path', + path: at, + fallbackPoint, + initialAt, + } + } + + if (!RangeApi.isRange(at) || RangeApi.isCollapsed(at)) { + return null + } + + if (!hanging) { + const [, end] = RangeApi.edges(at) + const endOfDocument = EditorApi.end(editor, []) + + if (!PointApi.equals(end, endOfDocument)) { + at = EditorApi.unhangRange(editor, at, { voids }) + } + } + + let [start, end] = RangeApi.edges(at) + const startBlock = EditorApi.above(editor, { + at: start, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'highest', + voids, + }) + const endBlock = EditorApi.above(editor, { + at: end, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'highest', + voids, + }) + const startMergeBlock = EditorApi.above(editor, { + at: start, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'lowest', + voids, + }) + const endMergeBlock = EditorApi.above(editor, { + at: end, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'lowest', + voids, + }) + const prefersLowestMergeBlocks = + startMergeBlock && + endMergeBlock && + startMergeBlock[1].length === endMergeBlock[1].length && + !PathApi.equals(startMergeBlock[1], endMergeBlock[1]) + const effectiveStartBlock = prefersLowestMergeBlocks + ? startMergeBlock + : startBlock + const effectiveEndBlock = prefersLowestMergeBlocks ? endMergeBlock : endBlock + const isAcrossBlocks = + !!effectiveStartBlock && + !!effectiveEndBlock && + !PathApi.equals(effectiveStartBlock[1], effectiveEndBlock[1]) + const isSingleText = PathApi.equals(start.path, end.path) + const startNonEditable = voids + ? undefined + : getHighestNonEditable(editor, start) + const endNonEditable = voids ? undefined : getHighestNonEditable(editor, end) + + if (startNonEditable) { + const before = EditorApi.before(editor, start) + + if ( + before && + startBlock && + PathApi.isAncestor(startBlock[1], before.path) + ) { + start = before + } + } + + if (endNonEditable) { + const after = EditorApi.after(editor, end) + + if (after && endBlock && PathApi.isAncestor(endBlock[1], after.path)) { + end = after + } + } + + const preserveEndBlock = + !hanging && + !isCollapsed && + !!effectiveEndBlock && + PointApi.equals(end, EditorApi.start(editor, effectiveEndBlock[1])) + const originalHangingBlockRange = + !!initialAt && + Location.isRange(initialAt) && + !RangeApi.isCollapsed(initialAt) && + (() => { + const [, originalEnd] = RangeApi.edges(initialAt) + const originalEndBlock = EditorApi.above(editor, { + at: originalEnd, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'highest', + voids, + }) + + return ( + !!originalEndBlock && + !!effectiveStartBlock && + !PathApi.equals(effectiveStartBlock[1], originalEndBlock[1]) && + PointApi.equals( + originalEnd, + EditorApi.start(editor, originalEndBlock[1]) + ) + ) + })() + const preserveEmptyStartBlockPath = + shouldPreserveEmptyStartBlockForHangingRange( + editor, + start, + isSingleText, + isAcrossBlocks, + preserveEndBlock, + originalHangingBlockRange, + effectiveStartBlock + ) + const preservedEmptyStartBlock = + preserveEmptyStartBlockPath && + effectiveStartBlock && + NodeApi.isElement(effectiveStartBlock[0]) + ? ({ + ...effectiveStartBlock[0], + children: [{ text: '' }], + } as SlateElement) + : null + + return { + kind: 'range', + initialAt, + reverse, + unit, + distance, + voids, + isCollapsed, + start, + end, + effectiveRange: { anchor: start, focus: end }, + isSingleText, + isAcrossBlocks, + startNonEditable, + endNonEditable, + preserveEndBlock, + preserveEmptyStartBlockPath, + preservedEmptyStartBlock, + effectiveEndBlockPath: effectiveEndBlock?.[1] ?? null, + removedInteriorElementSiblingStructure: + shouldKeepSplitTextAfterInteriorElementRemoval( + editor, + start, + end, + isAcrossBlocks + ), + } +} + +const deletePathTarget = (editor: Editor, target: DeletePathTarget) => { + editor.apply({ + type: 'remove_node', + path: target.path, + node: getCurrentNode(editor, target.path), + }) + + if (editor.hasPath(target.path)) { + maybeMergeAdjacentTextAt(editor, target.path) + } + + if (target.fallbackPoint) { + editor.apply( + createSetSelectionOperation(getCurrentSelection(editor), { + anchor: target.fallbackPoint, + focus: target.fallbackPoint, + }) + ) + } +} + +const collectDeleteMatchPaths = (editor: Editor, plan: DeleteRangePlan) => { + const matches: Path[] = [] + let lastPath: Path | undefined + + for (const [node, path] of EditorApi.nodes(editor, { + at: plan.effectiveRange, + voids: plan.voids, + })) { + if (lastPath && PathApi.compare(path, lastPath) === 0) { + continue + } + + if ( + plan.preserveEndBlock && + plan.effectiveEndBlockPath && + PathApi.isAncestor(plan.effectiveEndBlockPath, path) + ) { + lastPath = path + continue + } + + if ( + !PathApi.isCommon(path, plan.start.path) && + !PathApi.isCommon(path, plan.end.path) + ) { + matches.push(path) + lastPath = path + continue + } + + if ( + !plan.voids && + NodeApi.isElement(node) && + (editor.isVoid(node) || editor.isElementReadOnly(node)) + ) { + matches.push(path) + lastPath = path + } + } + + return matches +} + +const removeDeleteContents = (editor: Editor, plan: DeleteRangePlan) => { + const pathRefs = collectDeleteMatchPaths(editor, plan).map((path) => + editor.pathRef(path) + ) + const startRef = editor.pointRef(plan.start) + const endRef = editor.pointRef(plan.end) + let removedText = '' + + if (!plan.isSingleText && !plan.startNonEditable) { + const point = startRef.current! + const [node] = EditorApi.leaf(editor, point) + const text = node.text.slice(plan.start.offset) + + if (text.length > 0) { + editor.apply({ + type: 'remove_text', + path: point.path, + offset: plan.start.offset, + text, + }) + removedText = text + } + } + + pathRefs + .slice() + .reverse() + .map((ref) => ref.unref()) + .filter((path): path is Path => path !== null) + .forEach((path) => { + editor.apply({ + type: 'remove_node', + path, + node: getCurrentNode(editor, path), + }) + }) + + if (!plan.endNonEditable && !plan.preserveEndBlock) { + const point = + resolveRemovalEndPoint(editor, plan, startRef.current, endRef.current) ?? + getLivePoint(editor, startRef.current) + + if (!point) { + throw new Error('deleteAt could not resolve a surviving end point') + } + + const [node] = EditorApi.leaf(editor, point) + const offset = plan.isSingleText ? plan.start.offset : 0 + const text = node.text.slice(offset, plan.end.offset) + + if (text.length > 0) { + editor.apply({ + type: 'remove_text', + path: point.path, + offset, + text, + }) + removedText = text + } + } + + return { + startRef, + endRef, + removedText, + } +} + +const reconcileDeleteStructure = ( + editor: Editor, + plan: DeleteRangePlan, + removal: ReturnType +) => { + if (!plan.isSingleText && plan.isAcrossBlocks) { + const mergePoint = shouldMergeAcrossBlocks(plan) + ? resolveMergePoint( + editor, + plan, + removal.startRef.current, + removal.endRef.current + ) + : null - for (const entry of Editor.nodes(editor, { at, voids })) { - const [node, path] = entry + if (plan.preserveEndBlock && plan.preserveEmptyStartBlockPath) { + removeEmptyStructuralArtifacts(editor, plan.preserveEmptyStartBlockPath) + mergeAdjacentTextRuns(editor) + } else if (plan.preserveEndBlock && mergePoint) { + mergeBlocksAtPoint(editor, mergePoint, plan.voids) + removeEmptyStructuralArtifacts(editor, plan.preserveEmptyStartBlockPath) + mergeAdjacentTextRuns(editor) + } else if (plan.voids && mergePoint && plan.effectiveEndBlockPath) { + mergeNodes(editor, { + at: plan.effectiveEndBlockPath, + }) + removeEmptyStructuralArtifacts(editor, plan.preserveEmptyStartBlockPath) + mergeAdjacentTextRuns(editor) + } else if (mergePoint) { + mergeBlocksAtPoint(editor, mergePoint, plan.voids) + removeEmptyStructuralArtifacts(editor, plan.preserveEmptyStartBlockPath) + mergeAdjacentTextRuns(editor) + } else { + removeEmptyStructuralArtifacts(editor, plan.preserveEmptyStartBlockPath) - if (lastPath && Path.compare(path, lastPath) === 0) { - continue + if (!plan.startNonEditable && !plan.endNonEditable) { + mergeAdjacentTextRuns(editor) } + } + } else if (!plan.isSingleText) { + removeEmptyStructuralArtifacts(editor) + + if (!plan.removedInteriorElementSiblingStructure) { + mergeAdjacentTextRuns(editor) + } + } + + restorePreservedEmptyStartBlock( + editor, + plan.preserveEmptyStartBlockPath, + plan.preservedEmptyStartBlock + ) + + if (plan.initialAt == null) { + maybeMergeAdjacentTextAt(editor, removal.endRef.current?.path) + } +} + +const resolveDeleteSelection = ( + editor: Editor, + plan: DeleteRangePlan, + removal: ReturnType +) => { + let complexScriptSelection: DeletePoint | null = null + + if ( + plan.isCollapsed && + plan.reverse && + plan.unit === 'character' && + removal.removedText.length > 1 && + COMPLEX_SCRIPT_CHARACTER_REGEX.test(removal.removedText) + ) { + EditorApi.insertText( + editor, + removal.removedText.slice(0, removal.removedText.length - plan.distance) + ) + + const currentSelection = getCurrentSelection(editor) + + if (currentSelection && RangeApi.isCollapsed(currentSelection)) { + complexScriptSelection = currentSelection.anchor + } + } + + const startPoint = removal.startRef.unref() + const endPoint = removal.endRef.unref() + const currentSelection = getCurrentSelection(editor) + const collapseTarget = + complexScriptSelection ?? + (!plan.isCollapsed && + currentSelection && + (plan.startNonEditable != null || plan.endNonEditable != null) + ? currentSelection.anchor + : !plan.isCollapsed && editor.hasPath(plan.start.path) + ? { path: [...plan.start.path], offset: plan.start.offset } + : (startPoint ?? endPoint)) + let point = normalizeFinalDeletePoint(editor, collapseTarget, { + reverse: plan.reverse, + allowForwardBoundaryJump: + (plan.initialAt != null && Location.isPoint(plan.initialAt)) || + (plan.initialAt != null && + Location.isRange(plan.initialAt) && + RangeApi.isCollapsed(plan.initialAt)), + }) + + if (!plan.reverse && !plan.isCollapsed) { + point = moveLeadingSpacerPointIntoFollowingInline(editor, point) + } + + if (!plan.reverse && plan.isCollapsed) { + point = moveTrailingTextPointIntoFollowingInline(editor, point) + } + + if (!plan.reverse && !plan.isCollapsed && plan.isAcrossBlocks) { + point = movePointToFollowingInline(editor, point) + } + + if ( + plan.reverse && + plan.unit === 'character' && + point && + point.path.length >= 2 + ) { + const parentPath = point.path.slice(0, -1) as Path + + if (editor.hasPath(parentPath)) { + const parent = getCurrentNode(editor, parentPath) if ( - (!voids && - Node.isElement(node) && - (Editor.isVoid(editor, node) || - Editor.isElementReadOnly(editor, node))) || - (!Path.isCommon(path, start.path) && !Path.isCommon(path, end.path)) + NodeApi.isElement(parent) && + editor.isInline(parent) && + PointApi.equals(point, EditorApi.start(editor, parentPath)) ) { - matches.push(entry) - lastPath = path + const previousSiblingPath = + parentPath.at(-1) === 0 ? null : PathApi.previous(parentPath) + + if (previousSiblingPath && editor.hasPath(previousSiblingPath)) { + const previousSibling = getCurrentNode(editor, previousSiblingPath) + + if (isTextNode(previousSibling) && previousSibling.text === '') { + const nextSiblingPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if (isTextNode(nextSibling) && nextSibling.text === '') { + point = { path: [...point.path], offset: point.offset } + } else { + point = { path: previousSiblingPath, offset: 0 } + } + } else { + point = { path: previousSiblingPath, offset: 0 } + } + } + } + } + } + } + + if ((!plan.initialAt || !Location.isPath(plan.initialAt)) && point) { + editor.apply( + createSetSelectionOperation(getCurrentSelection(editor), { + anchor: point, + focus: point, + }) + ) + } + + const finalSelection = getCurrentSelection(editor) + + if (finalSelection && RangeApi.isCollapsed(finalSelection)) { + let normalizedSelectionPoint = normalizeFinalDeletePoint( + editor, + finalSelection.anchor, + { + reverse: plan.reverse, + allowForwardBoundaryJump: + (plan.initialAt != null && Location.isPoint(plan.initialAt)) || + (plan.initialAt != null && + Location.isRange(plan.initialAt) && + RangeApi.isCollapsed(plan.initialAt)), } + ) + + if (!plan.reverse && !plan.isCollapsed) { + normalizedSelectionPoint = moveLeadingSpacerPointIntoFollowingInline( + editor, + normalizedSelectionPoint + ) + } + + if (!plan.reverse && plan.isCollapsed) { + normalizedSelectionPoint = moveTrailingTextPointIntoFollowingInline( + editor, + normalizedSelectionPoint + ) + } + + if (!plan.reverse && !plan.isCollapsed && plan.isAcrossBlocks) { + normalizedSelectionPoint = movePointToFollowingInline( + editor, + normalizedSelectionPoint + ) + } + + if ( + normalizedSelectionPoint && + !PointApi.equals(normalizedSelectionPoint, finalSelection.anchor) + ) { + editor.apply( + createSetSelectionOperation(finalSelection, { + anchor: normalizedSelectionPoint, + focus: normalizedSelectionPoint, + }) + ) } + } +} + +const normalizeFinalDeletePoint = ( + editor: Editor, + point: import('../interfaces').Point | null | undefined, + options: { reverse: boolean; allowForwardBoundaryJump: boolean } +) => { + if (!point) { + return point + } - const pathRefs = Array.from(matches, ([, p]) => Editor.pathRef(editor, p)) - const startRef = Editor.pointRef(editor, start) - const endRef = Editor.pointRef(editor, end) + if (!editor.hasPath(point.path as Path)) { + return editor.children.length > 0 ? EditorApi.start(editor, []) : point + } - let removedText = '' + if (point.offset === 0 && point.path.length > 0) { + const previousSiblingPath = + point.path.at(-1) === 0 ? null : PathApi.previous(point.path as Path) - if (!isSingleText && !startNonEditable) { - const point = startRef.current! - const [node] = Editor.leaf(editor, point) - const { path } = point - const { offset } = start - const text = node.text.slice(offset) - if (text.length > 0) { - editor.apply({ type: 'remove_text', path, offset, text }) - removedText = text + if (previousSiblingPath && editor.hasPath(previousSiblingPath)) { + const previousSibling = getCurrentNode(editor, previousSiblingPath) + + if ( + NodeApi.isElement(previousSibling) && + editor.isInline(previousSibling) && + !editor.isVoid(previousSibling) && + NodeApi.string(previousSibling) === '' + ) { + return EditorApi.start(editor, previousSiblingPath) } } - pathRefs - .reverse() - .map((r) => r.unref()) - .filter((r): r is Path => r !== null) - .forEach((p) => { - Transforms.removeNodes(editor, { at: p, voids }) - }) + const nextSiblingPath = + point.path.at(-1) == null ? null : PathApi.next(point.path as Path) + + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) - if (!endNonEditable) { - const point = endRef.current! - const [node] = Editor.leaf(editor, point) - const { path } = point - const offset = isSingleText ? start.offset : 0 - const text = node.text.slice(offset, end.offset) - if (text.length > 0) { - editor.apply({ type: 'remove_text', path, offset, text }) - removedText = text + if ( + NodeApi.isElement(nextSibling) && + editor.isInline(nextSibling) && + !editor.isVoid(nextSibling) && + NodeApi.string(nextSibling) === '' + ) { + return EditorApi.start(editor, nextSiblingPath) } } + } - if (!isSingleText && isAcrossBlocks && endRef.current && startRef.current) { - Transforms.mergeNodes(editor, { - at: endRef.current, - hanging: true, - voids, - }) + if (!options.reverse) { + if ( + !options.allowForwardBoundaryJump && + point.path.length >= 2 && + editor.hasPath(point.path as Path) && + isTextNode(getCurrentNode(editor, point.path as Path)) + ) { + const currentTextNode = getCurrentNode(editor, point.path as Path) + const parentPath = point.path.slice(0, -1) as Path + + if (editor.hasPath(parentPath)) { + const parent = getCurrentNode(editor, parentPath) + + if ( + NodeApi.isElement(parent) && + editor.isInline(parent) && + isTextNode(currentTextNode) && + point.offset === currentTextNode.text.length + ) { + const spacerPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (spacerPath && editor.hasPath(spacerPath)) { + const spacer = getCurrentNode(editor, spacerPath) + + if (isTextNode(spacer) && spacer.text === '') { + const nextSiblingPath = + spacerPath.at(-1) == null ? null : PathApi.next(spacerPath) + + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if ( + NodeApi.isElement(nextSibling) && + editor.isInline(nextSibling) + ) { + return EditorApi.start(editor, nextSiblingPath) + } + } + } + } + } + } } - // For certain scripts, deleting N character(s) backward should delete - // N code point(s) instead of an entire grapheme cluster. - // Therefore, the remaining code points should be inserted back. - // Bengali: \u0980-\u09FF - // Thai: \u0E00-\u0E7F - // Burmese (Myanmar): \u1000-\u109F - // Hindi (Devanagari): \u0900-\u097F - // Khmer: \u1780-\u17FF - // Malayalam: \u0D00-\u0D7F - // Oriya: \u0B00-\u0B7F - // Punjabi (Gurmukhi): \u0A00-\u0A7F - // Tamil: \u0B80-\u0BFF - // Telugu: \u0C00-\u0C7F if ( - isCollapsed && - reverse && - unit === 'character' && - removedText.length > 1 && - removedText.match(COMPLEX_SCRIPT_RE) + point.path.length > 0 && + editor.hasPath(point.path as Path) && + isTextNode(getCurrentNode(editor, point.path as Path)) ) { - Transforms.insertText( - editor, - removedText.slice(0, removedText.length - distance) + const parentPath = point.path.slice(0, -1) as Path + + if (editor.hasPath(parentPath)) { + const parent = getCurrentNode(editor, parentPath) + + if ( + NodeApi.isElement(parent) && + editor.isInline(parent) && + editor.isVoid(parent) && + PointApi.equals(point, EditorApi.start(editor, parentPath)) + ) { + const previousSiblingPath = + parentPath.at(-1) === 0 ? null : PathApi.previous(parentPath) + + if (previousSiblingPath && editor.hasPath(previousSiblingPath)) { + return EditorApi.end(editor, previousSiblingPath) + } + } + } + } + + if (!options.allowForwardBoundaryJump) { + return point + } + + if (point.path.length > 0) { + const currentNode = getCurrentNode(editor, point.path as Path) + const nextSiblingPath = + point.path.at(-1) == null ? null : PathApi.next(point.path as Path) + + if ( + isTextNode(currentNode) && + point.offset === currentNode.text.length && + nextSiblingPath && + editor.hasPath(nextSiblingPath) + ) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if ( + NodeApi.isElement(nextSibling) && + (!editor.isInline(nextSibling) || editor.isVoid(nextSibling)) + ) { + return EditorApi.start(editor, nextSiblingPath) + } + } + + if (point.path.length >= 2) { + const parentPath = point.path.slice(0, -1) as Path + + if (editor.hasPath(parentPath)) { + const parent = getCurrentNode(editor, parentPath) + + if ( + NodeApi.isElement(parent) && + parentPath.length > 1 && + PointApi.equals(point, EditorApi.end(editor, parentPath)) + ) { + if ( + editor.isInline(parent) && + !editor.isVoid(parent) && + NodeApi.string(parent) === '' + ) { + return point + } + + const afterParentPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (afterParentPath && editor.hasPath(afterParentPath)) { + return EditorApi.start(editor, afterParentPath) + } + } + } + } + } + + return point + } + + if ( + editor.hasPath(point.path as Path) && + point.path.length >= 2 && + isTextNode(getCurrentNode(editor, point.path as Path)) && + (() => { + const parentPath = point.path.slice(0, -1) as Path + + if (!editor.hasPath(parentPath) || parentPath.at(-1) === 0) { + return false + } + + const parent = getCurrentNode(editor, parentPath) + + return ( + NodeApi.isElement(parent) && + !( + editor.isInline(parent) && + !editor.isVoid(parent) && + NodeApi.string(parent) === '' + ) && + parentPath.length > 1 && + PointApi.equals(point, EditorApi.end(editor, parentPath)) ) + })() + ) { + const parentPath = point.path.slice(0, -1) as Path + const nextSiblingPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if (isTextNode(nextSibling) && nextSibling.text.length > 0) { + return EditorApi.start(editor, nextSiblingPath) + } + } + } + + if (point.offset !== 0 || point.path.length < 2) { + if (point.offset !== 0 || point.path.length === 0) { + return point + } + + const currentNode = editor.hasPath(point.path as Path) + ? getCurrentNode(editor, point.path as Path) + : null + + if (currentNode && isTextNode(currentNode) && currentNode.text === '') { + return point + } + + const nextSiblingPath = + point.path.at(-1) == null ? null : PathApi.next(point.path) + + if (!nextSiblingPath || !editor.hasPath(nextSiblingPath)) { + return point + } + + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + return NodeApi.isElement(nextSibling) && + editor.isInline(nextSibling) && + nextSibling.children.length > 0 + ? EditorApi.start(editor, nextSiblingPath) + : point + } + + const parentPath = point.path.slice(0, -1) as Path + + if (!editor.hasPath(parentPath) || parentPath.at(-1) === 0) { + return point + } + + const parent = getCurrentNode(editor, parentPath) + + if (!NodeApi.isElement(parent) || !editor.isInline(parent)) { + return point + } + + if (!editor.isVoid(parent) && NodeApi.string(parent) === '') { + return point + } + + if ( + point.offset === 0 && + isTextNode(getCurrentNode(editor, point.path as Path)) + ) { + const previousSiblingPath = + parentPath.at(-1) === 0 ? null : PathApi.previous(parentPath) + + if (previousSiblingPath && editor.hasPath(previousSiblingPath)) { + const previousSibling = getCurrentNode(editor, previousSiblingPath) + + if (isTextNode(previousSibling) && previousSibling.text === '') { + const nextSiblingPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) + + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) + + if (isTextNode(nextSibling) && nextSibling.text === '') { + return point + } + } + + return { path: previousSiblingPath, offset: 0 } + } } + } + + if ( + isTextNode(getCurrentNode(editor, point.path as Path)) && + PointApi.equals(point, EditorApi.end(editor, parentPath)) + ) { + const nextSiblingPath = + parentPath.at(-1) == null ? null : PathApi.next(parentPath) - const startUnref = startRef.unref() - const endUnref = endRef.unref() - const point = reverse ? startUnref || endUnref : endUnref || startUnref + if (nextSiblingPath && editor.hasPath(nextSiblingPath)) { + const nextSibling = getCurrentNode(editor, nextSiblingPath) - if (options.at == null && point) { - Transforms.select(editor, point) + if (isTextNode(nextSibling) && nextSibling.text.length > 0) { + return EditorApi.start(editor, nextSiblingPath) + } } + } + + const previousSiblingPath = PathApi.previous(parentPath) + + if (!editor.hasPath(previousSiblingPath)) { + return point + } + + const previousSibling = getCurrentNode(editor, previousSiblingPath) + + return isTextNode(previousSibling) && previousSibling.text === '' + ? { path: previousSiblingPath, offset: 0 } + : point +} + +const getCollapsedDeleteTarget = ( + editor: Editor, + at: import('../interfaces').Point, + options: { + reverse: boolean + distance: number + unit: NonNullable< + Editor['delete'] extends (options?: infer T) => unknown + ? T extends { unit?: infer U } + ? U + : never + : never + > + voids: boolean + } +) => { + const { reverse, distance, unit, voids } = options + const pointTarget = reverse + ? (EditorApi.before(editor, at, { distance, unit, voids }) ?? + EditorApi.start(editor, [])) + : (EditorApi.after(editor, at, { distance, unit, voids }) ?? + EditorApi.end(editor, [])) + + if (unit !== 'character' || distance !== 1) { + return pointTarget + } + + const [leaf] = EditorApi.leaf(editor, at) + const atBoundary = reverse ? at.offset === 0 : at.offset === leaf.text.length + + if (!atBoundary) { + return pointTarget + } + + const offsetTarget = reverse + ? EditorApi.before(editor, at, { distance, unit: 'offset', voids }) + : EditorApi.after(editor, at, { distance, unit: 'offset', voids }) + + if (!offsetTarget) { + return pointTarget + } + + const currentBlock = EditorApi.above(editor, { + at, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'lowest', + voids, + }) + const targetBlock = EditorApi.above(editor, { + at: pointTarget, + match: (node) => NodeApi.isElement(node) && editor.isBlock(node), + mode: 'lowest', + voids, + }) + + if ( + currentBlock && + targetBlock && + ((reverse && + PointApi.equals(at, EditorApi.start(editor, currentBlock[1]))) || + (!reverse && + PointApi.equals(at, EditorApi.end(editor, currentBlock[1])))) && + !PathApi.equals(currentBlock[1], targetBlock[1]) + ) { + return offsetTarget + } + + return pointTarget +} + +const mergeBlocksAtPoint = ( + editor: Editor, + point: import('../interfaces').Point, + voids: boolean +) => { + const match = (node: import('../interfaces').Node) => + NodeApi.isElement(node) && editor.isBlock(node) + const current = EditorApi.above(editor, { + at: point, + match, + mode: 'lowest', + voids, + }) + + if (!current) { + return + } + + const [_node, path] = current + const prev = EditorApi.previous(editor, { + at: path, + match, + mode: 'lowest', + voids, + }) + + if (!prev) { + return + } + + const [_prevNode, prevPath] = prev + + if (path.length === 0 || prevPath.length === 0) { + return + } + + const newPath = PathApi.next(prevPath) + const commonPath = PathApi.common(path, prevPath) + const isPreviousSibling = PathApi.isSibling(path, prevPath) + const levels = Array.from( + EditorApi.levels(editor, { at: path }), + ([entry]) => entry + ) + .slice(commonPath.length) + .slice(0, -1) + + const emptyAncestor = EditorApi.above(editor, { + at: path, + mode: 'highest', + match: (entry) => + levels.includes(entry) && hasSingleChildNest(editor, entry), + }) + + const emptyRef = emptyAncestor ? editor.pathRef(emptyAncestor[1]) : null + + if (!isPreviousSibling) { + editor.moveNodes({ at: path, to: newPath }) + } + + if (emptyRef?.current) { + editor.removeNodes({ at: emptyRef.current }) + } + + const movedCurrentPath = isPreviousSibling ? path : newPath + const movedCurrent = getCurrentNode(editor, movedCurrentPath) + const previous = getCurrentNode(editor, prevPath) + + if (!NodeApi.isElement(movedCurrent) || !NodeApi.isElement(previous)) { + emptyRef?.unref() + return + } + + if ( + editor.shouldMergeNodesRemovePrevNode( + [previous, prevPath], + [movedCurrent, movedCurrentPath] + ) + ) { + editor.removeNodes({ at: prevPath }) + emptyRef?.unref() + return + } + + editor.apply({ + type: 'merge_node', + path: movedCurrentPath, + position: previous.children.length, + properties: Object.fromEntries( + Object.entries(movedCurrent).filter( + ([key]) => key !== 'type' && key !== 'children' + ) + ), + }) + + cleanupEmptyAncestors(editor, path) + emptyRef?.unref() +} + +export const deleteText: TextTransforms['delete'] = (editor, options = {}) => { + withTransaction(editor, () => { + const target = resolveDeleteTarget(editor, options) + + if (!target) { + return + } + + if (target.kind === 'path') { + deletePathTarget(editor, target) + return + } + + const removal = removeDeleteContents(editor, target) + + reconcileDeleteStructure(editor, target, removal) + resolveDeleteSelection(editor, target, removal) }) } diff --git a/packages/slate/src/utils/get-default-insert-location.ts b/packages/slate/src/utils/get-default-insert-location.ts index 5493386022..d952c4272c 100644 --- a/packages/slate/src/utils/get-default-insert-location.ts +++ b/packages/slate/src/utils/get-default-insert-location.ts @@ -7,8 +7,10 @@ import { Editor, type Location } from '../interfaces' * common use case when inserting from a non-selected state. */ export const getDefaultInsertLocation = (editor: Editor): Location => { - if (editor.selection) { - return editor.selection + const selection = Editor.getSnapshot(editor).selection + + if (selection) { + return selection } if (editor.children.length > 0) { return Editor.end(editor, []) diff --git a/packages/slate/src/utils/modify.ts b/packages/slate/src/utils/modify.ts index 61be357853..9dcbcffe51 100644 --- a/packages/slate/src/utils/modify.ts +++ b/packages/slate/src/utils/modify.ts @@ -7,6 +7,7 @@ import { Scrubber, type Text, } from '../interfaces' +import { inheritRuntimeId } from './runtime-ids' export const insertChildren = ( xs: T[], @@ -38,6 +39,7 @@ export const modifyDescendant = ( const node = Node.get(root, path) as N const slicedPath = path.slice() let modifiedNode: Node = f(node) + inheritRuntimeId(modifiedNode, node) while (slicedPath.length > 1) { const index = slicedPath.pop()! @@ -47,6 +49,7 @@ export const modifyDescendant = ( ...ancestorNode, children: replaceChildren(ancestorNode.children, index, 1, modifiedNode), } + inheritRuntimeId(modifiedNode, ancestorNode) } const index = slicedPath.pop()! diff --git a/packages/slate/src/utils/runtime-ids.ts b/packages/slate/src/utils/runtime-ids.ts new file mode 100644 index 0000000000..2a482e8a09 --- /dev/null +++ b/packages/slate/src/utils/runtime-ids.ts @@ -0,0 +1,100 @@ +import type { Descendant, RuntimeId, SnapshotIndex } from '../interfaces' +import type { Editor } from '../interfaces/editor' +import type { Path } from '../interfaces/path' + +const NODE_OWNERS = new WeakMap() +const NODE_RUNTIME_IDS = new WeakMap() +const NEXT_RUNTIME_ID = new WeakMap() + +const pathKey = (path: Path) => path.join('.') + +const allocateRuntimeId = (editor: Editor): RuntimeId => { + const next = NEXT_RUNTIME_ID.get(editor) ?? 0 + NEXT_RUNTIME_ID.set(editor, next + 1) + return `n${next}` as RuntimeId +} + +export const getOrCreateRuntimeId = ( + node: object, + owner?: Editor +): RuntimeId => { + const existing = NODE_RUNTIME_IDS.get(node) + + if (existing) { + return existing + } + + const editor = owner ?? NODE_OWNERS.get(node) + + if (!editor) { + throw new Error('Missing runtime-id owner for node') + } + + const runtimeId = allocateRuntimeId(editor) + NODE_OWNERS.set(node, editor) + NODE_RUNTIME_IDS.set(node, runtimeId) + return runtimeId +} + +export const setRuntimeId = ( + node: object, + editor: Editor, + runtimeId: RuntimeId +) => { + NODE_OWNERS.set(node, editor) + NODE_RUNTIME_IDS.set(node, runtimeId) + + const numericPart = Number.parseInt(runtimeId.slice(1), 10) + const next = NEXT_RUNTIME_ID.get(editor) ?? 0 + + if (Number.isFinite(numericPart) && numericPart >= next) { + NEXT_RUNTIME_ID.set(editor, numericPart + 1) + } +} + +export const inheritRuntimeId = (nextNode: object, previousNode: object) => { + const runtimeId = NODE_RUNTIME_IDS.get(previousNode) + const owner = NODE_OWNERS.get(previousNode) + + if (!runtimeId || !owner) { + return + } + + NODE_OWNERS.set(nextNode, owner) + NODE_RUNTIME_IDS.set(nextNode, runtimeId) +} + +export const seedRuntimeIds = ( + children: readonly Descendant[], + editor: Editor +) => { + for (const child of children) { + getOrCreateRuntimeId(child, editor) + + if ('children' in child && Array.isArray(child.children)) { + seedRuntimeIds(child.children, editor) + } + } +} + +export const seedRuntimeIdsFromIndex = ( + children: readonly Descendant[], + editor: Editor, + existingIndex: SnapshotIndex, + parentPath: Path = [] +) => { + children.forEach((child, index) => { + const path = [...parentPath, index] as Path + const runtimeId = existingIndex.pathToId[pathKey(path)] + + if (runtimeId) { + setRuntimeId(child, editor, runtimeId) + } else { + getOrCreateRuntimeId(child, editor) + } + + if ('children' in child && Array.isArray(child.children)) { + seedRuntimeIdsFromIndex(child.children, editor, existingIndex, path) + } + }) +} diff --git a/packages/slate/src/utils/weak-maps.ts b/packages/slate/src/utils/weak-maps.ts index c8407769fc..dfa78c2ba8 100644 --- a/packages/slate/src/utils/weak-maps.ts +++ b/packages/slate/src/utils/weak-maps.ts @@ -1,5 +1,6 @@ import type { Editor, Path, PathRef, PointRef, RangeRef } from '..' +export const ALL_RANGE_REFS: WeakMap> = new WeakMap() export const DIRTY_PATHS: WeakMap = new WeakMap() export const DIRTY_PATH_KEYS: WeakMap> = new WeakMap() export const FLUSHING: WeakMap = new WeakMap() diff --git a/packages/slate/test/accessor-transaction.test.ts b/packages/slate/test/accessor-transaction.test.ts new file mode 100644 index 0000000000..2807b55cff --- /dev/null +++ b/packages/slate/test/accessor-transaction.test.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + type Operation, + Transforms, +} from '../src' + +const paragraph = ( + text: string, + props: Record = {} +): Descendant => ({ + type: 'paragraph', + ...props, + children: [{ text }], +}) + +const clone = (value: T): T => structuredClone(value) + +const replaceChildren = ( + editor: ReturnType, + children: Descendant[] +) => { + Editor.replace(editor, { + children: clone(children), + selection: null, + marks: null, + }) +} + +const runManualTransaction = ( + editor: ReturnType, + operations: Operation[] +) => { + Editor.withTransaction(editor, () => { + for (const operation of clone(operations)) { + editor.apply(operation) + } + }) +} + +const getVisibleState = (editor: ReturnType) => { + const snapshot = Editor.getSnapshot(editor) + + return { + children: snapshot.children, + marks: snapshot.marks, + selection: snapshot.selection, + pathToId: snapshot.index.pathToId, + } +} + +describe('slate public accessor + transaction seam', () => { + it('children accessor routes through getChildren and setChildren', () => { + const editor = createEditor() as ReturnType & + Record + const calls: string[] = [] + const originalGetChildren = editor.getChildren + const originalSetChildren = editor.setChildren + const value = [paragraph('one')] + + editor.getChildren = () => { + calls.push('get') + return originalGetChildren() + } + + editor.setChildren = (children) => { + calls.push('set') + originalSetChildren(children) + } + + editor.children = value + const currentChildren = editor.children + + assert.deepEqual(currentChildren, value) + assert.equal(calls[0], 'set') + assert.equal(calls.includes('get'), true) + assert.equal(Editor.isEditor(editor, { deep: true }), true) + assert.deepEqual(Editor.getChildren(editor), value) + }) + + it('Editor.setChildren routes through the overrideable instance method', () => { + const editor = createEditor() as ReturnType & + Record + const calls: unknown[][] = [] + const originalSetChildren = editor.setChildren + const value = [paragraph('set')] + + editor.setChildren = (children) => { + calls.push(['setChildren', children]) + originalSetChildren(children) + } + + Editor.setChildren(editor, value) + + assert.deepEqual(calls[0], ['setChildren', value]) + assert.deepEqual(Editor.getChildren(editor), value) + }) + + it('withTransaction keeps direct replacement draft-visible and publishes once on exit', () => { + const editor = createEditor() + const publishedStates: ReturnType[] = [] + + replaceChildren(editor, [paragraph('one'), paragraph('two')]) + + const unsubscribe = Editor.subscribe(editor, () => { + publishedStates.push(getVisibleState(editor)) + }) + + publishedStates.length = 0 + + Editor.withTransaction(editor, () => { + editor.children = [paragraph('replacement')] + + assert.equal(publishedStates.length, 0) + assert.equal(Editor.string(editor, [0]), 'replacement') + + editor.apply({ + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'p0' }, + }) + + assert.equal(publishedStates.length, 0) + assert.deepEqual(Editor.getChildren(editor), [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'replacement' }], + }, + ]) + }) + + unsubscribe() + + assert.equal(publishedStates.length, 1) + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'replacement' }], + }, + ]) + }) + + it('applyBatch matches manual withTransaction for mixed text, selection, and node ops', () => { + const children = [paragraph('abcd')] + const selection = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + } + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'insert_text', + path: [0, 0], + offset: 1, + text: 'X', + }, + { + type: 'set_selection', + properties: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + newProperties: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + }, + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'p0' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + Transforms.select(batchEditor, selection) + Transforms.select(manualEditor, selection) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'aXbcd' }], + }, + ]) + assert.deepEqual(Editor.getSnapshot(batchEditor).selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }) + }) + + it('applyBatch matches manual withTransaction for duplicate exact-path set_node writes', () => { + const children = [paragraph('one'), paragraph('two'), paragraph('three')] + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'blue' }, + }, + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'final', role: 'final' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + { + type: 'paragraph', + id: 'final', + role: 'final', + children: [{ text: 'one' }], + }, + paragraph('two'), + paragraph('three'), + ]) + }) + + it('applyBatch matches manual withTransaction for structural insert, move, and set batches', () => { + const children = [paragraph('zero'), paragraph('one')] + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'insert_node', + path: [2], + node: paragraph('two'), + }, + { + type: 'move_node', + path: [2], + newPath: [0], + }, + { + type: 'set_node', + path: [1], + properties: {}, + newProperties: { id: 'shifted' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + paragraph('two'), + { + type: 'paragraph', + id: 'shifted', + children: [{ text: 'zero' }], + }, + paragraph('one'), + ]) + }) + + it('withTransaction rolls back staged changes when a later operation throws', () => { + const editor = createEditor() + + replaceChildren(editor, [paragraph('one'), paragraph('two')]) + + const before = getVisibleState(editor) + + assert.throws(() => { + Editor.withTransaction(editor, () => { + editor.apply({ + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'blue' }, + }) + + editor.apply({ + type: 'set_node', + path: [0], + properties: {}, + newProperties: { children: [] }, + }) + }) + }, /set_node does not update child content/) + + assert.deepEqual(getVisibleState(editor), before) + }) +}) diff --git a/packages/slate/test/bookmark-contract.ts b/packages/slate/test/bookmark-contract.ts new file mode 100644 index 0000000000..329db56243 --- /dev/null +++ b/packages/slate/test/bookmark-contract.ts @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createEditor, type Descendant, Editor, Transforms } from '../src' + +const createChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createSplitChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, +] + +const createMergeChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createMoveChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createRange = ( + anchor: { path: number[]; offset: number }, + focus: { path: number[]; offset: number } +) => ({ + anchor, + focus, +}) + +describe('slate bookmark contract', () => { + it('round-trips a bookmark on an unchanged snapshot and hides its backing range ref', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const range = createRange( + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 4 } + ) + const bookmark = Editor.bookmark(editor, range) + + assert.equal(Editor.rangeRefs(editor).size, 0) + assert.deepEqual(bookmark.resolve(), range) + assert.deepEqual(bookmark.unref(), range) + }) + + it('maps through text inserted before the anchor range without mounted DOM', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createSplitChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [0, 0], offset: 1 }, { path: [0, 0], offset: 4 }) + ) + + Transforms.insertText(editor, '>', { + at: { path: [0, 0], offset: 0 }, + }) + + assert.deepEqual(bookmark.resolve(), { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 5 }, + }) + assert.equal(Editor.string(editor, bookmark.resolve()!), 'lph') + }) + + it('defaults bookmark boundary behavior inward for annotation-style anchors', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createSplitChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [0, 0], offset: 1 }, { path: [0, 0], offset: 4 }) + ) + + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 4 }, + }) + + assert.deepEqual(bookmark.resolve(), { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + assert.equal(Editor.string(editor, bookmark.resolve()!), 'lph') + }) + + it('survives splitNodes block splitting across a bookmarked text span', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createSplitChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [0, 0], offset: 1 }, { path: [0, 0], offset: 4 }) + ) + + Transforms.splitNodes(editor, { + at: { path: [0, 0], offset: 2 }, + }) + + const resolved = bookmark.resolve() + + assert.ok(resolved) + assert.equal(Editor.string(editor, resolved), 'lph') + assert.deepEqual(resolved, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) + }) + + it('survives merge_node of the bookmarked block container', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createMergeChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [1, 0], offset: 1 }, { path: [1, 0], offset: 3 }) + ) + + editor.apply({ + type: 'merge_node', + path: [1], + position: 1, + properties: { type: 'paragraph' }, + }) + + const resolved = bookmark.resolve() + + assert.ok(resolved) + assert.deepEqual(resolved, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 8 }, + }) + assert.equal(Editor.string(editor, resolved), 'et') + }) + + it('survives move_node of the containing block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createMoveChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [1, 0], offset: 1 }, { path: [1, 0], offset: 3 }) + ) + + Transforms.moveNodes(editor, { at: [1], to: [0] }) + + const resolved = bookmark.resolve() + + assert.ok(resolved) + assert.deepEqual(resolved, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }) + assert.equal(Editor.string(editor, resolved), 'et') + }) + + it('rebases across normalization-driven spacer insertion', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'gamma' }], + }, + ], + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [0, 0], offset: 1 }, { path: [0, 0], offset: 4 }) + ) + + Transforms.insertNodes( + editor, + { + type: 'inline', + children: [{ text: 'beta' }], + } as Descendant, + { at: [0, 0] } + ) + + const resolved = bookmark.resolve() + + assert.ok(resolved) + assert.deepEqual(resolved, { + anchor: { path: [0, 2], offset: 1 }, + focus: { path: [0, 2], offset: 4 }, + }) + assert.equal(Editor.string(editor, resolved), 'amm') + }) + + it('fails closed when the bookmarked content is fully deleted', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const bookmark = Editor.bookmark( + editor, + createRange({ path: [1, 0], offset: 1 }, { path: [1, 0], offset: 3 }) + ) + + Transforms.removeNodes(editor, { at: [1] }) + + assert.equal(bookmark.resolve(), null) + }) +}) diff --git a/packages/slate/test/clipboard-contract.ts b/packages/slate/test/clipboard-contract.ts new file mode 100644 index 0000000000..2531e58218 --- /dev/null +++ b/packages/slate/test/clipboard-contract.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createEditor, type Descendant, Editor, Transforms } from '../src' + +const createChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +describe('slate clipboard contract', () => { + it('extracts the selected fragment from an expanded selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 5 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.getFragment(editor), [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ]) + }) + + it('extracts a mixed inline fragment from a single top-level block selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'alpha ' }, + { + type: 'chip', + children: [{ text: 'beta' }], + }, + { text: ' gamma' }, + ], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 2], offset: 3 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.getFragment(editor), [ + { + type: 'paragraph', + children: [ + { text: 'ha ' }, + { + type: 'chip', + children: [{ text: 'beta' }], + }, + { text: ' ga' }, + ], + }, + ]) + }) + + it('treats an empty fragment insert as a no-op', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + + Transforms.insertFragment(editor, []) + + const after = Editor.getSnapshot(editor) + + assert.equal(after, before) + assert.equal(editor.operations.length, 0) + }) + + it('inserts a fragment into a collapsed text selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.insertFragment(editor, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ]) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'bealphata' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [1, 0], offset: 7 }, + focus: { path: [1, 0], offset: 7 }, + }) + }) + + it('replaces an expanded text selection with a fragment', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.insertFragment(editor, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ]) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [1, 0], offset: 5 }, + focus: { path: [1, 0], offset: 5 }, + }) + }) +}) diff --git a/packages/slate/test/extension-contract.ts b/packages/slate/test/extension-contract.ts new file mode 100644 index 0000000000..0675db50ab --- /dev/null +++ b/packages/slate/test/extension-contract.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + Element, + Node, + type NodeEntry, + type Editor as SlateEditor, + type Element as SlateElement, + Transforms, +} from '../src' + +const createParagraphChildren = (text = 'alpha'): Descendant[] => [ + { + type: 'paragraph', + children: [{ text }], + }, +] + +const getBlockTexts = (children: readonly Descendant[]) => + children.map((child) => + 'text' in child + ? child.text + : child.children + .map((descendant) => ('text' in descendant ? descendant.text : '')) + .join('') + ) + +const withTrackedInsertBreak = (editor: T) => { + const e = editor as T & { + getInsertBreakCalls: () => number + } + const originalInsertBreak = e.insertBreak + let calls = 0 + + e.insertBreak = () => { + calls += 1 + originalInsertBreak() + } + + e.getInsertBreakCalls = () => calls + + return e +} + +const createLinkNode = (url: string, text: string): Descendant => ({ + type: 'link', + url, + children: [{ text }], +}) + +const withLinks = (editor: T) => { + const e = editor as T & { + wrapLinkSelection: (url: string) => boolean + } + const { isInline } = editor + + editor.isInline = (element) => + element.type === 'link' ? true : isInline(element) + + e.wrapLinkSelection = (url: string) => { + const { selection } = Editor.getSnapshot(editor) + + if (!selection || selection.anchor.offset === selection.focus.offset) { + return false + } + + Transforms.wrapNodes(editor, createLinkNode(url, '') as SlateElement, { + split: true, + }) + + const currentSelection = Editor.getSnapshot(editor).selection + + if (currentSelection) { + const linkEntry = Editor.above(editor, { + at: currentSelection, + match: (node) => Element.isElement(node) && node.type === 'link', + }) + + if (linkEntry) { + const [, linkPath] = linkEntry + const after = Editor.after(editor, linkPath) + + if (after) { + Transforms.select(editor, after) + } + } + } + + return true + } + + return e +} + +const createMentionNode = (character: string): Descendant => ({ + type: 'mention', + character, + children: [{ text: '' }], +}) + +const withMentions = (editor: T) => { + const e = editor as T & { + insertMention: (character: string) => boolean + } + const { isInline, isVoid, markableVoid } = editor + + editor.isInline = (element) => + element.type === 'mention' ? true : isInline(element) + editor.isVoid = (element) => + element.type === 'mention' ? true : isVoid(element) + editor.markableVoid = (element) => + element.type === 'mention' || markableVoid(element) + + e.insertMention = (character: string) => { + Transforms.insertNodes(editor, createMentionNode(character)) + Transforms.move(editor) + + return true + } + + return e +} + +const createForcedLayoutTitle = (): Descendant => ({ + type: 'title', + children: [{ text: 'Untitled' }], +}) + +const createForcedLayoutParagraph = (): Descendant => ({ + type: 'paragraph', + children: [{ text: '' }], +}) + +const withForcedLayout = (editor: ReturnType) => { + const { normalizeNode } = editor + + editor.normalizeNode = (entry: NodeEntry, options) => { + const [_node, path] = entry + + if (path.length === 0) { + if (editor.children.length <= 1 && Editor.string(editor, [0, 0]) === '') { + Transforms.insertNodes(editor, createForcedLayoutTitle(), { + at: [...path, 0], + select: true, + }) + } + + if (editor.children.length < 2) { + Transforms.insertNodes(editor, createForcedLayoutParagraph(), { + at: [...path, 1], + }) + } + + for (const [child, childPath] of Node.children(editor, path)) { + const slateIndex = childPath[0] + const enforceType = (type: 'title' | 'paragraph') => { + if (Node.isElement(child) && child.type !== type) { + Transforms.setNodes( + editor, + { type }, + { + at: childPath, + } + ) + } + } + + switch (slateIndex) { + case 0: + enforceType('title') + break + case 1: + enforceType('paragraph') + break + default: + break + } + } + } + + return normalizeNode(entry, options) + } + + return editor +} + +describe('slate extension contract', () => { + it('supports primitive behavior interception through overrideable instance methods', () => { + const editor = withTrackedInsertBreak(createEditor()) + + Editor.replace(editor, { + children: createParagraphChildren(), + selection: { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }, + marks: null, + }) + + Editor.insertBreak(editor) + + assert.equal(editor.getInsertBreakCalls(), 1) + assert.deepEqual(getBlockTexts(Editor.getSnapshot(editor).children), [ + 'alpha', + '', + ]) + assert.deepEqual(Editor.getSnapshot(editor).selection, { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) + }) + + it('keeps editor.apply as the low-level seam under intercepted editors', () => { + const editor = withTrackedInsertBreak(createEditor()) + + Editor.replace(editor, { + children: createParagraphChildren(), + selection: { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }, + marks: null, + }) + + editor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + assert.equal( + Editor.getSnapshot(editor).children[0].children[0].text, + 'alpha!' + ) + }) + + it('supports inline behavior interception through a link wrapper', () => { + const editor = withLinks(createEditor()) + + Editor.replace(editor, { + children: createParagraphChildren('link me'), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + assert.equal(editor.wrapLinkSelection('https://example.com'), true) + + const linkNode = Editor.getSnapshot(editor).children[0].children.find( + Element.isElement + ) + + assert.ok(linkNode) + assert.equal(editor.isInline(linkNode), true) + assert.equal(linkNode.type, 'link') + assert.deepEqual(Editor.getSnapshot(editor).selection, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) + }) + + it('supports domain command extension through a mentions wrapper', () => { + const editor = withMentions(createEditor()) + + Editor.replace(editor, { + children: createParagraphChildren('hi'), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + marks: null, + }) + + assert.equal(editor.insertMention('Jabba'), true) + + const mentionNode = Editor.getSnapshot(editor).children[0].children.find( + Element.isElement + ) + + assert.ok(Element.isElement(mentionNode)) + assert.equal(editor.isInline(mentionNode), true) + assert.equal(editor.isVoid(mentionNode), true) + assert.equal(mentionNode.type, 'mention') + }) + + it('supports schema extension through withForcedLayout in headless usage', () => { + const editor = withForcedLayout(createEditor()) + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + selection: null, + marks: null, + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.equal(snapshot.children.length, 2) + assert.equal((snapshot.children[0] as { type: string }).type, 'title') + assert.equal((snapshot.children[1] as { type: string }).type, 'paragraph') + }) + + it('composes multiple wrappers on one editor instance', () => { + const editor = withLinks(withTrackedInsertBreak(createEditor())) + + Editor.replace(editor, { + children: createParagraphChildren('beta'), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + assert.equal(editor.wrapLinkSelection('https://example.com'), true) + Transforms.select(editor, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) + Editor.insertBreak(editor) + + assert.equal(editor.getInsertBreakCalls(), 1) + const linkNode = Editor.getSnapshot(editor).children[0].children.find( + Element.isElement + ) + + assert.ok(linkNode) + assert.equal(editor.isInline(linkNode), true) + }) +}) diff --git a/packages/slate/test/headless-contract.ts b/packages/slate/test/headless-contract.ts new file mode 100644 index 0000000000..9762f31a1f --- /dev/null +++ b/packages/slate/test/headless-contract.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + type Editor as SlateEditor, + Transforms, +} from 'slate' +import { History, HistoryEditor, withHistory } from 'slate-history' +import { createHyperscript } from 'slate-hyperscript' + +import { jsx } from './index.js' + +const createSelectedEditor = (): SlateEditor => + jsx( + 'editor', + {}, + [ + jsx('element', { type: 'paragraph' }, 'alpha'), + jsx('element', { type: 'paragraph' }, 'beta'), + ], + jsx( + 'selection', + {}, + jsx('anchor', { path: [1, 0], offset: 2 }), + jsx('focus', { path: [1, 0], offset: 2 }) + ) + ) as SlateEditor + +describe('slate headless contract', () => { + it('supports package-split headless composition through source-resolved package imports', () => { + const editor = withHistory(createEditor()) + const input = createSelectedEditor() + const h = createHyperscript({ + elements: { + paragraph: { type: 'paragraph' }, + }, + }) + const fragment = h( + 'fragment', + {}, + h('paragraph', {}, 'alpha') + ) as Descendant[] + + assert.equal(History.isHistory(editor.history), true) + + Editor.replace(editor, { + children: input.children as Descendant[], + selection: input.selection, + marks: null, + }) + + const ref = Editor.rangeRef(editor, input.selection!) + + Transforms.insertFragment(editor, fragment) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'bealphata' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [1, 0], offset: 7 }, + focus: { path: [1, 0], offset: 7 }, + }) + assert.deepEqual(ref.current, snapshot.selection) + + HistoryEditor.undo(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, input.children) + assert.deepEqual(Editor.getSnapshot(editor).selection, input.selection) + }) + + it('lets hyperscript-built selections drive core fragment extraction without React', () => { + const h = createHyperscript({ + elements: { + paragraph: { type: 'paragraph' }, + }, + }) + const input = h( + 'editor', + {}, + h('paragraph', {}, 'word'), + h( + 'selection', + {}, + h('anchor', { path: [0, 0], offset: 1 }), + h('focus', { path: [0, 0], offset: 3 }) + ) + ) as SlateEditor + const editor = createEditor() + + Editor.replace(editor, { + children: input.children as Descendant[], + selection: input.selection, + marks: null, + }) + + assert.deepEqual(Editor.getFragment(editor), [ + { + type: 'paragraph', + children: [{ text: 'or' }], + }, + ]) + }) +}) diff --git a/packages/slate/test/index.spec.ts b/packages/slate/test/index.spec.ts index 9f930b4966..33192d5931 100644 --- a/packages/slate/test/index.spec.ts +++ b/packages/slate/test/index.spec.ts @@ -7,6 +7,7 @@ import { createEditor, Editor } from 'slate' import { withTest } from './support/with-test.js' const testsDir = dirname(fileURLToPath(import.meta.url)) +const fixtureFilter = process.env.SLATE_FIXTURE_FILTER?.trim() || null const isFixtureFile = (file: string) => (file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.tsx')) && @@ -20,7 +21,7 @@ const getFixtureName = (file: string) => file.replace(/\.(tsx|ts|js)$/u, '') const runFixtureTree = ( path: string, - runFixture: (module: Record) => void + runFixture: (module: Record, fixturePath: string) => void ) => { describe(basename(path), () => { for (const file of readdirSync(path).sort()) { @@ -33,6 +34,7 @@ const runFixtureTree = ( } if (!stat.isFile() || !isFixtureFile(file)) continue + if (fixtureFilter && !fixturePath.includes(fixtureFilter)) continue const name = getFixtureName(file) const source = readFileSync(fixturePath, 'utf8') @@ -45,7 +47,11 @@ const runFixtureTree = ( pathToFileURL(fixturePath).href )) as Record - runFixture(module) + if (process.env.SLATE_FIXTURE_DEBUG === '1') { + console.log('[fixture]', fixturePath) + } + + runFixture(module, fixturePath) }) } }) @@ -63,17 +69,28 @@ const withBatchTest = (editor: Editor, dirties: string[]) => { } describe('slate', () => { - runFixtureTree(resolve(testsDir, 'interfaces'), (module) => { + runFixtureTree(resolve(testsDir, 'interfaces'), (module, fixturePath) => { let { input, test, output } = module if (Editor.isEditor(input)) { input = withTest(input) } - assert.deepEqual(test(input), output) + const actual = test(input) + + if (process.env.SLATE_FIXTURE_DEBUG === '1') { + console.log('[actual]', JSON.stringify(actual)) + console.log('[expected]', JSON.stringify(output)) + if (Editor.isEditor(input)) { + console.log('[selection]', JSON.stringify(input.selection)) + console.log('[children]', JSON.stringify(input.children)) + } + } + + assert.deepEqual(actual, output, fixturePath) }) - runFixtureTree(resolve(testsDir, 'operations'), (module) => { + runFixtureTree(resolve(testsDir, 'operations'), (module, fixturePath) => { const { input, operations, output } = module const editor = withTest(input) @@ -83,11 +100,11 @@ describe('slate', () => { } }) - assert.deepEqual(editor.children, output.children) - assert.deepEqual(editor.selection, output.selection) + assert.deepEqual(editor.children, output.children, fixturePath) + assert.deepEqual(editor.selection, output.selection, fixturePath) }) - runFixtureTree(resolve(testsDir, 'normalization'), (module) => { + runFixtureTree(resolve(testsDir, 'normalization'), (module, fixturePath) => { const { input, output, withFallbackElement } = module const editor = withTest(input) @@ -101,29 +118,32 @@ describe('slate', () => { Editor.normalize(editor, { force: true }) - assert.deepEqual(editor.children, output.children) - assert.deepEqual(editor.selection, output.selection) + assert.deepEqual(editor.children, output.children, fixturePath) + assert.deepEqual(editor.selection, output.selection, fixturePath) }) - runFixtureTree(resolve(testsDir, 'transforms'), (module) => { + runFixtureTree(resolve(testsDir, 'transforms'), (module, fixturePath) => { const { input, output, run } = module const editor = withTest(input) run(editor) - assert.deepEqual(editor.children, output.children) - assert.deepEqual(editor.selection, output.selection) + assert.deepEqual(editor.children, output.children, fixturePath) + assert.deepEqual(editor.selection, output.selection, fixturePath) }) - runFixtureTree(resolve(testsDir, 'utils/deep-equal'), (module) => { - let { input, test, output } = module + runFixtureTree( + resolve(testsDir, 'utils/deep-equal'), + (module, fixturePath) => { + let { input, test, output } = module - if (Editor.isEditor(input)) { - input = withTest(input) - } + if (Editor.isEditor(input)) { + input = withTest(input) + } - assert.deepEqual(test(input), output) - }) + assert.deepEqual(test(input), output, fixturePath) + } + ) describe('batchDirty', () => { const runBatchDirtyTree = (path: string) => { diff --git a/packages/slate/test/interfaces-contract.ts b/packages/slate/test/interfaces-contract.ts new file mode 100644 index 0000000000..3f486417c1 --- /dev/null +++ b/packages/slate/test/interfaces-contract.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createEditor, Element, Node, Operation, Range, Text } from '../src' + +describe('slate interfaces contract', () => { + it('treats editors as nodes, not elements', () => { + const editor = createEditor() + + assert.equal(Node.isNode(editor), true) + assert.equal(Element.isElement(editor), false) + }) + + it('treats arrays of editor-like values as not an element list', () => { + const editor = createEditor() + + assert.equal(Element.isElementList([editor]), false) + }) + + it('treats plain text objects as text', () => { + assert.equal(Text.isText({ text: '' }), true) + }) + + it('rejects plain objects as nodes', () => { + assert.equal(Node.isNode({}), false) + }) + + it('recognizes move_node operations', () => { + assert.equal( + Operation.isOperation({ + type: 'move_node', + path: [0], + newPath: [1], + }), + true + ) + }) + + it('recognizes operation lists', () => { + assert.equal( + Operation.isOperationList([ + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: {}, + }, + ]), + true + ) + }) + + it('recognizes ranges', () => { + assert.equal( + Range.isRange({ + anchor: { path: [0, 1], offset: 0 }, + focus: { path: [0, 1], offset: 0 }, + }), + true + ) + }) + + it('rejects insert_fragment operations whose at target is only a Path', () => { + assert.equal( + Operation.isOperation({ + type: 'insert_fragment', + fragment: [], + at: [0], + }), + false + ) + }) + + it('mirrors the legacy Editor/legacy-minimal.tsx oracle row', () => { + const editor = createEditor() as ReturnType & { + exec?: () => void + } + + editor.exec = () => {} + + assert.equal(typeof editor.apply, 'function') + assert.equal(Array.isArray(editor.children), true) + assert.equal(editor.selection, null) + }) +}) diff --git a/packages/slate/test/legacy-editor-nodes-fixtures.ts b/packages/slate/test/legacy-editor-nodes-fixtures.ts new file mode 100644 index 0000000000..8536ab02ba --- /dev/null +++ b/packages/slate/test/legacy-editor-nodes-fixtures.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test' + +import { + assertLegacyInterfaceFixture, + listLegacyFixtures, +} from './legacy-fixture-utils' + +const legacyEditorNodesRoot = + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Editor/nodes' + +describe('legacy editor nodes fixtures', () => { + for (const fixture of listLegacyFixtures(legacyEditorNodesRoot)) { + const name = fixture.replace( + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Editor/nodes/', + '' + ) + + it(name, async () => { + await assertLegacyInterfaceFixture(fixture) + }) + } +}) diff --git a/packages/slate/test/legacy-fixture-utils.ts b/packages/slate/test/legacy-fixture-utils.ts new file mode 100644 index 0000000000..f29d7c4442 --- /dev/null +++ b/packages/slate/test/legacy-fixture-utils.ts @@ -0,0 +1,282 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import * as Slate from '../src' + +const legacySlateTestRoot = '/Users/zbeyens/git/slate/packages/slate/test' +const legacySlateTestIndex = `${legacySlateTestRoot}/index.js` +const legacyHistoryTestRoot = + '/Users/zbeyens/git/slate/packages/slate-history/test' +const legacyHistoryTestIndex = `${legacyHistoryTestRoot}/index.js` +const currentSlateTestJsxPath = + '/Users/zbeyens/git/slate-v2/config/slate-test-jsx.js' +const requireFromCurrentRepo = createRequire( + '/Users/zbeyens/git/slate-v2/package.json' +) +const currentRuntimeSpecifierTargets = { + lodash: requireFromCurrentRepo.resolve('lodash'), + slate: '/Users/zbeyens/git/slate-v2/packages/slate/src/index.ts', + 'slate-hyperscript': + '/Users/zbeyens/git/slate-v2/packages/slate-hyperscript/src/index.ts', +} as const +const legacyFixtureTranspilers = { + js: new Bun.Transpiler({ loader: 'js' }), + jsx: new Bun.Transpiler({ + loader: 'jsx', + tsconfig: { + compilerOptions: { + jsxFactory: 'jsx', + jsx: 'react', + }, + }, + }), + ts: new Bun.Transpiler({ loader: 'ts' }), + tsx: new Bun.Transpiler({ + loader: 'tsx', + tsconfig: { + compilerOptions: { + jsxFactory: 'jsx', + jsx: 'react', + }, + }, + }), +} as const + +type LegacyFixtureModule = { + input?: unknown + output?: unknown + skip?: boolean + test?: (input: unknown) => unknown + run?: (editor: Slate.Editor) => void +} + +const getCurrentSlateTestJsxSpecifier = (filename: string) => { + const relativePath = path + .relative(path.dirname(filename), currentSlateTestJsxPath) + .replaceAll('\\', '/') + + return relativePath.startsWith('.') ? relativePath : `./${relativePath}` +} + +const getRelativeSpecifier = (fromFilename: string, targetPath: string) => { + const relativePath = path + .relative(path.dirname(fromFilename), targetPath) + .replaceAll('\\', '/') + + return relativePath.startsWith('.') ? relativePath : `./${relativePath}` +} + +const rewriteLegacyTestIndexImports = (source: string, filename: string) => { + const requireFromFixture = createRequire(pathToFileURL(filename).href) + const rewriteSpecifier = (specifier: string) => { + if (specifier in currentRuntimeSpecifierTargets) { + return getRelativeSpecifier( + filename, + currentRuntimeSpecifierTargets[ + specifier as keyof typeof currentRuntimeSpecifierTargets + ] + ) + } + + if (!specifier.startsWith('.')) { + return specifier + } + + const resolved = requireFromFixture.resolve(specifier) + + if (!isLegacyTestIndexPath(resolved)) { + return specifier + } + + return getCurrentSlateTestJsxSpecifier(filename) + } + + const rewriteMatch = ( + _match: string, + prefix: string, + quote: string, + specifier: string + ) => `${prefix}${quote}${rewriteSpecifier(specifier)}${quote}` + + return source + .replace( + /(\b(?:import|export)\s+[^'"]*?\sfrom\s)(['"])([^'"]+)\2/gm, + rewriteMatch + ) + .replace(/(\bimport\s)(['"])([^'"]+)\2/gm, rewriteMatch) +} + +const transformLegacyFixture = (filename: string) => { + const source = fs.readFileSync(filename, 'utf8') + const rewrittenSource = rewriteLegacyTestIndexImports(source, filename) + const extension = path + .extname(filename) + .slice(1) as keyof typeof legacyFixtureTranspilers + const transpiler = legacyFixtureTranspilers[extension] + + if (!transpiler) { + throw new Error(`Unsupported legacy fixture extension for ${filename}`) + } + + const code = transpiler.transformSync(rewrittenSource) + + if (!code) { + throw new Error(`Failed to transform legacy fixture ${filename}`) + } + + return code +} + +const normalizeLegacyValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeLegacyValue) + } + + if (!value || typeof value !== 'object') { + return value + } + + const normalized = Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .map(([key, entry]) => [key, normalizeLegacyValue(entry)]) + ) + + return normalized +} + +const isLegacyTestIndexPath = (resolvedPath: string) => + resolvedPath === legacySlateTestRoot || + resolvedPath === legacySlateTestIndex || + resolvedPath === legacyHistoryTestRoot || + resolvedPath === legacyHistoryTestIndex + +export const withLegacyTestBehaviors = ( + editor: T +): T => { + const { isBlock, isInline, isVoid, isElementReadOnly, isSelectable } = editor + + editor.isBlock = (element) => + element.inline === true + ? false + : element.type == null + ? true + : isBlock(element) + + editor.isInline = (element) => + element.inline === true ? true : isInline(element) + editor.isVoid = (element) => (element.void === true ? true : isVoid(element)) + editor.isElementReadOnly = (element) => + element.readOnly === true ? true : isElementReadOnly(element) + editor.isSelectable = (element) => + element.nonSelectable === true ? false : isSelectable(element) + + return editor +} + +export const loadLegacyFixture = async ( + filename: string +): Promise => { + const code = transformLegacyFixture(filename) + const tempFilename = `${filename}.slate-v2-legacy-fixture.mjs` + + fs.writeFileSync(tempFilename, code, 'utf8') + + try { + const module = (await import( + `${pathToFileURL(tempFilename).href}?t=${Date.now()}` + )) as LegacyFixtureModule + + return module + } finally { + fs.unlinkSync(tempFilename) + } +} + +const walkFixtures = (root: string): string[] => + fs + .readdirSync(root, { withFileTypes: true }) + .flatMap((entry) => { + const next = path.join(root, entry.name) + + if (entry.isDirectory()) { + return walkFixtures(next) + } + + return /\.[jt]sx?$/.test(entry.name) ? [next] : [] + }) + .sort() + +export const listLegacyFixtures = (root: string) => walkFixtures(root) + +export const assertLegacyInterfaceFixture = async (filename: string) => { + const fixture = await loadLegacyFixture(filename) + + if (fixture.skip) { + return + } + + if (!fixture.test) { + throw new Error(`Legacy interface fixture ${filename} does not export test`) + } + + const input = Slate.Editor.isEditor(fixture.input) + ? withLegacyTestBehaviors(fixture.input) + : fixture.input + + assert.deepStrictEqual( + normalizeLegacyValue(fixture.test(input)), + normalizeLegacyValue(fixture.output) + ) +} + +export const runLegacyTransformFixture = async (filename: string) => { + const fixture = await loadLegacyFixture(filename) + + if (fixture.skip) { + return + } + + if (!fixture.run || !fixture.input || !Slate.Editor.isEditor(fixture.input)) { + throw new Error( + `Legacy transform fixture ${filename} does not export a valid editor input/run pair` + ) + } + + const editor = withLegacyTestBehaviors(fixture.input) + Slate.Editor.replace(editor, { + children: editor.children, + selection: editor.selection, + marks: editor.marks, + }) + fixture.run(editor) + + return { + actualChildren: normalizeLegacyValue(editor.children), + actualSelection: normalizeLegacyValue(editor.selection), + expectedChildren: normalizeLegacyValue( + Slate.Editor.isEditor(fixture.output) + ? fixture.output.children + : undefined + ), + expectedSelection: normalizeLegacyValue( + Slate.Editor.isEditor(fixture.output) + ? fixture.output.selection + : undefined + ), + } +} + +export const assertLegacyTransformFixture = async (filename: string) => { + const result = await runLegacyTransformFixture(filename) + + if (!result) { + return + } + + assert.deepStrictEqual(result.actualChildren, result.expectedChildren) + assert.deepStrictEqual(result.actualSelection, result.expectedSelection) +} diff --git a/packages/slate/test/legacy-interfaces-fixtures.ts b/packages/slate/test/legacy-interfaces-fixtures.ts new file mode 100644 index 0000000000..0cd0d905cb --- /dev/null +++ b/packages/slate/test/legacy-interfaces-fixtures.ts @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test' + +import { + assertLegacyInterfaceFixture, + listLegacyFixtures, +} from './legacy-fixture-utils' + +const legacyInterfacesRoots = [ + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Element', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Location', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Node', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Operation', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Path', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Point', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Range', + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/Text', +] + +describe('legacy interface fixtures', () => { + for (const root of legacyInterfacesRoots) { + for (const fixture of listLegacyFixtures(root)) { + const name = fixture.replace( + '/Users/zbeyens/git/slate/packages/slate/test/interfaces/', + '' + ) + + it(name, async () => { + await assertLegacyInterfaceFixture(fixture) + }) + } + } +}) diff --git a/packages/slate/test/legacy-transforms-fixtures.ts b/packages/slate/test/legacy-transforms-fixtures.ts new file mode 100644 index 0000000000..bd20f4efb2 --- /dev/null +++ b/packages/slate/test/legacy-transforms-fixtures.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' + +import { + assertLegacyTransformFixture, + listLegacyFixtures, +} from './legacy-fixture-utils' + +const legacyTransformRoots = [ + '/Users/zbeyens/git/slate/packages/slate/test/transforms/delete', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/deselect', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/insertFragment', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/liftNodes', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/move', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/select', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/setPoint', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/unsetNodes', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/unwrapNodes', + '/Users/zbeyens/git/slate/packages/slate/test/transforms/wrapNodes', +] + +const describeLegacyTransformAudit = + process.env.SLATE_RUN_LEGACY_TRANSFORM_AUDIT === '1' + ? describe + : describe.skip + +describeLegacyTransformAudit('legacy transform fixtures', () => { + for (const root of legacyTransformRoots) { + for (const fixture of listLegacyFixtures(root)) { + const name = fixture.replace( + '/Users/zbeyens/git/slate/packages/slate/test/transforms/', + '' + ) + + it(name, async () => { + await assertLegacyTransformFixture(fixture) + }) + } + } +}) diff --git a/packages/slate/test/normalization-contract.ts b/packages/slate/test/normalization-contract.ts new file mode 100644 index 0000000000..877aeb0b5e --- /dev/null +++ b/packages/slate/test/normalization-contract.ts @@ -0,0 +1,377 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + Node, + type NodeEntry, + type Element as SlateElement, + Transforms, +} from '../src' + +const bodyParagraph = (text = ''): Descendant => ({ + type: 'paragraph', + children: [{ text }], +}) + +const createForcedLayoutTitle = (): Descendant => ({ + type: 'title', + children: [{ text: 'Untitled' }], +}) + +const createForcedLayoutParagraph = (): Descendant => ({ + type: 'paragraph', + children: [{ text: '' }], +}) + +const withForcedLayout = (editor: ReturnType) => { + const { normalizeNode } = editor + + editor.normalizeNode = (entry: NodeEntry, options) => { + const [_node, path] = entry + + if (path.length === 0) { + if (editor.children.length <= 1 && Editor.string(editor, [0, 0]) === '') { + Transforms.insertNodes(editor, createForcedLayoutTitle(), { + at: [...path, 0], + select: true, + }) + } + + if (editor.children.length < 2) { + Transforms.insertNodes(editor, createForcedLayoutParagraph(), { + at: [...path, 1], + }) + } + + for (const [child, childPath] of Node.children(editor, path)) { + const slateIndex = childPath[0] + const enforceType = (type: 'title' | 'paragraph') => { + if (Node.isElement(child) && child.type !== type) { + Transforms.setNodes( + editor, + { type }, + { + at: childPath, + } + ) + } + } + + switch (slateIndex) { + case 0: + enforceType('title') + break + case 1: + enforceType('paragraph') + break + default: + break + } + } + } + + return normalizeNode(entry, options) + } + + return editor +} + +describe('slate normalization contract', () => { + it('repairs an empty block with an empty text child', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [{ type: 'block', children: [] } as Descendant], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: '' }] }, + ]) + }) + + it('supports app-owned block normalization that inserts custom blocks and normalizes them', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.normalizeNode = (entry, options) => { + const [node, path] = entry + + if ( + !Editor.isEditor(node) && + 'children' in node && + node.type === 'body' && + node.children.length === 0 + ) { + Transforms.insertNodes(editor, bodyParagraph(), { at: [...path, 0] }) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: [{ type: 'body', children: [] } as Descendant], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'body', + children: [bodyParagraph()], + }, + ]) + }) + + it('supports app-owned forced layout through a real wrapper', () => { + const editor = withForcedLayout(createEditor()) + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + createForcedLayoutTitle(), + { + type: 'paragraph', + children: [{ text: '' }], + }, + ]) + }) + + it('supports app-owned descendant-level normalization with supported transforms', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.normalizeNode = (entry, options) => { + const [_node, path] = entry + + if (path.length > 0 && 'children' in node && node.type === 'heading') { + Transforms.setNodes( + editor, + { + type: 'paragraph', + }, + { at: path } + ) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: [ + { + type: 'heading', + children: [{ text: 'nested' }], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'nested' }], + }, + ]) + }) + + it('supports app-owned delegation into core fallbackElement wrapping', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.isInline = (element) => element.type === 'chip' + editor.normalizeNode = (entry, options) => { + originalNormalizeNode(entry, { + ...options, + fallbackElement: () => ({ + type: 'paragraph', + children: [{ text: '' }], + }), + }) + } + + Editor.replace(editor, { + children: [ + { text: 'alpha' } as Descendant, + { + type: 'chip', + children: [{ text: 'beta' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [ + { text: '' }, + { + type: 'chip', + children: [{ text: 'beta' }], + }, + { text: '' }, + ], + }, + ]) + }) + + it('removes stray top-level text during replace-time block-only cleanup', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { text: 'one' } as Descendant, + { type: 'block', children: [{ text: 'two' }] } as Descendant, + { text: 'three' } as Descendant, + { type: 'block', children: [{ text: 'four' }] } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: 'two' }] }, + { type: 'block', children: [{ text: 'four' }] }, + ]) + }) + + it('removes stray top-level text during node-op block-only cleanup', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'alpha' }], + }, + { + type: 'block', + children: [{ text: 'beta' }], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + Transforms.insertNodes(editor, { text: 'stray' }, { at: [0] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + children: [{ text: 'alpha' }], + }, + { + type: 'block', + children: [{ text: 'beta' }], + }, + ]) + }) + + it('explicitly merges adjacent compatible text children in inline-style containers', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'al', bold: true }, + { text: 'pha', bold: true }, + ], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + Editor.normalize(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha', bold: true }], + }, + ]) + }) + + it('explicitly removes empty adjacent text in inline-style containers', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'alpha', bold: true }, + { text: '', bold: true }, + { text: 'beta', bold: true }, + ], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + Editor.normalize(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alphabeta', bold: true }], + }, + ]) + }) + + it('flattens a direct block child inserted into an inline-style container without merging unrelated text runs', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { text: 'gamma' }], + }, + ] as Descendant[], + selection: null, + marks: null, + }) + + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'beta' }], + } as Descendant, + { at: [0, 1] } + ) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { text: 'beta' }, { text: 'gamma' }], + }, + ]) + }) +}) diff --git a/packages/slate/test/operations-contract.ts b/packages/slate/test/operations-contract.ts new file mode 100644 index 0000000000..16524bca21 --- /dev/null +++ b/packages/slate/test/operations-contract.ts @@ -0,0 +1,529 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createEditor, type Descendant, Editor } from '../src' + +const moveChildren = (): Descendant[] => [ + { + type: 'element', + children: [{ text: '1' }], + }, + { + type: 'element', + children: [{ text: '2' }], + }, +] + +const collapsedSelection = (path: number[], offset: number) => ({ + anchor: { path, offset }, + focus: { path, offset }, +}) + +describe('slate operations contract', () => { + it('treats move_node as a no-op when path equals newPath', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: null, + marks: null, + }) + + editor.apply({ + type: 'move_node', + path: [0], + newPath: [0], + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, moveChildren()) + }) + + it('moves a node when move_node targets the post-removal destination path', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: null, + marks: null, + }) + + editor.apply({ + type: 'move_node', + path: [0], + newPath: [2], + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + children: [{ text: '2' }], + }, + { + type: 'element', + children: [{ text: '1' }], + }, + ]) + }) + + it('rebases selection with the effective move_node target when moving to a later sibling slot', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: collapsedSelection([0, 0], 0), + marks: null, + }) + + editor.apply({ + type: 'move_node', + path: [0], + newPath: [2], + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'element', + children: [{ text: '2' }], + }, + { + type: 'element', + children: [{ text: '1' }], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([1, 0], 0)) + }) + + it('rebases selection when insert_node inserts before the selected node', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: collapsedSelection([0, 0], 0), + marks: null, + }) + + editor.apply({ + type: 'insert_node', + path: [0], + node: { + type: 'element', + children: [{ text: '0' }], + }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'element', + children: [{ text: '0' }], + }, + { + type: 'element', + children: [{ text: '1' }], + }, + { + type: 'element', + children: [{ text: '2' }], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([1, 0], 0)) + }) + + it('applies partial set_selection patches against the current selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 1 }, + }, + marks: null, + }) + + editor.apply({ + type: 'set_selection', + properties: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 1 }, + }, + newProperties: { + focus: { path: [1, 0], offset: 0 }, + }, + }) + + assert.deepEqual(Editor.getSnapshot(editor).selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) + }) + + it('rejects partial set_selection patches when the editor has no live selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: moveChildren(), + selection: null, + marks: null, + }) + + assert.throws( + () => + editor.apply({ + type: 'set_selection', + properties: null, + newProperties: { + anchor: { path: [0, 0], offset: 0 }, + }, + }), + /set_selection patch requires an existing selection or a full range/ + ) + }) + + it('splits a text node with split_node then splits its parent element', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: 'some text', bold: true }], + }, + ], + selection: null, + marks: null, + }) + + editor.apply({ + type: 'split_node', + path: [0, 0], + position: 5, + properties: { + bold: true, + }, + }) + + editor.apply({ + type: 'split_node', + path: [0], + position: 1, + properties: {}, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + children: [{ text: 'some ', bold: true }], + }, + { + type: 'element', + children: [{ text: 'text', bold: true }], + }, + ]) + }) + + it('splits an element node with element-level split_node properties', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'element', + data: true, + children: [ + { text: 'before text' }, + { + type: 'inline', + children: [{ text: 'hyperlink' }], + }, + { text: 'after text' }, + ], + }, + ], + selection: null, + marks: null, + }) + + editor.apply({ + type: 'split_node', + path: [0], + position: 1, + properties: { + data: true, + }, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + data: true, + children: [{ text: 'before text' }], + }, + { + type: 'element', + data: true, + children: [ + { text: '' }, + { + type: 'inline', + children: [{ text: 'hyperlink' }], + }, + { text: 'after text' }, + ], + }, + ]) + }) + + it('rebases selection to the next text when remove_node deletes the selected leading empty text', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: '' }, { text: 'b' }], + }, + ], + selection: collapsedSelection([0, 0], 0), + marks: null, + }) + + editor.apply({ + type: 'remove_node', + path: [0, 0], + node: { text: '' }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'element', + children: [{ text: 'b' }], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([0, 0], 0)) + }) + + it('rebases selection to the previous text end when remove_node deletes the selected trailing empty text', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: 'a' }, { text: '' }], + }, + ], + selection: collapsedSelection([0, 1], 0), + marks: null, + }) + + editor.apply({ + type: 'remove_node', + path: [0, 1], + node: { text: '' }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'element', + children: [{ text: 'a' }], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([0, 0], 1)) + }) + + it('rebases selection into the adjacent inline when remove_node deletes the selected trailing spacer text', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [ + { text: '' }, + { + type: 'inline', + children: [{ text: 'a' }], + }, + { text: '' }, + ], + }, + ], + selection: collapsedSelection([0, 2], 0), + marks: null, + }) + + editor.apply({ + type: 'remove_node', + path: [0, 2], + node: { text: '' }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'element', + children: [ + { text: '' }, + { + type: 'inline', + children: [{ text: 'a' }], + }, + { text: '' }, + ], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([0, 1, 0], 1)) + }) + + it('rebases expanded selections inward when remove_text deletes text inside the range', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: 'word' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + editor.apply({ + type: 'remove_text', + path: [0, 0], + offset: 1, + text: 'or', + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0]?.children[0]?.text, 'wd') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 2 }, + }) + }) + + it('removes omitted text props through raw set_node', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: 'a', someKey: true }], + }, + ], + selection: null, + marks: null, + }) + + editor.apply({ + type: 'set_node', + path: [0, 0], + properties: { someKey: true }, + newProperties: {}, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + children: [{ text: 'a' }], + }, + ]) + }) + + it('splits a text node with empty split_node properties and clears the right branch props', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: 'some text', bold: true }], + }, + ], + selection: null, + marks: null, + }) + + editor.apply({ + type: 'split_node', + path: [0, 0], + position: 5, + properties: {}, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + children: [{ text: 'some ', bold: true }, { text: 'text' }], + }, + ]) + }) + + it('splits an element node with empty split_node properties and clears the right branch props', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'element', + data: true, + children: [ + { text: 'before text' }, + { + type: 'inline', + children: [{ text: 'hyperlink' }], + }, + { text: 'after text' }, + ], + }, + ], + selection: null, + marks: null, + }) + + editor.apply({ + type: 'split_node', + path: [0], + position: 1, + properties: {}, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'element', + data: true, + children: [{ text: 'before text' }], + }, + { + type: 'element', + children: [ + { text: '' }, + { + type: 'inline', + children: [{ text: 'hyperlink' }], + }, + { text: 'after text' }, + ], + }, + ]) + }) +}) diff --git a/packages/slate/test/query-contract.ts b/packages/slate/test/query-contract.ts new file mode 100644 index 0000000000..90d78ba31f --- /dev/null +++ b/packages/slate/test/query-contract.ts @@ -0,0 +1,2805 @@ +import assert from 'node:assert/strict' + +import { createEditor, type Descendant, Editor, Transforms } from '../src' + +const createChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createLegacyBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, +] + +const createElementSplitChildren = (): Descendant[] => [ + { + type: 'paragraph', + data: true, + children: [ + { text: 'before' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'hyperlink' }], + }, + { text: 'after' }, + ], + } as Descendant, +] + +const createVoidBlockPairChildren = (): Descendant[] => [ + { + type: 'paragraph', + void: true, + children: [{ text: 'one' }], + } as Descendant, + { + type: 'paragraph', + void: true, + children: [{ text: 'two' }], + } as Descendant, +] + +const createVoidSplitChildren = (): Descendant[] => [ + { + type: 'paragraph', + void: true, + children: [{ text: 'one' }, { text: 'two' }], + } as Descendant, +] + +const createNonSelectableBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + nonSelectable: true, + children: [{ text: 'two' }], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'three' }], + }, +] + +const createLeadingNonSelectableBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + nonSelectable: true, + children: [{ text: 'two' }], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'three' }], + }, +] + +const createTrailingNonSelectableBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + nonSelectable: true, + children: [{ text: 'two' }], + } as Descendant, +] + +const createNonSelectableInlineChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'inline', + nonSelectable: true, + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createLeadingNonSelectableInlineChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { + type: 'inline', + nonSelectable: true, + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createTrailingNonSelectableInlineChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'inline', + nonSelectable: true, + children: [{ text: 'two' }], + }, + ], + } as Descendant, +] + +const createNonSelectableInlineVoidChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'inline', + void: true, + nonSelectable: true, + children: [{ text: '' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createSingleBlockChildren = (): Descendant[] => [ + { + type: 'block', + children: [{ text: 'one' }], + } as Descendant, +] + +const createTwoBlockChildren = (): Descendant[] => [ + { + type: 'block', + children: [{ text: 'one' }], + } as Descendant, + { + type: 'block', + children: [{ text: 'two' }], + } as Descendant, +] + +const createNestedBlockChildren = (): Descendant[] => [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + } as Descendant, +] + +const createInlineBlockChildren = (): Descendant[] => [ + { + type: 'block', + children: [ + { text: 'one' }, + { + type: 'inline', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createNestedInlineChildren = (): Descendant[] => [ + { + type: 'block', + children: [ + { text: 'one' }, + { + type: 'inline', + children: [ + { text: 'two' }, + { + type: 'inline', + children: [{ text: 'three' }], + }, + { text: 'four' }, + ], + }, + { text: 'five' }, + ], + } as Descendant, +] + +const createVoidBlockChildren = (): Descendant[] => [ + { + type: 'block', + void: true, + children: [{ text: 'one' }, { text: 'two' }], + } as Descendant, +] + +const createVoidInlineChildren = (): Descendant[] => [ + { + type: 'block', + children: [ + { text: 'one' }, + { + type: 'inline', + void: true, + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createMarkableVoidChildren = (): Descendant[] => [ + { + type: 'block', + children: [ + { text: 'word' }, + { + type: 'inline', + void: true, + markable: true, + children: [{ text: '', bold: true }], + }, + { text: '' }, + ], + } as Descendant, +] + +it('above exposes the current traversal seam', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [ + { text: 'one ' }, + { + type: 'link', + children: [{ text: 'two' }], + }, + { text: ' three' }, + ], + }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0, 1, 0], offset: 1 }, + focus: { path: [0, 0, 1, 0], offset: 1 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.above(editor, { at: [0, 0, 1, 0] }), [ + { + type: 'link', + children: [{ text: 'two' }], + }, + [0, 0, 1], + ]) + assert.deepEqual( + Editor.above(editor, { + at: [0, 0, 1, 0], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'paragraph', + }), + [ + { + type: 'paragraph', + children: [ + { text: 'one ' }, + { + type: 'link', + children: [{ text: 'two' }], + }, + { text: ' three' }, + ], + }, + [0, 0], + ] + ) +}) + +it('mirrors the legacy above/block-lowest.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one ' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { + at: [0, 0, 0], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'block' && + Editor.isBlock(editor, node), + mode: 'lowest', + }), + [ + { + type: 'block', + children: [{ text: 'one ' }], + }, + [0, 0], + ] + ) +}) + +it('mirrors the legacy above/block-highest.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { + at: [0, 0, 0], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'block' && + Editor.isBlock(editor, node), + mode: 'highest', + }), + [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + }, + [0], + ] + ) +}) + +it('mirrors the legacy above/inline.tsx oracle row', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: 'one' }, + { + type: 'inline', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { + at: [0, 1, 0], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'inline' && + Editor.isInline(editor, node), + }), + [ + { + type: 'inline', + children: [{ text: 'two' }], + }, + [0, 1], + ] + ) +}) + +it('mirrors the legacy above/point.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { at: { path: [0, 0, 0], offset: 1 } }), + [ + { + type: 'block', + children: [{ text: 'one' }], + }, + [0, 0], + ] + ) +}) + +it('mirrors the legacy above/potential-parent.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + }, + { + type: 'block', + children: [{ text: 'two' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { + at: [0, 0, 1], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'block' && + Editor.isBlock(editor, node), + }), + [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + }, + [0, 0], + ] + ) +}) + +it('mirrors the legacy above/range.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + }, + { + type: 'block', + children: [{ text: 'two' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.above(editor, { + at: { + anchor: { path: [0, 0, 0, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }, + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'block' && + Editor.isBlock(editor, node), + }), + [ + { + type: 'block', + children: [ + { + type: 'block', + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + ], + }, + { + type: 'block', + children: [{ text: 'two' }], + }, + ], + }, + [0], + ] + ) +}) + +it('mirrors the legacy edges/end/start/path/point/node/parent/range oracle rows', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createSingleBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.edges(editor, [0]), [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 3 }, + ]) + assert.deepEqual(Editor.edges(editor, { path: [0, 0], offset: 1 }), [ + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 1 }, + ]) + assert.deepEqual( + Editor.edges(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }), + [ + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 3 }, + ] + ) + + assert.deepEqual(Editor.start(editor, [0]), { path: [0, 0], offset: 0 }) + assert.deepEqual(Editor.start(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 1, + }) + assert.deepEqual( + Editor.start(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }), + { path: [0, 0], offset: 1 } + ) + + assert.deepEqual(Editor.end(editor, [0]), { path: [0, 0], offset: 3 }) + assert.deepEqual(Editor.end(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 1, + }) + assert.deepEqual( + Editor.end(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 2 }, + }), + { path: [0, 0], offset: 2 } + ) + + assert.deepEqual(Editor.path(editor, [0]), [0]) + assert.deepEqual(Editor.path(editor, { path: [0, 0], offset: 1 }), [0, 0]) + assert.deepEqual(Editor.point(editor, [0]), { path: [0, 0], offset: 0 }) + assert.deepEqual(Editor.point(editor, [0], { edge: 'end' }), { + path: [0, 0], + offset: 3, + }) + assert.deepEqual(Editor.point(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 1, + }) + + assert.deepEqual(Editor.node(editor, [0]), [ + { + type: 'block', + children: [{ text: 'one' }], + }, + [0], + ]) + assert.deepEqual(Editor.node(editor, { path: [0, 0], offset: 1 }), [ + { text: 'one' }, + [0, 0], + ]) + assert.deepEqual(Editor.parent(editor, [0, 0]), [ + { + type: 'block', + children: [{ text: 'one' }], + }, + [0], + ]) + assert.deepEqual(Editor.parent(editor, { path: [0, 0], offset: 1 }), [ + { + type: 'block', + children: [{ text: 'one' }], + }, + [0], + ]) + assert.deepEqual(Editor.range(editor, [0]), { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 3 }, + }) + assert.deepEqual(Editor.range(editor, { path: [0, 0], offset: 1 }), { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) + assert.deepEqual( + Editor.range(editor, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 1 }, + }), + { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 1 }, + } + ) + + Editor.replace(editor, { + children: createTwoBlockChildren(), + selection: null, + marks: null, + }) + + const spanningRange = { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + } + + assert.deepEqual(Editor.path(editor, spanningRange), []) + assert.deepEqual( + Editor.path(editor, spanningRange, { edge: 'start' }), + [0, 0] + ) + assert.deepEqual(Editor.path(editor, spanningRange, { edge: 'end' }), [1, 0]) + assert.deepEqual(Editor.point(editor, spanningRange), { + path: [0, 0], + offset: 1, + }) + assert.deepEqual(Editor.point(editor, spanningRange, { edge: 'end' }), { + path: [1, 0], + offset: 2, + }) + const rangeNode = Editor.node(editor, spanningRange) + assert.equal(Editor.isEditor(rangeNode[0]), true) + assert.deepEqual(rangeNode[1], []) + assert.deepEqual(Editor.node(editor, spanningRange, { edge: 'start' }), [ + { text: 'one' }, + [0, 0], + ]) + assert.deepEqual(Editor.node(editor, spanningRange, { edge: 'end' }), [ + { text: 'two' }, + [1, 0], + ]) + assert.deepEqual(Editor.parent(editor, spanningRange, { edge: 'start' }), [ + { + type: 'block', + children: [{ text: 'one' }], + }, + [0], + ]) + assert.deepEqual(Editor.parent(editor, spanningRange, { edge: 'end' }), [ + { + type: 'block', + children: [{ text: 'two' }], + }, + [1], + ]) + assert.deepEqual(Editor.range(editor, spanningRange), spanningRange) +}) + +it('mirrors the legacy string oracle rows', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'one' }, { text: 'two' }], + } as Descendant, + { + type: 'block', + children: [{ text: 'three' }, { text: 'four' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.equal(Editor.string(editor, [0, 0]), 'one') + assert.equal(Editor.string(editor, [0]), 'onetwo') + assert.equal(Editor.string(editor, []), 'onetwothreefour') + + Editor.replace(editor, { + children: createInlineBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.string(editor, [0, 1]), 'two') + + Editor.replace(editor, { + children: createVoidBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.string(editor, [0]), '') + assert.equal(Editor.string(editor, [0], { voids: true }), 'onetwo') +}) + +it('mirrors the legacy has*/is* editor predicate oracle rows', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: createNestedBlockChildren(), + selection: null, + marks: null, + }) + + const nestedBlock = Editor.node(editor, [0])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.hasBlocks(editor, nestedBlock), true) + assert.equal(Editor.hasInlines(editor, nestedBlock), false) + assert.equal(Editor.hasTexts(editor, nestedBlock), false) + + Editor.replace(editor, { + children: createSingleBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + const block = Editor.node(editor, [0])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.hasBlocks(editor, block), false) + assert.equal(Editor.hasInlines(editor, block), true) + assert.equal(Editor.hasTexts(editor, block), true) + assert.equal(Editor.isBlock(editor, block), true) + assert.equal(Editor.isInline(editor, block), false) + assert.equal(Editor.isVoid(editor, block), false) + assert.equal( + Editor.isEmpty(editor, { type: 'block', children: [{ text: '' }] }), + true + ) + assert.equal(Editor.isEmpty(editor, { type: 'block', children: [] }), true) + assert.equal(Editor.isEmpty(editor, block), false) + assert.equal(Editor.isStart(editor, { path: [0, 0], offset: 0 }, [0]), true) + assert.equal(Editor.isStart(editor, { path: [0, 0], offset: 2 }, [0]), false) + assert.equal(Editor.isEnd(editor, { path: [0, 0], offset: 3 }, [0]), true) + assert.equal(Editor.isEnd(editor, { path: [0, 0], offset: 2 }, [0]), false) + assert.equal(Editor.isEdge(editor, { path: [0, 0], offset: 0 }, [0]), true) + assert.equal(Editor.isEdge(editor, { path: [0, 0], offset: 2 }, [0]), false) + assert.equal(Editor.isEdge(editor, { path: [0, 0], offset: 3 }, [0]), true) + + Editor.replace(editor, { + children: createInlineBlockChildren(), + selection: null, + marks: null, + }) + + const inline = Editor.node(editor, [0, 1])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.hasBlocks(editor, inline), false) + assert.equal(Editor.hasInlines(editor, inline), true) + assert.equal(Editor.hasTexts(editor, inline), true) + assert.equal(Editor.isBlock(editor, inline), false) + assert.equal(Editor.isInline(editor, inline), true) + assert.equal(Editor.isVoid(editor, inline), false) + assert.equal( + Editor.isEmpty(editor, { type: 'inline', children: [{ text: '' }] }), + true + ) + assert.equal(Editor.isEmpty(editor, { type: 'inline', children: [] }), true) + assert.equal(Editor.isEmpty(editor, inline), false) + + Editor.replace(editor, { + children: createNestedInlineChildren(), + selection: null, + marks: null, + }) + + const nestedInline = Editor.node(editor, [0, 1])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.hasBlocks(editor, nestedInline), false) + assert.equal(Editor.hasInlines(editor, nestedInline), true) + assert.equal(Editor.hasTexts(editor, nestedInline), false) + + Editor.replace(editor, { + children: createVoidBlockChildren(), + selection: null, + marks: null, + }) + + const voidBlock = Editor.node(editor, [0])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.isVoid(editor, voidBlock), true) + assert.equal(Editor.isEmpty(editor, voidBlock), false) + + Editor.replace(editor, { + children: createVoidInlineChildren(), + selection: null, + marks: null, + }) + + const voidInline = Editor.node(editor, [0, 1])[0] as Descendant & { + children: Descendant[] + } + assert.equal(Editor.isVoid(editor, voidInline), true) + assert.equal(Editor.isEmpty(editor, voidInline), false) +}) + +it('mirrors the legacy Editor.marks oracle rows', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: 'plain ' }, + { text: 'bold', bold: true }, + { text: ' plain' }, + ], + } as Descendant, + { + type: 'block', + children: [{ text: 'block two' }], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 1], offset: 4 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.marks(editor), { bold: true }) + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'block one' }], + } as Descendant, + { + type: 'block', + children: [{ text: 'block two', bold: true }], + } as Descendant, + { + type: 'block', + children: [{ text: 'block three', bold: true }], + } as Descendant, + ], + selection: { + anchor: { path: [2, 0], offset: 0 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.marks(editor), { bold: true }) + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: 'plain' }, + { text: 'bold text that isbold', bold: true }, + { text: 'bold italic', bold: true, italic: true }, + ], + } as Descendant, + { + type: 'block', + children: [{ text: 'block two' }], + } as Descendant, + ], + selection: { + anchor: { path: [0, 1], offset: 17 }, + focus: { path: [0, 1], offset: 17 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.marks(editor), { bold: true }) + + Editor.replace(editor, { + children: createMarkableVoidChildren(), + selection: { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }, + marks: null, + }) + editor.markableVoid = (element) => + Boolean((element as { markable?: boolean }).markable) + + assert.deepEqual(Editor.marks(editor), { bold: true }) + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: 'word' }, + { + type: 'inline', + void: true, + markable: true, + children: [{ text: '', bold: true }], + }, + { text: 'bold', bold: true }, + { + type: 'inline', + void: true, + markable: true, + children: [{ text: '', bold: true, italic: true }], + }, + { text: 'bold italic', bold: true, italic: true }, + { text: '' }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 4], offset: 11 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.marks(editor), { bold: true }) +}) + +it('positions exposes the current point-iteration seam across offset, character, word, and block units', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'link', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'four five' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.positions(editor, { at: [0] })), [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 2 }, + { path: [0, 0], offset: 3 }, + { path: [0, 1, 0], offset: 0 }, + { path: [0, 1, 0], offset: 1 }, + { path: [0, 1, 0], offset: 2 }, + { path: [0, 1, 0], offset: 3 }, + { path: [0, 2], offset: 0 }, + { path: [0, 2], offset: 1 }, + { path: [0, 2], offset: 2 }, + { path: [0, 2], offset: 3 }, + { path: [0, 2], offset: 4 }, + { path: [0, 2], offset: 5 }, + ]) + + assert.deepEqual( + Array.from(Editor.positions(editor, { at: [], unit: 'character' })), + [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 2 }, + { path: [0, 0], offset: 3 }, + { path: [0, 1, 0], offset: 1 }, + { path: [0, 1, 0], offset: 2 }, + { path: [0, 1, 0], offset: 3 }, + { path: [0, 2], offset: 1 }, + { path: [0, 2], offset: 2 }, + { path: [0, 2], offset: 3 }, + { path: [0, 2], offset: 4 }, + { path: [0, 2], offset: 5 }, + { path: [1, 0], offset: 0 }, + { path: [1, 0], offset: 1 }, + { path: [1, 0], offset: 2 }, + { path: [1, 0], offset: 3 }, + { path: [1, 0], offset: 4 }, + { path: [1, 0], offset: 5 }, + { path: [1, 0], offset: 6 }, + { path: [1, 0], offset: 7 }, + { path: [1, 0], offset: 8 }, + { path: [1, 0], offset: 9 }, + ] + ) + + assert.deepEqual( + Array.from(Editor.positions(editor, { at: [1], unit: 'word' })), + [ + { path: [1, 0], offset: 0 }, + { path: [1, 0], offset: 4 }, + { path: [1, 0], offset: 9 }, + ] + ) + + assert.deepEqual( + Array.from( + Editor.positions(editor, { at: [], unit: 'block', reverse: true }) + ), + [ + { path: [1, 0], offset: 9 }, + { path: [1, 0], offset: 0 }, + { path: [0, 2], offset: 5 }, + { path: [0, 0], offset: 0 }, + ] + ) +}) + +it('mirrors the legacy positions/path/inline.tsx oracle row', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: 'one' }, + { + type: 'inline', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.positions(editor, { at: [0, 1] })), [ + { path: [0, 1, 0], offset: 0 }, + { path: [0, 1, 0], offset: 1 }, + { path: [0, 1, 0], offset: 2 }, + { path: [0, 1, 0], offset: 3 }, + ]) +}) + +it('mirrors the legacy positions/all/inline-fragmentation.tsx oracle row', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: '1' }, + { + type: 'inline', + children: [{ text: '2' }], + }, + { text: '3' }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.positions(editor, { at: [] })), [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 1 }, + { path: [0, 1, 0], offset: 0 }, + { path: [0, 1, 0], offset: 1 }, + { path: [0, 2], offset: 0 }, + { path: [0, 2], offset: 1 }, + ]) +}) + +it('unhangRange exposes the current hanging-range seam', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + { + type: 'paragraph', + children: [{ text: 'another' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.unhangRange(editor, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }), + { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 4 }, + } + ) + + assert.deepEqual( + Editor.unhangRange(editor, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 0 }, + }), + { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 0 }, + } + ) +}) + +const unhangOracleCases = [ + { + name: 'mirrors the legacy unhangRange/block-hanging.tsx oracle row', + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + { + type: 'paragraph', + children: [{ text: 'another' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 4 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/collapsed.tsx oracle row', + children: [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }, + expected: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/text-hanging.tsx oracle row', + children: [ + { + type: 'paragraph', + children: [{ text: 'before' }, { text: 'selected' }, { text: 'after' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 2], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 2], offset: 0 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/block-hanging-over-void.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isVoid = (element) => element.type === 'block' + }, + children: [ + { + type: 'paragraph', + children: [{ text: 'This is a first paragraph' }], + }, + { + type: 'paragraph', + children: [{ text: 'This is the second paragraph' }], + }, + { + type: 'block', + children: [{ text: 'This void paragraph gets skipped over' }], + } as Descendant, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [3, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 28 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/block-hanging-over-void-with-voids-option.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isVoid = (element) => element.type === 'block' + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [{ text: 'This is a first paragraph' }], + }, + { + type: 'paragraph', + children: [{ text: 'This is the second paragraph' }], + }, + { + type: 'block', + children: [{ text: '' }], + } as Descendant, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [3, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 28 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/block-hanging-over-non-empty-void-with-voids-option.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isVoid = (element) => element.type === 'block' + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [{ text: 'This is a first paragraph' }], + }, + { + type: 'paragraph', + children: [{ text: 'This is the second paragraph' }], + }, + { + type: 'block', + children: [{ text: 'This is the third paragraph' }], + } as Descendant, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [3, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [2, 0], offset: 27 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/inline-at-end.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + element.type === 'inline' && + Boolean((element as { void?: boolean }).void) + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [ + { text: 'This is a first paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/multi-block-inline-at-end.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + element.type === 'inline' && + Boolean((element as { void?: boolean }).void) + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [ + { text: 'This is the first paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [ + { text: 'This is the second paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [2, 0], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 2], offset: 0 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/not-hanging-inline-at-end.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + element.type === 'inline' && + Boolean((element as { void?: boolean }).void) + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [ + { text: 'This is the first paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'This is the second paragraph' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/not-hanging-multi-block-inline-at-end.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + element.type === 'inline' && + Boolean((element as { void?: boolean }).void) + }, + options: { voids: true }, + children: [ + { + type: 'paragraph', + children: [ + { text: 'This is the first paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [ + { text: 'This is the second paragraph' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'This is the third paragraph' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 2], offset: 0 }, + }, + expected: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 2], offset: 0 }, + }, + }, + { + name: 'mirrors the legacy unhangRange/inline-range-normal.tsx oracle row', + configure: (editor: ReturnType) => { + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + element.type === 'inline' && + Boolean((element as { void?: boolean }).void) + }, + children: [ + { + type: 'paragraph', + children: [{ text: 'Block before' }], + }, + { + type: 'paragraph', + children: [ + { text: 'Some text before ' }, + { + type: 'inline', + void: true, + children: [{ text: '' }], + }, + { text: '' }, + ], + } as Descendant, + { + type: 'paragraph', + children: [{ text: 'Another block' }], + }, + ] as Descendant[], + selection: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 1, 0], offset: 0 }, + }, + expected: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 1, 0], offset: 0 }, + }, + }, +] + +for (const testCase of unhangOracleCases) { + it(testCase.name, () => { + const editor = createEditor() + testCase.configure?.(editor) + + Editor.replace(editor, { + children: testCase.children, + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.unhangRange(editor, testCase.selection, testCase.options), + testCase.expected + ) + }) +} + +it('nodes supports pass and universal on the current traversal seam', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'section', + pass: true, + children: [ + { + type: 'paragraph', + match: true, + children: [{ text: 'one' }], + }, + ], + } as Descendant, + { + type: 'section', + children: [ + { + type: 'paragraph', + match: true, + children: [{ text: 'two' }], + }, + { + type: 'paragraph', + pass: true, + match: true, + children: [{ text: 'three' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Array.from( + Editor.nodes(editor, { + at: [], + match: (node) => !!(node as Record).match, + pass: ([node]) => !!(node as Record).pass, + }) + ), + [ + [ + { + type: 'paragraph', + match: true, + children: [{ text: 'two' }], + }, + [1, 0], + ], + [ + { + type: 'paragraph', + pass: true, + match: true, + children: [{ text: 'three' }], + }, + [1, 1], + ], + ] + ) + + assert.deepEqual( + Array.from( + Editor.nodes(editor, { + at: [], + match: (node) => !!(node as Record).match, + mode: 'lowest', + universal: true, + }) + ), + [ + [ + { + type: 'paragraph', + match: true, + children: [{ text: 'one' }], + }, + [0, 0], + ], + [ + { + type: 'paragraph', + match: true, + children: [{ text: 'two' }], + }, + [1, 0], + ], + [ + { + type: 'paragraph', + pass: true, + match: true, + children: [{ text: 'three' }], + }, + [1, 1], + ], + ] + ) +}) + +it('positions enters void content only when the voids option is enabled', () => { + const editor = createEditor() + editor.isVoid = (element) => element.type === 'mention' + + Editor.replace(editor, { + children: [ + { + type: 'mention', + children: [ + { text: 'one' }, + { + type: 'chip', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.positions(editor, { at: [] })), []) + assert.deepEqual( + Array.from(Editor.positions(editor, { at: [], voids: true })), + [ + { path: [0, 0], offset: 0 }, + { path: [0, 0], offset: 1 }, + { path: [0, 0], offset: 2 }, + { path: [0, 0], offset: 3 }, + { path: [0, 1, 0], offset: 0 }, + { path: [0, 1, 0], offset: 1 }, + { path: [0, 1, 0], offset: 2 }, + { path: [0, 1, 0], offset: 3 }, + { path: [0, 2], offset: 0 }, + { path: [0, 2], offset: 1 }, + { path: [0, 2], offset: 2 }, + { path: [0, 2], offset: 3 }, + { path: [0, 2], offset: 4 }, + { path: [0, 2], offset: 5 }, + ] + ) +}) + +it('Editor exposes a narrowed static read/query layer for the current public surface', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'one ' }, + { + type: 'link', + children: [{ text: 'two' }], + }, + { text: ' three' }, + ], + } as Descendant, + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'four' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + const paragraph = Editor.node(editor, [0])[0] as Descendant & { + children: Descendant[] + type: string + } + const quote = Editor.node(editor, [1])[0] as Descendant & { + children: Descendant[] + type: string + } + const sameBlockRange = { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [0, 2], offset: 2 }, + } + const spanningRange = { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [1, 0, 0], offset: 2 }, + } + + assert.deepEqual(Editor.path(editor, sameBlockRange), [0]) + assert.deepEqual(Editor.path(editor, spanningRange), []) + assert.deepEqual( + Editor.path(editor, spanningRange, { edge: 'end' }), + [1, 0, 0] + ) + assert.deepEqual(Editor.start(editor, [0]), { path: [0, 0], offset: 0 }) + assert.deepEqual(Editor.end(editor, [0]), { path: [0, 2], offset: 6 }) + assert.deepEqual(Editor.edges(editor, spanningRange), [ + { path: [0, 1, 0], offset: 1 }, + { path: [1, 0, 0], offset: 2 }, + ]) + assert.deepEqual(Editor.point(editor, [1], { edge: 'start' }), { + path: [1, 0, 0], + offset: 0, + }) + assert.deepEqual(Editor.first(editor, spanningRange), [ + { text: 'two' }, + [0, 1, 0], + ]) + assert.deepEqual(Editor.last(editor, [1]), [{ text: 'four' }, [1, 0, 0]]) + assert.deepEqual(Editor.range(editor, [1]), { + anchor: { path: [1, 0, 0], offset: 0 }, + focus: { path: [1, 0, 0], offset: 4 }, + }) + assert.deepEqual(Editor.node(editor, sameBlockRange), [paragraph, [0]]) + assert.deepEqual(Editor.parent(editor, { path: [1, 0, 0], offset: 2 }), [ + quote.children[0], + [1, 0], + ]) + assert.deepEqual( + Array.from( + Editor.nodes(editor, { + at: [], + match: (node) => + 'type' in node && + (node as Descendant & { type?: string }).type === 'paragraph', + mode: 'lowest', + }) + ), + [ + [paragraph, [0]], + [quote.children[0], [1, 0]], + ] + ) + assert.deepEqual( + Array.from(Editor.levels(editor, { at: { path: [0, 1, 0], offset: 1 } })), + [ + [Editor.node(editor, [])[0], []], + [paragraph, [0]], + [paragraph.children[1], [0, 1]], + [{ text: 'two' }, [0, 1, 0]], + ] + ) + assert.deepEqual(Editor.next(editor, { at: [0] }), [quote, [1]]) + assert.deepEqual(Editor.previous(editor, { at: [1] }), [paragraph, [0]]) + assert.equal(Editor.string(editor, [0]), 'one two three') + assert.equal(Editor.string(editor, spanningRange), 'wo threefo') + assert.deepEqual(Editor.fragment(editor, [1]), [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'four' }], + }, + ], + }, + ]) + assert.deepEqual( + Editor.fragment(editor, { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 1 }, + }), + [] + ) + assert.equal(Editor.hasPath(editor, [1, 0, 0]), true) + assert.equal(Editor.hasPath(editor, [9]), false) + assert.equal(Editor.hasInlines(editor, paragraph), true) + assert.equal(Editor.hasTexts(editor, paragraph), false) + assert.equal(Editor.hasBlocks(editor, quote), true) + assert.equal(Editor.isBlock(editor, paragraph), true) + assert.equal( + Editor.isEmpty(editor, { + type: 'paragraph', + children: [{ text: '' }], + }), + true + ) + assert.equal(Editor.isStart(editor, { path: [0, 0], offset: 0 }, [0]), true) + assert.equal(Editor.isEnd(editor, { path: [0, 2], offset: 6 }, [0]), true) + assert.equal(Editor.isEdge(editor, { path: [0, 2], offset: 6 }, [0]), true) +}) + +it('Editor static read/query helpers see the live draft tree inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + let observed: + | { + firstString: string + insertedPathExists: boolean + shiftedNodeString: string + lastPoint: { path: number[]; offset: number } + } + | undefined + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'zero' }], + }, + { at: [0] } + ) + + observed = { + firstString: Editor.string(editor, [0]), + insertedPathExists: Editor.hasPath(editor, [2]), + shiftedNodeString: Editor.string(editor, [2]), + lastPoint: Editor.end(editor, [2]), + } + }) + + assert.deepEqual(observed, { + firstString: 'zero', + insertedPathExists: true, + shiftedNodeString: 'beta', + lastPoint: { path: [2, 0], offset: 4 }, + }) +}) + +it('supports Editor.after across supported top-level block boundaries', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 5 }), { + path: [1, 0], + offset: 0, + }) +}) + +it('mirrors the legacy Editor.levels/success.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: '' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.levels(editor, { at: [0, 0] })), [ + [Editor.node(editor, [])[0], []], + [ + { + type: 'element', + children: [{ text: '' }], + }, + [0], + ], + [{ text: '' }, [0, 0]], + ]) +}) + +it('mirrors the legacy Editor.levels/reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + children: [{ text: '' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Array.from(Editor.levels(editor, { at: [0, 0], reverse: true })), + [ + [{ text: '' }, [0, 0]], + [ + { + type: 'element', + children: [{ text: '' }], + }, + [0], + ], + [Editor.node(editor, [])[0], []], + ] + ) +}) + +it('mirrors the legacy Editor.levels/match.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'element', + a: true, + children: [{ text: '', a: true }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Array.from( + Editor.levels(editor, { + at: [0, 0], + match: (node) => Boolean((node as { a?: boolean }).a), + }) + ), + [ + [ + { + type: 'element', + a: true, + children: [{ text: '', a: true }], + }, + [0], + ], + [{ text: '', a: true }, [0, 0]], + ] + ) +}) + +it('mirrors the legacy Editor.levels/voids-false.tsx oracle row', () => { + const editor = createEditor() + editor.isVoid = (element) => + Boolean((element as { type?: string }).type) && + Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: [ + { + type: 'element', + void: true, + children: [{ text: '' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Array.from(Editor.levels(editor, { at: [0, 0] })), [ + [Editor.node(editor, [])[0], []], + [ + { + type: 'element', + void: true, + children: [{ text: '' }], + }, + [0], + ], + ]) +}) + +it('mirrors the legacy Editor.levels/voids-true.tsx oracle row', () => { + const editor = createEditor() + editor.isVoid = (element) => + Boolean((element as { type?: string }).type) && + Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: [ + { + type: 'element', + void: true, + children: [{ text: '' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Array.from(Editor.levels(editor, { at: [0, 0], voids: true })), + [ + [Editor.node(editor, [])[0], []], + [ + { + type: 'element', + void: true, + children: [{ text: '' }], + }, + [0], + ], + [{ text: '' }, [0, 0]], + ] + ) +}) + +it('mirrors the legacy Editor.next/default.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.next(editor, { at: [0] }), [ + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + [1], + ]) +}) + +it('mirrors the legacy Editor.next/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.next(editor, { + at: [0], + match: (node) => 'type' in node && Editor.isBlock(editor, node), + }), + [ + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + [1], + ] + ) +}) + +it('mirrors the legacy Editor.next/text.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.next(editor, { + at: [0], + match: (node) => 'text' in node, + }), + [{ text: 'two' }, [1, 0]] + ) +}) + +it('mirrors the legacy Editor.previous/default.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.previous(editor, { at: [1] }), [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + [0], + ]) +}) + +it('mirrors the legacy Editor.previous/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.previous(editor, { + at: [1], + match: (node) => 'type' in node && Editor.isBlock(editor, node), + }), + [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + [0], + ] + ) +}) + +it('mirrors the legacy Editor.previous/text.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.previous(editor, { + at: [1], + match: (node) => 'text' in node, + }), + [{ text: 'one' }, [0, 0]] + ) +}) + +it('supports Editor.before across supported top-level block boundaries', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, { path: [1, 0], offset: 0 }), { + path: [0, 0], + offset: 5, + }) +}) + +it('supports Editor.after across top-level block boundaries inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + let point: { path: readonly number[]; offset: number } | undefined + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [1] } + ) + point = Editor.after(editor, { path: [0, 0], offset: 5 }) + }) + + assert.deepEqual(point, { + path: [1, 0], + offset: 0, + }) +}) + +it('supports move helper calls across supported top-level block boundaries', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) + Transforms.move(editor, { distance: 1 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) +}) + +it('supports Editor.after and Editor.before with top-level block paths', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, [0]), { + path: [1, 0], + offset: 0, + }) + assert.deepEqual(Editor.before(editor, [1]), { + path: [0, 0], + offset: 5, + }) +}) + +it('mirrors the legacy Editor.before/path.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, [1, 0]), { + path: [0, 0], + offset: 3, + }) +}) + +it('mirrors the legacy Editor.after/path.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, [0, 0]), { + path: [1, 0], + offset: 0, + }) +}) + +it('mirrors the legacy Editor.before/point.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren().slice(0, 1), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 0, + }) +}) + +it('mirrors the legacy Editor.after/point.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren().slice(0, 1), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 2, + }) +}) + +it('supports Editor.after and Editor.before with supported mixed-inline descendant paths', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, [0, 1]), { + path: [0, 2], + offset: 0, + }) + assert.deepEqual(Editor.before(editor, [0, 1]), { + path: [0, 0], + offset: 6, + }) +}) + +it('supports Editor.after with a top-level block path inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + let point: { path: readonly number[]; offset: number } | undefined + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [1] } + ) + point = Editor.after(editor, [0]) + }) + + assert.deepEqual(point, { + path: [1, 0], + offset: 0, + }) +}) + +it('supports Editor.after on a point within the current text node', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 1 }), { + path: [0, 0], + offset: 2, + }) +}) + +it('supports Editor.before and Editor.after on a range by using the start/end edges', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.before(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }), + { path: [0, 0], offset: 0 } + ) + assert.deepEqual( + Editor.after(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }), + { path: [1, 0], offset: 3 } + ) +}) + +it('mirrors the legacy Editor.before/range.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.before(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }), + { path: [0, 0], offset: 0 } + ) +}) + +it('mirrors the legacy Editor.after/range.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.after(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }), + { path: [1, 0], offset: 3 } + ) +}) + +it('returns undefined when Editor.before or Editor.after hits the document boundary', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.before(editor, { path: [0, 0], offset: 0 }), undefined) + assert.equal(Editor.after(editor, { path: [1, 0], offset: 4 }), undefined) +}) + +it('mirrors the legacy Editor.before/start.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.before(editor, [0, 0]), undefined) +}) + +it('mirrors the legacy Editor.after/end.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.after(editor, [1, 0]), undefined) +}) + +it('supports Editor.after inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + let point: { path: readonly number[]; offset: number } | undefined + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 5 }, + }) + point = Editor.after(editor, { path: [0, 0], offset: 5 }) + }) + + assert.deepEqual(point, { path: [0, 0], offset: 6 }) +}) + +it('supports Editor.after across mixed-inline sibling text leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 6 }), { + path: [0, 1, 0], + offset: 0, + }) +}) + +it('supports Editor.before across mixed-inline sibling text leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, { path: [0, 2], offset: 0 }), { + path: [0, 1, 0], + offset: 9, + }) +}) + +it('supports Editor.after across mixed-inline siblings inside an outer transaction using the live draft tree', () => {}) + +it('supports Editor.before and Editor.after with voids: true on path and point locations', () => { + const editor = createEditor() + editor.isVoid = (element) => + Boolean((element as { type?: string }).type) && + Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: createVoidBlockPairChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, [1, 0], { voids: true }), { + path: [0, 0], + offset: 3, + }) + assert.deepEqual( + Editor.before(editor, { path: [0, 0], offset: 1 }, { voids: true }), + { + path: [0, 0], + offset: 0, + } + ) + assert.deepEqual( + Editor.after(editor, { path: [0, 0], offset: 1 }, { voids: true }), + { + path: [0, 0], + offset: 2, + } + ) +}) + +it('supports Editor.before and Editor.after with voids: true on range and split-void paths', () => { + const editor = createEditor() + editor.isVoid = (element) => + Boolean((element as { type?: string }).type) && + Boolean((element as { void?: boolean }).void) + + Editor.replace(editor, { + children: createVoidSplitChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, [0, 0], { voids: true }), { + path: [0, 1], + offset: 0, + }) + assert.deepEqual( + Editor.before( + editor, + { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 1], offset: 2 }, + }, + { voids: true } + ), + { path: [0, 0], offset: 0 } + ) + assert.deepEqual( + Editor.after( + editor, + { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 1], offset: 2 }, + }, + { voids: true } + ), + { path: [0, 1], offset: 3 } + ) +}) + +it('supports Editor.before and Editor.after by skipping non-selectable blocks', () => { + const editor = createEditor() + editor.isSelectable = (element) => + !(element as { nonSelectable?: boolean }).nonSelectable + + Editor.replace(editor, { + children: createNonSelectableBlockChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, { path: [2, 0], offset: 0 }), { + path: [0, 0], + offset: 3, + }) + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 3 }), { + path: [2, 0], + offset: 0, + }) + + Editor.replace(editor, { + children: createLeadingNonSelectableBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.before(editor, { path: [1, 0], offset: 0 }), undefined) + + Editor.replace(editor, { + children: createTrailingNonSelectableBlockChildren(), + selection: null, + marks: null, + }) + + assert.equal(Editor.after(editor, { path: [0, 0], offset: 3 }), undefined) +}) + +it('supports Editor.before and Editor.after by skipping non-selectable inline descendants', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + editor.isSelectable = (element) => + !(element as { nonSelectable?: boolean }).nonSelectable + + Editor.replace(editor, { + children: createNonSelectableInlineChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.before(editor, { path: [0, 2], offset: 0 }), { + path: [0, 0], + offset: 3, + }) + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 3 }), { + path: [0, 2], + offset: 0, + }) + + Editor.replace(editor, { + children: createLeadingNonSelectableInlineChildren(), + selection: null, + marks: null, + }) + + let point: { path: readonly number[]; offset: number } | undefined + + Editor.withTransaction(editor, () => { + editor.setChildren(createLeadingNonSelectableInlineChildren()) + point = Editor.before(editor, { path: [0, 1], offset: 0 }) + }) + + assert.equal(point, undefined) + + Editor.replace(editor, { + children: createTrailingNonSelectableInlineChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + editor.setChildren(createTrailingNonSelectableInlineChildren()) + point = Editor.after(editor, { path: [0, 0], offset: 3 }) + }) + + assert.equal(point, undefined) +}) + +it('supports Editor.after by skipping non-selectable inline void descendants', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + editor.isVoid = (element) => + Boolean((element as { type?: string }).type) && + Boolean((element as { void?: boolean }).void) + editor.isSelectable = (element) => + !(element as { nonSelectable?: boolean }).nonSelectable + + Editor.replace(editor, { + children: createNonSelectableInlineVoidChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.after(editor, { path: [0, 0], offset: 3 }), { + path: [0, 2], + offset: 0, + }) +}) + +it('supports Editor.before and Editor.after unit-based traversal', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'one two' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual( + Editor.after(editor, { path: [0, 0], offset: 0 }, { unit: 'word' }), + { path: [0, 0], offset: 3 } + ) + assert.deepEqual( + Editor.before(editor, { path: [0, 0], offset: 7 }, { unit: 'word' }), + { path: [0, 0], offset: 4 } + ) + assert.deepEqual( + Editor.after(editor, { path: [0, 0], offset: 0 }, { unit: 'character' }), + { path: [0, 0], offset: 1 } + ) +}) diff --git a/packages/slate/test/range-ref-contract.ts b/packages/slate/test/range-ref-contract.ts new file mode 100644 index 0000000000..8ce9d8bbf1 --- /dev/null +++ b/packages/slate/test/range-ref-contract.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createEditor, type Descendant, Editor, Transforms } from '../src' + +const createChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createNestedChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + }, +] + +describe('slate range ref contract', () => { + it('publishes range ref updates at transaction commit', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const ref = Editor.rangeRef(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '>', { + at: { path: [0, 0], offset: 0 }, + }) + + assert.deepEqual(ref.current, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + }) + + assert.deepEqual(ref.current, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 5 }, + }) + }) + + it('defaults rangeRef affinity inward', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const ref = Editor.rangeRef(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 4 }, + }) + + assert.deepEqual(ref.current, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + }) + + it('rebases range ref paths when top-level blocks move', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const ref = Editor.rangeRef(editor, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 3 }, + }) + + Editor.withTransaction(editor, () => { + Transforms.moveNodes(editor, { + at: [0], + to: [2], + }) + }) + + assert.deepEqual(ref.current, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }) + }) + + it('rebases range refs inside the moved top-level block when moveNodes targets a later slot', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const ref = Editor.rangeRef(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + + Editor.withTransaction(editor, () => { + Transforms.moveNodes(editor, { + at: [0], + to: [2], + }) + }) + + assert.deepEqual(ref.current, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 4 }, + }) + }) + + it('rebases nested range ref paths when nested blocks move', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createNestedChildren(), + selection: null, + marks: null, + }) + + const ref = Editor.rangeRef(editor, { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 3 }, + }) + + Editor.withTransaction(editor, () => { + Transforms.moveNodes(editor, { + at: [0, 0], + to: [0, 2], + }) + }) + + assert.deepEqual(ref.current, { + anchor: { path: [0, 0, 0], offset: 1 }, + focus: { path: [0, 0, 0], offset: 3 }, + }) + }) +}) diff --git a/packages/slate/test/snapshot-contract.ts b/packages/slate/test/snapshot-contract.ts new file mode 100644 index 0000000000..a901c8ffea --- /dev/null +++ b/packages/slate/test/snapshot-contract.ts @@ -0,0 +1,5793 @@ +import assert from 'node:assert/strict' +import { + createEditor, + type Descendant, + Editor, + Path, + type SnapshotChange, + Transforms, +} from '../src' + +const createChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, +] + +const createLegacyBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, +] + +const createLegacyMoveChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one two three' }], + }, +] + +const createLegacySingleBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, +] + +const createLegacyDeleteBoundaryChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + { + type: 'paragraph', + children: [{ text: 'another' }], + }, +] + +const createLegacyInlineDeleteChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'two' }], + }, + { text: 'three' }, + ], + } as Descendant, +] + +const createLegacyInlineDeleteInsideChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: '' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'word' }], + }, + { text: '' }, + ], + } as Descendant, +] + +const createLegacyInlineBoundaryChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [ + { text: 'two' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'three' }], + }, + { text: 'four' }, + ], + } as Descendant, +] + +const createLegacyInlineAfterChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'one' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'two' }], + }, + { text: 'a' }, + ], + } as Descendant, +] + +const createLegacyWrappedBlockChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, +] + +const createLegacyNestedBlockChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + ], + } as Descendant, +] + +const createLegacyNestedBlockAcrossChildren = (): Descendant[] => [ + { + type: 'quote', + a: true, + children: createLegacyBlockChildren(), + } as Descendant, +] + +const createLegacyQuoteChildren = (...texts: string[]): Descendant[] => [ + { + type: 'quote', + children: texts.map((text) => ({ + type: 'paragraph', + children: [{ text }], + })), + } as Descendant, +] + +const createLegacyNestedBlockStartChildren = (): Descendant[] => [ + { + type: 'quote', + a: true, + children: createLegacyQuoteChildren( + 'one', + 'two', + 'three', + 'four', + 'five', + 'six' + )[0]!.children, + } as Descendant, +] + +const createLegacyNestedBlockMultipleChildren = (): Descendant[] => [ + ...createLegacyQuoteChildren('one', 'two'), +] + +const createLegacyLiftFullChildren = (): Descendant[] => [ + ...createLegacyQuoteChildren('one', 'two', 'three', 'four', 'five', 'six'), +] + +const createLegacyLiftPairChildren = createLegacyNestedBlockMultipleChildren + +const createLegacyLiftTripleChildren = (): Descendant[] => [ + ...createLegacyQuoteChildren('one', 'two', 'three'), +] + +const createExpandedChildren = (): Descendant[] => [ + ...createChildren(), + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, +] + +const createStyledChildren = (): Descendant[] => [ + { + type: 'paragraph', + align: 'left', + children: [{ text: 'alpha', bold: true }], + } as Descendant, + { + type: 'paragraph', + align: 'right', + children: [{ text: 'beta' }], + } as Descendant, +] + +const createMergeTextChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [ + { text: 'al', bold: true }, + { text: 'pha', bold: true }, + ], + } as Descendant, +] + +const createElementMergeChildren = (): Descendant[] => [ + { + type: 'paragraph', + data: true, + children: [{ text: 'before' }], + } as Descendant, + { + type: 'paragraph', + data: true, + children: [ + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'two' }], + }, + { text: 'after' }, + ], + } as Descendant, +] + +const createWrapChildren = (): Descendant[] => [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, +] + +const createListWrapperChildren = (): Descendant[] => [ + { + type: 'bulleted-list', + children: [ + { + type: 'list-item', + children: [{ text: 'one' }], + }, + { + type: 'list-item', + children: [{ text: 'two' }], + }, + ], + } as Descendant, +] + +const createUnwrapChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + } as Descendant, +] + +const createTopLevelUnwrapChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + } as Descendant, + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + ], + } as Descendant, +] + +const createLiftOnlyChildChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + } as Descendant, +] + +const createLiftSiblingChildren = (): Descendant[] => [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + { + type: 'paragraph', + children: [{ text: 'three' }], + }, + ], + } as Descendant, +] + +const createElementSplitChildren = (): Descendant[] => [ + { + type: 'paragraph', + data: true, + children: [ + { text: 'before' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'hyperlink' }], + }, + { text: 'after' }, + ], + } as Descendant, +] + +const getBlockTexts = (children: readonly Descendant[]) => + children.map((node) => { + assert.ok('children' in node) + return node.children + .map((child) => ('text' in child ? child.text : '')) + .join('') + }) + +it('withoutNormalizing suppresses custom normalization until manual normalize', () => { + const editor = createEditor() + let runs = 0 + const originalNormalizeNode = editor.normalizeNode + let runsInsideCallback = 0 + + editor.normalizeNode = (...args) => { + runs += 1 + originalNormalizeNode(...args) + } + + Editor.withoutNormalizing(editor, () => { + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + runsInsideCallback = runs + }) + + assert.equal(runsInsideCallback, 0) + assert.equal(runs > 0, true) + + Editor.normalize(editor) + + assert.equal(runs > 0, true) +}) + +it('mirrors the legacy transforms/normalization/split_node-and-insert_node.tsx oracle row', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'one' }] }, + { text: '' }, + ], + }, + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'two' }] }, + { text: '' }, + ], + }, + ], + selection: null, + marks: null, + }) + + Editor.withoutNormalizing(editor, () => { + Transforms.splitNodes(editor, { + at: [0], + position: 1, + }) + Transforms.splitNodes(editor, { + at: [2], + position: 1, + }) + editor.apply({ + type: 'insert_node', + path: [2, 1], + node: { text: '' }, + }) + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + children: [{ text: '' }], + }, + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'one' }] }, + { text: '' }, + ], + }, + { + type: 'block', + children: [{ text: '' }], + }, + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'two' }] }, + { text: '' }, + ], + }, + ]) +}) + +it('shouldMergeNodesRemovePrevNode can remove an empty previous sibling during mergeNodes', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + ], + selection: null, + marks: null, + }) + + Transforms.mergeNodes(editor, { at: [1] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + ]) +}) + +it('shouldNormalize runs once per custom normalization pass, not once per entry', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + const shouldNormalizeCalls: Array<{ + iteration: number + operation?: unknown + }> = [] + const normalizedPaths: Path[] = [] + + editor.shouldNormalize = (options) => { + shouldNormalizeCalls.push(options) + return shouldNormalizeCalls.length === 1 + } + + editor.normalizeNode = (entry, options) => { + normalizedPaths.push(entry[1]) + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + assert.deepEqual(shouldNormalizeCalls, [ + { explicit: false, iteration: 0, operation: undefined }, + ]) + assert.equal(normalizedPaths.length, 5) +}) + +it('shouldNormalize can skip a custom normalization pass for the current transaction', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + const shouldNormalizeCalls: Array<{ + iteration: number + operation?: unknown + }> = [] + + editor.shouldNormalize = (options) => { + shouldNormalizeCalls.push(options) + return false + } + + editor.normalizeNode = (entry, options) => { + const [node] = entry + + if (Editor.isEditor(node) && node.children.length < 2) { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: '' }], + }, + { at: [1] } + ) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: [ + { + type: 'title', + children: [{ text: 'Only title' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(shouldNormalizeCalls, [ + { explicit: false, iteration: 0, operation: undefined }, + ]) + assert.equal(Editor.getSnapshot(editor).children.length, 1) +}) + +it('Editor.normalize marks the custom normalization pass as explicit', () => { + const editor = createEditor() + const shouldNormalizeCalls: Array<{ + explicit?: boolean + iteration: number + operation?: unknown + }> = [] + + editor.shouldNormalize = (options) => { + shouldNormalizeCalls.push(options) + return false + } + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + shouldNormalizeCalls.length = 0 + + Editor.normalize(editor) + + assert.deepEqual(shouldNormalizeCalls, [ + { explicit: true, iteration: 0, operation: undefined }, + ]) +}) + +it('fails intentionally when custom normalization revisits an earlier draft state', () => { + const editor = createEditor() + + editor.normalizeNode = (entry) => { + const [node] = entry + + if (!Editor.isEditor(node)) { + return + } + + if (node.children.length === 1) { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: '' }], + }, + { at: [1] } + ) + return + } + + Transforms.removeNodes(editor, { at: [1] }) + } + + assert.throws(() => { + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + selection: null, + marks: null, + }) + }, /revisited an earlier draft state|no-progress|debt/i) +}) + +it('treats semantic id prop changes as normalization progress', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.normalizeNode = (entry, options) => { + const [node, path] = entry + + if ( + path.length === 1 && + !Editor.isEditor(node) && + 'children' in node && + node.type === 'paragraph' && + (node as Descendant & { id?: string }).id !== 'kept' + ) { + Transforms.setNodes(editor, { id: 'kept' }, { at: path }) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + selection: null, + marks: null, + }) + + assert.equal( + (Editor.getSnapshot(editor).children[0] as Descendant & { id?: string }).id, + 'kept' + ) +}) + +it('normalizeNode can enforce a descendant-level node rewrite with supported transforms', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.normalizeNode = (entry, options) => { + const [node, path] = entry + + if (path.length > 0 && 'children' in node && node.type === 'heading') { + Transforms.setNodes( + editor, + { + type: 'paragraph', + }, + { at: path } + ) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: [ + { + type: 'heading', + children: [{ text: 'nested' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'nested' }], + }, + ]) +}) + +it('normalizeNode can wrap stray top-level text and inline children through fallbackElement', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.isInline = (element) => element.type === 'chip' + editor.normalizeNode = (entry, options) => { + originalNormalizeNode(entry, { + ...options, + fallbackElement: () => ({ + type: 'paragraph', + children: [{ text: '' }], + }), + }) + } + + Editor.replace(editor, { + children: [ + { text: 'alpha' } as Descendant, + { + type: 'chip', + children: [{ text: 'beta' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [ + { text: '' }, + { + type: 'chip', + children: [{ text: 'beta' }], + }, + { text: '' }, + ], + }, + ]) +}) + +it('normalizeNode inserts an empty text child into empty elements', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + ]) +}) + +it('normalizeNode inserts spacer text around inline-only children', () => { + const editor = createEditor() + + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + children: [{ text: 'beta' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [ + { text: '' }, + { + type: 'link', + children: [{ text: 'beta' }], + }, + { text: '' }, + ], + }, + ]) +}) + +it('normalizeNode removes a stray top-level text child after insertNodes', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + selection: null, + marks: null, + }) + + Transforms.insertNodes(editor, { text: 'stray' }, { at: [0] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ]) +}) + +it('normalizeNode removes a stray block-only inline child after insertNodes', () => { + const editor = createEditor() + + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + Transforms.insertNodes( + editor, + { + type: 'link', + children: [{ text: 'stray' }], + }, + { at: [0, 1] } + ) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + }, + ]) +}) + +it('normalizeNode removes a stray top-level text child during replace', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { text: 'stray' } as Descendant, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ]) +}) + +it('Editor.normalize explicitly merges adjacent compatible text children in inline-style containers', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createMergeTextChildren(), + selection: null, + marks: null, + }) + + Editor.normalize(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha', bold: true }], + }, + ]) +}) + +it('Editor.normalize explicitly removes empty adjacent text in inline-style containers', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'alpha', bold: true }, + { text: '', bold: true }, + { text: 'beta', bold: true }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + Editor.normalize(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alphabeta', bold: true }], + }, + ]) +}) + +it('Editor.normalize explicitly flattens block wrappers inside inline-style containers', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'alpha' }, + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + Editor.normalize(editor) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alphabeta' }], + }, + ]) +}) + +it('normalizeNode removes a stray block-only inline child during replace', () => { + const editor = createEditor() + + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'link', + children: [{ text: 'stray' }], + }, + ], + } as Descendant, + ], + selection: null, + marks: null, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'quote', + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + ], + }, + ]) +}) + +it('normalizeNode flattens a direct block child inserted into an inline-style container', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { text: 'gamma' }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + { at: [0, 1] } + ) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }, { text: 'beta' }, { text: 'gamma' }], + }, + ]) +}) + +it('markableVoid lets addMark and removeMark target the text child inside a void element', () => { + const editor = createEditor() + editor.isVoid = (element) => element.type === 'mention' + editor.markableVoid = (element) => element.type === 'mention' + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { + type: 'mention', + character: 'Ada', + children: [{ text: '' }], + }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0], offset: 0 }, + }, + marks: null, + }) + + Editor.addMark(editor, 'bold', true) + + let snapshot = Editor.getSnapshot(editor) + let mention = snapshot.children[0].children[0] as Descendant & { + children: Array + } + + assert.equal(mention.children[0]?.bold, true) + assert.equal(snapshot.marks, null) + + Editor.removeMark(editor, 'bold') + + snapshot = Editor.getSnapshot(editor) + mention = snapshot.children[0].children[0] as Descendant & { + children: Array + } + + assert.equal(mention.children[0]?.bold, undefined) + assert.equal(snapshot.marks, null) +}) + +it('insertBreak splits the current top-level block and moves selection into the new block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }, + marks: null, + }) + + editor.insertBreak() + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) +}) + +it('insertSoftBreak currently aliases insertBreak on the proved block split seam', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }, + marks: null, + }) + + editor.insertSoftBreak() + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'alpha' }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'beta' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [2, 0], offset: 0 }, + focus: { path: [2, 0], offset: 0 }, + }) +}) + +it('publishes once after a transaction and keeps same-version reads stable', () => { + const editor = createEditor() + const snapshots = [Editor.getSnapshot(editor)] + let notifications = 0 + + Editor.subscribe(editor, (snapshot) => { + notifications += 1 + snapshots.push(snapshot) + }) + + Editor.replace(editor, { + children: createChildren(), + marks: { bold: true }, + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + }) + + const before = Editor.getSnapshot(editor) + const beforeAgain = Editor.getSnapshot(editor) + + assert.equal(before, beforeAgain) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { at: { path: [0, 0], offset: 5 } }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 6 }, + }) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(notifications, 2) + assert.equal(after.children[0].children[0].text, 'alpha!') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 6 }, + }) + assert.deepEqual(after.marks, { bold: true }) + assert.notEqual(before, after) +}) + +it('publishes touched runtime ids for collapsed insert_text operations', () => { + const editor = createEditor() + const changes: SnapshotChange[] = [] + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + const runtimeId = Editor.getSnapshot(editor).index.pathToId['0.0'] + + assert.ok(runtimeId) + + Editor.subscribe(editor, (_snapshot, change) => { + if (change) { + changes.push(change) + } + }) + + editor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + assert.equal(changes.length, 1) + assert.deepEqual(changes[0]?.classes, ['text']) + assert.deepEqual(changes[0]?.dirtyPaths, [[], [0], [0, 0]]) + assert.equal(changes[0]?.dirtyScope, 'paths') + assert.equal(changes[0]?.childrenChanged, true) + assert.equal(changes[0]?.selectionChanged, false) + assert.equal(changes[0]?.marksChanged, false) + assert.deepEqual(changes[0]?.touchedRuntimeIds, [runtimeId]) +}) + +it('publishes selection-only dirtiness without touched runtime ids', () => { + const editor = createEditor() + const changes: SnapshotChange[] = [] + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.subscribe(editor, (_snapshot, change) => { + if (change) { + changes.push(change) + } + }) + + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }) + + assert.equal(changes.length, 1) + assert.deepEqual(changes[0]?.classes, ['selection']) + assert.deepEqual(changes[0]?.dirtyPaths, []) + assert.equal(changes[0]?.dirtyScope, 'none') + assert.equal(changes[0]?.childrenChanged, false) + assert.equal(changes[0]?.selectionChanged, true) + assert.equal(changes[0]?.marksChanged, false) + assert.deepEqual(changes[0]?.touchedRuntimeIds, []) +}) + +it('publishes replace-level broad invalidation for Editor.replace', () => { + const editor = createEditor() + const changes: SnapshotChange[] = [] + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.subscribe(editor, (_snapshot, change) => { + if (change) { + changes.push(change) + } + }) + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'changed' }], + }, + ], + selection: null, + marks: null, + }) + + assert.equal(changes.length, 1) + assert.deepEqual(changes[0]?.classes, ['replace']) + assert.deepEqual(changes[0]?.dirtyPaths, []) + assert.equal(changes[0]?.dirtyScope, 'all') + assert.equal(changes[0]?.childrenChanged, true) + assert.equal(changes[0]?.selectionChanged, false) + assert.equal(changes[0]?.marksChanged, false) + assert.equal(changes[0]?.touchedRuntimeIds, null) +}) + +it('publishes marks-only dirtiness without pretending the document paths changed', () => { + const editor = createEditor() + const changes: SnapshotChange[] = [] + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Editor.subscribe(editor, (_snapshot, change) => { + if (change) { + changes.push(change) + } + }) + + Editor.addMark(editor, 'bold', true) + + assert.equal(changes.length, 1) + assert.deepEqual(changes[0]?.classes, ['mark']) + assert.deepEqual(changes[0]?.dirtyPaths, []) + assert.equal(changes[0]?.dirtyScope, 'none') + assert.equal(changes[0]?.childrenChanged, false) + assert.equal(changes[0]?.selectionChanged, false) + assert.equal(changes[0]?.marksChanged, true) + assert.deepEqual(changes[0]?.touchedRuntimeIds, []) +}) + +it('keeps selection null for direct insert_text apply just like the transaction path', () => { + const directEditor = createEditor() + const transactionEditor = createEditor() + + Editor.replace(directEditor, { + children: createChildren(), + selection: null, + marks: null, + }) + Editor.replace(transactionEditor, { + children: createChildren(), + selection: null, + marks: null, + }) + + directEditor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + Editor.withTransaction(transactionEditor, () => { + transactionEditor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + }) + + assert.equal(Editor.getSnapshot(directEditor).selection, null) + assert.equal(Editor.getSnapshot(transactionEditor).selection, null) + assert.deepEqual( + Editor.getSnapshot(directEditor), + Editor.getSnapshot(transactionEditor) + ) +}) + +it('publishes an immutable cloned selection for direct insert_text apply', () => { + const editor = createEditor() + const selection = { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + } + + Editor.replace(editor, { + children: createChildren(), + selection, + marks: null, + }) + + editor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.selection, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 6 }, + }) + assert.notEqual(snapshot.selection, selection) + assert.notEqual(snapshot.selection?.anchor, selection.anchor) + assert.throws(() => { + ;( + snapshot.selection as NonNullable + ).anchor.offset = 99 + }) + assert.equal(Editor.getSnapshot(editor).selection?.anchor.offset, 6) + assert.throws(() => { + ;( + snapshot.selection as NonNullable + ).anchor.path[0] = 9 + }) + assert.deepEqual(Editor.getSnapshot(editor).selection?.anchor.path, [0, 0]) +}) + +it('falls back to the transaction path for direct text ops when custom normalization is overridden', () => { + const editor = createEditor() + const originalNormalizeNode = editor.normalizeNode + + editor.normalizeNode = (entry, options) => { + const [node, path] = entry + + if ( + path.length === 1 && + !Editor.isEditor(node) && + 'children' in node && + node.type === 'paragraph' && + (node as Descendant & { normalized?: boolean }).normalized !== true && + node.children.some((child) => 'text' in child && child.text.includes('!')) + ) { + Transforms.setNodes(editor, { normalized: true }, { at: path }) + return + } + + originalNormalizeNode(entry, options) + } + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + editor.apply({ + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + const firstBlock = Editor.getSnapshot(editor).children[0] as Descendant & { + normalized?: boolean + } + + assert.equal(firstBlock.normalized, true) +}) + +it('replacement publishes a new snapshot without mutating the previous one', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const previous = Editor.getSnapshot(editor) + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'changed' }], + }, + ], + selection: null, + marks: { italic: true }, + }) + + const current = Editor.getSnapshot(editor) + + assert.equal(previous.children[0].children[0].text, 'alpha') + assert.equal(current.children[0].children[0].text, 'changed') + assert.equal(current.version, previous.version + 1) + assert.deepEqual(current.marks, { italic: true }) +}) + +it('Editor.marks returns the current text leaf marks for a collapsed selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha', bold: true }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + marks: null, + }) + + assert.deepEqual(Editor.marks(editor), { bold: true }) +}) + +it('Editor.addMark stores explicit marks for collapsed insertion and Editor.insertText uses them', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'plain' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }, + marks: null, + }) + + Editor.addMark(editor, 'bold', true) + + assert.deepEqual(Editor.marks(editor), { bold: true }) + + Editor.insertText(editor, '!') + + const snapshot = Editor.getSnapshot(editor) + const firstBlock = snapshot.children[0] as Descendant & { + children: Array + } + + assert.deepEqual(snapshot.marks, null) + assert.deepEqual(snapshot.selection, { + anchor: { path: [0, 1], offset: 1 }, + focus: { path: [0, 1], offset: 1 }, + }) + assert.deepEqual(firstBlock.children, [ + { text: 'plain' }, + { text: '!', bold: true }, + ]) +}) + +it('Editor.removeMark can clear inherited leaf marks for the next collapsed insertion', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'bold', bold: true }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Editor.removeMark(editor, 'bold') + + assert.deepEqual(Editor.marks(editor), {}) + + Editor.insertText(editor, '!') + + const snapshot = Editor.getSnapshot(editor) + const firstBlock = snapshot.children[0] as Descendant & { + children: Array + } + + assert.deepEqual(snapshot.marks, null) + assert.deepEqual(snapshot.selection, { + anchor: { path: [0, 1], offset: 1 }, + focus: { path: [0, 1], offset: 1 }, + }) + assert.deepEqual(firstBlock.children, [ + { text: 'bold', bold: true }, + { text: '!' }, + ]) +}) + +it('Editor.addMark applies bold across an expanded selection while preserving existing marks', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [ + { text: 'ab' }, + { text: 'cd', italic: true }, + { text: 'ef', underline: true }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 2], offset: 1 }, + }, + marks: null, + }) + + Editor.addMark(editor, 'bold', true) + + const snapshot = Editor.getSnapshot(editor) + const firstBlock = snapshot.children[0] as Descendant & { + children: Array< + Descendant & { bold?: boolean; italic?: boolean; underline?: boolean } + > + } + + assert.deepEqual(firstBlock.children, [ + { text: 'a' }, + { text: 'b', bold: true }, + { text: 'cd', italic: true, bold: true }, + { text: 'e', underline: true, bold: true }, + { text: 'f', underline: true }, + ]) +}) + +it('Editor.removeMark clears bold only inside an expanded subrange', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'alpha', bold: true }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Editor.removeMark(editor, 'bold') + + const snapshot = Editor.getSnapshot(editor) + const firstBlock = snapshot.children[0] as Descendant & { + children: Array + } + + assert.deepEqual(firstBlock.children, [ + { text: 'a', bold: true }, + { text: 'lph' }, + { text: 'a', bold: true }, + ]) +}) + +it('preserves custom node properties across replacement snapshots', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createStyledChildren(), + selection: null, + marks: null, + }) + + const snapshot = Editor.getSnapshot(editor) + const firstBlock = snapshot.children[0] as Descendant & { + align?: string + children: Array + } + + assert.equal(firstBlock.align, 'left') + assert.equal(firstBlock.children[0]?.bold, true) +}) + +it('preserves runtime ids when moving a node inside the proof subset', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + const before = Editor.getSnapshot(editor) + const firstId = before.index.pathToId['0'] + + assert.ok(firstId) + + Editor.withTransaction(editor, () => { + Transforms.moveNodes(editor, { + at: [0], + to: [2], + }) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.index.pathToId['1'], firstId) + assert.equal(after.children[1].children[0].text, 'alpha') +}) + +it('mirrors the legacy transforms/normalization/move_node.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + { + type: 'block', + children: [{ text: 'two' }], + }, + ], + selection: null, + marks: null, + }) + + Transforms.moveNodes(editor, { at: [0, 0], to: [1, 0] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + children: [{ text: '' }], + }, + { + type: 'block', + children: [{ text: 'one' }, { text: 'two' }], + }, + ]) +}) + +it('supports insert_node and remove_node through editor.apply and keeps sibling ids stable', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + const before = Editor.getSnapshot(editor) + const alphaId = before.index.pathToId['0'] + const betaId = before.index.pathToId['1'] + + assert.ok(alphaId) + assert.ok(betaId) + + editor.apply({ + type: 'insert_node', + path: [0], + node: { + type: 'paragraph', + children: [{ text: 'zero' }], + }, + }) + + const afterInsert = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(afterInsert.children), [ + 'zero', + 'alpha', + 'beta', + ]) + assert.equal(afterInsert.index.pathToId['1'], alphaId) + assert.equal(afterInsert.index.pathToId['2'], betaId) + + editor.apply({ + type: 'remove_node', + path: [1], + node: afterInsert.children[1]!, + }) + + const afterRemove = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(afterRemove.children), ['zero', 'beta']) + assert.equal(afterRemove.index.pathToId['1'], betaId) +}) + +it('supports path-based insertNodes/removeNodes transforms in one outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + }) + + const before = Editor.getSnapshot(editor) + const alphaId = before.index.pathToId['0'] + const betaId = before.index.pathToId['1'] + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + [ + { + type: 'paragraph', + children: [{ text: 'zero' }], + }, + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + ], + { at: [0] } + ) + Transforms.removeNodes(editor, { at: [3] }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['zero', 'one', 'alpha']) + assert.equal(after.index.pathToId['2'], alphaId) + assert.equal(after.selection, null) + assert.equal(after.index.pathToId['3'], undefined) + assert.notEqual(after.index.pathToId['0'], alphaId) + assert.notEqual(after.index.pathToId['1'], betaId) +}) + +it('supports set_node and path-based setNodes while keeping runtime ids stable', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createStyledChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const blockId = before.index.pathToId['0'] + const textId = before.index.pathToId['0.0'] + + editor.apply({ + type: 'set_node', + path: [0], + newProperties: { + type: 'quote', + align: 'center', + }, + }) + + Editor.withTransaction(editor, () => { + Transforms.setNodes( + editor, + { + italic: true, + }, + { at: [0, 0] } + ) + }) + + const after = Editor.getSnapshot(editor) + const firstBlock = after.children[0] as Descendant & { + align?: string + children: Array + type: string + } + + assert.equal(firstBlock.type, 'quote') + assert.equal(firstBlock.align, 'center') + assert.equal(firstBlock.children[0]?.bold, true) + assert.equal(firstBlock.children[0]?.italic, true) + assert.equal(after.index.pathToId['0'], blockId) + assert.equal(after.index.pathToId['0.0'], textId) +}) + +it('supports property removal through set_node and path-based unsetNodes while keeping runtime ids stable', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createStyledChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const blockId = before.index.pathToId['0'] + const textId = before.index.pathToId['0.0'] + + editor.apply({ + type: 'set_node', + path: [0], + properties: { + align: 'left', + }, + newProperties: {}, + }) + + Editor.withTransaction(editor, () => { + Transforms.unsetNodes(editor, 'bold', { at: [0, 0] }) + }) + + const after = Editor.getSnapshot(editor) + const firstBlock = after.children[0] as Descendant & { + align?: string + children: Array + } + + assert.equal(firstBlock.align, undefined) + assert.equal(firstBlock.children[0]?.bold, undefined) + assert.equal(after.index.pathToId['0'], blockId) + assert.equal(after.index.pathToId['0.0'], textId) +}) + +it('supports remove_text through editor.apply and rebases selection inward', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const textId = before.index.pathToId['0.0'] + + editor.apply({ + type: 'remove_text', + path: [0, 0], + offset: 1, + text: 'lp', + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'aha') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }) + assert.equal(after.index.pathToId['0.0'], textId) +}) + +it('supports exact removeText helper calls while keeping runtime ids stable', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const textId = before.index.pathToId['1.0'] + + Transforms.removeText(editor, 'et', { + at: { path: [1, 0], offset: 1 }, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[1].children[0].text, 'ba') + assert.equal(after.selection, null) + assert.equal(after.index.pathToId['1.0'], textId) +}) + +it('supports split_node on a text path and keeps the original id on the left branch', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0.0'] + + editor.apply({ + type: 'split_node', + path: [0, 0], + position: 3, + properties: {}, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'alp') + assert.equal(after.children[0].children[1].text, 'ha') + assert.equal(after.index.pathToId['0.0'], leftId) + assert.notEqual(after.index.pathToId['0.1'], leftId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 1], offset: 0 }, + focus: { path: [0, 1], offset: 0 }, + }) +}) + +it('supports point-based splitNodes helper calls on text nodes and keeps sibling ids stable', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['1.0'] + + Transforms.splitNodes(editor, { + at: { path: [1, 0], offset: 2 }, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[1].children[0].text, 'be') + assert.equal(after.children[1].children[1].text, 'ta') + assert.equal(after.index.pathToId['1.0'], leftId) + assert.notEqual(after.index.pathToId['1.1'], leftId) + assert.deepEqual(after.selection, { + anchor: { path: [1, 1], offset: 0 }, + focus: { path: [1, 1], offset: 0 }, + }) +}) + +it('supports split_node on an element path, keeps the legacy leading empty text, and preserves moved descendant ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: { + anchor: { path: [0, 2], offset: 2 }, + focus: { path: [0, 2], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0'] + const linkId = before.index.pathToId['0.1'] + const trailingTextId = before.index.pathToId['0.2'] + + editor.apply({ + type: 'split_node', + path: [0], + position: 1, + properties: { data: true }, + }) + + const after = Editor.getSnapshot(editor) + const leftBlock = after.children[0] as Descendant & { data?: boolean } + const rightBlock = after.children[1] as Descendant & { data?: boolean } + + assert.equal(leftBlock.data, true) + assert.equal(rightBlock.data, true) + assert.equal(leftBlock.children.length, 1) + assert.equal(rightBlock.children.length, 3) + assert.deepEqual(rightBlock.children[0], { text: '' }) + assert.equal(after.index.pathToId['0'], leftId) + assert.equal(after.index.pathToId['1.1'], linkId) + assert.equal(after.index.pathToId['1.2'], trailingTextId) + assert.notEqual(after.index.pathToId['1'], leftId) + assert.deepEqual(after.selection, { + anchor: { path: [1, 2], offset: 2 }, + focus: { path: [1, 2], offset: 2 }, + }) +}) + +it('supports path-based splitNodes helper calls on element nodes with the legacy leading empty text', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: { + anchor: { path: [0, 2], offset: 2 }, + focus: { path: [0, 2], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0'] + const linkId = before.index.pathToId['0.1'] + + Transforms.splitNodes(editor, { + at: [0], + position: 1, + }) + + const after = Editor.getSnapshot(editor) + const leftBlock = after.children[0] as Descendant & { data?: boolean } + const rightBlock = after.children[1] as Descendant & { data?: boolean } + + assert.equal(leftBlock.data, true) + assert.equal(rightBlock.data, true) + assert.equal(leftBlock.children.length, 1) + assert.equal(rightBlock.children.length, 3) + assert.deepEqual(rightBlock.children[0], { text: '' }) + assert.equal(after.index.pathToId['0'], leftId) + assert.equal(after.index.pathToId['1.1'], linkId) + assert.deepEqual(after.selection, { + anchor: { path: [1, 2], offset: 2 }, + focus: { path: [1, 2], offset: 2 }, + }) +}) + +it('supports merge_node on a text path and keeps the left branch id', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createMergeTextChildren(), + selection: { + anchor: { path: [0, 1], offset: 2 }, + focus: { path: [0, 1], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0.0'] + const rightId = before.index.pathToId['0.1'] + + editor.apply({ + type: 'merge_node', + path: [0, 1], + position: 2, + properties: { bold: true }, + }) + + const after = Editor.getSnapshot(editor) + const firstText = after.children[0].children[0] as Descendant & { + bold?: boolean + } + + assert.equal(after.children[0].children.length, 1) + assert.equal(firstText.text, 'alpha') + assert.equal(firstText.bold, true) + assert.equal(after.index.pathToId['0.0'], leftId) + assert.equal(after.index.pathToId['0.1'], undefined) + assert.notEqual(leftId, rightId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('supports path-based mergeNodes helper calls on text nodes and keeps the left branch id', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createMergeTextChildren(), + selection: { + anchor: { path: [0, 1], offset: 1 }, + focus: { path: [0, 1], offset: 1 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0.0'] + + Transforms.mergeNodes(editor, { at: [0, 1] }) + + const after = Editor.getSnapshot(editor) + const firstText = after.children[0].children[0] as Descendant & { + bold?: boolean + } + + assert.equal(after.children[0].children.length, 1) + assert.equal(firstText.text, 'alpha') + assert.equal(firstText.bold, true) + assert.equal(after.index.pathToId['0.0'], leftId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('supports merge_node on an element path and preserves moved descendant ids', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: createElementMergeChildren(), + selection: { + anchor: { path: [1, 1], offset: 2 }, + focus: { path: [1, 1], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0'] + const movedSpacerId = before.index.pathToId['1.0'] + const movedLinkId = before.index.pathToId['1.1'] + const movedTextId = before.index.pathToId['1.2'] + + editor.apply({ + type: 'merge_node', + path: [1], + position: 1, + properties: { data: true }, + }) + + const after = Editor.getSnapshot(editor) + const block = after.children[0] as Descendant & { data?: boolean } + + assert.equal(after.children.length, 1) + assert.equal(block.data, true) + assert.equal(block.children.length, 4) + assert.equal(after.index.pathToId['0'], leftId) + assert.equal(after.index.pathToId['0.1'], movedSpacerId) + assert.equal(after.index.pathToId['0.2'], movedLinkId) + assert.equal(after.index.pathToId['0.3'], movedTextId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 3], offset: 2 }, + focus: { path: [0, 3], offset: 2 }, + }) +}) + +it('supports path-based mergeNodes helper calls on element nodes', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'link' + + Editor.replace(editor, { + children: createElementMergeChildren(), + selection: { + anchor: { path: [1, 1], offset: 1 }, + focus: { path: [1, 1], offset: 1 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const leftId = before.index.pathToId['0'] + const movedSpacerId = before.index.pathToId['1.0'] + const movedLinkId = before.index.pathToId['1.1'] + + Transforms.mergeNodes(editor, { at: [1] }) + + const after = Editor.getSnapshot(editor) + const block = after.children[0] as Descendant & { data?: boolean } + + assert.equal(after.children.length, 1) + assert.equal(block.data, true) + assert.equal(block.children.length, 4) + assert.equal(after.index.pathToId['0'], leftId) + assert.equal(after.index.pathToId['0.1'], movedSpacerId) + assert.equal(after.index.pathToId['0.2'], movedLinkId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 3], offset: 1 }, + focus: { path: [0, 3], offset: 1 }, + }) +}) + +it('supports setSelection helper calls against the live transaction selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 5 }, + }) + Transforms.setSelection(editor, { + anchor: { path: [0, 0], offset: 1 }, + }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('supports deselect helper calls against the live transaction selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.deselect(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.selection, null) +}) + +it('supports collapse helper calls to the anchor against the live transaction selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 3 }, + }) + Transforms.collapse(editor, { edge: 'anchor' }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('supports collapse helper calls to the end against the live transaction selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 5 }, + }) + Transforms.collapse(editor, { edge: 'end' }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 5 }, + focus: { path: [1, 0], offset: 5 }, + }) +}) + +it('supports setPoint helper calls on the focus edge against the live transaction selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 3 }, + }) + Transforms.setPoint( + editor, + { + offset: 2, + }, + { edge: 'focus' } + ) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('supports setPoint helper calls on the start edge against a backward live selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 4 }, + focus: { path: [1, 0], offset: 4 }, + }, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 3 }, + focus: { path: [0, 0], offset: 2 }, + }) + Transforms.setPoint( + editor, + { + offset: 0, + }, + { edge: 'start' } + ) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 3 }, + focus: { path: [0, 0], offset: 0 }, + }) +}) + +it('supports move helper calls on both edges within the current text node', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }) + Transforms.move(editor, { distance: 2 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/anchor/basic.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('mirrors the legacy move/both/distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { distance: 6 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 10 }, + }) +}) + +it('mirrors the legacy move/anchor/backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 10 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 10 }, + }) +}) + +it('mirrors the legacy move/focus/distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', distance: 4 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 10 }, + }) +}) + +it('mirrors the legacy move/start/backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/end/distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 12 }, + }) +}) + +it('mirrors the legacy move/anchor/distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 11 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor', distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 7 }, + focus: { path: [0, 0], offset: 11 }, + }) +}) + +it('mirrors the legacy move/anchor/reverse-basic.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('mirrors the legacy move/both/backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 11 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/both/basic-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy move/end/backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy move/focus/expanded.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 7 }, + }) +}) + +it('mirrors the legacy move/start/expanded.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/end/expanded.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 10 }, + }) +}) + +it('mirrors the legacy move/anchor/reverse-distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor', reverse: true, distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('mirrors the legacy move/both/distance-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 10 }, + }, + marks: null, + }) + + Transforms.move(editor, { reverse: true, distance: 6 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy move/end/distance-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true, distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('mirrors the legacy move/start/distance-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', reverse: true, distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/focus/distance-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 11 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', reverse: true, distance: 6 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/end/backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 8 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy move/focus/backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 8 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', distance: 7 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 8 }, + focus: { path: [0, 0], offset: 11 }, + }) +}) + +it('mirrors the legacy move/start/distance.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', distance: 3 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 7 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/anchor/reverse-backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy move/start/backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy move/both/backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy move/both/expanded.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 10 }, + }, + marks: null, + }) + + Transforms.move(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 11 }, + }) +}) + +it('mirrors the legacy move/both/expanded-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 10 }, + }, + marks: null, + }) + + Transforms.move(editor, { reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/end/to-backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 7 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true, distance: 6 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy move/start/from-backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', distance: 7 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 11 }, + }) +}) + +it('mirrors the legacy move/start/to-backward.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', distance: 8 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 12 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/anchor/collapsed.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'anchor' }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 10 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/both/collapsed.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/end/collapsed-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 8 }, + }) +}) + +it('mirrors the legacy move/focus/collapsed-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 9 }, + focus: { path: [0, 0], offset: 8 }, + }) +}) + +it('mirrors the legacy move/end/expanded-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 8 }, + }) +}) + +it('mirrors the legacy move/focus/expanded-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 6 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy move/start/expanded-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 9 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'start', reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 9 }, + }) +}) + +it('mirrors the legacy move/end/from-backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 8 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'end', reverse: true, distance: 7 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy move/focus/to-backward-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyMoveChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 11 }, + }, + marks: null, + }) + + Transforms.move(editor, { edge: 'focus', reverse: true, distance: 10 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('supports move helper calls on the start edge of a backward selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 1 }, + }) + Transforms.move(editor, { edge: 'start', distance: 1, reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 0 }, + }) +}) + +it('supports move helper calls inside an outer transaction using the live draft selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) + Transforms.move(editor, { distance: 2 }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('supports move helper calls across mixed-inline sibling text leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 6 }, + }) + Transforms.move(editor, { distance: 1 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 1 }, + }) +}) + +it('supports reverse move helper calls across mixed-inline sibling text leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) + Transforms.move(editor, { reverse: true, distance: 1 }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 8 }, + focus: { path: [0, 1, 0], offset: 8 }, + }) +}) + +it('supports move helper calls across mixed-inline siblings inside an outer transaction using the live draft selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 6 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 7 }, + focus: { path: [0, 0], offset: 7 }, + }) + Transforms.move(editor, { distance: 1 }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 1 }, + }) +}) + +it('supports select helper calls with a point and creates a collapsed selection', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, { + path: [1, 0], + offset: 2, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('supports select helper calls with a point inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.select(editor, { + path: [1, 0], + offset: 5, + }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 5 }, + focus: { path: [1, 0], offset: 5 }, + }) +}) + +it('supports select helper calls with a path and creates a node range', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Transforms.select(editor, [0]) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('supports select helper calls with a path inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [2] } + ) + Transforms.select(editor, [2]) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [2, 0], offset: 0 }, + focus: { path: [2, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy select/path.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacySingleBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.select(editor, [0, 0]) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy select/point.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacySingleBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.select(editor, { + path: [0, 0], + offset: 1, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy select/range.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacySingleBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 3 }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy setPoint/offset.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'foo' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }, + marks: null, + }) + + Transforms.move(editor) + Transforms.setPoint( + editor, + { + offset: 0, + }, + { edge: 'focus' } + ) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy deselect/basic.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacySingleBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.deselect(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.selection, null) +}) + +it('supports path-based wrapNodes helper calls and preserves the moved node id', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createWrapChildren(), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const paragraphId = before.index.pathToId['0'] + + Transforms.wrapNodes( + editor, + { + type: 'quote', + children: [], + }, + { at: [0] } + ) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[0] as Descendant & { type: string } + + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.children.length, 1) + assert.equal(after.index.pathToId['0.0'], paragraphId) +}) + +it('supports range-based wrapNodes helper calls across top-level block spans and preserves moved block ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstId = before.index.pathToId['0'] + const secondId = before.index.pathToId['1'] + + Transforms.wrapNodes( + editor, + { + type: 'quote', + children: [], + }, + { + at: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }, + } + ) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[0] as Descendant & { type: string } + + assert.equal(after.children.length, 1) + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.children.length, 2) + assert.equal(after.index.pathToId['0.0'], firstId) + assert.equal(after.index.pathToId['0.1'], secondId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 2 }, + }) +}) + +it('supports path-based wrapNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [2] } + ) + Transforms.wrapNodes( + editor, + { + type: 'quote', + children: [], + }, + { at: [2] } + ) + }) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[2] as Descendant & { type: string } + + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.children.length, 1) + assert.equal(wrapper.children[0].children[0].text, 'gamma') +}) + +it('mirrors the legacy wrapNodes/path/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyWrappedBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.wrapNodes( + editor, + { + type: 'quote', + a: true, + children: [], + } as Descendant, + { at: [0] } + ) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[0] as Descendant & { + a?: boolean + type: string + } + + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.a, true) + assert.equal(wrapper.children[0].children[0].text, 'word') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy wrapNodes/block/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyWrappedBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.wrapNodes(editor, { + type: 'quote', + a: true, + children: [], + } as Descendant) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[0] as Descendant & { + a?: boolean + type: string + } + + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.a, true) + assert.equal(wrapper.children[0].children[0].text, 'word') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy wrapNodes/block/block-across.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.wrapNodes(editor, { + type: 'quote', + a: true, + children: [], + } as Descendant) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[0] as Descendant & { + a?: boolean + type: string + } + + assert.equal(after.children.length, 1) + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.a, true) + assert.deepEqual(getBlockTexts(wrapper.children), ['one', 'two']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 2 }, + }) +}) + +it('mirrors the legacy wrapNodes/block/block-end.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createExpandedChildren(), + selection: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [2, 0], offset: 5 }, + }, + marks: null, + }) + + Transforms.wrapNodes(editor, { + type: 'quote', + a: true, + children: [], + } as Descendant) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[1] as Descendant & { + a?: boolean + type: string + } + + assert.equal(after.children.length, 2) + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.a, true) + assert.deepEqual(getBlockTexts(wrapper.children), ['beta', 'gamma']) + assert.deepEqual(after.selection, { + anchor: { path: [1, 0, 0], offset: 0 }, + focus: { path: [1, 1, 0], offset: 5 }, + }) +}) + +it('supports selection-based wrapNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [2] } + ) + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [2, 0], offset: 3 }, + }) + Transforms.wrapNodes(editor, { + type: 'quote', + children: [], + }) + }) + + const after = Editor.getSnapshot(editor) + const wrapper = after.children[1] as Descendant & { type: string } + + assert.equal(after.children.length, 2) + assert.equal(wrapper.type, 'quote') + assert.equal(wrapper.children.length, 2) + assert.deepEqual(getBlockTexts(wrapper.children), ['beta', 'gamma']) + assert.deepEqual(after.selection, { + anchor: { path: [1, 0, 0], offset: 1 }, + focus: { path: [1, 1, 0], offset: 3 }, + }) +}) + +it('supports list formatting flows by turning selected top-level blocks into list items and wrapping them', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.setNodes(editor, { type: 'list-item' }, { at: [0] }) + Transforms.setNodes(editor, { type: 'list-item' }, { at: [1] }) + Transforms.wrapNodes(editor, { + type: 'bulleted-list', + children: [], + }) + + const snapshot = Editor.getSnapshot(editor) + const wrapper = snapshot.children[0] as Descendant & { + children: Array + type?: string + } + + assert.equal(wrapper.type, 'bulleted-list') + assert.deepEqual(wrapper.children, [ + { + type: 'list-item', + children: [{ text: 'one' }], + }, + { + type: 'list-item', + children: [{ text: 'two' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [0, 0, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 2 }, + }) +}) + +it('supports path-based unwrapNodes helper calls and preserves moved child ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createUnwrapChildren(), + selection: { + anchor: { path: [0, 1, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstChildId = before.index.pathToId['0.0'] + const secondChildId = before.index.pathToId['0.1'] + + Transforms.unwrapNodes(editor, { at: [0] }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 2) + assert.equal(after.index.pathToId['0'], firstChildId) + assert.equal(after.index.pathToId['1'], secondChildId) +}) + +it('supports range-based unwrapNodes helper calls across top-level wrapper spans and preserves moved child ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createTopLevelUnwrapChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [1, 0, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const alphaId = before.index.pathToId['0.0'] + const betaId = before.index.pathToId['0.1'] + const gammaId = before.index.pathToId['1.0'] + + Transforms.unwrapNodes(editor, { + at: { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [1, 0, 0], offset: 2 }, + }, + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta', 'gamma']) + assert.equal(after.index.pathToId['0'], alphaId) + assert.equal(after.index.pathToId['1'], betaId) + assert.equal(after.index.pathToId['2'], gammaId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [2, 0], offset: 2 }, + }) +}) + +it('mirrors the legacy unwrapNodes/path/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockChildren(), + selection: null, + marks: null, + }) + + Transforms.unwrapNodes(editor, { at: [0] }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.equal(after.children[0].children[0].text, 'word') +}) + +it('mirrors the legacy unwrapNodes/match-block/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockAcrossChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 2) + assert.deepEqual(getBlockTexts(after.children), ['one', 'two']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy unwrapNodes/match-block/block-across.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockAcrossChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 2) + assert.deepEqual(getBlockTexts(after.children), ['one', 'two']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('mirrors the legacy unwrapNodes/match-block/block-end.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'quote', + a: true, + children: createExpandedChildren(), + } as Descendant, + ], + selection: { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 2, 0], offset: 5 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta', 'gamma']) + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [2, 0], offset: 5 }, + }) +}) + +it('mirrors the legacy unwrapNodes/match-block/block-middle.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'quote', + a: true, + children: [ + { type: 'paragraph', children: [{ text: 'one' }] }, + { type: 'paragraph', children: [{ text: 'two' }] }, + { type: 'paragraph', children: [{ text: 'three' }] }, + { type: 'paragraph', children: [{ text: 'four' }] }, + { type: 'paragraph', children: [{ text: 'five' }] }, + { type: 'paragraph', children: [{ text: 'six' }] }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 2, 0], offset: 0 }, + focus: { path: [0, 3, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + ]) + assert.deepEqual(after.selection, { + anchor: { path: [2, 0], offset: 0 }, + focus: { path: [3, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy unwrapNodes/match-block/block-start.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockStartChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + ]) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) +}) + +it('supports path-based unwrapNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.wrapNodes( + editor, + { + type: 'quote', + children: [], + }, + { at: [1] } + ) + Transforms.unwrapNodes(editor, { at: [1] }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta']) +}) + +it('mirrors the legacy unwrapNodes/path/block-multiple.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockMultipleChildren(), + selection: null, + marks: null, + }) + + Transforms.unwrapNodes(editor, { at: [0] }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['one', 'two']) +}) + +it('supports selection-based unwrapNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }) + Transforms.wrapNodes(editor, { + type: 'quote', + children: [], + }) + Transforms.unwrapNodes(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta']) + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }) +}) + +it('supports path-based liftNodes helper calls for an only child and preserves the moved node id', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLiftOnlyChildChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 2 }, + focus: { path: [0, 0, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const paragraphId = before.index.pathToId['0.0'] + + Transforms.liftNodes(editor, { at: [0, 0] }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.equal(after.children[0].children[0].text, 'alpha') + assert.equal(after.index.pathToId['0'], paragraphId) +}) + +it('supports path-based liftNodes helper calls for a first child', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLiftSiblingChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstChildId = before.index.pathToId['0.0'] + + Transforms.liftNodes(editor, { at: [0, 0] }) + + const after = Editor.getSnapshot(editor) + const trailingWrapper = after.children[1] as Descendant & { type: string } + + assert.equal(after.children.length, 2) + assert.equal(after.children[0].children[0].text, 'one') + assert.equal(after.index.pathToId['0'], firstChildId) + assert.equal(trailingWrapper.type, 'quote') + assert.deepEqual( + trailingWrapper.children.map((child) => child.children[0].text), + ['two', 'three'] + ) +}) + +it('mirrors the legacy liftNodes/path/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyNestedBlockChildren(), + selection: null, + marks: null, + }) + + Transforms.liftNodes(editor, { at: [0, 0] }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.equal(after.children[0].children[0].text, 'word') +}) + +it('mirrors the legacy liftNodes/path/first-block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyLiftPairChildren(), + selection: null, + marks: null, + }) + + Transforms.liftNodes(editor, { at: [0, 0] }) + + const after = Editor.getSnapshot(editor) + const trailingWrapper = after.children[1] as Descendant & { type: string } + + assert.equal(after.children[0].children[0].text, 'one') + assert.equal(trailingWrapper.type, 'quote') + assert.deepEqual(getBlockTexts(trailingWrapper.children), ['two']) +}) + +it('mirrors the legacy liftNodes/path/last-block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyLiftPairChildren(), + selection: null, + marks: null, + }) + + Transforms.liftNodes(editor, { at: [0, 1] }) + + const after = Editor.getSnapshot(editor) + const leadingWrapper = after.children[0] as Descendant & { type: string } + + assert.equal(leadingWrapper.type, 'quote') + assert.deepEqual(getBlockTexts(leadingWrapper.children), ['one']) + assert.equal(after.children[1].children[0].text, 'two') +}) + +it('mirrors the legacy liftNodes/path/middle-block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyLiftTripleChildren(), + selection: null, + marks: null, + }) + + Transforms.liftNodes(editor, { at: [0, 1] }) + + const after = Editor.getSnapshot(editor) + const leadingWrapper = after.children[0] as Descendant & { type: string } + const trailingWrapper = after.children[2] as Descendant & { type: string } + + assert.equal(leadingWrapper.type, 'quote') + assert.deepEqual(getBlockTexts(leadingWrapper.children), ['one']) + assert.equal(after.children[1].children[0].text, 'two') + assert.equal(trailingWrapper.type, 'quote') + assert.deepEqual(getBlockTexts(trailingWrapper.children), ['three']) +}) + +it('mirrors the legacy liftNodes/selection/block-full.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyLiftFullChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 0 }, + focus: { path: [0, 5, 0], offset: 3 }, + }, + marks: null, + }) + + Transforms.liftNodes(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), [ + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + ]) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [5, 0], offset: 3 }, + }) +}) + +it('supports path-based liftNodes helper calls for a middle child', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLiftSiblingChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const middleChildId = before.index.pathToId['0.1'] + + Transforms.liftNodes(editor, { at: [0, 1] }) + + const after = Editor.getSnapshot(editor) + const leadingWrapper = after.children[0] as Descendant & { type: string } + const trailingWrapper = after.children[2] as Descendant & { type: string } + + assert.equal(after.children.length, 3) + assert.equal(leadingWrapper.type, 'quote') + assert.deepEqual( + leadingWrapper.children.map((child) => child.children[0].text), + ['one'] + ) + assert.equal(after.children[1].children[0].text, 'two') + assert.equal(after.index.pathToId['1'], middleChildId) + assert.equal(trailingWrapper.type, 'quote') + assert.deepEqual( + trailingWrapper.children.map((child) => child.children[0].text), + ['three'] + ) +}) + +it('supports path-based liftNodes helper calls for a last child', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLiftSiblingChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const lastChildId = before.index.pathToId['0.2'] + + Transforms.liftNodes(editor, { at: [0, 2] }) + + const after = Editor.getSnapshot(editor) + const leadingWrapper = after.children[0] as Descendant & { type: string } + + assert.equal(after.children.length, 2) + assert.equal(leadingWrapper.type, 'quote') + assert.deepEqual( + leadingWrapper.children.map((child) => child.children[0].text), + ['one', 'two'] + ) + assert.equal(after.children[1].children[0].text, 'three') + assert.equal(after.index.pathToId['1'], lastChildId) +}) + +it('supports range-based liftNodes helper calls across top-level wrapper-child spans and preserves moved ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLiftSiblingChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstId = before.index.pathToId['0.0'] + const secondId = before.index.pathToId['0.1'] + + Transforms.liftNodes(editor, { + at: { + anchor: { path: [0, 0, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + }) + + const after = Editor.getSnapshot(editor) + const trailingWrapper = after.children[2] as Descendant & { type: string } + + assert.deepEqual(getBlockTexts(after.children), ['one', 'two', '']) + assert.equal(after.index.pathToId['0'], firstId) + assert.equal(after.index.pathToId['1'], secondId) + assert.equal(trailingWrapper.type, 'quote') + assert.deepEqual(getBlockTexts(trailingWrapper.children), ['three']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('supports path-based liftNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [2] } + ) + Transforms.wrapNodes( + editor, + { + type: 'quote', + children: [], + }, + { at: [2] } + ) + Transforms.liftNodes(editor, { at: [2, 0] }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta', 'gamma']) +}) + +it('supports selection-based liftNodes inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) + Transforms.wrapNodes(editor, { + type: 'quote', + children: [], + }) + Transforms.liftNodes(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('supports list outdent flows by lifting selected list items and restoring paragraph blocks', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createListWrapperChildren(), + selection: { + anchor: { path: [0, 0, 0], offset: 1 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.liftNodes(editor) + Transforms.setNodes(editor, { type: 'paragraph' }, { at: [0] }) + Transforms.setNodes(editor, { type: 'paragraph' }, { at: [1] }) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.children, [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + ]) + assert.deepEqual(snapshot.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }) +}) + +it('supports delete helper calls with an exact block path and preserves surviving ids', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstId = before.index.pathToId['0'] + + Transforms.delete(editor, { at: [1] }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.equal(after.children[0].children[0].text, 'alpha') + assert.equal(after.index.pathToId['0'], firstId) + assert.equal(after.selection, null) +}) + +it('mirrors the legacy delete/path/block.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: null, + marks: null, + }) + + Transforms.delete(editor, { at: [1] }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['one']) + assert.equal(after.selection, null) +}) + +it('supports delete helper calls with an exact path inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertNodes( + editor, + { + type: 'paragraph', + children: [{ text: 'gamma' }], + }, + { at: [2] } + ) + Transforms.delete(editor, { at: [2] }) + }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['alpha', 'beta']) + assert.equal(after.selection, null) +}) + +it('supports delete helper calls with an exact point and removes one forward character', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const textId = before.index.pathToId['0.0'] + + Transforms.delete(editor, { + at: { path: [0, 0], offset: 2 }, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'alha') + assert.equal(after.index.pathToId['0.0'], textId) +}) + +it('supports delete helper calls with an exact point, reverse, and distance inside the current text node', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const textId = before.index.pathToId['0.0'] + + Transforms.delete(editor, { + at: { path: [0, 0], offset: 3 }, + reverse: true, + distance: 2, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'aha') + assert.equal(after.index.pathToId['0.0'], textId) +}) + +it('supports delete helper calls with an exact point across mixed-inline sibling leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Transforms.delete(editor, { + at: { path: [0, 0], offset: 6 }, + distance: 1, + }) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(link.children[0].text, 'yperlink') + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }) +}) + +it('supports delete helper calls with an exact point across an adjacent top-level block boundary', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstBlockId = before.index.pathToId['0'] + + Transforms.delete(editor, { + at: { path: [0, 0], offset: 5 }, + distance: 1, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.deepEqual(getBlockTexts(after.children), ['alphabeta']) + assert.equal(after.index.pathToId['0'], firstBlockId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('supports delete helper calls with an exact point inside an outer transaction using the live draft tree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 4 }, + focus: { path: [1, 0], offset: 4 }, + }, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.delete(editor, { + at: { path: [1, 0], offset: 4 }, + }) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[1].children[0].text, 'beta') + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 4 }, + focus: { path: [1, 0], offset: 4 }, + }) +}) + +it('supports delete helper calls with the current same-text selection and collapses inward', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 3 }, + }, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const textId = before.index.pathToId['0.0'] + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'aha') + assert.equal(after.index.pathToId['0.0'], textId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy delete/selection/character-middle.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'wrd') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy delete/point/basic.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyDeleteBoundaryChildren(), + selection: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['wordanother']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('mirrors the legacy delete/point/basic-reverse.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyBlockChildren(), + selection: { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.delete(editor, { reverse: true }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['onetwo']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy delete/point/inline.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyInlineBoundaryChildren(), + selection: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.deepEqual(after.children[0].children, [ + { text: 'onetwo' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'three' }], + }, + { text: 'four' }, + ]) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy delete/selection/character-start.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 1 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'ord') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy delete/selection/character-end.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'word' }], + }, + ], + selection: { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'wor') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('mirrors the legacy delete/selection/block-middle.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'one' }], + }, + { + type: 'paragraph', + children: [{ text: 'two' }], + }, + { + type: 'paragraph', + children: [{ text: 'three' }], + }, + ], + selection: { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['one', 'to', 'three']) + assert.deepEqual(after.selection, { + anchor: { path: [1, 0], offset: 1 }, + focus: { path: [1, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy delete/selection/block-across.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyDeleteBoundaryChildren(), + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(getBlockTexts(after.children), ['woother']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }) +}) + +it('mirrors the legacy delete/selection/inline-inside.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyInlineDeleteInsideChildren(), + selection: { + anchor: { path: [0, 1, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 3 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(link.children[0].text, 'wod') + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 2 }, + focus: { path: [0, 1, 0], offset: 2 }, + }) +}) + +it('mirrors the legacy delete/selection/inline-over.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyInlineDeleteChildren(), + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 2], offset: 4 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + const remainingTexts = after.children[0].children + .map((child) => ('text' in child ? child.text : '')) + .join('') + + assert.equal(remainingTexts, 'oe') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('mirrors the legacy delete/selection/inline-whole.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyInlineDeleteInsideChildren(), + selection: { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 4 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(link.children[0].text, '') + assert.deepEqual(after.selection, { + anchor: { path: [0, 1, 0], offset: 0 }, + focus: { path: [0, 1, 0], offset: 0 }, + }) +}) + +it('mirrors the legacy delete/selection/inline-after.tsx oracle row', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createLegacyInlineAfterChildren(), + selection: { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 1 }, + }, + marks: null, + }) + + Transforms.delete(editor) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children[0].children, [ + { text: 'one' }, + { + type: 'link', + url: 'https://example.com', + children: [{ text: 'two' }], + }, + { text: '' }, + ]) + assert.deepEqual(after.selection, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) +}) + +it('supports delete helper calls with an explicit non-empty range across adjacent mixed-inline sibling leaves', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Transforms.delete(editor, { + at: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 1, 0], offset: 2 }, + }, + }) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(after.children[0].children[0].text, 'befo') + assert.equal(link.children[0].text, 'perlink') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('supports delete helper calls with an explicit non-empty range across a fully covered interior inline subtree', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Transforms.delete(editor, { + at: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 2], offset: 2 }, + }, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'befo') + assert.equal(after.children[0].children[1].text, 'ter') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('supports delete helper calls with an explicit non-empty range across an adjacent top-level block boundary', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + const before = Editor.getSnapshot(editor) + const firstBlockId = before.index.pathToId['0'] + + Transforms.delete(editor, { + at: { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [1, 0], offset: 2 }, + }, + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.deepEqual(getBlockTexts(after.children), ['alphta']) + assert.equal(after.index.pathToId['0'], firstBlockId) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 4 }, + focus: { path: [0, 0], offset: 4 }, + }) +}) + +it('supports delete helper calls with the current same-text selection inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 5 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + Transforms.delete(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'aa!') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 1 }, + }) +}) + +it('supports delete helper calls with the current non-empty selection across adjacent mixed-inline sibling leaves inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 6 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 1, 0], offset: 1 }, + }) + Transforms.delete(editor) + }) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(after.children[0].children[0].text, 'before') + assert.equal(link.children[0].text, 'yperlink') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 6 }, + focus: { path: [0, 0], offset: 6 }, + }) +}) + +it('supports delete helper calls with the current non-empty selection across a fully covered interior inline subtree inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 6 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 7 }, + focus: { path: [0, 2], offset: 1 }, + }) + Transforms.delete(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'before!') + assert.equal(after.children[0].children[1].text, 'fter') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 7 }, + focus: { path: [0, 0], offset: 7 }, + }) +}) + +it('supports delete helper calls with the current non-empty selection across an adjacent top-level block boundary inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [1, 0], offset: 2 }, + }) + Transforms.delete(editor) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.deepEqual(getBlockTexts(after.children), ['alphata!']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('supports delete helper calls with the current collapsed selection, reverse, and distance inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 5 }, + }) + Transforms.select(editor, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) + Transforms.delete(editor, { reverse: true, distance: 2 }) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children[0].children[0].text, 'alp!') + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }) +}) + +it('supports delete helper calls with the current collapsed selection across mixed-inline sibling leaves in one block', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createElementSplitChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.select(editor, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) + Transforms.delete(editor, { reverse: true, distance: 1 }) + }) + + const after = Editor.getSnapshot(editor) + const link = after.children[0].children[1] as Descendant & { + children: Descendant[] + } + + assert.equal(link.children[0].text, 'hyperlin') + assert.deepEqual(after.selection, { + anchor: { path: [0, 2], offset: 0 }, + focus: { path: [0, 2], offset: 0 }, + }) +}) + +it('supports delete helper calls with the current collapsed selection across an adjacent top-level block boundary inside an outer transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + marks: null, + }) + + Editor.withTransaction(editor, () => { + Transforms.insertText(editor, '!', { + at: { path: [1, 0], offset: 4 }, + }) + Transforms.select(editor, { + anchor: { path: [1, 0], offset: 0 }, + focus: { path: [1, 0], offset: 0 }, + }) + Transforms.delete(editor, { reverse: true, distance: 1 }) + }) + + const after = Editor.getSnapshot(editor) + + assert.equal(after.children.length, 1) + assert.deepEqual(getBlockTexts(after.children), ['alphabeta!']) + assert.deepEqual(after.selection, { + anchor: { path: [0, 0], offset: 5 }, + focus: { path: [0, 0], offset: 5 }, + }) +}) + +it('supports editor.apply through implicit transactions', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + editor.apply({ + type: 'insert_text', + path: [1, 0], + offset: 4, + text: '!', + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.equal(snapshot.children[1].children[0].text, 'beta!') + assert.equal(snapshot.version, 2) + assert.equal(editor.operations.length, 1) +}) + +it('stages replacement inside the active transaction', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + Editor.withTransaction(editor, () => { + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'fresh' }], + }, + ], + selection: null, + }) + + Transforms.insertText(editor, '!', { + at: { path: [0, 0], offset: 5 }, + }) + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.equal(snapshot.version, 2) + assert.equal(snapshot.children.length, 1) + assert.equal(snapshot.children[0].children[0].text, 'fresh!') +}) + +it('publishes immutable snapshots detached from public editor fields', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.throws(() => { + ;(snapshot.children as Descendant[]).push({ + type: 'paragraph', + children: [{ text: 'oops' }], + }) + }) + + assert.throws(() => { + ;(snapshot.index.pathToId as Record)['0'] = 'broken' + }) + ;(editor as { children: Descendant[] }).children = [ + { + type: 'paragraph', + children: [{ text: 'mutated' }], + }, + ] + + const reread = Editor.getSnapshot(editor) + + assert.notEqual(reread, snapshot) + assert.equal(snapshot.children[0].children[0].text, 'alpha') + assert.equal(reread.children[0].children[0].text, 'mutated') +}) + +it('deep-freezes snapshot selections including point paths', () => { + const editor = createEditor() + const selection = { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [1, 0], offset: 2 }, + } + + Editor.replace(editor, { + children: createChildren(), + selection, + }) + + const snapshot = Editor.getSnapshot(editor) + + assert.deepEqual(snapshot.selection, selection) + assert.notEqual(snapshot.selection, selection) + assert.notEqual(snapshot.selection?.anchor.path, selection.anchor.path) + assert.throws(() => { + ;( + snapshot.selection as NonNullable + ).anchor.path[0] = 9 + }) + assert.deepEqual(Editor.getSnapshot(editor).selection?.anchor.path, [0, 0]) +}) + +it('deep-freezes nested marks instead of sharing nested payloads', () => { + const editor = createEditor() + const marks = { + style: { + color: 'red', + }, + } + + Editor.replace(editor, { + children: createChildren(), + marks, + }) + + const snapshot = Editor.getSnapshot(editor) + + marks.style.color = 'blue' + + assert.equal( + (snapshot.marks as { style: { color: string } }).style.color, + 'red' + ) + assert.throws(() => { + ;(snapshot.marks as { style: { color: string } }).style.color = 'green' + }) +}) + +it('reads selection from the committed snapshot instead of the mutable public copy', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: { + anchor: { path: [1, 0], offset: 4 }, + focus: { path: [1, 0], offset: 4 }, + }, + }) + ;(editor as { selection: typeof editor.selection }).selection = { + anchor: { path: [99, 99], offset: 0 }, + focus: { path: [99, 99], offset: 0 }, + } + + Transforms.insertText(editor, '!') + + const snapshot = Editor.getSnapshot(editor) + + assert.equal(snapshot.children[1].children[0].text, 'beta!') + assert.deepEqual(snapshot.selection, { + anchor: { path: [1, 0], offset: 5 }, + focus: { path: [1, 0], offset: 5 }, + }) +}) + +it('keeps ids stable across repeated replace calls in one outer transaction', () => { + const singleReplaceEditor = createEditor() + const doubleReplaceEditor = createEditor() + + Editor.replace(singleReplaceEditor, { + children: createChildren(), + }) + Editor.replace(doubleReplaceEditor, { + children: createChildren(), + }) + + Editor.withTransaction(singleReplaceEditor, () => { + Editor.replace(singleReplaceEditor, { + children: createExpandedChildren(), + }) + }) + + Editor.withTransaction(doubleReplaceEditor, () => { + Editor.replace(doubleReplaceEditor, { + children: createExpandedChildren(), + }) + Editor.replace(doubleReplaceEditor, { + children: createExpandedChildren(), + }) + }) + + assert.equal( + Editor.getSnapshot(singleReplaceEditor).index.pathToId['2.0'], + Editor.getSnapshot(doubleReplaceEditor).index.pathToId['2.0'] + ) +}) + +it('projects a cross-block range into local text segments keyed by runtime id', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + }) + + const snapshot = Editor.getSnapshot(editor) + const leftId = snapshot.index.pathToId['0.0'] + const rightId = snapshot.index.pathToId['1.0'] + + assert.ok(leftId) + assert.ok(rightId) + + assert.deepEqual( + Editor.projectRange(editor, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 2 }, + }), + [ + { + runtimeId: leftId, + path: [0, 0], + start: 2, + end: 5, + }, + { + runtimeId: rightId, + path: [1, 0], + start: 0, + end: 2, + }, + ] + ) +}) + +it('projects a collapsed range into a zero-width local segment', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + selection: null, + }) + + const snapshot = Editor.getSnapshot(editor) + const leftId = snapshot.index.pathToId['0.0'] + + assert.ok(leftId) + assert.deepEqual( + Editor.projectRange(editor, { + anchor: { path: [0, 0], offset: 3 }, + focus: { path: [0, 0], offset: 3 }, + }), + [ + { + runtimeId: leftId, + path: [0, 0], + start: 3, + end: 3, + }, + ] + ) +}) + +it('keeps runtime ids unique across replace commits that allocate new nodes', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: createChildren(), + }) + + Editor.replace(editor, { + children: createExpandedChildren(), + }) + + Editor.replace(editor, { + children: [ + ...createExpandedChildren(), + { + type: 'paragraph', + children: [{ text: 'delta' }], + }, + ], + }) + + const ids = Object.values(Editor.getSnapshot(editor).index.pathToId) + + assert.equal(new Set(ids).size, ids.length) +}) diff --git a/packages/slate/test/surface-contract.ts b/packages/slate/test/surface-contract.ts new file mode 100644 index 0000000000..1dc48109cc --- /dev/null +++ b/packages/slate/test/surface-contract.ts @@ -0,0 +1,374 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { + EditorAfterOptions, + EditorBeforeOptions, + EditorDirectedDeletionOptions, + EditorElementReadOnlyOptions, + EditorFragmentDeletionOptions, + EditorInterface, + EditorLeafOptions, + EditorLevelsOptions, + EditorNextOptions, + EditorNodeOptions, + EditorNodesOptions, + EditorParentOptions, + EditorPathOptions, + EditorPathRefOptions, + EditorPointOptions, + EditorPointRefOptions, + EditorPositionsOptions, + EditorPreviousOptions, + EditorRangeRefOptions, + EditorStringOptions, + EditorUnhangRangeOptions, + EditorVoidOptions, + PropsCompare, + PropsMerge, +} from '../src' +import { + createEditor, + type Descendant, + Editor, + Element, + GeneralTransforms, + Location, + NodeTransforms, + Operation, + Path, + Range, + Scrubber, + SelectionTransforms, + Span, + Text, + TextTransforms, + Transforms, +} from '../src' + +describe('slate surface contract', () => { + it('keeps legacy editor helper type names and transform family exports on the current surface', () => { + const editor = createEditor() + const paragraph = { + type: 'paragraph', + children: [{ text: 'alpha' }], + } satisfies Descendant + + const editorInterface: EditorInterface = Editor + const beforeOptions: EditorBeforeOptions = { unit: 'word', voids: true } + const afterOptions: EditorAfterOptions = { distance: 1, unit: 'line' } + const directedDeletionOptions: EditorDirectedDeletionOptions = { + unit: 'character', + } + const fragmentDeletionOptions: EditorFragmentDeletionOptions = { + direction: 'backward', + } + const elementReadOnlyOptions: EditorElementReadOnlyOptions = { at: [] } + const leafOptions: EditorLeafOptions = { edge: 'start' } + const levelsOptions: EditorLevelsOptions = { at: [], voids: true } + const nextOptions: EditorNextOptions = { at: [], voids: true } + const nodeOptions: EditorNodeOptions = { depth: 1, edge: 'end' } + const nodesOptions: EditorNodesOptions = { at: [], mode: 'lowest' } + const parentOptions: EditorParentOptions = { depth: 1 } + const pathOptions: EditorPathOptions = { depth: 1 } + const pathRefOptions: EditorPathRefOptions = { affinity: 'backward' } + const pointOptions: EditorPointOptions = { edge: 'end' } + const pointRefOptions: EditorPointRefOptions = { affinity: 'forward' } + const positionsOptions: EditorPositionsOptions = { at: [], unit: 'word' } + const previousOptions: EditorPreviousOptions = { at: [], voids: true } + const rangeRefOptions: EditorRangeRefOptions = { affinity: 'inward' } + const stringOptions: EditorStringOptions = { voids: true } + const unhangRangeOptions: EditorUnhangRangeOptions = { voids: true } + const voidOptions: EditorVoidOptions = { at: [] } + const compare: PropsCompare = (prop, nodeProp) => prop === nodeProp + const merge: PropsMerge = () => ({ merged: true }) + + assert.equal(typeof editorInterface.before, 'function') + assert.equal(beforeOptions.voids, true) + assert.equal(afterOptions.unit, 'line') + assert.equal(directedDeletionOptions.unit, 'character') + assert.equal(fragmentDeletionOptions.direction, 'backward') + assert.deepEqual(elementReadOnlyOptions, { at: [] }) + assert.equal(leafOptions.edge, 'start') + assert.equal(levelsOptions.voids, true) + assert.equal(nextOptions.voids, true) + assert.equal(nodeOptions.depth, 1) + assert.equal(nodesOptions.mode, 'lowest') + assert.equal(parentOptions.depth, 1) + assert.equal(pathOptions.depth, 1) + assert.equal(pathRefOptions.affinity, 'backward') + assert.equal(pointOptions.edge, 'end') + assert.equal(pointRefOptions.affinity, 'forward') + assert.equal(positionsOptions.unit, 'word') + assert.equal(previousOptions.voids, true) + assert.equal(rangeRefOptions.affinity, 'inward') + assert.equal(stringOptions.voids, true) + assert.equal(unhangRangeOptions.voids, true) + assert.deepEqual(voidOptions, { at: [] }) + assert.equal(compare('a', 'a'), true) + assert.deepEqual(merge('left', 'right'), { merged: true }) + + editor.children = [] + editor.selection = null + editor.operations = [] + assert.deepEqual(editor.children, []) + assert.deepEqual(editor.operations, []) + + editor.insertNode(paragraph) + assert.equal(Editor.getSnapshot(editor).children.length, 1) + + editor.selection = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + } + editor.removeNodes() + assert.equal(Editor.getSnapshot(editor).children.length, 0) + + assert.equal(typeof GeneralTransforms.transform, 'function') + assert.equal(typeof NodeTransforms.insertNodes, 'function') + assert.equal(typeof SelectionTransforms.select, 'function') + assert.equal(typeof TextTransforms.insertText, 'function') + assert.equal(typeof Transforms.insertNodes, 'function') + }) + + it('exports the wider runtime helper surface consumed by sibling packages', () => { + const editor = createEditor() + const text = { text: 'alpha' } + const element = { + type: 'paragraph', + children: [text], + } as const + const range = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 5 }, + } as const + + assert.equal(Text.isText(text), true) + assert.equal(Element.isElement(element), true) + assert.equal(Path.isPath([0, 1]), true) + assert.equal(Range.isRange(range), true) + assert.equal(Location.isLocation([0, 0]), true) + assert.equal(Span.isSpan([[0], [1]]), true) + + Editor.replace(editor, { + children: [element], + selection: null, + marks: null, + }) + + GeneralTransforms.transform(editor, { + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }) + + assert.equal( + Editor.getSnapshot(editor).children[0].children[0].text, + 'alpha!' + ) + + Editor.replace(editor, { + children: [element], + selection: null, + marks: null, + }) + + GeneralTransforms.applyBatch(editor, [ + { + type: 'insert_text', + path: [0, 0], + offset: 5, + text: '!', + }, + { + type: 'insert_text', + path: [0, 0], + offset: 6, + text: '?', + }, + ]) + + assert.equal( + Editor.getSnapshot(editor).children[0].children[0].text, + 'alpha!?' + ) + + assert.equal(Operation.isOperationList(editor.operations), true) + assert.equal(Scrubber.stringify({ text: 'secret' }), '{"text":"secret"}') + }) + + it('createEditor exposes an overrideable instance surface for the supported editor and transform methods', () => { + const editor = createEditor() as typeof createEditor extends () => infer T + ? T & Record + : never + + const methodNames = [ + 'addMark', + 'above', + 'after', + 'before', + 'bookmark', + 'collapse', + 'delete', + 'deleteBackward', + 'deleteForward', + 'deleteFragment', + 'deselect', + 'elementReadOnly', + 'edges', + 'end', + 'first', + 'fragment', + 'getChildren', + 'getDirtyPaths', + 'getFragment', + 'getSnapshot', + 'insertBreak', + 'insertFragment', + 'insertNode', + 'insertNodes', + 'insertSoftBreak', + 'insertText', + 'levels', + 'moveNodes', + 'node', + 'nodes', + 'normalize', + 'normalizeNode', + 'pathRef', + 'pathRefs', + 'pointRef', + 'pointRefs', + 'positions', + 'rangeRef', + 'rangeRefs', + 'removeNodes', + 'replace', + 'reset', + 'select', + 'setChildren', + 'setNodes', + 'setSelection', + 'splitNodes', + 'string', + 'subscribe', + 'unhangRange', + 'unsetNodes', + 'unwrapNodes', + 'withoutNormalizing', + 'withTransaction', + 'wrapNodes', + ] as const + + methodNames.forEach((methodName) => { + assert.equal( + typeof editor[methodName], + 'function', + `${methodName} exists` + ) + }) + }) + + it('children accessor routes through getChildren and setChildren', () => { + const editor = createEditor() as typeof createEditor extends () => infer T + ? T & Record + : never + const calls: string[] = [] + const originalGetChildren = editor.getChildren + const originalSetChildren = editor.setChildren + const value = [{ type: 'paragraph', children: [{ text: 'one' }] }] + + editor.getChildren = () => { + calls.push('get') + return originalGetChildren() + } + + editor.setChildren = (children) => { + calls.push('set') + originalSetChildren(children) + } + + editor.children = value + const currentChildren = editor.children + + assert.deepEqual(currentChildren, value) + assert.equal(calls[0], 'set') + assert.equal(calls.includes('get'), true) + assert(Object.keys(editor).includes('children')) + }) + + it('Editor and Transforms helpers delegate through overrideable instance methods', () => { + const editor = createEditor() as typeof createEditor extends () => infer T + ? T & Record + : never + const calls: unknown[][] = [] + const expectedPoint = { path: [0, 0], offset: 1 } + + editor.insertText = (...args: unknown[]) => { + calls.push(['insertText', ...args]) + } + editor.getChildren = (...args: unknown[]) => { + calls.push(['getChildren', ...args]) + return [{ type: 'paragraph', children: [{ text: 'child' }] }] + } + editor.getDirtyPaths = (...args: unknown[]) => { + calls.push(['getDirtyPaths', ...args]) + return [[0]] + } + editor.rangeRefs = (...args: unknown[]) => { + calls.push(['rangeRefs', ...args]) + return new Set() + } + editor.withTransaction = (...args: unknown[]) => { + calls.push(['withTransaction', ...args]) + const fn = args[0] as () => void + fn() + } + + Editor.insertText(editor, 'x') + assert.deepEqual(calls[0], ['insertText', 'x']) + + assert.deepEqual(Editor.getChildren(editor), [ + { type: 'paragraph', children: [{ text: 'child' }] }, + ]) + assert.deepEqual(calls[1], ['getChildren']) + + assert.deepEqual( + Editor.getDirtyPaths(editor, { + type: 'insert_text', + path: [0, 0], + offset: 0, + text: 'x', + }), + [[0]] + ) + assert.deepEqual(calls[2], [ + 'getDirtyPaths', + { + type: 'insert_text', + path: [0, 0], + offset: 0, + text: 'x', + }, + ]) + + GeneralTransforms.applyBatch(editor, [ + { + type: 'set_selection', + properties: null, + newProperties: { + anchor: expectedPoint, + focus: expectedPoint, + }, + }, + ]) + assert.equal( + calls.some((call) => call[0] === 'withTransaction'), + true + ) + + assert.equal(Editor.rangeRefs(editor).size, 0) + assert.equal(calls.at(-1)?.[0], 'rangeRefs') + }) +}) diff --git a/packages/slate/test/text-units-contract.ts b/packages/slate/test/text-units-contract.ts new file mode 100644 index 0000000000..eef46b2bec --- /dev/null +++ b/packages/slate/test/text-units-contract.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { getCharacterDistance, getWordDistance } from '../src/text-units' + +describe('slate text-units contract', () => { + it('measures basic grapheme distance left-to-right', () => { + assert.equal(getCharacterDistance('a'), 1) + assert.equal(getCharacterDistance('🙂🙂'), 2) + assert.equal(getCharacterDistance('🏁🇨🇳🏁🇨🇳'), 2) + assert.equal(getCharacterDistance('👩‍❤️‍👨👩‍❤️‍👨'), 8) + }) + + it('measures basic grapheme distance right-to-left', () => { + assert.equal(getCharacterDistance('a', true), 1) + assert.equal(getCharacterDistance('🇨🇳🎌', true), 2) + assert.equal(getCharacterDistance('🏴🏳️', true), 3) + }) + + it('measures word distance left-to-right', () => { + assert.equal(getWordDistance('hello foobarbaz'), 5) + assert.equal(getWordDistance("Don't do this"), 5) + assert.equal(getWordDistance("I'm ok"), 3) + }) + + it('measures word distance right-to-left', () => { + assert.equal(getWordDistance('hello foobarbaz', true), 9) + assert.equal(getWordDistance("Don't", true), 5) + assert.equal(getWordDistance("Don't do this", true), 4) + assert.equal(getWordDistance("I'm", true), 3) + }) + + it('handles punctuation and keycap sequences consistently', () => { + assert.equal(getCharacterDistance('#️⃣#️⃣'), 3) + assert.equal(getCharacterDistance('*️⃣*️⃣'), 3) + assert.equal(getWordDistance("Don't do this", true), 4) + }) +}) diff --git a/packages/slate/test/transaction-contract.ts b/packages/slate/test/transaction-contract.ts new file mode 100644 index 0000000000..ef8a6d15d9 --- /dev/null +++ b/packages/slate/test/transaction-contract.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + type Operation, + Transforms, +} from '../src' + +const paragraph = ( + text: string, + props: Record = {} +): Descendant => ({ + type: 'paragraph', + ...props, + children: [{ text }], +}) + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +const replaceChildren = ( + editor: ReturnType, + children: Descendant[] +) => { + Editor.replace(editor, { + children: clone(children), + selection: null, + marks: null, + }) +} + +const runManualTransaction = ( + editor: ReturnType, + operations: Operation[] +) => { + Editor.withTransaction(editor, () => { + for (const operation of clone(operations)) { + editor.apply(operation) + } + }) +} + +const getVisibleState = (editor: ReturnType) => { + const snapshot = Editor.getSnapshot(editor) + + return { + children: snapshot.children, + marks: snapshot.marks, + selection: snapshot.selection, + pathToId: snapshot.index.pathToId, + } +} + +describe('slate transaction contract', () => { + it('applyBatch matches manual withTransaction for duplicate exact-path set_node writes', () => { + const children = [paragraph('one'), paragraph('two'), paragraph('three')] + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'blue' }, + }, + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'final', role: 'final' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + { + type: 'paragraph', + id: 'final', + role: 'final', + children: [{ text: 'one' }], + }, + paragraph('two'), + paragraph('three'), + ]) + }) + + it('applyBatch matches manual withTransaction for mixed text, selection, and node ops', () => { + const children = [paragraph('abcd')] + const selection = { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + } + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'insert_text', + path: [0, 0], + offset: 1, + text: 'X', + }, + { + type: 'set_selection', + properties: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + newProperties: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }, + }, + { + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'p0' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + Transforms.select(batchEditor, selection) + Transforms.select(manualEditor, selection) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'aXbcd' }], + }, + ]) + assert.deepEqual(Editor.getSnapshot(batchEditor).selection, { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [0, 0], offset: 2 }, + }) + }) + + it('applyBatch matches manual withTransaction for structural insert, move, and set batches', () => { + const children = [paragraph('zero'), paragraph('one')] + const batchEditor = createEditor() + const manualEditor = createEditor() + const operations: Operation[] = [ + { + type: 'insert_node', + path: [2], + node: paragraph('two'), + }, + { + type: 'move_node', + path: [2], + newPath: [0], + }, + { + type: 'set_node', + path: [1], + properties: {}, + newProperties: { id: 'shifted' }, + }, + ] + + replaceChildren(batchEditor, children) + replaceChildren(manualEditor, children) + + Transforms.applyBatch(batchEditor, clone(operations)) + runManualTransaction(manualEditor, operations) + + assert.deepEqual( + getVisibleState(batchEditor), + getVisibleState(manualEditor) + ) + assert.deepEqual(Editor.getSnapshot(batchEditor).children, [ + paragraph('two'), + { + type: 'paragraph', + id: 'shifted', + children: [{ text: 'zero' }], + }, + paragraph('one'), + ]) + }) + + it('withTransaction keeps direct replacement draft-visible and publishes once on exit', () => { + const editor = createEditor() + const publishedStates: ReturnType[] = [] + + replaceChildren(editor, [paragraph('one'), paragraph('two')]) + + const unsubscribe = Editor.subscribe(editor, () => { + publishedStates.push(getVisibleState(editor)) + }) + + publishedStates.length = 0 + + Editor.withTransaction(editor, () => { + editor.children = [paragraph('replacement')] + + assert.equal(publishedStates.length, 0) + assert.equal(Editor.string(editor, [0]), 'replacement') + + editor.apply({ + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'p0' }, + }) + + assert.equal(publishedStates.length, 0) + assert.deepEqual(Editor.getChildren(editor), [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'replacement' }], + }, + ]) + }) + + unsubscribe() + + assert.equal(publishedStates.length, 1) + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'paragraph', + id: 'p0', + children: [{ text: 'replacement' }], + }, + ]) + }) + + it('withTransaction rolls back staged changes when a later operation throws', () => { + const editor = createEditor() + + replaceChildren(editor, [paragraph('one'), paragraph('two')]) + + const before = getVisibleState(editor) + + assert.throws(() => { + Editor.withTransaction(editor, () => { + editor.apply({ + type: 'set_node', + path: [0], + properties: {}, + newProperties: { id: 'temp' }, + }) + + editor.apply({ + type: 'set_node', + path: [99], + properties: {}, + newProperties: { boom: true }, + }) + }) + }) + + assert.deepEqual(getVisibleState(editor), before) + }) +}) diff --git a/packages/slate/test/transforms-contract.ts b/packages/slate/test/transforms-contract.ts new file mode 100644 index 0000000000..56db73a983 --- /dev/null +++ b/packages/slate/test/transforms-contract.ts @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + createEditor, + type Descendant, + Editor, + type Element, + Node, + Transforms, +} from '../src' + +const collapsedSelection = (path: number[], offset: number) => ({ + anchor: { path, offset }, + focus: { path, offset }, +}) + +describe('slate transforms contract', () => { + it('moveNodes is a no-op when the source and destination paths are equal', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { type: 'block', children: [{ text: '1' }] }, + { type: 'block', children: [{ text: '2' }] }, + ], + selection: null, + marks: null, + }) + + Transforms.moveNodes(editor, { at: [1], to: [1] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: '1' }] }, + { type: 'block', children: [{ text: '2' }] }, + ]) + }) + + it('moveNodes can move a top-level block inside the next block container', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [{ text: 'one' }], + }, + { + type: 'block', + children: [{ type: 'block', children: [{ text: 'two' }] }], + }, + ], + selection: collapsedSelection([0, 0], 0), + marks: null, + }) + + Transforms.moveNodes(editor, { at: [0], to: [1, 1] }) + + const after = Editor.getSnapshot(editor) + + assert.deepEqual(after.children, [ + { + type: 'block', + children: [ + { type: 'block', children: [{ text: 'two' }] }, + { type: 'block', children: [{ text: 'one' }] }, + ], + }, + ]) + assert.deepEqual(after.selection, collapsedSelection([0, 1, 0], 0)) + }) + + it('setNodes can target the selected inline element through match without an explicit path', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'word' }] }, + { text: '' }, + ], + } as Descendant, + ], + selection: collapsedSelection([0, 1, 0], 0), + marks: null, + }) + + Transforms.setNodes( + editor, + { someKey: true }, + { + match: (node) => 'children' in node && Editor.isInline(editor, node), + } + ) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + children: [ + { text: '' }, + { type: 'inline', someKey: true, children: [{ text: 'word' }] }, + { text: '' }, + ], + }, + ]) + }) + + it('setNodes still accepts the legacy generic props contract', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'paragraph', + children: [{ text: 'one' }], + } as Descendant, + ], + selection: collapsedSelection([0, 0], 0), + marks: null, + }) + + Transforms.setNodes(editor, { type: 'heading-one' }, { at: [0] }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'heading-one', + children: [{ text: 'one' }], + }, + ]) + }) + + it('setNodes can target the highest matching inline when mode is highest', () => { + const editor = createEditor() + editor.isInline = (element) => element.type === 'inline' + + Editor.replace(editor, { + children: [ + { + type: 'block', + children: [ + { text: '' }, + { + type: 'inline', + children: [ + { text: '' }, + { type: 'inline', children: [{ text: 'word' }] }, + { text: '' }, + ], + }, + { text: '' }, + ], + } as Descendant, + ], + selection: collapsedSelection([0, 1, 1, 0], 0), + marks: null, + }) + + Transforms.setNodes( + editor, + { someKey: true }, + { + match: (node) => 'children' in node && Editor.isInline(editor, node), + mode: 'highest', + } + ) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + children: [ + { text: '' }, + { + type: 'inline', + someKey: true, + children: [ + { text: '' }, + { + type: 'inline', + children: [{ text: 'word' }], + }, + { text: '' }, + ], + }, + { text: '' }, + ], + }, + ]) + }) + + it('wrapNodes can split a selected block range', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { type: 'block', children: [{ text: 'one' }] }, + { type: 'block', children: [{ text: 'two' }] }, + ], + selection: { + anchor: { path: [0, 0], offset: 2 }, + focus: { path: [1, 0], offset: 1 }, + }, + marks: null, + }) + + Transforms.wrapNodes(editor, { type: 'quote', children: [] } as Element, { + split: true, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: 'on' }] }, + { + type: 'quote', + children: [ + { type: 'block', children: [{ text: 'e' }] }, + { type: 'block', children: [{ text: 't' }] }, + ], + }, + { type: 'block', children: [{ text: 'wo' }] }, + ]) + }) + + it('wrapNodes can honor match and leave rejected nodes alone', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + noneditable: true, + children: [{ text: 'word' }], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.wrapNodes(editor, { type: 'quote', children: [] } as Element, { + match: (node, currentPath) => { + if ('noneditable' in node && node.noneditable === true) return false + + for (const [ancestor] of Node.ancestors(editor, currentPath)) { + if ('noneditable' in ancestor && ancestor.noneditable === true) { + return false + } + } + + return true + }, + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { + type: 'block', + noneditable: true, + children: [{ text: 'word' }], + }, + ]) + }) + + it('unwrapNodes can honor match with mode all', () => { + const editor = createEditor() + + Editor.replace(editor, { + children: [ + { + type: 'block', + a: true, + children: [ + { + type: 'block', + a: true, + children: [{ type: 'block', children: [{ text: 'word' }] }], + }, + ], + } as Descendant, + ], + selection: { + anchor: { path: [0, 0, 0, 0], offset: 0 }, + focus: { path: [0, 0, 0, 0], offset: 0 }, + }, + marks: null, + }) + + Transforms.unwrapNodes(editor, { + match: (node) => 'a' in node && node.a === true, + mode: 'all', + }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: 'word' }] }, + ]) + }) + + it('liftNodes can target inside a void element when voids is true', () => { + const editor = createEditor() + editor.isVoid = (element) => element.void === true + + Editor.replace(editor, { + children: [ + { + type: 'block', + void: true, + children: [{ type: 'block', children: [{ text: 'word' }] }], + } as Descendant, + ], + selection: null, + marks: null, + }) + + Transforms.liftNodes(editor, { at: [0, 0], voids: true }) + + assert.deepEqual(Editor.getSnapshot(editor).children, [ + { type: 'block', children: [{ text: 'word' }] }, + ]) + }) +}) diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index b7b15ff12e..501035592e 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -21,7 +21,10 @@ const EXAMPLE_IMPORTERS: Record< 'custom-placeholder': () => import('../../examples/ts/custom-placeholder'), 'editable-voids': () => import('../../examples/ts/editable-voids'), embeds: () => import('../../examples/ts/embeds'), + 'external-decoration-sources': () => + import('../../examples/ts/external-decoration-sources'), 'forced-layout': () => import('../../examples/ts/forced-layout'), + 'highlighted-text': () => import('../../examples/ts/highlighted-text'), 'hovering-toolbar': () => import('../../examples/ts/hovering-toolbar'), 'huge-document': () => import('../../examples/ts/huge-document'), images: () => import('../../examples/ts/images'), @@ -30,8 +33,11 @@ const EXAMPLE_IMPORTERS: Record< 'markdown-shortcuts': () => import('../../examples/ts/markdown-shortcuts'), mentions: () => import('../../examples/ts/mentions'), 'paste-html': () => import('../../examples/ts/paste-html'), + 'persistent-annotation-anchors': () => + import('../../examples/ts/persistent-annotation-anchors'), plaintext: () => import('../../examples/ts/plaintext'), 'read-only': () => import('../../examples/ts/read-only'), + 'review-comments': () => import('../../examples/ts/review-comments'), iframe: () => import('../../examples/ts/iframe'), richtext: () => import('../../examples/ts/richtext'), 'search-highlighting': () => import('../../examples/ts/search-highlighting'), From 46e3c6aa9e3f1ac18e030181d3fd2a5cb43d7aff Mon Sep 17 00:00:00 2001 From: zbeyens Date: Mon, 27 Apr 2026 07:43:48 +0200 Subject: [PATCH 02/49] v2 --- .changeset/decorate-compat-source-name.md | 5 + .../editor-method-target-fresh-marks.md | 5 + .../remove-legacy-react-renderer-exports.md | 5 + .changeset/remove-transforms-namespace.md | 6 + .changeset/richtext-history-dom-repair.md | 5 + .changeset/runtime-id-live-path.md | 5 + .changeset/selectable-void-navigation.md | 5 + .changeset/shell-backed-partial-paste.md | 5 + .changeset/slate-commit-metadata.md | 5 + ...late-multiline-paste-expanded-selection.md | 5 + .../slate-react-custom-placeholder-delete.md | 5 + .../slate-react-decorate-adapter-internal.md | 5 + .../slate-react-decoration-source-adapter.md | 5 + .../slate-react-dom-text-sync-capability.md | 5 + .../slate-react-editable-semantic-runtime.md | 5 + .changeset/slate-react-live-runtime-reads.md | 5 + ...slate-react-remove-child-count-chunking.md | 5 + ...embeds-void-arrow-navigation-regression.md | 38 + .../slate-browser/src/core/release-proof.ts | 299 ++++ .../test/core/release-proof.test.ts | 157 ++ .../slate-browser/test/core/scenario.test.ts | 584 +++++++ packages/slate-dom/src/dom-globals.d.ts | 24 + packages/slate-dom/test/bridge.test.ts | 1 + packages/slate-dom/test/bridge.ts | 250 +++ .../slate-dom/test/clipboard-boundary.test.ts | 1 + packages/slate-dom/test/clipboard-boundary.ts | 412 +++++ .../test/generic-history-contract.ts | 27 + .../slate-history/test/history-contract.ts | 358 ++++ .../slate-history/test/integrity-contract.ts | 388 +++++ .../test/tsconfig.generic-types.json | 11 + .../slate-hyperscript/test/smoke-contract.ts | 44 + .../slate-hyperscript/tsconfig.build.json | 12 + packages/slate-react/src/annotation-store.ts | 288 ++++ packages/slate-react/src/compat/index.ts | 5 + .../src/components/editable-element.tsx | 32 + .../src/components/editable-text-blocks.tsx | 778 +++++++++ .../src/components/editable-text.tsx | 571 +++++++ .../src/components/slate-element.tsx | 79 + .../slate-react/src/components/slate-leaf.tsx | 4 + .../src/components/slate-placeholder.tsx | 52 + .../src/components/slate-spacer.tsx | 20 + .../slate-react/src/components/slate-text.tsx | 23 + .../src/components/text-string.tsx | 15 + .../src/components/void-element.tsx | 41 + .../src/components/zero-width-string.tsx | 39 + packages/slate-react/src/context.tsx | 11 + packages/slate-react/src/dom-globals.d.ts | 24 + packages/slate-react/src/dom-text-sync.ts | 54 + .../src/editable/browser-handle.ts | 333 ++++ .../slate-react/src/editable/caret-engine.ts | 151 ++ .../src/editable/clipboard-input-strategy.ts | 538 ++++++ .../src/editable/composition-state.ts | 244 +++ .../src/editable/dom-repair-queue.ts | 275 +++ .../src/editable/editing-epoch-kernel.ts | 283 ++++ .../src/editable/editing-kernel.ts | 1213 +++++++++++++ .../src/editable/input-controller.ts | 229 +++ .../slate-react/src/editable/input-router.ts | 348 ++++ .../slate-react/src/editable/input-state.ts | 77 + .../src/editable/keyboard-input-strategy.ts | 353 ++++ .../src/editable/model-input-strategy.ts | 300 ++++ .../src/editable/mutation-controller.ts | 317 ++++ .../src/editable/native-input-strategy.ts | 128 ++ .../src/editable/selection-controller.ts | 458 +++++ .../src/editable/selection-reconciler.ts | 936 +++++++++++ .../src/hooks/use-slate-annotation-store.tsx | 50 + .../src/hooks/use-slate-annotations.tsx | 48 + .../src/hooks/use-slate-node-ref.tsx | 237 +++ .../src/hooks/use-slate-projections.tsx | 40 + .../src/hooks/use-slate-widget-store.tsx | 68 + .../src/hooks/use-slate-widgets.tsx | 57 + .../large-document/classify-island-kind.ts | 65 + .../src/large-document/create-island-plan.ts | 69 + .../src/large-document/island-shell.tsx | 165 ++ .../large-document/large-document-commands.ts | 81 + .../slate-react/src/projection-context.tsx | 7 + packages/slate-react/src/projection-store.ts | 383 +++++ packages/slate-react/src/widget-store.ts | 315 ++++ .../test/annotation-store-contract.test.tsx | 1 + .../test/annotation-store-contract.tsx | 303 ++++ .../test/app-owned-customization.test.tsx | 1 + .../test/app-owned-customization.tsx | 342 ++++ .../test/dom-repair-policy-contract.ts | 48 + .../test/dom-text-sync-contract.ts | 48 + .../test/editable-behavior.test.tsx | 1 + .../slate-react/test/editable-behavior.tsx | 111 ++ .../test/editing-epoch-kernel-contract.ts | 186 ++ .../test/editing-kernel-contract.ts | 274 +++ .../test/generic-react-editor-contract.tsx | 57 + .../test/kernel-authority-audit-contract.ts | 236 +++ .../slate-react/test/large-doc-and-scroll.tsx | 789 +++++++++ .../test/primitives-contract.test.tsx | 1 + .../slate-react/test/primitives-contract.tsx | 157 ++ ...rojections-and-selection-contract.test.tsx | 1 + .../projections-and-selection-contract.tsx | 401 +++++ .../test/provider-hooks-contract.test.tsx | 1 + .../test/provider-hooks-contract.tsx | 131 ++ .../test/react-editor-contract.test.tsx | 1 + .../test/react-editor-contract.tsx | 109 ++ .../test/rendered-dom-shape-contract.tsx | 237 +++ .../test/selection-controller-contract.ts | 281 ++++ .../test/surface-contract.test.tsx | 1 + .../slate-react/test/surface-contract.tsx | 185 ++ .../test/target-runtime-contract.tsx | 116 ++ .../test/tsconfig.generic-types.json | 19 + .../test/widget-layer-contract.test.tsx | 1 + .../test/widget-layer-contract.tsx | 213 +++ .../slate-react/test/with-react-contract.tsx | 64 + packages/slate/src/core/command-registry.ts | 109 ++ packages/slate/src/core/editor-extension.ts | 307 ++++ packages/slate/src/core/extension-registry.ts | 108 ++ packages/slate/src/core/leaf-lifecycle.ts | 130 ++ packages/slate/src/editor/block-format.ts | 162 ++ packages/slate/src/editor/toggle-mark.ts | 48 + .../slate/src/transforms-text/insert-text.ts | 149 ++ .../test/apply-onchange-hard-cut-contract.ts | 121 ++ .../test/collab-history-runtime-contract.ts | 97 ++ .../slate/test/commit-metadata-contract.ts | 97 ++ .../slate/test/editor-methods-contract.ts | 268 +++ .../test/escape-hatch-inventory-contract.ts | 501 ++++++ .../slate/test/extension-methods-contract.ts | 302 ++++ .../slate/test/fixture-claim-overrides.ts | 65 + .../slate/test/generic-editor-api-contract.ts | 50 + .../slate/test/generic-extension-contract.ts | 44 + .../slate/test/generic-operation-contract.ts | 44 + packages/slate/test/generic-value-contract.ts | 209 +++ .../slate/test/leaf-lifecycle-contract.ts | 71 + .../test/primitive-method-runtime-contract.ts | 679 ++++++++ .../test/public-field-hard-cut-contract.ts | 59 + .../slate/test/public-surface-contract.ts | 83 + packages/slate/test/read-update-contract.ts | 128 ++ .../slate/test/selection-rebase-contract.ts | 78 + .../transaction-target-runtime-contract.ts | 232 +++ ...types.json => tsconfig.generic-types.json} | 6 +- .../slate/test/write-boundary-contract.ts | 123 ++ packages/slate/tsconfig.build.json | 12 + .../external-decoration-sources.test.ts | 60 + .../examples/highlighted-text.test.ts | 419 +++++ .../examples/large-document-runtime.test.ts | 724 ++++++++ .../persistent-annotation-anchors.test.ts | 69 + .../examples/review-comments.test.ts | 53 + playwright/stress/generated-editing.test.ts | 215 +++ playwright/stress/replay.test.ts | 41 + playwright/stress/stress-utils.ts | 133 ++ scripts/benchmarks/README.md | 154 ++ .../browser/react/active-typing-breakdown.tsx | 358 ++++ .../react/huge-document-legacy-compare.mjs | 717 ++++++++ .../browser/react/huge-document-overlays.tsx | 556 ++++++ .../browser/react/rerender-breadth.tsx | 1497 +++++++++++++++++ scripts/benchmarks/core/compare/history.mjs | 280 +++ .../benchmarks/core/compare/huge-document.mjs | 320 ++++ .../benchmarks/core/compare/normalization.mjs | 271 +++ .../benchmarks/core/compare/observation.mjs | 235 +++ .../benchmarks/core/current/editor-store.mjs | 155 ++ .../core/current/node-transforms.mjs | 289 ++++ .../benchmarks/core/current/normalization.mjs | 153 ++ .../core/current/query-ref-observation.mjs | 249 +++ .../core/current/refs-projection.mjs | 196 +++ .../core/current/text-selection.mjs | 213 +++ .../core/current/transaction-execution.mjs | 135 ++ scripts/benchmarks/shared/react-benchmark.tsx | 180 ++ scripts/benchmarks/shared/repo-compare.mjs | 120 ++ scripts/benchmarks/shared/stats.mjs | 29 + .../slate/6038-transaction-execution.mjs | 1 + scripts/proof/mobile-device-proof.mjs | 113 ++ scripts/proof/persistent-browser-soak.mjs | 388 +++++ .../ts/external-decoration-sources.tsx | 301 ++++ site/examples/ts/highlighted-text.tsx | 93 + site/examples/ts/large-document-runtime.tsx | 458 +++++ .../ts/persistent-annotation-anchors.tsx | 554 ++++++ site/examples/ts/review-comments.tsx | 512 ++++++ 170 files changed, 31333 insertions(+), 2 deletions(-) create mode 100644 .changeset/decorate-compat-source-name.md create mode 100644 .changeset/editor-method-target-fresh-marks.md create mode 100644 .changeset/remove-legacy-react-renderer-exports.md create mode 100644 .changeset/remove-transforms-namespace.md create mode 100644 .changeset/richtext-history-dom-repair.md create mode 100644 .changeset/runtime-id-live-path.md create mode 100644 .changeset/selectable-void-navigation.md create mode 100644 .changeset/shell-backed-partial-paste.md create mode 100644 .changeset/slate-commit-metadata.md create mode 100644 .changeset/slate-multiline-paste-expanded-selection.md create mode 100644 .changeset/slate-react-custom-placeholder-delete.md create mode 100644 .changeset/slate-react-decorate-adapter-internal.md create mode 100644 .changeset/slate-react-decoration-source-adapter.md create mode 100644 .changeset/slate-react-dom-text-sync-capability.md create mode 100644 .changeset/slate-react-editable-semantic-runtime.md create mode 100644 .changeset/slate-react-live-runtime-reads.md create mode 100644 .changeset/slate-react-remove-child-count-chunking.md create mode 100644 docs/plans/2026-04-26-embeds-void-arrow-navigation-regression.md create mode 100644 packages/slate-browser/src/core/release-proof.ts create mode 100644 packages/slate-browser/test/core/release-proof.test.ts create mode 100644 packages/slate-browser/test/core/scenario.test.ts create mode 100644 packages/slate-dom/src/dom-globals.d.ts create mode 100644 packages/slate-dom/test/bridge.test.ts create mode 100644 packages/slate-dom/test/bridge.ts create mode 100644 packages/slate-dom/test/clipboard-boundary.test.ts create mode 100644 packages/slate-dom/test/clipboard-boundary.ts create mode 100644 packages/slate-history/test/generic-history-contract.ts create mode 100644 packages/slate-history/test/history-contract.ts create mode 100644 packages/slate-history/test/integrity-contract.ts create mode 100644 packages/slate-history/test/tsconfig.generic-types.json create mode 100644 packages/slate-hyperscript/test/smoke-contract.ts create mode 100644 packages/slate-hyperscript/tsconfig.build.json create mode 100644 packages/slate-react/src/annotation-store.ts create mode 100644 packages/slate-react/src/compat/index.ts create mode 100644 packages/slate-react/src/components/editable-element.tsx create mode 100644 packages/slate-react/src/components/editable-text-blocks.tsx create mode 100644 packages/slate-react/src/components/editable-text.tsx create mode 100644 packages/slate-react/src/components/slate-element.tsx create mode 100644 packages/slate-react/src/components/slate-leaf.tsx create mode 100644 packages/slate-react/src/components/slate-placeholder.tsx create mode 100644 packages/slate-react/src/components/slate-spacer.tsx create mode 100644 packages/slate-react/src/components/slate-text.tsx create mode 100644 packages/slate-react/src/components/text-string.tsx create mode 100644 packages/slate-react/src/components/void-element.tsx create mode 100644 packages/slate-react/src/components/zero-width-string.tsx create mode 100644 packages/slate-react/src/context.tsx create mode 100644 packages/slate-react/src/dom-globals.d.ts create mode 100644 packages/slate-react/src/dom-text-sync.ts create mode 100644 packages/slate-react/src/editable/browser-handle.ts create mode 100644 packages/slate-react/src/editable/caret-engine.ts create mode 100644 packages/slate-react/src/editable/clipboard-input-strategy.ts create mode 100644 packages/slate-react/src/editable/composition-state.ts create mode 100644 packages/slate-react/src/editable/dom-repair-queue.ts create mode 100644 packages/slate-react/src/editable/editing-epoch-kernel.ts create mode 100644 packages/slate-react/src/editable/editing-kernel.ts create mode 100644 packages/slate-react/src/editable/input-controller.ts create mode 100644 packages/slate-react/src/editable/input-router.ts create mode 100644 packages/slate-react/src/editable/input-state.ts create mode 100644 packages/slate-react/src/editable/keyboard-input-strategy.ts create mode 100644 packages/slate-react/src/editable/model-input-strategy.ts create mode 100644 packages/slate-react/src/editable/mutation-controller.ts create mode 100644 packages/slate-react/src/editable/native-input-strategy.ts create mode 100644 packages/slate-react/src/editable/selection-controller.ts create mode 100644 packages/slate-react/src/editable/selection-reconciler.ts create mode 100644 packages/slate-react/src/hooks/use-slate-annotation-store.tsx create mode 100644 packages/slate-react/src/hooks/use-slate-annotations.tsx create mode 100644 packages/slate-react/src/hooks/use-slate-node-ref.tsx create mode 100644 packages/slate-react/src/hooks/use-slate-projections.tsx create mode 100644 packages/slate-react/src/hooks/use-slate-widget-store.tsx create mode 100644 packages/slate-react/src/hooks/use-slate-widgets.tsx create mode 100644 packages/slate-react/src/large-document/classify-island-kind.ts create mode 100644 packages/slate-react/src/large-document/create-island-plan.ts create mode 100644 packages/slate-react/src/large-document/island-shell.tsx create mode 100644 packages/slate-react/src/large-document/large-document-commands.ts create mode 100644 packages/slate-react/src/projection-context.tsx create mode 100644 packages/slate-react/src/projection-store.ts create mode 100644 packages/slate-react/src/widget-store.ts create mode 100644 packages/slate-react/test/annotation-store-contract.test.tsx create mode 100644 packages/slate-react/test/annotation-store-contract.tsx create mode 100644 packages/slate-react/test/app-owned-customization.test.tsx create mode 100644 packages/slate-react/test/app-owned-customization.tsx create mode 100644 packages/slate-react/test/dom-repair-policy-contract.ts create mode 100644 packages/slate-react/test/dom-text-sync-contract.ts create mode 100644 packages/slate-react/test/editable-behavior.test.tsx create mode 100644 packages/slate-react/test/editable-behavior.tsx create mode 100644 packages/slate-react/test/editing-epoch-kernel-contract.ts create mode 100644 packages/slate-react/test/editing-kernel-contract.ts create mode 100644 packages/slate-react/test/generic-react-editor-contract.tsx create mode 100644 packages/slate-react/test/kernel-authority-audit-contract.ts create mode 100644 packages/slate-react/test/large-doc-and-scroll.tsx create mode 100644 packages/slate-react/test/primitives-contract.test.tsx create mode 100644 packages/slate-react/test/primitives-contract.tsx create mode 100644 packages/slate-react/test/projections-and-selection-contract.test.tsx create mode 100644 packages/slate-react/test/projections-and-selection-contract.tsx create mode 100644 packages/slate-react/test/provider-hooks-contract.test.tsx create mode 100644 packages/slate-react/test/provider-hooks-contract.tsx create mode 100644 packages/slate-react/test/react-editor-contract.test.tsx create mode 100644 packages/slate-react/test/react-editor-contract.tsx create mode 100644 packages/slate-react/test/rendered-dom-shape-contract.tsx create mode 100644 packages/slate-react/test/selection-controller-contract.ts create mode 100644 packages/slate-react/test/surface-contract.test.tsx create mode 100644 packages/slate-react/test/surface-contract.tsx create mode 100644 packages/slate-react/test/target-runtime-contract.tsx create mode 100644 packages/slate-react/test/tsconfig.generic-types.json create mode 100644 packages/slate-react/test/widget-layer-contract.test.tsx create mode 100644 packages/slate-react/test/widget-layer-contract.tsx create mode 100644 packages/slate-react/test/with-react-contract.tsx create mode 100644 packages/slate/src/core/command-registry.ts create mode 100644 packages/slate/src/core/editor-extension.ts create mode 100644 packages/slate/src/core/extension-registry.ts create mode 100644 packages/slate/src/core/leaf-lifecycle.ts create mode 100644 packages/slate/src/editor/block-format.ts create mode 100644 packages/slate/src/editor/toggle-mark.ts create mode 100644 packages/slate/src/transforms-text/insert-text.ts create mode 100644 packages/slate/test/apply-onchange-hard-cut-contract.ts create mode 100644 packages/slate/test/collab-history-runtime-contract.ts create mode 100644 packages/slate/test/commit-metadata-contract.ts create mode 100644 packages/slate/test/editor-methods-contract.ts create mode 100644 packages/slate/test/escape-hatch-inventory-contract.ts create mode 100644 packages/slate/test/extension-methods-contract.ts create mode 100644 packages/slate/test/fixture-claim-overrides.ts create mode 100644 packages/slate/test/generic-editor-api-contract.ts create mode 100644 packages/slate/test/generic-extension-contract.ts create mode 100644 packages/slate/test/generic-operation-contract.ts create mode 100644 packages/slate/test/generic-value-contract.ts create mode 100644 packages/slate/test/leaf-lifecycle-contract.ts create mode 100644 packages/slate/test/primitive-method-runtime-contract.ts create mode 100644 packages/slate/test/public-field-hard-cut-contract.ts create mode 100644 packages/slate/test/public-surface-contract.ts create mode 100644 packages/slate/test/read-update-contract.ts create mode 100644 packages/slate/test/selection-rebase-contract.ts create mode 100644 packages/slate/test/transaction-target-runtime-contract.ts rename packages/slate/test/{tsconfig.custom-types.json => tsconfig.generic-types.json} (60%) create mode 100644 packages/slate/test/write-boundary-contract.ts create mode 100644 packages/slate/tsconfig.build.json create mode 100644 playwright/integration/examples/external-decoration-sources.test.ts create mode 100644 playwright/integration/examples/highlighted-text.test.ts create mode 100644 playwright/integration/examples/large-document-runtime.test.ts create mode 100644 playwright/integration/examples/persistent-annotation-anchors.test.ts create mode 100644 playwright/integration/examples/review-comments.test.ts create mode 100644 playwright/stress/generated-editing.test.ts create mode 100644 playwright/stress/replay.test.ts create mode 100644 playwright/stress/stress-utils.ts create mode 100644 scripts/benchmarks/README.md create mode 100644 scripts/benchmarks/browser/react/active-typing-breakdown.tsx create mode 100644 scripts/benchmarks/browser/react/huge-document-legacy-compare.mjs create mode 100644 scripts/benchmarks/browser/react/huge-document-overlays.tsx create mode 100644 scripts/benchmarks/browser/react/rerender-breadth.tsx create mode 100644 scripts/benchmarks/core/compare/history.mjs create mode 100644 scripts/benchmarks/core/compare/huge-document.mjs create mode 100644 scripts/benchmarks/core/compare/normalization.mjs create mode 100644 scripts/benchmarks/core/compare/observation.mjs create mode 100644 scripts/benchmarks/core/current/editor-store.mjs create mode 100644 scripts/benchmarks/core/current/node-transforms.mjs create mode 100644 scripts/benchmarks/core/current/normalization.mjs create mode 100644 scripts/benchmarks/core/current/query-ref-observation.mjs create mode 100644 scripts/benchmarks/core/current/refs-projection.mjs create mode 100644 scripts/benchmarks/core/current/text-selection.mjs create mode 100644 scripts/benchmarks/core/current/transaction-execution.mjs create mode 100644 scripts/benchmarks/shared/react-benchmark.tsx create mode 100644 scripts/benchmarks/shared/repo-compare.mjs create mode 100644 scripts/benchmarks/shared/stats.mjs create mode 100644 scripts/benchmarks/slate/6038-transaction-execution.mjs create mode 100644 scripts/proof/mobile-device-proof.mjs create mode 100644 scripts/proof/persistent-browser-soak.mjs create mode 100644 site/examples/ts/external-decoration-sources.tsx create mode 100644 site/examples/ts/highlighted-text.tsx create mode 100644 site/examples/ts/large-document-runtime.tsx create mode 100644 site/examples/ts/persistent-annotation-anchors.tsx create mode 100644 site/examples/ts/review-comments.tsx diff --git a/.changeset/decorate-compat-source-name.md b/.changeset/decorate-compat-source-name.md new file mode 100644 index 0000000000..15a749a56f --- /dev/null +++ b/.changeset/decorate-compat-source-name.md @@ -0,0 +1,5 @@ +--- +"slate-react": minor +--- + +Rename the legacy decoration projection adapter to `createSlateDecorateCompatSource`. diff --git a/.changeset/editor-method-target-fresh-marks.md b/.changeset/editor-method-target-fresh-marks.md new file mode 100644 index 0000000000..3c2bb8ae3e --- /dev/null +++ b/.changeset/editor-method-target-fresh-marks.md @@ -0,0 +1,5 @@ +--- +"slate": minor +--- + +Expose `editor.toggleMark`, `editor.setBlock`, and `editor.toggleBlock`, and resolve implicit mark/block targets through the transaction target runtime. diff --git a/.changeset/remove-legacy-react-renderer-exports.md b/.changeset/remove-legacy-react-renderer-exports.md new file mode 100644 index 0000000000..7b14593159 --- /dev/null +++ b/.changeset/remove-legacy-react-renderer-exports.md @@ -0,0 +1,5 @@ +--- +"slate-react": minor +--- + +Remove legacy renderer component exports that are not used by the semantic `Editable` runtime. diff --git a/.changeset/remove-transforms-namespace.md b/.changeset/remove-transforms-namespace.md new file mode 100644 index 0000000000..16e8c71f87 --- /dev/null +++ b/.changeset/remove-transforms-namespace.md @@ -0,0 +1,6 @@ +--- +"slate": major +--- + +Remove the public `Transforms` namespace and require primitive document and +selection writes to run inside `editor.update(...)`. diff --git a/.changeset/richtext-history-dom-repair.md b/.changeset/richtext-history-dom-repair.md new file mode 100644 index 0000000000..e54fe1953d --- /dev/null +++ b/.changeset/richtext-history-dom-repair.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Repair editor DOM after keyboard undo and redo history operations diff --git a/.changeset/runtime-id-live-path.md b/.changeset/runtime-id-live-path.md new file mode 100644 index 0000000000..ea23bcd7ed --- /dev/null +++ b/.changeset/runtime-id-live-path.md @@ -0,0 +1,5 @@ +--- +"slate": patch +--- + +Add live runtime-id path lookup for renderer-owned subscriptions. diff --git a/.changeset/selectable-void-navigation.md b/.changeset/selectable-void-navigation.md new file mode 100644 index 0000000000..e35cf5a370 --- /dev/null +++ b/.changeset/selectable-void-navigation.md @@ -0,0 +1,5 @@ +--- +"slate": patch +--- + +Fix arrow-key navigation across selectable block voids and inline voids. diff --git a/.changeset/shell-backed-partial-paste.md b/.changeset/shell-backed-partial-paste.md new file mode 100644 index 0000000000..fc037b5192 --- /dev/null +++ b/.changeset/shell-backed-partial-paste.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Preserve fragment and rich paste for shell-backed large-document selections. diff --git a/.changeset/slate-commit-metadata.md b/.changeset/slate-commit-metadata.md new file mode 100644 index 0000000000..5c627178d4 --- /dev/null +++ b/.changeset/slate-commit-metadata.md @@ -0,0 +1,5 @@ +--- +"slate": patch +--- + +Expose last commit metadata for transaction-aware runtime consumers diff --git a/.changeset/slate-multiline-paste-expanded-selection.md b/.changeset/slate-multiline-paste-expanded-selection.md new file mode 100644 index 0000000000..073b84003f --- /dev/null +++ b/.changeset/slate-multiline-paste-expanded-selection.md @@ -0,0 +1,5 @@ +--- +"slate": patch +--- + +Fix multiline plain-text paste after replacing the whole editor selection diff --git a/.changeset/slate-react-custom-placeholder-delete.md b/.changeset/slate-react-custom-placeholder-delete.md new file mode 100644 index 0000000000..87ccabc904 --- /dev/null +++ b/.changeset/slate-react-custom-placeholder-delete.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Fix custom placeholders restoring after all editor text is deleted diff --git a/.changeset/slate-react-decorate-adapter-internal.md b/.changeset/slate-react-decorate-adapter-internal.md new file mode 100644 index 0000000000..d226bf2e60 --- /dev/null +++ b/.changeset/slate-react-decorate-adapter-internal.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Route `decorate` through the projection-source compatibility adapter diff --git a/.changeset/slate-react-decoration-source-adapter.md b/.changeset/slate-react-decoration-source-adapter.md new file mode 100644 index 0000000000..72f04cdf3e --- /dev/null +++ b/.changeset/slate-react-decoration-source-adapter.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Add a projection-source adapter for legacy decoration callbacks diff --git a/.changeset/slate-react-dom-text-sync-capability.md b/.changeset/slate-react-dom-text-sync-capability.md new file mode 100644 index 0000000000..6c2f638024 --- /dev/null +++ b/.changeset/slate-react-dom-text-sync-capability.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Expose explicit DOM text sync opt-out reasons diff --git a/.changeset/slate-react-editable-semantic-runtime.md b/.changeset/slate-react-editable-semantic-runtime.md new file mode 100644 index 0000000000..e5028f97e0 --- /dev/null +++ b/.changeset/slate-react-editable-semantic-runtime.md @@ -0,0 +1,5 @@ +--- +"slate-react": major +--- + +Make `Editable` use the semantic-blocks runtime with projection sources, large-document islands, and browser-safe model-owned text input. diff --git a/.changeset/slate-react-live-runtime-reads.md b/.changeset/slate-react-live-runtime-reads.md new file mode 100644 index 0000000000..47f3f660b8 --- /dev/null +++ b/.changeset/slate-react-live-runtime-reads.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Use live runtime reads for mounted text and large-document island lookup diff --git a/.changeset/slate-react-remove-child-count-chunking.md b/.changeset/slate-react-remove-child-count-chunking.md new file mode 100644 index 0000000000..800a0bc6ae --- /dev/null +++ b/.changeset/slate-react-remove-child-count-chunking.md @@ -0,0 +1,5 @@ +--- +"slate-react": patch +--- + +Remove child-count chunking from the current React runtime diff --git a/docs/plans/2026-04-26-embeds-void-arrow-navigation-regression.md b/docs/plans/2026-04-26-embeds-void-arrow-navigation-regression.md new file mode 100644 index 0000000000..ed18a70fab --- /dev/null +++ b/docs/plans/2026-04-26-embeds-void-arrow-navigation-regression.md @@ -0,0 +1,38 @@ +# Embeds Void Arrow Navigation Regression + +## Goal + +Restore keyboard navigation in `/examples/embeds` so ArrowRight from the end of the first paragraph lands on the selectable void embed before moving to the next paragraph. + +## Findings + +- The embeds example marks `video` as void and renders the normal Slate children spacer. +- The regression lives in core traversal, not the example renderer. +- Default `Editor.positions` must expose selectable void and read-only elements as one atomic point. +- `voids: true` remains the escape hatch for traversing the actual void children. + +## Files + +- `packages/slate/src/editor/positions.ts` +- `packages/slate/test/query-contract.ts` +- `playwright/integration/examples/embeds.test.ts` +- `.changeset/selectable-void-navigation.md` + +## Verification + +- `bun test ./packages/slate/test/query-contract.ts --bail 1` +- `PLAYWRIGHT_BASE_URL=http://localhost:3100 bunx playwright test ./playwright/integration/examples/embeds.test.ts --project=chromium --workers=1 --retries=0` +- `bunx turbo build --filter=./packages/slate --force` +- `bunx turbo typecheck --filter=./packages/slate --force` +- `bun run typecheck:root` +- `bun run lint:fix` +- `bun run lint` + +## Status + +- [x] Reproduce the regression. +- [x] Add core traversal coverage. +- [x] Add embeds browser regression coverage. +- [x] Fix core traversal. +- [x] Add changeset. +- [x] Verify focused unit, browser, build, typecheck, and lint gates. diff --git a/packages/slate-browser/src/core/release-proof.ts b/packages/slate-browser/src/core/release-proof.ts new file mode 100644 index 0000000000..2b1583aa22 --- /dev/null +++ b/packages/slate-browser/src/core/release-proof.ts @@ -0,0 +1,299 @@ +import type { + BrowserMobileProofPlatform, + BrowserMobileSupportedClaim, + BrowserMobileTransportId, + BrowserMobileUnsupportedClaim, +} from '../transports/contracts' +import { classifyBrowserMobileTransportProof } from '../transports/contracts' +import type { ProofEvidenceClass } from './proof' + +export type SlateBrowserReleaseClaim = + | 'android-chrome-device-browser-text-input' + | 'android-chrome-device-browser-ime-commit' + | 'ios-safari-device-browser-text-input' + | 'ios-safari-device-browser-ime-commit' + | 'native-mobile-clipboard' + | 'persistent-browser-caret-soak' + | 'release-discipline-guards' + +export type SlateBrowserMobileReleaseCapability = + | BrowserMobileSupportedClaim + | BrowserMobileUnsupportedClaim + +export type SlateBrowserMobileDeviceProofArtifact = { + capabilities: SlateBrowserMobileReleaseCapability[] + evidenceClass: ProofEvidenceClass + kind: 'mobile-device' + passed: boolean + platform: BrowserMobileProofPlatform + releaseGateCapable: boolean + scenario: string + transport: BrowserMobileTransportId +} + +export type SlateBrowserPersistentSoakProofArtifact = { + browserName: string + iterations: number + kind: 'persistent-browser-soak' + passed: boolean + profilePersistence: 'ephemeral' | 'persistent' + replayable: boolean + scenario: string +} + +export type SlateBrowserReleaseDisciplineProofArtifact = { + guards: string[] + kind: 'release-discipline' + passed: boolean +} + +export type SlateBrowserReleaseProofArtifact = + | SlateBrowserMobileDeviceProofArtifact + | SlateBrowserPersistentSoakProofArtifact + | SlateBrowserReleaseDisciplineProofArtifact + +export type SlateBrowserReleaseProofOptions = { + artifacts: readonly SlateBrowserReleaseProofArtifact[] + claims: readonly SlateBrowserReleaseClaim[] + requiredDisciplineGuards?: readonly string[] + requiredSoakIterations?: number +} + +export type SlateBrowserReleaseProofResult = { + issues: string[] + ok: boolean +} + +export const SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS = [ + 'public-surface-contract', + 'public-field-hard-cut-contract', + 'escape-hatch-inventory-contract', + 'write-boundary-contract', + 'leaf-lifecycle-contract', + 'selection-rebase-contract', + 'rendered-dom-shape-contract', + 'destructive-leaf-boundary-gauntlet', + 'legacy-leaf-delete-parity', +] as const + +export const createBrowserMobileReleaseProofArtifact = ({ + passed, + scenario, + transport, +}: { + passed: boolean + scenario: string + transport: BrowserMobileTransportId +}): SlateBrowserMobileDeviceProofArtifact => { + const proof = classifyBrowserMobileTransportProof(transport) + + return { + capabilities: proof.supportedClaims, + evidenceClass: proof.evidenceClass, + kind: 'mobile-device', + passed, + platform: proof.platform, + releaseGateCapable: proof.releaseGateCapable, + scenario, + transport, + } +} + +export const createPersistentBrowserSoakProofArtifact = ({ + browserName, + iterations, + passed, + profilePersistence, + replayable, + scenario, +}: Omit< + SlateBrowserPersistentSoakProofArtifact, + 'kind' +>): SlateBrowserPersistentSoakProofArtifact => ({ + browserName, + iterations, + kind: 'persistent-browser-soak', + passed, + profilePersistence, + replayable, + scenario, +}) + +export const createReleaseDisciplineProofArtifact = ({ + guards, + passed, +}: Omit< + SlateBrowserReleaseDisciplineProofArtifact, + 'kind' +>): SlateBrowserReleaseDisciplineProofArtifact => ({ + guards: [...guards], + kind: 'release-discipline', + passed, +}) + +const hasDirectMobileProof = ( + artifacts: readonly SlateBrowserReleaseProofArtifact[], + platform: BrowserMobileProofPlatform, + capability: SlateBrowserMobileReleaseCapability +) => + artifacts.some( + (artifact) => + artifact.kind === 'mobile-device' && + artifact.passed && + artifact.releaseGateCapable && + artifact.evidenceClass === 'automated-direct' && + artifact.platform === platform && + artifact.capabilities.includes(capability) + ) + +const describeMobileClaim = ( + platform: BrowserMobileProofPlatform, + capability: SlateBrowserMobileReleaseCapability +) => `${platform} ${capability}` + +const validateMobileClaim = ( + issues: string[], + artifacts: readonly SlateBrowserReleaseProofArtifact[], + platform: BrowserMobileProofPlatform, + capability: SlateBrowserMobileReleaseCapability +) => { + if (!hasDirectMobileProof(artifacts, platform, capability)) { + issues.push( + `Missing automated-direct release proof for ${describeMobileClaim( + platform, + capability + )}` + ) + } +} + +const validatePersistentSoak = ( + issues: string[], + artifacts: readonly SlateBrowserReleaseProofArtifact[], + requiredSoakIterations: number +) => { + const artifact = artifacts.find( + (candidate) => + candidate.kind === 'persistent-browser-soak' && + candidate.passed && + candidate.profilePersistence === 'persistent' && + candidate.replayable && + candidate.iterations >= requiredSoakIterations + ) + + if (!artifact) { + issues.push( + `Missing persistent browser soak proof with at least ${requiredSoakIterations} replayable iterations` + ) + } +} + +const validateReleaseDiscipline = ( + issues: string[], + artifacts: readonly SlateBrowserReleaseProofArtifact[], + requiredDisciplineGuards: readonly string[] +) => { + const artifact = artifacts.find( + (candidate) => candidate.kind === 'release-discipline' && candidate.passed + ) + + if (!artifact || artifact.kind !== 'release-discipline') { + issues.push('Missing release discipline proof artifact') + return + } + + const missing = requiredDisciplineGuards.filter( + (guard) => !artifact.guards.includes(guard) + ) + + if (missing.length > 0) { + issues.push(`Missing release discipline guards: ${missing.join(', ')}`) + } +} + +export const validateSlateBrowserReleaseProof = ({ + artifacts, + claims, + requiredDisciplineGuards = SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS, + requiredSoakIterations = 5, +}: SlateBrowserReleaseProofOptions): SlateBrowserReleaseProofResult => { + const issues: string[] = [] + + for (const claim of claims) { + switch (claim) { + case 'android-chrome-device-browser-text-input': + validateMobileClaim( + issues, + artifacts, + 'android-chrome', + 'device-browser-text-input' + ) + break + case 'android-chrome-device-browser-ime-commit': + validateMobileClaim( + issues, + artifacts, + 'android-chrome', + 'device-browser-ime-commit' + ) + break + case 'ios-safari-device-browser-text-input': + validateMobileClaim( + issues, + artifacts, + 'ios-safari', + 'device-browser-text-input' + ) + break + case 'ios-safari-device-browser-ime-commit': + validateMobileClaim( + issues, + artifacts, + 'ios-safari', + 'device-browser-ime-commit' + ) + break + case 'native-mobile-clipboard': + validateMobileClaim( + issues, + artifacts, + 'android-chrome', + 'native-mobile-clipboard' + ) + validateMobileClaim( + issues, + artifacts, + 'ios-safari', + 'native-mobile-clipboard' + ) + break + case 'persistent-browser-caret-soak': + validatePersistentSoak(issues, artifacts, requiredSoakIterations) + break + case 'release-discipline-guards': + validateReleaseDiscipline(issues, artifacts, requiredDisciplineGuards) + break + } + } + + return { + issues, + ok: issues.length === 0, + } +} + +export const assertSlateBrowserReleaseProof = ( + options: SlateBrowserReleaseProofOptions +) => { + const result = validateSlateBrowserReleaseProof(options) + + if (!result.ok) { + throw new Error( + `Slate browser release proof failed:\n${result.issues + .map((issue) => `- ${issue}`) + .join('\n')}` + ) + } + + return result +} diff --git a/packages/slate-browser/test/core/release-proof.test.ts b/packages/slate-browser/test/core/release-proof.test.ts new file mode 100644 index 0000000000..a243ca2e06 --- /dev/null +++ b/packages/slate-browser/test/core/release-proof.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from 'bun:test' + +import { + assertSlateBrowserReleaseProof, + createBrowserMobileReleaseProofArtifact, + createPersistentBrowserSoakProofArtifact, + createReleaseDisciplineProofArtifact, + SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS, + type SlateBrowserMobileDeviceProofArtifact, + validateSlateBrowserReleaseProof, +} from '../../src/core' + +describe('release proof helpers', () => { + test('accepts direct Appium mobile proof and persistent browser soak artifacts', () => { + const artifacts = [ + createBrowserMobileReleaseProofArtifact({ + passed: true, + scenario: 'placeholder-ime', + transport: 'appium-android', + }), + createBrowserMobileReleaseProofArtifact({ + passed: true, + scenario: 'inline-edge-ime', + transport: 'appium-ios', + }), + createPersistentBrowserSoakProofArtifact({ + browserName: 'chromium', + iterations: 5, + passed: true, + profilePersistence: 'persistent', + replayable: true, + scenario: 'richtext-warm-toolbar-mark-arrow-conformance', + }), + createReleaseDisciplineProofArtifact({ + guards: [...SLATE_BROWSER_RELEASE_DISCIPLINE_GUARDS], + passed: true, + }), + ] + + expect( + validateSlateBrowserReleaseProof({ + artifacts, + claims: [ + 'android-chrome-device-browser-text-input', + 'android-chrome-device-browser-ime-commit', + 'ios-safari-device-browser-text-input', + 'ios-safari-device-browser-ime-commit', + 'persistent-browser-caret-soak', + 'release-discipline-guards', + ], + }) + ).toEqual({ issues: [], ok: true }) + }) + + test('does not let semantic or proxy mobile proof satisfy raw device claims', () => { + const result = validateSlateBrowserReleaseProof({ + artifacts: [ + createBrowserMobileReleaseProofArtifact({ + passed: true, + scenario: 'placeholder-ime', + transport: 'agent-browser-ios', + }), + ], + claims: ['ios-safari-device-browser-ime-commit'], + }) + + expect(result.ok).toBe(false) + expect(result.issues).toEqual([ + 'Missing automated-direct release proof for ios-safari device-browser-ime-commit', + ]) + }) + + test('keeps native mobile clipboard outside the release claim without explicit direct evidence', () => { + const android = createBrowserMobileReleaseProofArtifact({ + passed: true, + scenario: 'placeholder-ime', + transport: 'appium-android', + }) + const ios = createBrowserMobileReleaseProofArtifact({ + passed: true, + scenario: 'inline-edge-ime', + transport: 'appium-ios', + }) + + const result = validateSlateBrowserReleaseProof({ + artifacts: [android, ios], + claims: ['native-mobile-clipboard'], + }) + + expect(result.ok).toBe(false) + expect(result.issues).toEqual([ + 'Missing automated-direct release proof for android-chrome native-mobile-clipboard', + 'Missing automated-direct release proof for ios-safari native-mobile-clipboard', + ]) + }) + + test('requires persistent profile replay for browser soak claims', () => { + const result = validateSlateBrowserReleaseProof({ + artifacts: [ + createPersistentBrowserSoakProofArtifact({ + browserName: 'chromium', + iterations: 50, + passed: true, + profilePersistence: 'ephemeral', + replayable: true, + scenario: 'richtext-warm-toolbar-mark-arrow-conformance', + }), + ], + claims: ['persistent-browser-caret-soak'], + requiredSoakIterations: 10, + }) + + expect(result.ok).toBe(false) + expect(result.issues).toEqual([ + 'Missing persistent browser soak proof with at least 10 replayable iterations', + ]) + }) + + test('requires all release discipline guards', () => { + const result = validateSlateBrowserReleaseProof({ + artifacts: [ + createReleaseDisciplineProofArtifact({ + guards: ['public-surface-contract'], + passed: true, + }), + ], + claims: ['release-discipline-guards'], + }) + + expect(result.ok).toBe(false) + expect(result.issues).toEqual([ + 'Missing release discipline guards: public-field-hard-cut-contract, escape-hatch-inventory-contract, write-boundary-contract, leaf-lifecycle-contract, selection-rebase-contract, rendered-dom-shape-contract, destructive-leaf-boundary-gauntlet, legacy-leaf-delete-parity', + ]) + }) + + test('throws with actionable release proof failures', () => { + expect(() => + assertSlateBrowserReleaseProof({ + artifacts: [ + { + capabilities: ['device-browser-ime-commit'], + evidenceClass: 'automated-direct', + kind: 'mobile-device', + passed: false, + platform: 'android-chrome', + releaseGateCapable: true, + scenario: 'placeholder-ime', + transport: 'appium-android', + } satisfies SlateBrowserMobileDeviceProofArtifact, + ], + claims: ['android-chrome-device-browser-ime-commit'], + }) + ).toThrow( + /Missing automated-direct release proof for android-chrome device-browser-ime-commit/ + ) + }) +}) diff --git a/packages/slate-browser/test/core/scenario.test.ts b/packages/slate-browser/test/core/scenario.test.ts new file mode 100644 index 0000000000..ab4635cf16 --- /dev/null +++ b/packages/slate-browser/test/core/scenario.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, test } from 'bun:test' + +import { + classifyScenarioTransportClaim, + createScenarioReductionCandidates, + createScenarioReplay, + createSlateBrowserCompositionGauntlet, + createSlateBrowserDestructiveEditingGauntlet, + createSlateBrowserInlineCutTypingGauntlet, + createSlateBrowserInternalControlGauntlet, + createSlateBrowserMixedEditingConformanceGauntlet, + createSlateBrowserSemanticEditingConformanceGauntlet, + createSlateBrowserShellActivationGauntlet, + createSlateBrowserToolbarMarkClickTypingGauntlet, + createSlateBrowserWarmLoopSteps, + createSlateBrowserWarmToolbarArrowGauntlet, + normalizeScenarioMetadata, + type SlateBrowserScenarioStep, + serializeScenarioStepForReplay, + summarizeScenarioReductionCandidate, +} from '../../src/playwright' + +describe('scenario helpers', () => { + test('creates prefix, suffix, and single-step reduction candidates', () => { + const steps: SlateBrowserScenarioStep[] = [ + { kind: 'focus', label: 'focus' }, + { + kind: 'select', + label: 'select', + selection: { + anchor: { path: [0, 0], offset: 0 }, + focus: { path: [0, 0], offset: 0 }, + }, + }, + { kind: 'type', label: 'type', text: 'A' }, + ] + + const candidates = createScenarioReductionCandidates(steps) + + expect(candidates.map((candidate) => candidate.label)).toEqual([ + 'prefix:2', + 'prefix:1', + 'suffix:1', + 'suffix:2', + 'without:0', + 'without:1', + 'without:2', + ]) + expect(candidates.map((candidate) => candidate.steps.length)).toEqual([ + 2, 1, 2, 1, 2, 2, 2, + ]) + expect(candidates[0].removedRange).toEqual({ end: 3, start: 2 }) + expect(candidates[2].removedRange).toEqual({ end: 1, start: 0 }) + expect(candidates[4].removedRange).toEqual({ end: 1, start: 0 }) + }) + + test('does not return empty scenario candidates', () => { + const steps: SlateBrowserScenarioStep[] = [ + { kind: 'snapshot', label: 'only-step' }, + ] + + expect(createScenarioReductionCandidates(steps)).toEqual([]) + }) + + test('summarizes reduction candidates without serializing step functions', () => { + const steps: SlateBrowserScenarioStep[] = [ + { + kind: 'custom', + label: 'custom-step', + run: () => {}, + }, + { kind: 'type', label: 'type-step', text: 'A' }, + ] + const candidate = createScenarioReductionCandidates(steps)[0] + + expect(summarizeScenarioReductionCandidate(candidate)).toEqual({ + kind: 'prefix', + label: 'prefix:1', + removedRange: { end: 2, start: 1 }, + replay: { + replayable: false, + steps: [ + { + kind: 'custom', + label: 'custom-step', + replayable: false, + value: { + kind: 'custom', + label: 'custom-step', + }, + }, + ], + }, + stepLabels: ['custom-step'], + }) + }) + + test('serializes replayable scenario steps with action payloads', () => { + const step: SlateBrowserScenarioStep = { + iteration: 2, + kind: 'select', + label: 'select-word', + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 5 }, + }, + warmLoop: 'warm-toolbar', + } + + expect(serializeScenarioStepForReplay(step, 0)).toEqual({ + iteration: 2, + kind: 'select', + label: 'select-word', + replayable: true, + value: { + iteration: 2, + kind: 'select', + label: 'select-word', + selection: { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 5 }, + }, + warmLoop: 'warm-toolbar', + }, + warmLoop: 'warm-toolbar', + }) + }) + + test('serializes rendered DOM shape assertions for replay', () => { + const step: SlateBrowserScenarioStep = { + kind: 'assertRenderedDOMShape', + label: 'assert-first-block-dom-shape', + shape: { + blockIndex: 0, + domSelectionTarget: { + anchorPath: [0, 0], + isCollapsed: true, + }, + lineBoxCount: { max: 1 }, + noUnexpectedZeroWidthBreaks: true, + textContent: 'alpha', + zeroWidthBreakCount: 0, + }, + } + + expect(serializeScenarioStepForReplay(step, 0)).toEqual({ + iteration: undefined, + kind: 'assertRenderedDOMShape', + label: 'assert-first-block-dom-shape', + replayable: true, + value: { + kind: 'assertRenderedDOMShape', + label: 'assert-first-block-dom-shape', + shape: { + blockIndex: 0, + domSelectionTarget: { + anchorPath: [0, 0], + isCollapsed: true, + }, + lineBoxCount: { max: 1 }, + noUnexpectedZeroWidthBreaks: true, + textContent: 'alpha', + zeroWidthBreakCount: 0, + }, + }, + warmLoop: undefined, + }) + }) + + test('marks custom scenario steps as non-replayable without serializing functions', () => { + const replay = createScenarioReplay([ + { + kind: 'custom', + label: 'custom-step', + run: () => {}, + }, + { kind: 'type', label: 'type-step', text: 'A' }, + ]) + + expect(replay).toEqual({ + replayable: false, + steps: [ + { + kind: 'custom', + label: 'custom-step', + replayable: false, + value: { + kind: 'custom', + label: 'custom-step', + }, + }, + { + kind: 'type', + label: 'type-step', + replayable: true, + value: { + kind: 'type', + label: 'type-step', + text: 'A', + }, + }, + ], + }) + }) + + test('creates replayable warm toolbar arrow gauntlet steps', () => { + const replay = createScenarioReplay( + createSlateBrowserWarmToolbarArrowGauntlet({ + domCaretAfterInsert: { + offset: 9, + text: 'editableW', + }, + insertedText: 'W', + markDOMSelection: { + anchorNodeText: 'This is editable ', + anchorOffset: 8, + focusNodeText: 'This is editable ', + focusOffset: 16, + }, + markButtonTestId: 'mark-button-bold', + markSelection: { + anchor: { path: [0, 0], offset: 8 }, + focus: { path: [0, 0], offset: 16 }, + }, + selectedText: 'editable', + selectionAfterArrowLeft: { + anchor: { path: [0, 1], offset: 7 }, + focus: { path: [0, 1], offset: 7 }, + }, + selectionAfterCollapse: { + anchor: { path: [0, 1], offset: 8 }, + focus: { path: [0, 1], offset: 8 }, + }, + selectionAfterInsert: { + anchor: { path: [0, 1], offset: 9 }, + focus: { path: [0, 1], offset: 9 }, + }, + textAfterInsert: + 'This is editableW rich text, much better than a