diff --git a/.changeset/resolve-key-paths.md b/.changeset/resolve-key-paths.md new file mode 100644 index 000000000..41e243ef7 --- /dev/null +++ b/.changeset/resolve-key-paths.md @@ -0,0 +1,5 @@ +--- +'@portabletext/editor': patch +--- + +fix: centralize key-path resolution in `operation-to-patches` diff --git a/packages/editor/src/internal-utils/operation-to-patches.ts b/packages/editor/src/internal-utils/operation-to-patches.ts index 7ff4475b1..139f3d3a6 100644 --- a/packages/editor/src/internal-utils/operation-to-patches.ts +++ b/packages/editor/src/internal-utils/operation-to-patches.ts @@ -10,12 +10,11 @@ import { isSpan, isTextBlock, type PortableTextBlock, - type PortableTextSpan, + type PortableTextTextBlock, } from '@portabletext/schema' import type {EditorSchema} from '../editor/editor-schema' import { Element, - Text, type Descendant, type InsertNodeOperation, type InsertTextOperation, @@ -24,6 +23,15 @@ import { type SetNodeOperation, } from '../slate' import type {Path} from '../types/paths' +import {type KeyPath, resolveKeyPath} from './resolve-key-path' + +function withTextField(keyPath: KeyPath): Path { + return [...keyPath, 'text'] +} + +function withProperty(keyPath: KeyPath, property: string): Path { + return [...keyPath, property] +} export function insertTextPatch( schema: EditorSchema, @@ -31,30 +39,30 @@ export function insertTextPatch( operation: InsertTextOperation, beforeValue: Array, ): Array { - const block = - isTextBlock({schema}, children[operation.path[0]!]) && - children[operation.path[0]!] - if (!block) { + const keyPath = resolveKeyPath( + schema, + children as Array, + operation.path, + ) + if (!keyPath || keyPath.length < 3) { return [] } - const textChild = - isTextBlock({schema}, block) && - isSpan({schema}, block.children[operation.path[1]!]) && - (block.children[operation.path[1]!] as PortableTextSpan) - if (!textChild) { + + const block = children[operation.path[0]!] + if (!block || !isTextBlock({schema}, block)) { + return [] + } + const child = block.children[operation.path[1]!] + if (!child || !isSpan({schema}, child)) { throw new Error('Could not find child') } - const path: Path = [ - {_key: block._key}, - 'children', - {_key: textChild._key}, - 'text', - ] + const prevBlock = beforeValue[operation.path[0]!] const prevChild = isTextBlock({schema}, prevBlock) && prevBlock.children[operation.path[1]!] const prevText = isSpan({schema}, prevChild) ? prevChild.text : '' - const patch = diffMatchPatch(prevText, textChild.text, path) + + const patch = diffMatchPatch(prevText, child.text, withTextField(keyPath)) return patch.value.length ? [patch] : [] } @@ -64,32 +72,34 @@ export function removeTextPatch( operation: RemoveTextOperation, beforeValue: Array, ): Array { + const keyPath = resolveKeyPath( + schema, + children as Array, + operation.path, + ) + if (!keyPath || keyPath.length < 3) { + return [] + } + const block = children[operation.path[0]!] if (!block || !isTextBlock({schema}, block)) { return [] } - const child = block.children[operation.path[1]!] || undefined - const textChild: PortableTextSpan | undefined = isSpan({schema}, child) - ? child - : undefined - if (child && !textChild) { - throw new Error('Expected span') - } - if (!textChild) { + const child = block.children[operation.path[1]!] + if (!child) { throw new Error('Could not find child') } - const path: Path = [ - {_key: block._key}, - 'children', - {_key: textChild._key}, - 'text', - ] + if (!isSpan({schema}, child)) { + throw new Error('Expected span') + } + const beforeBlock = beforeValue[operation.path[0]!] - const prevTextChild = + const prevChild = isTextBlock({schema}, beforeBlock) && beforeBlock.children[operation.path[1]!] - const prevText = isSpan({schema}, prevTextChild) && prevTextChild.text - const patch = diffMatchPatch(prevText || '', textChild.text, path) + const prevText = isSpan({schema}, prevChild) ? prevChild.text : '' + + const patch = diffMatchPatch(prevText, child.text, withTextField(keyPath)) return patch.value ? [patch] : [] } @@ -98,158 +108,109 @@ export function setNodePatch( children: Descendant[], operation: SetNodeOperation, ): Array { - const blockIndex = operation.path.at(0) + if (operation.path.length === 1) { + return setBlockNodePatch(schema, children, operation) + } - if (blockIndex !== undefined && operation.path.length === 1) { - const block = children.at(blockIndex) + if (operation.path.length === 2) { + return setChildNodePatch(schema, children, operation) + } - if (!block) { - console.error('Could not find block at index', blockIndex) - return [] - } + throw new Error( + `Unexpected path encountered: ${JSON.stringify(operation.path)}`, + ) +} - if (isTextBlock({schema}, block)) { - const patches: Patch[] = [] - - for (const [key, propertyValue] of Object.entries( - operation.newProperties, - )) { - if (key === '_key') { - patches.push(set(propertyValue, [blockIndex, '_key'])) - } else { - patches.push(set(propertyValue, [{_key: block._key}, key])) - } - } +function setBlockNodePatch( + schema: EditorSchema, + children: Descendant[], + operation: SetNodeOperation, +): Array { + const blockIndex = operation.path[0]! + const keyPath = resolveKeyPath( + schema, + children as Array, + operation.path, + ) + + if (!keyPath) { + console.error('Could not find block at index', blockIndex) + return [] + } - for (const key of Object.keys(operation.properties)) { - if (!(key in operation.newProperties)) { - patches.push(unset([{_key: block._key}, key])) - } - } + const block = children[blockIndex]! + const skipChildren = !isTextBlock({schema}, block) + const patches: Patch[] = [] - return patches - } else { - const patches: Patch[] = [] + for (const [key, value] of Object.entries(operation.newProperties)) { + if (key === '_key') { + patches.push(set(value, [blockIndex, '_key'])) + } else if (!skipChildren || key !== 'children') { + patches.push(set(value, withProperty(keyPath, key))) + } + } - const _key = operation.newProperties._key + for (const key of Object.keys(operation.properties)) { + if (key === '_key' || (skipChildren && key === 'children')) { + continue + } + if (!(key in operation.newProperties)) { + patches.push(unset(withProperty(keyPath, key))) + } + } - if (_key !== undefined) { - patches.push(set(_key, [blockIndex, '_key'])) - } + return patches +} - for (const [key, propertyValue] of Object.entries( - operation.newProperties, - )) { - if (key === '_key' || key === 'children') { - continue - } +function setChildNodePatch( + schema: EditorSchema, + children: Descendant[], + operation: SetNodeOperation, +): Array { + const keyPath = resolveKeyPath( + schema, + children as Array, + operation.path, + ) + + if (!keyPath) { + throw new Error('Could not find a valid block or child') + } - patches.push(set(propertyValue, [{_key: block._key}, key])) - } + const block = children[operation.path[0]!] + if (!isTextBlock({schema}, block)) { + throw new Error('Could not find a valid block') + } - for (const key of Object.keys(operation.properties)) { - if (key === '_key' || key === 'children') { - continue - } + const child = block.children[operation.path[1]!] + if (!child) { + throw new Error('Could not find a valid child') + } - if (!(key in operation.newProperties)) { - patches.push(unset([{_key: block._key}, key])) - } - } + const childIndex = block.children.indexOf(child) + const blockKey = block._key + const isElement = Element.isElement(child, schema) + const patches: Patch[] = [] - return patches + for (const [key, value] of Object.entries(operation.newProperties)) { + if (key === '_key') { + patches.push( + set(value, [{_key: blockKey}, 'children', childIndex, '_key']), + ) + } else if (!isElement || key !== 'children') { + patches.push(set(value, withProperty(keyPath, key))) } - } else if (operation.path.length === 2) { - const block = children[operation.path[0]!] - if (isTextBlock({schema}, block)) { - const child = block.children[operation.path[1]!] - if (child) { - const blockKey = block._key - const childKey = child._key - const patches: Patch[] = [] - - if (Element.isElement(child, schema)) { - const _key = operation.newProperties._key - - if (_key !== undefined) { - patches.push( - set(_key, [ - {_key: blockKey}, - 'children', - block.children.indexOf(child), - '_key', - ]), - ) - } - - for (const [key, propertyValue] of Object.entries( - operation.newProperties, - )) { - if (key === '_key' || key === 'children') { - continue - } - - patches.push( - set(propertyValue, [ - {_key: blockKey}, - 'children', - {_key: childKey}, - key, - ]), - ) - } - - return patches - } - - for (const [keyName, propertyValue] of Object.entries( - operation.newProperties, - )) { - if (keyName === '_key') { - patches.push( - set(propertyValue, [ - {_key: blockKey}, - 'children', - block.children.indexOf(child), - keyName, - ]), - ) - - continue - } - - patches.push( - set(propertyValue, [ - {_key: blockKey}, - 'children', - {_key: childKey}, - keyName, - ]), - ) - } - - const propNames = Object.keys(operation.properties) - - for (const keyName of propNames) { - if (keyName in operation.newProperties) { - continue - } - - patches.push( - unset([{_key: blockKey}, 'children', {_key: childKey}, keyName]), - ) - } - - return patches + } + + if (!isElement) { + for (const key of Object.keys(operation.properties)) { + if (!(key in operation.newProperties)) { + patches.push(unset(withProperty(keyPath, key))) } - throw new Error('Could not find a valid child') } - throw new Error('Could not find a valid block') - } else { - throw new Error( - `Unexpected path encountered: ${JSON.stringify(operation.path)}`, - ) } + + return patches } export function insertNodePatch( @@ -258,89 +219,107 @@ export function insertNodePatch( operation: InsertNodeOperation, beforeValue: Array, ): Array { - const block = beforeValue[operation.path[0]!] if (operation.path.length === 1) { - const position = operation.path[0] === 0 ? 'before' : 'after' - const beforeBlock = beforeValue[operation.path[0]! - 1] - const targetKey = operation.path[0] === 0 ? block?._key : beforeBlock?._key - if (targetKey) { - return [ - insert([operation.node as PortableTextBlock], position, [ - {_key: targetKey}, - ]), - ] - } - return [ - setIfMissing(beforeValue, []), - insert([operation.node as PortableTextBlock], 'before', [ - operation.path[0]!, - ]), - ] - } else if ( + return insertBlockNodePatch(schema, operation, beforeValue) + } + + const block = beforeValue[operation.path[0]!] + + if ( isTextBlock({schema}, block) && operation.path.length === 2 && children[operation.path[0]!] ) { - const position = - block.children.length === 0 || !block.children[operation.path[1]! - 1] - ? 'before' - : 'after' - const path = - block.children.length <= 1 || !block.children[operation.path[1]! - 1] - ? [{_key: block._key}, 'children', 0] - : [ - {_key: block._key}, - 'children', - {_key: block.children[operation.path[1]! - 1]!._key}, - ] - - // Defensive setIfMissing to ensure children array exists before inserting - const setIfMissingPatch = setIfMissing([], [{_key: block._key}, 'children']) - - if (Text.isText(operation.node, schema)) { - return [setIfMissingPatch, insert([operation.node], position, path)] - } - - return [setIfMissingPatch, insert([operation.node], position, path)] + return insertChildNodePatch(operation, block) } return [] } +function insertBlockNodePatch( + schema: EditorSchema, + operation: InsertNodeOperation, + beforeValue: Array, +): Array { + const insertIdx = operation.path[0]! + const position = insertIdx === 0 ? 'before' : 'after' + const adjacentIdx = insertIdx === 0 ? 0 : insertIdx - 1 + const adjacentKeyPath = resolveKeyPath(schema, beforeValue, [adjacentIdx]) + + if (adjacentKeyPath) { + return [ + insert( + [operation.node as PortableTextBlock], + position, + adjacentKeyPath as Path, + ), + ] + } + + return [ + setIfMissing(beforeValue, []), + insert([operation.node as PortableTextBlock], 'before', [insertIdx]), + ] +} + +function insertChildNodePatch( + operation: InsertNodeOperation, + block: PortableTextTextBlock, +): Array { + const childIdx = operation.path[1]! + const prevChild = block.children[childIdx - 1] + const position = + block.children.length === 0 || !prevChild ? 'before' : 'after' + const path: Path = + block.children.length <= 1 || !prevChild + ? [{_key: block._key}, 'children', 0] + : [{_key: block._key}, 'children', {_key: prevChild._key}] + + return [ + setIfMissing([], [{_key: block._key}, 'children']), + insert([operation.node], position, path), + ] +} + export function removeNodePatch( schema: EditorSchema, beforeValue: Array, operation: RemoveNodeOperation, ): Array { const block = beforeValue[operation.path[0]!] + if (operation.path.length === 1) { - // Remove a single block - if (block && block._key) { - return [unset([{_key: block._key}])] + const keyPath = resolveKeyPath(schema, beforeValue, operation.path) + if (!keyPath) { + throw new Error('Block not found') } - throw new Error('Block not found') - } else if (isTextBlock({schema}, block) && operation.path.length === 2) { - const spanToRemove = block.children[operation.path[1]!] + return [unset(keyPath as Path)] + } - if (spanToRemove) { - const spansMatchingKey = block.children.filter( - (span) => span._key === operation.node._key, - ) + if (!isTextBlock({schema}, block) || operation.path.length !== 2) { + return [] + } - if (spansMatchingKey.length > 1) { - console.warn( - `Multiple spans have \`_key\` ${operation.node._key}. It's ambiguous which one to remove.`, - JSON.stringify(block, null, 2), - ) - return [] - } + const child = block.children[operation.path[1]!] + if (!child) { + return [] + } - return [ - unset([{_key: block._key}, 'children', {_key: spanToRemove._key}]), - ] - } + const duplicates = block.children.filter( + (span) => span._key === operation.node._key, + ) + if (duplicates.length > 1) { + console.warn( + `Multiple spans have \`_key\` ${operation.node._key}. It's ambiguous which one to remove.`, + JSON.stringify(block, null, 2), + ) return [] - } else { + } + + const keyPath = resolveKeyPath(schema, beforeValue, operation.path) + if (!keyPath) { return [] } + + return [unset(keyPath as Path)] } diff --git a/packages/editor/src/internal-utils/resolve-key-path.test.ts b/packages/editor/src/internal-utils/resolve-key-path.test.ts new file mode 100644 index 000000000..5b05ef94b --- /dev/null +++ b/packages/editor/src/internal-utils/resolve-key-path.test.ts @@ -0,0 +1,93 @@ +import {compileSchema, defineSchema} from '@portabletext/schema' +import {describe, expect, it} from 'vitest' +import {resolveKeyPath} from './resolve-key-path' + +const schema = compileSchema(defineSchema({inlineObjects: [{name: 'stock-ticker'}]})) + +const tree = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_type: 'span', _key: 's1', text: 'hello', marks: []}, + {_key: 'obj1', _type: 'stock-ticker', symbol: 'AAPL'}, + {_type: 'span', _key: 's2', text: ' world', marks: []}, + ], + }, + { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 's3', text: 'second block', marks: []}], + }, + { + _key: 'img1', + _type: 'image', + url: 'https://example.com/image.png', + }, +] + +describe('resolveKeyPath', () => { + it('resolves a block path', () => { + expect(resolveKeyPath(schema, tree, [0])).toEqual([{_key: 'b1'}]) + }) + + it('resolves a second block path', () => { + expect(resolveKeyPath(schema, tree, [1])).toEqual([{_key: 'b2'}]) + }) + + it('resolves a block object path', () => { + expect(resolveKeyPath(schema, tree, [2])).toEqual([{_key: 'img1'}]) + }) + + it('resolves a child span path', () => { + expect(resolveKeyPath(schema, tree, [0, 0])).toEqual([ + {_key: 'b1'}, + 'children', + {_key: 's1'}, + ]) + }) + + it('resolves a second child span path', () => { + expect(resolveKeyPath(schema, tree, [0, 2])).toEqual([ + {_key: 'b1'}, + 'children', + {_key: 's2'}, + ]) + }) + + it('resolves a child in the second block', () => { + expect(resolveKeyPath(schema, tree, [1, 0])).toEqual([ + {_key: 'b2'}, + 'children', + {_key: 's3'}, + ]) + }) + + it('returns undefined for empty path', () => { + expect(resolveKeyPath(schema, tree, [])).toBeUndefined() + }) + + it('returns undefined for out-of-bounds block index', () => { + expect(resolveKeyPath(schema, tree, [99])).toBeUndefined() + }) + + it('returns undefined for out-of-bounds child index', () => { + expect(resolveKeyPath(schema, tree, [0, 99])).toBeUndefined() + }) + + it('returns undefined for child path on block object', () => { + expect(resolveKeyPath(schema, tree, [2, 0])).toBeUndefined() + }) + + it('resolves inline object child path', () => { + expect(resolveKeyPath(schema, tree, [0, 1])).toEqual([ + {_key: 'b1'}, + 'children', + {_key: 'obj1'}, + ]) + }) +}) diff --git a/packages/editor/src/internal-utils/resolve-key-path.ts b/packages/editor/src/internal-utils/resolve-key-path.ts new file mode 100644 index 000000000..12a7a9ffb --- /dev/null +++ b/packages/editor/src/internal-utils/resolve-key-path.ts @@ -0,0 +1,45 @@ +import {isTextBlock, type PortableTextBlock} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' + +export type KeyPathSegment = {_key: string} | string + +export type KeyPath = KeyPathSegment[] + +/** + * Converts a positional Slate path to a key-based PT path using the tree. + * + * Returns undefined if the path can't be resolved. + */ +export function resolveKeyPath( + schema: EditorSchema, + tree: Array, + slatePath: number[], +): KeyPath | undefined { + if (slatePath.length === 0) { + return undefined + } + + const blockIdx = slatePath[0]! + const block = tree[blockIdx] + + if (!block?._key) { + return undefined + } + + if (slatePath.length === 1) { + return [{_key: block._key}] + } + + if (!isTextBlock({schema}, block)) { + return undefined + } + + const childIdx = slatePath[1]! + const child = block.children[childIdx] + + if (!child?._key) { + return undefined + } + + return [{_key: block._key}, 'children', {_key: child._key}] +}