diff --git a/.changeset/block-path-map.md b/.changeset/block-path-map.md new file mode 100644 index 000000000..511f2e13e --- /dev/null +++ b/.changeset/block-path-map.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': minor +--- + +Add `BlockPathMap` for incremental block indexing with depth-aware key-path lookups. This replaces the internal flat `blockIndexMap` rebuild with O(1) lookups and O(affected siblings) incremental updates on structural operations. Text edits have zero cost. The public `blockIndexMap` on `EditorSnapshot` is preserved for backward compatibility, derived from the new `blockPathMap`. diff --git a/.gitignore b/.gitignore index 152023d7f..d38c9a27c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules .vercel .env*.local .eslintcache +.bundle-stats/ diff --git a/packages/editor/src/editor/create-slate-editor.tsx b/packages/editor/src/editor/create-slate-editor.tsx index 991a956c9..e16f966ca 100644 --- a/packages/editor/src/editor/create-slate-editor.tsx +++ b/packages/editor/src/editor/create-slate-editor.tsx @@ -1,4 +1,5 @@ import type {PortableTextBlock} from '@portabletext/schema' +import {InternalBlockPathMap} from '../internal-utils/block-path-map' import {buildIndexMaps} from '../internal-utils/build-index-maps' import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block' import {debug} from '../internal-utils/debug' @@ -35,6 +36,7 @@ export function createSlateEditor(config: SlateEditorConfig): SlateEditor { editor.decoratedRanges = [] editor.decoratorState = {} editor.blockIndexMap = new Map() + editor.blockPathMap = new InternalBlockPathMap() editor.history = {undos: [], redos: []} editor.lastSelection = null editor.lastSlateSelection = null @@ -70,6 +72,8 @@ export function createSlateEditor(config: SlateEditorConfig): SlateEditor { }, ) + instance.blockPathMap.rebuild(instance.children as Array) + const slateEditor: SlateEditor = { instance, initialValue: [placeholderBlock], diff --git a/packages/editor/src/editor/editor-selector.ts b/packages/editor/src/editor/editor-selector.ts index 277f1abee..2700c8ea8 100644 --- a/packages/editor/src/editor/editor-selector.ts +++ b/packages/editor/src/editor/editor-selector.ts @@ -77,7 +77,9 @@ export function getEditorSnapshot({ return { blockIndexMap: slateEditorInstance.blockIndexMap, + blockPathMap: slateEditorInstance.blockPathMap, context: { + containers: new Set(), converters: [...editorActorSnapshot.context.converters], keyGenerator: editorActorSnapshot.context.keyGenerator, readOnly: editorActorSnapshot.matches({'edit mode': 'read only'}), diff --git a/packages/editor/src/editor/editor-snapshot.ts b/packages/editor/src/editor/editor-snapshot.ts index d00f53157..d014cf92c 100644 --- a/packages/editor/src/editor/editor-snapshot.ts +++ b/packages/editor/src/editor/editor-snapshot.ts @@ -1,5 +1,6 @@ import type {PortableTextBlock} from '@portabletext/schema' import type {Converter} from '../converters/converter.types' +import type {BlockPathMap} from '../internal-utils/block-path-map' import {slateRangeToSelection} from '../internal-utils/slate-utils' import type {EditorSelection} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' @@ -9,6 +10,7 @@ import type {EditorSchema} from './editor-schema' * @public */ export type EditorContext = { + containers: Set converters: Array keyGenerator: () => string readOnly: boolean @@ -23,6 +25,7 @@ export type EditorContext = { export type EditorSnapshot = { context: EditorContext blockIndexMap: Map + blockPathMap: BlockPathMap /** * @beta * Subject to change @@ -31,12 +34,14 @@ export type EditorSnapshot = { } export function createEditorSnapshot({ + containers, converters, editor, keyGenerator, readOnly, schema, }: { + containers?: Set converters: Array editor: PortableTextSlateEditor keyGenerator: () => string @@ -52,6 +57,7 @@ export function createEditorSnapshot({ : null const context = { + containers: containers ?? new Set(), converters, keyGenerator, readOnly, @@ -62,6 +68,7 @@ export function createEditorSnapshot({ return { blockIndexMap: editor.blockIndexMap, + blockPathMap: editor.blockPathMap, context, decoratorState: editor.decoratorState, } satisfies EditorSnapshot diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 86b0fdddd..4655d1809 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -37,6 +37,7 @@ export { } from '@portabletext/schema' export {useEditorSelector, type EditorSelector} from './editor/editor-selector' export type {EditorContext, EditorSnapshot} from './editor/editor-snapshot' +export type {BlockPathMap} from './internal-utils/block-path-map' export {usePortableTextEditor} from './editor/usePortableTextEditor' export {usePortableTextEditorSelection} from './editor/usePortableTextEditorSelection' export {defaultKeyGenerator as keyGenerator} from './utils/key-generator' diff --git a/packages/editor/src/internal-utils/block-path-map.test.ts b/packages/editor/src/internal-utils/block-path-map.test.ts new file mode 100644 index 000000000..6abb67ef3 --- /dev/null +++ b/packages/editor/src/internal-utils/block-path-map.test.ts @@ -0,0 +1,592 @@ +import {describe, expect, test} from 'vitest' +import {InternalBlockPathMap, serializeKeyPath} from './block-path-map' + +function block(_key: string) { + return {_key, _type: 'block', children: []} +} + +describe(InternalBlockPathMap.name, () => { + describe('serializeKeyPath', () => { + test('single key (top-level)', () => { + expect(serializeKeyPath(['abc'])).toBe('abc') + }) + + test('two keys (nested)', () => { + expect(serializeKeyPath(['container', 'nested'])).toBe('container/nested') + }) + + test('three keys (deeply nested)', () => { + expect(serializeKeyPath(['container', 'sub', 'block'])).toBe( + 'container/sub/block', + ) + }) + + test('static method matches standalone function', () => { + expect(InternalBlockPathMap.serializeKeyPath(['a', 'b'])).toBe( + serializeKeyPath(['a', 'b']), + ) + }) + }) + + describe('rebuild', () => { + test('empty value', () => { + const map = new InternalBlockPathMap() + map.rebuild([]) + expect(map.size).toBe(0) + }) + + test('single block', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + expect(map.get(['a'])).toEqual([0]) + expect(map.size).toBe(1) + }) + + test('multiple blocks', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.get(['c'])).toEqual([2]) + expect(map.size).toBe(3) + }) + + test('rebuild clears previous state', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + expect(map.size).toBe(2) + + map.rebuild([block('x')]) + expect(map.size).toBe(1) + expect(map.has(['a'])).toBe(false) + expect(map.get(['x'])).toEqual([0]) + }) + }) + + describe('has', () => { + test('returns true for existing key', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + expect(map.has(['a'])).toBe(true) + }) + + test('returns false for missing key', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + expect(map.has(['z'])).toBe(false) + }) + }) + + describe('entries', () => { + test('iterates all entries', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + const entries = Array.from(map.entries()) + expect(entries).toEqual([ + ['a', [0]], + ['b', [1]], + ]) + }) + }) + + describe('onInsertNode', () => { + test('insert at beginning shifts all siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + // Insert 'x' at [0] + map.onInsertNode([0], 'x') + expect(map.get(['x'])).toEqual([0]) + expect(map.get(['a'])).toEqual([1]) + expect(map.get(['b'])).toEqual([2]) + }) + + test('insert at middle shifts later siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + // Insert 'x' at [1] + map.onInsertNode([1], 'x') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['x'])).toEqual([1]) + expect(map.get(['b'])).toEqual([2]) + expect(map.get(['c'])).toEqual([3]) + }) + + test('insert at end does not shift existing', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + // Insert 'x' at [2] + map.onInsertNode([2], 'x') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.get(['x'])).toEqual([2]) + }) + + test('insert shifts descendants of shifted blocks', () => { + const map = new InternalBlockPathMap() + // Simulate a container structure: + // [0] = block 'a' + // [1] = container 'c' with child [1, 0, 0] = 'c1' + // [2] = block 'b' + map.rebuild([block('a')]) + // Manually add nested entries to simulate container children + map.onInsertNode([1], 'c') + map.onInsertNode([1, 0, 0], 'c1') + map.onInsertNode([2], 'b') + + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['c'])).toEqual([1]) + expect(map.get(['c', 'c1'])).toEqual([1, 0, 0]) + expect(map.get(['b'])).toEqual([2]) + + // Now insert 'x' at [0] - everything shifts + map.onInsertNode([0], 'x') + expect(map.get(['x'])).toEqual([0]) + expect(map.get(['a'])).toEqual([1]) + expect(map.get(['c'])).toEqual([2]) + expect(map.get(['c', 'c1'])).toEqual([2, 0, 0]) + expect(map.get(['b'])).toEqual([3]) + }) + }) + + describe('onRemoveNode', () => { + test('remove at beginning shifts all siblings back', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + map.onRemoveNode([0]) + expect(map.has(['a'])).toBe(false) + expect(map.get(['b'])).toEqual([0]) + expect(map.get(['c'])).toEqual([1]) + expect(map.size).toBe(2) + }) + + test('remove at middle shifts later siblings back', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + map.onRemoveNode([1]) + expect(map.get(['a'])).toEqual([0]) + expect(map.has(['b'])).toBe(false) + expect(map.get(['c'])).toEqual([1]) + expect(map.size).toBe(2) + }) + + test('remove at end does not shift existing', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + map.onRemoveNode([2]) + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.has(['c'])).toBe(false) + expect(map.size).toBe(2) + }) + + test('remove also removes descendants', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + // Add container with children + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + map.onInsertNode([1, 0, 1], 'child2') + map.onInsertNode([2], 'b') + + expect(map.size).toBe(5) + + // Remove the container at [1] - should also remove child1 and child2 + map.onRemoveNode([1]) + expect(map.has(['container'])).toBe(false) + expect(map.has(['container', 'child1'])).toBe(false) + expect(map.has(['container', 'child2'])).toBe(false) + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.size).toBe(2) + }) + + test('remove shifts descendants of shifted blocks', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'b') + map.onInsertNode([2], 'container') + map.onInsertNode([2, 0, 0], 'child') + + // Remove 'a' at [0] - 'b' shifts to [0], 'container' to [1], 'child' to [1, 0, 0] + map.onRemoveNode([0]) + expect(map.get(['b'])).toEqual([0]) + expect(map.get(['container'])).toEqual([1]) + expect(map.get(['container', 'child'])).toEqual([1, 0, 0]) + }) + }) + + describe('onSplitNode', () => { + test('split creates new entry and shifts siblings after', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + // Split 'a' at [0] - new block 'a2' at [1], 'b' shifts to [2], 'c' to [3] + map.onSplitNode([0], 'a2') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['a2'])).toEqual([1]) + expect(map.get(['b'])).toEqual([2]) + expect(map.get(['c'])).toEqual([3]) + expect(map.size).toBe(4) + }) + + test('split at last block', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + map.onSplitNode([1], 'b2') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.get(['b2'])).toEqual([2]) + expect(map.size).toBe(3) + }) + + test('split shifts descendants of shifted blocks', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child') + + // Split 'a' at [0] - container shifts to [2], child to [2, 0, 0] + map.onSplitNode([0], 'a2') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['a2'])).toEqual([1]) + expect(map.get(['container'])).toEqual([2]) + expect(map.get(['container', 'child'])).toEqual([2, 0, 0]) + }) + }) + + describe('onMergeNode', () => { + test('merge removes entry and shifts siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + // Merge at [1] - 'b' is removed, 'c' shifts to [1] + map.onMergeNode([1]) + expect(map.get(['a'])).toEqual([0]) + expect(map.has(['b'])).toBe(false) + expect(map.get(['c'])).toEqual([1]) + expect(map.size).toBe(2) + }) + + test('merge at last position', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b')]) + map.onMergeNode([1]) + expect(map.get(['a'])).toEqual([0]) + expect(map.has(['b'])).toBe(false) + expect(map.size).toBe(1) + }) + }) + + describe('onMoveNode', () => { + test('move from beginning to end', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + // Move [0] to [2] - after removal of [0], 'b' is at [0], 'c' at [1], + // then insert at [2] + map.onMoveNode([0], [2]) + expect(map.get(['b'])).toEqual([0]) + expect(map.get(['c'])).toEqual([1]) + expect(map.get(['a'])).toEqual([2]) + }) + + test('move from end to beginning', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + // Move [2] to [0] - after removal of [2], 'a' at [0], 'b' at [1], + // then insert at [0] shifts them + map.onMoveNode([2], [0]) + expect(map.get(['c'])).toEqual([0]) + expect(map.get(['a'])).toEqual([1]) + expect(map.get(['b'])).toEqual([2]) + }) + + test('swap adjacent blocks', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + + // Move b[1] -> [0] (swap a and b) + map.onMoveNode([1], [0]) + + expect(map.get(['b'])).toEqual([0]) + expect(map.get(['a'])).toEqual([1]) + expect(map.get(['c'])).toEqual([2]) + }) + + test('move to next position', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + + // Move a[0] -> [1] + map.onMoveNode([0], [1]) + + expect(map.get(['b'])).toEqual([0]) + expect(map.get(['a'])).toEqual([1]) + expect(map.get(['c'])).toEqual([2]) + }) + + test('move to same position is a no-op', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + map.onMoveNode([1], [1]) + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['b'])).toEqual([1]) + expect(map.get(['c'])).toEqual([2]) + }) + }) + + describe('nested paths', () => { + test('insert inside container does not affect top-level siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([2], 'b') + + // Insert a child inside the container at [1, 0, 0] + map.onInsertNode([1, 0, 0], 'child1') + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['container'])).toEqual([1]) + expect(map.get(['container', 'child1'])).toEqual([1, 0, 0]) + expect(map.get(['b'])).toEqual([2]) + }) + + test('insert inside container shifts nested siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + map.onInsertNode([1, 0, 1], 'child2') + + // Insert 'child0' at [1, 0, 0] - child1 shifts to [1, 0, 1], child2 to [1, 0, 2] + map.onInsertNode([1, 0, 0], 'child0') + expect(map.get(['container', 'child0'])).toEqual([1, 0, 0]) + expect(map.get(['container', 'child1'])).toEqual([1, 0, 1]) + expect(map.get(['container', 'child2'])).toEqual([1, 0, 2]) + // Top-level unaffected + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['container'])).toEqual([1]) + }) + + test('remove inside container shifts nested siblings', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + map.onInsertNode([1, 0, 1], 'child2') + map.onInsertNode([1, 0, 2], 'child3') + + // Remove child1 at [1, 0, 0] + map.onRemoveNode([1, 0, 0]) + expect(map.has(['container', 'child1'])).toBe(false) + expect(map.get(['container', 'child2'])).toEqual([1, 0, 0]) + expect(map.get(['container', 'child3'])).toEqual([1, 0, 1]) + // Top-level unaffected + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['container'])).toEqual([1]) + }) + + test('split inside container', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + map.onInsertNode([1, 0, 1], 'child2') + + // Split child1 at [1, 0, 0] + map.onSplitNode([1, 0, 0], 'child1b') + expect(map.get(['container', 'child1'])).toEqual([1, 0, 0]) + expect(map.get(['container', 'child1b'])).toEqual([1, 0, 1]) + expect(map.get(['container', 'child2'])).toEqual([1, 0, 2]) + }) + + test('merge inside container', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + map.onInsertNode([1, 0, 1], 'child2') + map.onInsertNode([1, 0, 2], 'child3') + + // Merge at [1, 0, 1] - child2 removed, child3 shifts to [1, 0, 1] + map.onMergeNode([1, 0, 1]) + expect(map.get(['container', 'child1'])).toEqual([1, 0, 0]) + expect(map.has(['container', 'child2'])).toBe(false) + expect(map.get(['container', 'child3'])).toEqual([1, 0, 1]) + }) + }) + + describe('toBlockIndexMap', () => { + test('returns map of key to top-level index', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + const indexMap = map.toBlockIndexMap() + expect(indexMap.get('a')).toBe(0) + expect(indexMap.get('b')).toBe(1) + expect(indexMap.get('c')).toBe(2) + }) + + test('excludes nested entries', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a')]) + map.onInsertNode([1], 'container') + map.onInsertNode([1, 0, 0], 'child1') + + const indexMap = map.toBlockIndexMap() + expect(indexMap.size).toBe(2) // only 'a' and 'container' + expect(indexMap.get('a')).toBe(0) + expect(indexMap.get('container')).toBe(1) + expect(indexMap.has('child1')).toBe(false) + }) + }) + + describe('performance', () => { + function buildLargeDocument(blockCount: number) { + return Array.from({length: blockCount}, (_, i) => block(`block-${i}`)) + } + + test('rebuild 10,000 blocks under 50ms', () => { + const map = new InternalBlockPathMap() + const blocks = buildLargeDocument(10_000) + + const start = performance.now() + map.rebuild(blocks) + const elapsed = performance.now() - start + + expect(elapsed).toBeLessThan(50) + expect(map.get(['block-0'])).toEqual([0]) + expect(map.get(['block-9999'])).toEqual([9999]) + }) + + test('lookup is O(1) - 10,000 lookups under 5ms', () => { + const map = new InternalBlockPathMap() + map.rebuild(buildLargeDocument(10_000)) + + const start = performance.now() + for (let i = 0; i < 10_000; i++) { + map.get([`block-${i}`]) + } + const elapsed = performance.now() - start + + expect(elapsed).toBeLessThan(50) + }) + + test('single structural op on 10,000-block doc under 10ms', () => { + const map = new InternalBlockPathMap() + map.rebuild(buildLargeDocument(10_000)) + + // Worst case: insert at beginning, shifts all 10k entries + const start = performance.now() + map.onInsertNode([0], 'new-block') + const elapsed = performance.now() - start + + // Worst case is O(n) where n = map size - shifting all entries. + // On a 10k-block doc this should still be well under a frame budget. + expect(elapsed).toBeLessThan(10) + expect(map.get(['new-block'])).toEqual([0]) + expect(map.get(['block-0'])).toEqual([1]) + }) + + test('applyOperation handles set_node key change', () => { + const map = new InternalBlockPathMap() + map.rebuild([block('a'), block('b'), block('c')]) + + map.applyOperation({ + type: 'set_node', + path: [1], + properties: {_key: 'b'}, + newProperties: {_key: 'b-new'}, + }) + + expect(map.has(['b'])).toBe(false) + expect(map.get(['b-new'])).toEqual([1]) + expect(map.get(['a'])).toEqual([0]) + expect(map.get(['c'])).toEqual([2]) + }) + + describe('deep structures', () => { + function buildDeepMap() { + const map = new InternalBlockPathMap() + const blocks = Array.from({length: 100}, (_, i) => + block(`container-${i}`), + ) + map.rebuild(blocks) + for (let c = 0; c < 100; c++) { + for (let n = 0; n < 50; n++) { + map.onInsertNode([c, 0, n], `child-${c}-${n}`) + } + } + return map + } + + test('build 5,100-entry nested map (100 containers x 50 children)', () => { + const start = performance.now() + const map = buildDeepMap() + const elapsed = performance.now() - start + + expect(map.size).toBe(5100) + expect(elapsed).toBeLessThan(200) + expect(map.get(['container-0', 'child-0-0'])).toEqual([0, 0, 0]) + expect(map.get(['container-99', 'child-99-49'])).toEqual([99, 0, 49]) + }) + + test('5,000 nested lookups under 50ms', () => { + const map = buildDeepMap() + + const start = performance.now() + for (let c = 0; c < 100; c++) { + for (let n = 0; n < 50; n++) { + map.get([`container-${c}`, `child-${c}-${n}`]) + } + } + const elapsed = performance.now() - start + + expect(elapsed).toBeLessThan(50) + }) + + test('insert inside container shifts only nested siblings', () => { + const map = buildDeepMap() + + const start = performance.now() + map.onInsertNode([50, 0, 0], 'new-nested-block') + const elapsed = performance.now() - start + + expect(elapsed).toBeLessThan(5) + expect(map.get(['container-50', 'new-nested-block'])).toEqual([ + 50, 0, 0, + ]) + expect(map.get(['container-50', 'child-50-0'])).toEqual([50, 0, 1]) + }) + + test('insert top-level shifts all 5,100 entries', () => { + const map = buildDeepMap() + + const start = performance.now() + map.onInsertNode([0], 'new-top-block') + const elapsed = performance.now() - start + + expect(elapsed).toBeLessThan(20) + expect(map.get(['new-top-block'])).toEqual([0]) + expect(map.get(['container-0'])).toEqual([1]) + expect(map.get(['container-0', 'child-0-0'])).toEqual([1, 0, 0]) + }) + }) + + test('applyOperation with text ops is zero-cost', () => { + const map = new InternalBlockPathMap() + map.rebuild(buildLargeDocument(10_000)) + + const textOp = { + type: 'insert_text' as const, + path: [500, 0], + offset: 5, + text: 'hello', + } + + const start = performance.now() + for (let i = 0; i < 100_000; i++) { + map.applyOperation(textOp) + } + const elapsed = performance.now() - start + + // 100k text ops should be essentially free - early return + expect(elapsed).toBeLessThan(50) + }) + }) +}) diff --git a/packages/editor/src/internal-utils/block-path-map.ts b/packages/editor/src/internal-utils/block-path-map.ts new file mode 100644 index 000000000..435c6ed1e --- /dev/null +++ b/packages/editor/src/internal-utils/block-path-map.ts @@ -0,0 +1,520 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' +import type {Operation} from '../slate' +import {resolveArrayFields} from './resolve-container-fields' + +/** + * Separator used to join key segments in serialized key-paths. + * PTE `_key` values are alphanumeric and never contain `/`. + */ +const KEY_PATH_SEPARATOR = '/' + +/** + * Serialize an array of key segments into a deterministic string. + * - Top-level: `["abc"]` -> `"abc"` + * - Nested: `["container", "nested"]` -> `"container/nested"` + * - Deeply nested: `["container", "sub", "block"]` -> `"container/sub/block"` + */ +export function serializeKeyPath(keys: string[]): string { + return keys.join(KEY_PATH_SEPARATOR) +} + +/** + * Helper: exact path equality + */ +function pathEquals(a: number[], b: number[]): boolean { + if (a.length !== b.length) { + return false + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false + } + } + return true +} + +/** + * Helper: does `path` start with `prefix`? + * Strict descendant check - path must be longer than prefix. + */ +function pathStartsWith(path: number[], prefix: number[]): boolean { + if (path.length <= prefix.length) { + return false + } + for (let i = 0; i < prefix.length; i++) { + if (path[i] !== prefix[i]) { + return false + } + } + return true +} + +/** + * Helper: do two paths share the same parent prefix at a given depth? + * For depth 0 (top-level), there's no parent prefix to check - all entries match. + * For depth > 0, the path segments before `depth` must match. + */ +function sharesParentPrefix( + path: number[], + reference: number[], + depth: number, +): boolean { + if (path.length <= depth) { + return false + } + for (let i = 0; i < depth; i++) { + if (path[i] !== reference[i]) { + return false + } + } + return true +} + +/** + * @public + */ +export type BlockPathMap = Pick< + InternalBlockPathMap, + 'get' | 'getIndex' | 'has' | 'size' | 'entries' +> + +/** + * Maps serialized key-paths to their Slate paths (number[]). + * + * The map key is a serialized key-based path, not a bare `_key`. + * For top-level blocks, the serialized key-path is just the `_key` itself. + * For nested blocks, it encodes the ancestry: `"parent/child"`. + * + * Keys are only unique among siblings, not globally. The serialized key-path + * ensures uniqueness across the entire tree by encoding the full ancestry. + * + * Uses incremental updates - O(affected siblings) instead of O(total blocks). + * + * Currently indexes top-level blocks only. + * Container recursion will be added when container schema support lands. + * + * The incremental update methods are already container-aware - they handle + * paths of any depth and shift descendants correctly. + */ +export class InternalBlockPathMap { + private map: Map + + constructor() { + this.map = new Map() + } + + /** + * Serialize an array of key segments into a deterministic string. + * Static helper exposed on the class for convenience. + */ + static serializeKeyPath(keys: string[]): string { + return serializeKeyPath(keys) + } + + /** + * O(1) lookup by key-path. + * Accepts an array of `_key` strings from root to target block. + * For top-level blocks: `['abc']` + * For nested blocks: `['container', 'nested']` + */ + get(keyPath: string[]): number[] | undefined { + const path = this.map.get(serializeKeyPath(keyPath)) + return path ? [...path] : undefined + } + + /** + * Existence check by key-path. + * Accepts an array of `_key` strings from root to target block. + */ + has(keyPath: string[]): boolean { + return this.map.has(serializeKeyPath(keyPath)) + } + + /** + * Convenience: get the block's index within its parent. + * For top-level blocks with path [n], returns n. + * For nested blocks with path [a, b, c], returns c (the last segment). + * Accepts an array of `_key` strings from root to target block. + */ + getIndex(keyPath: string[]): number | undefined { + const path = this.map.get(serializeKeyPath(keyPath)) + return path?.[path.length - 1] + } + + /** + * Entry count. + */ + get size(): number { + return this.map.size + } + + /** + * Iteration over all entries. + */ + entries(): IterableIterator<[string, number[]]> { + return this.map.entries() + } + + /** + * Full rebuild from value array. Called once on init/reset. + * + * When containers and schema are provided, recurses into container fields + * to index nested blocks. The positional path uses one index per keyed + * segment: for a block at value[1].rows[0].cells[2], the positional path + * is [1, 0, 2]. + * + * Without containers, only indexes top-level blocks (backward compatible). + */ + rebuild( + value: Array, + containers?: Set, + schema?: EditorSchema, + ): void { + this.map.clear() + if (containers && containers.size > 0 && schema) { + this.indexBlocks(value, [], [], containers, schema) + } else { + // Backward compatible: top-level only + for (let i = 0; i < value.length; i++) { + const block = value[i] + if (block !== undefined) { + this.map.set(serializeKeyPath([block._key]), [i]) + } + } + } + } + + /** + * Recursively index blocks into the map. + * For each block, adds an entry with its key-path and positional path, + * then recurses into container array fields. + */ + private indexBlocks( + blocks: Array, + keyPrefix: string[], + pathPrefix: number[], + containers: Set, + schema: EditorSchema, + ): void { + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + if (block === undefined) { + continue + } + const keyPath = [...keyPrefix, block._key] + const posPath = [...pathPrefix, i] + this.map.set(serializeKeyPath(keyPath), posPath) + + // Recurse into container fields + if (containers.has(block._type)) { + const arrayFields = resolveArrayFields(schema, block._type) + for (const fieldName of arrayFields) { + const children = (block as Record)[fieldName] + if (Array.isArray(children)) { + this.indexBlocks( + children as Array, + keyPath, + posPath, + containers, + schema, + ) + } + } + } + } + } + + /** + * Find the serialized key-path prefix for a given positional path. + * Looks up the map to find the ancestor block key at each level. + * + * For a top-level path like [2], returns "" (no prefix). + * For a nested path like [1, 0, 0], finds the key at [1] and returns it. + */ + private findKeyPathPrefix(path: number[]): string | undefined { + if (path.length <= 1) { + // Top-level: no prefix needed + return '' + } + + // Find the parent block's serialized key-path. + // The parent block is at the positional path truncated to the block level. + // For path [1, 0, 0], the parent block is at [1]. + // We need to find which serialized key-path maps to a positional path + // that is a prefix of our path. + const parentBlockPath = [path[0]!] + + for (const [serializedKey, entryPath] of this.map) { + if (pathEquals(entryPath, parentBlockPath)) { + // For deeper nesting, we'd need to recurse, but for now + // we handle the two-level case (top-level parent + nested child). + return serializedKey + } + } + + return undefined + } + + /** + * Build the serialized key-path for a block being inserted/created + * at the given positional path with the given key. + */ + private buildSerializedKeyPath(path: number[], key: string): string { + if (path.length <= 1) { + // Top-level block: serialized key-path is just the key + return serializeKeyPath([key]) + } + + // Nested block: find the parent's serialized key-path + const prefix = this.findKeyPathPrefix(path) + if (prefix === undefined) { + // Parent not found - fall back to just the key + return serializeKeyPath([key]) + } + if (prefix === '') { + return serializeKeyPath([key]) + } + + return prefix + KEY_PATH_SEPARATOR + key + } + + /** + * Find the serialized key-path for an entry at the given positional path. + */ + private findKeyPathByPath(path: number[]): string | undefined { + for (const [keyPath, entryPath] of this.map) { + if (pathEquals(entryPath, path)) { + return keyPath + } + } + return undefined + } + + /** + * Apply a Slate operation to the map incrementally. + * Only handles block-level structural operations - text edits, + * selection changes, and sub-block operations are ignored. + */ + applyOperation(operation: Operation): void { + if (operation.type === 'set_selection') { + return + } + + // Only handle block-level operations for now. + // When container support lands, this guard relaxes to handle deeper paths. + if (operation.path.length !== 1) { + return + } + + switch (operation.type) { + case 'insert_node': { + const key = (operation.node as {_key?: string})._key + if (key !== undefined) { + this.onInsertNode(operation.path, key) + } + break + } + case 'remove_node': { + this.onRemoveNode(operation.path) + break + } + case 'split_node': { + const key = (operation.properties as {_key?: string})._key + if (key !== undefined) { + this.onSplitNode(operation.path, key) + } + break + } + case 'merge_node': { + this.onMergeNode(operation.path) + break + } + case 'move_node': { + this.onMoveNode(operation.path, operation.newPath) + break + } + case 'set_node': { + const newKey = (operation.newProperties as {_key?: string})._key + if (newKey !== undefined) { + const oldKey = (operation.properties as {_key?: string})._key + if (oldKey !== undefined) { + this.onSetNodeKey(operation.path, oldKey, newKey) + } + } + break + } + // insert_text, remove_text: no map change needed + } + } + + /** + * Handle a key change on a node at the given path. + * For top-level blocks, this is a simple old-key -> new-key rename. + * For nested blocks (future), this would update all descendants + * that share the old key as a prefix. + */ + private onSetNodeKey(_path: number[], oldKey: string, newKey: string): void { + // For top-level blocks, the serialized key-path is just the key + const oldKeyPath = serializeKeyPath([oldKey]) + const newKeyPath = serializeKeyPath([newKey]) + + const slatePath = this.map.get(oldKeyPath) + if (slatePath === undefined) { + return + } + + this.map.delete(oldKeyPath) + this.map.set(newKeyPath, slatePath) + + // For nested blocks (future): update all descendants whose serialized + // key-path starts with the old key prefix. + const oldPrefix = oldKeyPath + KEY_PATH_SEPARATOR + const newPrefix = newKeyPath + KEY_PATH_SEPARATOR + const entriesToUpdate: Array<[string, number[]]> = [] + + for (const [keyPath, entryPath] of this.map) { + if (keyPath.startsWith(oldPrefix)) { + entriesToUpdate.push([keyPath, entryPath]) + } + } + + for (const [keyPath, entryPath] of entriesToUpdate) { + this.map.delete(keyPath) + this.map.set(newPrefix + keyPath.slice(oldPrefix.length), entryPath) + } + } + + /** + * Incremental: a node was inserted at `path` with the given `key`. + * Shift siblings at/after the insertion point, then add the new entry. + */ + onInsertNode(path: number[], key: string): void { + const depth = path.length - 1 + const insertedIndex = path[depth]! + + // Shift existing entries at/after the insertion point + for (const [, entryPath] of this.map) { + if ( + sharesParentPrefix(entryPath, path, depth) && + entryPath[depth]! >= insertedIndex + ) { + entryPath[depth]!++ + } + } + + // Build the serialized key-path and add the new entry + const serializedKeyPath = this.buildSerializedKeyPath(path, key) + this.map.set(serializedKeyPath, [...path]) + } + + /** + * Incremental: a node was removed at `path`. + * Find and remove the entry (and any descendants), then shift siblings after + * the removal point. + */ + onRemoveNode(path: number[]): void { + const depth = path.length - 1 + const removedIndex = path[depth]! + + // Find keys to remove: exact match or descendants + const keysToRemove: string[] = [] + for (const [key, entryPath] of this.map) { + if (pathEquals(entryPath, path) || pathStartsWith(entryPath, path)) { + keysToRemove.push(key) + } + } + + for (const key of keysToRemove) { + this.map.delete(key) + } + + // Shift siblings after the removal point + for (const [, entryPath] of this.map) { + if ( + sharesParentPrefix(entryPath, path, depth) && + entryPath[depth]! > removedIndex + ) { + entryPath[depth]!-- + } + } + } + + /** + * Incremental: a node was split at `path`. + * The new node (at path+1 at the same depth) gets `newKey`. + * Shift siblings after the split point, then add the new entry. + */ + onSplitNode(path: number[], newKey: string): void { + const depth = path.length - 1 + const splitIndex = path[depth]! + const newPath = [...path] + newPath[depth] = splitIndex + 1 + + // Shift existing entries after the split point + for (const [, entryPath] of this.map) { + if ( + sharesParentPrefix(entryPath, path, depth) && + entryPath[depth]! > splitIndex + ) { + entryPath[depth]!++ + } + } + + // Build the serialized key-path and add the new entry at path+1 + const serializedKeyPath = this.buildSerializedKeyPath(newPath, newKey) + this.map.set(serializedKeyPath, newPath) + } + + /** + * Incremental: a node was merged at `path`. + * Merge removes the node at `path` (its content is merged into the previous + * sibling). Delegates to onRemoveNode. + */ + onMergeNode(path: number[]): void { + this.onRemoveNode(path) + } + + /** + * Derive a Map for backward compatibility. + * Returns a map of key -> top-level block index. + * Extracts the last key segment from the serialized path. + */ + toBlockIndexMap(): Map { + const result = new Map() + for (const [keyPath, path] of this.map) { + if (path.length === 1) { + // Extract the last key segment from the serialized path + const segments = keyPath.split(KEY_PATH_SEPARATOR) + const lastKey = segments[segments.length - 1]! + result.set(lastKey, path[0]!) + } + } + return result + } + + /** + * Incremental: a node was moved from `fromPath` to `toPath`. + * Decomposed into remove + insert. The remove shifts indices first, + * so the insert sees the post-removal state - this correctly handles + * all cases including adjacent swaps. + */ + onMoveNode(fromPath: number[], toPath: number[]): void { + // Find the serialized key-path at fromPath + const movedKeyPath = this.findKeyPathByPath(fromPath) + + if (movedKeyPath === undefined) { + return + } + + // Extract the bare key (last segment) for re-insertion + const segments = movedKeyPath.split(KEY_PATH_SEPARATOR) + const bareKey = segments[segments.length - 1]! + + // Remove from old position (shifts siblings) + this.onRemoveNode(fromPath) + + // Insert at new position (shifts siblings and adds entry) + this.onInsertNode(toPath, bareKey) + } +} diff --git a/packages/editor/src/internal-utils/create-test-snapshot.ts b/packages/editor/src/internal-utils/create-test-snapshot.ts index a5192ac78..fdc3e4965 100644 --- a/packages/editor/src/internal-utils/create-test-snapshot.ts +++ b/packages/editor/src/internal-utils/create-test-snapshot.ts @@ -1,12 +1,14 @@ import {compileSchema, defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import type {EditorSnapshot} from '../editor/editor-snapshot' +import {InternalBlockPathMap} from './block-path-map' export function createTestSnapshot(snapshot: { context?: Partial decoratorState?: Partial }): EditorSnapshot { const context = { + containers: snapshot.context?.containers ?? new Set(), converters: snapshot.context?.converters ?? [], schema: snapshot.context?.schema ?? compileSchema(defineSchema({})), keyGenerator: snapshot.context?.keyGenerator ?? createTestKeyGenerator(), @@ -15,13 +17,17 @@ export function createTestSnapshot(snapshot: { selection: snapshot.context?.selection ?? null, } const blockIndexMap = new Map() + const blockPathMap = new InternalBlockPathMap() snapshot.context?.value?.forEach((block, index) => { blockIndexMap.set(block._key, index) }) + blockPathMap.rebuild(context.value, context.containers, context.schema) + return { blockIndexMap, + blockPathMap, context, decoratorState: snapshot?.decoratorState ?? {}, } diff --git a/packages/editor/src/internal-utils/resolve-container-fields.ts b/packages/editor/src/internal-utils/resolve-container-fields.ts new file mode 100644 index 000000000..1754fab41 --- /dev/null +++ b/packages/editor/src/internal-utils/resolve-container-fields.ts @@ -0,0 +1,53 @@ +import type {FieldDefinition} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' + +/** + * Find array field names for a type by searching through schema.blockObjects + * and their nested `of` definitions. + * + * First checks top-level block objects for a direct name match. + * If not found, searches nested `of` definitions recursively. + */ +export function resolveArrayFields( + schema: EditorSchema, + typeName: string, +): string[] { + // First check top-level block objects + const blockObj = schema.blockObjects.find((bo) => bo.name === typeName) + if (blockObj) { + return blockObj.fields.filter((f) => f.type === 'array').map((f) => f.name) + } + + // Search nested of definitions in all block objects + for (const bo of schema.blockObjects) { + const found = findTypeInFields(bo.fields, typeName) + if (found) { + return found.filter((f) => f.type === 'array').map((f) => f.name) + } + } + + return [] +} + +function findTypeInFields( + fields: ReadonlyArray, + typeName: string, +): ReadonlyArray | undefined { + for (const field of fields) { + if (field.type === 'array' && 'of' in field && field.of) { + for (const ofDef of field.of) { + if (ofDef.type === typeName && 'fields' in ofDef && ofDef.fields) { + return ofDef.fields + } + // Recurse into nested of definitions + if ('fields' in ofDef && ofDef.fields) { + const found = findTypeInFields(ofDef.fields, typeName) + if (found) { + return found + } + } + } + } + } + return undefined +} diff --git a/packages/editor/src/internal-utils/traversal.test.ts b/packages/editor/src/internal-utils/traversal.test.ts new file mode 100644 index 000000000..14e75b007 --- /dev/null +++ b/packages/editor/src/internal-utils/traversal.test.ts @@ -0,0 +1,935 @@ +import {compileSchema, defineSchema} from '@portabletext/schema' +import type {PortableTextBlock} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import {createTestSnapshot} from './create-test-snapshot' +import { + getAncestors, + getChildren, + getContainingContainer, + getDepth, + getFirstLeaf, + getLastLeaf, + getNextBlock, + getNextSibling, + getNode, + getParent, + getPrevBlock, + getPrevSibling, + isDescendantOf, + isNested, +} from './traversal' + +function textBlock(key: string, text = ''): PortableTextBlock { + return { + _type: 'block', + _key: key, + children: [{_type: 'span', _key: `${key}-span`, text}], + markDefs: [], + } +} + +function imageBlock(key: string): PortableTextBlock { + return {_type: 'image', _key: key} +} + +function container( + key: string, + type: string, + fieldName: string, + items: Array, +): PortableTextBlock { + return {_type: type, _key: key, [fieldName]: items} +} + +const schemaWithImage = compileSchema( + defineSchema({ + blockObjects: [ + {name: 'image'}, + { + 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 containerTypes = new Set(['table', 'row', 'cell']) + +// -- Flat document (top-level only) -- + +const flatValue: Array = [ + textBlock('a'), + textBlock('b'), + imageBlock('img1'), + textBlock('c'), +] + +function flatSnapshot() { + return createTestSnapshot({ + context: { + value: flatValue, + schema: schemaWithImage, + containers: containerTypes, + }, + }) +} + +// -- Deep document with nested containers -- +// Structure: +// p1 (text) +// table-1 (container) +// rows: +// row-1 (container) +// cells: +// cell-1 (container) +// content: +// nested-p1 (text) +// nested-p2 (text) +// cell-2 (container) +// content: +// nested-p3 (text) +// row-2 (container) +// cells: +// cell-3 (container) +// content: +// nested-p4 (text) +// nested-img1 (block object) +// p2 (text) +// img-1 (block object) + +const nestedP1 = textBlock('nested-p1', 'Cell 1 para 1') +const nestedP2 = textBlock('nested-p2', 'Cell 1 para 2') +const nestedP3 = textBlock('nested-p3', 'Cell 2 text') +const nestedP4 = textBlock('nested-p4', 'Row 2 text') +const nestedImg1 = imageBlock('nested-img1') + +const cell1 = container('cell-1', 'cell', 'content', [nestedP1, nestedP2]) +const cell2 = container('cell-2', 'cell', 'content', [nestedP3]) +const cell3 = container('cell-3', 'cell', 'content', [nestedP4, nestedImg1]) + +const row1 = container('row-1', 'row', 'cells', [cell1, cell2]) +const row2 = container('row-2', 'row', 'cells', [cell3]) + +const table1 = container('table-1', 'table', 'rows', [row1, row2]) + +const p1 = textBlock('p1', 'Before table') +const p2 = textBlock('p2', 'After table') +const img1 = imageBlock('img-1') + +const deepValue: Array = [p1, table1, p2, img1] + +function deepSnapshot() { + return createTestSnapshot({ + context: { + value: deepValue, + schema: schemaWithImage, + containers: containerTypes, + }, + }) +} + +// Path helpers for readability +const pathP1 = [{_key: 'p1'}] +const pathP2 = [{_key: 'p2'}] +const pathImg1 = [{_key: 'img-1'}] +const pathTable1 = [{_key: 'table-1'}] +const pathRow1 = [{_key: 'table-1'}, 'rows', {_key: 'row-1'}] +const pathRow2 = [{_key: 'table-1'}, 'rows', {_key: 'row-2'}] +const pathCell1 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-1'}, + 'cells', + {_key: 'cell-1'}, +] +const pathCell2 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-1'}, + 'cells', + {_key: 'cell-2'}, +] +const pathCell3 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-2'}, + 'cells', + {_key: 'cell-3'}, +] +const pathNestedP1 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-1'}, + 'cells', + {_key: 'cell-1'}, + 'content', + {_key: 'nested-p1'}, +] +const pathNestedP2 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-1'}, + 'cells', + {_key: 'cell-1'}, + 'content', + {_key: 'nested-p2'}, +] +const pathNestedP3 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-1'}, + 'cells', + {_key: 'cell-2'}, + 'content', + {_key: 'nested-p3'}, +] +const pathNestedP4 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-2'}, + 'cells', + {_key: 'cell-3'}, + 'content', + {_key: 'nested-p4'}, +] +const pathNestedImg1 = [ + {_key: 'table-1'}, + 'rows', + {_key: 'row-2'}, + 'cells', + {_key: 'cell-3'}, + 'content', + {_key: 'nested-img1'}, +] + +describe('traversal', () => { + // ---- getNode ---- + describe('getNode', () => { + test('finds top-level text block', () => { + const snapshot = flatSnapshot() + expect(getNode(snapshot, [{_key: 'a'}])).toEqual({ + node: flatValue[0], + path: [{_key: 'a'}], + }) + }) + + test('finds top-level block object', () => { + const snapshot = flatSnapshot() + expect(getNode(snapshot, [{_key: 'img1'}])).toEqual({ + node: flatValue[2], + path: [{_key: 'img1'}], + }) + }) + + test('returns undefined for missing key', () => { + const snapshot = flatSnapshot() + expect(getNode(snapshot, [{_key: 'missing'}])).toEqual(undefined) + }) + + test('finds deeply nested block', () => { + const snapshot = deepSnapshot() + expect(getNode(snapshot, pathNestedP1)).toEqual({ + node: nestedP1, + path: pathNestedP1, + }) + }) + + test('finds container itself', () => { + const snapshot = deepSnapshot() + expect(getNode(snapshot, pathTable1)).toEqual({ + node: table1, + path: pathTable1, + }) + }) + + test('finds intermediate container', () => { + const snapshot = deepSnapshot() + expect(getNode(snapshot, pathRow1)).toEqual({ + node: row1, + path: pathRow1, + }) + }) + + test('returns undefined for nonexistent nested key', () => { + const snapshot = deepSnapshot() + expect( + getNode(snapshot, [{_key: 'table-1'}, 'rows', {_key: 'nonexistent'}]), + ).toEqual(undefined) + }) + }) + + // ---- getParent ---- + describe('getParent', () => { + test('returns undefined for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(getParent(snapshot, [{_key: 'a'}])).toEqual(undefined) + expect(getParent(snapshot, [{_key: 'b'}])).toEqual(undefined) + }) + + test('returns table for row', () => { + const snapshot = deepSnapshot() + expect(getParent(snapshot, pathRow1)).toEqual({ + node: table1, + path: pathTable1, + }) + }) + + test('returns row for cell', () => { + const snapshot = deepSnapshot() + expect(getParent(snapshot, pathCell1)).toEqual({ + node: row1, + path: pathRow1, + }) + }) + + test('returns cell for deeply nested text block', () => { + const snapshot = deepSnapshot() + expect(getParent(snapshot, pathNestedP1)).toEqual({ + node: cell1, + path: pathCell1, + }) + }) + + test('returns undefined for top-level in deep document', () => { + const snapshot = deepSnapshot() + expect(getParent(snapshot, pathP1)).toEqual(undefined) + }) + }) + + // ---- getChildren ---- + describe('getChildren', () => { + test('returns empty for text blocks', () => { + const snapshot = flatSnapshot() + expect(getChildren(snapshot, [{_key: 'a'}])).toEqual([]) + }) + + test('returns empty for block objects', () => { + const snapshot = flatSnapshot() + expect(getChildren(snapshot, [{_key: 'img1'}])).toEqual([]) + }) + + test('returns rows for table', () => { + const snapshot = deepSnapshot() + const children = getChildren(snapshot, pathTable1) + expect(children).toEqual([ + {node: row1, path: pathRow1}, + {node: row2, path: pathRow2}, + ]) + }) + + test('returns cells for row', () => { + const snapshot = deepSnapshot() + const children = getChildren(snapshot, pathRow1) + expect(children).toEqual([ + {node: cell1, path: pathCell1}, + {node: cell2, path: pathCell2}, + ]) + }) + + test('returns text blocks and block objects for cell', () => { + const snapshot = deepSnapshot() + const children = getChildren(snapshot, pathCell1) + expect(children).toEqual([ + {node: nestedP1, path: pathNestedP1}, + {node: nestedP2, path: pathNestedP2}, + ]) + }) + + test('returns mixed children for cell with block object', () => { + const snapshot = deepSnapshot() + const children = getChildren(snapshot, pathCell3) + expect(children).toEqual([ + {node: nestedP4, path: pathNestedP4}, + {node: nestedImg1, path: pathNestedImg1}, + ]) + }) + + test('returns empty for nested text block', () => { + const snapshot = deepSnapshot() + expect(getChildren(snapshot, pathNestedP1)).toEqual([]) + }) + + test('returns empty for missing node', () => { + const snapshot = deepSnapshot() + expect(getChildren(snapshot, [{_key: 'missing'}])).toEqual([]) + }) + }) + + // ---- getNextSibling ---- + describe('getNextSibling', () => { + test('returns next top-level block', () => { + const snapshot = flatSnapshot() + expect(getNextSibling(snapshot, [{_key: 'a'}])).toEqual({ + node: flatValue[1], + path: [{_key: 'b'}], + }) + }) + + test('returns undefined for last top-level block', () => { + const snapshot = flatSnapshot() + expect(getNextSibling(snapshot, [{_key: 'c'}])).toEqual(undefined) + }) + + test('returns next nested sibling', () => { + const snapshot = deepSnapshot() + expect(getNextSibling(snapshot, pathNestedP1)).toEqual({ + node: nestedP2, + path: pathNestedP2, + }) + }) + + test('returns undefined for last nested sibling', () => { + const snapshot = deepSnapshot() + expect(getNextSibling(snapshot, pathNestedP2)).toEqual(undefined) + }) + + test('returns next cell sibling', () => { + const snapshot = deepSnapshot() + expect(getNextSibling(snapshot, pathCell1)).toEqual({ + node: cell2, + path: pathCell2, + }) + }) + + test('returns next row sibling', () => { + const snapshot = deepSnapshot() + expect(getNextSibling(snapshot, pathRow1)).toEqual({ + node: row2, + path: pathRow2, + }) + }) + + test('returns undefined for missing key', () => { + const snapshot = deepSnapshot() + expect(getNextSibling(snapshot, [{_key: 'missing'}])).toEqual(undefined) + }) + }) + + // ---- getPrevSibling ---- + describe('getPrevSibling', () => { + test('returns previous top-level block', () => { + const snapshot = flatSnapshot() + expect(getPrevSibling(snapshot, [{_key: 'b'}])).toEqual({ + node: flatValue[0], + path: [{_key: 'a'}], + }) + }) + + test('returns undefined for first top-level block', () => { + const snapshot = flatSnapshot() + expect(getPrevSibling(snapshot, [{_key: 'a'}])).toEqual(undefined) + }) + + test('returns previous nested sibling', () => { + const snapshot = deepSnapshot() + expect(getPrevSibling(snapshot, pathNestedP2)).toEqual({ + node: nestedP1, + path: pathNestedP1, + }) + }) + + test('returns undefined for first nested sibling', () => { + const snapshot = deepSnapshot() + expect(getPrevSibling(snapshot, pathNestedP1)).toEqual(undefined) + }) + + test('returns previous cell sibling', () => { + const snapshot = deepSnapshot() + expect(getPrevSibling(snapshot, pathCell2)).toEqual({ + node: cell1, + path: pathCell1, + }) + }) + + test('returns undefined for missing key', () => { + const snapshot = deepSnapshot() + expect(getPrevSibling(snapshot, [{_key: 'missing'}])).toEqual(undefined) + }) + }) + + // ---- getAncestors ---- + describe('getAncestors', () => { + test('returns empty for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(getAncestors(snapshot, [{_key: 'a'}])).toEqual([]) + }) + + test('returns [table] for row', () => { + const snapshot = deepSnapshot() + expect(getAncestors(snapshot, pathRow1)).toEqual([ + {node: table1, path: pathTable1}, + ]) + }) + + test('returns [table, row] for cell', () => { + const snapshot = deepSnapshot() + expect(getAncestors(snapshot, pathCell1)).toEqual([ + {node: table1, path: pathTable1}, + {node: row1, path: pathRow1}, + ]) + }) + + test('returns [table, row, cell] for deeply nested text block', () => { + const snapshot = deepSnapshot() + expect(getAncestors(snapshot, pathNestedP1)).toEqual([ + {node: table1, path: pathTable1}, + {node: row1, path: pathRow1}, + {node: cell1, path: pathCell1}, + ]) + }) + + test('returns ancestors for nested-p4 in second row', () => { + const snapshot = deepSnapshot() + expect(getAncestors(snapshot, pathNestedP4)).toEqual([ + {node: table1, path: pathTable1}, + {node: row2, path: pathRow2}, + {node: cell3, path: pathCell3}, + ]) + }) + }) + + // ---- getNextBlock (cursor-order, containers opaque) ---- + describe('getNextBlock', () => { + test('returns next leaf in flat document', () => { + const snapshot = flatSnapshot() + expect(getNextBlock(snapshot, [{_key: 'a'}])).toEqual({ + node: flatValue[1], + path: [{_key: 'b'}], + }) + }) + + test('returns undefined at end of flat document', () => { + const snapshot = flatSnapshot() + expect(getNextBlock(snapshot, [{_key: 'c'}])).toEqual(undefined) + }) + + test('returns container from preceding block (does not descend)', () => { + const snapshot = deepSnapshot() + // p1 -> table-1 (container returned as cursor stop) + expect(getNextBlock(snapshot, pathP1)).toEqual({ + node: table1, + path: pathTable1, + }) + }) + + test('moves to next sibling within cell', () => { + const snapshot = deepSnapshot() + expect(getNextBlock(snapshot, pathNestedP1)).toEqual({ + node: nestedP2, + path: pathNestedP2, + }) + }) + + test('walks up to next container sibling at cell boundary', () => { + const snapshot = deepSnapshot() + // nested-p2 (last in cell-1.content) -> walks up -> cell-2 + expect(getNextBlock(snapshot, pathNestedP2)).toEqual({ + node: cell2, + path: pathCell2, + }) + }) + + test('walks up to next container sibling at row boundary', () => { + const snapshot = deepSnapshot() + // nested-p3 (only in cell-2.content) -> walks up past cell-2, past row-1 -> row-2 + expect(getNextBlock(snapshot, pathNestedP3)).toEqual({ + node: row2, + path: pathRow2, + }) + }) + + test('returns next leaf block object within cell', () => { + const snapshot = deepSnapshot() + // nested-p4 -> nested-img1 (image is a leaf block object) + expect(getNextBlock(snapshot, pathNestedP4)).toEqual({ + node: nestedImg1, + path: pathNestedImg1, + }) + }) + + test('exits container to following block', () => { + const snapshot = deepSnapshot() + // nested-img1 (last in table) -> walks up through cell-3, row-2, table-1 -> p2 + expect(getNextBlock(snapshot, pathNestedImg1)).toEqual({ + node: p2, + path: pathP2, + }) + }) + + test('moves from text to block object', () => { + const snapshot = deepSnapshot() + // p2 -> img-1 + expect(getNextBlock(snapshot, pathP2)).toEqual({ + node: img1, + path: pathImg1, + }) + }) + + test('returns undefined at end of deep document', () => { + const snapshot = deepSnapshot() + expect(getNextBlock(snapshot, pathImg1)).toEqual(undefined) + }) + + test('returns next sibling when given container path', () => { + const snapshot = deepSnapshot() + // table-1 -> p2 (does not descend into container) + expect(getNextBlock(snapshot, pathTable1)).toEqual({ + node: p2, + path: pathP2, + }) + }) + + test('returns undefined for missing key', () => { + const snapshot = deepSnapshot() + expect(getNextBlock(snapshot, [{_key: 'missing'}])).toEqual(undefined) + }) + }) + + // ---- getPrevBlock (cursor-order, containers opaque) ---- + describe('getPrevBlock', () => { + test('returns previous leaf in flat document', () => { + const snapshot = flatSnapshot() + expect(getPrevBlock(snapshot, [{_key: 'b'}])).toEqual({ + node: flatValue[0], + path: [{_key: 'a'}], + }) + }) + + test('returns undefined at start of flat document', () => { + const snapshot = flatSnapshot() + expect(getPrevBlock(snapshot, [{_key: 'a'}])).toEqual(undefined) + }) + + test('returns container from following block (does not descend)', () => { + const snapshot = deepSnapshot() + // p2 -> table-1 (container returned as cursor stop) + expect(getPrevBlock(snapshot, pathP2)).toEqual({ + node: table1, + path: pathTable1, + }) + }) + + test('moves to previous sibling within cell', () => { + const snapshot = deepSnapshot() + expect(getPrevBlock(snapshot, pathNestedP2)).toEqual({ + node: nestedP1, + path: pathNestedP1, + }) + }) + + test('walks up to prev container sibling at cell boundary', () => { + const snapshot = deepSnapshot() + // nested-p3 (first in cell-2.content) -> walks up -> cell-1 + expect(getPrevBlock(snapshot, pathNestedP3)).toEqual({ + node: cell1, + path: pathCell1, + }) + }) + + test('walks up to prev container sibling at row boundary', () => { + const snapshot = deepSnapshot() + // nested-p4 (first in cell-3.content) -> walks up past cell-3, past row-2 -> row-1 + expect(getPrevBlock(snapshot, pathNestedP4)).toEqual({ + node: row1, + path: pathRow1, + }) + }) + + test('exits container to top-level backward', () => { + const snapshot = deepSnapshot() + // nested-p1 (first in table) -> walks up through cell-1, row-1, table-1 -> p1 + expect(getPrevBlock(snapshot, pathNestedP1)).toEqual({ + node: p1, + path: pathP1, + }) + }) + + test('moves from block object to text', () => { + const snapshot = deepSnapshot() + // img-1 -> p2 + expect(getPrevBlock(snapshot, pathImg1)).toEqual({ + node: p2, + path: pathP2, + }) + }) + + test('getPrevBlock from block object to text within cell', () => { + const snapshot = deepSnapshot() + // nested-img1 -> nested-p4 + expect(getPrevBlock(snapshot, pathNestedImg1)).toEqual({ + node: nestedP4, + path: pathNestedP4, + }) + }) + + test('returns undefined at start of deep document', () => { + const snapshot = deepSnapshot() + expect(getPrevBlock(snapshot, pathP1)).toEqual(undefined) + }) + + test('returns undefined for missing key', () => { + const snapshot = deepSnapshot() + expect(getPrevBlock(snapshot, [{_key: 'missing'}])).toEqual(undefined) + }) + }) + + // ---- isNested ---- + describe('isNested', () => { + test('returns false for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(isNested(snapshot, [{_key: 'a'}])).toEqual(false) + }) + + test('returns true for nested blocks', () => { + const snapshot = deepSnapshot() + expect(isNested(snapshot, pathRow1)).toEqual(true) + expect(isNested(snapshot, pathCell1)).toEqual(true) + expect(isNested(snapshot, pathNestedP1)).toEqual(true) + }) + + test('returns false for top-level in deep document', () => { + const snapshot = deepSnapshot() + expect(isNested(snapshot, pathP1)).toEqual(false) + expect(isNested(snapshot, pathTable1)).toEqual(false) + }) + }) + + // ---- getDepth ---- + describe('getDepth', () => { + test('returns 0 for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(getDepth(snapshot, [{_key: 'a'}])).toEqual(0) + }) + + test('returns 1 for row (one level deep)', () => { + const snapshot = deepSnapshot() + expect(getDepth(snapshot, pathRow1)).toEqual(1) + }) + + test('returns 2 for cell (two levels deep)', () => { + const snapshot = deepSnapshot() + expect(getDepth(snapshot, pathCell1)).toEqual(2) + }) + + test('returns 3 for deeply nested text block', () => { + const snapshot = deepSnapshot() + expect(getDepth(snapshot, pathNestedP1)).toEqual(3) + }) + + test('returns 0 for top-level in deep document', () => { + const snapshot = deepSnapshot() + expect(getDepth(snapshot, pathP1)).toEqual(0) + expect(getDepth(snapshot, pathTable1)).toEqual(0) + }) + }) + + // ---- isDescendantOf ---- + describe('isDescendantOf', () => { + test('returns false for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(isDescendantOf(snapshot, [{_key: 'a'}], [{_key: 'b'}])).toEqual( + false, + ) + }) + + test('returns false when paths are equal', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathTable1, pathTable1)).toEqual(false) + }) + + test('returns true for row under table', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathRow1, pathTable1)).toEqual(true) + }) + + test('returns true for deeply nested block under table', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathNestedP1, pathTable1)).toEqual(true) + }) + + test('returns true for nested block under cell', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathNestedP1, pathCell1)).toEqual(true) + }) + + test('returns false for unrelated paths', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathP1, pathTable1)).toEqual(false) + }) + + test('returns false for sibling paths', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathNestedP1, pathNestedP2)).toEqual( + false, + ) + }) + + test('returns false for ancestor as descendant', () => { + const snapshot = deepSnapshot() + expect(isDescendantOf(snapshot, pathTable1, pathNestedP1)).toEqual(false) + }) + }) + + // ---- getContainingContainer ---- + describe('getContainingContainer', () => { + test('returns undefined for top-level blocks', () => { + const snapshot = flatSnapshot() + expect(getContainingContainer(snapshot, [{_key: 'a'}])).toEqual(undefined) + }) + + test('returns undefined for top-level in deep document', () => { + const snapshot = deepSnapshot() + expect(getContainingContainer(snapshot, pathP1)).toEqual(undefined) + }) + + test('returns cell for deeply nested text block', () => { + const snapshot = deepSnapshot() + expect(getContainingContainer(snapshot, pathNestedP1)).toEqual({ + node: cell1, + path: pathCell1, + }) + }) + + test('returns row for cell', () => { + const snapshot = deepSnapshot() + expect(getContainingContainer(snapshot, pathCell1)).toEqual({ + node: row1, + path: pathRow1, + }) + }) + + test('returns table for row', () => { + const snapshot = deepSnapshot() + expect(getContainingContainer(snapshot, pathRow1)).toEqual({ + node: table1, + path: pathTable1, + }) + }) + + test('returns undefined for top-level container', () => { + const snapshot = deepSnapshot() + expect(getContainingContainer(snapshot, pathTable1)).toEqual(undefined) + }) + }) + + describe('getFirstLeaf', () => { + test('returns leaf block as itself', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, p1, pathP1)).toEqual({ + node: p1, + path: pathP1, + }) + }) + + test('returns first deeply nested leaf from table', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, table1, pathTable1)).toEqual({ + node: nestedP1, + path: pathNestedP1, + }) + }) + + test('returns first leaf from row', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, row1, pathRow1)).toEqual({ + node: nestedP1, + path: pathNestedP1, + }) + }) + + test('returns first leaf from cell', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, cell2, pathCell2)).toEqual({ + node: nestedP3, + path: pathNestedP3, + }) + }) + + test('returns block object leaf (non-container)', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, img1, pathImg1)).toEqual({ + node: img1, + path: pathImg1, + }) + }) + + test('returns first leaf from second row', () => { + const snapshot = deepSnapshot() + expect(getFirstLeaf(snapshot, row2, pathRow2)).toEqual({ + node: nestedP4, + path: pathNestedP4, + }) + }) + }) + + describe('getLastLeaf', () => { + test('returns leaf block as itself', () => { + const snapshot = deepSnapshot() + expect(getLastLeaf(snapshot, p2, pathP2)).toEqual({ + node: p2, + path: pathP2, + }) + }) + + test('returns last deeply nested leaf from table', () => { + const snapshot = deepSnapshot() + expect(getLastLeaf(snapshot, table1, pathTable1)).toEqual({ + node: nestedImg1, + path: pathNestedImg1, + }) + }) + + test('returns last leaf from row', () => { + const snapshot = deepSnapshot() + expect(getLastLeaf(snapshot, row1, pathRow1)).toEqual({ + node: nestedP3, + path: pathNestedP3, + }) + }) + + test('returns last leaf from cell with mixed content', () => { + const snapshot = deepSnapshot() + // cell-3 has nested-p4 and nested-img1 + expect(getLastLeaf(snapshot, cell3, pathCell3)).toEqual({ + node: nestedImg1, + path: pathNestedImg1, + }) + }) + + test('returns last leaf from first cell', () => { + const snapshot = deepSnapshot() + expect(getLastLeaf(snapshot, cell1, pathCell1)).toEqual({ + node: nestedP2, + path: pathNestedP2, + }) + }) + }) +}) diff --git a/packages/editor/src/internal-utils/traversal.ts b/packages/editor/src/internal-utils/traversal.ts new file mode 100644 index 000000000..1a40d9df3 --- /dev/null +++ b/packages/editor/src/internal-utils/traversal.ts @@ -0,0 +1,761 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import type {EditorSnapshot} from '../editor/editor-snapshot' +import type {KeyedSegment, Path, PathSegment} from '../types/paths' +import {resolveArrayFields} from './resolve-container-fields' + +/** + * Result of a traversal operation: the node and its path. + */ +export type TraversalResult = {node: PortableTextBlock; path: Path} + +/** + * Internal result from resolving a node by path, including sibling context. + */ +interface ResolvedNode { + node: PortableTextBlock + siblings: Array + indexInSiblings: number +} + +/** + * Check if a path segment is a keyed segment. + */ +function isKeyedSegment(segment: PathSegment): segment is KeyedSegment { + return ( + typeof segment === 'object' && + segment !== null && + '_key' in segment && + !Array.isArray(segment) + ) +} + +/** + * Extract `_key` values from KeyedSegment segments in a Path. + * Used to build the key-path for BlockPathMap lookups. + */ +function extractKeys(path: Path): string[] { + const keys: string[] = [] + for (const segment of path) { + if (isKeyedSegment(segment)) { + keys.push(segment._key) + } + } + return keys +} + +/** + * Extract field name segments (strings) from a Path. + * These are the named array fields between keyed segments. + * For path [{_key: 'table-1'}, 'rows', {_key: 'row-1'}], + * returns ['rows']. + */ +function extractFieldNames(path: Path): string[] { + const fields: string[] = [] + for (const segment of path) { + if (typeof segment === 'string') { + fields.push(segment) + } + } + return fields +} + +/** + * Walk the value tree using field names and positional indices. + * + * The positional path has one index per keyed segment: + * - posPath[0] is the index of the first keyed block in `value` + * - posPath[1] is the index of the second keyed block in its parent's array field + * - etc. + * + * The field names tell us which named array field to descend into at each level. + * fieldNames[0] is the field name on the block at posPath[0] that contains the block at posPath[1]. + * + * Returns the resolved node info including siblings and index. + */ +function walkValueTree( + value: Array, + fieldNames: string[], + posPath: number[], +): ResolvedNode | undefined { + if (posPath.length === 0) { + return undefined + } + + let siblings: Array = value + let node: PortableTextBlock | undefined + let indexInSiblings = -1 + + for (let i = 0; i < posPath.length; i++) { + const idx = posPath[i] + if (idx === undefined) { + return undefined + } + + node = siblings[idx] + if (!node) { + return undefined + } + indexInSiblings = idx + + // If there's a next level, descend into the named field + if (i < posPath.length - 1) { + const fieldName = fieldNames[i] + if (!fieldName) { + return undefined + } + const field = (node as Record)[fieldName] + if (!Array.isArray(field)) { + return undefined + } + siblings = field as Array + } + } + + if (!node) { + return undefined + } + + return {node, siblings, indexInSiblings} +} + +/** + * Resolve a node by walking the value tree following the path segments. + * + * Uses the BlockPathMap for O(1) position resolution when available, + * then walks the tree using positional indices - O(depth) with O(1) per step. + * + * Falls back to linear scan when the map doesn't have the entry + * (e.g., during transitions or for paths not yet indexed). + */ +function resolveNode( + snapshot: EditorSnapshot, + path: Path, +): ResolvedNode | undefined { + if (path.length === 0) { + return undefined + } + + const value = snapshot.context.value + const keys = extractKeys(path) + + if (keys.length === 0) { + return undefined + } + + // Try O(1) map lookup + const posPath = snapshot.blockPathMap.get(keys) + + if (posPath) { + const fieldNames = extractFieldNames(path) + return walkValueTree(value, fieldNames, posPath) + } + + // Fallback: walk the tree using findIndex (for entries not in the map) + return resolveNodeByWalk(value, path) +} + +/** + * Fallback: resolve a node by walking the value tree with findIndex scans. + * Used when the BlockPathMap doesn't have the entry. + */ +function resolveNodeByWalk( + value: Array, + path: Path, +): ResolvedNode | undefined { + if (path.length === 0) { + return undefined + } + + let siblings: Array = value + let node: PortableTextBlock | undefined + let indexInSiblings = -1 + + for (let i = 0; i < path.length; i++) { + const segment = path[i] + + if (segment === undefined) { + return undefined + } + + if (typeof segment === 'string') { + // Field name segment - access the named array field on the current node + if (!node) { + return undefined + } + const field = (node as Record)[segment] + if (!Array.isArray(field)) { + return undefined + } + siblings = field as Array + continue + } + + // KeyedSegment - find the block with this _key in current siblings + if (!isKeyedSegment(segment)) { + return undefined + } + const key = segment._key + const idx = siblings.findIndex((b) => b._key === key) + if (idx === -1) { + return undefined + } + indexInSiblings = idx + node = siblings[idx] + } + + if (!node) { + return undefined + } + + return {node, siblings, indexInSiblings} +} + +/** + * Check if a block is a text block. + */ +function isTextBlock( + snapshot: EditorSnapshot, + block: PortableTextBlock, +): boolean { + return block._type === snapshot.context.schema.block.name +} + +/** + * Check if a block is a block object (void). + */ +function isBlockObject( + snapshot: EditorSnapshot, + block: PortableTextBlock, +): boolean { + return snapshot.context.schema.blockObjects.some( + (bo) => bo.name === block._type, + ) +} + +/** + * Check if a block is a container (a block object in the containers set). + */ +function isContainer( + snapshot: EditorSnapshot, + block: PortableTextBlock, +): boolean { + return snapshot.context.containers.has(block._type) +} + +/** + * Check if a block is a leaf (text block or non-container block object) + * where a cursor can rest. + * Containers are NOT leaf blocks - they contain other blocks. + */ +function isLeafBlock( + snapshot: EditorSnapshot, + block: PortableTextBlock, +): boolean { + if (isTextBlock(snapshot, block)) { + return true + } + if (isBlockObject(snapshot, block) && !isContainer(snapshot, block)) { + return true + } + return false +} + +/** + * Get the array field names for a container type. + * Uses snapshot.context.containers to check if the type is a container, + * then resolves fields from schema.blockObjects and their nested of definitions. + */ +function getContainerArrayFields( + snapshot: EditorSnapshot, + typeName: string, +): string[] { + if (!snapshot.context.containers.has(typeName)) { + return [] + } + return resolveArrayFields(snapshot.context.schema, typeName) +} + +/** + * Find all direct block children of a node by scanning its array fields. + * + * Uses schema-driven container field discovery when the node's type is + * registered as a container. Falls back to duck-typing (Object.entries + * scanning) for unregistered types. + */ +function findBlockChildren( + snapshot: EditorSnapshot, + node: PortableTextBlock, + parentPath: Path, +): Array<{node: PortableTextBlock; path: Path; fieldName: string}> { + const results: Array<{ + node: PortableTextBlock + path: Path + fieldName: string + }> = [] + + const arrayFields = getContainerArrayFields(snapshot, node._type) + + if (arrayFields.length > 0) { + // Schema-driven: iterate fields in schema-defined order + for (const fieldName of arrayFields) { + const value = (node as Record)[fieldName] + if (!Array.isArray(value)) { + continue + } + for (const item of value) { + if ( + item && + typeof item === 'object' && + '_key' in item && + '_type' in item + ) { + results.push({ + node: item as PortableTextBlock, + path: [ + ...parentPath, + fieldName, + {_key: String((item as Record)['_key'])}, + ], + fieldName, + }) + } + } + } + } else { + // Fallback: duck-typing for unregistered container types + for (const [key, value] of Object.entries(node)) { + if (key.startsWith('_')) { + continue + } + if (key === 'children' || key === 'markDefs') { + continue + } + if (Array.isArray(value)) { + for (const item of value) { + if ( + item && + typeof item === 'object' && + '_key' in item && + '_type' in item + ) { + results.push({ + node: item as PortableTextBlock, + path: [ + ...parentPath, + key, + {_key: String((item as Record)['_key'])}, + ], + fieldName: key, + }) + } + } + } + } + } + + return results +} + +/** + * Get the first leaf block (depth-first) within a node. + * Useful for explicitly entering a container. + */ +export function getFirstLeaf( + snapshot: EditorSnapshot, + node: PortableTextBlock, + path: Path, +): TraversalResult | undefined { + if (isLeafBlock(snapshot, node)) { + return {node, path} + } + const children = findBlockChildren(snapshot, node, path) + for (const child of children) { + const leaf = getFirstLeaf(snapshot, child.node, child.path) + if (leaf) { + return leaf + } + } + return undefined +} + +/** + * Get the last leaf block (depth-first) within a node. + * Useful for explicitly entering a container from the end. + */ +export function getLastLeaf( + snapshot: EditorSnapshot, + node: PortableTextBlock, + path: Path, +): TraversalResult | undefined { + if (isLeafBlock(snapshot, node)) { + return {node, path} + } + const children = findBlockChildren(snapshot, node, path) + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i] + if (child) { + const leaf = getLastLeaf(snapshot, child.node, child.path) + if (leaf) { + return leaf + } + } + } + return undefined +} + +/** + * Build the path to a sibling by replacing the last keyed segment. + */ +function siblingPath(path: Path, siblingKey: string): Path { + const result = [...path] + result[result.length - 1] = {_key: siblingKey} + return result +} + +/** + * Get a node by its path. + */ +export function getNode( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + return {node: resolved.node, path} +} + +/** + * Get the parent of a node. + * Strips the last keyed segment (and preceding string field name if present). + * Returns undefined for top-level blocks. + */ +export function getParent( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + // Count keyed segments - if only one, it's top-level + const keyedCount = path.filter(isKeyedSegment).length + if (keyedCount <= 1) { + return undefined + } + + // Strip the last keyed segment and the preceding string field name + let parentPath = [...path] + + // Remove last segment (should be a keyed segment) + const lastSegment = parentPath[parentPath.length - 1] + if (lastSegment && isKeyedSegment(lastSegment)) { + parentPath = parentPath.slice(0, -1) + } + + // Remove the field name string segment before it + const prevSegment = parentPath[parentPath.length - 1] + if (prevSegment && typeof prevSegment === 'string') { + parentPath = parentPath.slice(0, -1) + } + + if (parentPath.length === 0) { + return undefined + } + + return getNode(snapshot, parentPath) +} + +/** + * Get all direct children of a node. + * For text blocks and block objects, returns empty (they have no block children). + * For containers, returns their direct block children. + */ +export function getChildren( + snapshot: EditorSnapshot, + path: Path, +): Array { + const result = getNode(snapshot, path) + + if (!result) { + return [] + } + + if (isLeafBlock(snapshot, result.node)) { + return [] + } + + return findBlockChildren(snapshot, result.node, path).map((child) => ({ + node: child.node, + path: child.path, + })) +} + +/** + * Get the next sibling at the same level. + */ +export function getNextSibling( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + const nextBlock = resolved.siblings[resolved.indexInSiblings + 1] + + if (!nextBlock) { + return undefined + } + + return {node: nextBlock, path: siblingPath(path, nextBlock._key)} +} + +/** + * Get the previous sibling at the same level. + */ +export function getPrevSibling( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved || resolved.indexInSiblings <= 0) { + return undefined + } + + const prevBlock = resolved.siblings[resolved.indexInSiblings - 1] + + if (!prevBlock) { + return undefined + } + + return {node: prevBlock, path: siblingPath(path, prevBlock._key)} +} + +/** + * Get all ancestors from root to the node's parent. + * Returns ancestors in order from outermost to innermost. + */ +export function getAncestors( + snapshot: EditorSnapshot, + path: Path, +): Array { + const ancestors: Array = [] + + // Build ancestor paths by collecting keyed segment prefixes + let currentPath: Path = [] + + for (let i = 0; i < path.length; i++) { + const segment = path[i] + if (segment === undefined) { + break + } + + currentPath = [...currentPath, segment] + + if (isKeyedSegment(segment)) { + // Don't include the node itself - only ancestors + const isLast = + i === path.length - 1 || + // Check if this is the last keyed segment + !path.slice(i + 1).some(isKeyedSegment) + + if (!isLast) { + const resolved = resolveNode(snapshot, currentPath) + if (resolved) { + ancestors.push({node: resolved.node, path: [...currentPath]}) + } + } + } + } + + return ancestors +} + +/** + * Get the next block in document order. + * + * Containers are treated as opaque cursor stops - they are returned as-is, + * never auto-descended into. Use getFirstLeaf/getLastLeaf to explicitly + * enter a container. + * + * When called on a container, returns the next sibling (or walks up). + * When called on a leaf, returns the next sibling (or walks up). + * Walking up skips own ancestors and returns the first non-ancestor block found. + */ +export function getNextBlock( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + // Try next sibling and walk up if needed + return getNextBlockFromPosition(snapshot, path) +} + +/** + * Walk forward from a position: try next sibling, then walk up to parent's + * next sibling. Containers are returned as cursor stops, never descended into. + */ +function getNextBlockFromPosition( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + // Try the immediate next sibling + const nextBlock = resolved.siblings[resolved.indexInSiblings + 1] + + if (nextBlock) { + return {node: nextBlock, path: siblingPath(path, nextBlock._key)} + } + + // No more siblings - walk up to parent's next sibling + const parent = getParent(snapshot, path) + if (parent) { + return getNextBlockFromPosition(snapshot, parent.path) + } + + return undefined +} + +/** + * Get the previous block in document order. + * + * Containers are treated as opaque cursor stops - they are returned as-is, + * never auto-descended into. Use getFirstLeaf/getLastLeaf to explicitly + * enter a container. + * + * Walking up skips own ancestors and returns the first non-ancestor block found. + */ +export function getPrevBlock( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + // Try previous sibling and walk up if needed + return getPrevBlockFromPosition(snapshot, path) +} + +/** + * Walk backward from a position: try prev sibling, then walk up to parent's + * prev sibling. Containers are returned as cursor stops, never descended into. + */ +function getPrevBlockFromPosition( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const resolved = resolveNode(snapshot, path) + + if (!resolved) { + return undefined + } + + // Try the immediate previous sibling + if (resolved.indexInSiblings > 0) { + const prevBlock = resolved.siblings[resolved.indexInSiblings - 1] + if (prevBlock) { + return {node: prevBlock, path: siblingPath(path, prevBlock._key)} + } + } + + // No previous siblings - walk up to parent's previous sibling + const parent = getParent(snapshot, path) + if (parent) { + return getPrevBlockFromPosition(snapshot, parent.path) + } + + return undefined +} + +/** + * Is this block inside a container? + * True when the path has more than one keyed segment. + */ +export function isNested(_snapshot: EditorSnapshot, path: Path): boolean { + return path.filter(isKeyedSegment).length > 1 +} + +/** + * How deep is this block? (0 = top-level) + * Depth is the number of keyed segments minus 1. + */ +export function getDepth(_snapshot: EditorSnapshot, path: Path): number { + return path.filter(isKeyedSegment).length - 1 +} + +/** + * Is one block a descendant of another? + * Checks if ancestorPath is a prefix of path. + */ +export function isDescendantOf( + _snapshot: EditorSnapshot, + path: Path, + ancestorPath: Path, +): boolean { + if (path.length <= ancestorPath.length) { + return false + } + + for (let i = 0; i < ancestorPath.length; i++) { + const pathSegment = path[i] + const ancestorSegment = ancestorPath[i] + + if (pathSegment === undefined || ancestorSegment === undefined) { + return false + } + + if ( + typeof pathSegment === 'string' && + typeof ancestorSegment === 'string' + ) { + if (pathSegment !== ancestorSegment) { + return false + } + } else if (isKeyedSegment(pathSegment) && isKeyedSegment(ancestorSegment)) { + if (pathSegment._key !== ancestorSegment._key) { + return false + } + } else { + return false + } + } + + return true +} + +/** + * Get the nearest ancestor that's a container. + * Walks up the ancestors and returns the first that is not a leaf block. + */ +export function getContainingContainer( + snapshot: EditorSnapshot, + path: Path, +): TraversalResult | undefined { + const ancestors = getAncestors(snapshot, path) + + // Walk from innermost to outermost + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i] + if (ancestor && !isLeafBlock(snapshot, ancestor.node)) { + return ancestor + } + } + + return undefined +} diff --git a/packages/editor/src/slate-plugins/slate-plugin.update-value.ts b/packages/editor/src/slate-plugins/slate-plugin.update-value.ts index 45091f5e6..c3f27edde 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.update-value.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.update-value.ts @@ -31,6 +31,8 @@ export function updateValuePlugin( apply(operation) + editor.blockPathMap.applyOperation(operation) + buildIndexMaps( { schema: context.schema, diff --git a/packages/editor/src/types/slate-editor.ts b/packages/editor/src/types/slate-editor.ts index baa89428c..e63fcbb75 100644 --- a/packages/editor/src/types/slate-editor.ts +++ b/packages/editor/src/types/slate-editor.ts @@ -7,6 +7,7 @@ import type { } from '@portabletext/schema' import type {EditorSchema} from '../editor/editor-schema' import type {DecoratedRange} from '../editor/range-decorations-machine' +import type {InternalBlockPathMap} from '../internal-utils/block-path-map' import type {Range, Operation as SlateOperation} from '../slate' import type {ReactEditor} from '../slate-react' import type {EditorSelection} from './editor' @@ -43,6 +44,7 @@ export interface PortableTextSlateEditor extends ReactEditor { decoratedRanges: Array decoratorState: Record blockIndexMap: Map + blockPathMap: InternalBlockPathMap history: History lastSelection: EditorSelection lastSlateSelection: Range | null