diff --git a/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts b/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts index 7b4f803f5..6744c70ff 100644 --- a/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts +++ b/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts @@ -172,4 +172,95 @@ describe(applyOperationToPortableText.name, () => { }, ]) }) + + test('updating block object with a field named "text"', () => { + const keyGenerator = createTestKeyGenerator() + const k0 = keyGenerator() + + expect( + applyOperationToPortableText( + createContext(), + [ + { + _type: 'quote', + _key: k0, + text: 'h', + }, + ], + { + type: 'set_node', + path: [0], + properties: { + value: {text: 'h'}, + }, + newProperties: { + value: {text: 'hello'}, + }, + }, + ), + ).toEqual([ + { + _type: 'quote', + _key: k0, + text: 'hello', + }, + ]) + }) + + test('updating inline object with a field named "text"', () => { + const keyGenerator = createTestKeyGenerator() + const k0 = keyGenerator() + const k1 = keyGenerator() + const k2 = keyGenerator() + const k3 = keyGenerator() + + expect( + applyOperationToPortableText( + createContext(), + [ + { + _key: k0, + _type: 'block', + children: [ + { + _key: k1, + _type: 'span', + text: '', + }, + { + _key: k2, + _type: 'mention', + text: 'J', + }, + { + _key: k3, + _type: 'span', + text: '', + }, + ], + }, + ], + { + type: 'set_node', + path: [0, 1], + properties: { + value: {text: 'J'}, + }, + newProperties: { + value: {text: 'John Doe'}, + }, + }, + ), + ).toEqual([ + { + _type: 'block', + _key: k0, + children: [ + {_type: 'span', _key: k1, text: ''}, + {_type: 'mention', _key: k2, text: 'John Doe'}, + {_type: 'span', _key: k3, text: ''}, + ], + }, + ]) + }) }) 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 fe60cb2a4..d30253e60 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 @@ -107,7 +107,7 @@ function applyOperationToPortableTextDraft( break } - if (isPartialSpanNode(insertedNode)) { + if (isPartialSpanNode(context, insertedNode)) { // Text nodes can be inserted as is parent.children.splice(index, 0, insertedNode) @@ -161,7 +161,10 @@ function applyOperationToPortableTextDraft( const index = path[path.length - 1] - if (isPartialSpanNode(node) && isPartialSpanNode(prev)) { + if ( + isPartialSpanNode(context, node) && + isPartialSpanNode(context, prev) + ) { prev.text += node.text } else if ( isTextBlockNode(context, node) && @@ -342,7 +345,7 @@ function applyOperationToPortableTextDraft( break } - if (isPartialSpanNode(node)) { + if (isPartialSpanNode(context, node)) { for (const key in newProperties) { if (key === 'text') { break diff --git a/packages/editor/src/internal-utils/portable-text-node.test.ts b/packages/editor/src/internal-utils/portable-text-node.test.ts new file mode 100644 index 000000000..c8b9a1a4c --- /dev/null +++ b/packages/editor/src/internal-utils/portable-text-node.test.ts @@ -0,0 +1,132 @@ +import {compileSchema, defineSchema} from '@portabletext/schema' +import {describe, expect, test} from 'vitest' +import {isObjectNode, isPartialSpanNode, isSpanNode} from './portable-text-node' + +const schema = compileSchema(defineSchema({})) + +describe(isPartialSpanNode.name, () => { + test('object with only text property', () => { + expect(isPartialSpanNode({schema}, {text: 'Hello'})).toBe(true) + }) + + test('non-objects', () => { + expect(isPartialSpanNode({schema}, null)).toBe(false) + expect(isPartialSpanNode({schema}, undefined)).toBe(false) + expect(isPartialSpanNode({schema}, 'text')).toBe(false) + expect(isPartialSpanNode({schema}, 123)).toBe(false) + }) + + test('text is not a string', () => { + expect(isPartialSpanNode({schema}, {text: 123})).toBe(false) + expect(isPartialSpanNode({schema}, {text: null})).toBe(false) + expect(isPartialSpanNode({schema}, {text: undefined})).toBe(false) + }) + + test('inline object with text field and _type', () => { + expect( + isPartialSpanNode( + {schema}, + {_type: 'mention', _key: 'abc123', text: 'John Doe'}, + ), + ).toBe(false) + }) + + test('block object with text field and _type', () => { + expect( + isPartialSpanNode( + {schema}, + { + _type: 'quote', + _key: 'abc123', + text: 'Hello world', + source: 'Anonymous', + }, + ), + ).toBe(false) + }) +}) + +describe(isSpanNode.name, () => { + test('span with _type="span"', () => { + expect(isSpanNode({schema}, {_type: 'span', text: 'Hello'})).toBe(true) + }) + + test('partial span (no _type, has text)', () => { + expect(isSpanNode({schema}, {text: 'Hello'})).toBe(true) + }) + + test('object with children', () => { + expect( + isSpanNode({schema}, {_type: 'span', text: 'Hello', children: []}), + ).toBe(false) + }) + + test('object with different _type', () => { + expect(isSpanNode({schema}, {_type: 'mention', text: 'Hello'})).toBe(false) + }) +}) + +describe(isObjectNode.name, () => { + test('inline object', () => { + expect( + isObjectNode( + {schema}, + {_type: 'stock-ticker', _key: 'abc', symbol: 'AAPL'}, + ), + ).toBe(true) + }) + + test('block object', () => { + expect( + isObjectNode( + {schema}, + {_type: 'image', _key: 'abc', src: 'https://example.com'}, + ), + ).toBe(true) + }) + + test('inline object with text field', () => { + expect( + isObjectNode( + {schema}, + {_type: 'mention', _key: 'abc123', text: 'John Doe'}, + ), + ).toBe(true) + }) + + test('block object with text field', () => { + expect( + isObjectNode( + {schema}, + { + _type: 'quote', + _key: 'abc123', + text: 'Hello world', + source: 'Anonymous', + }, + ), + ).toBe(true) + }) + + test('span', () => { + expect( + isObjectNode( + {schema}, + {_type: 'span', _key: 'abc', text: 'Hello', marks: []}, + ), + ).toBe(false) + }) + + test('text block', () => { + expect( + isObjectNode( + {schema}, + { + _type: 'block', + _key: 'abc', + children: [{_type: 'span', text: 'Hello'}], + }, + ), + ).toBe(false) + }) +}) diff --git a/packages/editor/src/internal-utils/portable-text-node.ts b/packages/editor/src/internal-utils/portable-text-node.ts index 3b33dc13f..62c3bd00d 100644 --- a/packages/editor/src/internal-utils/portable-text-node.ts +++ b/packages/editor/src/internal-utils/portable-text-node.ts @@ -79,13 +79,23 @@ export type PartialSpanNode = { [other: string]: unknown } -export function isPartialSpanNode(node: unknown): node is PartialSpanNode { - return ( - typeof node === 'object' && - node !== null && - 'text' in node && - typeof node.text === 'string' - ) +export function isPartialSpanNode( + context: {schema: EditorSchema}, + node: unknown, +): node is PartialSpanNode { + if (typeof node !== 'object' || node === null) { + return false + } + + if (!('text' in node) || typeof node.text !== 'string') { + return false + } + + if ('_type' in node && node._type !== context.schema.span.name) { + return false + } + + return true } ////////// @@ -104,7 +114,7 @@ export function isObjectNode( !isEditorNode(node) && !isTextBlockNode(context, node) && !isSpanNode(context, node) && - !isPartialSpanNode(node) + !isPartialSpanNode(context, node) ) } diff --git a/packages/editor/tests/event.child.set.test.tsx b/packages/editor/tests/event.child.set.test.tsx index 60635da8c..dac545638 100644 --- a/packages/editor/tests/event.child.set.test.tsx +++ b/packages/editor/tests/event.child.set.test.tsx @@ -395,4 +395,81 @@ describe('event.child.set', () => { ]) }) }) + + test('Scenario: Setting "text" field on inline object', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const mentionKey = keyGenerator() + const initialValue = [ + { + _type: 'block', + _key: blockKey, + children: [ + { + _type: 'span', + _key: keyGenerator(), + text: '', + marks: [], + }, + { + _type: 'mention', + _key: mentionKey, + text: 'J', + }, + { + _type: 'span', + _key: keyGenerator(), + text: '', + marks: [], + }, + ], + style: 'normal', + markDefs: [], + }, + ] + + const {editor} = await createTestEditor({ + initialValue, + keyGenerator, + schemaDefinition: defineSchema({ + inlineObjects: [ + { + name: 'mention', + fields: [{name: 'text', type: 'string'}], + }, + ], + }), + }) + + await vi.waitFor(() => { + return expect(getTersePt(editor.getSnapshot().context)).toEqual([ + ',{mention},', + ]) + }) + + editor.send({ + type: 'child.set', + at: [{_key: blockKey}, 'children', {_key: mentionKey}], + props: { + text: 'John Doe', + }, + }) + + await vi.waitFor(() => { + return expect(editor.getSnapshot().context.value).toEqual([ + { + ...initialValue[0], + children: [ + initialValue[0]!.children[0], + { + _type: 'mention', + _key: mentionKey, + text: 'John Doe', + }, + initialValue[0]!.children[2], + ], + }, + ]) + }) + }) }) diff --git a/packages/editor/tests/event.patches.test.tsx b/packages/editor/tests/event.patches.test.tsx index fb8a88f60..7a1636553 100644 --- a/packages/editor/tests/event.patches.test.tsx +++ b/packages/editor/tests/event.patches.test.tsx @@ -1997,4 +1997,72 @@ describe('event.patches', () => { expect(editor.getSnapshot().context.selection).toEqual(midBarSelection) }) }) + + test('Scenario: `set`ing "text" field on inline object', async () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const span1Key = keyGenerator() + const mentionKey = keyGenerator() + const span2Key = keyGenerator() + const initialValue = [ + { + _key: blockKey, + _type: 'block', + children: [ + {_type: 'span', _key: span1Key, text: '', marks: []}, + { + _type: 'mention', + _key: mentionKey, + text: 'John Doe', + }, + { + _type: 'span', + _key: span2Key, + text: '', + marks: [], + }, + ], + markDefs: [], + style: 'normal', + }, + ] + + const {editor} = await createTestEditor({ + initialValue, + keyGenerator, + schemaDefinition: defineSchema({ + inlineObjects: [ + {name: 'mention', fields: [{name: 'text', type: 'string'}]}, + ], + }), + }) + + editor.send({ + type: 'patches', + patches: [ + { + type: 'set', + path: [{_key: blockKey}, 'children', {_key: mentionKey}, 'text'], + value: 'Jane Doe', + }, + ], + snapshot: undefined, + }) + + await vi.waitFor(() => { + return expect(editor.getSnapshot().context.value).toEqual([ + { + _key: blockKey, + _type: 'block', + children: [ + {_type: 'span', _key: span1Key, text: '', marks: []}, + {_type: 'mention', _key: mentionKey, text: 'Jane Doe'}, + {_type: 'span', _key: span2Key, text: '', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) })