diff --git a/.changeset/pt-native-traversal.md b/.changeset/pt-native-traversal.md new file mode 100644 index 000000000..937e8da68 --- /dev/null +++ b/.changeset/pt-native-traversal.md @@ -0,0 +1,7 @@ +--- +"@portabletext/editor": minor +--- + +feat: internal Portable Text-native node traversal + +Replaces the vendored Slate traversal system with schema-driven functions that resolve children on any node type, not just `.children`. The old system hardcoded `.children` as the only child field, which blocks first-class nesting where children will live in schema-defined fields like `rows`, `cells`, or `content`. diff --git a/packages/editor/src/editor/create-editable-api.ts b/packages/editor/src/editor/create-editable-api.ts index 7cae8eb3d..a9f3ca088 100644 --- a/packages/editor/src/editor/create-editable-api.ts +++ b/packages/editor/src/editor/create-editable-api.ts @@ -12,18 +12,21 @@ import { slateRangeToSelection, } from '../internal-utils/slate-utils' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getNodes} from '../node-traversal/get-nodes' +import {getTextBlockNode} from '../node-traversal/get-text-block-node' import {getActiveAnnotationsMarks} from '../selectors/selector.get-active-annotation-marks' import {getActiveDecorators} from '../selectors/selector.get-active-decorators' import {getFocusBlock} from '../selectors/selector.get-focus-block' import {getFocusSpan} from '../selectors/selector.get-focus-span' import {getSelectedValue} from '../selectors/selector.get-selected-value' import {isActiveAnnotation} from '../selectors/selector.is-active-annotation' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' +import {path as editorPath} from '../slate/editor/path' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import {isExpandedRange} from '../slate/range/is-expanded-range' import {isRange} from '../slate/range/is-range' +import {rangeEnd} from '../slate/range/range-end' import {rangeIncludes} from '../slate/range/range-includes' +import {rangeStart} from '../slate/range/range-start' import type { EditableAPI, EditableAPIDeleteOptions, @@ -289,13 +292,10 @@ export function createEditableAPI( let node: Node | undefined try { const entry = Array.from( - nodes(editor, { - at: [], - match: (n) => n._key === element._key, - }) || [], + getNodes(editor, {match: (n) => n._key === element._key}), )[0] if (entry) { - const [, itemPath] = entry + const itemPath = entry.path node = getDomNode(editor, itemPath) } } catch { @@ -309,28 +309,34 @@ export function createEditableAPI( } try { const activeAnnotations: PortableTextObject[] = [] - const spans = nodes(editor, { - at: editor.selection, + const spans = getNodes(editor, { + from: rangeStart(editor.selection).path, + to: rangeEnd(editor.selection).path, match: (node) => isSpan({schema: editor.schema}, node) && node.marks !== undefined && Array.isArray(node.marks) && node.marks.length > 0, }) - for (const [span, path] of spans) { - const [block] = editorNode(editor, path, {depth: 1}) - if (isTextBlock({schema: editor.schema}, block)) { - block.markDefs?.forEach((def) => { - if ( - isSpan({schema: editor.schema}, span) && - span.marks && - Array.isArray(span.marks) && - span.marks.includes(def._key) - ) { - activeAnnotations.push(def) - } - }) + for (const {node: span, path: spanPath} of spans) { + const blockEntry = getTextBlockNode( + editor, + editorPath(editor, spanPath, {depth: 1}), + ) + if (!blockEntry) { + continue } + const block = blockEntry.node + block.markDefs?.forEach((def) => { + if ( + isSpan({schema: editor.schema}, span) && + span.marks && + Array.isArray(span.marks) && + span.marks.includes(def._key) + ) { + activeAnnotations.push(def) + } + }) } return activeAnnotations } catch { diff --git a/packages/editor/src/editor/create-slate-editor.tsx b/packages/editor/src/editor/create-slate-editor.tsx index 3127c091d..f9d07ebd2 100644 --- a/packages/editor/src/editor/create-slate-editor.tsx +++ b/packages/editor/src/editor/create-slate-editor.tsx @@ -28,6 +28,8 @@ export function createSlateEditor( keyGenerator: context.keyGenerator, }) + editor.editableTypes = new Set() + editor.decoratedRanges = [] editor.decoratorState = {} editor.blockIndexMap = new Map() diff --git a/packages/editor/src/editor/editor-dom.ts b/packages/editor/src/editor/editor-dom.ts index 3d74b0586..4c9cb4409 100644 --- a/packages/editor/src/editor/editor-dom.ts +++ b/packages/editor/src/editor/editor-dom.ts @@ -1,9 +1,10 @@ import type {BehaviorEvent} from '../behaviors/behavior.types.event' import {getDomNode} from '../dom-traversal/get-dom-node' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getNodes} from '../node-traversal/get-nodes' import {getSelectionEndBlock, getSelectionStartBlock} from '../selectors' -import {isEditor} from '../slate/editor/is-editor' -import {nodes} from '../slate/editor/nodes' +import {isAncestorPath} from '../slate/path/is-ancestor-path' +import {rangeEdges} from '../slate/range/range-edges' import type {PickFromUnion} from '../type-utils' import type {PortableTextSlateEditor} from '../types/slate-editor' import type {EditorSnapshot} from './editor-snapshot' @@ -63,16 +64,26 @@ function getBlockNodes( } try { - const blockEntries = Array.from( - nodes(slateEditor, { - at: range, - mode: 'highest', - match: (n) => !isEditor(n), - }), - ) + const [start, end] = rangeEdges(range) + const blockEntries: Array<{node: unknown; path: Array}> = [] + let lastHighestPath: Array | undefined - return blockEntries.flatMap(([, blockPath]) => { - const domNode = getDomNode(slateEditor, blockPath) + for (const entry of getNodes(slateEditor, { + from: start.path, + to: end.path, + })) { + const entryPath = entry.path + + if (lastHighestPath && isAncestorPath(lastHighestPath, entryPath)) { + continue + } + + lastHighestPath = entryPath + blockEntries.push(entry) + } + + return blockEntries.flatMap((blockEntry) => { + const domNode = getDomNode(slateEditor, blockEntry.path) if (!domNode) { return [] @@ -100,16 +111,34 @@ function getChildNodes( } try { - const childEntries = Array.from( - nodes(slateEditor, { - at: range, - mode: 'lowest', - match: (n) => !isEditor(n), - }), - ) - - return childEntries.flatMap(([, childPath]) => { - const domNode = getDomNode(slateEditor, childPath) + const [start, end] = rangeEdges(range) + const childEntries: Array<{node: unknown; path: Array}> = [] + let buffered: {node: unknown; path: Array} | undefined + + for (const entry of getNodes(slateEditor, { + from: start.path, + to: end.path, + })) { + const entryPath = entry.path + + if (buffered) { + if (isAncestorPath(buffered.path, entryPath)) { + buffered = entry + continue + } + + childEntries.push(buffered) + } + + buffered = entry + } + + if (buffered) { + childEntries.push(buffered) + } + + return childEntries.flatMap((childEntry) => { + const domNode = getDomNode(slateEditor, childEntry.path) if (!domNode) { return [] diff --git a/packages/editor/src/editor/render.inline-object.tsx b/packages/editor/src/editor/render.inline-object.tsx index 838b6738a..5f9657aa2 100644 --- a/packages/editor/src/editor/render.inline-object.tsx +++ b/packages/editor/src/editor/render.inline-object.tsx @@ -1,6 +1,6 @@ import type {PortableTextChild, PortableTextObject} from '@portabletext/schema' import {useContext, useRef, type ReactElement} from 'react' -import {getPointBlock} from '../internal-utils/slate-utils' +import {getNode} from '../node-traversal/get-node' import {serializePath} from '../paths/serialize-path' import type {Path} from '../slate/interfaces/path' import type {RenderElementProps} from '../slate/react/components/editable' @@ -32,13 +32,8 @@ export function RenderInlineObject(props: { ) } - const [block] = getPointBlock({ - editor: slateEditor, - point: { - path: props.indexedPath, - offset: 0, - }, - }) + const blockEntry = getNode(slateEditor, props.indexedPath.slice(0, 1)) + const block = blockEntry?.node if (!block) { console.error( diff --git a/packages/editor/src/editor/sync-machine.ts b/packages/editor/src/editor/sync-machine.ts index 9b884e226..6aa44125f 100644 --- a/packages/editor/src/editor/sync-machine.ts +++ b/packages/editor/src/editor/sync-machine.ts @@ -24,15 +24,15 @@ import { import {safeStringify} from '../internal-utils/safe-json' import {validateValue} from '../internal-utils/validateValue' import {toSlateBlock} from '../internal-utils/values' +import {getNode} from '../node-traversal/get-node' +import {hasNode} from '../node-traversal/has-node' import {withRemoteChanges} from '../slate-plugins/slate-plugin.remote-changes' import {pluginWithoutHistory} from '../slate-plugins/slate-plugin.without-history' import {withoutPatching} from '../slate-plugins/slate-plugin.without-patching' import {deleteText} from '../slate/core/delete-text' -import {node as editorNode} from '../slate/editor/node' import {start} from '../slate/editor/start' import {withoutNormalizing} from '../slate/editor/without-normalizing' import type {Node} from '../slate/interfaces/node' -import {hasNode} from '../slate/node/has-node' import type {PickFromUnion} from '../type-utils' import type {InvalidValueResolution} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -612,7 +612,11 @@ function clearEditor({ slateEditor.children.forEach((_, index) => { const removePath = [childrenLength - 1 - index] - const [removeNode] = editorNode(slateEditor, removePath) + const removeEntry = getNode(slateEditor, removePath) + if (!removeEntry) { + return + } + const removeNode = removeEntry.node slateEditor.apply({ type: 'remove_node', path: removePath, @@ -645,7 +649,11 @@ function removeExtraBlocks({ if (value.length < childrenLength) { for (let i = childrenLength - 1; i > value.length - 1; i--) { - const [removeNode] = editorNode(slateEditor, [i]) + const removeEntry2 = getNode(slateEditor, [i]) + if (!removeEntry2) { + continue + } + const removeNode = removeEntry2.node slateEditor.apply({ type: 'remove_node', path: [i], @@ -861,7 +869,11 @@ function replaceBlock({ applyDeselect(slateEditor) } - const [oldNode] = editorNode(slateEditor, [index]) + const oldNodeEntry = getNode(slateEditor, [index]) + if (!oldNodeEntry) { + return + } + const oldNode = oldNodeEntry.node slateEditor.apply({type: 'remove_node', path: [index], node: oldNode}) slateEditor.apply({type: 'insert_node', path: [index], node: slateBlock}) @@ -869,8 +881,8 @@ function replaceBlock({ if ( selectionFocusOnBlock && - hasNode(slateEditor, currentSelection.anchor.path, slateEditor.schema) && - hasNode(slateEditor, currentSelection.focus.path, slateEditor.schema) + hasNode(slateEditor, currentSelection.anchor.path) && + hasNode(slateEditor, currentSelection.focus.path) ) { applySelect(slateEditor, currentSelection) } @@ -945,7 +957,11 @@ function updateBlock({ if (childIndex > 0) { debug.syncValue('Removing child') - const [childNode] = editorNode(slateEditor, [index, childIndex]) + const childNodeEntry = getNode(slateEditor, [index, childIndex]) + if (!childNodeEntry) { + return + } + const childNode = childNodeEntry.node slateEditor.apply({ type: 'remove_node', path: [index, childIndex], @@ -1019,10 +1035,14 @@ function updateBlock({ } else if (oldBlockChild) { debug.syncValue('Replacing child', currentBlockChild) - const [oldChild] = editorNode(slateEditor, [ + const oldChildEntry = getNode(slateEditor, [ index, currentBlockChildIndex, ]) + if (!oldChildEntry) { + return + } + const oldChild = oldChildEntry.node slateEditor.apply({ type: 'remove_node', path: [index, currentBlockChildIndex], diff --git a/packages/editor/src/internal-utils/apply-insert-node.ts b/packages/editor/src/internal-utils/apply-insert-node.ts index b071a995a..19fd22228 100644 --- a/packages/editor/src/internal-utils/apply-insert-node.ts +++ b/packages/editor/src/internal-utils/apply-insert-node.ts @@ -1,15 +1,14 @@ import {isSpan} from '@portabletext/schema' +import {getNode} from '../node-traversal/get-node' import {end} from '../slate/editor/end' import {isEdge} from '../slate/editor/is-edge' import {isEnd} from '../slate/editor/is-end' -import {nodes} from '../slate/editor/nodes' import {pathRef} from '../slate/editor/path-ref' import {withoutNormalizing} from '../slate/editor/without-normalizing' import type {Node} from '../slate/interfaces/node' import type {Path} from '../slate/interfaces/path' import type {Point} from '../slate/interfaces/point' import {extractProps} from '../slate/node/extract-props' -import {getNode} from '../slate/node/get-node' import {isObjectNode} from '../slate/node/is-object-node' import {nextPath} from '../slate/path/next-path' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -50,24 +49,26 @@ export function applyInsertNodeAtPoint( isSpan({schema: editor.schema}, n) || isObjectNode({schema: editor.schema}, n) - const [entry] = nodes(editor, { - at: at.path, - match, - mode: 'lowest', - includeObjectNodes: false, - }) + const nodeEntry = getNode(editor, at.path) + const entry = nodeEntry && match(nodeEntry.node) ? nodeEntry : undefined if (!entry) { return } - const [, matchPath] = entry + const matchPath = entry.path const ref = pathRef(editor, matchPath) const isAtEnd = isEnd(editor, at, matchPath) // Split the node at the point if we're not at an edge if (!isEdge(editor, at, matchPath)) { - const textNode = getNode(editor, at.path, editor.schema) + const textNodeEntry = getNode(editor, at.path) + + if (!textNodeEntry) { + return + } + + const textNode = textNodeEntry.node const properties = extractProps(textNode, editor.schema) applySplitNode(editor, at.path, at.offset, properties) diff --git a/packages/editor/src/internal-utils/apply-merge-node.ts b/packages/editor/src/internal-utils/apply-merge-node.ts index 9ad611f3b..fbf654598 100644 --- a/packages/editor/src/internal-utils/apply-merge-node.ts +++ b/packages/editor/src/internal-utils/apply-merge-node.ts @@ -1,9 +1,9 @@ import {isSpan, isTextBlock} from '@portabletext/schema' +import {getNode} from '../node-traversal/get-node' import {withoutNormalizing} from '../slate/editor/without-normalizing' import type {Path} from '../slate/interfaces/path' import type {Point} from '../slate/interfaces/point' import type {Range} from '../slate/interfaces/range' -import {getNode} from '../slate/node/get-node' import {isAncestorPath} from '../slate/path/is-ancestor-path' import {pathEndsBefore} from '../slate/path/path-ends-before' import {pathEquals} from '../slate/path/path-equals' @@ -28,7 +28,13 @@ export function applyMergeNode( path: Path, position: number, ): void { - const node = getNode(editor, path, editor.schema) + const nodeEntry = getNode(editor, path) + + if (!nodeEntry) { + return + } + + const node = nodeEntry.node const prevPath = previousPath(path) // Pre-transform all refs with merge semantics diff --git a/packages/editor/src/internal-utils/apply-set-node.ts b/packages/editor/src/internal-utils/apply-set-node.ts index aca5bc79f..4f17d37ed 100644 --- a/packages/editor/src/internal-utils/apply-set-node.ts +++ b/packages/editor/src/internal-utils/apply-set-node.ts @@ -1,6 +1,6 @@ import {isTextBlock} from '@portabletext/schema' +import {getNode} from '../node-traversal/get-node' import type {Path} from '../slate/interfaces/path' -import {getNode} from '../slate/node/get-node' import {isObjectNode} from '../slate/node/is-object-node' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -19,7 +19,14 @@ export function applySetNode( props: Record | object, path: Path, ): void { - const node = getNode(editor, path, editor.schema) as Record + const nodeEntry = getNode(editor, path) + + if (!nodeEntry) { + return + } + + const node = nodeEntry.node + const nodeRecord = node as Record const propsRecord = props as Record const properties: Record = {} const newProperties: Record = {} @@ -37,9 +44,9 @@ export function applySetNode( continue } - if (propsRecord[key] !== node[key]) { - if (node.hasOwnProperty(key)) { - properties[key] = node[key] + if (propsRecord[key] !== nodeRecord[key]) { + if (nodeRecord.hasOwnProperty(key)) { + properties[key] = nodeRecord[key] } if (propsRecord[key] != null) { diff --git a/packages/editor/src/internal-utils/apply-split-node.ts b/packages/editor/src/internal-utils/apply-split-node.ts index 5ac724407..e27524052 100644 --- a/packages/editor/src/internal-utils/apply-split-node.ts +++ b/packages/editor/src/internal-utils/apply-split-node.ts @@ -1,9 +1,9 @@ import {isSpan, isTextBlock} from '@portabletext/schema' +import {getNode} from '../node-traversal/get-node' import {withoutNormalizing} from '../slate/editor/without-normalizing' import type {Node} from '../slate/interfaces/node' import type {Path} from '../slate/interfaces/path' import type {Point} from '../slate/interfaces/point' -import {getNode} from '../slate/node/get-node' import {isAncestorPath} from '../slate/path/is-ancestor-path' import {nextPath} from '../slate/path/next-path' import {pathEndsBefore} from '../slate/path/path-ends-before' @@ -26,7 +26,13 @@ export function applySplitNode( position: number, properties: Record, ): void { - const node = getNode(editor, path, editor.schema) + const nodeEntry = getNode(editor, path) + + if (!nodeEntry) { + return + } + + const node = nodeEntry.node // Pre-transform all refs with split semantics for (const ref of editor.pathRefs) { diff --git a/packages/editor/src/internal-utils/applyPatch.ts b/packages/editor/src/internal-utils/applyPatch.ts index 6f87f6b91..afa30047d 100644 --- a/packages/editor/src/internal-utils/applyPatch.ts +++ b/packages/editor/src/internal-utils/applyPatch.ts @@ -24,11 +24,10 @@ import { } from '@sanity/diff-match-patch' import type {EditorSchema} from '../editor/editor-schema' import type {EditorContext} from '../editor/editor-snapshot' -import {node as editorNode} from '../slate/editor/node' -import {nodes as editorNodes} from '../slate/editor/nodes' +import {getChildren} from '../node-traversal/get-children' +import {getNode} from '../node-traversal/get-node' import {pathRef} from '../slate/editor/path-ref' import type {Node} from '../slate/interfaces/node' -import {getChildren} from '../slate/node/get-children' import {isObjectNode} from '../slate/node/is-object-node' import type {Path} from '../types/paths' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -196,8 +195,15 @@ function insertPatch( position === 'before' ? targetBlockIndex + blocksToInsert.length : targetBlockIndex - const [removeNode] = editorNode(editor, [removeIdx]) - editor.apply({type: 'remove_node', path: [removeIdx], node: removeNode}) + const removeEntry = getNode(editor, [removeIdx]) + if (!removeEntry) { + return false + } + editor.apply({ + type: 'remove_node', + path: [removeIdx], + node: removeEntry.node, + }) } return true @@ -271,20 +277,24 @@ function setPatch( const previousSelection = editor.selection - // Remove the previous children - const childPaths = Array.from( - editorNodes(editor, { - at: [block.index], - reverse: true, - mode: 'lowest', - }), - ([, p]) => pathRef(editor, p), + // Remove the previous children in reverse order (highest index first) + const children = getChildren(editor, [block.index]) + + const childPaths = Array.from(children.reverse(), (entry) => + pathRef(editor, entry.path), ) for (const pathRef of childPaths) { const childPath = pathRef.unref()! if (childPath) { - const [childNode] = editorNode(editor, childPath) - editor.apply({type: 'remove_node', path: childPath, node: childNode}) + const childNodeEntry = getNode(editor, childPath) + if (!childNodeEntry) { + continue + } + editor.apply({ + type: 'remove_node', + path: childPath, + node: childNodeEntry.node, + }) } } @@ -433,12 +443,15 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) { if (patch.path.length === 0) { applyDeselect(editor) - const children = getChildren(editor, [], editor.schema, { - reverse: true, - }) + const children = getChildren(editor, []) - for (const [node, path] of children) { - editor.apply({type: 'remove_node', path, node}) + for (let index = children.length - 1; index >= 0; index--) { + const entry = children[index] + if (!entry) { + continue + } + const {node: node, path: nodePath} = entry + editor.apply({type: 'remove_node', path: nodePath, node}) } return true @@ -452,8 +465,15 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) { // Single blocks if (patch.path.length === 1) { - const [blockNode] = editorNode(editor, [block.index]) - editor.apply({type: 'remove_node', path: [block.index], node: blockNode}) + const blockNodeEntry = getNode(editor, [block.index]) + if (!blockNodeEntry) { + return false + } + editor.apply({ + type: 'remove_node', + path: [block.index], + node: blockNodeEntry.node, + }) return true } @@ -463,11 +483,14 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) { // Unset on text block children if (isTextBlock({schema: editor.schema}, block.node) && child) { if (patch.path[1] === 'children' && patch.path.length === 3) { - const [childNode] = editorNode(editor, [block.index, child.index]) + const childNodeEntry2 = getNode(editor, [block.index, child.index]) + if (!childNodeEntry2) { + return false + } editor.apply({ type: 'remove_node', path: [block.index, child.index], - node: childNode, + node: childNodeEntry2.node, }) return true diff --git a/packages/editor/src/internal-utils/event-position.ts b/packages/editor/src/internal-utils/event-position.ts index d3874fac5..ea13be19f 100644 --- a/packages/editor/src/internal-utils/event-position.ts +++ b/packages/editor/src/internal-utils/event-position.ts @@ -2,25 +2,21 @@ import {getDomNode} from '../dom-traversal/get-dom-node' import {getDomNodePath} from '../dom-traversal/get-dom-node-path' import type {EditorActor} from '../editor/editor-machine' import type {EditorSchema} from '../editor/editor-schema' +import {getNode} from '../node-traversal/get-node' +import {getBlock} from '../node-traversal/is-block' import {keyedPathToIndexedPath} from '../paths/keyed-path-to-indexed-path' import {DOMEditor} from '../slate/dom/plugin/dom-editor' import {isDOMNode} from '../slate/dom/utils/dom' import {isEditor} from '../slate/editor/is-editor' import type {Path} from '../slate/interfaces/path' import type {Range as SlateRange} from '../slate/interfaces/range' -import {getNodeIf} from '../slate/node/get-node-if' import type {EditorSelection} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {getBlockEndPoint} from '../utils/util.get-block-end-point' import {getBlockStartPoint} from '../utils/util.get-block-start-point' import {isSelectionCollapsed} from '../utils/util.is-selection-collapsed' import {getBlockKeyFromSelectionPoint} from '../utils/util.selection-point' -import { - getFirstBlock, - getLastBlock, - getNodeBlock, - slateRangeToSelection, -} from './slate-utils' +import {slateRangeToSelection} from './slate-utils' export type EventPosition = { block: 'start' | 'end' @@ -53,11 +49,8 @@ export function getEventPosition({ const {node: eventNode, path: eventPath} = eventResult - const eventBlock = getNodeBlock({ - editor: slateEditor, - schema: editorActor.getSnapshot().context.schema, - node: eventNode, - }) + const eventBlockEntry = getBlock(slateEditor, eventPath.slice(0, 1)) + const eventBlock = eventBlockEntry?.node const eventPositionBlock = getEventPositionBlock({ nodePath: eventPath, slateEditor, @@ -168,9 +161,9 @@ function getEventNode({ if (indexedPath.length === 0) { return {node: slateEditor, path: indexedPath} } else { - const node = getNodeIf(slateEditor, indexedPath, slateEditor.schema) - if (node) { - return {node, path: indexedPath} + const nodeEntry = getNode(slateEditor, indexedPath) + if (nodeEntry) { + return {node: nodeEntry.node, path: indexedPath} } } } @@ -190,13 +183,13 @@ function getEventPositionBlock({ slateEditor: PortableTextSlateEditor event: DragEvent | MouseEvent }): EventPositionBlock | undefined { - const [firstBlock, firstBlockPath] = getFirstBlock({editor: slateEditor}) + const firstBlockEntry = getNode(slateEditor, [0]) - if (!firstBlock) { + if (!firstBlockEntry) { return undefined } - const firstBlockElement = getDomNode(slateEditor, firstBlockPath) + const firstBlockElement = getDomNode(slateEditor, firstBlockEntry.path) if (!firstBlockElement) { return undefined @@ -208,13 +201,15 @@ function getEventPositionBlock({ return 'start' } - const [lastBlock, lastBlockPath] = getLastBlock({editor: slateEditor}) + const lastBlockIndex = slateEditor.children.length - 1 + const lastBlockEntry = + lastBlockIndex >= 0 ? getNode(slateEditor, [lastBlockIndex]) : undefined - if (!lastBlock) { + if (!lastBlockEntry) { return undefined } - const lastBlockElement = getDomNode(slateEditor, lastBlockPath) + const lastBlockElement = getDomNode(slateEditor, lastBlockEntry.path) if (!lastBlockElement) { return undefined diff --git a/packages/editor/src/internal-utils/slate-utils.test.tsx b/packages/editor/src/internal-utils/slate-utils.test.tsx deleted file mode 100644 index 66878bf1b..000000000 --- a/packages/editor/src/internal-utils/slate-utils.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import {defineSchema} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import React from 'react' -import {describe, expect, test} from 'vitest' -import {InternalSlateEditorRefPlugin} from '../plugins/plugin.internal.slate-editor-ref' -import {createTestEditor} from '../test/vitest' -import type {PortableTextSlateEditor} from '../types/slate-editor' -import {getFocusSpan} from './slate-utils' - -describe(getFocusSpan.name, () => { - const keyGenerator = createTestKeyGenerator() - const blockKey = keyGenerator() - const fooSpanKey = keyGenerator() - const stockTickerKey = keyGenerator() - const barSpanKey = keyGenerator() - const imageKey = keyGenerator() - const initialValue = [ - { - _key: blockKey, - _type: 'block', - children: [ - { - _type: 'span', - _key: fooSpanKey, - text: 'foo', - }, - { - _type: 'stock-ticker', - _key: stockTickerKey, - }, - { - _type: 'span', - _key: barSpanKey, - text: 'bar', - }, - ], - }, - { - _key: imageKey, - _type: 'image', - }, - ] - const schemaDefinition = defineSchema({ - blockObjects: [{name: 'image'}], - inlineObjects: [{name: 'stock-ticker'}], - }) - - test('Scenario: Text block span is selected', async () => { - const slateEditorRef = React.createRef() - const {editor} = await createTestEditor({ - children: , - schemaDefinition, - initialValue, - }) - - editor.send({ - type: 'select', - at: { - anchor: { - path: [{_key: blockKey}, 'children', {_key: fooSpanKey}], - offset: 0, - }, - focus: { - path: [{_key: blockKey}, 'children', {_key: fooSpanKey}], - offset: 0, - }, - }, - }) - - expect(getFocusSpan({editor: slateEditorRef.current!})).toEqual([ - {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, - [0, 0], - ]) - }) - - test('Scenario: Inline object is selected', async () => { - const slateEditorRef = React.createRef() - await createTestEditor({ - children: , - schemaDefinition, - initialValue, - editableProps: { - selection: { - anchor: { - path: [{_key: blockKey}, 'children', {_key: stockTickerKey}], - offset: 0, - }, - focus: { - path: [{_key: blockKey}, 'children', {_key: stockTickerKey}], - offset: 0, - }, - }, - }, - }) - - expect(getFocusSpan({editor: slateEditorRef.current!})).toEqual([ - undefined, - undefined, - ]) - }) - - test('Scenario: Block object is selected', async () => { - const slateEditorRef = React.createRef() - await createTestEditor({ - children: , - schemaDefinition, - initialValue, - editableProps: { - selection: { - anchor: {path: [{_key: imageKey}], offset: 0}, - focus: {path: [{_key: imageKey}], offset: 0}, - }, - }, - }) - - expect(getFocusSpan({editor: slateEditorRef.current!})).toEqual([ - undefined, - undefined, - ]) - }) -}) diff --git a/packages/editor/src/internal-utils/slate-utils.ts b/packages/editor/src/internal-utils/slate-utils.ts index 608e9c019..c6fa101ad 100644 --- a/packages/editor/src/internal-utils/slate-utils.ts +++ b/packages/editor/src/internal-utils/slate-utils.ts @@ -1,123 +1,19 @@ -import { - isSpan, - isTextBlock, - type PortableTextSpan, - type PortableTextTextBlock, -} from '@portabletext/schema' +import {isTextBlock} from '@portabletext/schema' import type {EditorSchema} from '../editor/editor-schema' -import {end} from '../slate/editor/end' -import {isEditor} from '../slate/editor/is-editor' -import {node as editorNode} from '../slate/editor/node' -import {nodes as editorNodes} from '../slate/editor/nodes' -import {start} from '../slate/editor/start' +import {getChildren} from '../node-traversal/get-children' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' import type {Editor} from '../slate/interfaces/editor' import type {Node} from '../slate/interfaces/node' import type {Path as SlatePath} from '../slate/interfaces/path' import type {Point} from '../slate/interfaces/point' import type {Range} from '../slate/interfaces/range' -import {getChild} from '../slate/node/get-child' import {isBackwardRange} from '../slate/range/is-backward-range' +import {rangeEdges} from '../slate/range/range-edges' import type {EditorSelection, EditorSelectionPoint} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {isListBlock} from '../utils/parse-blocks' -export function getFocusBlock({ - editor, -}: { - editor: PortableTextSlateEditor -}): [node: Node, path: SlatePath] | [undefined, undefined] { - if (!editor.selection) { - return [undefined, undefined] - } - - try { - return ( - editorNode(editor, editor.selection.focus.path.slice(0, 1)) ?? [ - undefined, - undefined, - ] - ) - } catch { - return [undefined, undefined] - } -} - -export function getFocusSpan({ - editor, -}: { - editor: PortableTextSlateEditor -}): [node: PortableTextSpan, path: SlatePath] | [undefined, undefined] { - if (!editor.selection) { - return [undefined, undefined] - } - - try { - const [focusBlock] = getFocusBlock({editor}) - - if (!focusBlock) { - return [undefined, undefined] - } - - if (!isTextBlock({schema: editor.schema}, focusBlock)) { - return [undefined, undefined] - } - - const [node, path] = editorNode( - editor, - editor.selection.focus.path.slice(0, 2), - ) - - if (isSpan({schema: editor.schema}, node)) { - return [node, path] - } - } catch { - return [undefined, undefined] - } - - return [undefined, undefined] -} - -export function getPointBlock({ - editor, - point, -}: { - editor: PortableTextSlateEditor - point: Point -}): [node: Node, path: SlatePath] | [undefined, undefined] { - try { - const [block] = editorNode(editor, point.path.slice(0, 1)) ?? [ - undefined, - undefined, - ] - return block ? [block, point.path.slice(0, 1)] : [undefined, undefined] - } catch { - return [undefined, undefined] - } -} - -export function getFocusChild({ - editor, -}: { - editor: PortableTextSlateEditor -}): [node: Node, path: SlatePath] | [undefined, undefined] { - const [focusBlock, focusBlockPath] = getFocusBlock({editor}) - const childIndex = editor.selection?.focus.path.at(1) - - if (!focusBlock || !focusBlockPath || childIndex === undefined) { - return [undefined, undefined] - } - - try { - const focusChild = getChild(focusBlock, childIndex, editor.schema) - - return focusChild - ? [focusChild, [...focusBlockPath, childIndex]] - : [undefined, undefined] - } catch { - return [undefined, undefined] - } -} - function getPointChild({ editor, point, @@ -125,117 +21,16 @@ function getPointChild({ editor: PortableTextSlateEditor point: Point }): [node: Node, path: SlatePath] | [undefined, undefined] { - const [block, blockPath] = getPointBlock({editor, point}) + const blockEntry = getNode(editor, point.path.slice(0, 1)) const childIndex = point.path.at(1) - if (!block || !blockPath || childIndex === undefined) { - return [undefined, undefined] - } - - try { - const pointChild = getChild(block, childIndex, editor.schema) - - return pointChild - ? [pointChild, [...blockPath, childIndex]] - : [undefined, undefined] - } catch { - return [undefined, undefined] - } -} - -export function getFirstBlock({ - editor, -}: { - editor: PortableTextSlateEditor -}): [node: Node, path: SlatePath] | [undefined, undefined] { - if (editor.children.length === 0) { + if (!blockEntry || childIndex === undefined) { return [undefined, undefined] } - const firstPoint = start(editor, []) - const firstBlockPath = firstPoint.path.at(0) + const entry = getChildren(editor, blockEntry.path).at(childIndex) - try { - return firstBlockPath !== undefined - ? (editorNode(editor, [firstBlockPath]) ?? [undefined, undefined]) - : [undefined, undefined] - } catch { - return [undefined, undefined] - } -} - -export function getLastBlock({ - editor, -}: { - editor: PortableTextSlateEditor -}): [node: Node, path: SlatePath] | [undefined, undefined] { - if (editor.children.length === 0) { - return [undefined, undefined] - } - - const lastPoint = end(editor, []) - const lastBlockPath = lastPoint.path.at(0) - - try { - return lastBlockPath !== undefined - ? (editorNode(editor, [lastBlockPath]) ?? [undefined, undefined]) - : [undefined, undefined] - } catch { - return [undefined, undefined] - } -} - -export function getNodeBlock({ - editor, - schema, - node, -}: { - editor: PortableTextSlateEditor - schema: EditorSchema - node: Editor | Node -}) { - if (isEditor(node)) { - return undefined - } - - if (isBlockElement({schema}, node)) { - return elementToBlock({schema, element: node}) - } - - const parent = Array.from( - editorNodes(editor, { - mode: 'highest', - at: [], - match: (n) => - isBlockElement({schema}, n) && - n.children.some((child: Node) => child._key === node._key), - }), - ) - .at(0) - ?.at(0) - - return isTextBlock({schema: editor.schema}, parent) - ? elementToBlock({ - schema, - element: parent, - }) - : undefined -} - -function elementToBlock({ - element, -}: { - schema: EditorSchema - element: PortableTextTextBlock -}) { - return element -} - -function isBlockElement( - {schema}: {schema: EditorSchema}, - node: Node, -): node is PortableTextTextBlock { - return isTextBlock({schema}, node) + return entry ? [entry.node, entry.path] : [undefined, undefined] } export function isListItemActive({ @@ -249,18 +44,21 @@ export function isListItemActive({ return false } + const [selStart, selEnd] = rangeEdges(editor.selection) + const selectedBlocks = [ - ...editorNodes(editor, { - at: editor.selection, + ...getNodes(editor, { + from: selStart.path, + to: selEnd.path, match: (node) => isTextBlock({schema: editor.schema}, node), }), ] if (selectedBlocks.length > 0) { return selectedBlocks.every( - ([node]) => - isListBlock({schema: editor.schema}, node) && - node.listItem === listItem, + (entry) => + isListBlock({schema: editor.schema}, entry.node) && + entry.node.listItem === listItem, ) } @@ -278,15 +76,22 @@ export function isStyleActive({ return false } + const [selStart, selEnd] = rangeEdges(editor.selection) + const selectedBlocks = [ - ...editorNodes(editor, { - at: editor.selection, + ...getNodes(editor, { + from: selStart.path, + to: selEnd.path, match: (node) => isTextBlock({schema: editor.schema}, node), }), ] if (selectedBlocks.length > 0) { - return selectedBlocks.every(([node]) => node.style === style) + return selectedBlocks.every( + (entry) => + isTextBlock({schema: editor.schema}, entry.node) && + entry.node.style === style, + ) } return false @@ -301,14 +106,11 @@ export function slateRangeToSelection({ editor: PortableTextSlateEditor range: Range }): EditorSelection { - const [anchorBlock] = getPointBlock({ - editor, - point: range.anchor, - }) - const [focusBlock] = getPointBlock({ - editor, - point: range.focus, - }) + const anchorBlockEntry = getNode(editor, range.anchor.path.slice(0, 1)) + const focusBlockEntry = getNode(editor, range.focus.path.slice(0, 1)) + + const anchorBlock = anchorBlockEntry?.node + const focusBlock = focusBlockEntry?.node if (!anchorBlock || !focusBlock) { return null @@ -363,10 +165,8 @@ export function slatePointToSelectionPoint({ editor: PortableTextSlateEditor point: Point }): EditorSelectionPoint | undefined { - const [block] = getPointBlock({ - editor, - point, - }) + const blockEntry = getNode(editor, point.path.slice(0, 1)) + const block = blockEntry?.node if (!block) { return undefined diff --git a/packages/editor/src/node-traversal/get-ancestor-object-node.ts b/packages/editor/src/node-traversal/get-ancestor-object-node.ts new file mode 100644 index 000000000..e82c5b14b --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestor-object-node.ts @@ -0,0 +1,25 @@ +import type {PortableTextObject} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {isObjectNode} from '../slate/node/is-object-node' +import {getAncestor} from './get-ancestor' + +export function getAncestorObjectNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextObject; path: Array} | undefined { + const result = getAncestor(context, path, (node) => + isObjectNode({schema: context.schema}, node), + ) + if (!result) { + return undefined + } + if (!isObjectNode({schema: context.schema}, result.node)) { + return undefined + } + return {node: result.node, path: result.path} +} diff --git a/packages/editor/src/node-traversal/get-ancestor-text-block.ts b/packages/editor/src/node-traversal/get-ancestor-text-block.ts new file mode 100644 index 000000000..e1bb1b78c --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestor-text-block.ts @@ -0,0 +1,25 @@ +import type {PortableTextTextBlock} from '@portabletext/schema' +import {isTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getAncestor} from './get-ancestor' + +export function getAncestorTextBlock( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextTextBlock; path: Array} | undefined { + const result = getAncestor(context, path, (node) => + isTextBlock({schema: context.schema}, node), + ) + if (!result) { + return undefined + } + if (!isTextBlock({schema: context.schema}, result.node)) { + return undefined + } + return {node: result.node, path: result.path} +} diff --git a/packages/editor/src/node-traversal/get-ancestor.test.ts b/packages/editor/src/node-traversal/get-ancestor.test.ts new file mode 100644 index 000000000..4d5311c4b --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestor.test.ts @@ -0,0 +1,111 @@ +import {isTextBlock} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import type {Node} from '../slate/interfaces/node' +import {getAncestor} from './get-ancestor' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getAncestor.name, () => { + const testbed = createNodeTraversalTestbed() + + test('empty path returns undefined', () => { + expect(getAncestor(testbed.context, [], () => true)).toBeUndefined() + }) + + test('top-level block returns undefined', () => { + expect(getAncestor(testbed.context, [0], () => true)).toBeUndefined() + }) + + test('find text block ancestor of span', () => { + const entry = getAncestor(testbed.context, [0, 0], (node) => + isTextBlock({schema: testbed.schema}, node), + ) + expect(entry?.node).toBe(testbed.textBlock1) + expect(entry?.path).toEqual([0]) + }) + + test('find text block ancestor of span in cell', () => { + const entry = getAncestor(testbed.context, [4, 0, 0, 0, 0], (node) => + isTextBlock({schema: testbed.schema}, node), + ) + expect(entry?.node).toBe(testbed.cellBlock1) + expect(entry?.path).toEqual([4, 0, 0, 0]) + }) + + test('find cell ancestor of span in cell', () => { + const entry = getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (node) => node._type === 'cell', + ) + expect(entry?.node).toBe(testbed.cell1) + expect(entry?.path).toEqual([4, 0, 0]) + }) + + test('find row ancestor of span in cell', () => { + const entry = getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (node) => node._type === 'row', + ) + expect(entry?.node).toBe(testbed.row1) + expect(entry?.path).toEqual([4, 0]) + }) + + test('find table ancestor of span in cell', () => { + const entry = getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (node) => node._type === 'table', + ) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('no matching ancestor returns undefined', () => { + expect( + getAncestor(testbed.context, [0, 0], (node) => node._type === 'table'), + ).toBeUndefined() + }) + + test('match receives path', () => { + const entry = getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (_node: Node, path: Array) => path.length === 2, + ) + expect(entry?.node).toBe(testbed.row1) + expect(entry?.path).toEqual([4, 0]) + }) + + test('does not check the node itself', () => { + expect( + getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (node) => node._type === 'span', + ), + ).toBeUndefined() + }) + + test('find code block ancestor of code span', () => { + const entry = getAncestor( + testbed.context, + [3, 0, 0], + (node) => node._type === 'code-block', + ) + expect(entry?.node).toBe(testbed.codeBlock) + expect(entry?.path).toEqual([3]) + }) + + test('returns nearest matching ancestor', () => { + const entry = getAncestor( + testbed.context, + [4, 0, 0, 0, 0], + (node) => + isTextBlock({schema: testbed.schema}, node) || node._type === 'table', + ) + // Should return cellBlock1 (nearest), not table (furthest) + expect(entry?.node).toBe(testbed.cellBlock1) + expect(entry?.path).toEqual([4, 0, 0, 0]) + }) +}) diff --git a/packages/editor/src/node-traversal/get-ancestor.ts b/packages/editor/src/node-traversal/get-ancestor.ts new file mode 100644 index 000000000..4bf860c0f --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestor.ts @@ -0,0 +1,27 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getAncestors} from './get-ancestors' + +/** + * Find the first ancestor of the node at a given path that matches a predicate. + * Does not check the node at the path itself, only its ancestors. + */ +export function getAncestor( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, + match: (node: Node, path: Array) => boolean, +): {node: Node; path: Array} | undefined { + const ancestors = getAncestors(context, path) + + for (const ancestor of ancestors) { + if (match(ancestor.node, ancestor.path)) { + return ancestor + } + } + + return undefined +} diff --git a/packages/editor/src/node-traversal/get-ancestors.test.ts b/packages/editor/src/node-traversal/get-ancestors.test.ts new file mode 100644 index 000000000..eb57dd5eb --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestors.test.ts @@ -0,0 +1,84 @@ +import {describe, expect, test} from 'vitest' +import {getAncestors} from './get-ancestors' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getAncestors.name, () => { + const testbed = createNodeTraversalTestbed() + + test('empty path returns empty array', () => { + expect(getAncestors(testbed.context, [])).toEqual([]) + }) + + test('top-level block has no ancestors', () => { + expect(getAncestors(testbed.context, [0])).toEqual([]) + }) + + test('span in text block has one ancestor', () => { + const ancestors = getAncestors(testbed.context, [0, 1]) + expect(ancestors).toHaveLength(1) + expect(ancestors.at(0)?.node).toBe(testbed.textBlock1) + expect(ancestors.at(0)?.path).toEqual([0]) + }) + + test('span in cell block has four ancestors', () => { + const ancestors = getAncestors(testbed.context, [4, 0, 0, 0, 0]) + expect(ancestors).toHaveLength(4) + expect(ancestors.at(0)?.node).toBe(testbed.cellBlock1) + expect(ancestors.at(0)?.path).toEqual([4, 0, 0, 0]) + expect(ancestors.at(1)?.node).toBe(testbed.cell1) + expect(ancestors.at(1)?.path).toEqual([4, 0, 0]) + expect(ancestors.at(2)?.node).toBe(testbed.row1) + expect(ancestors.at(2)?.path).toEqual([4, 0]) + expect(ancestors.at(3)?.node).toBe(testbed.table) + expect(ancestors.at(3)?.path).toEqual([4]) + }) + + test('block in cell has three ancestors', () => { + const ancestors = getAncestors(testbed.context, [4, 0, 0, 0]) + expect(ancestors).toHaveLength(3) + expect(ancestors.at(0)?.node).toBe(testbed.cell1) + expect(ancestors.at(0)?.path).toEqual([4, 0, 0]) + expect(ancestors.at(1)?.node).toBe(testbed.row1) + expect(ancestors.at(1)?.path).toEqual([4, 0]) + expect(ancestors.at(2)?.node).toBe(testbed.table) + expect(ancestors.at(2)?.path).toEqual([4]) + }) + + test('cell has two ancestors', () => { + const ancestors = getAncestors(testbed.context, [4, 0, 0]) + expect(ancestors).toHaveLength(2) + expect(ancestors.at(0)?.node).toBe(testbed.row1) + expect(ancestors.at(0)?.path).toEqual([4, 0]) + expect(ancestors.at(1)?.node).toBe(testbed.table) + expect(ancestors.at(1)?.path).toEqual([4]) + }) + + test('row has one ancestor', () => { + const ancestors = getAncestors(testbed.context, [4, 0]) + expect(ancestors).toHaveLength(1) + expect(ancestors.at(0)?.node).toBe(testbed.table) + expect(ancestors.at(0)?.path).toEqual([4]) + }) + + test('code span has two ancestors', () => { + const ancestors = getAncestors(testbed.context, [3, 0, 0]) + expect(ancestors).toHaveLength(2) + expect(ancestors.at(0)?.node).toBe(testbed.codeLine1) + expect(ancestors.at(0)?.path).toEqual([3, 0]) + expect(ancestors.at(1)?.node).toBe(testbed.codeBlock) + expect(ancestors.at(1)?.path).toEqual([3]) + }) + + test('code line has one ancestor', () => { + const ancestors = getAncestors(testbed.context, [3, 0]) + expect(ancestors).toHaveLength(1) + expect(ancestors.at(0)?.node).toBe(testbed.codeBlock) + expect(ancestors.at(0)?.path).toEqual([3]) + }) + + test('ancestors are ordered from nearest to furthest', () => { + const ancestors = getAncestors(testbed.context, [4, 0, 0, 0, 0]) + const paths = ancestors.map((ancestor) => ancestor.path) + expect(paths).toEqual([[4, 0, 0, 0], [4, 0, 0], [4, 0], [4]]) + }) +}) diff --git a/packages/editor/src/node-traversal/get-ancestors.ts b/packages/editor/src/node-traversal/get-ancestors.ts new file mode 100644 index 000000000..2b0cab82e --- /dev/null +++ b/packages/editor/src/node-traversal/get-ancestors.ts @@ -0,0 +1,32 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' + +/** + * Get all ancestors of the node at a given path, from nearest to furthest. + */ +export function getAncestors( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): Array<{node: Node; path: Array}> { + if (path.length <= 1) { + return [] + } + + const ancestors: Array<{node: Node; path: Array}> = [] + + for (let length = path.length - 1; length >= 1; length--) { + const ancestorPath = path.slice(0, length) + const entry = getNode(context, ancestorPath) + + if (entry) { + ancestors.push(entry) + } + } + + return ancestors +} diff --git a/packages/editor/src/node-traversal/get-children.test.ts b/packages/editor/src/node-traversal/get-children.test.ts new file mode 100644 index 000000000..3b30fef2a --- /dev/null +++ b/packages/editor/src/node-traversal/get-children.test.ts @@ -0,0 +1,92 @@ +import {describe, expect, test} from 'vitest' +import {getChildren} from './get-children' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getChildren.name, () => { + const testbed = createNodeTraversalTestbed() + + test('root children', () => { + expect(getChildren(testbed.context, [])).toEqual([ + {node: testbed.textBlock1, path: [0]}, + {node: testbed.image, path: [1]}, + {node: testbed.textBlock2, path: [2]}, + {node: testbed.codeBlock, path: [3]}, + {node: testbed.table, path: [4]}, + ]) + }) + + test('text block children', () => { + expect(getChildren(testbed.context, [0])).toEqual([ + {node: testbed.span1, path: [0, 0]}, + {node: testbed.stockTicker1, path: [0, 1]}, + {node: testbed.span2, path: [0, 2]}, + ]) + }) + + test('code block children (code lines)', () => { + expect(getChildren(testbed.context, [3])).toEqual([ + {node: testbed.codeLine1, path: [3, 0]}, + {node: testbed.codeLine2, path: [3, 1]}, + ]) + }) + + test('code line children', () => { + expect(getChildren(testbed.context, [3, 0])).toEqual([ + {node: testbed.codeSpan1, path: [3, 0, 0]}, + ]) + }) + + test('table children (rows)', () => { + expect(getChildren(testbed.context, [4])).toEqual([ + {node: testbed.row1, path: [4, 0]}, + {node: testbed.row2, path: [4, 1]}, + ]) + }) + + test('row children (cells)', () => { + expect(getChildren(testbed.context, [4, 0])).toEqual([ + {node: testbed.cell1, path: [4, 0, 0]}, + {node: testbed.cell2, path: [4, 0, 1]}, + ]) + }) + + test('cell with multiple blocks', () => { + expect(getChildren(testbed.context, [4, 0, 0])).toEqual([ + {node: testbed.cellBlock1, path: [4, 0, 0, 0]}, + {node: testbed.cellBlock2, path: [4, 0, 0, 1]}, + ]) + }) + + test('block inside cell children', () => { + expect(getChildren(testbed.context, [4, 0, 0, 0])).toEqual([ + {node: testbed.cellSpan1, path: [4, 0, 0, 0, 0]}, + {node: testbed.stockTicker2, path: [4, 0, 0, 0, 1]}, + ]) + }) + + test('leaf node returns empty array', () => { + expect(getChildren(testbed.context, [0, 0])).toEqual([]) + }) + + test('block object without children returns empty array', () => { + expect(getChildren(testbed.context, [1])).toEqual([]) + }) + + test('invalid path returns empty array', () => { + expect(getChildren(testbed.context, [99])).toEqual([]) + }) + + test('non-editable code block returns empty array', () => { + const tableOnly = new Set(['table', 'table.row', 'table.row.cell']) + expect( + getChildren({...testbed.context, editableTypes: tableOnly}, [3]), + ).toEqual([]) + }) + + test('non-editable table returns empty array', () => { + const codeOnly = new Set(['code-block']) + expect( + getChildren({...testbed.context, editableTypes: codeOnly}, [4]), + ).toEqual([]) + }) +}) diff --git a/packages/editor/src/node-traversal/get-children.ts b/packages/editor/src/node-traversal/get-children.ts new file mode 100644 index 000000000..fa0ea2bde --- /dev/null +++ b/packages/editor/src/node-traversal/get-children.ts @@ -0,0 +1,143 @@ +import type {OfDefinition} from '@portabletext/schema' +import {isTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import {resolveChildArrayField} from '../schema/resolve-child-array-field' +import type {Node} from '../slate/interfaces/node' +import {isObjectNode} from '../slate/node/is-object-node' + +/** + * Get the children of a node at a given path. + */ +export function getChildren( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): Array<{node: Node; path: Array}> { + const traversalContext = { + schema: context.schema, + editableTypes: context.editableTypes, + } + return getChildrenInternal(traversalContext, {value: context.value}, path) +} + +export function getChildrenInternal( + context: { + schema: EditorSchema + editableTypes: Set + }, + root: Node | {value: Array}, + path: Array, +): Array<{node: Node; path: Array}> { + const rootChildren = getNodeChildren(context, root, undefined, '') + + if (!rootChildren) { + return [] + } + + let currentChildren = rootChildren.children + let currentScope = rootChildren.scope + let scopePath = rootChildren.scopePath + let currentPath: Array = [] + + for (const index of path) { + const node = currentChildren[index] + + if (!node) { + return [] + } + + currentPath = [...currentPath, index] + + const next = getNodeChildren(context, node, currentScope, scopePath) + + if (!next) { + return [] + } + + currentChildren = next.children + currentScope = next.scope + scopePath = next.scopePath + } + + return currentChildren.map((child, index) => ({ + node: child, + path: [...currentPath, index], + })) +} + +// Reusable result objects to avoid allocations in hot paths. +// Safe because callers read the fields immediately and don't store references. +const _textBlockResult: { + children: Array + scope: ReadonlyArray | undefined + scopePath: string +} = {children: [], scope: undefined, scopePath: ''} + +const _rootResult: { + children: Array + scope: ReadonlyArray | undefined + scopePath: string +} = {children: [], scope: undefined, scopePath: ''} + +export function getNodeChildren( + context: { + schema: EditorSchema + editableTypes: Set + }, + node: Node | {value: Array}, + scope: ReadonlyArray | undefined, + scopePath: string, +): + | { + children: Array + scope: ReadonlyArray | undefined + scopePath: string + } + | undefined { + // Text blocks store children in .children + if (isTextBlock(context, node)) { + _textBlockResult.children = node.children + return _textBlockResult + } + + if (isObjectNode(context, node)) { + const scopedKey = scopePath ? `${scopePath}.${node._type}` : node._type + + if (!context.editableTypes.has(scopedKey)) { + return undefined + } + + const arrayField = resolveChildArrayField( + {schema: context.schema, scope}, + node, + ) + + if (!arrayField) { + return undefined + } + + return { + children: (node as Record)[ + arrayField.name + ] as Array, + scope: arrayField.of, + scopePath: scopedKey, + } + } + + // Root context: has .value array but no _key or _type + if ( + 'value' in node && + Array.isArray(node['value']) && + !('_key' in node) && + !('_type' in node) + ) { + _rootResult.children = node['value'] as Array + return _rootResult + } + + return undefined +} diff --git a/packages/editor/src/node-traversal/get-first-child.test.ts b/packages/editor/src/node-traversal/get-first-child.test.ts new file mode 100644 index 000000000..982a02cf6 --- /dev/null +++ b/packages/editor/src/node-traversal/get-first-child.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {getFirstChild} from './get-first-child' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getFirstChild.name, () => { + const testbed = createNodeTraversalTestbed() + + test('first descendant from root', () => { + expect(getFirstChild(testbed.context, [])).toEqual({ + node: testbed.textBlock1, + path: [0], + }) + }) + + test('first descendant of text block', () => { + expect(getFirstChild(testbed.context, [0])).toEqual({ + node: testbed.span1, + path: [0, 0], + }) + }) + + test('first descendant of code block', () => { + expect(getFirstChild(testbed.context, [3])).toEqual({ + node: testbed.codeLine1, + path: [3, 0], + }) + }) + + test('first descendant of table', () => { + expect(getFirstChild(testbed.context, [4])).toEqual({ + node: testbed.row1, + path: [4, 0], + }) + }) + + test('leaf node returns undefined', () => { + expect(getFirstChild(testbed.context, [0, 0])).toBeUndefined() + }) + + test('void block object returns undefined', () => { + expect(getFirstChild(testbed.context, [1])).toBeUndefined() + }) + + test('invalid path returns undefined', () => { + expect(getFirstChild(testbed.context, [99])).toBeUndefined() + }) + + test('first of non-editable container returns undefined', () => { + const tableOnly = new Set(['table', 'table.row', 'table.row.cell']) + expect( + getFirstChild({...testbed.context, editableTypes: tableOnly}, [3]), + ).toBeUndefined() + }) +}) diff --git a/packages/editor/src/node-traversal/get-first-child.ts b/packages/editor/src/node-traversal/get-first-child.ts new file mode 100644 index 000000000..82b3da0e8 --- /dev/null +++ b/packages/editor/src/node-traversal/get-first-child.ts @@ -0,0 +1,17 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getChildren} from './get-children' + +/** + * Get the first child of a node at a given path. + */ +export function getFirstChild( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: Node; path: Array} | undefined { + return getChildren(context, path).at(0) +} diff --git a/packages/editor/src/node-traversal/get-highest-object-node.test.ts b/packages/editor/src/node-traversal/get-highest-object-node.test.ts new file mode 100644 index 000000000..57faf54f6 --- /dev/null +++ b/packages/editor/src/node-traversal/get-highest-object-node.test.ts @@ -0,0 +1,77 @@ +import {describe, expect, test} from 'vitest' +import {getHighestObjectNode} from './get-highest-object-node' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getHighestObjectNode.name, () => { + const testbed = createNodeTraversalTestbed() + + test('empty path returns undefined', () => { + expect(getHighestObjectNode(testbed.context, [])).toBeUndefined() + }) + + test('text block returns undefined', () => { + expect(getHighestObjectNode(testbed.context, [0])).toBeUndefined() + }) + + test('span returns undefined', () => { + expect(getHighestObjectNode(testbed.context, [0, 0])).toBeUndefined() + }) + + test('block object at path returns itself', () => { + const entry = getHighestObjectNode(testbed.context, [1]) + expect(entry?.node).toBe(testbed.image) + expect(entry?.path).toEqual([1]) + }) + + test('inline object at path returns itself', () => { + const entry = getHighestObjectNode(testbed.context, [0, 1]) + expect(entry?.node).toBe(testbed.stockTicker1) + expect(entry?.path).toEqual([0, 1]) + }) + + test('table at path returns itself', () => { + const entry = getHighestObjectNode(testbed.context, [4]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('span inside cell finds table as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [4, 0, 0, 0, 0]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('cell block finds table as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [4, 0, 0, 0]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('cell finds table as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [4, 0, 0]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('row finds table as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [4, 0]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('code span finds code-block as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [3, 0, 0]) + expect(entry?.node).toBe(testbed.codeBlock) + expect(entry?.path).toEqual([3]) + }) + + test('inline object in cell finds table as highest object node', () => { + const entry = getHighestObjectNode(testbed.context, [4, 0, 0, 0, 1]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('invalid path returns undefined', () => { + expect(getHighestObjectNode(testbed.context, [99])).toBeUndefined() + }) +}) diff --git a/packages/editor/src/node-traversal/get-highest-object-node.ts b/packages/editor/src/node-traversal/get-highest-object-node.ts new file mode 100644 index 000000000..0b807af57 --- /dev/null +++ b/packages/editor/src/node-traversal/get-highest-object-node.ts @@ -0,0 +1,45 @@ +import type {PortableTextObject} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {isObjectNode} from '../slate/node/is-object-node' +import {getAncestors} from './get-ancestors' +import {getNode} from './get-node' + +/** + * Find the highest (closest to root) object node at or above the given path. + * + * Checks ancestors from furthest to nearest, returning the first object node + * found. If no ancestor is an object node, checks the node at the path itself. + */ +export function getHighestObjectNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextObject; path: Array} | undefined { + // Walk ancestors from furthest (root) to nearest, return the first object node + const ancestors = getAncestors(context, path) + + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors.at(i)! + + if (isObjectNode({schema: context.schema}, ancestor.node)) { + return {node: ancestor.node, path: ancestor.path} + } + } + + // No ancestor is an object node - check the node at the path itself + const entry = getNode(context, path) + + if (!entry) { + return undefined + } + + if (!isObjectNode({schema: context.schema}, entry.node)) { + return undefined + } + + return {node: entry.node, path: entry.path} +} diff --git a/packages/editor/src/node-traversal/get-last-child.test.ts b/packages/editor/src/node-traversal/get-last-child.test.ts new file mode 100644 index 000000000..d966c050e --- /dev/null +++ b/packages/editor/src/node-traversal/get-last-child.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {getLastChild} from './get-last-child' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getLastChild.name, () => { + const testbed = createNodeTraversalTestbed() + + test('last descendant from root', () => { + expect(getLastChild(testbed.context, [])).toEqual({ + node: testbed.table, + path: [4], + }) + }) + + test('last descendant of text block', () => { + expect(getLastChild(testbed.context, [0])).toEqual({ + node: testbed.span2, + path: [0, 2], + }) + }) + + test('last descendant of code block', () => { + expect(getLastChild(testbed.context, [3])).toEqual({ + node: testbed.codeLine2, + path: [3, 1], + }) + }) + + test('last descendant of table', () => { + expect(getLastChild(testbed.context, [4])).toEqual({ + node: testbed.row2, + path: [4, 1], + }) + }) + + test('leaf node returns undefined', () => { + expect(getLastChild(testbed.context, [0, 0])).toBeUndefined() + }) + + test('void block object returns undefined', () => { + expect(getLastChild(testbed.context, [1])).toBeUndefined() + }) + + test('invalid path returns undefined', () => { + expect(getLastChild(testbed.context, [99])).toBeUndefined() + }) + + test('last of non-editable container returns undefined', () => { + const tableOnly = new Set(['table', 'table.row', 'table.row.cell']) + expect( + getLastChild({...testbed.context, editableTypes: tableOnly}, [3]), + ).toBeUndefined() + }) +}) diff --git a/packages/editor/src/node-traversal/get-last-child.ts b/packages/editor/src/node-traversal/get-last-child.ts new file mode 100644 index 000000000..31710fad8 --- /dev/null +++ b/packages/editor/src/node-traversal/get-last-child.ts @@ -0,0 +1,19 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getChildren} from './get-children' + +/** + * Get the last child of a node at a given path. + */ +export function getLastChild( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: Node; path: Array} | undefined { + const children = getChildren(context, path) + + return children.at(-1) +} diff --git a/packages/editor/src/node-traversal/get-leaf.test.ts b/packages/editor/src/node-traversal/get-leaf.test.ts new file mode 100644 index 000000000..fb62f194f --- /dev/null +++ b/packages/editor/src/node-traversal/get-leaf.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, test} from 'vitest' +import {getLeaf} from './get-leaf' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getLeaf.name, () => { + const testbed = createNodeTraversalTestbed() + + test('first leaf from root (start edge)', () => { + const entry = getLeaf(testbed.context, [], {edge: 'start'}) + expect(entry?.node).toBe(testbed.span1) + expect(entry?.path).toEqual([0, 0]) + }) + + test('last leaf from root (end edge)', () => { + const entry = getLeaf(testbed.context, [], {edge: 'end'}) + expect(entry?.node).toBe(testbed.emptySpan) + expect(entry?.path).toEqual([4, 1, 0, 0, 0]) + }) + + test('first leaf from text block', () => { + const entry = getLeaf(testbed.context, [0], {edge: 'start'}) + expect(entry?.node).toBe(testbed.span1) + expect(entry?.path).toEqual([0, 0]) + }) + + test('last leaf from text block', () => { + const entry = getLeaf(testbed.context, [0], {edge: 'end'}) + expect(entry?.node).toBe(testbed.span2) + expect(entry?.path).toEqual([0, 2]) + }) + + test('span is already a leaf', () => { + const entry = getLeaf(testbed.context, [0, 0], {edge: 'start'}) + expect(entry?.node).toBe(testbed.span1) + expect(entry?.path).toEqual([0, 0]) + }) + + test('void block object is a leaf', () => { + const entry = getLeaf(testbed.context, [1], {edge: 'start'}) + expect(entry?.node).toBe(testbed.image) + expect(entry?.path).toEqual([1]) + }) + + test('inline object is a leaf', () => { + const entry = getLeaf(testbed.context, [0, 1], {edge: 'start'}) + expect(entry?.node).toBe(testbed.stockTicker1) + expect(entry?.path).toEqual([0, 1]) + }) + + test('empty document returns undefined', () => { + const emptyContext = { + ...testbed.context, + value: [], + } + expect(getLeaf(emptyContext, [], {edge: 'start'})).toBeUndefined() + expect(getLeaf(emptyContext, [], {edge: 'end'})).toBeUndefined() + }) + + test('invalid path returns undefined', () => { + expect(getLeaf(testbed.context, [99], {edge: 'start'})).toBeUndefined() + expect(getLeaf(testbed.context, [0, 99], {edge: 'end'})).toBeUndefined() + }) + + test('first leaf from code block', () => { + const entry = getLeaf(testbed.context, [3], {edge: 'start'}) + expect(entry?.node).toBe(testbed.codeSpan1) + expect(entry?.path).toEqual([3, 0, 0]) + }) + + test('last leaf from code block', () => { + const entry = getLeaf(testbed.context, [3], {edge: 'end'}) + expect(entry?.node).toBe(testbed.codeSpan2) + expect(entry?.path).toEqual([3, 1, 0]) + }) + + test('first leaf from table', () => { + const entry = getLeaf(testbed.context, [4], {edge: 'start'}) + expect(entry?.node).toBe(testbed.cellSpan1) + expect(entry?.path).toEqual([4, 0, 0, 0, 0]) + }) + + test('last leaf from table', () => { + const entry = getLeaf(testbed.context, [4], {edge: 'end'}) + expect(entry?.node).toBe(testbed.emptySpan) + expect(entry?.path).toEqual([4, 1, 0, 0, 0]) + }) +}) diff --git a/packages/editor/src/node-traversal/get-leaf.ts b/packages/editor/src/node-traversal/get-leaf.ts new file mode 100644 index 000000000..0f81877c6 --- /dev/null +++ b/packages/editor/src/node-traversal/get-leaf.ts @@ -0,0 +1,58 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getChildren} from './get-children' +import {getNode} from './get-node' + +/** + * Get the deepest leaf node starting from a path, walking toward either the + * start or end edge. A leaf is any node that has no children according to the + * traversal context. + */ +export function getLeaf( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, + options: {edge: 'start' | 'end'}, +): {node: Node; path: Array} | undefined { + const {edge} = options + + let currentPath = path + + // If starting from root (empty path), descend into first/last child + if (currentPath.length === 0) { + const children = getChildren(context, []) + if (children.length === 0) { + return undefined + } + const firstOrLast = edge === 'end' ? children.at(-1)! : children.at(0)! + const nodeChildren = getChildren(context, firstOrLast.path) + if (nodeChildren.length === 0) { + return firstOrLast + } + currentPath = firstOrLast.path + } else { + // Check if the node at path is already a leaf + const entry = getNode(context, currentPath) + if (!entry) { + return undefined + } + const children = getChildren(context, currentPath) + if (children.length === 0) { + return entry + } + } + + // Descend to deepest leaf + while (true) { + const children = getChildren(context, currentPath) + if (children.length === 0) { + const entry = getNode(context, currentPath) + return entry ?? undefined + } + const child = edge === 'end' ? children.at(-1)! : children.at(0)! + currentPath = child.path + } +} diff --git a/packages/editor/src/node-traversal/get-node.test.ts b/packages/editor/src/node-traversal/get-node.test.ts new file mode 100644 index 000000000..8835450c8 --- /dev/null +++ b/packages/editor/src/node-traversal/get-node.test.ts @@ -0,0 +1,124 @@ +import {describe, expect, test} from 'vitest' +import {getNode} from './get-node' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getNode.name, () => { + const testbed = createNodeTraversalTestbed() + + test('empty path', () => { + expect(getNode(testbed.context, [])).toBeUndefined() + }) + + test('text block', () => { + const entry = getNode(testbed.context, [0]) + expect(entry?.node).toBe(testbed.textBlock1) + expect(entry?.path).toEqual([0]) + }) + + test('span', () => { + const entry = getNode(testbed.context, [0, 0]) + expect(entry?.node).toBe(testbed.span1) + expect(entry?.path).toEqual([0, 0]) + }) + + test('inline object', () => { + const entry = getNode(testbed.context, [0, 1]) + expect(entry?.node).toBe(testbed.stockTicker1) + expect(entry?.path).toEqual([0, 1]) + }) + + test('block object', () => { + const entry = getNode(testbed.context, [1]) + expect(entry?.node).toBe(testbed.image) + expect(entry?.path).toEqual([1]) + }) + + test('second text block', () => { + const entry = getNode(testbed.context, [2]) + expect(entry?.node).toBe(testbed.textBlock2) + expect(entry?.path).toEqual([2]) + }) + + test('out of bounds', () => { + expect(getNode(testbed.context, [99])).toBeUndefined() + }) + + test('code block', () => { + const entry = getNode(testbed.context, [3]) + expect(entry?.node).toBe(testbed.codeBlock) + expect(entry?.path).toEqual([3]) + }) + + test('code block -> first line', () => { + const entry = getNode(testbed.context, [3, 0]) + expect(entry?.node).toBe(testbed.codeLine1) + expect(entry?.path).toEqual([3, 0]) + }) + + test('code block -> second line', () => { + const entry = getNode(testbed.context, [3, 1]) + expect(entry?.node).toBe(testbed.codeLine2) + expect(entry?.path).toEqual([3, 1]) + }) + + test('code block -> line -> span', () => { + const entry = getNode(testbed.context, [3, 0, 0]) + expect(entry?.node).toBe(testbed.codeSpan1) + expect(entry?.path).toEqual([3, 0, 0]) + }) + + test('table -> row', () => { + const entry = getNode(testbed.context, [4, 0]) + expect(entry?.node).toBe(testbed.row1) + expect(entry?.path).toEqual([4, 0]) + }) + + test('table -> row -> first cell', () => { + const entry = getNode(testbed.context, [4, 0, 0]) + expect(entry?.node).toBe(testbed.cell1) + expect(entry?.path).toEqual([4, 0, 0]) + }) + + test('table -> row -> second cell', () => { + const entry = getNode(testbed.context, [4, 0, 1]) + expect(entry?.node).toBe(testbed.cell2) + expect(entry?.path).toEqual([4, 0, 1]) + }) + + test('cell -> first block', () => { + const entry = getNode(testbed.context, [4, 0, 0, 0]) + expect(entry?.node).toBe(testbed.cellBlock1) + expect(entry?.path).toEqual([4, 0, 0, 0]) + }) + + test('cell -> second block', () => { + const entry = getNode(testbed.context, [4, 0, 0, 1]) + expect(entry?.node).toBe(testbed.cellBlock2) + expect(entry?.path).toEqual([4, 0, 0, 1]) + }) + + test('span inside cell block', () => { + const entry = getNode(testbed.context, [4, 0, 0, 0, 0]) + expect(entry?.node).toBe(testbed.cellSpan1) + expect(entry?.path).toEqual([4, 0, 0, 0, 0]) + }) + + test('inline object inside cell block', () => { + const entry = getNode(testbed.context, [4, 0, 0, 0, 1]) + expect(entry?.node).toBe(testbed.stockTicker2) + expect(entry?.path).toEqual([4, 0, 0, 0, 1]) + }) + + test('second row', () => { + const entry = getNode(testbed.context, [4, 1]) + expect(entry?.node).toBe(testbed.row2) + expect(entry?.path).toEqual([4, 1]) + }) + + test('node inside non-editable container returns undefined', () => { + const tableOnly = new Set(['table', 'table.row', 'table.row.cell']) + expect( + getNode({...testbed.context, editableTypes: tableOnly}, [3, 0]), + ).toBeUndefined() + }) +}) diff --git a/packages/editor/src/node-traversal/get-node.ts b/packages/editor/src/node-traversal/get-node.ts new file mode 100644 index 000000000..e2501c1ca --- /dev/null +++ b/packages/editor/src/node-traversal/get-node.ts @@ -0,0 +1,50 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNodeChildren} from './get-children' + +/** + * Get the node at a given path. + */ +export function getNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: Node; path: Array} | undefined { + if (path.length === 0) { + return undefined + } + + let currentChildren: Array = context.value + let scope: Parameters[2] = undefined + let scopePath = '' + let node: Node | undefined + + for (let i = 0; i < path.length; i++) { + node = currentChildren.at(path.at(i)!) + + if (!node) { + return undefined + } + + if (i < path.length - 1) { + const next = getNodeChildren(context, node, scope, scopePath) + + if (!next) { + return undefined + } + + currentChildren = next.children + scope = next.scope + scopePath = next.scopePath + } + } + + if (!node) { + return undefined + } + + return {node, path} +} diff --git a/packages/editor/src/node-traversal/get-nodes.test.ts b/packages/editor/src/node-traversal/get-nodes.test.ts new file mode 100644 index 000000000..85e80b4aa --- /dev/null +++ b/packages/editor/src/node-traversal/get-nodes.test.ts @@ -0,0 +1,568 @@ +import {isSpan, isTextBlock} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import {getNodes} from './get-nodes' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getNodes.name, () => { + const testbed = createNodeTraversalTestbed() + + test('all nodes from root', () => { + const nodes = [...getNodes(testbed.context)] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.span1, + testbed.stockTicker1, + testbed.span2, + testbed.image, + testbed.textBlock2, + testbed.span3, + testbed.codeBlock, + testbed.codeLine1, + testbed.codeSpan1, + testbed.codeLine2, + testbed.codeSpan2, + testbed.table, + testbed.row1, + testbed.cell1, + testbed.cellBlock1, + testbed.cellSpan1, + testbed.stockTicker2, + testbed.cellBlock2, + testbed.cellSpan2, + testbed.cell2, + testbed.cellBlock3, + testbed.cellSpan3, + testbed.row2, + testbed.cell3, + testbed.emptyBlock, + testbed.emptySpan, + ]) + }) + + test('paths are correct', () => { + const nodes = [...getNodes(testbed.context)] + const paths = nodes.map((entry) => entry.path) + + expect(paths).toEqual([ + [0], + [0, 0], + [0, 1], + [0, 2], + [1], + [2], + [2, 0], + [3], + [3, 0], + [3, 0, 0], + [3, 1], + [3, 1, 0], + [4], + [4, 0], + [4, 0, 0], + [4, 0, 0, 0], + [4, 0, 0, 0, 0], + [4, 0, 0, 0, 1], + [4, 0, 0, 1], + [4, 0, 0, 1, 0], + [4, 0, 1], + [4, 0, 1, 0], + [4, 0, 1, 0, 0], + [4, 1], + [4, 1, 0], + [4, 1, 0, 0], + [4, 1, 0, 0, 0], + ]) + }) + + test('from a subtree', () => { + const nodes = [...getNodes(testbed.context, {at: [3]})] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.codeLine1, + testbed.codeSpan1, + testbed.codeLine2, + testbed.codeSpan2, + ]) + }) + + test('from a leaf returns empty', () => { + const nodes = [...getNodes(testbed.context, {at: [0, 0]})] + + expect(nodes).toEqual([]) + }) + + test('reverse order', () => { + const nodes = [...getNodes(testbed.context, {at: [3], reverse: true})] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.codeLine2, + testbed.codeSpan2, + testbed.codeLine1, + testbed.codeSpan1, + ]) + }) + + test('skips non-editable container internals', () => { + const tableOnly = new Set(['table', 'table.row', 'table.row.cell']) + const nodes = [...getNodes({...testbed.context, editableTypes: tableOnly})] + const nodeValues = nodes.map((entry) => entry.node) + + // code-block itself appears (it's a root child) but its children don't + expect(nodeValues).toContain(testbed.codeBlock) + expect(nodeValues).not.toContain(testbed.codeLine1) + expect(nodeValues).not.toContain(testbed.codeSpan1) + + // table internals still traversed + expect(nodeValues).toContain(testbed.table) + expect(nodeValues).toContain(testbed.row1) + expect(nodeValues).toContain(testbed.cell1) + expect(nodeValues).toContain(testbed.cellBlock1) + }) + + describe('match predicate', () => { + test('filters by isSpan', () => { + const nodes = [ + ...getNodes(testbed.context, { + match: (node) => isSpan({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.span1, + testbed.span2, + testbed.span3, + testbed.codeSpan1, + testbed.codeSpan2, + testbed.cellSpan1, + testbed.cellSpan2, + testbed.cellSpan3, + testbed.emptySpan, + ]) + }) + + test('filters by isTextBlock', () => { + const nodes = [ + ...getNodes(testbed.context, { + match: (node) => isTextBlock({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.textBlock2, + testbed.codeLine1, + testbed.codeLine2, + testbed.cellBlock1, + testbed.cellBlock2, + testbed.cellBlock3, + testbed.emptyBlock, + ]) + }) + + test('filters with custom predicate on subtree', () => { + const nodes = [ + ...getNodes(testbed.context, { + at: [4], + match: (node) => isSpan({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.cellSpan1, + testbed.cellSpan2, + testbed.cellSpan3, + testbed.emptySpan, + ]) + }) + + test('match with reverse', () => { + const nodes = [ + ...getNodes(testbed.context, { + at: [0], + match: (node) => isSpan({schema: testbed.schema}, node), + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([testbed.span2, testbed.span1]) + }) + }) + + describe('from/to range', () => { + test('range within flat blocks', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 0], + to: [1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.span1, + testbed.stockTicker1, + testbed.span2, + testbed.image, + ]) + }) + + test('range within a single block', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 1], + to: [0, 2], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.stockTicker1, + testbed.span2, + ]) + }) + + test('range spanning into container', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [2], + to: [3, 0], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock2, + testbed.span3, + testbed.codeBlock, + testbed.codeLine1, + ]) + }) + + test('range within container', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 0, 0, 0], + to: [4, 0, 0, 1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row1, + testbed.cell1, + testbed.cellBlock1, + testbed.cellSpan1, + testbed.stockTicker2, + testbed.cellBlock2, + ]) + }) + + test('range spanning across cells', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 0, 0, 1, 0], + to: [4, 0, 1, 0, 0], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row1, + testbed.cell1, + testbed.cellBlock2, + testbed.cellSpan2, + testbed.cell2, + testbed.cellBlock3, + testbed.cellSpan3, + ]) + }) + + test('range spanning across rows', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 0, 1, 0], + to: [4, 1, 0, 0, 0], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row1, + testbed.cell2, + testbed.cellBlock3, + testbed.cellSpan3, + testbed.row2, + testbed.cell3, + testbed.emptyBlock, + testbed.emptySpan, + ]) + }) + + test('from at document start', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0], + to: [0, 1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.span1, + testbed.stockTicker1, + ]) + }) + + test('to at document end', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 1], + to: [4, 1, 0, 0, 0], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row2, + testbed.cell3, + testbed.emptyBlock, + testbed.emptySpan, + ]) + }) + + test('from equals to (single node)', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [1], + to: [1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([testbed.image]) + }) + + test('from equals to (single leaf node)', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 0], + to: [0, 0], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([testbed.textBlock1, testbed.span1]) + }) + + test('from only (no to)', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row2, + testbed.cell3, + testbed.emptyBlock, + testbed.emptySpan, + ]) + }) + + test('to only (no from)', () => { + const nodes = [ + ...getNodes(testbed.context, { + to: [1], + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.span1, + testbed.stockTicker1, + testbed.span2, + testbed.image, + ]) + }) + }) + + describe('from/to with match', () => { + test('range with span filter', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0], + to: [3], + match: (node) => isSpan({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([testbed.span1, testbed.span2, testbed.span3]) + }) + + test('range with text block filter', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [3], + match: (node) => isTextBlock({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.codeLine1, + testbed.codeLine2, + testbed.cellBlock1, + testbed.cellBlock2, + testbed.cellBlock3, + testbed.emptyBlock, + ]) + }) + + test('range within container with match', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4], + to: [4, 1, 0, 0, 0], + match: (node) => isSpan({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.cellSpan1, + testbed.cellSpan2, + testbed.cellSpan3, + testbed.emptySpan, + ]) + }) + }) + + describe('reverse with from/to', () => { + test('reverse range within flat blocks', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 0], + to: [1], + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.image, + testbed.textBlock1, + testbed.span2, + testbed.stockTicker1, + testbed.span1, + ]) + }) + + test('reverse range within a single block', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 1], + to: [0, 2], + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.textBlock1, + testbed.span2, + testbed.stockTicker1, + ]) + }) + + test('reverse range spanning container', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [2], + to: [3, 0], + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.codeBlock, + testbed.codeLine1, + testbed.textBlock2, + testbed.span3, + ]) + }) + + test('reverse from equals to (single node)', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [1], + to: [1], + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([testbed.image]) + }) + + test('reverse with match', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [0, 0], + to: [3, 1, 0], + reverse: true, + match: (node) => isSpan({schema: testbed.schema}, node), + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.codeSpan2, + testbed.codeSpan1, + testbed.span3, + testbed.span2, + testbed.span1, + ]) + }) + + test('reverse range within container', () => { + const nodes = [ + ...getNodes(testbed.context, { + from: [4, 0, 0, 0], + to: [4, 0, 0, 1], + reverse: true, + }), + ] + const nodeValues = nodes.map((entry) => entry.node) + + expect(nodeValues).toEqual([ + testbed.table, + testbed.row1, + testbed.cell1, + testbed.cellBlock2, + testbed.cellBlock1, + testbed.stockTicker2, + testbed.cellSpan1, + ]) + }) + }) +}) diff --git a/packages/editor/src/node-traversal/get-nodes.ts b/packages/editor/src/node-traversal/get-nodes.ts new file mode 100644 index 000000000..8354f9b0b --- /dev/null +++ b/packages/editor/src/node-traversal/get-nodes.ts @@ -0,0 +1,272 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getChildrenInternal} from './get-children' + +/** + * Get the descendant nodes of the node at a given path. + * + * When `from` and `to` are provided, performs a range-bounded DFS traversal, + * yielding only nodes between `from` and `to` (inclusive). Both paths are + * always in document order: `from` is the earlier path, `to` is the later + * path. The `reverse` flag controls iteration direction within that range. + * + * When `match` is provided, only yields nodes where the predicate returns true. + * The traversal still visits all nodes in range - `match` is a filter, not a + * traversal control. + * + * When `at` is provided, traverses descendants of the node at that path + * instead of the root. + */ +export function* getNodes( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + options: { + at?: Array + from?: Array + to?: Array + match?: (node: Node, path: Array) => boolean + reverse?: boolean + } = {}, +): Generator<{node: Node; path: Array}, void, undefined> { + const {at = [], from, to, match, reverse = false} = options + const traversalContext = { + schema: context.schema, + editableTypes: context.editableTypes, + } + const root = {value: context.value} + + // When from/to are not provided, use the simple recursive DFS + if (from === undefined && to === undefined) { + yield* getNodesSimple(traversalContext, root, at, {match, reverse}) + return + } + + yield* getNodesInRange(traversalContext, root, at, { + from, + to, + match, + reverse, + }) +} + +/** + * Get descendant nodes of a standalone node (not in the editor tree). + * Used for cases like getDirtyPaths where the node hasn't been inserted yet. + */ +export function* getNodeDescendants( + context: { + schema: EditorSchema + editableTypes: Set + }, + node: Node | {value: Array}, +): Generator<{node: Node; path: Array}, void, undefined> { + yield* getNodesSimple(context, node, [], {}) +} + +/** + * Simple recursive DFS - the original behavior. + * Yields all descendants of the node at `path`. + */ +function* getNodesSimple( + context: { + schema: EditorSchema + editableTypes: Set + }, + root: Node | {value: Array}, + path: Array, + options: { + match?: (node: Node, path: Array) => boolean + reverse?: boolean + }, +): Generator<{node: Node; path: Array}, void, undefined> { + const {match, reverse = false} = options + + const children = getChildrenInternal(context, root, path) + + const entries = reverse ? [...children].reverse() : children + + for (const entry of entries) { + if (!match || match(entry.node, entry.path)) { + yield entry + } + + yield* getNodesSimple(context, root, entry.path, options) + } +} + +/** + * Compare two paths in DFS (document) order. + * + * In DFS order, a parent comes before its children, and children come before + * the parent's next sibling. So [4] < [4,0] < [4,0,0] < [4,1] < [5]. + */ +function compareDfsOrder( + pathA: Array, + pathB: Array, +): -1 | 0 | 1 { + const minLength = Math.min(pathA.length, pathB.length) + + for (let index = 0; index < minLength; index++) { + const segmentA = pathA[index]! + const segmentB = pathB[index]! + + if (segmentA < segmentB) { + return -1 + } + if (segmentA > segmentB) { + return 1 + } + } + + if (pathA.length < pathB.length) { + return -1 + } + if (pathA.length > pathB.length) { + return 1 + } + + return 0 +} + +/** + * Check if `candidatePath` is a proper prefix of `targetPath`. + */ +function isAncestorOf( + candidatePath: Array, + targetPath: Array, +): boolean { + if (candidatePath.length >= targetPath.length) { + return false + } + + for (let index = 0; index < candidatePath.length; index++) { + if (candidatePath[index] !== targetPath[index]) { + return false + } + } + + return true +} + +/** + * Range-bounded recursive DFS traversal. + * + * `from` and `to` are always in document order (from is earlier, to is + * later), regardless of traversal direction. + */ +function* getNodesInRange( + context: { + schema: EditorSchema + editableTypes: Set + }, + root: Node | {value: Array}, + path: Array, + options: { + from?: Array + to?: Array + match?: (node: Node, path: Array) => boolean + reverse?: boolean + }, +): Generator<{node: Node; path: Array}, void, undefined> { + const {from, to, match, reverse = false} = options + + const children = getChildrenInternal(context, root, path) + const entries = reverse ? [...children].reverse() : children + + for (const entry of entries) { + if (canStopTraversal(entry.path, from, to, reverse)) { + return + } + + if (!couldContainInRangeNodes(entry.path, from, to)) { + continue + } + + if (isInRange(entry.path, from, to)) { + if (!match || match(entry.node, entry.path)) { + yield entry + } + } + + yield* getNodesInRange(context, root, entry.path, options) + } +} + +/** + * Check if a node is within the [from, to] range in document order. + * Both bounds are inclusive. Ancestor nodes of from or to are also + * considered in range since they contain the range boundary. + */ +function isInRange( + nodePath: Array, + from: Array | undefined, + to: Array | undefined, +): boolean { + if (from !== undefined && compareDfsOrder(nodePath, from) === -1) { + if (!isAncestorOf(nodePath, from)) { + return false + } + } + + if (to !== undefined && compareDfsOrder(nodePath, to) === 1) { + if (!isAncestorOf(nodePath, to)) { + return false + } + } + + return true +} + +/** + * Check if a subtree rooted at `nodePath` could contain any nodes in the + * [from, to] range. Returns true if the node is in range, or if the node + * is an ancestor of either bound (its subtree contains in-range nodes). + */ +function couldContainInRangeNodes( + nodePath: Array, + from: Array | undefined, + to: Array | undefined, +): boolean { + if (isInRange(nodePath, from, to)) { + return true + } + + if (from !== undefined && isAncestorOf(nodePath, from)) { + return true + } + + if (to !== undefined && isAncestorOf(nodePath, to)) { + return true + } + + return false +} + +/** + * Check if all remaining nodes in iteration order will be outside the range. + */ +function canStopTraversal( + nodePath: Array, + from: Array | undefined, + to: Array | undefined, + reverse: boolean, +): boolean { + if (reverse) { + if (from === undefined) { + return false + } + + return ( + compareDfsOrder(nodePath, from) === -1 && !isAncestorOf(nodePath, from) + ) + } + + if (to === undefined) { + return false + } + + return compareDfsOrder(nodePath, to) === 1 +} diff --git a/packages/editor/src/node-traversal/get-parent.test.ts b/packages/editor/src/node-traversal/get-parent.test.ts new file mode 100644 index 000000000..914c0ea28 --- /dev/null +++ b/packages/editor/src/node-traversal/get-parent.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, test} from 'vitest' +import {getParent} from './get-parent' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getParent.name, () => { + const testbed = createNodeTraversalTestbed() + + test('parent of top-level block', () => { + expect(getParent(testbed.context, [0])).toBeUndefined() + }) + + test('parent of span', () => { + const entry = getParent(testbed.context, [0, 0]) + expect(entry?.node).toBe(testbed.textBlock1) + expect(entry?.path).toEqual([0]) + }) + + test('parent of row', () => { + const entry = getParent(testbed.context, [4, 0]) + expect(entry?.node).toBe(testbed.table) + expect(entry?.path).toEqual([4]) + }) + + test('parent of cell', () => { + const entry = getParent(testbed.context, [4, 0, 0]) + expect(entry?.node).toBe(testbed.row1) + expect(entry?.path).toEqual([4, 0]) + }) + + test('parent of block inside cell', () => { + const entry = getParent(testbed.context, [4, 0, 0, 0]) + expect(entry?.node).toBe(testbed.cell1) + expect(entry?.path).toEqual([4, 0, 0]) + }) + + test('parent of span inside cell block', () => { + const entry = getParent(testbed.context, [4, 0, 0, 0, 0]) + expect(entry?.node).toBe(testbed.cellBlock1) + expect(entry?.path).toEqual([4, 0, 0, 0]) + }) + + test('parent of code line', () => { + const entry = getParent(testbed.context, [3, 0]) + expect(entry?.node).toBe(testbed.codeBlock) + expect(entry?.path).toEqual([3]) + }) + + test('empty path returns undefined', () => { + expect(getParent(testbed.context, [])).toBeUndefined() + }) +}) diff --git a/packages/editor/src/node-traversal/get-parent.ts b/packages/editor/src/node-traversal/get-parent.ts new file mode 100644 index 000000000..fcdddacd0 --- /dev/null +++ b/packages/editor/src/node-traversal/get-parent.ts @@ -0,0 +1,21 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' + +/** + * Get the parent of a node at a given path. + */ +export function getParent( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: Node; path: Array} | undefined { + if (path.length <= 1) { + return undefined + } + + return getNode(context, path.slice(0, -1)) +} diff --git a/packages/editor/src/node-traversal/get-sibling.test.ts b/packages/editor/src/node-traversal/get-sibling.test.ts new file mode 100644 index 000000000..2cbd1b1fd --- /dev/null +++ b/packages/editor/src/node-traversal/get-sibling.test.ts @@ -0,0 +1,121 @@ +import {describe, expect, test} from 'vitest' +import {getSibling} from './get-sibling' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getSibling.name, () => { + const testbed = createNodeTraversalTestbed() + + test('empty path returns undefined', () => { + expect(getSibling(testbed.context, [], 'next')).toBeUndefined() + expect(getSibling(testbed.context, [], 'previous')).toBeUndefined() + }) + + test('next sibling of first top-level block', () => { + const entry = getSibling(testbed.context, [0], 'next') + expect(entry?.node).toBe(testbed.image) + expect(entry?.path).toEqual([1]) + }) + + test('previous sibling of second top-level block', () => { + const entry = getSibling(testbed.context, [1], 'previous') + expect(entry?.node).toBe(testbed.textBlock1) + expect(entry?.path).toEqual([0]) + }) + + test('next sibling of last top-level block returns undefined', () => { + expect(getSibling(testbed.context, [4], 'next')).toBeUndefined() + }) + + test('previous sibling of first top-level block returns undefined', () => { + expect(getSibling(testbed.context, [0], 'previous')).toBeUndefined() + }) + + test('next sibling of span in text block', () => { + const entry = getSibling(testbed.context, [0, 0], 'next') + expect(entry?.node).toBe(testbed.stockTicker1) + expect(entry?.path).toEqual([0, 1]) + }) + + test('previous sibling of last span in text block', () => { + const entry = getSibling(testbed.context, [0, 2], 'previous') + expect(entry?.node).toBe(testbed.stockTicker1) + expect(entry?.path).toEqual([0, 1]) + }) + + test('next sibling of last span in text block returns undefined', () => { + expect(getSibling(testbed.context, [0, 2], 'next')).toBeUndefined() + }) + + test('previous sibling of first span in text block returns undefined', () => { + expect(getSibling(testbed.context, [0, 0], 'previous')).toBeUndefined() + }) + + test('next sibling of first block in cell', () => { + const entry = getSibling(testbed.context, [4, 0, 0, 0], 'next') + expect(entry?.node).toBe(testbed.cellBlock2) + expect(entry?.path).toEqual([4, 0, 0, 1]) + }) + + test('previous sibling of second block in cell', () => { + const entry = getSibling(testbed.context, [4, 0, 0, 1], 'previous') + expect(entry?.node).toBe(testbed.cellBlock1) + expect(entry?.path).toEqual([4, 0, 0, 0]) + }) + + test('next sibling of last block in cell returns undefined', () => { + expect(getSibling(testbed.context, [4, 0, 0, 1], 'next')).toBeUndefined() + }) + + test('next sibling of first cell in row', () => { + const entry = getSibling(testbed.context, [4, 0, 0], 'next') + expect(entry?.node).toBe(testbed.cell2) + expect(entry?.path).toEqual([4, 0, 1]) + }) + + test('previous sibling of second cell in row', () => { + const entry = getSibling(testbed.context, [4, 0, 1], 'previous') + expect(entry?.node).toBe(testbed.cell1) + expect(entry?.path).toEqual([4, 0, 0]) + }) + + test('next sibling of first row in table', () => { + const entry = getSibling(testbed.context, [4, 0], 'next') + expect(entry?.node).toBe(testbed.row2) + expect(entry?.path).toEqual([4, 1]) + }) + + test('previous sibling of second row in table', () => { + const entry = getSibling(testbed.context, [4, 1], 'previous') + expect(entry?.node).toBe(testbed.row1) + expect(entry?.path).toEqual([4, 0]) + }) + + test('next sibling of span inside cell block', () => { + const entry = getSibling(testbed.context, [4, 0, 0, 0, 0], 'next') + expect(entry?.node).toBe(testbed.stockTicker2) + expect(entry?.path).toEqual([4, 0, 0, 0, 1]) + }) + + test('previous sibling of inline object inside cell block', () => { + const entry = getSibling(testbed.context, [4, 0, 0, 0, 1], 'previous') + expect(entry?.node).toBe(testbed.cellSpan1) + expect(entry?.path).toEqual([4, 0, 0, 0, 0]) + }) + + test('out of bounds path returns undefined', () => { + expect(getSibling(testbed.context, [99], 'next')).toBeUndefined() + expect(getSibling(testbed.context, [99], 'previous')).toBeUndefined() + }) + + test('next sibling of code line', () => { + const entry = getSibling(testbed.context, [3, 0], 'next') + expect(entry?.node).toBe(testbed.codeLine2) + expect(entry?.path).toEqual([3, 1]) + }) + + test('previous sibling of second code line', () => { + const entry = getSibling(testbed.context, [3, 1], 'previous') + expect(entry?.node).toBe(testbed.codeLine1) + expect(entry?.path).toEqual([3, 0]) + }) +}) diff --git a/packages/editor/src/node-traversal/get-sibling.ts b/packages/editor/src/node-traversal/get-sibling.ts new file mode 100644 index 000000000..a5e8f003c --- /dev/null +++ b/packages/editor/src/node-traversal/get-sibling.ts @@ -0,0 +1,37 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getChildren} from './get-children' + +/** + * Get the next or previous sibling of the node at a given path. + */ +export function getSibling( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, + direction: 'next' | 'previous', +): {node: Node; path: Array} | undefined { + if (path.length === 0) { + return undefined + } + + const parentPath = path.slice(0, -1) + const index = path.at(-1) + + if (index === undefined) { + return undefined + } + + const siblingIndex = direction === 'next' ? index + 1 : index - 1 + + if (siblingIndex < 0) { + return undefined + } + + const children = getChildren(context, parentPath) + + return children.at(siblingIndex) +} diff --git a/packages/editor/src/node-traversal/get-span-node.ts b/packages/editor/src/node-traversal/get-span-node.ts new file mode 100644 index 000000000..377a0fcf3 --- /dev/null +++ b/packages/editor/src/node-traversal/get-span-node.ts @@ -0,0 +1,28 @@ +import {isSpan, type PortableTextSpan} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' + +/** + * Get the span node at a given path. + */ +export function getSpanNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextSpan; path: Array} | undefined { + const entry = getNode(context, path) + + if (!entry) { + return undefined + } + + if (!isSpan({schema: context.schema}, entry.node)) { + return undefined + } + + return {node: entry.node, path: entry.path} +} diff --git a/packages/editor/src/node-traversal/get-text-block-node.ts b/packages/editor/src/node-traversal/get-text-block-node.ts new file mode 100644 index 000000000..c823953f3 --- /dev/null +++ b/packages/editor/src/node-traversal/get-text-block-node.ts @@ -0,0 +1,28 @@ +import {isTextBlock, type PortableTextTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' + +/** + * Get the text block node at a given path. + */ +export function getTextBlockNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextTextBlock; path: Array} | undefined { + const entry = getNode(context, path) + + if (!entry) { + return undefined + } + + if (!isTextBlock({schema: context.schema}, entry.node)) { + return undefined + } + + return {node: entry.node, path: entry.path} +} diff --git a/packages/editor/src/node-traversal/get-text.test.ts b/packages/editor/src/node-traversal/get-text.test.ts new file mode 100644 index 000000000..9b963a4d0 --- /dev/null +++ b/packages/editor/src/node-traversal/get-text.test.ts @@ -0,0 +1,87 @@ +import {describe, expect, test} from 'vitest' +import {getText} from './get-text' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(getText.name, () => { + const testbed = createNodeTraversalTestbed() + + test('text of a text block', () => { + expect(getText(testbed.context, [0])).toBe('hello world') + }) + + test('text of a span', () => { + expect(getText(testbed.context, [0, 0])).toBe('hello ') + }) + + test('text of second span', () => { + expect(getText(testbed.context, [0, 2])).toBe(' world') + }) + + test('text of an inline object', () => { + expect(getText(testbed.context, [0, 1])).toBe('') + }) + + test('text of a block object with no children', () => { + expect(getText(testbed.context, [1])).toBe('') + }) + + test('text of second text block', () => { + expect(getText(testbed.context, [2])).toBe('second block') + }) + + test('text of code block', () => { + expect(getText(testbed.context, [3])).toBe('const a = 1console.log(a)') + }) + + test('text of code line', () => { + expect(getText(testbed.context, [3, 0])).toBe('const a = 1') + }) + + test('text of code span', () => { + expect(getText(testbed.context, [3, 0, 0])).toBe('const a = 1') + }) + + test('text of cell block with inline object', () => { + expect(getText(testbed.context, [4, 0, 0, 0])).toBe('a ') + }) + + test('text of cell span', () => { + expect(getText(testbed.context, [4, 0, 0, 0, 0])).toBe('a ') + }) + + test('text of second cell block', () => { + expect(getText(testbed.context, [4, 0, 0, 1])).toBe('b') + }) + + test('text of cell block in second cell', () => { + expect(getText(testbed.context, [4, 0, 1, 0])).toBe('c') + }) + + test('text of empty block', () => { + expect(getText(testbed.context, [4, 1, 0, 0])).toBe('') + }) + + test('text of empty span', () => { + expect(getText(testbed.context, [4, 1, 0, 0, 0])).toBe('') + }) + + test('out of bounds returns undefined', () => { + expect(getText(testbed.context, [99])).toBeUndefined() + }) + + test('empty path returns undefined', () => { + expect(getText(testbed.context, [])).toBeUndefined() + }) + + test('text of table includes all cell text', () => { + expect(getText(testbed.context, [4])).toBe('a bc') + }) + + test('text of row includes all cell text', () => { + expect(getText(testbed.context, [4, 0])).toBe('a bc') + }) + + test('text of cell includes all block text', () => { + expect(getText(testbed.context, [4, 0, 0])).toBe('a b') + }) +}) diff --git a/packages/editor/src/node-traversal/get-text.ts b/packages/editor/src/node-traversal/get-text.ts new file mode 100644 index 000000000..ce222e8d4 --- /dev/null +++ b/packages/editor/src/node-traversal/get-text.ts @@ -0,0 +1,37 @@ +import {isSpan} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' +import {getNodes} from './get-nodes' + +/** + * Get the concatenated text content of the node at a given path. + */ +export function getText( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): string | undefined { + const entry = getNode(context, path) + + if (!entry) { + return undefined + } + + if (isSpan({schema: context.schema}, entry.node)) { + return entry.node.text + } + + let text = '' + + for (const descendant of getNodes(context, {at: path})) { + if (isSpan({schema: context.schema}, descendant.node)) { + text += descendant.node.text + } + } + + return text +} diff --git a/packages/editor/src/node-traversal/has-node.test.ts b/packages/editor/src/node-traversal/has-node.test.ts new file mode 100644 index 000000000..92f51267e --- /dev/null +++ b/packages/editor/src/node-traversal/has-node.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, test} from 'vitest' +import {hasNode} from './has-node' +import {createNodeTraversalTestbed} from './node-traversal-testbed' + +describe(hasNode.name, () => { + const testbed = createNodeTraversalTestbed() + + test('root level', () => { + expect(hasNode(testbed.context, [0])).toBe(true) + }) + + test('deep path', () => { + expect(hasNode(testbed.context, [4, 0, 0, 0, 0])).toBe(true) + }) + + test('out of bounds', () => { + expect(hasNode(testbed.context, [99])).toBe(false) + }) + + test('empty path', () => { + expect(hasNode(testbed.context, [])).toBe(false) + }) +}) diff --git a/packages/editor/src/node-traversal/has-node.ts b/packages/editor/src/node-traversal/has-node.ts new file mode 100644 index 000000000..1ebaf64db --- /dev/null +++ b/packages/editor/src/node-traversal/has-node.ts @@ -0,0 +1,17 @@ +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' + +/** + * Check if a node exists at a given path. + */ +export function hasNode( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): boolean { + return getNode(context, path) !== undefined +} diff --git a/packages/editor/src/node-traversal/is-block.ts b/packages/editor/src/node-traversal/is-block.ts new file mode 100644 index 000000000..a253c3c93 --- /dev/null +++ b/packages/editor/src/node-traversal/is-block.ts @@ -0,0 +1,65 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import {isSpan, isTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Node} from '../slate/interfaces/node' +import {getNode} from './get-node' +import {getParent} from './get-parent' + +/** + * Determine if a node at the given path is a block. + * + * A node is a block if its parent is not a text block. Top-level nodes + * (direct children of the editor) are always blocks. Children of text blocks + * (spans and inline objects) are not blocks. Children of containers are + * blocks within that container. + */ +export function isBlock( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): boolean { + const parent = getParent(context, path) + + if (!parent) { + return true + } + + return !isTextBlock({schema: context.schema}, parent.node) +} + +/** + * Get the node at the given path if it is a block. + * + * Returns the node narrowed to PortableTextBlock, or undefined if the node + * doesn't exist or is not a block. + */ +export function getBlock( + context: { + schema: EditorSchema + editableTypes: Set + value: Array + }, + path: Array, +): {node: PortableTextBlock; path: Array} | undefined { + const entry = getNode(context, path) + + if (!entry) { + return undefined + } + + if (!isBlock(context, path)) { + return undefined + } + + // Narrow the type: a block is never a span (spans always have a text block + // parent, so isBlock returns false for them). + if (isSpan({schema: context.schema}, entry.node)) { + return undefined + } + + // Node minus PortableTextSpan = PortableTextTextBlock | PortableTextObject = PortableTextBlock + return {node: entry.node, path: entry.path} +} diff --git a/packages/editor/src/node-traversal/node-traversal-testbed.ts b/packages/editor/src/node-traversal/node-traversal-testbed.ts new file mode 100644 index 000000000..2ac07190b --- /dev/null +++ b/packages/editor/src/node-traversal/node-traversal-testbed.ts @@ -0,0 +1,241 @@ +import {compileSchema, defineSchema} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' + +/** + * A comprehensive test fixture for node traversal tests. + * + * Structure: + * + * root + * ├── textBlock1 [0] + * │ ├── span1 "hello " [0, 0] + * │ ├── stockTicker1 (inline object) [0, 1] + * │ └── span2 " world" [0, 2] + * ├── image (block object, no children) [1] + * ├── textBlock2 [2] + * │ └── span3 "second block" [2, 0] + * ├── codeBlock (block object) [3] + * │ └── code + * │ ├── codeLine1 [3, 0] + * │ │ └── codeSpan1 [3, 0, 0] + * │ └── codeLine2 [3, 1] + * │ └── codeSpan2 [3, 1, 0] + * └── table (nested block object) [4] + * └── rows + * ├── row1 [4, 0] + * │ ├── cell1 [4, 0, 0] + * │ │ └── content + * │ │ ├── cellBlock1 [4, 0, 0, 0] + * │ │ │ ├── cellSpan1 "a " [4, 0, 0, 0, 0] + * │ │ │ └── stockTicker2 [4, 0, 0, 0, 1] + * │ │ └── cellBlock2 [4, 0, 0, 1] + * │ │ └── cellSpan2 "b" [4, 0, 0, 1, 0] + * │ └── cell2 [4, 0, 1] + * │ └── content + * │ └── cellBlock3 [4, 0, 1, 0] + * │ └── cellSpan3 "c" [4, 0, 1, 0, 0] + * └── row2 [4, 1] + * └── cell3 [4, 1, 0] + * └── content + * └── emptyBlock [4, 1, 0, 0] + * └── emptySpan "" [4, 1, 0, 0, 0] + */ +const allEditableTypes = new Set([ + 'code-block', + 'table', + 'table.row', + 'table.row.cell', +]) + +export function createNodeTraversalTestbed() { + const keyGenerator = createTestKeyGenerator() + + const span1 = {_key: keyGenerator(), _type: 'span', text: 'hello '} + const stockTicker1 = {_key: keyGenerator(), _type: 'stock-ticker'} + const span2 = {_key: keyGenerator(), _type: 'span', text: ' world'} + const textBlock1 = { + _key: keyGenerator(), + _type: 'block', + children: [span1, stockTicker1, span2], + } + + const image = {_key: keyGenerator(), _type: 'image'} + + const span3 = {_key: keyGenerator(), _type: 'span', text: 'second block'} + const textBlock2 = { + _key: keyGenerator(), + _type: 'block', + children: [span3], + } + + const codeSpan1 = {_key: keyGenerator(), _type: 'span', text: 'const a = 1'} + const codeLine1 = { + _key: keyGenerator(), + _type: 'block', + children: [codeSpan1], + } + const codeSpan2 = { + _key: keyGenerator(), + _type: 'span', + text: 'console.log(a)', + } + const codeLine2 = { + _key: keyGenerator(), + _type: 'block', + children: [codeSpan2], + } + const codeBlock = { + _key: keyGenerator(), + _type: 'code-block', + code: [codeLine1, codeLine2], + } + + const cellSpan1 = {_key: keyGenerator(), _type: 'span', text: 'a '} + const stockTicker2 = {_key: keyGenerator(), _type: 'stock-ticker'} + const cellBlock1 = { + _key: keyGenerator(), + _type: 'block', + children: [cellSpan1, stockTicker2], + } + const cellSpan2 = {_key: keyGenerator(), _type: 'span', text: 'b'} + const cellBlock2 = { + _key: keyGenerator(), + _type: 'block', + children: [cellSpan2], + } + const cell1 = { + _key: keyGenerator(), + _type: 'cell', + content: [cellBlock1, cellBlock2], + } + + const cellSpan3 = {_key: keyGenerator(), _type: 'span', text: 'c'} + const cellBlock3 = { + _key: keyGenerator(), + _type: 'block', + children: [cellSpan3], + } + const cell2 = { + _key: keyGenerator(), + _type: 'cell', + content: [cellBlock3], + } + + const row1 = { + _key: keyGenerator(), + _type: 'row', + cells: [cell1, cell2], + } + + const emptySpan = {_key: keyGenerator(), _type: 'span', text: ''} + const emptyBlock = { + _key: keyGenerator(), + _type: 'block', + children: [emptySpan], + } + const cell3 = { + _key: keyGenerator(), + _type: 'cell', + content: [emptyBlock], + } + const row2 = { + _key: keyGenerator(), + _type: 'row', + cells: [cell3], + } + + const table = { + _key: keyGenerator(), + _type: 'table', + rows: [row1, row2], + } + + const schema = compileSchema( + defineSchema({ + inlineObjects: [{name: 'stock-ticker'}], + blockObjects: [ + {name: 'image'}, + { + name: 'code-block', + fields: [ + { + name: 'code', + type: 'array', + of: [{type: 'block'}], + }, + ], + }, + { + name: 'table', + fields: [ + { + name: 'rows', + type: 'array', + of: [ + { + type: 'row', + fields: [ + { + name: 'cells', + type: 'array', + of: [ + { + type: 'cell', + fields: [ + { + name: 'content', + type: 'array', + of: [{type: 'block'}], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }), + ) + + const context = { + schema, + editableTypes: allEditableTypes, + value: [textBlock1, image, textBlock2, codeBlock, table], + } + + return { + schema, + context, + textBlock1, + span1, + stockTicker1, + span2, + image, + textBlock2, + span3, + codeBlock, + codeLine1, + codeSpan1, + codeLine2, + codeSpan2, + table, + row1, + cell1, + cellBlock1, + cellSpan1, + stockTicker2, + cellBlock2, + cellSpan2, + cell2, + cellBlock3, + cellSpan3, + row2, + cell3, + emptyBlock, + emptySpan, + } +} diff --git a/packages/editor/src/operations/operation.annotation.add.ts b/packages/editor/src/operations/operation.annotation.add.ts index 6ebab336a..e43b3f390 100644 --- a/packages/editor/src/operations/operation.annotation.add.ts +++ b/packages/editor/src/operations/operation.annotation.add.ts @@ -4,20 +4,22 @@ import {applySetNode} from '../internal-utils/apply-set-node' import {applySplitNode} from '../internal-utils/apply-split-node' import {safeStringify} from '../internal-utils/safe-json' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getChildren} from '../node-traversal/get-children' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' import {isEdge} from '../slate/editor/is-edge' import {isEnd} from '../slate/editor/is-end' import {isStart} from '../slate/editor/is-start' -import {leaf} from '../slate/editor/leaf' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' import {rangeRef} from '../slate/editor/range-ref' import {extractProps} from '../slate/node/extract-props' -import {getChildren} from '../slate/node/get-children' +import {isObjectNode} from '../slate/node/is-object-node' import {isBackwardRange} from '../slate/range/is-backward-range' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import {isRange} from '../slate/range/is-range' import {rangeEdges} from '../slate/range/range-edges' +import {rangeEnd} from '../slate/range/range-end' import {rangeIncludes} from '../slate/range/range-includes' +import {rangeStart} from '../slate/range/range-start' import {parseAnnotation} from '../utils/parse-blocks' import type {OperationImplementation} from './operation.types' @@ -62,15 +64,22 @@ export const addAnnotationOperationImplementation: OperationImplementation< // Track the range across mutations when `at` is explicitly provided const ref = at ? rangeRef(editor, at, {affinity: 'inward'}) : null - const selectedBlocks = nodes(editor, { - at: effectiveSelection, - match: (node) => isTextBlock({schema: editor.schema}, node), - reverse: isBackwardRange(effectiveSelection), - }) + const selectedBlocks = Array.from( + getNodes(editor, { + from: rangeStart(effectiveSelection).path, + to: rangeEnd(effectiveSelection).path, + match: (node) => isTextBlock({schema: editor.schema}, node), + reverse: isBackwardRange(effectiveSelection), + }), + ) let blockIndex = 0 - for (const [block, blockPath] of selectedBlocks) { + for (const {node: block, path: blockPath} of selectedBlocks) { + if (!isTextBlock({schema: editor.schema}, block)) { + continue + } + if (block.children.length === 0) { continue } @@ -107,9 +116,16 @@ export const addAnnotationOperationImplementation: OperationImplementation< // Split text nodes at range boundaries const splitRange = at ?? editor.selection if (splitRange && isRange(splitRange)) { - const [splitLeaf] = leaf(editor, splitRange.anchor) + const splitLeafNodeEntry = getNode(editor, splitRange.anchor.path) + const splitLeaf = + splitLeafNodeEntry && + (isSpan({schema: editor.schema}, splitLeafNodeEntry.node) || + isObjectNode({schema: editor.schema}, splitLeafNodeEntry.node)) + ? splitLeafNodeEntry.node + : undefined if ( !( + splitLeaf && isCollapsedRange(splitRange) && isSpan({schema: editor.schema}, splitLeaf) && splitLeaf.text.length > 0 @@ -121,23 +137,29 @@ export const addAnnotationOperationImplementation: OperationImplementation< const [splitStart, splitEnd] = rangeEdges(splitRange) const endAtEnd = isEnd(editor, splitEnd, splitEnd.path) if (!endAtEnd || !isEdge(editor, splitEnd, splitEnd.path)) { - const [endNode] = editorNode(editor, splitEnd.path) - applySplitNode( - editor, - splitEnd.path, - splitEnd.offset, - extractProps(endNode, editor.schema), - ) + const endNodeEntry = getNode(editor, splitEnd.path) + if (endNodeEntry) { + const endNode = endNodeEntry.node + applySplitNode( + editor, + splitEnd.path, + splitEnd.offset, + extractProps(endNode, editor.schema), + ) + } } const startAtStart = isStart(editor, splitStart, splitStart.path) if (!startAtStart || !isEdge(editor, splitStart, splitStart.path)) { - const [startNode] = editorNode(editor, splitStart.path) - applySplitNode( - editor, - splitStart.path, - splitStart.offset, - extractProps(startNode, editor.schema), - ) + const startNodeEntry = getNode(editor, splitStart.path) + if (startNodeEntry) { + const startNode = startNodeEntry.node + applySplitNode( + editor, + splitStart.path, + splitStart.offset, + extractProps(startNode, editor.schema), + ) + } } // Update selection if using editor.selection (not explicit `at`) const updatedSplitRange = splitRangeRef.unref() @@ -147,23 +169,23 @@ export const addAnnotationOperationImplementation: OperationImplementation< } } - const children = getChildren(editor, blockPath, editor.schema) + const children = getChildren(editor, blockPath) // Use the tracked range (updated after splits) or fall back to editor.selection const selectionRange = ref?.current ?? editor.selection - for (const [span, path] of children) { + for (const {node: span, path: spanPath} of children) { if (!isSpan({schema: editor.schema}, span)) { continue } - if (!selectionRange || !rangeIncludes(selectionRange, path)) { + if (!selectionRange || !rangeIncludes(selectionRange, spanPath)) { continue } const marks = span.marks ?? [] - applySetNode(editor, {marks: [...marks, annotationKey]}, path) + applySetNode(editor, {marks: [...marks, annotationKey]}, spanPath) } blockIndex++ diff --git a/packages/editor/src/operations/operation.annotation.remove.ts b/packages/editor/src/operations/operation.annotation.remove.ts index d08eaa7b2..3e029ce22 100644 --- a/packages/editor/src/operations/operation.annotation.remove.ts +++ b/packages/editor/src/operations/operation.annotation.remove.ts @@ -4,22 +4,25 @@ import {applySelect} from '../internal-utils/apply-selection' import {applySetNode} from '../internal-utils/apply-set-node' import {applySplitNode} from '../internal-utils/apply-split-node' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getChildren} from '../node-traversal/get-children' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' import {isEdge} from '../slate/editor/is-edge' import {isEnd} from '../slate/editor/is-end' import {isStart} from '../slate/editor/is-start' -import {leaf} from '../slate/editor/leaf' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' +import {path as editorPath} from '../slate/editor/path' import {rangeRef} from '../slate/editor/range-ref' import type {Path} from '../slate/interfaces/path' import {extractProps} from '../slate/node/extract-props' -import {getChildren} from '../slate/node/get-children' +import {isObjectNode} from '../slate/node/is-object-node' import {isAfterPath} from '../slate/path/is-after-path' import {isBeforePath} from '../slate/path/is-before-path' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import {isRange} from '../slate/range/is-range' import {rangeEdges} from '../slate/range/range-edges' +import {rangeEnd} from '../slate/range/range-end' import {rangeIncludes} from '../slate/range/range-includes' +import {rangeStart} from '../slate/range/range-start' import type {OperationImplementation} from './operation.types' export const removeAnnotationOperationImplementation: OperationImplementation< @@ -45,9 +48,16 @@ export const removeAnnotationOperationImplementation: OperationImplementation< } if (isCollapsedRange(effectiveSelection)) { - const [block, blockPath] = editorNode(editor, effectiveSelection, { - depth: 1, - }) + const blockEntry = getNode( + editor, + editorPath(editor, effectiveSelection, {depth: 1}), + ) + + if (!blockEntry) { + return + } + + const {node: block, path: blockPath} = blockEntry if (!isTextBlock({schema: editor.schema}, block)) { return @@ -58,14 +68,17 @@ export const removeAnnotationOperationImplementation: OperationImplementation< (markDef) => markDef._type === operation.annotation.name, ) - const [selectedChild, selectedChildPath] = editorNode( + const selectedChildEntry = getNode( editor, - effectiveSelection, - { - depth: 2, - }, + editorPath(editor, effectiveSelection, {depth: 2}), ) + if (!selectedChildEntry) { + return + } + + const {node: selectedChild, path: selectedChildPath} = selectedChildEntry + if (!isSpan({schema: editor.schema}, selectedChild)) { return } @@ -82,14 +95,14 @@ export const removeAnnotationOperationImplementation: OperationImplementation< [span: PortableTextSpan, path: Path] > = [] - for (const [child, childPath] of getChildren( - editor, - blockPath, - editor.schema, - { - reverse: true, - }, - )) { + const reversedChildren = getChildren(editor, blockPath) + + for (let index = reversedChildren.length - 1; index >= 0; index--) { + const entry = reversedChildren[index] + if (!entry) { + continue + } + const {node: child, path: childPath} = entry if (!isSpan({schema: editor.schema}, child)) { continue } @@ -109,10 +122,9 @@ export const removeAnnotationOperationImplementation: OperationImplementation< [span: PortableTextSpan, path: Path] > = [] - for (const [child, childPath] of getChildren( + for (const {node: child, path: childPath} of getChildren( editor, blockPath, - editor.schema, )) { if (!isSpan({schema: editor.schema}, child)) { continue @@ -131,7 +143,7 @@ export const removeAnnotationOperationImplementation: OperationImplementation< for (const [child, childPath] of [ ...previousSpansWithSameAnnotation, - [selectedChild, selectedChildPath] as const, + [selectedChild, selectedChildPath] satisfies [PortableTextSpan, Path], ...nextSpansWithSameAnnotation, ]) { applySetNode( @@ -149,9 +161,16 @@ export const removeAnnotationOperationImplementation: OperationImplementation< // Split text nodes at range boundaries const splitRange = at ?? editor.selection if (splitRange && isRange(splitRange)) { - const [splitLeaf] = leaf(editor, splitRange.anchor) + const splitLeafNodeEntry = getNode(editor, splitRange.anchor.path) + const splitLeaf = + splitLeafNodeEntry && + (isSpan({schema: editor.schema}, splitLeafNodeEntry.node) || + isObjectNode({schema: editor.schema}, splitLeafNodeEntry.node)) + ? splitLeafNodeEntry.node + : undefined if ( !( + splitLeaf && isCollapsedRange(splitRange) && isSpan({schema: editor.schema}, splitLeaf) && splitLeaf.text.length > 0 @@ -163,23 +182,27 @@ export const removeAnnotationOperationImplementation: OperationImplementation< const [splitStart, splitEnd] = rangeEdges(splitRange) const endAtEnd = isEnd(editor, splitEnd, splitEnd.path) if (!endAtEnd || !isEdge(editor, splitEnd, splitEnd.path)) { - const [endNode] = editorNode(editor, splitEnd.path) - applySplitNode( - editor, - splitEnd.path, - splitEnd.offset, - extractProps(endNode, editor.schema), - ) + const endNodeEntry = getNode(editor, splitEnd.path) + if (endNodeEntry) { + applySplitNode( + editor, + splitEnd.path, + splitEnd.offset, + extractProps(endNodeEntry.node, editor.schema), + ) + } } const startAtStart = isStart(editor, splitStart, splitStart.path) if (!startAtStart || !isEdge(editor, splitStart, splitStart.path)) { - const [startNode] = editorNode(editor, splitStart.path) - applySplitNode( - editor, - splitStart.path, - splitStart.offset, - extractProps(startNode, editor.schema), - ) + const startNodeEntry = getNode(editor, splitStart.path) + if (startNodeEntry) { + applySplitNode( + editor, + splitStart.path, + splitStart.offset, + extractProps(startNodeEntry.node, editor.schema), + ) + } } // Update selection if using editor.selection (not explicit `at`) const updatedSplitRange = splitRangeRef.unref() @@ -189,18 +212,25 @@ export const removeAnnotationOperationImplementation: OperationImplementation< } } - const blocks = nodes(editor, { - at: effectiveSelection, - match: (node) => isTextBlock({schema: editor.schema}, node), - }) + const blocks = Array.from( + getNodes(editor, { + from: rangeStart(effectiveSelection).path, + to: rangeEnd(effectiveSelection).path, + match: (node) => isTextBlock({schema: editor.schema}, node), + }), + ) // Use the tracked range (updated after splits) or fall back to editor.selection const selectionRange = ref?.current ?? editor.selection - for (const [block, blockPath] of blocks) { - const children = getChildren(editor, blockPath, editor.schema) + for (const {node: block, path: blockPath} of blocks) { + if (!isTextBlock({schema: editor.schema}, block)) { + continue + } + + const children = getChildren(editor, blockPath) - for (const [child, childPath] of children) { + for (const {node: child, path: childPath} of children) { if (!isSpan({schema: editor.schema}, child)) { continue } diff --git a/packages/editor/src/operations/operation.child.set.ts b/packages/editor/src/operations/operation.child.set.ts index cdef24a59..e8bea9af8 100644 --- a/packages/editor/src/operations/operation.child.set.ts +++ b/packages/editor/src/operations/operation.child.set.ts @@ -2,7 +2,8 @@ import {isSpan} from '@portabletext/schema' import {applySetNode} from '../internal-utils/apply-set-node' import {safeStringify} from '../internal-utils/safe-json' import {toSlateRange} from '../internal-utils/to-slate-range' -import {node as editorNode} from '../slate/editor/node' +import {getNode} from '../node-traversal/get-node' +import {path as editorPath} from '../slate/editor/path' import {isObjectNode} from '../slate/node/is-object-node' import type {OperationImplementation} from './operation.types' @@ -27,14 +28,15 @@ export const childSetOperationImplementation: OperationImplementation< ) } - const childEntry = editorNode(operation.editor, location, {depth: 2}) - const child = childEntry?.[0] - const childPath = childEntry?.[1] + const resolvedPath = editorPath(operation.editor, location, {depth: 2}) + const childEntry = getNode(operation.editor, resolvedPath) - if (!child || !childPath) { + if (!childEntry) { throw new Error(`Unable to find child at ${safeStringify(operation.at)}`) } + const {node: child, path: childPath} = childEntry + if (isSpan({schema: operation.editor.schema}, child)) { const {_type, text, ...rest} = operation.props diff --git a/packages/editor/src/operations/operation.child.unset.ts b/packages/editor/src/operations/operation.child.unset.ts index b0e0f887a..59549e185 100644 --- a/packages/editor/src/operations/operation.child.unset.ts +++ b/packages/editor/src/operations/operation.child.unset.ts @@ -1,7 +1,7 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import {applySetNode} from '../internal-utils/apply-set-node' import {safeStringify} from '../internal-utils/safe-json' -import {node as editorNode} from '../slate/editor/node' +import {getNode} from '../node-traversal/get-node' import {isObjectNode} from '../slate/node/is-object-node' import type {OperationImplementation} from './operation.types' @@ -44,16 +44,14 @@ export const childUnsetOperationImplementation: OperationImplementation< throw new Error(`Unable to find child at ${safeStringify(operation.at)}`) } - const childEntry = editorNode(operation.editor, [blockIndex, childIndex], { - depth: 2, - }) - const child = childEntry?.[0] - const childPath = childEntry?.[1] + const childEntry = getNode(operation.editor, [blockIndex, childIndex]) - if (!child || !childPath) { + if (!childEntry) { throw new Error(`Unable to find child at ${safeStringify(operation.at)}`) } + const {node: child, path: childPath} = childEntry + if (isSpan({schema: operation.editor.schema}, child)) { const newNode: Record = {} diff --git a/packages/editor/src/operations/operation.decorator.add.ts b/packages/editor/src/operations/operation.decorator.add.ts index 12663d0bf..a0f1d7d54 100644 --- a/packages/editor/src/operations/operation.decorator.add.ts +++ b/packages/editor/src/operations/operation.decorator.add.ts @@ -3,15 +3,18 @@ import {applySelect} from '../internal-utils/apply-selection' import {applySetNode} from '../internal-utils/apply-set-node' import {applySplitNode} from '../internal-utils/apply-split-node' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' import {isEdge} from '../slate/editor/is-edge' import {isEnd} from '../slate/editor/is-end' import {isStart} from '../slate/editor/is-start' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' +import {path as editorPath} from '../slate/editor/path' import {rangeRef} from '../slate/editor/range-ref' import {extractProps} from '../slate/node/extract-props' import {isExpandedRange} from '../slate/range/is-expanded-range' import {rangeEdges} from '../slate/range/range-edges' +import {rangeEnd} from '../slate/range/range-end' +import {rangeStart} from '../slate/range/range-start' import type {OperationImplementation} from './operation.types' export const decoratorAddOperationImplementation: OperationImplementation< @@ -43,25 +46,31 @@ export const decoratorAddOperationImplementation: OperationImplementation< const endAtEndOfNode = isEnd(editor, end, end.path) if (!endAtEndOfNode || !isEdge(editor, end, end.path)) { - const [endNode] = editorNode(editor, end.path) - applySplitNode( - editor, - end.path, - end.offset, - extractProps(endNode, editor.schema), - ) + const endNodeEntry = getNode(editor, end.path) + if (endNodeEntry) { + const endNode = endNodeEntry.node + applySplitNode( + editor, + end.path, + end.offset, + extractProps(endNode, editor.schema), + ) + } } const startAtStartOfNode = isStart(editor, start, start.path) if (!startAtStartOfNode || !isEdge(editor, start, start.path)) { - const [startNode] = editorNode(editor, start.path) - applySplitNode( - editor, - start.path, - start.offset, - extractProps(startNode, editor.schema), - ) + const startNodeEntry = getNode(editor, start.path) + if (startNodeEntry) { + const startNode = startNodeEntry.node + applySplitNode( + editor, + start.path, + start.offset, + extractProps(startNode, editor.schema), + ) + } } at = ref.unref() @@ -75,24 +84,32 @@ export const decoratorAddOperationImplementation: OperationImplementation< } // Use new selection to find nodes to decorate - const splitTextNodes = nodes(editor, { - at, - match: (n) => isSpan({schema: editor.schema}, n), - }) + const splitTextNodes = Array.from( + getNodes(editor, { + from: rangeStart(at).path, + to: rangeEnd(at).path, + match: (n) => isSpan({schema: editor.schema}, n), + }), + ) + + for (const {node, path: spanPath} of splitTextNodes) { + if (!isSpan({schema: editor.schema}, node)) { + continue + } - for (const [node, path] of splitTextNodes) { const marks = [ ...(Array.isArray(node.marks) ? node.marks : []).filter( (eMark: string) => eMark !== mark, ), mark, ] - applySetNode(editor, {marks}, path) + applySetNode(editor, {marks}, spanPath) } } else { const selectedSpan = Array.from( - nodes(editor, { - at, + getNodes(editor, { + from: rangeStart(at).path, + to: rangeEnd(at).path, match: (node) => isSpan({schema: editor.schema}, node), }), )?.at(0) @@ -101,9 +118,11 @@ export const decoratorAddOperationImplementation: OperationImplementation< return } - const [block, blockPath] = editorNode(editor, at, { - depth: 1, - }) + const blockEntry = getNode(editor, editorPath(editor, at, {depth: 1})) + if (!blockEntry) { + return + } + const {node: block, path: blockPath} = blockEntry const lonelyEmptySpan = isTextBlock({schema: editor.schema}, block) && block.children.length === 1 && @@ -122,10 +141,12 @@ export const decoratorAddOperationImplementation: OperationImplementation< existingMarks.length === existingMarksWithoutDecorator.length ? [...existingMarks, mark] : existingMarksWithoutDecorator - for (const [, spanPath] of nodes(editor, { - at: blockPath, - match: (node) => isSpan({schema: editor.schema}, node), - })) { + for (const {path: spanPath} of Array.from( + getNodes(editor, { + at: blockPath, + match: (node) => isSpan({schema: editor.schema}, node), + }), + )) { applySetNode(editor, {marks: newMarks}, spanPath) } } else { diff --git a/packages/editor/src/operations/operation.decorator.remove.ts b/packages/editor/src/operations/operation.decorator.remove.ts index ddff7d19c..c893495cf 100644 --- a/packages/editor/src/operations/operation.decorator.remove.ts +++ b/packages/editor/src/operations/operation.decorator.remove.ts @@ -2,17 +2,20 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import {applySetNode} from '../internal-utils/apply-set-node' import {applySplitNode} from '../internal-utils/apply-split-node' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' import {isEdge} from '../slate/editor/is-edge' import {isEnd} from '../slate/editor/is-end' import {isStart} from '../slate/editor/is-start' -import {leaf} from '../slate/editor/leaf' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' +import {path as editorPath} from '../slate/editor/path' import {rangeRef} from '../slate/editor/range-ref' import {extractProps} from '../slate/node/extract-props' +import {isObjectNode} from '../slate/node/is-object-node' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import {isExpandedRange} from '../slate/range/is-expanded-range' import {rangeEdges} from '../slate/range/range-edges' +import {rangeEnd} from '../slate/range/range-end' +import {rangeStart} from '../slate/range/range-start' import type {OperationImplementation} from './operation.types' export const decoratorRemoveOperationImplementation: OperationImplementation< @@ -40,9 +43,16 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< const ref = rangeRef(editor, at, {affinity: 'inward'}) // Split text nodes at range boundaries (equivalent to setNodes with split:true and empty props) - const [decoratorLeaf] = leaf(editor, at.anchor) + const decoratorLeafNodeEntry = getNode(editor, at.anchor.path) + const decoratorLeaf = + decoratorLeafNodeEntry && + (isSpan({schema: editor.schema}, decoratorLeafNodeEntry.node) || + isObjectNode({schema: editor.schema}, decoratorLeafNodeEntry.node)) + ? decoratorLeafNodeEntry.node + : undefined if ( !( + decoratorLeaf && isCollapsedRange(at) && isSpan({schema: editor.schema}, decoratorLeaf) && decoratorLeaf.text.length > 0 @@ -51,23 +61,29 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< const [start, end] = rangeEdges(at) const endAtEndOfNode = isEnd(editor, end, end.path) if (!endAtEndOfNode || !isEdge(editor, end, end.path)) { - const [endNode] = editorNode(editor, end.path) - applySplitNode( - editor, - end.path, - end.offset, - extractProps(endNode, editor.schema), - ) + const endNodeEntry = getNode(editor, end.path) + if (endNodeEntry) { + const endNode = endNodeEntry.node + applySplitNode( + editor, + end.path, + end.offset, + extractProps(endNode, editor.schema), + ) + } } const startAtStartOfNode = isStart(editor, start, start.path) if (!startAtStartOfNode || !isEdge(editor, start, start.path)) { - const [startNode] = editorNode(editor, start.path) - applySplitNode( - editor, - start.path, - start.offset, - extractProps(startNode, editor.schema), - ) + const startNodeEntry = getNode(editor, start.path) + if (startNodeEntry) { + const startNode = startNodeEntry.node + applySplitNode( + editor, + start.path, + start.offset, + extractProps(startNode, editor.schema), + ) + } } } @@ -75,13 +91,18 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< if (updatedAt) { const splitTextNodes = [ - ...nodes(editor, { - at: updatedAt, + ...getNodes(editor, { + from: rangeStart(updatedAt).path, + to: rangeEnd(updatedAt).path, match: (n) => isSpan({schema: editor.schema}, n), }), ] - splitTextNodes.forEach(([node, path]) => { - const block = editor.children[path[0]!] + for (const {node, path: nodePath} of splitTextNodes) { + if (!isSpan({schema: editor.schema}, node)) { + continue + } + + const block = editor.children[nodePath[0]!] if ( isTextBlock({schema: editor.schema}, block) && block.children.includes(node) @@ -94,15 +115,17 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< ), _type: context.schema.span.name, }, - path, + nodePath, ) } - }) + } } } else { - const [block, blockPath] = editorNode(editor, at, { - depth: 1, - }) + const blockEntry = getNode(editor, editorPath(editor, at, {depth: 1})) + if (!blockEntry) { + return + } + const {node: block, path: blockPath} = blockEntry const lonelyEmptySpan = isTextBlock({schema: editor.schema}, block) && block.children.length === 1 && @@ -117,10 +140,12 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< (existingMark) => existingMark !== mark, ) - for (const [, spanPath] of nodes(editor, { - at: blockPath, - match: (node) => isSpan({schema: editor.schema}, node), - })) { + for (const {path: spanPath} of Array.from( + getNodes(editor, { + at: blockPath, + match: (node) => isSpan({schema: editor.schema}, node), + }), + )) { applySetNode(editor, {marks: existingMarksWithoutDecorator}, spanPath) } } else { diff --git a/packages/editor/src/operations/operation.delete.ts b/packages/editor/src/operations/operation.delete.ts index 5e4c7da77..73afa9610 100644 --- a/packages/editor/src/operations/operation.delete.ts +++ b/packages/editor/src/operations/operation.delete.ts @@ -1,25 +1,28 @@ -import {isSpan, isTextBlock} from '@portabletext/schema' +import {isTextBlock} from '@portabletext/schema' import {toSlateRange} from '../internal-utils/to-slate-range' +import {getAncestorTextBlock} from '../node-traversal/get-ancestor-text-block' +import {getHighestObjectNode} from '../node-traversal/get-highest-object-node' +import {getNode} from '../node-traversal/get-node' +import {getNodes} from '../node-traversal/get-nodes' +import {getParent} from '../node-traversal/get-parent' +import {getSpanNode} from '../node-traversal/get-span-node' +import {isBlock} from '../node-traversal/is-block' import {deleteText} from '../slate/core/delete-text' import {DOMEditor} from '../slate/dom/plugin/dom-editor' -import {above} from '../slate/editor/above' -import {elementReadOnly} from '../slate/editor/element-read-only' import {end as editorEnd} from '../slate/editor/end' -import {getObjectNode} from '../slate/editor/get-object-node' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' import {pathRef} from '../slate/editor/path-ref' import {pointRef} from '../slate/editor/point-ref' import {positions as editorPositions} from '../slate/editor/positions' import {range as editorRange} from '../slate/editor/range' import {start as editorStart} from '../slate/editor/start' import type {Editor} from '../slate/interfaces/editor' -import type {NodeEntry} from '../slate/interfaces/node' +import type {Node} from '../slate/interfaces/node' import type {Path} from '../slate/interfaces/path' import type {Range} from '../slate/interfaces/range' -import {getNode} from '../slate/node/get-node' import {isObjectNode} from '../slate/node/is-object-node' +import {commonPath} from '../slate/path/common-path' import {comparePaths} from '../slate/path/compare-paths' +import {isAncestorPath} from '../slate/path/is-ancestor-path' import {isCommonPath} from '../slate/path/is-common-path' import {pathEquals} from '../slate/path/path-equals' import {pointEquals} from '../slate/point/point-equals' @@ -61,22 +64,36 @@ export const deleteOperationImplementation: OperationImplementation< anchor: {path: [startBlockIndex], offset: 0}, focus: {path: [endBlockIndex], offset: 0}, } - const blockMatches = nodes(operation.editor, { - at: removeRange, - match: (n) => - isTextBlock({schema: operation.editor.schema}, n) || - (isObjectNode({schema: operation.editor.schema}, n) && - !operation.editor.isInline(n)), - mode: 'highest', - }) - const blockPathRefs = Array.from(blockMatches, ([, p]) => - pathRef(operation.editor, p), + const removeRangeEdges = rangeEdges(removeRange) + const blockMatches: Array<{node: Node; path: Array}> = [] + let lastHighestPath: Path | undefined + + for (const entry of getNodes(operation.editor, { + from: removeRangeEdges[0].path, + to: removeRangeEdges[1].path, + })) { + const {path: entryPath} = entry + + if (lastHighestPath && isAncestorPath(lastHighestPath, entryPath)) { + continue + } + + if (isBlock(operation.editor, entryPath)) { + lastHighestPath = entryPath + blockMatches.push(entry) + } + } + const blockPathRefs = Array.from(blockMatches, (entry) => + pathRef(operation.editor, entry.path), ) for (const pathRef of blockPathRefs) { const path = pathRef.unref()! if (path) { - const [node] = editorNode(operation.editor, path) - operation.editor.apply({type: 'remove_node', path, node}) + const nodeEntry = getNode(operation.editor, path) + if (nodeEntry) { + const node = nodeEntry.node + operation.editor.apply({type: 'remove_node', path, node}) + } } } @@ -84,24 +101,22 @@ export const deleteOperationImplementation: OperationImplementation< } if (operation.unit === 'child') { - const childMatches = nodes(operation.editor, { - at, - match: (node, path) => - isSpan(context, node) || - (isTextBlock({schema: operation.editor.schema}, node) && - operation.editor.isInline(node)) || - // TODO: Update depth check when containers land (path.length > 1 assumes flat structure) - (isObjectNode({schema: operation.editor.schema}, node) && - path.length > 1), + const childMatches = getNodes(operation.editor, { + from: start.path, + to: end.path, + match: (_node, path) => !isBlock(operation.editor, path), }) - const childPathRefs = Array.from(childMatches, ([, p]) => - pathRef(operation.editor, p), + const childPathRefs = Array.from(childMatches, (entry) => + pathRef(operation.editor, entry.path), ) for (const pathRef of childPathRefs) { const path = pathRef.unref()! if (path) { - const [node] = editorNode(operation.editor, path) - operation.editor.apply({type: 'remove_node', path, node}) + const nodeEntry2 = getNode(operation.editor, path) + if (nodeEntry2) { + const node = nodeEntry2.node + operation.editor.apply({type: 'remove_node', path, node}) + } } } @@ -109,13 +124,22 @@ export const deleteOperationImplementation: OperationImplementation< } if (operation.direction === 'backward' && operation.unit === 'line') { - const parentBlockEntry = above(operation.editor, { - match: (n) => isTextBlock({schema: operation.editor.schema}, n), - at, - }) + const parentBlockEntry = pathEquals(at.anchor.path, at.focus.path) + ? getAncestorTextBlock(operation.editor, at.anchor.path) + : (() => { + const fromPath = commonPath(at.anchor.path, at.focus.path) + const nodeEntry = getNode(operation.editor, fromPath) + if ( + nodeEntry && + isTextBlock({schema: operation.editor.schema}, nodeEntry.node) + ) { + return nodeEntry + } + return getAncestorTextBlock(operation.editor, fromPath) + })() if (parentBlockEntry) { - const [, parentBlockPath] = parentBlockEntry + const parentBlockPath = parentBlockEntry.path const parentElementRange = editorRange( operation.editor, parentBlockPath, @@ -147,123 +171,94 @@ export const deleteOperationImplementation: OperationImplementation< } if (isCollapsedRange(at) && start.path.length >= 2) { - try { - const node = getNode( - operation.editor, - start.path, - operation.editor.schema, - ) + const nodeEntry = getNode(operation.editor, start.path) + if (nodeEntry) { + const {node: node, path: nodePath} = nodeEntry if (isObjectNode({schema: operation.editor.schema}, node)) { operation.editor.apply({ type: 'remove_node', - path: start.path, + path: nodePath, node, }) return } - } catch { - // Fall through to normal handling } } // Editor.above only checks ancestors, so for top-level ObjectNodes // we also check the node at the point directly. - const startNodeEntry: NodeEntry | undefined = (() => { + const startNodeEntry: {node: Node; path: Array} | undefined = (() => { const blockIndex = start.path.at(0) if (blockIndex !== undefined) { - const node = getNode( - operation.editor, - [blockIndex], - operation.editor.schema, - ) - if (isObjectNode({schema: operation.editor.schema}, node)) { - return [node, [blockIndex]] + const entry = getNode(operation.editor, [blockIndex]) + if ( + entry && + isObjectNode({schema: operation.editor.schema}, entry.node) + ) { + return entry } } return undefined })() - const endNodeEntry: NodeEntry | undefined = (() => { + const endNodeEntry: {node: Node; path: Array} | undefined = (() => { const blockIndex = end.path.at(0) if (blockIndex !== undefined) { - const node = getNode( - operation.editor, - [blockIndex], - operation.editor.schema, - ) - if (isObjectNode({schema: operation.editor.schema}, node)) { - return [node, [blockIndex]] + const entry = getNode(operation.editor, [blockIndex]) + if ( + entry && + isObjectNode({schema: operation.editor.schema}, entry.node) + ) { + return entry } } return undefined })() - const startBlock = - startNodeEntry ?? - above(operation.editor, { - match: (n) => - isTextBlock({schema: operation.editor.schema}, n) || - (isObjectNode({schema: operation.editor.schema}, n) && - !operation.editor.isInline(n)), - at: start, - includeObjectNodes: false, - }) - const endBlock = - endNodeEntry ?? - above(operation.editor, { - match: (n) => - isTextBlock({schema: operation.editor.schema}, n) || - (isObjectNode({schema: operation.editor.schema}, n) && - !operation.editor.isInline(n)), - at: end, - includeObjectNodes: false, - }) + const startBlock = startNodeEntry ?? getParent(operation.editor, start.path) + const endBlock = endNodeEntry ?? getParent(operation.editor, end.path) const isAcrossBlocks = - startBlock && endBlock && !pathEquals(startBlock[1], endBlock[1]) + startBlock && endBlock && !pathEquals(startBlock.path, endBlock.path) const startObjectNode = - startBlock && isObjectNode({schema: operation.editor.schema}, startBlock[0]) + startBlock && + isObjectNode({schema: operation.editor.schema}, startBlock.node) ? startBlock : undefined const endObjectNode = - endBlock && isObjectNode({schema: operation.editor.schema}, endBlock[0]) + endBlock && isObjectNode({schema: operation.editor.schema}, endBlock.node) ? endBlock : undefined const startNonEditable = - startObjectNode ?? - getObjectNode(operation.editor, {at: start, mode: 'highest'}) ?? - elementReadOnly(operation.editor, {at: start, mode: 'highest'}) + startObjectNode ?? getHighestObjectNode(operation.editor, start.path) const endNonEditable = - endObjectNode ?? - getObjectNode(operation.editor, {at: end, mode: 'highest'}) ?? - elementReadOnly(operation.editor, {at: end, mode: 'highest'}) + endObjectNode ?? getHighestObjectNode(operation.editor, end.path) - const matches: NodeEntry[] = [] + const matches: Array<{node: Node; path: Array}> = [] let lastPath: Path | undefined - for (const entry of nodes(operation.editor, { - at, - includeObjectNodes: false, + for (const entry of getNodes(operation.editor, { + from: start.path, + to: end.path, })) { - const [node, path] = entry + const {node, path: entryPath} = entry - if (lastPath && comparePaths(path, lastPath) === 0) { + if (lastPath && comparePaths(entryPath, lastPath) === 0) { continue } if ( isObjectNode({schema: operation.editor.schema}, node) || - (isTextBlock({schema: operation.editor.schema}, node) && - operation.editor.isElementReadOnly(node)) || - (!isCommonPath(path, start.path) && !isCommonPath(path, end.path)) + (!isCommonPath(entryPath, start.path) && + !isCommonPath(entryPath, end.path)) ) { matches.push(entry) - lastPath = path + lastPath = entryPath } } - const pathRefs = Array.from(matches, ([, path]) => - pathRef(operation.editor, path), + const pathRefs = Array.from(matches, (entry) => + pathRef(operation.editor, entry.path), ) const startRef = pointRef(operation.editor, start) const endRef = pointRef(operation.editor, end) @@ -271,28 +266,24 @@ export const deleteOperationImplementation: OperationImplementation< const endToEndSelection = startBlock && endBlock && - pointEquals(start, editorStart(operation.editor, startBlock[1])) && - pointEquals(end, editorEnd(operation.editor, endBlock[1])) + pointEquals(start, editorStart(operation.editor, startBlock.path)) && + pointEquals(end, editorEnd(operation.editor, endBlock.path)) if (endToEndSelection && isAcrossBlocks) { if (!startNonEditable) { const point = startRef.current! - const node = getNode( - operation.editor, - point.path, - operation.editor.schema, - ) + const nodeEntry = getSpanNode(operation.editor, point.path) - if ( - isSpan({schema: operation.editor.schema}, node) && - node.text.length > 0 - ) { - operation.editor.apply({ - type: 'remove_text', - path: point.path, - offset: 0, - text: node.text, - }) + if (nodeEntry) { + const node = nodeEntry.node + if (node.text.length > 0) { + operation.editor.apply({ + type: 'remove_text', + path: point.path, + offset: 0, + text: node.text, + }) + } } } @@ -300,20 +291,20 @@ export const deleteOperationImplementation: OperationImplementation< const path = pathRef.unref() if (path) { - const [nodeAtPath] = editorNode(operation.editor, path) - operation.editor.apply({type: 'remove_node', path, node: nodeAtPath}) + const nodeAtPathEntry = getNode(operation.editor, path) + if (nodeAtPathEntry) { + const nodeAtPath = nodeAtPathEntry.node + operation.editor.apply({type: 'remove_node', path, node: nodeAtPath}) + } } } if (!endNonEditable) { const point = endRef.current! - const node = getNode( - operation.editor, - point.path, - operation.editor.schema, - ) + const nodeEntry = getSpanNode(operation.editor, point.path) - if (isSpan({schema: operation.editor.schema}, node)) { + if (nodeEntry) { + const node = nodeEntry.node const {path} = point const offset = 0 const text = node.text.slice(offset, end.offset) @@ -325,25 +316,26 @@ export const deleteOperationImplementation: OperationImplementation< } if (endRef.current && startRef.current) { - const endBlockMatches = nodes(operation.editor, { - at: endRef.current, - match: (n) => - isTextBlock({schema: operation.editor.schema}, n) || - (isObjectNode({schema: operation.editor.schema}, n) && - !operation.editor.isInline(n)), + const endBlockMatches = getNodes(operation.editor, { + from: endRef.current.path, + to: endRef.current.path, + match: (_node, path) => isBlock(operation.editor, path), }) - const endBlockPathRefs = Array.from(endBlockMatches, ([, p]) => - pathRef(operation.editor, p), + const endBlockPathRefs = Array.from(endBlockMatches, (entry) => + pathRef(operation.editor, entry.path), ) for (const pathRef of endBlockPathRefs) { const endPath = pathRef.unref()! if (endPath) { - const [endNode] = editorNode(operation.editor, endPath) - operation.editor.apply({ - type: 'remove_node', - path: endPath, - node: endNode, - }) + const endNodeEntry = getNode(operation.editor, endPath) + if (endNodeEntry) { + const endNode = endNodeEntry.node + operation.editor.apply({ + type: 'remove_node', + path: endPath, + node: endNode, + }) + } } } } @@ -366,14 +358,14 @@ export const deleteOperationImplementation: OperationImplementation< startNonEditable && startBlock && endBlock && - pathEquals(startBlock[1], endBlock[1]) && - isObjectNode({schema: operation.editor.schema}, startBlock[0]) + pathEquals(startBlock.path, endBlock.path) && + isObjectNode({schema: operation.editor.schema}, startBlock.node) ) { - const path = startBlock[1] + const path = startBlock.path operation.editor.apply({ type: 'remove_node', path, - node: startBlock[0], + node: startBlock.node, }) return @@ -385,16 +377,23 @@ export const deleteOperationImplementation: OperationImplementation< const currentPath = pathRef.current if (currentPath) { - const [nodeAtPath] = editorNode(operation.editor, currentPath) + const nodeAtPathEntry2 = getNode(operation.editor, currentPath) - if (isObjectNode({schema: operation.editor.schema}, nodeAtPath)) { + if ( + nodeAtPathEntry2 && + isObjectNode({schema: operation.editor.schema}, nodeAtPathEntry2.node) + ) { const path = pathRef.unref() if (path) { - if (endObjectNode && pathEquals(path, endObjectNode[1])) { + if (endObjectNode && pathEquals(path, endObjectNode.path)) { removedEndObjectNode = true } - operation.editor.apply({type: 'remove_node', path, node: nodeAtPath}) + operation.editor.apply({ + type: 'remove_node', + path, + node: nodeAtPathEntry2.node, + }) } } } @@ -429,12 +428,12 @@ export const deleteOperationImplementation: OperationImplementation< const reverse = operation.direction === 'backward' const hanging = reverse ? end - ? isTextBlock(context, endBlock) + ? isTextBlock(context, endBlock?.node) ? end.offset === 0 : true : false : start - ? isTextBlock(context, startBlock) + ? isTextBlock(context, startBlock?.node) ? start.offset === 0 : true : false diff --git a/packages/editor/src/operations/operation.insert.block.ts b/packages/editor/src/operations/operation.insert.block.ts index c9bda7ecd..daab1a8d0 100644 --- a/packages/editor/src/operations/operation.insert.block.ts +++ b/packages/editor/src/operations/operation.insert.block.ts @@ -5,11 +5,14 @@ import {applySetNode} from '../internal-utils/apply-set-node' import {applySplitNode} from '../internal-utils/apply-split-node' import {isEqualChildren, isEqualMarks} from '../internal-utils/equality' import {safeStringify} from '../internal-utils/safe-json' -import {getFocusChild} from '../internal-utils/slate-utils' import {toSlateRange} from '../internal-utils/to-slate-range' import {toSlateBlock} from '../internal-utils/values' +import {getChildren} from '../node-traversal/get-children' +import {getNode} from '../node-traversal/get-node' +import {getSpanNode} from '../node-traversal/get-span-node' +import {getTextBlockNode} from '../node-traversal/get-text-block-node' +import {isBlock} from '../node-traversal/is-block' import {end as editorEnd} from '../slate/editor/end' -import {nodes} from '../slate/editor/nodes' import {pathRef} from '../slate/editor/path-ref' import {pointRef} from '../slate/editor/point-ref' import {rangeRef} from '../slate/editor/range-ref' @@ -19,9 +22,6 @@ import type {Node} from '../slate/interfaces/node' import type {Path} from '../slate/interfaces/path' import type {Point} from '../slate/interfaces/point' import type {Range} from '../slate/interfaces/range' -import {getNode} from '../slate/node/get-node' -import {getSpanNode} from '../slate/node/get-span-node' -import {getTextBlockNode} from '../slate/node/get-text-block-node' import {isObjectNode} from '../slate/node/is-object-node' import {nextPath} from '../slate/path/next-path' import {parentPath} from '../slate/path/parent-path' @@ -98,26 +98,27 @@ function insertBlock(options: { const start = at ? rangeStart(at) : editorStart(editor, []) const end = at ? rangeEnd(at) : editorEnd(editor, []) - const [startBlock, startBlockPath] = Array.from( - nodes(editor, { - at: start, - mode: 'lowest', - match: (node, path) => - (isTextBlock({schema: editor.schema}, node) || - isObjectNode({schema: editor.schema}, node)) && - path.length <= start.path.length, - }), - ).at(0) ?? [undefined, undefined] - let [endBlock, endBlockPath] = Array.from( - nodes(editor, { - at: end, - mode: 'lowest', - match: (node, path) => - (isTextBlock({schema: editor.schema}, node) || - isObjectNode({schema: editor.schema}, node)) && - path.length <= end.path.length, - }), - ).at(0) ?? [undefined, undefined] + const findContainingBlock = ( + pointPath: Array, + ): {node: Node; path: Array} | undefined => { + // Check each prefix of the path from longest to shortest (deepest first) + // to find the closest containing block. + for (let length = pointPath.length; length >= 1; length--) { + const candidatePath = pointPath.slice(0, length) + const entry = getNode(editor, candidatePath) + if (entry && isBlock(editor, candidatePath)) { + return entry + } + } + return undefined + } + + const startBlockEntry = findContainingBlock(start.path) + const startBlock = startBlockEntry?.node + const startBlockPath = startBlockEntry?.path + const endBlockEntry = findContainingBlock(end.path) + let endBlock = endBlockEntry?.node + let endBlockPath = endBlockEntry?.path if (!startBlock || !startBlockPath || !endBlock || !endBlockPath) { throw new Error('Unable to insert block without a start and end block') @@ -144,7 +145,7 @@ function insertBlock(options: { isTextBlock({schema: editor.schema}, endBlock) ) { const selectionBefore = editorEnd(editor, endBlockPath) - insertTextBlockFragment(context, editor, block, selectionBefore) + insertTextBlockFragment(editor, block, selectionBefore) if (select === 'start') { setSelectionToPoint(editor, selectionBefore) @@ -163,14 +164,13 @@ function insertBlock(options: { // Remember if the selection started at the beginning of the block const start = rangeStart(at) - const startBlock = getNode(editor, [start.path[0]!], editor.schema) + const startBlockEntry = getTextBlockNode(editor, [start.path[0]!]) const startOfBlock = editorStart(editor, [start.path[0]!]) const isAtStartOfBlock = - isTextBlock({schema: editor.schema}, startBlock) && - pointEquals(start, startOfBlock) + startBlockEntry !== undefined && pointEquals(start, startOfBlock) // Delete the expanded range - deleteExpandedRange(context, editor, at) + deleteExpandedRange(editor, at) const atAfterDelete = atBeforeDelete.unref() ?? editor.selection @@ -213,24 +213,19 @@ function insertBlock(options: { const emptyBlockPath: Path = isAtStartOfBlock ? [atAfterDelete.anchor.path[0]! + 1] : [atAfterDelete.anchor.path[0]!] - try { - const potentiallyEmptyBlock = getNode( - editor, - emptyBlockPath, - editor.schema, - ) - if ( - isTextBlock({schema: editor.schema}, potentiallyEmptyBlock) && - isEmptyTextBlock(context, potentiallyEmptyBlock) - ) { - editor.apply({ - type: 'remove_node', - path: emptyBlockPath, - node: potentiallyEmptyBlock, - }) - } - } catch { - // Block doesn't exist, nothing to remove + const potentiallyEmptyBlockEntry = getTextBlockNode( + editor, + emptyBlockPath, + ) + if ( + potentiallyEmptyBlockEntry && + isEmptyTextBlock(context, potentiallyEmptyBlockEntry.node) + ) { + editor.apply({ + type: 'remove_node', + path: potentiallyEmptyBlockEntry.path, + node: potentiallyEmptyBlockEntry.node, + }) } } @@ -254,7 +249,11 @@ function insertBlock(options: { if (!isAtBlockStart && !isAtBlockEnd) { // We need to split the block at the selection point - const currentBlock = getTextBlockNode(editor, blockPath, editor.schema) + const currentBlockEntry = getTextBlockNode(editor, blockPath) + if (!currentBlockEntry) { + return + } + const currentBlock = currentBlockEntry.node // Find the child index and offset within that child const childIndex = selectionPoint.path[1]! @@ -262,10 +261,13 @@ function insertBlock(options: { // Split the text node at the offset if needed if (childOffset > 0) { - const textNode = getSpanNode(editor, selectionPoint.path, editor.schema) - if (childOffset < textNode.text.length) { - const {text: _, ...properties} = textNode - applySplitNode(editor, selectionPoint.path, childOffset, properties) + const textNodeEntry = getSpanNode(editor, selectionPoint.path) + if (textNodeEntry) { + const textNode = textNodeEntry.node + if (childOffset < textNode.text.length) { + const {text: _, ...properties} = textNode + applySplitNode(editor, selectionPoint.path, childOffset, properties) + } } } @@ -322,14 +324,14 @@ function insertBlock(options: { const [start, end] = rangeEdges(at) const isCrossBlock = start.path[0] !== end.path[0] - deleteExpandedRange(context, editor, at) + deleteExpandedRange(editor, at) // For cross-block deletion, set selection to the merge point if (isCrossBlock) { wasCrossBlockDeletion = true const startBlockPath: Path = [start.path[0]!] - const mergedBlock = getNode(editor, startBlockPath, editor.schema) - if (isTextBlock({schema: editor.schema}, mergedBlock)) { + const mergedBlockEntry = getTextBlockNode(editor, startBlockPath) + if (mergedBlockEntry) { // Find the merge position (where blocks were joined) const mergePoint: Point = { path: [...startBlockPath, start.path[1]!], @@ -342,16 +344,11 @@ function insertBlock(options: { } // After deletion, refetch the end block since paths may have changed - const [newEndBlock, newEndBlockPath] = Array.from( - nodes(editor, { - at: editorEnd(editor, []), - mode: 'lowest', - match: (node, path) => - (isTextBlock({schema: editor.schema}, node) || - isObjectNode({schema: editor.schema}, node)) && - path.length === 1, - }), - ).at(-1) ?? [undefined, undefined] + const lastIndex = editor.children.length - 1 + const lastBlockEntry = + lastIndex >= 0 ? getNode(editor, [lastIndex]) : undefined + const newEndBlock = lastBlockEntry?.node + const newEndBlockPath = lastBlockEntry?.path if ( newEndBlock && @@ -431,11 +428,11 @@ function insertBlock(options: { }) // Carry over the markDefs from the incoming block to the end block - const endBlockNode = getNode(editor, endBlockPath, editor.schema) + const endBlockNodeEntry = getTextBlockNode(editor, endBlockPath) - if (isTextBlock({schema: editor.schema}, endBlockNode)) { + if (endBlockNodeEntry) { const properties: Partial = { - markDefs: endBlockNode.markDefs, + markDefs: endBlockNodeEntry.node.markDefs, } const newProperties: Partial = { markDefs: [...(endBlock.markDefs ?? []), ...(adjustedMarkDefs ?? [])], @@ -467,13 +464,13 @@ function insertBlock(options: { ? rangeEnd(editor.selection) : editorEnd(editor, endBlockPath) - insertTextBlockFragment(context, editor, adjustedBlock, insertAt) + insertTextBlockFragment(editor, adjustedBlock, insertAt) return } // Use selectionStartPoint (updated after any deletion) instead of stale rangeStart(at) - insertTextBlockFragment(context, editor, adjustedBlock, selectionStartPoint) + insertTextBlockFragment(editor, adjustedBlock, selectionStartPoint) if (select === 'start') { setSelectionToPoint(editor, selectionStartPoint) @@ -553,13 +550,13 @@ function insertBlock(options: { const [, end] = rangeEdges(at) // Delete text in end node - const endNode = getSpanNode(editor, end.path, editor.schema) - if (end.offset > 0) { + const endNodeEntry = getSpanNode(editor, end.path) + if (endNodeEntry && end.offset > 0) { editor.apply({ type: 'remove_text', path: end.path, offset: 0, - text: endNode.text.slice(0, end.offset), + text: endNodeEntry.node.text.slice(0, end.offset), }) } @@ -568,12 +565,7 @@ function insertBlock(options: { removeNodeAt(editor, [...endBlockPath, i]) } - insertTextBlockFragment( - context, - editor, - block, - editorStart(editor, endBlockPath), - ) + insertTextBlockFragment(editor, block, editorStart(editor, endBlockPath)) if (select !== 'none') { setSelection(editor, endBlockPath, select) @@ -589,23 +581,30 @@ function insertBlock(options: { const [start] = rangeEdges(at) // Remove nodes after start - const blockNode = getTextBlockNode(editor, endBlockPath, editor.schema) - for (let i = blockNode.children.length - 1; i > start.path[1]!; i--) { + const blockNodeEntry = getTextBlockNode(editor, endBlockPath) + if (!blockNodeEntry) { + return + } + for ( + let i = blockNodeEntry.node.children.length - 1; + i > start.path[1]!; + i-- + ) { removeNodeAt(editor, [...endBlockPath, i]) } // Delete text from start node - const startNode = getSpanNode(editor, start.path, editor.schema) - if (start.offset < startNode.text.length) { + const startNodeEntry = getSpanNode(editor, start.path) + if (startNodeEntry && start.offset < startNodeEntry.node.text.length) { editor.apply({ type: 'remove_text', path: start.path, offset: start.offset, - text: startNode.text.slice(start.offset), + text: startNodeEntry.node.text.slice(start.offset), }) } - insertTextBlockFragment(context, editor, block, start) + insertTextBlockFragment(editor, block, start) if (select !== 'none') { setSelection(editor, nextPath(endBlockPath), select) @@ -614,20 +613,25 @@ function insertBlock(options: { } // General case: selection in the middle of the block - const [focusChild] = getFocusChild({editor}) + const focusChildIndex = editor.selection?.focus.path.at(1) + const focusBlockPath = editor.selection?.focus.path.slice(0, 1) + const focusChild = + focusChildIndex !== undefined && focusBlockPath + ? getChildren(editor, focusBlockPath).at(focusChildIndex)?.node + : undefined if (focusChild && isSpan({schema: editor.schema}, focusChild)) { const startPoint = rangeStart(at) if (isTextBlock({schema: editor.schema}, block)) { // Inserting text block: split the text node and insert fragment - const nodeToSplit = getNode(editor, startPoint.path, editor.schema) - if (isSpan(context, nodeToSplit)) { - const {text: _, ...properties} = nodeToSplit + const nodeToSplitEntry = getSpanNode(editor, startPoint.path) + if (nodeToSplitEntry) { + const {text: _, ...properties} = nodeToSplitEntry.node applySplitNode(editor, startPoint.path, startPoint.offset, properties) } - insertTextBlockFragment(context, editor, block, startPoint) + insertTextBlockFragment(editor, block, startPoint) if (select === 'none') { setSelectionToRange(editor, at) @@ -650,13 +654,13 @@ function insertBlock(options: { const firstBlockPathRef = pathRef(editor, blockPath) // Split text node first - const textNode = getNode(editor, currentPath, editor.schema) + const textNodeEntry = getSpanNode(editor, currentPath) if ( - isSpan(context, textNode) && + textNodeEntry && currentOffset > 0 && - currentOffset < textNode.text.length + currentOffset < textNodeEntry.node.text.length ) { - const {text: _, ...properties} = textNode + const {text: _, ...properties} = textNodeEntry.node applySplitNode(editor, currentPath, currentOffset, properties) currentPath = nextPath(currentPath) currentOffset = 0 @@ -665,14 +669,14 @@ function insertBlock(options: { // Split the block, preserving block properties const splitAtIndex = currentOffset > 0 ? currentPath[1]! + 1 : currentPath[1]! - const blockToSplit = getNode(editor, blockPath, editor.schema) + const blockToSplitEntry = getTextBlockNode(editor, blockPath) if ( - isTextBlock({schema: editor.schema}, blockToSplit) && - splitAtIndex < blockToSplit.children.length + blockToSplitEntry && + splitAtIndex < blockToSplitEntry.node.children.length ) { // Get the properties to preserve in the split - const {children: _, ...blockProperties} = blockToSplit + const {children: _, ...blockProperties} = blockToSplitEntry.node applySplitNode(editor, blockPath, splitAtIndex, blockProperties) } @@ -789,8 +793,14 @@ function insertNodeAt( * Removes a node at the given path. */ function removeNodeAt(editor: PortableTextSlateEditor, path: Path) { - const node = getNode(editor, path, editor.schema) - editor.apply({type: 'remove_node', path, node}) + const nodeEntry = getNode(editor, path) + + if (!nodeEntry) { + return + } + + const {node: node, path: nodePath} = nodeEntry + editor.apply({type: 'remove_node', path: nodePath, node}) } /** @@ -828,7 +838,6 @@ function replaceEmptyTextBlock( * Deletes content within a single block (same block deletion). */ function deleteSameBlockRange( - context: OperationContext, editor: PortableTextSlateEditor, start: Point, end: Point, @@ -837,8 +846,11 @@ function deleteSameBlockRange( if (pathEquals(start.path, end.path)) { // Same text node - simple text removal - const text = getSpanNode(editor, start.path, editor.schema) - const textToRemove = text.text.slice(start.offset, end.offset) + const textEntry = getSpanNode(editor, start.path) + if (!textEntry) { + return + } + const textToRemove = textEntry.node.text.slice(start.offset, end.offset) editor.apply({ type: 'remove_text', path: start.path, @@ -850,9 +862,9 @@ function deleteSameBlockRange( // Different nodes in same block // Remove from start node to end - const startNode = getSpanNode(editor, start.path, editor.schema) - if (start.offset < startNode.text.length) { - const textToRemove = startNode.text.slice(start.offset) + const startNodeEntry = getSpanNode(editor, start.path) + if (startNodeEntry && start.offset < startNodeEntry.node.text.length) { + const textToRemove = startNodeEntry.node.text.slice(start.offset) editor.apply({ type: 'remove_text', path: start.path, @@ -868,9 +880,9 @@ function deleteSameBlockRange( // Remove from beginning of end node const newEndPath: Path = [...blockPath, start.path[1]! + 1] - const endNode = getSpanNode(editor, newEndPath, editor.schema) - if (end.offset > 0) { - const textToRemove = endNode.text.slice(0, end.offset) + const endNodeEntry = getSpanNode(editor, newEndPath) + if (endNodeEntry && end.offset > 0) { + const textToRemove = endNodeEntry.node.text.slice(0, end.offset) editor.apply({ type: 'remove_text', path: newEndPath, @@ -880,10 +892,10 @@ function deleteSameBlockRange( } // Merge adjacent text nodes - const startNodeAfter = getNode(editor, start.path, editor.schema) - const endNodeAfter = getNode(editor, newEndPath, editor.schema) - if (isSpan(context, startNodeAfter) && isSpan(context, endNodeAfter)) { - applyMergeNode(editor, newEndPath, startNodeAfter.text.length) + const startNodeAfterEntry = getSpanNode(editor, start.path) + const endNodeAfterEntry = getSpanNode(editor, newEndPath) + if (startNodeAfterEntry && endNodeAfterEntry) { + applyMergeNode(editor, newEndPath, startNodeAfterEntry.node.text.length) } } @@ -900,9 +912,9 @@ function deleteCrossBlockRange( // Remove from start position to end of start block if (start.path.length > 1) { - const startNode = getSpanNode(editor, start.path, editor.schema) - if (start.offset < startNode.text.length) { - const textToRemove = startNode.text.slice(start.offset) + const startNodeEntry = getSpanNode(editor, start.path) + if (startNodeEntry && start.offset < startNodeEntry.node.text.length) { + const textToRemove = startNodeEntry.node.text.slice(start.offset) editor.apply({ type: 'remove_text', path: start.path, @@ -912,9 +924,15 @@ function deleteCrossBlockRange( } // Remove remaining nodes in start block - const startBlock = getTextBlockNode(editor, startBlockPath, editor.schema) - for (let i = startBlock.children.length - 1; i > start.path[1]!; i--) { - removeNodeAt(editor, [...startBlockPath, i]) + const startBlockEntry = getTextBlockNode(editor, startBlockPath) + if (startBlockEntry) { + for ( + let i = startBlockEntry.node.children.length - 1; + i > start.path[1]!; + i-- + ) { + removeNodeAt(editor, [...startBlockPath, i]) + } } } @@ -933,9 +951,9 @@ function deleteCrossBlockRange( // Remove text from end node const endNodePath = [...adjustedEndBlockPath, 0] - const endNode = getSpanNode(editor, endNodePath, editor.schema) - if (end.offset > 0) { - const textToRemove = endNode.text.slice(0, end.offset) + const endNodeEntry = getSpanNode(editor, endNodePath) + if (endNodeEntry && end.offset > 0) { + const textToRemove = endNodeEntry.node.text.slice(0, end.offset) editor.apply({ type: 'remove_text', path: endNodePath, @@ -946,26 +964,36 @@ function deleteCrossBlockRange( } // Merge the blocks if both are text blocks - const startBlock = getNode(editor, startBlockPath, editor.schema) - const endBlock = getNode(editor, adjustedEndBlockPath, editor.schema) - if ( - isTextBlock({schema: editor.schema}, startBlock) && - isTextBlock({schema: editor.schema}, endBlock) - ) { + const startBlockEntry = getTextBlockNode(editor, startBlockPath) + const endBlockEntry = getTextBlockNode(editor, adjustedEndBlockPath) + if (startBlockEntry && endBlockEntry) { + const startBlockNode = startBlockEntry.node + const endBlockNode = endBlockEntry.node // Wrap in withoutNormalizing so normalization doesn't strip the copied // markDefs before the merge moves the children that reference them. withoutNormalizing(editor, () => { - if (Array.isArray(endBlock.markDefs) && endBlock.markDefs.length > 0) { + if ( + Array.isArray(endBlockNode.markDefs) && + endBlockNode.markDefs.length > 0 + ) { const oldDefs = - (Array.isArray(startBlock.markDefs) && startBlock.markDefs) || [] + (Array.isArray(startBlockNode.markDefs) && startBlockNode.markDefs) || + [] const newMarkDefs = [ ...new Map( - [...oldDefs, ...endBlock.markDefs].map((def) => [def._key, def]), + [...oldDefs, ...endBlockNode.markDefs].map((def) => [ + def._key, + def, + ]), ).values(), ] applySetNode(editor, {markDefs: newMarkDefs}, startBlockPath) } - applyMergeNode(editor, adjustedEndBlockPath, startBlock.children.length) + applyMergeNode( + editor, + adjustedEndBlockPath, + startBlockNode.children.length, + ) }) } } @@ -974,14 +1002,13 @@ function deleteCrossBlockRange( * Deletes an expanded range, handling both same-block and cross-block cases. */ function deleteExpandedRange( - context: OperationContext, editor: PortableTextSlateEditor, range: Range, ): void { const [start, end] = rangeEdges(range) if (start.path[0] === end.path[0]) { - deleteSameBlockRange(context, editor, start, end) + deleteSameBlockRange(editor, start, end) } else { deleteCrossBlockRange(editor, start, end) } @@ -991,7 +1018,6 @@ function deleteExpandedRange( * Inserts a text block's children at a point within another text block. */ function insertTextBlockFragment( - context: OperationContext, editor: PortableTextSlateEditor, block: Node, at: Point, @@ -1002,10 +1028,10 @@ function insertTextBlockFragment( // Split the text node at the insertion point if needed if (at.offset > 0) { - const textNode = getNode(editor, at.path, editor.schema) + const textNodeEntry = getSpanNode(editor, at.path) - if (isSpan(context, textNode)) { - const {text: _, ...properties} = textNode + if (textNodeEntry) { + const {text: _, ...properties} = textNodeEntry.node applySplitNode(editor, at.path, at.offset, properties) } diff --git a/packages/editor/src/operations/operation.insert.child.ts b/packages/editor/src/operations/operation.insert.child.ts index 0a2a28884..bdead16ad 100644 --- a/packages/editor/src/operations/operation.insert.child.ts +++ b/packages/editor/src/operations/operation.insert.child.ts @@ -1,9 +1,9 @@ -import {isTextBlock} from '@portabletext/schema' +import {isSpan, isTextBlock} from '@portabletext/schema' import { applyInsertNodeAtPath, applyInsertNodeAtPoint, } from '../internal-utils/apply-insert-node' -import {getFocusBlock, getFocusSpan} from '../internal-utils/slate-utils' +import {getNode} from '../node-traversal/get-node' import {parseInlineObject, parseSpan} from '../utils/parse-blocks' import type {OperationImplementation} from './operation.types' @@ -18,7 +18,11 @@ export const insertChildOperationImplementation: OperationImplementation< throw new Error('Unable to insert child without a focus') } - const [focusBlock, focusBlockPath] = getFocusBlock({editor: operation.editor}) + const focusBlockEntry = focus + ? getNode(operation.editor, focus.path.slice(0, 1)) + : undefined + const focusBlock = focusBlockEntry?.node + const focusBlockPath = focusBlockEntry?.path if (!focus || !focusBlock || !focusBlockPath) { throw new Error('Unable to insert child without a focus block') @@ -42,7 +46,12 @@ export const insertChildOperationImplementation: OperationImplementation< }) if (span) { - const [focusSpan] = getFocusSpan({editor: operation.editor}) + const focusSpanEntry = getNode(operation.editor, focus.path.slice(0, 2)) + const focusSpan = + focusSpanEntry && + isSpan({schema: operation.editor.schema}, focusSpanEntry.node) + ? focusSpanEntry.node + : undefined if (focusSpan) { applyInsertNodeAtPoint(operation.editor, span, focus) @@ -75,7 +84,12 @@ export const insertChildOperationImplementation: OperationImplementation< ...rest, } - const [focusSpan] = getFocusSpan({editor: operation.editor}) + const focusSpanEntry = getNode(operation.editor, focus.path.slice(0, 2)) + const focusSpan = + focusSpanEntry && + isSpan({schema: operation.editor.schema}, focusSpanEntry.node) + ? focusSpanEntry.node + : undefined if (focusSpan) { applyInsertNodeAtPoint(operation.editor, inlineNode, focus) diff --git a/packages/editor/src/operations/operation.insert.text.ts b/packages/editor/src/operations/operation.insert.text.ts index c7351958a..882fc1d57 100644 --- a/packages/editor/src/operations/operation.insert.text.ts +++ b/packages/editor/src/operations/operation.insert.text.ts @@ -1,9 +1,7 @@ -import {isSpan} from '@portabletext/schema' import {applySelect} from '../internal-utils/apply-selection' -import {elementReadOnly} from '../slate/editor/element-read-only' -import {getObjectNode} from '../slate/editor/get-object-node' -import {hasPath} from '../slate/editor/has-path' -import {getNode} from '../slate/node/get-node' +import {getNode} from '../node-traversal/get-node' +import {getSpanNode} from '../node-traversal/get-span-node' +import {hasNode} from '../node-traversal/has-node' import {isObjectNode} from '../slate/node/is-object-node' import {nextPath} from '../slate/path/next-path' import {isCollapsedRange} from '../slate/range/is-collapsed-range' @@ -21,16 +19,22 @@ export const insertTextOperationImplementation: OperationImplementation< let {path, offset} = selection.anchor - const node = getNode(editor, path, editor.schema) + const nodeEntry = getNode(editor, path) + + if (!nodeEntry) { + return + } + + const node = nodeEntry.node // If the selection is at an ObjectNode, move to the adjacent span. if (isObjectNode({schema: editor.schema}, node)) { const next = nextPath(path) - if (hasPath(editor, next)) { - const nextNode = getNode(editor, next, editor.schema) + if (hasNode(editor, next)) { + const nextNodeEntry = getSpanNode(editor, next) - if (isSpan({schema: editor.schema}, nextNode)) { + if (nextNodeEntry) { path = next offset = 0 applySelect(editor, {path, offset}) @@ -40,11 +44,6 @@ export const insertTextOperationImplementation: OperationImplementation< } else { return } - } else if ( - getObjectNode(editor, {at: selection}) || - elementReadOnly(editor, {at: selection}) - ) { - return } if (operation.text.length > 0) { diff --git a/packages/editor/src/operations/operation.move.block.ts b/packages/editor/src/operations/operation.move.block.ts index df1818010..4ba9d015b 100644 --- a/packages/editor/src/operations/operation.move.block.ts +++ b/packages/editor/src/operations/operation.move.block.ts @@ -1,6 +1,6 @@ +import {getNode} from '../node-traversal/get-node' import {withoutNormalizing} from '../slate/editor/without-normalizing' import type {Point} from '../slate/interfaces/point' -import {getNode} from '../slate/node/get-node' import {getBlockKeyFromSelectionPoint} from '../utils/util.selection-point' import type {OperationImplementation} from './operation.types' @@ -39,7 +39,14 @@ export const moveBlockOperationImplementation: OperationImplementation< } const editor = operation.editor - const node = getNode(editor, [originBlockIndex], editor.schema) + const nodeEntry = getNode(editor, [originBlockIndex]) + + if (!nodeEntry) { + return + } + + const node = nodeEntry.node + const savedSelection = editor.selection ? {anchor: {...editor.selection.anchor}, focus: {...editor.selection.focus}} : null diff --git a/packages/editor/src/slate-plugins/slate-plugin.normalization.ts b/packages/editor/src/slate-plugins/slate-plugin.normalization.ts index d49f397af..8a7784f94 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.normalization.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.normalization.ts @@ -7,10 +7,10 @@ import {applySetNode} from '../internal-utils/apply-set-node' import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block' import {debug} from '../internal-utils/debug' import {isEqualMarkDefs} from '../internal-utils/equality' +import {getChildren} from '../node-traversal/get-children' +import {getSpanNode} from '../node-traversal/get-span-node' +import {getTextBlockNode} from '../node-traversal/get-text-block-node' import {isEditor} from '../slate/editor/is-editor' -import {node as editorNode} from '../slate/editor/node' -import {nodes} from '../slate/editor/nodes' -import {getChildren} from '../slate/node/get-children' import {parentPath} from '../slate/path/parent-path' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -48,10 +48,11 @@ export function createNormalizationPlugin( * Merge spans with same set of .marks */ if (isTextBlock({schema: editor.schema}, node)) { - const children = getChildren(editor, path, editor.schema) + const children = getChildren(editor, path) - for (const [child, childPath] of children) { - const nextNode = node.children[childPath[1]! + 1] + for (const {node: child, path: childPath} of children) { + const childIndex = childPath[childPath.length - 1]! + const nextNode = node.children[childIndex + 1] if ( isSpan({schema: editor.schema}, child) && @@ -115,7 +116,10 @@ export function createNormalizationPlugin( */ if (isSpan({schema: editor.schema}, node)) { const blockPath = parentPath(path) - const [block] = editorNode(editor, blockPath) + const blockEntry = getTextBlockNode(editor, blockPath) + if (!blockEntry) { + return + } const decorators = editorActor .getSnapshot() .context.schema.decorators.map((decorator) => decorator.name) @@ -123,22 +127,18 @@ export function createNormalizationPlugin( (mark) => !decorators.includes(mark), ) - if (isTextBlock({schema: editor.schema}, block)) { - if (node.text === '' && annotations && annotations.length > 0) { - debug.normalization('removing annotations from empty span node') - withNormalizeNode(editor, () => { - applySetNode( - editor, - { - marks: node.marks?.filter((mark) => - decorators.includes(mark), - ), - }, - path, - ) - }) - return - } + if (node.text === '' && annotations && annotations.length > 0) { + debug.normalization('removing annotations from empty span node') + withNormalizeNode(editor, () => { + applySetNode( + editor, + { + marks: node.marks?.filter((mark) => decorators.includes(mark)), + }, + path, + ) + }) + return } } @@ -150,10 +150,9 @@ export function createNormalizationPlugin( .getSnapshot() .context.schema.decorators.map((decorator) => decorator.name) - for (const [child, childPath] of getChildren( + for (const {node: child, path: childPath} of getChildren( editor, path, - editor.schema, )) { if (isSpan({schema: editor.schema}, child)) { const marks = child.marks ?? [] @@ -190,9 +189,10 @@ export function createNormalizationPlugin( */ if (isSpan({schema: editor.schema}, node)) { const blockPath = parentPath(path) - const [block] = editorNode(editor, blockPath) + const blockEntry2 = getTextBlockNode(editor, blockPath) - if (isTextBlock({schema: editor.schema}, block)) { + if (blockEntry2) { + const block = blockEntry2.node const decorators = editorActor .getSnapshot() .context.schema.decorators.map((decorator) => decorator.name) @@ -314,22 +314,14 @@ export function createNormalizationPlugin( }) if (previousSelectionIsCollapsed && newSelectionIsCollapsed) { - const focusSpan: PortableTextSpan | undefined = Array.from( - nodes(editor, { - mode: 'lowest', - at: op.properties.focus, - match: (n) => isSpan({schema: editor.schema}, n), - includeObjectNodes: false, - }), - )[0]?.[0] - const newFocusSpan: PortableTextSpan | undefined = Array.from( - nodes(editor, { - mode: 'lowest', - at: op.newProperties.focus, - match: (n) => isSpan({schema: editor.schema}, n), - includeObjectNodes: false, - }), - )[0]?.[0] + const focusSpanEntry = getSpanNode(editor, op.properties.focus.path) + const focusSpan: PortableTextSpan | undefined = focusSpanEntry?.node + const newFocusSpanEntry = getSpanNode( + editor, + op.newProperties.focus.path, + ) + const newFocusSpan: PortableTextSpan | undefined = + newFocusSpanEntry?.node const movedToNextSpan = focusSpan && newFocusSpan && diff --git a/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts b/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts index bf7ba21e5..132d8debc 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts @@ -2,11 +2,10 @@ import {isTextBlock} from '@portabletext/schema' import type {EditorActor} from '../editor/editor-machine' import type {EditorContext, EditorSnapshot} from '../editor/editor-snapshot' import {applySetNode} from '../internal-utils/apply-set-node' +import {getChildren} from '../node-traversal/get-children' +import {getParent} from '../node-traversal/get-parent' import {isEditor} from '../slate/editor/is-editor' -import {parent as editorParent} from '../slate/editor/parent' import type {Path} from '../slate/interfaces/path' -import {getChildren} from '../slate/node/get-children' -import {isObjectNode} from '../slate/node/is-object-node' import type {PortableTextSlateEditor} from '../types/slate-editor' import {withNormalizeNode} from './slate-plugin.normalize-node' @@ -81,94 +80,44 @@ export function createUniqueKeysPlugin(editorActor: EditorActor) { } editor.normalizeNode = (entry) => { - const [node, path] = entry + const [_node, path] = entry - if ( - isTextBlock({schema: editor.schema}, node) || - isObjectNode({schema: editor.schema}, node) - ) { - const [parentNode] = editorParent(editor, path) + const parent = getParent(editor, path) + const siblings = parent + ? getChildren(editor, parent.path) + : editor.children.map((child, index) => ({ + node: child, + path: [index], + })) - if (parentNode && isEditor(parentNode)) { - const blockKeys = new Set() + const siblingKeys = new Set() - for (const sibling of parentNode.children) { - if (sibling._key && blockKeys.has(sibling._key)) { - const _key = editorActor.getSnapshot().context.keyGenerator() + for (const sibling of siblings) { + if (sibling.node._key && siblingKeys.has(sibling.node._key)) { + const _key = editorActor.getSnapshot().context.keyGenerator() - blockKeys.add(_key) + siblingKeys.add(_key) - withNormalizeNode(editor, () => { - applySetNode(editor, {_key}, path) - }) - - return - } - - if (!sibling._key) { - const _key = editorActor.getSnapshot().context.keyGenerator() - - blockKeys.add(_key) - - withNormalizeNode(editor, () => { - applySetNode(editor, {_key}, path) - }) - - return - } - - blockKeys.add(sibling._key) - } - } - } - - if (isTextBlock({schema: editor.schema}, node)) { - // Set key on block itself - if (!node._key) { withNormalizeNode(editor, () => { - applySetNode( - editor, - {_key: editorActor.getSnapshot().context.keyGenerator()}, - path, - ) + applySetNode(editor, {_key}, path) }) + return } - // Set unique keys on it's children - const childKeys = new Set() + if (!sibling.node._key) { + const _key = editorActor.getSnapshot().context.keyGenerator() - for (const [child, childPath] of getChildren( - editor, - path, - editor.schema, - )) { - if (child._key && childKeys.has(child._key)) { - const _key = editorActor.getSnapshot().context.keyGenerator() + siblingKeys.add(_key) - childKeys.add(_key) - - withNormalizeNode(editor, () => { - applySetNode(editor, {_key}, childPath) - }) - - return - } - - if (!child._key) { - const _key = editorActor.getSnapshot().context.keyGenerator() - - childKeys.add(_key) - - withNormalizeNode(editor, () => { - applySetNode(editor, {_key}, childPath) - }) - - return - } + withNormalizeNode(editor, () => { + applySetNode(editor, {_key}, path) + }) - childKeys.add(child._key) + return } + + siblingKeys.add(sibling.node._key) } withNormalizeNode(editor, () => { diff --git a/packages/editor/src/slate/core/apply-operation.ts b/packages/editor/src/slate/core/apply-operation.ts index ece9c9091..7ba81c28d 100644 --- a/packages/editor/src/slate/core/apply-operation.ts +++ b/packages/editor/src/slate/core/apply-operation.ts @@ -1,10 +1,11 @@ import type {PortableTextSpan} from '@portabletext/schema' +import {isSpan, isTextBlock} from '@portabletext/schema' import {safeStringify} from '../../internal-utils/safe-json' +import {getNodes} from '../../node-traversal/get-nodes' import type {Editor, Selection} from '../interfaces/editor' import type {Node, NodeEntry} from '../interfaces/node' import type {Operation} from '../interfaces/operation' import type {Range} from '../interfaces/range' -import {getTexts} from '../node/get-texts' import {commonPath} from '../path/common-path' import {comparePaths} from '../path/compare-paths' import {isSiblingPath} from '../path/is-sibling-path' @@ -87,7 +88,10 @@ export function applyOperation(editor: Editor, op: Operation): void { let prev: NodeEntry | undefined let next: NodeEntry | undefined - for (const [n, p] of getTexts(editor, editor.schema)) { + for (const {node: n, path: p} of getNodes(editor)) { + if (!isSpan({schema: editor.schema}, n)) { + continue + } if (comparePaths(p, path) === -1) { prev = [n, p] } else { @@ -156,7 +160,7 @@ export function applyOperation(editor: Editor, op: Operation): void { modifyDescendant(editor, path, editor.schema, (node) => { const newNode = {...node} - const isElement = 'children' in node && Array.isArray(node.children) + const isTextBlockNode = isTextBlock({schema: editor.schema}, node) for (const key in newProperties) { if (key === 'children') { @@ -167,7 +171,7 @@ export function applyOperation(editor: Editor, op: Operation): void { // where `text` is a user property. if ( key === 'text' && - !isElement && + !isTextBlockNode && Array.isArray((node as Record)['marks']) ) { throw new Error(`Cannot set the "${key}" property of nodes!`) diff --git a/packages/editor/src/slate/core/delete-text.ts b/packages/editor/src/slate/core/delete-text.ts index 169ee3443..294eee71f 100644 --- a/packages/editor/src/slate/core/delete-text.ts +++ b/packages/editor/src/slate/core/delete-text.ts @@ -2,29 +2,29 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import {applyMergeNode} from '../../internal-utils/apply-merge-node' import {applySetNode} from '../../internal-utils/apply-set-node' import {safeStringify} from '../../internal-utils/safe-json' +import {getAncestor} from '../../node-traversal/get-ancestor' +import {getAncestorTextBlock} from '../../node-traversal/get-ancestor-text-block' +import {getAncestors} from '../../node-traversal/get-ancestors' +import {getHighestObjectNode} from '../../node-traversal/get-highest-object-node' +import {getNode} from '../../node-traversal/get-node' +import {getNodes} from '../../node-traversal/get-nodes' +import {getSpanNode} from '../../node-traversal/get-span-node' import type {PortableTextSlateEditor} from '../../types/slate-editor' -import {above} from '../editor/above' import {after} from '../editor/after' import {before} from '../editor/before' -import {elementReadOnly} from '../editor/element-read-only' import {end as editorEnd} from '../editor/end' -import {getObjectNode} from '../editor/get-object-node' import {isEditor} from '../editor/is-editor' -import {levels} from '../editor/levels' -import {nodes} from '../editor/nodes' import {pathRef} from '../editor/path-ref' import {pointRef} from '../editor/point-ref' -import {previous as editorPrevious} from '../editor/previous' import {shouldMergeNodesRemovePrevNode} from '../editor/should-merge-nodes-remove-prev-node' import {start as editorStart} from '../editor/start' import {unhangRange} from '../editor/unhang-range' import {withoutNormalizing} from '../editor/without-normalizing' import type {Editor} from '../interfaces/editor' import type {Location} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' +import type {Node} from '../interfaces/node' import type {Path} from '../interfaces/path' import type {Point} from '../interfaces/point' -import {getNode} from '../node/get-node' import {isObjectNode} from '../node/is-object-node' import {commonPath} from '../path/common-path' import {comparePaths} from '../path/compare-paths' @@ -34,6 +34,7 @@ import {isPath} from '../path/is-path' import {isSiblingPath} from '../path/is-sibling-path' import {nextPath} from '../path/next-path' import {pathEquals} from '../path/path-equals' +import {pathLevels} from '../path/path-levels' import {previousPath} from '../path/previous-path' import {isPoint} from '../point/is-point' import {pointEquals} from '../point/point-equals' @@ -74,10 +75,10 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { } if (isPoint(at)) { - const furthestObjectNode = getObjectNode(editor, {at, mode: 'highest'}) + const furthestObjectNode = getHighestObjectNode(editor, at.path) if (!includeObjectNodes && furthestObjectNode) { - const [, voidPath] = furthestObjectNode + const voidPath = furthestObjectNode.path at = voidPath } else { const opts = {unit, distance} @@ -103,32 +104,22 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { const endOfDoc = editorEnd(editor, []) if (!pointEquals(end, endOfDoc)) { - at = unhangRange(editor, at, {includeObjectNodes}) + at = unhangRange(editor, at) } } let [start, end] = rangeEdges(at) - const startBlock = above(editor, { - match: (n) => isTextBlock({schema: editor.schema}, n), - at: start, - includeObjectNodes, - }) - const endBlock = above(editor, { - match: (n) => isTextBlock({schema: editor.schema}, n), - at: end, - includeObjectNodes, - }) + const startBlock = getAncestorTextBlock(editor, start.path) + const endBlock = getAncestorTextBlock(editor, end.path) const isAcrossBlocks = - startBlock && endBlock && !pathEquals(startBlock[1], endBlock[1]) + startBlock && endBlock && !pathEquals(startBlock.path, endBlock.path) const isSingleText = pathEquals(start.path, end.path) const startNonEditable = includeObjectNodes ? null - : (getObjectNode(editor, {at: start, mode: 'highest'}) ?? - elementReadOnly(editor, {at: start, mode: 'highest'})) + : getHighestObjectNode(editor, start.path) const endNonEditable = includeObjectNodes ? null - : (getObjectNode(editor, {at: end, mode: 'highest'}) ?? - elementReadOnly(editor, {at: end, mode: 'highest'})) + : getHighestObjectNode(editor, end.path) // If the start or end points are inside an inline void, nudge them out. if (startNonEditable) { @@ -137,7 +128,7 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { if ( beforePoint && startBlock && - isAncestorPath(startBlock[1], beforePoint.path) + isAncestorPath(startBlock.path, beforePoint.path) ) { start = beforePoint } @@ -149,7 +140,7 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { if ( afterPoint && endBlock && - isAncestorPath(endBlock[1], afterPoint.path) + isAncestorPath(endBlock.path, afterPoint.path) ) { end = afterPoint } @@ -157,29 +148,30 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { // Get the highest nodes that are completely inside the range, as well as // the start and end nodes. - const matches: NodeEntry[] = [] + const matches: Array<{node: Node; path: Array}> = [] let lastPath: Path | undefined - for (const entry of nodes(editor, {at, includeObjectNodes})) { - const [node, path] = entry + for (const entry of getNodes(editor, { + from: start.path, + to: end.path, + })) { + const {node, path: entryPath} = entry - if (lastPath && comparePaths(path, lastPath) === 0) { + if (lastPath && comparePaths(entryPath, lastPath) === 0) { continue } if ( - (!includeObjectNodes && - (isObjectNode({schema: editor.schema}, node) || - (isTextBlock({schema: editor.schema}, node) && - editor.isElementReadOnly(node)))) || - (!isCommonPath(path, start.path) && !isCommonPath(path, end.path)) + (!includeObjectNodes && isObjectNode({schema: editor.schema}, node)) || + (!isCommonPath(entryPath, start.path) && + !isCommonPath(entryPath, end.path)) ) { matches.push(entry) - lastPath = path + lastPath = entryPath } } - const pathRefs = Array.from(matches, ([, p]) => pathRef(editor, p)) + const pathRefs = Array.from(matches, (entry) => pathRef(editor, entry.path)) const startRef = pointRef(editor, start) const endRef = pointRef(editor, end) @@ -187,8 +179,9 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { if (!isSingleText && !startNonEditable) { const point = startRef.current! - const node = getNode(editor, point.path, editor.schema) - if (isSpan({schema: editor.schema}, node)) { + const nodeEntry = getSpanNode(editor, point.path) + if (nodeEntry) { + const node = nodeEntry.node const {path} = point const {offset} = start const text = node.text.slice(offset) @@ -209,8 +202,9 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { if (!endNonEditable) { const point = endRef.current! - const node = getNode(editor, point.path, editor.schema) - if (isSpan({schema: editor.schema}, node)) { + const endNodeEntry = getSpanNode(editor, point.path) + if (endNodeEntry) { + const node = endNodeEntry.node const {path} = point const offset = isSingleText ? start.offset : 0 const text = node.text.slice(offset, end.offset) @@ -225,32 +219,33 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { const mergeAt: Point = endRef.current const mergeMatch = (n: Node) => isTextBlock({schema: editor.schema}, n) - const [current] = nodes(editor, { - at: mergeAt, - match: mergeMatch, - includeObjectNodes, - mode: 'lowest', - }) - const prev = editorPrevious(editor, { - at: mergeAt, - match: mergeMatch, - includeObjectNodes, - mode: 'lowest', - }) + const current = getAncestor(editor, mergeAt.path, mergeMatch) + const beforePoint = before(editor, mergeAt) + const prev = beforePoint + ? getNodes(editor, { + to: beforePoint.path, + match: mergeMatch, + reverse: true, + }).next().value + : undefined if (current && prev) { - const [mergeNode, mergePath] = current - const [prevNode, prevPath] = prev + const {node: mergeNode, path: mergePath} = current + const {node: prevNode, path: prevPath} = prev if (mergePath.length !== 0 && prevPath.length !== 0) { const newPath = nextPath(prevPath) const common = commonPath(mergePath, prevPath) const isPreviousSibling = isSiblingPath(mergePath, prevPath) - const editorLevels = Array.from( - levels(editor, {at: mergePath}), - ([n]) => n, - ) - .slice(common.length) + const editorLevels = pathLevels(mergePath) + .filter((levelPath) => levelPath.length > 0) + .map((levelPath) => getNode(editor, levelPath)) + .filter( + (entry): entry is {node: Node; path: Array} => + entry !== undefined, + ) + .map((entry) => entry.node) + .slice(Math.max(0, common.length - 1)) .slice(0, -1) // Determine if the merge will leave an ancestor of the path empty @@ -270,13 +265,15 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { } } - const emptyAncestor = above(editor, { - at: mergePath, - mode: 'highest', - match: (n) => editorLevels.includes(n) && hasSingleChildNest(n), - }) + const emptyAncestor = getAncestors(editor, mergePath) + .reverse() + .find( + (ancestor) => + editorLevels.includes(ancestor.node) && + hasSingleChildNest(ancestor.node), + ) - const emptyRef = emptyAncestor && pathRef(editor, emptyAncestor[1]) + const emptyRef = emptyAncestor && pathRef(editor, emptyAncestor.path) let position: number @@ -299,9 +296,16 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { } if (!isPreviousSibling) { - const moveNode = getNode(editor, mergePath, editor.schema) - editor.apply({type: 'remove_node', path: mergePath, node: moveNode}) - editor.apply({type: 'insert_node', path: newPath, node: moveNode}) + const moveNodeEntry = getNode(editor, mergePath) + if (moveNodeEntry) { + const moveNode = moveNodeEntry.node + editor.apply({ + type: 'remove_node', + path: mergePath, + node: moveNode, + }) + editor.apply({type: 'insert_node', path: newPath, node: moveNode}) + } } if (emptyRef) { @@ -311,7 +315,13 @@ export function deleteText(editor: Editor, options: TextDeleteOptions = {}) { }) } - if (shouldMergeNodesRemovePrevNode(editor, prev, current)) { + if ( + shouldMergeNodesRemovePrevNode( + editor, + {node: prevNode, path: prevPath}, + {node: mergeNode, path: mergePath}, + ) + ) { removeNodes(editor, {at: prevPath, includeObjectNodes}) } else { // Copy markDefs from the merging block to the target before merging diff --git a/packages/editor/src/slate/core/get-dirty-paths.ts b/packages/editor/src/slate/core/get-dirty-paths.ts index 438a12b8c..f98173b4b 100644 --- a/packages/editor/src/slate/core/get-dirty-paths.ts +++ b/packages/editor/src/slate/core/get-dirty-paths.ts @@ -1,6 +1,6 @@ import {isSpan} from '@portabletext/schema' +import {getNodeDescendants} from '../../node-traversal/get-nodes' import type {Editor} from '../interfaces/editor' -import {getNodes} from '../node/get-nodes' import {pathAncestors} from '../path/path-ancestors' import {pathLevels} from '../path/path-levels' import type {WithEditorFirstArg} from '../utils/types' @@ -9,7 +9,7 @@ import type {WithEditorFirstArg} from '../utils/types' * Get the "dirty" paths generated from an operation. */ export const getDirtyPaths: WithEditorFirstArg = ( - _editor, + editor, op, ) => { switch (op.type) { @@ -23,9 +23,15 @@ export const getDirtyPaths: WithEditorFirstArg = ( case 'insert_node': { const {node, path} = op const levels = pathLevels(path) - const descendants = isSpan({schema: _editor.schema}, node) - ? [] - : Array.from(getNodes(node, _editor.schema), ([, p]) => path.concat(p)) + + if (isSpan(editor, node)) { + return levels + } + + const descendants = Array.from( + getNodeDescendants(editor, node), + (entry) => path.concat(entry.path), + ) return [...levels, ...descendants] } diff --git a/packages/editor/src/slate/core/insert-nodes.ts b/packages/editor/src/slate/core/insert-nodes.ts index 1c64bacdd..49bc20bc0 100644 --- a/packages/editor/src/slate/core/insert-nodes.ts +++ b/packages/editor/src/slate/core/insert-nodes.ts @@ -1,11 +1,12 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import {applySplitNode} from '../../internal-utils/apply-split-node' +import {getAncestorObjectNode} from '../../node-traversal/get-ancestor-object-node' +import {getNode} from '../../node-traversal/get-node' +import {getNodeDescendants, getNodes} from '../../node-traversal/get-nodes' import {end as editorEnd} from '../editor/end' -import {getObjectNode} from '../editor/get-object-node' import {isEdge} from '../editor/is-edge' import {isEnd} from '../editor/is-end' -import {levels} from '../editor/levels' -import {nodes as editorNodes} from '../editor/nodes' +import {path as editorPath} from '../editor/path' import {pathRef} from '../editor/path-ref' import {pointRef} from '../editor/point-ref' import {unhangRange} from '../editor/unhang-range' @@ -18,7 +19,8 @@ import type {Path} from '../interfaces/path' import type {Point} from '../interfaces/point' import type {PointRef} from '../interfaces/point-ref' import {extractProps} from '../node/extract-props' -import {getNodes} from '../node/get-nodes' +import {isObjectNode} from '../node/is-object-node' +import {comparePaths} from '../path/compare-paths' import {nextPath} from '../path/next-path' import {operationCanTransformPath} from '../path/operation-can-transform-path' import {parentPath} from '../path/parent-path' @@ -78,7 +80,7 @@ export function insertNodes( if (isRange(at)) { if (!hanging) { - at = unhangRange(editor, at, {includeObjectNodes}) + at = unhangRange(editor, at) } if (isCollapsedRange(at)) { @@ -103,15 +105,15 @@ export function insertNodes( } } - const [entry] = editorNodes(editor, { - at: at.path, - match, + const entry = firstNodeWithMode(editor, { + from: editorPath(editor, at.path, {edge: 'start'}), + to: editorPath(editor, at.path, {edge: 'end'}), + match: match!, mode, - includeObjectNodes, }) if (entry) { - const [, matchPath] = entry + const matchPath = entry.path const matchPathRef = pathRef(editor, matchPath) const isAtEnd = isEnd(editor, at, matchPath) @@ -122,25 +124,30 @@ export function insertNodes( }) let afterRef: PointRef | undefined try { - const [highest] = editorNodes(editor, { - at: splitAt, - match, + const highest = firstNodeWithMode(editor, { + from: splitAt.path, + to: splitAt.path, + match: match!, mode, - includeObjectNodes, }) if (highest) { afterRef = pointRef(editor, splitAt) const depth = splitAt.path.length - const [, highestPath] = highest + const highestPath = highest.path const lowestPath = splitAt.path.slice(0, depth) let position = splitAt.offset - for (const [node, nodePath] of levels(editor, { - at: lowestPath, - reverse: true, - includeObjectNodes, - })) { + const levelEntries = pathLevels(lowestPath) + .filter((levelPath) => levelPath.length > 0) + .map((levelPath) => getNode(editor, levelPath)) + .filter( + (entry): entry is {node: Node; path: Array} => + entry !== undefined, + ) + .reverse() + + for (const {node: node, path: nodePath} of levelEntries) { let split = false if ( @@ -180,8 +187,17 @@ export function insertNodes( const parentPath_ = parentPath(at) let index = at[at.length - 1]! - if (!includeObjectNodes && getObjectNode(editor, {at: parentPath_})) { - return + if (!includeObjectNodes) { + const parentNodePath = editorPath(editor, parentPath_) + const parentNodeEntry = getNode(editor, parentNodePath) + const parentObjectNode = + parentNodeEntry && + isObjectNode({schema: editor.schema}, parentNodeEntry.node) + ? parentNodeEntry + : getAncestorObjectNode(editor, parentPath_) + if (parentObjectNode) { + return + } } if (batchDirty) { @@ -205,14 +221,11 @@ export function insertNodes( at = nextPath(at as Path) batchedOps.push(op) - if (isSpan({schema: editor.schema}, node)) { - newDirtyPaths.push(path) - } else { - newDirtyPaths.push( - ...Array.from(getNodes(node, editor.schema), ([, p]) => - path.concat(p), - ), - ) + + if (!isSpan(editor, node)) { + for (const {path: p} of getNodeDescendants(editor, node)) { + newDirtyPaths.push(path.concat(p)) + } } } }, @@ -252,3 +265,44 @@ export function insertNodes( } }) } + +function firstNodeWithMode( + editor: Editor, + options: { + from: Array + to: Array + match: (node: Node, path: Array) => boolean + mode: 'highest' | 'lowest' + }, +): {node: Node; path: Array} | undefined { + const {from, to, match, mode} = options + let hit: {node: Node; path: Array} | undefined + + for (const {node, path: nodePath} of getNodes(editor, { + from, + to, + match, + })) { + const entry = {node, path: nodePath} + const isLower = hit && comparePaths(nodePath, hit.path) === 0 + + if (mode === 'highest' && isLower) { + continue + } + + if (mode === 'lowest' && isLower) { + hit = entry + continue + } + + const emit = mode === 'lowest' ? hit : entry + + if (emit) { + return emit + } + + hit = entry + } + + return hit +} diff --git a/packages/editor/src/slate/core/insert-text.ts b/packages/editor/src/slate/core/insert-text.ts index 64760a3c1..3f67a6893 100644 --- a/packages/editor/src/slate/core/insert-text.ts +++ b/packages/editor/src/slate/core/insert-text.ts @@ -1,10 +1,12 @@ -import {elementReadOnly} from '../editor/element-read-only' -import {getObjectNode} from '../editor/get-object-node' +import {getAncestorObjectNode} from '../../node-traversal/get-ancestor-object-node' +import {getNode} from '../../node-traversal/get-node' +import {path as editorPath} from '../editor/path' import {pointRef} from '../editor/point-ref' import {range as editorRange} from '../editor/range' import {withoutNormalizing} from '../editor/without-normalizing' import type {Editor} from '../interfaces/editor' import type {Location} from '../interfaces/location' +import {isObjectNode} from '../node/is-object-node' import {isPath} from '../path/is-path' import {isCollapsedRange} from '../range/is-collapsed-range' import {isRange} from '../range/is-range' @@ -36,8 +38,16 @@ export function insertText( at = at.anchor } else { const end = rangeEnd(at) - if (!includeObjectNodes && getObjectNode(editor, {at: end})) { - return + if (!includeObjectNodes) { + const endPath = editorPath(editor, end) + const endEntry = getNode(editor, endPath) + const endObjectNode = + endEntry && isObjectNode({schema: editor.schema}, endEntry.node) + ? endEntry + : getAncestorObjectNode(editor, end.path) + if (endObjectNode) { + return + } } const start = rangeStart(at) const startRef = pointRef(editor, start) @@ -51,13 +61,17 @@ export function insertText( } } - if ( - (!includeObjectNodes && getObjectNode(editor, {at})) || - elementReadOnly(editor, {at}) - ) { - return + if (!includeObjectNodes) { + const atPath = editorPath(editor, at) + const atEntry = getNode(editor, atPath) + const atObjectNode = + atEntry && isObjectNode({schema: editor.schema}, atEntry.node) + ? atEntry + : getAncestorObjectNode(editor, at.path) + if (atObjectNode) { + return + } } - const {path, offset} = at if (text.length > 0) { editor.apply({type: 'insert_text', path, offset, text}) diff --git a/packages/editor/src/slate/core/normalize-node.ts b/packages/editor/src/slate/core/normalize-node.ts index 81df5d661..5d3a7aba8 100644 --- a/packages/editor/src/slate/core/normalize-node.ts +++ b/packages/editor/src/slate/core/normalize-node.ts @@ -1,10 +1,10 @@ import type {PortableTextTextBlock} from '@portabletext/schema' import {isSpan, isTextBlock} from '@portabletext/schema' import {applyMergeNode} from '../../internal-utils/apply-merge-node' +import {getTextBlockNode} from '../../node-traversal/get-text-block-node' import {isEditor} from '../editor/is-editor' import type {Editor} from '../interfaces/editor' import type {Node} from '../interfaces/node' -import {getTextBlockNode} from '../node/get-text-block-node' import {isObjectNode} from '../node/is-object-node' import {textEquals} from '../text/text-equals' import type {WithEditorFirstArg} from '../utils/types' @@ -38,7 +38,11 @@ export const normalizeNode: WithEditorFirstArg = ( if (element !== editor && element.children.length === 0) { const child = editor.createSpan() insertNodes(editor, [child], {at: path.concat(0), includeObjectNodes: true}) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched } // Determine whether the node should have only block or only inline children. @@ -71,19 +75,31 @@ export const normalizeNode: WithEditorFirstArg = ( at: path.concat(n), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n-- } else if (prev.text === '') { removeNodes(editor, { at: path.concat(n - 1), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n-- } else if (textEquals(child, prev, {loose: true})) { const mergePath = path.concat(n) applyMergeNode(editor, mergePath, prev.text.length) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n-- } } @@ -96,7 +112,11 @@ export const normalizeNode: WithEditorFirstArg = ( at: path.concat(n), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n++ } if (n === element.children.length - 1) { @@ -105,13 +125,21 @@ export const normalizeNode: WithEditorFirstArg = ( at: path.concat(n + 1), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n++ } } else { // An Element cannot appear inline in another Element removeNodes(editor, {at: path.concat(n), includeObjectNodes: true}) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n-- } } else if (isObjectNode({schema: editor.schema}, child)) { @@ -121,7 +149,11 @@ export const normalizeNode: WithEditorFirstArg = ( at: path.concat(n), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n++ } if (n === element.children.length - 1) { @@ -130,7 +162,11 @@ export const normalizeNode: WithEditorFirstArg = ( at: path.concat(n + 1), includeObjectNodes: true, }) - element = getTextBlockNode(editor, path, editor.schema) + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched n++ } } @@ -148,10 +184,15 @@ export const normalizeNode: WithEditorFirstArg = ( (isTextBlock({schema: editor.schema}, child) && editor.isInline(child)) ) { removeNodes(editor, {at: path.concat(n), includeObjectNodes: true}) - element = - path.length === 0 - ? editor - : getTextBlockNode(editor, path, editor.schema) + if (path.length === 0) { + element = editor + } else { + const refetched = getTextBlockNode(editor, path)?.node + if (!refetched) { + return + } + element = refetched + } n-- } } diff --git a/packages/editor/src/slate/core/remove-nodes.ts b/packages/editor/src/slate/core/remove-nodes.ts index 19eefc6f9..41866c0a9 100644 --- a/packages/editor/src/slate/core/remove-nodes.ts +++ b/packages/editor/src/slate/core/remove-nodes.ts @@ -1,14 +1,17 @@ import {isTextBlock} from '@portabletext/schema' -import {node as editorNode} from '../editor/node' -import {nodes} from '../editor/nodes' +import {getNode} from '../../node-traversal/get-node' +import {getNodes} from '../../node-traversal/get-nodes' +import {path as editorPath} from '../editor/path' import {pathRef} from '../editor/path-ref' import {unhangRange} from '../editor/unhang-range' import {withoutNormalizing} from '../editor/without-normalizing' import type {Editor, NodeMatch} from '../interfaces/editor' import type {Location} from '../interfaces/location' import type {Node} from '../interfaces/node' +import {comparePaths} from '../path/compare-paths' import {isPath} from '../path/is-path' import {isRange} from '../range/is-range' +import {rangeEdges} from '../range/range-edges' import type {RangeMode} from '../types/types' import {matchPath} from '../utils/match-path' @@ -25,11 +28,7 @@ export function removeNodes( options: RemoveNodesOptions = {}, ): void { withoutNormalizing(editor, () => { - const { - hanging = false, - includeObjectNodes = false, - mode = 'lowest', - } = options + const {hanging = false, mode = 'lowest'} = options let {at = editor.selection, match} = options if (!at) { @@ -43,18 +42,70 @@ export function removeNodes( } if (!hanging && isRange(at)) { - at = unhangRange(editor, at, {includeObjectNodes}) + at = unhangRange(editor, at) + } + + // Resolve location to from/to paths + let from: Array + let to: Array + + if (isRange(at)) { + const [start, end] = rangeEdges(at) + from = start.path + to = end.path + } else if (isPath(at)) { + from = editorPath(editor, at, {edge: 'start'}) + to = editorPath(editor, at, {edge: 'end'}) + } else { + // Point + from = at.path + to = at.path + } + + // Apply mode filtering (replicating old nodes() behavior) + const depths: Array<[Node, Array]> = [] + let hit: [Node, Array] | undefined + + for (const {node, path: nodePath} of getNodes(editor, { + from, + to, + match: (n, p) => match!(n, p), + })) { + const isLower = hit && comparePaths(nodePath, hit[1]) === 0 + + if (mode === 'highest' && isLower) { + continue + } + + if (mode === 'lowest' && isLower) { + hit = [node, nodePath] + continue + } + + const emit = mode === 'lowest' ? hit : [node, nodePath] + + if (emit) { + depths.push(emit as [Node, Array]) + } + + hit = [node, nodePath] + } + + if (mode === 'lowest' && hit) { + depths.push(hit) } - const depths = nodes(editor, {at, match, mode, includeObjectNodes}) const pathRefs = Array.from(depths, ([, p]) => pathRef(editor, p)) for (const ref of pathRefs) { const path = ref.unref()! if (path) { - const [removedNode] = editorNode(editor, path) - editor.apply({type: 'remove_node', path, node: removedNode}) + const removedEntry = getNode(editor, path) + if (removedEntry) { + const removedNode = removedEntry.node + editor.apply({type: 'remove_node', path, node: removedNode}) + } } } }) diff --git a/packages/editor/src/slate/create-editor.ts b/packages/editor/src/slate/create-editor.ts index 8d693d833..9286d998a 100644 --- a/packages/editor/src/slate/create-editor.ts +++ b/packages/editor/src/slate/create-editor.ts @@ -25,6 +25,9 @@ export const createEditor = (context: { const e: any = { [EDITOR_BRAND]: true, children: [], + get value() { + return this.children + }, operations: [], selection: null, marks: null, @@ -42,7 +45,6 @@ export const createEditor = (context: { text: '', marks: [], }), - isElementReadOnly: () => false, isInline: () => false, onChange: () => {}, diff --git a/packages/editor/src/slate/dom/plugin/dom-editor.ts b/packages/editor/src/slate/dom/plugin/dom-editor.ts index 6e6f8eeb7..5a47e575e 100644 --- a/packages/editor/src/slate/dom/plugin/dom-editor.ts +++ b/packages/editor/src/slate/dom/plugin/dom-editor.ts @@ -1,10 +1,12 @@ import {getDomNode} from '../../../dom-traversal/get-dom-node' import {getDomNodePath} from '../../../dom-traversal/get-dom-node-path' import {safeStringify} from '../../../internal-utils/safe-json' +import {getAncestorObjectNode} from '../../../node-traversal/get-ancestor-object-node' +import {getHighestObjectNode} from '../../../node-traversal/get-highest-object-node' +import {getNode} from '../../../node-traversal/get-node' +import {hasNode} from '../../../node-traversal/has-node' import {keyedPathToIndexedPath} from '../../../paths/keyed-path-to-indexed-path' -import {getObjectNode} from '../../editor/get-object-node' -import {hasPath} from '../../editor/has-path' -import {node as editorNode} from '../../editor/node' +import {path as editorPath} from '../../editor/path' import {start as editorStart} from '../../editor/start' import {unhangRange} from '../../editor/unhang-range' import type {BaseEditor, Editor, EditorMarks} from '../../interfaces/editor' @@ -12,7 +14,6 @@ import type {Operation} from '../../interfaces/operation' import type {Point} from '../../interfaces/point' import type {Range} from '../../interfaces/range' import type {RangeRef} from '../../interfaces/range-ref' -import {getNode} from '../../node/get-node' import {isObjectNode} from '../../node/is-object-node' import {isBackwardRange} from '../../range/is-backward-range' import {isCollapsedRange} from '../../range/is-collapsed-range' @@ -331,7 +332,7 @@ export const DOMEditor: DOMEditorInterface = { hasRange: (editor, range) => { const {anchor, focus} = range - return hasPath(editor, anchor.path) && hasPath(editor, focus.path) + return hasNode(editor, anchor.path) && hasNode(editor, focus.path) }, hasSelectableTarget: (editor, target) => @@ -361,7 +362,7 @@ export const DOMEditor: DOMEditorInterface = { }, toDOMPoint: (editor, point) => { - const [node] = editorNode(editor, point.path) + const nodeEntry = getNode(editor, point.path) const el = getDomNode(editor, point.path) if (!el) { @@ -370,7 +371,7 @@ export const DOMEditor: DOMEditorInterface = { let domPoint: DOMPoint | undefined - if (isObjectNode({schema: editor.schema}, node)) { + if (nodeEntry && isObjectNode({schema: editor.schema}, nodeEntry.node)) { const spacer = el.querySelector('[data-slate-zero-width]') if (spacer) { const domText = spacer.childNodes[0] @@ -391,7 +392,13 @@ export const DOMEditor: DOMEditorInterface = { // If we're inside an object node, force the offset to 0, otherwise the zero // width spacing character will result in an incorrect offset of 1 - if (getObjectNode(editor, {at: point})) { + const pointPath = editorPath(editor, point) + const pointEntry = getNode(editor, pointPath) + const pointObjectNode = + pointEntry && isObjectNode({schema: editor.schema}, pointEntry.node) + ? pointEntry + : getAncestorObjectNode(editor, point.path) + if (pointObjectNode) { point = {path: point.path, offset: 0} } @@ -786,14 +793,13 @@ export const DOMEditor: DOMEditorInterface = { // Truncate paths that resolve to the spacer's virtual text node. if (indexedPath.length > 1) { const parentPath = indexedPath.slice(0, -1) - try { - const parentSlateNode = getNode(editor, parentPath, editor.schema) + const parentEntry = getNode(editor, parentPath) - if (isObjectNode({schema: editor.schema}, parentSlateNode)) { - return {path: parentPath, offset: 0} - } - } catch { - // Parent path doesn't exist in the tree, continue with original path + if ( + parentEntry && + isObjectNode({schema: editor.schema}, parentEntry.node) + ) { + return {path: parentPath, offset: 0} } } @@ -973,9 +979,9 @@ export const DOMEditor: DOMEditorInterface = { isExpandedRange(range) && isForwardRange(range) && isDOMElement(focusNode) && - getObjectNode(editor, {at: range.focus, mode: 'highest'}) + getHighestObjectNode(editor, range.focus.path) ) { - range = unhangRange(editor, range, {includeObjectNodes: true}) + range = unhangRange(editor, range) } return range as unknown as T extends true ? Range | null : Range diff --git a/packages/editor/src/slate/dom/utils/diff-text.ts b/packages/editor/src/slate/dom/utils/diff-text.ts index 0e5b6ef77..1fe1a8292 100644 --- a/packages/editor/src/slate/dom/utils/diff-text.ts +++ b/packages/editor/src/slate/dom/utils/diff-text.ts @@ -1,13 +1,14 @@ -import {isSpan, isTextBlock} from '@portabletext/schema' -import {above} from '../../editor/above' -import {hasPath} from '../../editor/has-path' -import {next as editorNext} from '../../editor/next' +import {isSpan} from '@portabletext/schema' +import {getAncestorTextBlock} from '../../../node-traversal/get-ancestor-text-block' +import {getNodes} from '../../../node-traversal/get-nodes' +import {getSpanNode} from '../../../node-traversal/get-span-node' +import {hasNode} from '../../../node-traversal/has-node' +import {after} from '../../editor/after' import type {Editor} from '../../interfaces/editor' import type {Operation} from '../../interfaces/operation' import type {Path} from '../../interfaces/path' import type {Point} from '../../interfaces/point' import type {Range} from '../../interfaces/range' -import {getNode} from '../../node/get-node' import {isDescendantPath} from '../../path/is-descendant-path' import {nextPath as getNextPath} from '../../path/next-path' import {pathEquals} from '../../path/path-equals' @@ -33,14 +34,15 @@ export type TextDiff = { */ export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { const {path, diff} = textDiff - if (!hasPath(editor, path)) { + if (!hasNode(editor, path)) { return false } - const node = getNode(editor, path, editor.schema) - if (!isSpan({schema: editor.schema}, node)) { + const nodeEntry = getSpanNode(editor, path) + if (!nodeEntry) { return false } + const node = nodeEntry.node if (diff.start !== node.text.length || diff.text.length === 0) { return ( @@ -49,15 +51,12 @@ export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { } const nextPath = getNextPath(path) - if (!hasPath(editor, nextPath)) { + if (!hasNode(editor, nextPath)) { return false } - const nextNode = getNode(editor, nextPath, editor.schema) - return ( - isSpan({schema: editor.schema}, nextNode) && - nextNode.text.startsWith(diff.text) - ) + const nextNodeEntry = getSpanNode(editor, nextPath) + return !!nextNodeEntry && nextNodeEntry.node.text.startsWith(diff.text) } export function applyStringDiff(text: string, ...diffs: StringDiff[]) { @@ -173,36 +172,41 @@ export function targetRange(textDiff: TextDiff): Range { */ export function normalizePoint(editor: Editor, point: Point): Point | null { let {path, offset} = point - if (!hasPath(editor, path)) { + if (!hasNode(editor, path)) { return null } - let leaf = getNode(editor, path, editor.schema) - if (!isSpan({schema: editor.schema}, leaf)) { + const leafEntry = getSpanNode(editor, path) + if (!leafEntry) { return null } + let leaf = leafEntry.node - const parentBlock = above(editor, { - match: (n) => isTextBlock({schema: editor.schema}, n), - at: path, - }) + const parentBlock = getAncestorTextBlock(editor, path) if (!parentBlock) { return null } while (offset > leaf.text.length) { - const entry = editorNext(editor, { - at: path, - match: (n) => isSpan({schema: editor.schema}, n), - }) - if (!entry || !isDescendantPath(entry[1], parentBlock[1])) { + const afterPoint = after(editor, path) + const [nextEntry] = afterPoint + ? getNodes(editor, { + from: afterPoint.path, + match: (n) => isSpan({schema: editor.schema}, n), + }) + : [] + if ( + !nextEntry || + !isSpan({schema: editor.schema}, nextEntry.node) || + !isDescendantPath(nextEntry.path, parentBlock.path) + ) { return null } offset -= leaf.text.length - leaf = entry[0] - path = entry[1] + leaf = nextEntry.node + path = nextEntry.path } return {path, offset} diff --git a/packages/editor/src/slate/editor/above.ts b/packages/editor/src/slate/editor/above.ts deleted file mode 100644 index c43aee068..000000000 --- a/packages/editor/src/slate/editor/above.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type {Editor, NodeMatch} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import {parentPath} from '../path/parent-path' -import {pathEquals} from '../path/path-equals' -import {isRange} from '../range/is-range' -import type {MaximizeMode} from '../types/types' -import {levels} from './levels' -import {path} from './path' - -export function above( - editor: Editor, - options: { - at?: Location - match: NodeMatch - mode?: MaximizeMode - includeObjectNodes?: boolean - }, -): NodeEntry | undefined { - const { - includeObjectNodes = false, - mode = 'lowest', - at = editor.selection, - match, - } = options - - if (!at) { - return - } - - let fromPath = path(editor, at) - - // If `at` is a Range that spans mulitple nodes, `path` will be their common ancestor. - // Otherwise `path` will be a text node and/or the same as `at`, in which cases we want to start with its parent. - if (!isRange(at) || pathEquals(at.focus.path, at.anchor.path)) { - if (fromPath.length === 0) { - return - } - fromPath = parentPath(fromPath) - } - - const reverse = mode === 'lowest' - - const [firstMatch] = levels(editor, { - at: fromPath, - includeObjectNodes, - match, - reverse, - }) - return firstMatch -} diff --git a/packages/editor/src/slate/editor/after.ts b/packages/editor/src/slate/editor/after.ts index f93a25c19..552a95ddf 100644 --- a/packages/editor/src/slate/editor/after.ts +++ b/packages/editor/src/slate/editor/after.ts @@ -12,7 +12,6 @@ export function after( options: { distance?: number unit?: TextUnitAdjustment - includeObjectNodes?: boolean } = {}, ): Point | undefined { const anchor = point(editor, at, {edge: 'end'}) diff --git a/packages/editor/src/slate/editor/before.ts b/packages/editor/src/slate/editor/before.ts index 360ee4963..c598684dc 100644 --- a/packages/editor/src/slate/editor/before.ts +++ b/packages/editor/src/slate/editor/before.ts @@ -12,7 +12,6 @@ export function before( options: { distance?: number unit?: TextUnitAdjustment - includeObjectNodes?: boolean } = {}, ): Point | undefined { const anchor = start(editor, []) diff --git a/packages/editor/src/slate/editor/element-read-only.ts b/packages/editor/src/slate/editor/element-read-only.ts deleted file mode 100644 index 94d390bda..000000000 --- a/packages/editor/src/slate/editor/element-read-only.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - PortableTextObject, - PortableTextTextBlock, -} from '@portabletext/schema' -import {isTextBlock} from '@portabletext/schema' -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {NodeEntry} from '../interfaces/node' -import type {MaximizeMode} from '../types/types' -import {above} from './above' - -export function elementReadOnly( - editor: Editor, - options: { - at?: Location - mode?: MaximizeMode - includeObjectNodes?: boolean - } = {}, -): NodeEntry | undefined { - return above(editor, { - ...options, - match: (n) => - isTextBlock({schema: editor.schema}, n) && editor.isElementReadOnly(n), - }) -} diff --git a/packages/editor/src/slate/editor/get-object-node.ts b/packages/editor/src/slate/editor/get-object-node.ts deleted file mode 100644 index 0f87d7865..000000000 --- a/packages/editor/src/slate/editor/get-object-node.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {PortableTextObject} from '@portabletext/schema' -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {NodeEntry} from '../interfaces/node' -import {getNode} from '../node/get-node' -import {isObjectNode} from '../node/is-object-node' -import type {MaximizeMode} from '../types/types' -import {above} from './above' -import {path} from './path' - -export function getObjectNode( - editor: Editor, - options: {at?: Location; mode?: MaximizeMode} = {}, -): NodeEntry | undefined { - const {at = editor.selection} = options - if (!at) { - return - } - - const nodePath = path(editor, at) - const node = getNode(editor, nodePath, editor.schema) - - if (isObjectNode({schema: editor.schema}, node)) { - return [node, nodePath] - } - - return above(editor, { - ...options, - match: (n) => isObjectNode({schema: editor.schema}, n), - }) -} diff --git a/packages/editor/src/slate/editor/has-inlines.ts b/packages/editor/src/slate/editor/has-inlines.ts deleted file mode 100644 index 792ae4a2c..000000000 --- a/packages/editor/src/slate/editor/has-inlines.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {PortableTextTextBlock} from '@portabletext/schema' -import {isSpan} from '@portabletext/schema' -import type {Editor} from '../interfaces/editor' - -export function hasInlines( - editor: Editor, - element: PortableTextTextBlock, -): boolean { - return element.children.some((n) => { - if (isSpan({schema: editor.schema}, n)) { - return true - } - return editor.isInline(n) - }) -} diff --git a/packages/editor/src/slate/editor/has-path.ts b/packages/editor/src/slate/editor/has-path.ts deleted file mode 100644 index 793003e32..000000000 --- a/packages/editor/src/slate/editor/has-path.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {Editor} from '../interfaces/editor' -import type {Path} from '../interfaces/path' -import {hasNode} from '../node/has-node' - -export function hasPath(editor: Editor, path: Path): boolean { - return hasNode(editor, path, editor.schema) -} diff --git a/packages/editor/src/slate/editor/leaf.ts b/packages/editor/src/slate/editor/leaf.ts deleted file mode 100644 index 6bfc2d395..000000000 --- a/packages/editor/src/slate/editor/leaf.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type {PortableTextObject, PortableTextSpan} from '@portabletext/schema' -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {NodeEntry} from '../interfaces/node' -import {getLeaf} from '../node/get-leaf' -import type {LeafEdge} from '../types/types' -import {path} from './path' - -export function leaf( - editor: Editor, - at: Location, - options: {depth?: number; edge?: LeafEdge} = {}, -): NodeEntry { - const leafPath = path(editor, at, options) - const node = getLeaf(editor, leafPath, editor.schema) - return [node, leafPath] -} diff --git a/packages/editor/src/slate/editor/levels.ts b/packages/editor/src/slate/editor/levels.ts deleted file mode 100644 index 72e43b0f2..000000000 --- a/packages/editor/src/slate/editor/levels.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type {Editor, NodeMatch} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import {getLevels} from '../node/get-levels' -import {path} from './path' - -export function* levels( - editor: Editor, - options: { - at?: Location - match?: NodeMatch - reverse?: boolean - includeObjectNodes?: boolean - } = {}, -): Generator, void, undefined> { - const {at = editor.selection, reverse = false} = options - let {match} = options - - if (match == null) { - match = () => true - } - - if (!at) { - return - } - - const levels: NodeEntry[] = [] - const fromPath = path(editor, at) - - for (const [n, p] of getLevels(editor, fromPath, editor.schema)) { - if (!match(n, p)) { - continue - } - - levels.push([n as T, p] satisfies NodeEntry) - } - - if (reverse) { - levels.reverse() - } - - yield* levels -} diff --git a/packages/editor/src/slate/editor/next.ts b/packages/editor/src/slate/editor/next.ts deleted file mode 100644 index 12a1230a5..000000000 --- a/packages/editor/src/slate/editor/next.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type {Editor, NodeMatch} from '../interfaces/editor' -import type {Location, Span} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import {isPath} from '../path/is-path' -import type {SelectionMode} from '../types/types' -import {after} from './after' -import {node} from './node' -import {nodes} from './nodes' -import {path} from './path' - -export function next( - editor: Editor, - options: { - at?: Location - match: NodeMatch - mode?: SelectionMode - includeObjectNodes?: boolean - }, -): NodeEntry | undefined { - const {mode = 'lowest', includeObjectNodes = false} = options - const {match, at = editor.selection} = options - - if (!at) { - return - } - - const pointAfterLocation = after(editor, at, {includeObjectNodes}) - - if (!pointAfterLocation) { - return - } - - const [, to] = node(editor, path(editor, [], {edge: 'end'})) - - const span: Span = [pointAfterLocation.path, to] - - if (isPath(at) && at.length === 0) { - throw new Error(`Cannot get the next node from the root node!`) - } - - const [nextEntry] = nodes(editor, {at: span, match, mode, includeObjectNodes}) - - return nextEntry -} diff --git a/packages/editor/src/slate/editor/node.ts b/packages/editor/src/slate/editor/node.ts deleted file mode 100644 index 480dddaf5..000000000 --- a/packages/editor/src/slate/editor/node.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import {getNode} from '../node/get-node' -import type {LeafEdge} from '../types/types' -import {path} from './path' - -export function node( - editor: Editor, - at: Location, - options: {depth?: number; edge?: LeafEdge} = {}, -): NodeEntry { - const nodePath = path(editor, at, options) - const nodeValue = getNode(editor, nodePath, editor.schema) - return [nodeValue, nodePath] -} diff --git a/packages/editor/src/slate/editor/nodes.ts b/packages/editor/src/slate/editor/nodes.ts deleted file mode 100644 index 1d47d6659..000000000 --- a/packages/editor/src/slate/editor/nodes.ts +++ /dev/null @@ -1,109 +0,0 @@ -import {isTextBlock} from '@portabletext/schema' -import type {Editor, NodeMatch} from '../interfaces/editor' -import type {Location, Span} from '../interfaces/location' -import {Span as SpanUtils} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNodes} from '../node/get-nodes' -import {comparePaths} from '../path/compare-paths' -import type {SelectionMode} from '../types/types' -import {path} from './path' - -export function* nodes( - editor: Editor, - options: { - at?: Location | Span - match?: NodeMatch - mode?: SelectionMode - reverse?: boolean - includeObjectNodes?: boolean - } = {}, -): Generator, void, undefined> { - const { - at = editor.selection, - mode, - reverse = false, - includeObjectNodes = false, - } = options - let {match} = options - - if (!match) { - match = () => true - } - - if (!at) { - return - } - - let from: Path - let to: Path - - if (SpanUtils.isSpan(at)) { - from = at[0] - to = at[1] - } else { - const first = path(editor, at, {edge: 'start'}) - const last = path(editor, at, {edge: 'end'}) - from = reverse ? last : first - to = reverse ? first : last - } - - const nodeEntries = getNodes(editor, editor.schema, { - reverse, - from, - to, - pass: ([node]) => { - if (!isTextBlock({schema: editor.schema}, node)) { - return false - } - if (!includeObjectNodes && editor.isElementReadOnly(node)) { - return true - } - - return false - }, - }) - - let hit: NodeEntry | undefined - - for (const [node, path] of nodeEntries) { - const isLower = hit && comparePaths(path, hit[1]) === 0 - - // In highest mode any node lower than the last hit is not a match. - if (mode === 'highest' && isLower) { - continue - } - - if (!match(node, path)) { - continue - } - - // When no mode is specified, yield every matching node. - if (!mode) { - yield [node as T, path] satisfies NodeEntry - hit = [node as T, path] satisfies NodeEntry - continue - } - - // If there's a match and it's lower than the last, update the hit. - if (mode === 'lowest' && isLower) { - hit = [node as T, path] satisfies NodeEntry - continue - } - - // In lowest mode we emit the last hit, once it's guaranteed lowest. - const emit: NodeEntry | undefined = - mode === 'lowest' ? hit : ([node as T, path] satisfies NodeEntry) - - if (emit) { - yield emit - } - - hit = [node as T, path] satisfies NodeEntry - } - - // Since lowest is always emitting one behind, catch up at the end. - if (mode === 'lowest' && hit) { - yield hit - } -} diff --git a/packages/editor/src/slate/editor/normalize.ts b/packages/editor/src/slate/editor/normalize.ts index 633f253ff..d2090d185 100644 --- a/packages/editor/src/slate/editor/normalize.ts +++ b/packages/editor/src/slate/editor/normalize.ts @@ -1,11 +1,11 @@ import {isTextBlock} from '@portabletext/schema' +import {getNode} from '../../node-traversal/get-node' +import {getNodes} from '../../node-traversal/get-nodes' +import {hasNode} from '../../node-traversal/has-node' import type {Editor} from '../interfaces/editor' import type {Operation} from '../interfaces/operation' import type {Path} from '../interfaces/path' -import {getNodes} from '../node/get-nodes' -import {hasNode} from '../node/has-node' import {isNormalizing} from './is-normalizing' -import {node} from './node' import {withoutNormalizing} from './without-normalizing' export function normalize( @@ -33,7 +33,7 @@ export function normalize( } if (force) { - const allPaths = Array.from(getNodes(editor, editor.schema), ([, p]) => p) + const allPaths = Array.from(getNodes(editor), (entry) => entry.path) const allPathKeys = new Set(allPaths.map((p) => p.join(','))) editor.dirtyPaths = allPaths editor.dirtyPathKeys = allPathKeys @@ -54,9 +54,12 @@ export function normalize( continue } - if (hasNode(editor, dirtyPath, editor.schema)) { - const entry = node(editor, dirtyPath) - const [entryNode, _] = entry + if (hasNode(editor, dirtyPath)) { + const entry = getNode(editor, dirtyPath) + if (!entry) { + continue + } + const entryNode = entry.node /* The default normalizer inserts an empty text node in this scenario, but it can be customised. @@ -69,7 +72,7 @@ export function normalize( isTextBlock({schema: editor.schema}, entryNode) && entryNode.children.length === 0 ) { - editor.normalizeNode(entry, {operation}) + editor.normalizeNode([entry.node, entry.path], {operation}) } } } @@ -95,9 +98,11 @@ export function normalize( // If the node doesn't exist in the tree, it does not need to be normalized. if (dirtyPath.length === 0) { editor.normalizeNode([editor, dirtyPath], {operation}) - } else if (hasNode(editor, dirtyPath, editor.schema)) { - const entry = node(editor, dirtyPath) - editor.normalizeNode(entry, {operation}) + } else if (hasNode(editor, dirtyPath)) { + const entry = getNode(editor, dirtyPath) + if (entry) { + editor.normalizeNode([entry.node, entry.path], {operation}) + } } iteration++ dirtyPaths = getDirtyPaths(editor) diff --git a/packages/editor/src/slate/editor/parent.ts b/packages/editor/src/slate/editor/parent.ts deleted file mode 100644 index e86d608fc..000000000 --- a/packages/editor/src/slate/editor/parent.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {parentPath} from '../path/parent-path' -import type {LeafEdge} from '../types/types' -import {node} from './node' -import {path} from './path' - -export function parent( - editor: Editor, - at: Location, - options: {depth?: number; edge?: LeafEdge} = {}, -): NodeEntry | [Editor, Path] { - const nodePath = path(editor, at, options) - const parentPath_ = parentPath(nodePath) - - if (parentPath_.length === 0) { - return [editor, parentPath_] - } - - return node(editor, parentPath_) -} diff --git a/packages/editor/src/slate/editor/path.ts b/packages/editor/src/slate/editor/path.ts index ae9bb0b8e..fa18e335c 100644 --- a/packages/editor/src/slate/editor/path.ts +++ b/packages/editor/src/slate/editor/path.ts @@ -1,8 +1,7 @@ +import {getLeaf} from '../../node-traversal/get-leaf' import type {Editor} from '../interfaces/editor' import type {Location} from '../interfaces/location' import type {Path} from '../interfaces/path' -import {getFirst} from '../node/get-first' -import {getLast} from '../node/get-last' import {commonPath} from '../path/common-path' import {isPath} from '../path/is-path' import {isPoint} from '../point/is-point' @@ -19,12 +18,11 @@ export function path( const {depth, edge} = options if (isPath(at)) { - if (edge === 'start') { - const [, firstPath] = getFirst(editor, at, editor.schema) - at = firstPath - } else if (edge === 'end') { - const [, lastPath] = getLast(editor, at, editor.schema) - at = lastPath + if (edge === 'start' || edge === 'end') { + const leaf = getLeaf(editor, at, {edge}) + if (leaf) { + at = leaf.path + } } } diff --git a/packages/editor/src/slate/editor/point.ts b/packages/editor/src/slate/editor/point.ts index 925d5548a..ec40052a5 100644 --- a/packages/editor/src/slate/editor/point.ts +++ b/packages/editor/src/slate/editor/point.ts @@ -1,11 +1,9 @@ import {isSpan, isTextBlock} from '@portabletext/schema' +import {getLeaf} from '../../node-traversal/get-leaf' import type {Editor} from '../interfaces/editor' import type {Location} from '../interfaces/location' import type {Path} from '../interfaces/path' import type {Point} from '../interfaces/point' -import {getFirst} from '../node/get-first' -import {getLast} from '../node/get-last' -import {getNode} from '../node/get-node' import {isPath} from '../path/is-path' import {isRange} from '../range/is-range' import {rangeEdges} from '../range/range-edges' @@ -22,15 +20,17 @@ export function point( if (isPath(at)) { let path: Path - if (edge === 'end') { - const [, lastPath] = getLast(editor, at, editor.schema) - path = lastPath - } else { - const [, firstPath] = getFirst(editor, at, editor.schema) - path = firstPath + const deepest = getLeaf(editor, at, { + edge: edge === 'end' ? 'end' : 'start', + }) + if (!deepest) { + throw new Error( + `Cannot get the ${edge} point in the node at path [${at}] because it has no ${edge} text node.`, + ) } - const node = getNode(editor, path, editor.schema) + const {node, path: nodePath} = deepest + path = nodePath if ( !isSpan({schema: editor.schema}, node) && diff --git a/packages/editor/src/slate/editor/positions.ts b/packages/editor/src/slate/editor/positions.ts index 3d80ab2d5..4fd78a036 100644 --- a/packages/editor/src/slate/editor/positions.ts +++ b/packages/editor/src/slate/editor/positions.ts @@ -1,4 +1,5 @@ import {isSpan, isTextBlock} from '@portabletext/schema' +import {getNodes} from '../../node-traversal/get-nodes' import type {Editor} from '../interfaces/editor' import type {Location} from '../interfaces/location' import type {Point} from '../interfaces/point' @@ -13,12 +14,8 @@ import { splitByCharacterDistance, } from '../utils/string' import {end as editorEnd} from './end' -import {hasInlines} from './has-inlines' -import {isEditor} from './is-editor' -import {nodes} from './nodes' import {range} from './range' import {start as editorStart} from './start' -import {string} from './string' export function* positions( editor: Editor, @@ -26,15 +23,9 @@ export function* positions( at?: Location unit?: TextUnitAdjustment reverse?: boolean - includeObjectNodes?: boolean } = {}, ): Generator { - const { - at = editor.selection, - unit = 'offset', - reverse = false, - includeObjectNodes = false, - } = options + const {at = editor.selection, unit = 'offset', reverse = false} = options if (!at) { return @@ -73,23 +64,15 @@ export function* positions( // 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, nodePath] of nodes(editor, { - at, + for (const {node, path: nodePath} of getNodes(editor, { + from: start.path, + to: end.path, reverse, - includeObjectNodes, })) { /* * ELEMENT NODE - Yield position(s) for object nodes, collect blockText for blocks */ if (isTextBlock({schema: editor.schema}, node)) { - // Object nodes are a special case, so by default we will always - // yield their first point. If the `includeObjectNodes` option is set to true, - // then we will iterate over their content. - if (!includeObjectNodes && editor.isElementReadOnly(node)) { - yield editorStart(editor, nodePath) - continue - } - // Inline element nodes are ignored as they don't themselves // contribute to `blockText` or `leafText` - their parent and // children do. @@ -98,7 +81,7 @@ export function* positions( } // Block element node - set `blockText` to its text content. - if (hasInlines(editor, node)) { + { // We always exhaust block nodes before encountering a new one: // console.assert(blockText === '', // `blockText='${blockText}' - `+ @@ -117,7 +100,24 @@ export function* positions( ? start : editorStart(editor, nodePath) - blockText = string(editor, {anchor: s, focus: e}, {includeObjectNodes}) + blockText = '' + for (const {node: spanNode, path: spanPath} of getNodes(editor, { + from: s.path, + to: e.path, + match: (n) => isSpan({schema: editor.schema}, n), + })) { + if (!isSpan({schema: editor.schema}, spanNode)) { + continue + } + let spanText = spanNode.text + if (pathEquals(spanPath, e.path)) { + spanText = spanText.slice(0, e.offset) + } + if (pathEquals(spanPath, s.path)) { + spanText = spanText.slice(s.offset) + } + blockText += spanText + } isNewBlock = true } } @@ -128,7 +128,6 @@ export function* positions( } if ( - !isEditor(node) && !isTextBlock({schema: editor.schema}, node) && !isSpan({schema: editor.schema}, node) ) { diff --git a/packages/editor/src/slate/editor/previous.ts b/packages/editor/src/slate/editor/previous.ts deleted file mode 100644 index 84f2546dd..000000000 --- a/packages/editor/src/slate/editor/previous.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type {Editor, NodeMatch} from '../interfaces/editor' -import type {Location, Span} from '../interfaces/location' -import type {Node, NodeEntry} from '../interfaces/node' -import {isPath} from '../path/is-path' -import type {SelectionMode} from '../types/types' -import {before} from './before' -import {node} from './node' -import {nodes} from './nodes' -import {path} from './path' - -export function previous( - editor: Editor, - options: { - at?: Location - match: NodeMatch - mode?: SelectionMode - includeObjectNodes?: boolean - }, -): NodeEntry | undefined { - const {mode = 'lowest', includeObjectNodes = false} = options - const {match, at = editor.selection} = options - - if (!at) { - return - } - - const pointBeforeLocation = before(editor, at, {includeObjectNodes}) - - if (!pointBeforeLocation) { - return - } - - const [, to] = node(editor, path(editor, [], {edge: 'start'})) - - // The search location is from the start of the document to the path of - // the point before the location passed in - const span: Span = [pointBeforeLocation.path, to] - - if (isPath(at) && at.length === 0) { - throw new Error(`Cannot get the previous node from the root node!`) - } - - const [previousEntry] = nodes(editor, { - reverse: true, - at: span, - match, - mode, - includeObjectNodes, - }) - - return previousEntry -} diff --git a/packages/editor/src/slate/editor/should-merge-nodes-remove-prev-node.ts b/packages/editor/src/slate/editor/should-merge-nodes-remove-prev-node.ts index cca80e056..ae2000878 100644 --- a/packages/editor/src/slate/editor/should-merge-nodes-remove-prev-node.ts +++ b/packages/editor/src/slate/editor/should-merge-nodes-remove-prev-node.ts @@ -1,11 +1,11 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' +import type {Node} from '../interfaces/node' export function shouldMergeNodesRemovePrevNode( - _editor: Editor, - [prevNode, prevPath]: NodeEntry, - [_curNode, _curNodePath]: NodeEntry, + editor: Editor, + prev: {node: Node; path: Array}, + _current: {node: Node; path: Array}, ): boolean { // If the target node that we're merging with is empty, remove it instead // of merging the two. This is a common rich text editor behavior to @@ -14,19 +14,19 @@ export function shouldMergeNodesRemovePrevNode( // if prevNode is first child in parent,don't remove it. let isEmptyElement = false - if (isTextBlock({schema: _editor.schema}, prevNode)) { - const prevChildren = prevNode.children + if (isTextBlock({schema: editor.schema}, prev.node)) { + const prevChildren = prev.node.children isEmptyElement = prevChildren.length === 0 || (prevChildren.length === 1 && - isSpan({schema: _editor.schema}, prevChildren[0]) && + isSpan({schema: editor.schema}, prevChildren[0]) && prevChildren[0].text === '') } return ( isEmptyElement || - (isSpan({schema: _editor.schema}, prevNode) && - prevNode.text === '' && - prevPath[prevPath.length - 1] !== 0) + (isSpan({schema: editor.schema}, prev.node) && + prev.node.text === '' && + prev.path[prev.path.length - 1] !== 0) ) } diff --git a/packages/editor/src/slate/editor/string.ts b/packages/editor/src/slate/editor/string.ts deleted file mode 100644 index a0adfe64a..000000000 --- a/packages/editor/src/slate/editor/string.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {isSpan} from '@portabletext/schema' -import type {Editor} from '../interfaces/editor' -import type {Location} from '../interfaces/location' -import {pathEquals} from '../path/path-equals' -import {rangeEdges} from '../range/range-edges' -import {nodes} from './nodes' -import {range} from './range' - -export function string( - editor: Editor, - at: Location, - options: {includeObjectNodes?: boolean} = {}, -): string { - const {includeObjectNodes = false} = options - const editorRange = range(editor, at) - const [start, end] = rangeEdges(editorRange) - let text = '' - - for (const [node, path] of nodes(editor, { - at: editorRange, - match: (n) => isSpan({schema: editor.schema}, n), - includeObjectNodes, - })) { - let t = node.text - - if (pathEquals(path, end.path)) { - t = t.slice(0, end.offset) - } - - if (pathEquals(path, start.path)) { - t = t.slice(start.offset) - } - - text += t - } - - return text -} diff --git a/packages/editor/src/slate/editor/unhang-range.ts b/packages/editor/src/slate/editor/unhang-range.ts index f9412b43f..4ed7b1316 100644 --- a/packages/editor/src/slate/editor/unhang-range.ts +++ b/packages/editor/src/slate/editor/unhang-range.ts @@ -1,20 +1,15 @@ -import {isSpan, isTextBlock} from '@portabletext/schema' +import {isSpan} from '@portabletext/schema' +import {getAncestorTextBlock} from '../../node-traversal/get-ancestor-text-block' +import {getNodes} from '../../node-traversal/get-nodes' import type {Editor} from '../interfaces/editor' import type {Range} from '../interfaces/range' import {isBeforePath} from '../path/is-before-path' import {pathHasPrevious} from '../path/path-has-previous' import {isCollapsedRange} from '../range/is-collapsed-range' import {rangeEdges} from '../range/range-edges' -import {above} from './above' -import {nodes} from './nodes' import {start as editorStart} from './start' -export function unhangRange( - editor: Editor, - range: Range, - options: {includeObjectNodes?: boolean} = {}, -): Range { - const {includeObjectNodes = false} = options +export function unhangRange(editor: Editor, range: Range): Range { let [start, end] = rangeEdges(range) // PERF: exit early if we can guarantee that the range isn't hanging. @@ -27,29 +22,30 @@ export function unhangRange( return range } - const endBlock = above(editor, { - at: end, - match: (n) => isTextBlock({schema: editor.schema}, n), - includeObjectNodes, - }) - const blockPath = endBlock ? endBlock[1] : [] + const endBlock = getAncestorTextBlock(editor, end.path) + const blockPath = endBlock ? endBlock.path : [] const first = editorStart(editor, start) const before = {anchor: first, focus: end} + const [beforeStart, beforeEnd] = rangeEdges(before) let skip = true - for (const [node, path] of nodes(editor, { - at: before, + for (const {node, path: nodePath} of getNodes(editor, { + from: beforeStart.path, + to: beforeEnd.path, match: (n) => isSpan({schema: editor.schema}, n), reverse: true, - includeObjectNodes, })) { if (skip) { skip = false continue } - if (node.text !== '' || isBeforePath(path, blockPath)) { - end = {path, offset: node.text.length} + if (!isSpan({schema: editor.schema}, node)) { + continue + } + + if (node.text !== '' || isBeforePath(nodePath, blockPath)) { + end = {path: nodePath, offset: node.text.length} break } } diff --git a/packages/editor/src/slate/interfaces/editor.ts b/packages/editor/src/slate/interfaces/editor.ts index bff27d7f2..00a82392a 100644 --- a/packages/editor/src/slate/interfaces/editor.ts +++ b/packages/editor/src/slate/interfaces/editor.ts @@ -23,6 +23,7 @@ export interface BaseEditor { // Core state. children: PortableTextBlock[] + readonly value: PortableTextBlock[] selection: Selection operations: Operation[] marks: EditorMarks | null @@ -40,9 +41,6 @@ export interface BaseEditor { apply: (operation: Operation) => void createSpan: () => PortableTextSpan getDirtyPaths: (operation: Operation) => Path[] - isElementReadOnly: ( - element: PortableTextTextBlock | PortableTextObject, - ) => boolean isInline: (element: PortableTextTextBlock | PortableTextObject) => boolean normalizeNode: ( entry: [Editor | Node, Path], diff --git a/packages/editor/src/slate/interfaces/location.ts b/packages/editor/src/slate/interfaces/location.ts index 63c2765c6..0b59894da 100644 --- a/packages/editor/src/slate/interfaces/location.ts +++ b/packages/editor/src/slate/interfaces/location.ts @@ -1,4 +1,3 @@ -import {isPath} from '../path/is-path' import type {Path} from './path' import type {Point} from './point' import type {Range} from './range' @@ -13,24 +12,3 @@ import type {Range} from './range' */ export type Location = Path | Point | Range - -/** - * The `Span` interface is a low-level way to refer to locations in nodes - * without using `Point` which requires leaf text nodes to be present. - */ - -export type Span = [Path, Path] - -interface SpanInterface { - /** - * Check if a value implements the `Span` interface. - */ - isSpan: (value: any) => value is Span -} - -// eslint-disable-next-line no-redeclare -export const Span: SpanInterface = { - isSpan(value: any): value is Span { - return Array.isArray(value) && value.length === 2 && value.every(isPath) - }, -} diff --git a/packages/editor/src/slate/node/get-child.ts b/packages/editor/src/slate/node/get-child.ts deleted file mode 100644 index 301c30d1d..000000000 --- a/packages/editor/src/slate/node/get-child.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {isTextBlock} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import {safeStringify} from '../../internal-utils/safe-json' -import {isEditor} from '../editor/is-editor' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' - -export function getChild( - root: Editor | Node, - index: number, - schema: EditorSchema, -): Node { - if (isEditor(root)) { - return root.children[index]! - } - - if (isTextBlock({schema}, root)) { - return root.children[index]! - } - - throw new Error(`Cannot get the child of a leaf node: ${safeStringify(root)}`) -} diff --git a/packages/editor/src/slate/node/get-children.ts b/packages/editor/src/slate/node/get-children.ts deleted file mode 100644 index be650d5c1..000000000 --- a/packages/editor/src/slate/node/get-children.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {isTextBlock} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getChild} from './get-child' -import {getNode} from './get-node' - -export function* getChildren( - root: Editor | Node, - path: Path, - schema: EditorSchema, - options: {reverse?: boolean} = {}, -): Generator, void, undefined> { - const {reverse = false} = options - const ancestor = path.length === 0 ? root : getNode(root, path, schema) - - if (!isTextBlock({schema}, ancestor)) { - return - } - - const children = ancestor.children - let index = reverse ? children.length - 1 : 0 - - while (reverse ? index >= 0 : index < children.length) { - const child = getChild(ancestor, index, schema) - const childPath = path.concat(index) - yield [child, childPath] - index = reverse ? index - 1 : index + 1 - } -} diff --git a/packages/editor/src/slate/node/get-first.ts b/packages/editor/src/slate/node/get-first.ts deleted file mode 100644 index b5f71d341..000000000 --- a/packages/editor/src/slate/node/get-first.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNode} from './get-node' -import {isLeaf} from './is-leaf' - -export function getFirst( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): NodeEntry { - const p = path.slice() - let n: Node - - if (path.length === 0) { - if (isLeaf(root, schema) || root.children.length === 0) { - throw new Error('Cannot get the first descendant of a leaf or empty root') - } - - n = root.children[0]! - p.push(0) - } else { - n = getNode(root, p, schema) - } - - while (n) { - if (isLeaf(n, schema)) { - break - } - - const ancestorChildren = n.children - - if (ancestorChildren.length === 0) { - break - } - - n = ancestorChildren[0]! - p.push(0) - } - - return [n, p] -} diff --git a/packages/editor/src/slate/node/get-last.ts b/packages/editor/src/slate/node/get-last.ts deleted file mode 100644 index f53a9bd34..000000000 --- a/packages/editor/src/slate/node/get-last.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNode} from './get-node' -import {isLeaf} from './is-leaf' - -export function getLast( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): NodeEntry { - const p = path.slice() - let n: Node - - if (path.length === 0) { - if (isLeaf(root, schema) || root.children.length === 0) { - throw new Error('Cannot get the last descendant of a leaf or empty root') - } - - const i = root.children.length - 1 - n = root.children[i]! - p.push(i) - } else { - n = getNode(root, p, schema) - } - - while (n) { - if (isLeaf(n, schema)) { - break - } - - const ancestorChildren = n.children - - if (ancestorChildren.length === 0) { - break - } - - const i = ancestorChildren.length - 1 - n = ancestorChildren[i]! - p.push(i) - } - - return [n, p] -} diff --git a/packages/editor/src/slate/node/get-leaf.ts b/packages/editor/src/slate/node/get-leaf.ts deleted file mode 100644 index b247dee58..000000000 --- a/packages/editor/src/slate/node/get-leaf.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - isSpan, - type PortableTextObject, - type PortableTextSpan, -} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import {safeStringify} from '../../internal-utils/safe-json' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNode} from './get-node' -import {isObjectNode} from './is-object-node' - -export function getLeaf( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): PortableTextSpan | PortableTextObject { - const node = getNode(root, path, schema) - - if (isSpan({schema}, node)) { - return node - } - - if (isObjectNode({schema}, node)) { - return node - } - - throw new Error( - `Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${safeStringify( - node, - )}`, - ) -} diff --git a/packages/editor/src/slate/node/get-levels.ts b/packages/editor/src/slate/node/get-levels.ts deleted file mode 100644 index fabf759ae..000000000 --- a/packages/editor/src/slate/node/get-levels.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {pathLevels} from '../path/path-levels' -import {getNode} from './get-node' - -export function* getLevels( - root: Editor | Node, - path: Path, - schema: EditorSchema, - options: {reverse?: boolean} = {}, -): Generator { - for (const p of pathLevels(path, options)) { - if (p.length === 0) { - yield [root as Node, p] - continue - } - - const n = getNode(root, p, schema) - yield [n, p] - } -} diff --git a/packages/editor/src/slate/node/get-node-if.ts b/packages/editor/src/slate/node/get-node-if.ts deleted file mode 100644 index 59611a880..000000000 --- a/packages/editor/src/slate/node/get-node-if.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {isLeaf} from './is-leaf' - -export function getNodeIf( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): Node | undefined { - if (path.length === 0) { - return undefined - } - - let node: Editor | Node = root - - for (let i = 0; i < path.length; i++) { - const p = path[i]! - - if (isLeaf(node, schema)) { - return - } - - const children = node.children - - if (!children[p]) { - return - } - - node = children[p]! - } - - return node as Node -} diff --git a/packages/editor/src/slate/node/get-node.ts b/packages/editor/src/slate/node/get-node.ts deleted file mode 100644 index d35da623b..000000000 --- a/packages/editor/src/slate/node/get-node.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import {safeStringify} from '../../internal-utils/safe-json' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNodeIf} from './get-node-if' - -export function getNode( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): Node { - const node = getNodeIf(root, path, schema) - - if (node === undefined) { - throw new Error( - `Cannot find a descendant at path [${path}] in node: ${safeStringify( - root, - )}`, - ) - } - - return node -} diff --git a/packages/editor/src/slate/node/get-nodes.ts b/packages/editor/src/slate/node/get-nodes.ts deleted file mode 100644 index 53dde13e9..000000000 --- a/packages/editor/src/slate/node/get-nodes.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {isAfterPath} from '../path/is-after-path' -import {isAncestorPath} from '../path/is-ancestor-path' -import {isBeforePath} from '../path/is-before-path' -import {nextPath} from '../path/next-path' -import {parentPath} from '../path/parent-path' -import {previousPath} from '../path/previous-path' -import {getNode} from './get-node' -import {hasNode} from './has-node' -import {isLeaf} from './is-leaf' - -export function* getNodes( - root: Editor | Node, - schema: EditorSchema, - options: { - from?: Path - to?: Path - reverse?: boolean - pass?: (entry: NodeEntry) => boolean - } = {}, -): Generator { - const {pass, reverse = false} = options - const {from = [], to} = options - const visited = new Set() - let p: Path = [] - let n: Editor | Node = root - - while (true) { - if (to && (reverse ? isBeforePath(p, to) : isAfterPath(p, to))) { - break - } - - if (!visited.has(n)) { - yield [n as Node, p] - } - - if ( - !visited.has(n) && - !isLeaf(n, schema) && - n.children.length !== 0 && - (pass == null || pass([n as Node, p]) === false) - ) { - visited.add(n) - const children = n.children - let nextIndex = reverse ? children.length - 1 : 0 - - if (isAncestorPath(p, from)) { - nextIndex = from[p.length]! - } - - p = p.concat(nextIndex) - n = getNode(root, p, schema) - continue - } - - if (p.length === 0) { - break - } - - if (!reverse) { - const newPath = nextPath(p) - - if (hasNode(root, newPath, schema)) { - p = newPath - n = getNode(root, p, schema) - continue - } - } - - if (reverse && p[p.length - 1] !== 0) { - const newPath = previousPath(p) - p = newPath - n = getNode(root, p, schema) - continue - } - - p = parentPath(p) - n = p.length === 0 ? root : getNode(root, p, schema) - visited.add(n) - } -} diff --git a/packages/editor/src/slate/node/get-span-node.ts b/packages/editor/src/slate/node/get-span-node.ts deleted file mode 100644 index 833b3f09d..000000000 --- a/packages/editor/src/slate/node/get-span-node.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {PortableTextSpan} from '@portabletext/schema' -import {isSpan} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import {safeStringify} from '../../internal-utils/safe-json' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNode} from './get-node' - -export function getSpanNode( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): PortableTextSpan { - const node = getNode(root, path, schema) - - if (isSpan({schema}, node)) { - return node - } - - throw new Error( - `Cannot get the span at path [${path}] because it refers to a non-span node: ${safeStringify(node)}`, - ) -} diff --git a/packages/editor/src/slate/node/get-string.ts b/packages/editor/src/slate/node/get-string.ts deleted file mode 100644 index c710b97b2..000000000 --- a/packages/editor/src/slate/node/get-string.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {isSpan} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import {isEditor} from '../editor/is-editor' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import {isObjectNode} from './is-object-node' - -export function getString(node: Editor | Node, schema: EditorSchema): string { - if (isEditor(node)) { - return '' - } - - if (isObjectNode({schema}, node)) { - return '' - } - - if (isSpan({schema}, node)) { - return node.text - } - - return node.children.map((child) => getString(child, schema)).join('') -} diff --git a/packages/editor/src/slate/node/get-text-block-node.ts b/packages/editor/src/slate/node/get-text-block-node.ts deleted file mode 100644 index f0e326797..000000000 --- a/packages/editor/src/slate/node/get-text-block-node.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {PortableTextTextBlock} from '@portabletext/schema' -import {isTextBlock} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import {safeStringify} from '../../internal-utils/safe-json' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNode} from './get-node' - -export function getTextBlockNode( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): PortableTextTextBlock { - const node = getNode(root, path, schema) - - if (isTextBlock({schema}, node)) { - return node - } - - throw new Error( - `Cannot get the text block at path [${path}] because it refers to a non-text-block node: ${safeStringify(node)}`, - ) -} diff --git a/packages/editor/src/slate/node/get-texts.ts b/packages/editor/src/slate/node/get-texts.ts deleted file mode 100644 index 40af96bb2..000000000 --- a/packages/editor/src/slate/node/get-texts.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {PortableTextSpan} from '@portabletext/schema' -import {isSpan} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node, NodeEntry} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {getNodes} from './get-nodes' - -export function* getTexts( - root: Editor | Node, - schema: EditorSchema, - options: { - from?: Path - to?: Path - reverse?: boolean - pass?: (node: NodeEntry) => boolean - } = {}, -): Generator, void, undefined> { - for (const [node, path] of getNodes(root, schema, options)) { - if (isSpan({schema}, node)) { - yield [node, path] - } - } -} diff --git a/packages/editor/src/slate/node/has-node.ts b/packages/editor/src/slate/node/has-node.ts deleted file mode 100644 index 086ac1dce..000000000 --- a/packages/editor/src/slate/node/has-node.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import type {Path} from '../interfaces/path' -import {isLeaf} from './is-leaf' - -export function hasNode( - root: Editor | Node, - path: Path, - schema: EditorSchema, -): boolean { - let node: Editor | Node = root - - for (let i = 0; i < path.length; i++) { - const p = path[i]! - - if (isLeaf(node, schema)) { - return false - } - - const children = node.children - - if (!children[p]) { - return false - } - - node = children[p]! - } - - return true -} diff --git a/packages/editor/src/slate/node/is-leaf.ts b/packages/editor/src/slate/node/is-leaf.ts deleted file mode 100644 index 6d7b9e633..000000000 --- a/packages/editor/src/slate/node/is-leaf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - isSpan, - type PortableTextObject, - type PortableTextSpan, -} from '@portabletext/schema' -import type {EditorSchema} from '../../editor/editor-schema' -import type {Editor} from '../interfaces/editor' -import type {Node} from '../interfaces/node' -import {isObjectNode} from './is-object-node' - -export function isLeaf( - node: Editor | Node, - schema: EditorSchema, -): node is PortableTextSpan | PortableTextObject { - return isSpan({schema}, node) || isObjectNode({schema}, node) -} diff --git a/packages/editor/src/slate/react/components/editable.tsx b/packages/editor/src/slate/react/components/editable.tsx index 091cea33c..69d57e7a3 100644 --- a/packages/editor/src/slate/react/components/editable.tsx +++ b/packages/editor/src/slate/react/components/editable.tsx @@ -19,6 +19,11 @@ import scrollIntoView from 'scroll-into-view-if-needed' import {getDomNode} from '../../../dom-traversal/get-dom-node' import {getDomNodePath} from '../../../dom-traversal/get-dom-node-path' import type {EditorActor} from '../../../editor/editor-machine' +import {getAncestorObjectNode} from '../../../node-traversal/get-ancestor-object-node' +import {getAncestorTextBlock} from '../../../node-traversal/get-ancestor-text-block' +import {getNode} from '../../../node-traversal/get-node' +import {getNodes} from '../../../node-traversal/get-nodes' +import {getText} from '../../../node-traversal/get-text' import {keyedPathToIndexedPath} from '../../../paths/keyed-path-to-indexed-path' import {collapse} from '../../core/collapse' import {deselect} from '../../core/deselect' @@ -54,9 +59,7 @@ import { MARK_PLACEHOLDER_SYMBOL, PLACEHOLDER_SYMBOL, } from '../../dom/utils/symbols' -import {above} from '../../editor/above' import {end as editorEnd} from '../../editor/end' -import {getObjectNode} from '../../editor/get-object-node' import {range as editorRange} from '../../editor/range' import {rangeRef} from '../../editor/range-ref' import {start as editorStart} from '../../editor/start' @@ -64,11 +67,6 @@ import type {Editor} from '../../interfaces/editor' import type {NodeEntry} from '../../interfaces/node' import type {Path} from '../../interfaces/path' import type {DecoratedRange, LeafPosition} from '../../interfaces/text' -import {getLeaf} from '../../node/get-leaf' -import {getNode} from '../../node/get-node' -import {getNodeIf} from '../../node/get-node-if' -import {getString} from '../../node/get-string' -import {getTexts} from '../../node/get-texts' import {isObjectNode} from '../../node/is-object-node' import {pathEquals} from '../../path/path-equals' import {isBackwardRange} from '../../range/is-backward-range' @@ -686,16 +684,14 @@ export const Editable = forwardRef( window?.getComputedStyle(node.parentElement)?.whiteSpace === 'pre' ) { - const block = above(editor, { - at: anchor.path, - match: (n) => isTextBlock({schema: editor.schema}, n), - }) + const block = getAncestorTextBlock(editor, anchor.path) - if ( - block && - getString(block[0], editor.schema).includes('\t') - ) { - native = false + if (block) { + const blockText = getText(editor, block.path) + + if (blockText?.includes('\t')) { + native = false + } } } } @@ -1025,8 +1021,21 @@ export const Editable = forwardRef( const showPlaceholder = placeholder && editor.children.length === 1 && - Array.from(getTexts(editor, editor.schema)).length === 1 && - getString(editor, editor.schema) === '' && + (() => { + let spanCount = 0 + + for (const entry of getNodes(editor)) { + if (isSpan({schema: editor.schema}, entry.node)) { + spanCount++ + + if (spanCount > 1 || entry.node.text !== '') { + return false + } + } + } + + return spanCount === 1 + })() && !isComposing const placeHolderResizeHandler = useCallback( @@ -1056,9 +1065,10 @@ export const Editable = forwardRef( if (editor.selection && isCollapsedRange(editor.selection) && marks) { const {anchor} = editor.selection - const leaf = getLeaf(editor, anchor.path, editor.schema) + const leafEntry = getNode(editor, anchor.path) + const leaf = leafEntry ? leafEntry.node : undefined - if (isSpan({schema: editor.schema}, leaf)) { + if (leaf && isSpan({schema: editor.schema}, leaf)) { const {text: _text, ...rest} = leaf if (!textEquals(leaf, marks, {loose: true})) { @@ -1087,12 +1097,13 @@ export const Editable = forwardRef( const {selection} = editor if (selection) { const {anchor} = selection - const text = getLeaf(editor, anchor.path, editor.schema) + const textEntry = getNode(editor, anchor.path) - if (!isSpan({schema: editor.schema}, text)) { + if (!textEntry || !isSpan({schema: editor.schema}, textEntry.node)) { return } + const text = textEntry.node if (marks && !textEquals(text, marks, {loose: true})) { editor.pendingInsertionMarks = marks return @@ -1287,11 +1298,10 @@ export const Editable = forwardRef( : undefined if (indexedPath) { - const relatedNode = getNodeIf( - editor, - indexedPath, - editor.schema, - ) + const relatedNodeEntry = getNode(editor, indexedPath) + const relatedNode = relatedNodeEntry + ? relatedNodeEntry.node + : undefined if ( relatedNode && (isTextBlock({schema: editor.schema}, relatedNode) || @@ -1343,11 +1353,12 @@ export const Editable = forwardRef( // At this time, the Slate document may be arbitrarily different, // because onClick handlers can change the document before we get here. // Therefore we must check that this path actually exists. - const node = getNodeIf(editor, indexedPath, editor.schema) + const nodeClickEntry = getNode(editor, indexedPath) - if (!node) { + if (!nodeClickEntry) { return } + const node = nodeClickEntry.node if ( event.detail === TRIPLE_CLICK && @@ -1361,13 +1372,12 @@ export const Editable = forwardRef( !editor.isInline(node) ) ) { - const block = above(editor, { - match: (n) => - isTextBlock({schema: editor.schema}, n), - at: indexedPath, - }) + const block = getAncestorTextBlock( + editor, + indexedPath, + ) - blockPath = block?.[1] ?? indexedPath.slice(0, 1) + blockPath = block?.path ?? indexedPath.slice(0, 1) } const range = editorRange(editor, blockPath) @@ -1381,13 +1391,23 @@ export const Editable = forwardRef( const start = editorStart(editor, indexedPath) const end = editorEnd(editor, indexedPath) - const startObjectNode = getObjectNode(editor, {at: start}) - const endObjectNode = getObjectNode(editor, {at: end}) + const startEntry = getNode(editor, start.path) + const startObjectNode = + startEntry && + isObjectNode({schema: editor.schema}, startEntry.node) + ? startEntry + : getAncestorObjectNode(editor, start.path) + const endEntry = getNode(editor, end.path) + const endObjectNode = + endEntry && + isObjectNode({schema: editor.schema}, endEntry.node) + ? endEntry + : getAncestorObjectNode(editor, end.path) if ( startObjectNode && endObjectNode && - pathEquals(startObjectNode[1], endObjectNode[1]) + pathEquals(startObjectNode.path, endObjectNode.path) ) { const range = editorRange(editor, start) editor.select(range) @@ -1572,13 +1592,12 @@ export const Editable = forwardRef( } const {selection} = editor - const element = - editor.children[ - selection !== null ? selection.focus.path[0]! : 0 - ]! + const blockIndex = + selection !== null ? selection.focus.path[0]! : 0 + const elementText = getText(editor, [blockIndex]) const isRTL = - getDirection(getString(element, editor.schema)) === - 'rtl' + elementText !== undefined && + getDirection(elementText) === 'rtl' // COMPAT: Certain browsers don't handle the selection updates // properly. In Chrome, the selection isn't properly extended. @@ -1873,14 +1892,17 @@ export const Editable = forwardRef( Hotkeys.isDeleteForward(nativeEvent)) && isCollapsedRange(selection) ) { - const currentNode = getNode( + const currentNodeEntry = getNode( editor, selection.anchor.path, - editor.schema, ) if ( - isObjectNode({schema: editor.schema}, currentNode) + currentNodeEntry && + isObjectNode( + {schema: editor.schema}, + currentNodeEntry.node, + ) ) { event.preventDefault() editorActor.send({ diff --git a/packages/editor/src/slate/react/components/element.tsx b/packages/editor/src/slate/react/components/element.tsx index 76c2747e8..b43d6d868 100644 --- a/packages/editor/src/slate/react/components/element.tsx +++ b/packages/editor/src/slate/react/components/element.tsx @@ -4,11 +4,10 @@ import type { } from '@portabletext/schema' import {isTextBlock} from '@portabletext/schema' import React, {type JSX} from 'react' +import {getText} from '../../../node-traversal/get-text' import {isElementDecorationsEqual} from '../../dom/utils/range-list' -import {hasInlines} from '../../editor/has-inlines' import type {Path} from '../../interfaces/path' import type {DecoratedRange} from '../../interfaces/text' -import {getString} from '../../node/get-string' import {pathEquals} from '../../path/path-equals' import useChildren from '../hooks/use-children' import {useDecorations} from '../hooks/use-decorations' @@ -86,13 +85,9 @@ const Element = (props: { // If it's a block node with inline children, add the proper `dir` attribute // for text direction. - if ( - !isInline && - isTextBlock({schema: editor.schema}, element) && - hasInlines(editor, element) - ) { - const text = getString(element, editor.schema) - const dir = getDirection(text) + if (!isInline && isTextBlock({schema: editor.schema}, element)) { + const text = getText(editor, props.indexedPath) + const dir = text !== undefined ? getDirection(text) : undefined if (dir === 'rtl') { attributes.dir = dir diff --git a/packages/editor/src/slate/react/components/string.tsx b/packages/editor/src/slate/react/components/string.tsx index 89e7a9628..353a6adf7 100644 --- a/packages/editor/src/slate/react/components/string.tsx +++ b/packages/editor/src/slate/react/components/string.tsx @@ -1,15 +1,20 @@ import { + isSpan, isTextBlock, type PortableTextObject, type PortableTextSpan, type PortableTextTextBlock, } from '@portabletext/schema' import {forwardRef, memo, useRef, useState} from 'react' +import {getNodes} from '../../../node-traversal/get-nodes' import {IS_ANDROID} from '../../dom/utils/environment' import {MARK_PLACEHOLDER_SYMBOL} from '../../dom/utils/symbols' -import {string as editorString} from '../../editor/string' +import {end as editorEnd} from '../../editor/end' +import {start as editorStart} from '../../editor/start' +import type {Editor} from '../../interfaces/editor' import type {Path} from '../../interfaces/path' import {parentPath} from '../../path/parent-path' +import {pathEquals} from '../../path/path-equals' import {useIsomorphicLayoutEffect} from '../hooks/use-isomorphic-layout-effect' import {useSlateStatic} from '../hooks/use-slate-static' @@ -17,6 +22,32 @@ import {useSlateStatic} from '../hooks/use-slate-static' * Leaf content strings. */ +function getTextContent(editor: Editor, path: Path): string { + const start = editorStart(editor, path) + const end = editorEnd(editor, path) + let text = '' + + for (const {node, path: nodePath} of getNodes(editor, { + from: start.path, + to: end.path, + match: (n) => isSpan({schema: editor.schema}, n), + })) { + if (!isSpan({schema: editor.schema}, node)) { + continue + } + let nodeText = node.text + if (pathEquals(nodePath, end.path)) { + nodeText = nodeText.slice(0, end.offset) + } + if (pathEquals(nodePath, start.path)) { + nodeText = nodeText.slice(start.offset) + } + text += nodeText + } + + return text +} + const SlateString = (props: { isLast: boolean leaf: PortableTextSpan @@ -37,7 +68,7 @@ const SlateString = (props: { isTextBlock({schema: editor.schema}, parent) && parent.children[parent.children.length - 1] === text && !editor.isInline(parent) && - editorString(editor, parentIndexedPath) === '' + getTextContent(editor, parentIndexedPath) === '' ) { return } diff --git a/packages/editor/src/slate/react/hooks/android-input-manager/android-input-manager.ts b/packages/editor/src/slate/react/hooks/android-input-manager/android-input-manager.ts index 0943b123f..55759d86d 100644 --- a/packages/editor/src/slate/react/hooks/android-input-manager/android-input-manager.ts +++ b/packages/editor/src/slate/react/hooks/android-input-manager/android-input-manager.ts @@ -1,5 +1,8 @@ import {isSpan} from '@portabletext/schema' import type {EditorActor} from '../../../../editor/editor-machine' +import {getNode} from '../../../../node-traversal/get-node' +import {getNodes} from '../../../../node-traversal/get-nodes' +import {getSpanNode} from '../../../../node-traversal/get-span-node' import { applyStringDiff, mergeStringDiffs, @@ -12,15 +15,14 @@ import { type TextDiff, } from '../../../dom/utils/diff-text' import {isDOMSelection, isTrackedMutation} from '../../../dom/utils/dom' -import {leaf as editorLeaf} from '../../../editor/leaf' -import {next as editorNext} from '../../../editor/next' +import {after} from '../../../editor/after' import {range as editorRange} from '../../../editor/range' import {rangeRef} from '../../../editor/range-ref' import type {Editor} from '../../../interfaces/editor' import type {Path} from '../../../interfaces/path' import type {Point} from '../../../interfaces/point' import type {Range} from '../../../interfaces/range' -import {getLeaf} from '../../../node/get-leaf' +import {isObjectNode} from '../../../node/is-object-node' import {pathEquals} from '../../../path/path-equals' import {isPoint} from '../../../point/is-point' import {isCollapsedRange} from '../../../range/is-collapsed-range' @@ -300,12 +302,14 @@ export function createAndroidInputManager({ const pendingDiffs = editor.pendingDiffs - const target = getLeaf(editor, path, editor.schema) + const targetEntry = getSpanNode(editor, path) - if (!isSpan({schema: editor.schema}, target)) { + if (!targetEntry) { return } + const target = targetEntry.node + const idx = pendingDiffs.findIndex((change) => pathEquals(change.path, path), ) @@ -413,7 +417,28 @@ export function createAndroidInputManager({ if (type.startsWith('delete')) { const direction = type.endsWith('Backward') ? 'backward' : 'forward' let [start, end] = rangeEdges(targetRange) - let [leaf, path] = editorLeaf(editor, start.path) + const leafNodeEntry = getNode(editor, start.path) + const leafResult = + leafNodeEntry && + (isSpan({schema: editor.schema}, leafNodeEntry.node) || + isObjectNode({schema: editor.schema}, leafNodeEntry.node)) + ? leafNodeEntry + : undefined + + if (!leafResult) { + return scheduleAction( + () => + editorActor.send({ + type: 'behavior event', + behaviorEvent: {type: 'delete', direction}, + editor, + }), + {at: targetRange}, + ) + } + + let leaf = leafResult.node + let path = leafResult.path if (!isSpan({schema: editor.schema}, leaf)) { return scheduleAction( @@ -429,18 +454,26 @@ export function createAndroidInputManager({ if (isExpandedRange(targetRange)) { if (leaf.text.length === start.offset && end.offset === 0) { - const next = editorNext(editor, { - at: start.path, - match: (n) => isSpan({schema: editor.schema}, n), - }) - if (next && pathEquals(next[1], end.path)) { + const afterPoint = after(editor, start.path) + const [nextEntry] = afterPoint + ? getNodes(editor, { + from: afterPoint.path, + match: (n) => isSpan({schema: editor.schema}, n), + }) + : [] + if ( + nextEntry && + isSpan({schema: editor.schema}, nextEntry.node) && + pathEquals(nextEntry.path, end.path) + ) { // when deleting a linebreak, targetRange will span across the break (ie start in the node before and end in the node after) // if the node before is empty, this will look like a hanging range and get unhung later--which will take the break we want to remove out of the range // so to avoid this we collapse the target range to default to single character deletion if (direction === 'backward') { targetRange = {anchor: end, focus: end} start = end - ;[leaf, path] = next + leaf = nextEntry.node + path = nextEntry.path } else { targetRange = {anchor: start, focus: start} end = start @@ -449,6 +482,10 @@ export function createAndroidInputManager({ } } + if (!isSpan({schema: editor.schema}, leaf)) { + return + } + const diff = { text: '', start: start.offset, @@ -517,11 +554,11 @@ export function createAndroidInputManager({ case 'deleteContentForward': { const {anchor} = targetRange if (canStoreDiff && isCollapsedRange(targetRange)) { - const targetNode = getLeaf(editor, anchor.path, editor.schema) + const targetNodeEntry = getSpanNode(editor, anchor.path) if ( - isSpan({schema: editor.schema}, targetNode) && - anchor.offset < targetNode.text.length + targetNodeEntry && + anchor.offset < targetNodeEntry.node.text.length ) { return storeDiff(anchor.path, { text: '', diff --git a/packages/editor/src/slate/types/types.ts b/packages/editor/src/slate/types/types.ts index dc1ada2ce..eb9226d47 100644 --- a/packages/editor/src/slate/types/types.ts +++ b/packages/editor/src/slate/types/types.ts @@ -1,7 +1,5 @@ export type LeafEdge = 'start' | 'end' -export type MaximizeMode = RangeMode - export type MoveUnit = 'offset' | 'character' | 'word' | 'line' export type RangeDirection = TextDirection | 'outward' | 'inward' @@ -10,8 +8,6 @@ export type RangeMode = 'highest' | 'lowest' export type SelectionEdge = 'anchor' | 'focus' | 'start' | 'end' -export type SelectionMode = 'highest' | 'lowest' - export type TextDirection = 'forward' | 'backward' export type TextUnit = 'character' | 'word' | 'line' | 'block' diff --git a/packages/editor/src/slate/utils/match-path.ts b/packages/editor/src/slate/utils/match-path.ts index e2ea189e1..8ef373e8f 100644 --- a/packages/editor/src/slate/utils/match-path.ts +++ b/packages/editor/src/slate/utils/match-path.ts @@ -1,4 +1,4 @@ -import {node as editorNode} from '../editor/node' +import {getNode} from '../../node-traversal/get-node' import type {Editor} from '../interfaces/editor' import type {Node} from '../interfaces/node' import type {Path} from '../interfaces/path' @@ -7,6 +7,10 @@ export const matchPath = ( editor: Editor, path: Path, ): ((node: Node) => boolean) => { - const [matchedNode] = editorNode(editor, path) + const matchedEntry = getNode(editor, path) + if (!matchedEntry) { + return () => false + } + const matchedNode = matchedEntry.node return (n) => n === matchedNode } diff --git a/packages/editor/src/slate/utils/modify.ts b/packages/editor/src/slate/utils/modify.ts index 0a91be017..fb6713c8a 100644 --- a/packages/editor/src/slate/utils/modify.ts +++ b/packages/editor/src/slate/utils/modify.ts @@ -1,11 +1,11 @@ import {isSpan, isTextBlock, type PortableTextSpan} from '@portabletext/schema' import type {EditorSchema} from '../../editor/editor-schema' import {safeStringify} from '../../internal-utils/safe-json' +import {getNode} from '../../node-traversal/get-node' import {isEditor} from '../editor/is-editor' import type {Editor} from '../interfaces/editor' import type {Node} from '../interfaces/node' import type {Path} from '../interfaces/path' -import {getNode} from '../node/get-node' import {isObjectNode} from '../node/is-object-node' export const insertChildren = ( @@ -36,13 +36,35 @@ export const modifyDescendant = ( throw new Error('Cannot modify the editor') } - const node = getNode(root, path, schema) as N + const editableTypes = isEditor(root) ? root.editableTypes : new Set() + const context = {schema, editableTypes} + const typedRoot = isEditor(root) + ? root + : isTextBlock({schema}, root) + ? root + : undefined + + if (!typedRoot) { + throw new Error('Cannot modify descendant: root has no children') + } + const nodeEntry = getNode({...context, value: typedRoot.children}, path) + if (!nodeEntry) { + throw new Error(`Cannot find a descendant at path [${path}]`) + } + const node = nodeEntry.node const slicedPath = path.slice() - let modifiedNode: Node = f(node) + let modifiedNode: Node = f(node as N) while (slicedPath.length > 1) { const index = slicedPath.pop()! - const ancestorNode = getNode(root, slicedPath, schema) + const ancestorEntry = getNode( + {...context, value: typedRoot.children}, + slicedPath, + ) + if (!ancestorEntry) { + throw new Error(`Cannot find ancestor at path [${slicedPath}]`) + } + const ancestorNode = ancestorEntry.node modifiedNode = { ...ancestorNode, diff --git a/packages/editor/src/types/slate-editor.ts b/packages/editor/src/types/slate-editor.ts index 616217057..278b0dd37 100644 --- a/packages/editor/src/types/slate-editor.ts +++ b/packages/editor/src/types/slate-editor.ts @@ -29,6 +29,7 @@ export interface PortableTextSlateEditor extends ReactEditor { _type: 'editor' schema: EditorSchema + editableTypes: Set decoratedRanges: Array decoratorState: Record diff --git a/packages/editor/vitest.config.ts b/packages/editor/vitest.config.ts index 8ec831190..096949603 100644 --- a/packages/editor/vitest.config.ts +++ b/packages/editor/vitest.config.ts @@ -33,7 +33,6 @@ export default defineConfig({ 'tests/**/*.test.ts', 'tests/**/*.test.tsx', 'src/editor/*.test.ts', - 'src/internal-utils/slate-utils.test.tsx', 'src/plugins/*.test.tsx', 'src/history/**/*.test.ts', 'src/history/**/*.test.tsx', @@ -78,7 +77,6 @@ export default defineConfig({ 'tests', 'src/editor/*.test.ts', 'src/plugins/*.test.tsx', - 'src/internal-utils/slate-utils.test.tsx', 'src/history', ], environment: 'jsdom',