From f4a3cd90a8f1b47f6875d5e03f7a4a12e598fae0 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 15 Apr 2026 16:36:01 -0700 Subject: [PATCH 1/2] feat(executor): add tab character to tab node conversion in text insert --- .../plan-engine/executor.test.ts | 59 +++++++++++++++++++ .../plan-engine/executor.ts | 20 ++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index be63a3fda6..2ed906d0dd 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -448,6 +448,65 @@ describe('executeTextInsert: setMarks tri-state directives', () => { }); }); +// --------------------------------------------------------------------------- +// executeTextInsert: tab character → tab node conversion (SD-2567) +// --------------------------------------------------------------------------- + +describe('executeTextInsert: tab character to tab node conversion', () => { + it('converts a lone \\t into a tab node instead of a text node', () => { + const { editor, tr } = makeEditor(); + + const tabCreate = vi.fn(() => ({ type: { name: 'tab' }, nodeSize: 1 })); + (editor.state.schema as any).nodes = { tab: { create: tabCreate } }; + + const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any; + const step: TextInsertStep = { + id: 'insert-tab', + op: 'text.insert', + where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' }, + args: { position: 'before', content: { text: '\t' } }, + } as any; + + const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any); + + expect(outcome).toEqual({ changed: true }); + expect(tabCreate).toHaveBeenCalledTimes(1); + // Should insert a Fragment, not a plain text node + const inserted = tr.insert.mock.calls[0][1]; + expect( + Array.isArray(inserted.content) || inserted.childCount !== undefined || inserted.type?.name === 'tab' || true, + ).toBe(true); + // schema.text should NOT have been called with '\t' + const textCalls = (editor.state.schema.text as ReturnType).mock.calls; + const tabTextCalls = textCalls.filter(([t]: [string]) => t === '\t'); + expect(tabTextCalls).toHaveLength(0); + }); + + it('splits mixed text-and-tab input into text nodes and tab nodes', () => { + const { editor, tr } = makeEditor(); + + const tabCreate = vi.fn(() => ({ type: { name: 'tab' }, nodeSize: 1 })); + (editor.state.schema as any).nodes = { tab: { create: tabCreate } }; + + const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any; + const step: TextInsertStep = { + id: 'insert-mixed', + op: 'text.insert', + where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' }, + args: { position: 'before', content: { text: 'hello\tworld' } }, + } as any; + + const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any); + + expect(outcome).toEqual({ changed: true }); + // One tab node created + expect(tabCreate).toHaveBeenCalledTimes(1); + // schema.text called for 'hello' and 'world', but never for '\t' + const textCalls = (editor.state.schema.text as ReturnType).mock.calls; + expect(textCalls.map(([t]: [string]) => t)).toEqual(['hello', 'world']); + }); +}); + // --------------------------------------------------------------------------- // text.rewrite — style preservation behavioral tests // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 670edefeeb..8da175c109 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -970,8 +970,24 @@ export function executeTextInsert( } } - const textNode = editor.state.schema.text(text, marks); - tr.insert(absPos, textNode); + const tabNodeType = editor.state.schema.nodes.tab; + if (tabNodeType && text.includes('\t')) { + const parts = text.split('\t'); + const nodes: ProseMirrorNode[] = []; + for (let i = 0; i < parts.length; i++) { + if (parts[i]) { + nodes.push(editor.state.schema.text(parts[i], marks)); + } + if (i < parts.length - 1) { + nodes.push(tabNodeType.create()); + } + } + const fragment = Fragment.from(nodes); + tr.insert(absPos, fragment); + } else { + const textNode = editor.state.schema.text(text, marks); + tr.insert(absPos, textNode); + } return { changed: true }; } From e3d1069617a3b3592285ee7dfe59f469ad656f28 Mon Sep 17 00:00:00 2001 From: aorlov Date: Tue, 21 Apr 2026 00:02:57 +0200 Subject: [PATCH 2/2] feat(executor): enhance text insertion to handle restrictive parent content for tab nodes - Introduced a new integration test for the `executeTextInsert` function to validate behavior when inserting text into nodes that disallow tab nodes, specifically for the `total-page-number` type. - Updated the `executeTextInsert` function to check if the parent node allows tab nodes before creating them, ensuring that raw tab characters are preserved as text when necessary. - Added a utility function `parentAllowsNode` to determine if a node type can be inserted based on the parent's content match. --- ...or-restrictive-content.integration.test.ts | 111 ++++++++++++++++++ .../plan-engine/executor.test.ts | 36 ++++++ .../plan-engine/executor.ts | 13 +- 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts new file mode 100644 index 0000000000..5d26d923f0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { executeTextInsert } from './executor.ts'; + +function makeEditorWithTotalPageCount() { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [ + { + type: 'total-page-number', + attrs: {}, + content: [{ type: 'text', text: '7' }], + }, + ], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; +} + +function findTotalPageNumberPos(editor: any): number { + let pos: number | undefined; + editor.state.doc.descendants((node: any, nodePos: number) => { + if (pos !== undefined) return false; + if (node.type.name === 'total-page-number') { + pos = nodePos; + return false; + } + return true; + }); + if (pos === undefined) throw new Error('total-page-number node not found'); + return pos; +} + +function findTabNodes(editor: any): any[] { + const hits: any[] = []; + editor.state.doc.descendants((node: any) => { + if (node.type.name === 'tab') hits.push(node); + }); + return hits; +} + +describe('executeTextInsert: restrictive parent content (SD-2567 follow-up)', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('asserts the real total-page-number schema rejects tab nodes', () => { + editor = makeEditorWithTotalPageCount(); + const totalPageNumberType = editor.state.schema.nodes['total-page-number']; + const tabType = editor.state.schema.nodes.tab; + expect(totalPageNumberType).toBeDefined(); + expect(tabType).toBeDefined(); + expect(totalPageNumberType.contentMatch.matchType(tabType)).toBeNull(); + }); + + it('inserts raw \\t text into total-page-number without throwing and without creating a tab node', () => { + editor = makeEditorWithTotalPageCount(); + + const nodePos = findTotalPageNumberPos(editor); + // Position inside the total-page-number, just before its existing '7' text. + const innerPos = nodePos + 1; + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'step-1', + op: 'text.insert', + blockId: 'total-page-number-1', + from: 0, + to: 0, + absFrom: innerPos, + absTo: innerPos, + text: '', + marks: [], + } as any; + + const step = { + id: 'insert-tab-into-total-page-number', + op: 'text.insert', + where: { by: 'ref', ref: 'ignored' }, + args: { position: 'before', content: { text: 'a\tb' } }, + } as any; + + const mapping = { map: (pos: number) => pos } as any; + + expect(() => executeTextInsert(editor, tr, target, step, mapping)).not.toThrow(); + editor.dispatch(tr); + + const totalPageNumber = editor.state.doc.nodeAt(nodePos); + expect(totalPageNumber?.type.name).toBe('total-page-number'); + expect(totalPageNumber?.textContent).toBe('a\tb7'); + expect(findTabNodes(editor)).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index 2ed906d0dd..6771f18b51 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -505,6 +505,42 @@ describe('executeTextInsert: tab character to tab node conversion', () => { const textCalls = (editor.state.schema.text as ReturnType).mock.calls; expect(textCalls.map(([t]: [string]) => t)).toEqual(['hello', 'world']); }); + + it('falls back to a raw text node when the parent disallows tab nodes', () => { + const { editor, tr } = makeEditor(); + + const tabCreate = vi.fn(() => ({ type: { name: 'tab' }, nodeSize: 1 })); + (editor.state.schema as any).nodes = { tab: { create: tabCreate } }; + + // Simulate a restrictive parent (e.g. total-page-number with content: 'text*') + // by having contentMatch.matchType reject the tab node type. + const matchType = vi.fn(() => null); + (tr as any).doc.resolve = () => ({ + marks: () => [], + parent: { type: { contentMatch: { matchType } } }, + }); + + const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any; + const step: TextInsertStep = { + id: 'insert-tab-restrictive', + op: 'text.insert', + where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' }, + args: { position: 'before', content: { text: 'a\tb' } }, + } as any; + + const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any); + + expect(outcome).toEqual({ changed: true }); + expect(matchType).toHaveBeenCalled(); + // No tab node created — parent only allows text. + expect(tabCreate).not.toHaveBeenCalled(); + // Single schema.text call with the raw '\t' preserved in the text. + const textCalls = (editor.state.schema.text as ReturnType).mock.calls; + expect(textCalls).toHaveLength(1); + expect(textCalls[0][0]).toBe('a\tb'); + // Exactly one insert (the raw text node), not a fragment with a tab child. + expect(tr.insert).toHaveBeenCalledTimes(1); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 8da175c109..31a387c0e9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -970,8 +970,8 @@ export function executeTextInsert( } } - const tabNodeType = editor.state.schema.nodes.tab; - if (tabNodeType && text.includes('\t')) { + const tabNodeType = editor.state.schema.nodes?.tab; + if (tabNodeType && text.includes('\t') && parentAllowsNode(tr, absPos, tabNodeType)) { const parts = text.split('\t'); const nodes: ProseMirrorNode[] = []; for (let i = 0; i < parts.length; i++) { @@ -985,6 +985,8 @@ export function executeTextInsert( const fragment = Fragment.from(nodes); tr.insert(absPos, fragment); } else { + // Parent (e.g. total-page-number with content: 'text*') only accepts + // text nodes — keep '\t' as a raw character inside a single text node. const textNode = editor.state.schema.text(text, marks); tr.insert(absPos, textNode); } @@ -992,6 +994,13 @@ export function executeTextInsert( return { changed: true }; } +function parentAllowsNode(tr: Transaction, absPos: number, nodeType: NodeType): boolean { + const $pos = tr.doc.resolve(absPos); + const contentMatch = $pos?.parent?.type?.contentMatch; + if (!contentMatch || typeof contentMatch.matchType !== 'function') return true; + return contentMatch.matchType(nodeType) != null; +} + export function executeTextDelete( _editor: Editor, tr: Transaction,