diff --git a/.changeset/nested-blocks-apply-operation.md b/.changeset/nested-blocks-apply-operation.md new file mode 100644 index 000000000..3636597fa --- /dev/null +++ b/.changeset/nested-blocks-apply-operation.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: support nested blocks in editor value synchronization diff --git a/packages/editor/src/internal-utils/apply-operation-to-portable-text.nested-blocks.test.ts b/packages/editor/src/internal-utils/apply-operation-to-portable-text.nested-blocks.test.ts new file mode 100644 index 000000000..c0a1b4ba0 --- /dev/null +++ b/packages/editor/src/internal-utils/apply-operation-to-portable-text.nested-blocks.test.ts @@ -0,0 +1,1543 @@ +import {compileSchema, defineSchema} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import type {Node} from 'slate' +import {describe, expect, test} from 'vitest' +import {applyOperationToPortableText} from './apply-operation-to-portable-text' + +/** + * These tests exercise `applyOperationToPortableText` with nested block + * structures (depth > 2). Nested blocks (e.g., table cells containing PT + * content) produce paths at depth 3+. + * + * Coverage: + * - All 8 operation types with nested text blocks + spans (depth 2-3) + * - Nested inline objects inside text blocks inside containers + * - Nested block objects as direct children of containers + * - Depth 4+ (table > row > cell > block > span) to verify arbitrary depth + */ + +function createContext() { + const keyGenerator = createTestKeyGenerator() + const schema = compileSchema(defineSchema({})) + + return { + keyGenerator, + schema, + } +} + +/** + * The basic nesting model used in most tests: a container block at the root + * whose children are text blocks containing spans. This gives depth-3 paths + * (container > block > span). Deeper nesting (depth 4+) is tested separately + * with a table > row > cell > block > span structure. + */ + +describe(`${applyOperationToPortableText.name} — nested blocks`, () => { + describe('insert_text', () => { + test('inserting text into a span inside a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey = keyGenerator() + + // A container block with a nested text block inside it. + // Path to the span: [0, 0, 0] (container → nestedBlock → span) + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_text', + path: [0, 0, 0], + offset: 5, + text: ' World', + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: spanKey, + text: 'Hello World', + marks: [], + }, + ], + }, + ], + }, + ]) + }) + }) + + describe('remove_text', () => { + test('removing text from a span inside a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: spanKey, + text: 'Hello World', + marks: [], + }, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_text', + path: [0, 0, 0], + offset: 5, + text: ' World', + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + describe('insert_node', () => { + test('inserting a text block into a nested container (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const existingBlockKey = keyGenerator() + const existingSpanKey = keyGenerator() + const newBlockKey = keyGenerator() + const newSpanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: existingBlockKey, + children: [ + { + _type: 'span', + _key: existingSpanKey, + text: 'First', + marks: [], + }, + ], + }, + ], + }, + ] + + // Insert a new text block as the second child of the container + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_node', + path: [0, 1], + node: { + _type: 'block', + _key: newBlockKey, + children: [{_type: 'span', _key: newSpanKey, text: 'Second'}], + }, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: existingBlockKey, + children: [ + { + _type: 'span', + _key: existingSpanKey, + text: 'First', + marks: [], + }, + ], + }, + { + _type: 'block', + _key: newBlockKey, + children: [{_type: 'span', _key: newSpanKey, text: 'Second'}], + }, + ], + }, + ]) + }) + + test('inserting a span into a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const existingSpanKey = keyGenerator() + const newSpanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: existingSpanKey, + text: 'Hello', + marks: [], + }, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_node', + path: [0, 0, 1], + node: {_type: 'span', _key: newSpanKey, text: ' World'}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: existingSpanKey, + text: 'Hello', + marks: [], + }, + {_type: 'span', _key: newSpanKey, text: ' World'}, + ], + }, + ], + }, + ]) + }) + }) + + describe('remove_node', () => { + test('removing a text block from a nested container (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey1 = keyGenerator() + const spanKey1 = keyGenerator() + const blockKey2 = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey1, + children: [ + {_type: 'span', _key: spanKey1, text: 'First', marks: []}, + ], + }, + { + _type: 'block', + _key: blockKey2, + children: [ + {_type: 'span', _key: spanKey2, text: 'Second', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_node', + path: [0, 1], + node: { + _type: 'block', + _key: blockKey2, + children: [ + {_type: 'span', _key: spanKey2, text: 'Second', marks: []}, + ], + }, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey1, + children: [ + {_type: 'span', _key: spanKey1, text: 'First', marks: []}, + ], + }, + ], + }, + ]) + }) + + test('removing a span from a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey1 = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_node', + path: [0, 0, 1], + node: {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + describe('merge_node', () => { + test('merging two spans inside a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey1 = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'merge_node', + path: [0, 0, 1], + position: 5, + properties: {}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: spanKey1, + text: 'Hello World', + marks: [], + }, + ], + }, + ], + }, + ]) + }) + + test('merging two text blocks inside a nested container (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey1 = keyGenerator() + const spanKey1 = keyGenerator() + const blockKey2 = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey1, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + ], + }, + { + _type: 'block', + _key: blockKey2, + children: [ + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'merge_node', + path: [0, 1], + position: 1, + properties: {}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey1, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + describe('split_node', () => { + test('splitting a span inside a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey = keyGenerator() + const newSpanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: spanKey, + text: 'Hello World', + marks: [], + }, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'split_node', + path: [0, 0, 0], + position: 5, + properties: {_type: 'span', _key: newSpanKey}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + {_type: 'span', _key: newSpanKey, text: ' World'}, + ], + }, + ], + }, + ]) + }) + + test('splitting a nested text block (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey1 = keyGenerator() + const spanKey2 = keyGenerator() + const newBlockKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ] + + // Split the nested block after the first child + expect( + applyOperationToPortableText(createContext(), value, { + type: 'split_node', + path: [0, 0], + position: 1, + properties: {_key: newBlockKey}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Hello', marks: []}, + ], + }, + { + _type: 'block', + _key: newBlockKey, + children: [ + {_type: 'span', _key: spanKey2, text: ' World', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + describe('set_node', () => { + test('setting span marks inside a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'set_node', + path: [0, 0, 0], + properties: {marks: []}, + newProperties: {marks: ['strong']}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + { + _type: 'span', + _key: spanKey, + text: 'Hello', + marks: ['strong'], + }, + ], + }, + ], + }, + ]) + }) + + test('setting style on a nested text block (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'set_node', + path: [0, 0], + properties: {}, + newProperties: {style: 'h1'}, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + style: 'h1', + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + describe('move_node', () => { + test('moving a span within a nested text block (depth 3)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const nestedBlockKey = keyGenerator() + const spanKey1 = keyGenerator() + const spanKey2 = keyGenerator() + const spanKey3 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'First', marks: []}, + {_type: 'span', _key: spanKey2, text: 'Second', marks: []}, + {_type: 'span', _key: spanKey3, text: 'Third', marks: []}, + ], + }, + ], + }, + ] + + // Move first span to the end + expect( + applyOperationToPortableText(createContext(), value, { + type: 'move_node', + path: [0, 0, 0], + newPath: [0, 0, 2], + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: nestedBlockKey, + children: [ + {_type: 'span', _key: spanKey2, text: 'Second', marks: []}, + {_type: 'span', _key: spanKey3, text: 'Third', marks: []}, + {_type: 'span', _key: spanKey1, text: 'First', marks: []}, + ], + }, + ], + }, + ]) + }) + + test('moving a text block between nested containers (depth 2)', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey1 = keyGenerator() + const containerKey2 = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const emptyBlockKey = keyGenerator() + const emptySpanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey1, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + { + _type: 'tableCell', + _key: containerKey2, + children: [ + { + _type: 'block', + _key: emptyBlockKey, + children: [ + {_type: 'span', _key: emptySpanKey, text: '', marks: []}, + ], + }, + ], + }, + ] + + // Move the text block from first container to second container + expect( + applyOperationToPortableText(createContext(), value, { + type: 'move_node', + path: [0, 0], + newPath: [1, 1], + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey1, + children: [], + }, + { + _type: 'tableCell', + _key: containerKey2, + children: [ + { + _type: 'block', + _key: emptyBlockKey, + children: [ + {_type: 'span', _key: emptySpanKey, text: '', marks: []}, + ], + }, + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + // --------------------------------------------------------------- + // Nested inline objects + // --------------------------------------------------------------- + // An inline object (e.g., inline image) sitting alongside spans + // inside a text block that is itself nested in a container. + // Structure: container → text block → [span, inlineObject, span] + // Paths: inline object at [0, 0, 1], spans at [0, 0, 0] and [0, 0, 2] + + describe('nested inline objects', () => { + test('insert_node — inserting an inline object into a nested text block', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const inlineKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + // Slate marks inline objects with __inline and wraps their + // fields in a `value` property. The insert_node handler + // unwraps `value` back into the PT node. + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_node', + path: [0, 0, 1], + node: { + _type: 'inlineImage', + _key: inlineKey, + __inline: true, + children: [{text: ''}], + value: {src: 'cat.png'}, + } as unknown as Node, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + {_type: 'inlineImage', _key: inlineKey, src: 'cat.png'}, + ], + }, + ], + }, + ]) + }) + + test('remove_node — removing an inline object from a nested text block', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey1 = keyGenerator() + const inlineKey = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Before', marks: []}, + {_type: 'inlineImage', _key: inlineKey, src: 'cat.png'}, + {_type: 'span', _key: spanKey2, text: 'After', marks: []}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_node', + path: [0, 0, 1], + node: { + _type: 'inlineImage', + _key: inlineKey, + src: 'cat.png', + } as unknown as Node, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Before', marks: []}, + {_type: 'span', _key: spanKey2, text: 'After', marks: []}, + ], + }, + ], + }, + ]) + }) + + test('set_node — updating properties on a nested inline object', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const inlineKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + {_type: 'inlineImage', _key: inlineKey, src: 'cat.png'}, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'set_node', + path: [0, 0, 1], + properties: {src: 'cat.png'} as Partial, + newProperties: {src: 'dog.png'} as Partial, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + {_type: 'inlineImage', _key: inlineKey, src: 'dog.png'}, + ], + }, + ], + }, + ]) + }) + + test('move_node — moving an inline object within a nested text block', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey1 = keyGenerator() + const inlineKey = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey1, text: 'Before', marks: []}, + {_type: 'inlineImage', _key: inlineKey, src: 'cat.png'}, + {_type: 'span', _key: spanKey2, text: 'After', marks: []}, + ], + }, + ], + }, + ] + + // Move inline object from index 1 to index 0 (before the first span) + expect( + applyOperationToPortableText(createContext(), value, { + type: 'move_node', + path: [0, 0, 1], + newPath: [0, 0, 0], + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'inlineImage', _key: inlineKey, src: 'cat.png'}, + {_type: 'span', _key: spanKey1, text: 'Before', marks: []}, + {_type: 'span', _key: spanKey2, text: 'After', marks: []}, + ], + }, + ], + }, + ]) + }) + }) + + // --------------------------------------------------------------- + // Nested block objects + // --------------------------------------------------------------- + // A block object (e.g., image block) as a direct child of a container, + // sitting alongside text blocks. No children array on the block object. + // Structure: container → [text block, block object] + // Paths: block object at [0, 1] + + describe('nested block objects', () => { + test('insert_node — inserting a block object into a container', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const imageKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + // Slate represents block objects as Elements (with children). + // The insert_node handler strips children and unwraps `value` + // back into the PT node. + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_node', + path: [0, 1], + node: { + _type: 'image', + _key: imageKey, + children: [{text: ''}], + value: {src: 'photo.jpg'}, + } as unknown as Node, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + {_type: 'image', _key: imageKey, src: 'photo.jpg'}, + ], + }, + ]) + }) + + test('remove_node — removing a block object from a container', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const imageKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + {_type: 'image', _key: imageKey, src: 'photo.jpg'}, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_node', + path: [0, 1], + node: { + _type: 'image', + _key: imageKey, + src: 'photo.jpg', + } as unknown as Node, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ]) + }) + + test('set_node — updating properties on a nested block object', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey = keyGenerator() + const imageKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey, + children: [ + {_type: 'image', _key: imageKey, src: 'photo.jpg', alt: ''}, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'set_node', + path: [0, 0], + properties: {alt: ''} as Partial, + newProperties: {alt: 'A photo'} as Partial, + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey, + children: [ + { + _type: 'image', + _key: imageKey, + src: 'photo.jpg', + alt: 'A photo', + }, + ], + }, + ]) + }) + + test('move_node — moving a block object between containers', () => { + const keyGenerator = createTestKeyGenerator() + const containerKey1 = keyGenerator() + const containerKey2 = keyGenerator() + const imageKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + + const value = [ + { + _type: 'tableCell', + _key: containerKey1, + children: [{_type: 'image', _key: imageKey, src: 'photo.jpg'}], + }, + { + _type: 'tableCell', + _key: containerKey2, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + ], + }, + ] + + // Move image from first container to second container + expect( + applyOperationToPortableText(createContext(), value, { + type: 'move_node', + path: [0, 0], + newPath: [1, 1], + }), + ).toEqual([ + { + _type: 'tableCell', + _key: containerKey1, + children: [], + }, + { + _type: 'tableCell', + _key: containerKey2, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + {_type: 'span', _key: spanKey, text: 'Hello', marks: []}, + ], + }, + {_type: 'image', _key: imageKey, src: 'photo.jpg'}, + ], + }, + ]) + }) + }) + + // --------------------------------------------------------------- + // Depth 4+ (table → row → cell → block → span) + // --------------------------------------------------------------- + // Proves the loop-based helpers work beyond depth 3. + // Structure: table → tableRow → tableCell → block → span + // Span path: [0, 0, 0, 0, 0] + + describe('depth 4+ (table → row → cell → block → span)', () => { + function createDeepValue(keyGenerator: () => string) { + const tableKey = keyGenerator() + const rowKey = keyGenerator() + const cellKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + + return { + value: [ + { + _type: 'table', + _key: tableKey, + children: [ + { + _type: 'tableRow', + _key: rowKey, + children: [ + { + _type: 'tableCell', + _key: cellKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + { + _type: 'span', + _key: spanKey, + text: 'Deep text', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + keys: {tableKey, rowKey, cellKey, blockKey, spanKey}, + } + } + + test('insert_text at depth 5 (span inside table → row → cell → block)', () => { + const keyGenerator = createTestKeyGenerator() + const {value, keys} = createDeepValue(keyGenerator) + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'insert_text', + path: [0, 0, 0, 0, 0], + offset: 9, + text: ' here', + }), + ).toEqual([ + { + _type: 'table', + _key: keys.tableKey, + children: [ + { + _type: 'tableRow', + _key: keys.rowKey, + children: [ + { + _type: 'tableCell', + _key: keys.cellKey, + children: [ + { + _type: 'block', + _key: keys.blockKey, + children: [ + { + _type: 'span', + _key: keys.spanKey, + text: 'Deep text here', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + test('remove_text at depth 5', () => { + const keyGenerator = createTestKeyGenerator() + const {value, keys} = createDeepValue(keyGenerator) + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'remove_text', + path: [0, 0, 0, 0, 0], + offset: 4, + text: ' text', + }), + ).toEqual([ + { + _type: 'table', + _key: keys.tableKey, + children: [ + { + _type: 'tableRow', + _key: keys.rowKey, + children: [ + { + _type: 'tableCell', + _key: keys.cellKey, + children: [ + { + _type: 'block', + _key: keys.blockKey, + children: [ + { + _type: 'span', + _key: keys.spanKey, + text: 'Deep', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + test('split_node — splitting a span at depth 5', () => { + const keyGenerator = createTestKeyGenerator() + const {value, keys} = createDeepValue(keyGenerator) + const newSpanKey = keyGenerator() + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'split_node', + path: [0, 0, 0, 0, 0], + position: 4, + properties: {_type: 'span', _key: newSpanKey}, + }), + ).toEqual([ + { + _type: 'table', + _key: keys.tableKey, + children: [ + { + _type: 'tableRow', + _key: keys.rowKey, + children: [ + { + _type: 'tableCell', + _key: keys.cellKey, + children: [ + { + _type: 'block', + _key: keys.blockKey, + children: [ + { + _type: 'span', + _key: keys.spanKey, + text: 'Deep', + marks: [], + }, + {_type: 'span', _key: newSpanKey, text: ' text'}, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + test('merge_node — merging two spans at depth 5', () => { + const keyGenerator = createTestKeyGenerator() + const tableKey = keyGenerator() + const rowKey = keyGenerator() + const cellKey = keyGenerator() + const blockKey = keyGenerator() + const spanKey1 = keyGenerator() + const spanKey2 = keyGenerator() + + const value = [ + { + _type: 'table', + _key: tableKey, + children: [ + { + _type: 'tableRow', + _key: rowKey, + children: [ + { + _type: 'tableCell', + _key: cellKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + { + _type: 'span', + _key: spanKey1, + text: 'Deep', + marks: [], + }, + { + _type: 'span', + _key: spanKey2, + text: ' text', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ] + + expect( + applyOperationToPortableText(createContext(), value, { + type: 'merge_node', + path: [0, 0, 0, 0, 1], + position: 4, + properties: {}, + }), + ).toEqual([ + { + _type: 'table', + _key: tableKey, + children: [ + { + _type: 'tableRow', + _key: rowKey, + children: [ + { + _type: 'tableCell', + _key: cellKey, + children: [ + { + _type: 'block', + _key: blockKey, + children: [ + { + _type: 'span', + _key: spanKey1, + text: 'Deep text', + marks: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + }) + }) +}) diff --git a/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts b/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts index b76abafa8..f59b6cfc8 100644 --- a/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts +++ b/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts @@ -4,7 +4,6 @@ import type {EditorSchema} from '../editor/editor-schema' import type {EditorContext} from '../editor/editor-snapshot' import type {OmitFromUnion} from '../type-utils' import { - getBlock, getNode, getParent, getSpan, @@ -16,7 +15,6 @@ import { type EditorNode, type ObjectNode, type SpanNode, - type TextBlockNode, } from './portable-text-node' export function applyOperationToPortableText( @@ -54,74 +52,21 @@ function applyOperationToPortableTextImmutable( return root } - if (index > parent.children.length) { + if ( + 'children' in parent && + Array.isArray(parent.children) && + index > parent.children.length + ) { return root } - if (path.length === 1) { - // Inserting block at the root - - if (isTextBlockNode(context, insertedNode)) { - // Text blocks can be inserted as is - const newBlock = { - ...insertedNode, - children: insertedNode.children.map((child) => { - if ('__inline' in child) { - // Except for inline object children which need to have their - // `value` spread onto the block - return { - _key: child._key, - _type: child._type, - ...('value' in child && typeof child['value'] === 'object' - ? child['value'] - : {}), - } - } - - return child - }), - } - - return { - ...root, - children: insertChildren(root.children, index, newBlock), - } - } - - if (Element.isElement(insertedNode) && !('__inline' in insertedNode)) { - // Void blocks have to have their `value` spread onto the block - const newBlock = { - _key: insertedNode._key, - _type: insertedNode._type, - ...('value' in insertedNode && - typeof insertedNode.value === 'object' - ? insertedNode.value - : {}), - } - - return { - ...root, - children: insertChildren(root.children, index, newBlock), - } - } - } - - if (path.length === 2) { - // Inserting children into blocks - const blockIndex = path[0]! - - if (!isTextBlockNode(context, parent)) { - // Only text blocks can have children - return root - } - + if (isTextBlockNode(context, parent)) { + // Inserting a child into a text block (span or inline object) let newChild: SpanNode | ObjectNode | undefined if (isPartialSpanNode(context, insertedNode)) { - // Text nodes can be inserted as is newChild = insertedNode } else if ('__inline' in insertedNode) { - // Void children have to have their `value` spread onto the block newChild = { _key: insertedNode._key, _type: insertedNode._type, @@ -134,10 +79,50 @@ function applyOperationToPortableTextImmutable( return root } - return updateTextBlockAtIndex(context, root, blockIndex, (block) => ({ - ...block, - children: insertChildren(block.children, index, newChild), - })) + const parentPath = path.slice(0, -1) + return updateChildrenAtPath(root, parentPath, (children) => + insertChildren(children, index, newChild), + ) + } + + // Inserting a block into root or a container + if (isTextBlockNode(context, insertedNode)) { + const newBlock = { + ...insertedNode, + children: insertedNode.children.map((child) => { + if ('__inline' in child) { + return { + _key: child._key, + _type: child._type, + ...('value' in child && typeof child['value'] === 'object' + ? child['value'] + : {}), + } + } + + return child + }), + } + + const parentPath = path.slice(0, -1) + return updateChildrenAtPath(root, parentPath, (children) => + insertChildren(children, index, newBlock), + ) + } + + if (Element.isElement(insertedNode) && !('__inline' in insertedNode)) { + const newBlock = { + _key: insertedNode._key, + _type: insertedNode._type, + ...('value' in insertedNode && typeof insertedNode.value === 'object' + ? insertedNode.value + : {}), + } + + const parentPath = path.slice(0, -1) + return updateChildrenAtPath(root, parentPath, (children) => + insertChildren(children, index, newBlock), + ) } return root @@ -154,16 +139,15 @@ function applyOperationToPortableTextImmutable( return root } - const blockIndex = path[0]! - const childIndex = path[1]! + const parentPath = path.slice(0, -1) + const childIndex = path[path.length - 1]! const before = span.text.slice(0, offset) const after = span.text.slice(offset) const newSpan = {...span, text: before + text + after} - return updateTextBlockAtIndex(context, root, blockIndex, (block) => ({ - ...block, - children: replaceChild(block.children, childIndex, newSpan), - })) + return updateChildrenAtPath(root, parentPath, (children) => + replaceChild(children, childIndex, newSpan), + ) } case 'merge_node': { @@ -185,25 +169,18 @@ function applyOperationToPortableTextImmutable( } const index = path[path.length - 1]! + const parentPath = path.slice(0, -1) if ( isPartialSpanNode(context, node) && isPartialSpanNode(context, prev) ) { // Merging spans - const blockIndex = path[0]! const newPrev = {...prev, text: prev.text + node.text} - return updateTextBlockAtIndex(context, root, blockIndex, (block) => { - const newChildren = replaceChild( - block.children, - index - 1, - newPrev as never, - ) - return { - ...block, - children: removeChildren(newChildren, index), - } + return updateChildrenAtPath(root, parentPath, (children) => { + const newChildren = replaceChild(children, index - 1, newPrev) + return removeChildren(newChildren, index) }) } @@ -213,11 +190,11 @@ function applyOperationToPortableTextImmutable( ...prev, children: [...prev.children, ...node.children], } - const newChildren = replaceChild(root.children, index - 1, newPrev) - return { - ...root, - children: removeChildren(newChildren, index), - } + + return updateChildrenAtPath(root, parentPath, (children) => { + const newChildren = replaceChild(children, index - 1, newPrev) + return removeChildren(newChildren, index) + }) } return root @@ -239,29 +216,10 @@ function applyOperationToPortableTextImmutable( } // First, remove the node from its current position - let newRoot: EditorNode - - if (path.length === 1) { - // Removing block from root - newRoot = { - ...root, - children: removeChildren(root.children, index), - } - } else if (path.length === 2) { - // Removing child from block - const blockIndex = path[0]! - newRoot = updateTextBlockAtIndex( - context, - root, - blockIndex, - (block) => ({ - ...block, - children: removeChildren(block.children, index), - }), - ) - } else { - return root - } + const removeParentPath = path.slice(0, -1) + const newRoot = updateChildrenAtPath(root, removeParentPath, (children) => + removeChildren(children, index), + ) // This is tricky, but since the `path` and `newPath` both refer to // the same snapshot in time, there's a mismatch. After either @@ -271,36 +229,11 @@ function applyOperationToPortableTextImmutable( // the operation was applied. const truePath = Path.transform(path, operation)! const newIndex = truePath[truePath.length - 1]! + const insertParentPath = truePath.slice(0, -1) - if (truePath.length === 1) { - // Inserting block at root - return { - ...newRoot, - children: insertChildren(newRoot.children, newIndex, node as never), - } - } - - if (truePath.length === 2) { - // Inserting child into block - const newBlockIndex = truePath[0]! - const newParent = newRoot.children[newBlockIndex] - - if (!newParent || !isTextBlockNode(context, newParent)) { - return root - } - - return updateTextBlockAtIndex( - context, - newRoot, - newBlockIndex, - (block) => ({ - ...block, - children: insertChildren(block.children, newIndex, node as never), - }), - ) - } - - return root + return updateChildrenAtPath(newRoot, insertParentPath, (children) => + insertChildren(children, newIndex, node as never), + ) } case 'remove_node': { @@ -312,24 +245,10 @@ function applyOperationToPortableTextImmutable( return root } - if (path.length === 1) { - // Removing block from root - return { - ...root, - children: removeChildren(root.children, index), - } - } - - if (path.length === 2) { - // Removing child from block - const blockIndex = path[0]! - return updateTextBlockAtIndex(context, root, blockIndex, (block) => ({ - ...block, - children: removeChildren(block.children, index), - })) - } - - return root + const parentPath = path.slice(0, -1) + return updateChildrenAtPath(root, parentPath, (children) => + removeChildren(children, index), + ) } case 'remove_text': { @@ -345,16 +264,15 @@ function applyOperationToPortableTextImmutable( return root } - const blockIndex = path[0]! - const childIndex = path[1]! + const parentPath = path.slice(0, -1) + const childIndex = path[path.length - 1]! const before = span.text.slice(0, offset) const after = span.text.slice(offset + text.length) const newSpan = {...span, text: before + after} - return updateTextBlockAtIndex(context, root, blockIndex, (block) => ({ - ...block, - children: replaceChild(block.children, childIndex, newSpan as never), - })) + return updateChildrenAtPath(root, parentPath, (children) => + replaceChild(children, childIndex, newSpan), + ) } case 'set_node': { @@ -370,6 +288,9 @@ function applyOperationToPortableTextImmutable( return root } + const parentPath = path.slice(0, -1) + const index = path[path.length - 1]! + if (isObjectNode(context, node)) { const valueBefore = ( 'value' in properties && typeof properties.value === 'object' @@ -424,21 +345,9 @@ function applyOperationToPortableTextImmutable( } } - if (path.length === 1) { - return { - ...root, - children: replaceChild(root.children, path[0]!, newNode), - } - } - - if (path.length === 2) { - return updateTextBlockAtIndex(context, root, path[0]!, (block) => ({ - ...block, - children: replaceChild(block.children, path[1]!, newNode), - })) - } - - return root + return updateChildrenAtPath(root, parentPath, (children) => + replaceChild(children, index, newNode), + ) } if (isTextBlockNode(context, node)) { @@ -465,10 +374,9 @@ function applyOperationToPortableTextImmutable( } } - return { - ...root, - children: replaceChild(root.children, path[0]!, newNode), - } + return updateChildrenAtPath(root, parentPath, (children) => + replaceChild(children, index, newNode), + ) } if (isPartialSpanNode(context, node)) { @@ -495,10 +403,9 @@ function applyOperationToPortableTextImmutable( } } - return updateTextBlockAtIndex(context, root, path[0]!, (block) => ({ - ...block, - children: replaceChild(block.children, path[1]!, newNode), - })) + return updateChildrenAtPath(root, parentPath, (children) => + replaceChild(children, index, newNode), + ) } return root @@ -512,22 +419,19 @@ function applyOperationToPortableTextImmutable( } const parent = getParent(context, root, path) + const node = getNode(context, root, path) const index = path[path.length - 1]! + const parentPath = path.slice(0, -1) - if (!parent) { + if (!parent || !node) { return root } - if (isEditorNode(parent)) { - const block = getBlock(root, path) - - if (!block || !isTextBlockNode(context, block)) { - return root - } - - const before = block.children.slice(0, position) - const after = block.children.slice(position) - const updatedTextBlockNode = {...block, children: before} + if (isTextBlockNode(context, node)) { + // Splitting a text block: divide its children + const before = node.children.slice(0, position) + const after = node.children.slice(position) + const updatedTextBlockNode = {...node, children: before} // _key is deliberately left out const newTextBlockNode = { @@ -536,24 +440,17 @@ function applyOperationToPortableTextImmutable( _type: context.schema.block.name, } - return { - ...root, - children: insertChildren( - replaceChild(root.children, index, updatedTextBlockNode), + return updateChildrenAtPath(root, parentPath, (children) => + insertChildren( + replaceChild(children, index, updatedTextBlockNode), index + 1, newTextBlockNode, ), - } + ) } - if (isTextBlockNode(context, parent)) { - const node = getNode(context, root, path) - - if (!node || !isSpanNode(context, node)) { - return root - } - - const blockIndex = path[0]! + if (isSpanNode(context, node)) { + // Splitting a span: divide its text const before = node.text.slice(0, position) const after = node.text.slice(position) const updatedSpanNode = {...node, text: before} @@ -564,16 +461,13 @@ function applyOperationToPortableTextImmutable( text: after, } - return updateTextBlockAtIndex(context, root, blockIndex, (block) => { - return { - ...block, - children: insertChildren( - replaceChild(block.children, index, updatedSpanNode), - index + 1, - newSpanNode, - ), - } - }) + return updateChildrenAtPath(root, parentPath, (children) => + insertChildren( + replaceChild(children, index, updatedSpanNode), + index + 1, + newSpanNode, + ), + ) } return root @@ -593,26 +487,54 @@ function replaceChild(children: T[], index: number, newChild: T): T[] { return [...children.slice(0, index), newChild, ...children.slice(index + 1)] } -function updateTextBlockAtIndex( - context: Pick, +/** + * Immutably update a node's children at the given parent path. + * Rebuilds the tree from root down to the target parent. + * + * `parentPath` is the path to the node whose children should be updated. + * `updater` receives the current children array and returns the new one. + */ +function updateChildrenAtPath( root: EditorNode, - blockIndex: number, - updater: (block: TextBlockNode) => TextBlockNode, + parentPath: Path, + updater: (children: Array) => Array, ): EditorNode { - const block = root.children.at(blockIndex) - - if (!block) { - return root + if (parentPath.length === 0) { + return { + ...root, + children: updater(root.children) as EditorNode['children'], + } } - if (!isTextBlockNode(context, block)) { - return root - } + // Rebuild the tree immutably from root to the target parent + // biome-ignore lint/suspicious/noExplicitAny: walking a heterogeneous tree + function rebuild(node: any, depth: number): any { + const index = parentPath[depth]! + const child = node.children?.[index] - const newBlock = updater(block) + if (!child) { + return node + } - return { - ...root, - children: replaceChild(root.children, blockIndex, newBlock), + if (depth === parentPath.length - 1) { + // This child is the target parent, update its children + const newChild = { + ...child, + children: updater(child.children), + } + return { + ...node, + children: replaceChild(node.children, index, newChild), + } + } + + // Recurse deeper + const newChild = rebuild(child, depth + 1) + return { + ...node, + children: replaceChild(node.children, index, newChild), + } } + + return rebuild(root, 0) } diff --git a/packages/editor/src/internal-utils/portable-text-node.ts b/packages/editor/src/internal-utils/portable-text-node.ts index 5d30728d0..fb1d30be5 100644 --- a/packages/editor/src/internal-utils/portable-text-node.ts +++ b/packages/editor/src/internal-utils/portable-text-node.ts @@ -135,14 +135,11 @@ export function getBlock( } /** - * A "node" can either be - * 1. The root (path length is 0) - * 2. A block (path length is 1) - * 3. A span (path length is 2) - * 4. Or an inline object (path length is 2) + * Walk the tree to find the node at the given path. + * Supports arbitrary depth for nested blocks (containers). */ export function getNode( - context: {schema: TEditorSchema}, + _context: {schema: TEditorSchema}, root: EditorNode, path: Path, ): PortableTextNode | undefined { @@ -150,27 +147,22 @@ export function getNode( return root } - if (path.length === 1) { - return getBlock(root, path) - } - - if (path.length === 2) { - const block = getBlock(root, path.slice(0, 1)) - - if (!block || !isTextBlockNode(context, block)) { - return undefined - } - - const child = block.children.at(path[1]!) + // biome-ignore lint/suspicious/noExplicitAny: walking a heterogeneous tree + let current: any = root - if (!child) { + for (const index of path) { + if ( + !current || + !('children' in current) || + !Array.isArray(current.children) + ) { return undefined } - return child + current = current.children[index] } - return undefined + return current as PortableTextNode | undefined } export function getSpan( @@ -188,7 +180,8 @@ export function getSpan( } /** - * A parent can either be the root or a text block + * Get the parent node for the given path. + * Supports arbitrary depth for nested blocks (containers). */ export function getParent( context: {schema: TEditorSchema}, @@ -200,22 +193,5 @@ export function getParent( } const parentPath = path.slice(0, -1) - - if (parentPath.length === 0) { - return root - } - - const blockIndex = parentPath.at(0) - - if (blockIndex === undefined || parentPath.length !== 1) { - return undefined - } - - const block = root.children.at(blockIndex) - - if (block && isTextBlockNode(context, block)) { - return block - } - - return undefined + return getNode(context, root, parentPath) }