From f4a3cd90a8f1b47f6875d5e03f7a4a12e598fae0 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 15 Apr 2026 16:36:01 -0700 Subject: [PATCH 1/3] 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/3] 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, From 2418fe9562b95b662584728d32374dc240eca4d7 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 22 Apr 2026 00:45:54 +0200 Subject: [PATCH 3/3] feat(tabs): implement tab handling in text insertion and document extraction - Introduced `buildTextWithTabs` and `textBetweenWithTabs` functions to manage tab characters, converting them to tab nodes during text insertion and ensuring they are correctly represented in document extraction. - Updated existing commands (`insertHeadingAt`, `insertListItemAt`, `insertParagraphAt`) to utilize the new tab handling functions, enhancing text formatting capabilities. - Modified tests to validate the new tab handling behavior, ensuring that tab characters are preserved and correctly processed in various scenarios. - Added comprehensive tests for the new helper functions to ensure robust functionality across different document structures. This update enhances the editor's ability to handle tab characters, improving the overall user experience when working with formatted text. --- .../v1/core/commands/insertHeadingAt.js | 8 +- .../v1/core/commands/insertHeadingAt.test.js | 2 +- .../v1/core/commands/insertListItemAt.js | 7 +- .../v1/core/commands/insertListItemAt.test.js | 2 +- .../v1/core/commands/insertParagraphAt.js | 9 +- .../core/commands/insertParagraphAt.test.js | 2 +- .../document-api-adapters/get-text-adapter.ts | 7 +- .../helpers/adapter-utils.ts | 5 +- .../helpers/text-mutation-resolution.test.ts | 5 +- .../helpers/text-mutation-resolution.ts | 7 +- .../helpers/text-with-tabs.test.ts | 146 ++++++++ .../helpers/text-with-tabs.ts | 136 ++++++++ .../plan-engine/caption-wrappers.ts | 14 +- .../plan-engine/content-controls-wrappers.ts | 44 +-- ...or-restrictive-content.integration.test.ts | 111 ------ .../executor-tab-handling.integration.test.ts | 320 ++++++++++++++++++ .../plan-engine/executor.test.ts | 17 +- .../plan-engine/executor.ts | 129 +++---- .../node-materializer.ts | 23 +- .../structural-write-engine.test.ts | 23 ++ 20 files changed, 792 insertions(+), 225 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts delete mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-tab-handling.integration.test.ts diff --git a/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js b/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js index 29d191d969..8a8185539e 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js @@ -1,3 +1,5 @@ +import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js'; + /** * Insert a heading node at an absolute document position. * @@ -25,13 +27,13 @@ export const insertHeadingAt = }, }; const normalizedText = typeof text === 'string' ? text : ''; - const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null; + // buildTextWithTabs splits '\t' into real tab nodes so exports emit . + const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null; let paragraphNode; try { paragraphNode = - paragraphType.createAndFill(attrs, textNode ?? undefined) ?? - paragraphType.create(attrs, textNode ? [textNode] : undefined); + paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined); } catch { return false; } diff --git a/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js b/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js index 512403e27e..048c3637a4 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js @@ -145,7 +145,7 @@ describe('insertHeadingAt', () => { insertHeadingAt({ pos: 0, level: 1, text: 'Hello' })({ state, dispatch }); - expect(state.schema.text).toHaveBeenCalledWith('Hello'); + expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined); }); // --- tracked mode --- diff --git a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js index eab3bc760e..f6065177b8 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js @@ -1,4 +1,5 @@ import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; +import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js'; /** * Insert a list-item paragraph before/after a target list paragraph position. @@ -42,12 +43,12 @@ export const insertListItemAt = }; const normalizedText = typeof text === 'string' ? text : ''; - const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : undefined; + // buildTextWithTabs splits '\t' into real tab nodes so exports emit . + const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : undefined; let paragraphNode; try { - paragraphNode = - paragraphType.createAndFill(attrs, textNode) ?? paragraphType.create(attrs, textNode ? [textNode] : undefined); + paragraphNode = paragraphType.createAndFill(attrs, content) ?? paragraphType.create(attrs, content ?? undefined); } catch { return false; } diff --git a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js index ee94140729..d5823a7ec9 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js @@ -175,7 +175,7 @@ describe('insertListItemAt', () => { dispatch, }); - expect(state.schema.text).toHaveBeenCalledWith('New item'); + expect(state.schema.text).toHaveBeenCalledWith('New item', undefined); }); it('does not inherit sdBlockId from the target node when omitted', () => { diff --git a/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js b/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js index 2d89b53f87..2d039dd600 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js @@ -1,3 +1,5 @@ +import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js'; + /** * Insert a paragraph node at an absolute document position. * @@ -16,13 +18,14 @@ export const insertParagraphAt = ...(paraId ? { paraId } : undefined), }; const normalizedText = typeof text === 'string' ? text : ''; - const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null; + // buildTextWithTabs splits '\t' into real tab nodes so exports emit + // instead of a raw tab character inside . + const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null; let paragraphNode; try { paragraphNode = - paragraphType.createAndFill(attrs, textNode ?? undefined) ?? - paragraphType.create(attrs, textNode ? [textNode] : undefined); + paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined); } catch { return false; } diff --git a/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js b/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js index 979e3c3654..501d3cf7e5 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js @@ -100,7 +100,7 @@ describe('insertParagraphAt', () => { insertParagraphAt({ pos: 0, text: 'Hello' })({ state, dispatch }); - expect(state.schema.text).toHaveBeenCalledWith('Hello'); + expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined); }); it('sets forceTrackChanges meta when tracked is true', () => { diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts index 06ec156fe3..a9628e0ccd 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts @@ -1,15 +1,20 @@ import type { Editor } from '../core/Editor.js'; import type { GetTextInput } from '@superdoc/document-api'; import { resolveStoryRuntime } from './story-runtime/resolve-story-runtime.js'; +import { textBetweenWithTabs } from './helpers/text-with-tabs.js'; /** * Return the full document text content from the ProseMirror document. * + * Tab nodes are rendered as real '\t' so the extracted text round-trips with + * what the write APIs accept. Other inline leaves fall back to '\n' (matching + * the legacy behavior for non-text nodes). + * * @param editor - The editor instance. * @returns Plain text content of the document. */ export function getTextAdapter(editor: Editor, input: GetTextInput): string { const runtime = resolveStoryRuntime(editor, input.in); const doc = runtime.editor.state.doc; - return doc.textBetween(0, doc.content.size, '\n', '\n'); + return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n'); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts index e899902a51..5917cfd775 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts @@ -21,6 +21,7 @@ import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mut import type { Transaction } from 'prosemirror-state'; import type { Editor } from '../../core/Editor.js'; import { DocumentApiAdapterError } from '../errors.js'; +import { buildTextWithTabs } from './text-with-tabs.js'; export type WithinResult = { ok: true; range: { start: number; end: number } | undefined } | { ok: false }; export type ResolvedTextTarget = { from: number; to: number }; @@ -228,8 +229,8 @@ export function insertParagraphAtEnd( applyMeta?: (tr: Transaction) => Transaction, ): void { const schema = editor.state.schema; - const textNode = schema.text(text); - const paragraph = schema.nodes.paragraph.create(null, textNode); + const content = buildTextWithTabs(schema, text, undefined); + const paragraph = schema.nodes.paragraph.create(null, content); const tr = editor.state.tr; tr.insert(pos, paragraph); if (applyMeta) applyMeta(tr); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.test.ts index 66c3caf491..37fafcd1f1 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.test.ts @@ -1,8 +1,11 @@ +import { describe, expect, it, vi } from 'vitest'; import type { TextAddress } from '@superdoc/document-api'; import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mutation-resolution.js'; import type { Editor } from '../../core/Editor.js'; function makeEditor(text: string): Editor { + // textBetweenWithTabs uses nodesBetween in real PM docs and falls back to + // textBetween for mocked docs that don't provide nodesBetween. return { state: { doc: { @@ -13,7 +16,7 @@ function makeEditor(text: string): Editor { } describe('readTextAtResolvedRange', () => { - it('delegates to textBetween with canonical separators', () => { + it('reads text between resolved positions with canonical separators', () => { const editor = makeEditor('Hello'); const result = readTextAtResolvedRange(editor, { from: 1, to: 6 }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.ts index 0ea253b2cd..5e74916a86 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.ts @@ -1,6 +1,7 @@ import type { TextAddress, TextMutationResolution } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import type { ResolvedTextTarget } from './adapter-utils.js'; +import { textBetweenWithTabs } from './text-with-tabs.js'; /** Unicode Object Replacement Character — used as placeholder for leaf inline nodes in textBetween(). */ const OBJECT_REPLACEMENT_CHAR = '\ufffc'; @@ -9,14 +10,16 @@ const OBJECT_REPLACEMENT_CHAR = '\ufffc'; * Reads the canonical flattened text between two resolved document positions. * * Uses `\n` as the block separator and `\ufffc` (Object Replacement Character) as the - * leaf-inline placeholder, matching the offset model used by `TextAddress`. + * leaf-inline placeholder, matching the offset model used by `TextAddress`. Tab + * nodes round-trip to a real '\t' so no-op comparisons against caller-supplied + * text (e.g. `write-adapter`'s replace NO_OP check) continue to match. * * @param editor - The editor instance to read from. * @param range - Resolved absolute document positions. * @returns The text content between the resolved positions. */ export function readTextAtResolvedRange(editor: Editor, range: ResolvedTextTarget): string { - return editor.state.doc.textBetween(range.from, range.to, '\n', OBJECT_REPLACEMENT_CHAR); + return textBetweenWithTabs(editor.state.doc, range.from, range.to, '\n', OBJECT_REPLACEMENT_CHAR); } /** diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts new file mode 100644 index 0000000000..1c0e6594cf --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Fragment, Schema } from 'prosemirror-model'; +import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from './text-with-tabs.js'; + +function makeRealSchema(options: { hasTab?: boolean } = {}) { + const nodes: Record = { + doc: { content: 'paragraph+' }, + paragraph: { group: 'block', content: 'inline*' }, + text: { group: 'inline' }, + }; + if (options.hasTab) { + // Mirrors the real extensions/tab/tab.js shape: inline atom with `content: 'inline*'`. + // Tab is non-leaf, which is why `textBetweenWithTabs` (not PM's built-in textBetween) is needed. + nodes.tab = { group: 'inline', inline: true, atom: true, content: 'inline*' }; + } + return new Schema({ + nodes, + marks: { + bold: {}, + }, + }); +} + +describe('buildTextWithTabs', () => { + it('returns a plain text node when the text has no tab character', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'hello world', undefined); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('hello world'); + }); + + it('returns a plain text node when the schema has no tab node type', () => { + const schema = makeRealSchema({ hasTab: false }); + const result = buildTextWithTabs(schema, 'a\tb', undefined); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('a\tb'); + }); + + it('returns a plain text node when parentAllowsTab is false', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'a\tb', undefined, { parentAllowsTab: false }); + expect((result as any).isText).toBe(true); + expect((result as any).text).toBe('a\tb'); + }); + + it('splits text around a single tab into text + tab + text', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'left\tright', undefined); + expect(result).toBeInstanceOf(Fragment); + const fragment = result as Fragment; + expect(fragment.childCount).toBe(3); + expect(fragment.child(0).text).toBe('left'); + expect(fragment.child(1).type.name).toBe('tab'); + expect(fragment.child(2).text).toBe('right'); + }); + + it('omits empty segments so a leading or trailing tab does not emit an empty text node', () => { + const schema = makeRealSchema({ hasTab: true }); + const lead = buildTextWithTabs(schema, '\tfoo', undefined) as Fragment; + expect(lead.childCount).toBe(2); + expect(lead.child(0).type.name).toBe('tab'); + expect(lead.child(1).text).toBe('foo'); + + const trail = buildTextWithTabs(schema, 'foo\t', undefined) as Fragment; + expect(trail.childCount).toBe(2); + expect(trail.child(0).text).toBe('foo'); + expect(trail.child(1).type.name).toBe('tab'); + }); + + it('emits consecutive tab nodes for adjacent tab characters', () => { + const schema = makeRealSchema({ hasTab: true }); + const result = buildTextWithTabs(schema, 'a\t\tb', undefined) as Fragment; + expect(result.childCount).toBe(4); + expect(result.child(0).text).toBe('a'); + expect(result.child(1).type.name).toBe('tab'); + expect(result.child(2).type.name).toBe('tab'); + expect(result.child(3).text).toBe('b'); + }); + + it('forwards marks to both the text segments and the tab node so exporter keeps formatting unbroken across the tab', () => { + const schema = makeRealSchema({ hasTab: true }); + const boldMark = schema.marks.bold.create(); + const result = buildTextWithTabs(schema, 'x\ty', [boldMark]) as Fragment; + expect(result.childCount).toBe(3); + expect(result.child(0).text).toBe('x'); + expect(result.child(0).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + expect(result.child(1).type.name).toBe('tab'); + // Tab carries the run's marks — the OOXML exporter reads node.marks on tab + // nodes (tab-translator.js:53) to emit matching around . + expect(result.child(1).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + expect(result.child(2).text).toBe('y'); + expect(result.child(2).marks.some((m: any) => m.type.name === 'bold')).toBe(true); + }); +}); + +describe('parentAllowsNodeAt', () => { + const nodeType = { name: 'tab' } as any; + + it('returns true when the mocked doc has no contentMatch (defensive fallback)', () => { + const tr = { doc: { resolve: () => ({}) } } as any; + expect(parentAllowsNodeAt(tr, 3, nodeType)).toBe(true); + }); + + it('returns true when contentMatch.matchType returns a truthy match', () => { + const matchType = vi.fn(() => ({})); + const tr = { + doc: { resolve: () => ({ parent: { type: { contentMatch: { matchType } } } }) }, + } as any; + expect(parentAllowsNodeAt(tr, 5, nodeType)).toBe(true); + expect(matchType).toHaveBeenCalledWith(nodeType); + }); + + it('returns false when contentMatch.matchType returns null', () => { + const matchType = vi.fn(() => null); + const tr = { + doc: { resolve: () => ({ parent: { type: { contentMatch: { matchType } } } }) }, + } as any; + expect(parentAllowsNodeAt(tr, 5, nodeType)).toBe(false); + }); +}); + +describe('textBetweenWithTabs', () => { + function makeDoc() { + const schema = makeRealSchema({ hasTab: true }); + const doc = schema.nodes.doc.createAndFill({}, [ + schema.nodes.paragraph.create({}, [schema.text('hello'), schema.nodes.tab.create(), schema.text('world')]), + ])!; + return { schema, doc }; + } + + it('emits \\t for tab nodes even when PM treats tab as non-leaf', () => { + const { doc } = makeDoc(); + const paragraph = doc.firstChild!; + const result = textBetweenWithTabs(doc, 1, 1 + paragraph.content.size, '\n', '\ufffc'); + expect(result).toBe('hello\tworld'); + }); + + it('slices partial text ranges correctly around tab nodes', () => { + const { doc } = makeDoc(); + // Tab has content: 'inline*' so nodeSize = 2 (open + close). + // Positions: h=1 e=2 l=3 l=4 o=5 | tab opens at 6 (closes at 7) | w=8 o=9 r=10 l=11 d=12. + // Reading [2, 10) yields "ello" + tab + "wo". + const result = textBetweenWithTabs(doc, 2, 10, '\n', '\ufffc'); + expect(result).toBe('ello\two'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts new file mode 100644 index 0000000000..56038e0bcd --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-with-tabs.ts @@ -0,0 +1,136 @@ +import { Fragment } from 'prosemirror-model'; +import type { + Fragment as ProseMirrorFragment, + Mark as ProseMirrorMark, + Node as ProseMirrorNode, + NodeType, + Schema, +} from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; + +/** + * Build a text-or-fragment suitable for insertion, splitting on '\t' and + * inserting schema `tab` nodes at each split. + * + * Returns a plain text node when the schema has no `tab` node type, when the + * parent disallows tab nodes (see {@link parentAllowsTabAt}), or when the text + * contains no tab characters. The raw '\t' is preserved inside the text node + * so exporters and readers still see the character. + * + * Callers are responsible for ensuring `text` is non-empty (ProseMirror's + * `schema.text` throws on empty input). + */ +export function buildTextWithTabs( + schema: Schema, + text: string, + marks: readonly ProseMirrorMark[] | undefined, + opts: { parentAllowsTab?: boolean } = {}, +): ProseMirrorNode | ProseMirrorFragment { + // Check the cheapest/most selective predicate first — most calls carry no '\t'. + if (!text.includes('\t')) return schema.text(text, marks); + + const tabNodeType = schema.nodes?.tab; + if (!tabNodeType || opts.parentAllowsTab === false) return schema.text(text, marks); + + const tabMarks = (marks ?? null) as ProseMirrorMark[] | null; + const parts = text.split('\t'); + const nodes: ProseMirrorNode[] = []; + for (let i = 0; i < parts.length; i++) { + if (parts[i]) nodes.push(schema.text(parts[i], marks)); + // Carry the surrounding marks onto the tab node so the OOXML exporter + // wraps `` in a matching `` — keeps formatting unbroken + // across the tab (bold-run | tab | bold-run rather than bold | plain | bold). + if (i < parts.length - 1) nodes.push(tabNodeType.create(null, null, tabMarks)); + } + return Fragment.from(nodes); +} + +/** + * Check whether the parent node at `absPos` in `tr.doc` admits a node of + * `nodeType` according to its content expression. + * + * Returns `true` when the parent cannot be probed (e.g. mocked test docs), + * preserving pre-existing behavior for call sites that lack a full PM schema. + */ +export function parentAllowsNodeAt(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; +} + +/** + * Tab-aware analogue of ProseMirror's `Node.textBetween`. + * + * The built-in `textBetween` cannot surface `tab` nodes because the tab node + * schema defines `content: 'inline*'` (on purpose, to stop PM from auto- + * inserting a separator after it during export), which means `isLeaf` is + * `false` and any `leafText` callback is never invoked for tabs. This helper + * walks the range directly, emitting a literal '\t' for every tab node so + * reads round-trip with the input text. + * + * Non-tab inline leaves fall back to `leafFallback`, matching the placeholder + * that the caller would have passed to `textBetween` (e.g. '' for plain text + * extraction, '\ufffc' for offset-preserving reads, '\n' for get-text-adapter). + */ +export function textBetweenWithTabs( + doc: ProseMirrorNode, + from: number, + to: number, + blockSeparator: string, + leafFallback: string, +): string { + // Defensive path for mocked docs: when `nodesBetween` isn't available, fall + // back to the legacy `textBetween` semantics with no tab handling. Real PM + // docs always expose `nodesBetween`, so only synthetic test shims hit this. + const anyDoc = doc as unknown as { + nodesBetween?: (from: number, to: number, cb: (node: any, pos: number) => any) => void; + textBetween?: (from: number, to: number, blockSeparator: string, leafText: string) => string; + }; + if (typeof anyDoc.nodesBetween !== 'function') { + if (typeof anyDoc.textBetween === 'function') { + return anyDoc.textBetween(from, to, blockSeparator, leafFallback); + } + return ''; + } + + let out = ''; + let emitted = false; + const seenBlocks = new Set(); + + doc.nodesBetween(from, to, (node: any, pos: number) => { + if (pos >= to) return false; + if (!node) return false; + + if (node.type?.name === 'tab') { + out += '\t'; + emitted = true; + return false; + } + if (node.isText) { + const start = Math.max(from, pos) - pos; + const end = Math.min(to, pos + node.nodeSize) - pos; + // In real PM, node.text is always a string of length nodeSize. Some tests + // mock text nodes without a `.text` property; fall back to a placeholder + // of the correct length so downstream position math still matches. + const text = typeof node.text === 'string' ? node.text : '\ufffc'.repeat(node.nodeSize); + out += text.slice(start, end); + emitted = true; + return false; + } + if (node.isLeaf) { + if (node.isInline) { + out += leafFallback; + emitted = true; + } + return false; + } + if (node.isBlock && emitted && !seenBlocks.has(pos) && pos > from) { + out += blockSeparator; + seenBlocks.add(pos); + } + return true; + }); + + return out; +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts index df395cbfcf..70b48978ea 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/caption-wrappers.ts @@ -28,8 +28,9 @@ import { paginate, resolveBlockCreatePosition } from '../helpers/adapter-utils.j import { getRevision } from './revision-tracker.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { Fragment } from 'prosemirror-model'; import { clearIndexCache } from '../helpers/index-cache.js'; -import { DocumentApiAdapterError } from '../errors.js'; +import { buildTextWithTabs } from '../helpers/text-with-tabs.js'; // --------------------------------------------------------------------------- // Result helpers @@ -140,9 +141,14 @@ export function captionsInsertWrapper( ); } - // Add separator and user text + // Add separator and user text — splits any '\t' into tab nodes so exports emit . if (input.text) { - children.push(schema.text(`: ${input.text}`)); + const captionContent = buildTextWithTabs(schema, `: ${input.text}`, undefined); + if (captionContent instanceof Fragment) { + captionContent.forEach((child) => children.push(child)); + } else { + children.push(captionContent); + } } const captionParagraph = schema.nodes.paragraph.create(buildCaptionParagraphAttrs(nodeId), children); @@ -195,7 +201,7 @@ export function captionsUpdateWrapper( const newText = input.patch.text ? `: ${input.patch.text}` : ''; if (newText) { - tr.replaceWith(trailingTextStart, contentEnd, editor.schema.text(newText)); + tr.replaceWith(trailingTextStart, contentEnd, buildTextWithTabs(editor.schema, newText, undefined)); } else { tr.delete(trailingTextStart, contentEnd); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index cd62e1ff7a..9c2c7a21c7 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -90,6 +90,7 @@ import type { import { DocumentApiAdapterError } from '../errors.js'; import { executeDomainCommand } from './plan-wrappers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; +import { buildTextWithTabs, parentAllowsNodeAt } from '../helpers/text-with-tabs.js'; import { resolveSelectionTarget } from '../helpers/selection-target-resolver.js'; // Shared helpers — single source of truth for SDT logic @@ -362,7 +363,7 @@ function replaceSdtTextContent(editor: Editor, target: ContentControlTarget, tex } const paragraph = buildEmptyBlockContent(editor, resolved.node); - const paragraphText = text.length > 0 ? editor.schema.text(text) : null; + const paragraphText = text.length > 0 ? buildTextWithTabs(editor.schema, text, undefined) : null; const updatedParagraph = paragraph?.type.create(paragraph.attrs ?? null, paragraphText, paragraph.marks) ?? null; const updatedNode = resolved.node.type.create({ ...resolved.node.attrs }, updatedParagraph, resolved.node.marks); const { tr } = editor.state; @@ -960,6 +961,22 @@ function prependContentWrapper( }); } +function insertTextAroundSdt( + editor: Editor, + target: ContentControlTarget, + content: string, + resolvePos: (resolved: ReturnType) => number, +): boolean { + const resolved = resolveSdtByTarget(editor.state.doc, target); + const pos = resolvePos(resolved); + const { tr } = editor.state; + const tabType = editor.schema.nodes?.tab; + const parentAllowsTab = tabType && content.includes('\t') ? parentAllowsNodeAt(tr, pos, tabType) : false; + tr.insert(pos, buildTextWithTabs(editor.schema, content, undefined, { parentAllowsTab })); + dispatchTransaction(editor, tr); + return true; +} + function insertBeforeWrapper( editor: Editor, input: ContentControlsInsertBeforeInput, @@ -967,15 +984,9 @@ function insertBeforeWrapper( ): ContentControlMutationResult { const sdt = resolveSdtByTarget(editor.state.doc, input.target); const target = buildTarget(sdt); - - return executeSdtMutation(editor, target, options, () => { - const resolved = resolveSdtByTarget(editor.state.doc, input.target); - const textNode = editor.schema.text(input.content); - const { tr } = editor.state; - tr.insert(resolved.pos, textNode); - dispatchTransaction(editor, tr); - return true; - }); + return executeSdtMutation(editor, target, options, () => + insertTextAroundSdt(editor, input.target, input.content, (resolved) => resolved.pos), + ); } function insertAfterWrapper( @@ -985,16 +996,9 @@ function insertAfterWrapper( ): ContentControlMutationResult { const sdt = resolveSdtByTarget(editor.state.doc, input.target); const target = buildTarget(sdt); - - return executeSdtMutation(editor, target, options, () => { - const resolved = resolveSdtByTarget(editor.state.doc, input.target); - const insertPos = resolved.pos + resolved.node.nodeSize; - const textNode = editor.schema.text(input.content); - const { tr } = editor.state; - tr.insert(insertPos, textNode); - dispatchTransaction(editor, tr); - return true; - }); + return executeSdtMutation(editor, target, options, () => + insertTextAroundSdt(editor, input.target, input.content, (resolved) => resolved.pos + resolved.node.nodeSize), + ); } // --------------------------------------------------------------------------- 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 deleted file mode 100644 index 5d26d923f0..0000000000 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-restrictive-content.integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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-tab-handling.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-tab-handling.integration.test.ts new file mode 100644 index 0000000000..a212c9f77c --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor-tab-handling.integration.test.ts @@ -0,0 +1,320 @@ +import { beforeAll, afterEach, describe, expect, it } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { executeTextInsert } from './executor.ts'; +import { writeWrapper } from './plan-wrappers.ts'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { readTextAtResolvedRange } from '../helpers/text-mutation-resolution.js'; + +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', () => { + 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); + }); +}); + +function makeEditorWithParagraph(text: string) { + return initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: {}, + content: [ + { + type: 'run', + attrs: {}, + content: [{ type: 'text', text }], + }, + ], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; +} + +describe('tab-aware insert + read round-trip', () => { + let editor: any | undefined; + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('promotes \\t to a tab node when inserted inside a paragraph and reads it back as \\t', () => { + editor = makeEditorWithParagraph('hello'); + + // Resolve the position right after "hello" but before the paragraph close. + const textNodePos = (() => { + let found: number | undefined; + editor.state.doc.descendants((node: any, pos: number) => { + if (found !== undefined) return false; + if (node.isText) { + found = pos + node.nodeSize; + return false; + } + return true; + }); + if (found === undefined) throw new Error('text node not found'); + return found; + })(); + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'step-1', + op: 'text.insert', + blockId: 'p1', + from: 0, + to: 0, + absFrom: textNodePos, + absTo: textNodePos, + text: '', + marks: [], + } as any; + const step = { + id: 'insert-tab-in-paragraph', + op: 'text.insert', + where: { by: 'ref', ref: 'ignored' }, + args: { position: 'before', content: { text: '\tworld' } }, + } as any; + + executeTextInsert(editor, tr, target, step, { map: (pos: number) => pos } as any); + editor.dispatch(tr); + + // One real tab node now exists in the doc. + const tabs = findTabNodes(editor); + expect(tabs).toHaveLength(1); + + // Reading the paragraph back via the write-adapter's reader surfaces \t, not \ufffc. + const paragraph = editor.state.doc.firstChild; + const range = { from: 1, to: paragraph.nodeSize - 1 }; + const text = readTextAtResolvedRange(editor, range as any); + expect(text).toBe('hello\tworld'); + }); + + it('carries surrounding run marks onto the tab node so the exporter wraps in matching ', () => { + editor = makeEditorWithParagraph('plain'); + + // Pick a position inside the run's text, then run executeTextInsert with a bold mark + // active so buildTextWithTabs sees it and must hand it to the tab node too. + const boldMark = editor.state.schema.marks.bold?.create(); + expect(boldMark).toBeDefined(); + + const insertPos = (() => { + let found: number | undefined; + editor.state.doc.descendants((node: any, pos: number) => { + if (found !== undefined) return false; + if (node.isText) { + found = pos + node.nodeSize; + return false; + } + return true; + }); + if (found === undefined) throw new Error('text node not found'); + return found; + })(); + + const tr = editor.state.tr; + const target = { + kind: 'range', + stepId: 'step-1', + op: 'text.insert', + blockId: 'p1', + from: 0, + to: 0, + absFrom: insertPos, + absTo: insertPos, + text: '', + marks: [], + } as any; + const step = { + id: 'insert-bold-tab', + op: 'text.insert', + where: { by: 'ref', ref: 'ignored' }, + args: { + position: 'before', + content: { text: 'left\tright' }, + style: { inline: { mode: 'set', setMarks: { bold: 'on' } } }, + }, + } as any; + + executeTextInsert(editor, tr, target, step, { map: (pos: number) => pos } as any); + editor.dispatch(tr); + + const tabs = findTabNodes(editor); + expect(tabs).toHaveLength(1); + const tabNode = tabs[0]; + // The tab node carries the bold mark so tab-translator.js emits matching run properties. + expect(tabNode.marks.some((m: any) => m.type.name === 'bold')).toBe(true); + }); +}); + +describe('writeWrapper: untargeted doc.insert (end-to-end doc-api path) (SD-2567)', () => { + let editor: any | undefined; + + beforeAll(() => { + registerBuiltInExecutors(); + }); + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('untargeted insert with \\t produces real tab nodes via writeWrapper → executeTextInsert', () => { + editor = makeEditorWithParagraph('seed'); + + // This mirrors exactly what ed.doc.insert({ value: 'left\tright' }) does for + // an untargeted text insert: document-api → writeAdapter.write → writeWrapper. + const receipt = writeWrapper(editor, { kind: 'insert', text: 'left\tright' } as any); + + expect(receipt.success).toBe(true); + const tabs = findTabNodes(editor); + expect(tabs.length).toBeGreaterThanOrEqual(1); + }); + + it('inherits paragraph-level runProperties.bold onto the inserted tab (and surrounding text)', () => { + // Paragraph has a bold default at pPr > rPr level — just like the DOCX + // fixture in the user's manual-QA repro. super-editor encodes this on the + // paragraph node rather than as PM text marks. + editor = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { runProperties: { bold: true } }, + }, + content: [], + }, + ], + }, + user: { name: 'Integration User', email: 'integration@example.com' }, + }).editor; + + writeWrapper(editor, { kind: 'insert', text: 'left\tright' } as any); + + const tabs = findTabNodes(editor); + expect(tabs.length).toBeGreaterThanOrEqual(1); + const tabNode = tabs[0]; + // The tab carries bold, so tab-translator.js emits . + expect(tabNode.marks.some((m: any) => m.type.name === 'bold')).toBe(true); + + // Both text halves should also be bold when wrapped by the run plugin. + let boldTextCount = 0; + let plainTextCount = 0; + editor.state.doc.descendants((node: any) => { + if (!node.isText) return; + if (node.marks.some((m: any) => m.type.name === 'bold')) boldTextCount++; + else plainTextCount++; + }); + expect(boldTextCount).toBeGreaterThanOrEqual(2); + expect(plainTextCount).toBe(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 51eaab6875..db2ddc843a 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 @@ -451,7 +451,7 @@ describe('executeTextInsert: setMarks tri-state directives', () => { }); // --------------------------------------------------------------------------- -// executeTextInsert: tab character → tab node conversion (SD-2567) +// executeTextInsert: tab character → tab node conversion // --------------------------------------------------------------------------- describe('executeTextInsert: tab character to tab node conversion', () => { @@ -473,11 +473,11 @@ describe('executeTextInsert: tab character to tab node conversion', () => { expect(outcome).toEqual({ changed: true }); expect(tabCreate).toHaveBeenCalledTimes(1); - // Should insert a Fragment, not a plain text node + // Inserted value should be a Fragment holding exactly one tab node. const inserted = tr.insert.mock.calls[0][1]; - expect( - Array.isArray(inserted.content) || inserted.childCount !== undefined || inserted.type?.name === 'tab' || true, - ).toBe(true); + expect(typeof inserted.childCount).toBe('number'); + expect(inserted.childCount).toBe(1); + expect(inserted.firstChild?.type?.name).toBe('tab'); // 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'); @@ -501,8 +501,13 @@ describe('executeTextInsert: tab character to tab node conversion', () => { const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any); expect(outcome).toEqual({ changed: true }); - // One tab node created + // Fragment layout: text('hello'), tab, text('world'). expect(tabCreate).toHaveBeenCalledTimes(1); + const inserted = tr.insert.mock.calls[0][1]; + expect(inserted.childCount).toBe(3); + expect(inserted.child(0)?.text).toBe('hello'); + expect(inserted.child(1)?.type?.name).toBe('tab'); + expect(inserted.child(2)?.text).toBe('world'); // 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']); 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 31a387c0e9..cbde70e2fb 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 @@ -57,6 +57,8 @@ import { Fragment, Slice } from 'prosemirror-model'; import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; import type { Mapping } from 'prosemirror-transform'; +import { buildTextWithTabs, parentAllowsNodeAt, textBetweenWithTabs } from '../helpers/text-with-tabs.js'; +import { getFormattingStateAtPos } from '../../core/helpers/getMarksFromSelection.js'; // --------------------------------------------------------------------------- // Character-offset → document-position mapping @@ -840,7 +842,8 @@ export function executeTextRewrite( // 1. Character-level prefix/suffix trim to narrow the replacement range. // This handles cases where only a few characters differ (e.g., a "(" added // before a URL, or "YoY" → "year over year") without replacing the full range. - const originalText = tr.doc.textBetween(absFrom, absTo, '', ''); + // Tab nodes render as real '\t' so diffs align with caller-supplied replacement text. + const originalText = textBetweenWithTabs(tr.doc, absFrom, absTo, '', ''); const origLen = originalText.length; const replLen = replacementText.length; @@ -895,22 +898,50 @@ export function executeTextRewrite( if (change.type === 'delete') { tr.delete(remap(change.docFrom), remap(change.docTo)); } else if (change.type === 'insert') { - const node = editor.state.schema.text(change.newText, asProseMirrorMarks(marks)); - tr.insert(remap(change.docPos), node); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + tr.insert(remap(change.docPos), content); } else { - const node = editor.state.schema.text(change.newText, asProseMirrorMarks(marks)); - tr.replaceWith(remap(change.docFrom), remap(change.docTo), node); + const content = buildTextWithTabs(editor.state.schema, change.newText, asProseMirrorMarks(marks)); + tr.replaceWith(remap(change.docFrom), remap(change.docTo), content); } } } else { // 0 or 1 word change: replace just the trimmed range. - const textNode = editor.state.schema.text(trimmedNew, asProseMirrorMarks(marks)); - tr.replaceWith(trimmedFrom, trimmedTo, textNode); + const content = buildTextWithTabs(editor.state.schema, trimmedNew, asProseMirrorMarks(marks)); + tr.replaceWith(trimmedFrom, trimmedTo, content); } return { changed: replacementText !== target.text }; } +/** + * Resolve the marks an insertion at `absPos` should inherit. + * + * Falls back to PM's `$pos.marks()` for mocked test docs. In a real editor, + * runs through `getFormattingStateAtPos` so super-editor's paragraph-level + * and run-level `runProperties` (e.g. a bold-paragraph default) flow through + * to the inserted content — otherwise inserts into a bold paragraph produce + * unmarked text/tab nodes that export without ``. + */ +function resolveInheritedMarksAt(editor: Editor, tr: Transaction, absPos: number): readonly ProseMirrorMark[] { + try { + const state = editor.state as unknown as { doc: { resolve?: unknown } }; + if (typeof state?.doc?.resolve !== 'function') { + const $pos = tr.doc.resolve(absPos); + return $pos.marks(); + } + const resolved = getFormattingStateAtPos( + editor.state as unknown as import('prosemirror-state').EditorState, + absPos, + editor as unknown as undefined, + ); + return (resolved?.resolvedMarks as ProseMirrorMark[]) ?? []; + } catch { + const $pos = tr.doc.resolve(absPos); + return $pos.marks(); + } +} + export function executeTextInsert( editor: Editor, tr: Transaction, @@ -926,18 +957,12 @@ export function executeTextInsert( let marks: readonly ProseMirrorMark[] = []; const stylePolicy = step.args.style?.inline; - if (stylePolicy) { - if (stylePolicy.mode === 'set') { - marks = buildMarksFromSetMarks(editor, stylePolicy.setMarks); - } else if (stylePolicy.mode === 'clear') { - marks = []; - } else { - const resolvedPos = tr.doc.resolve(absPos); - marks = resolvedPos.marks(); - } + if (stylePolicy?.mode === 'set') { + marks = buildMarksFromSetMarks(editor, stylePolicy.setMarks); + } else if (stylePolicy?.mode === 'clear') { + marks = []; } else { - const resolvedPos = tr.doc.resolve(absPos); - marks = resolvedPos.marks(); + marks = resolveInheritedMarksAt(editor, tr, absPos); } const structuralInsert = resolveStructuralTextInsert(tr.doc, absPos, step); @@ -971,36 +996,12 @@ export function executeTextInsert( } 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++) { - 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 { - // 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); - } + const parentAllowsTab = tabNodeType && text.includes('\t') ? parentAllowsNodeAt(tr, absPos, tabNodeType) : false; + tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab })); 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, @@ -1175,8 +1176,8 @@ export function executeSpanTextRewrite( // For single replacement block, use flat replacement into the span if (replacementBlocks.length === 1) { const marks = resolveSpanMarks(editor, target, policy, step.id); - const textNode = editor.state.schema.text(replacementBlocks[0], asProseMirrorMarks(marks)); - tr.replaceWith(absFrom, absTo, textNode); + const content = buildTextWithTabs(editor.state.schema, replacementBlocks[0], asProseMirrorMarks(marks)); + tr.replaceWith(absFrom, absTo, content); return { changed: true }; } @@ -1194,10 +1195,10 @@ export function executeSpanTextRewrite( const paragraphAttrs = resolveInheritedParagraphAttrsForReplacement(editor, target, segmentIndex); const text = replacementBlocks[i]; - const textNode = text.length > 0 ? schema.text(text, asProseMirrorMarks(marks)) : null; + const content = text.length > 0 ? buildTextWithTabs(schema, text, asProseMirrorMarks(marks)) : null; const para = - paragraphType.createAndFill(paragraphAttrs, textNode ?? undefined) ?? - paragraphType.create(paragraphAttrs, textNode ? [textNode] : undefined); + paragraphType.createAndFill(paragraphAttrs, content ?? undefined) ?? + paragraphType.create(paragraphAttrs, content ?? undefined); nodes.push(para); } @@ -1639,8 +1640,11 @@ function buildReplacementParagraphNodes( throw planError('INVALID_INPUT', 'paragraph node type not in schema', stepId); } - const wrapInlineContent = (contentNode: ProseMirrorNode | null, wrappers: InlineWrapperSpec[]): ProseMirrorNode => { - let content = contentNode; + const wrapInlineContent = ( + contentNode: ProseMirrorNode | Fragment | null, + wrappers: InlineWrapperSpec[], + ): ProseMirrorNode => { + let content: ProseMirrorNode | Fragment | null = contentNode; for (let index = wrappers.length - 1; index >= 0; index -= 1) { const wrapper = wrappers[index]; @@ -1649,7 +1653,7 @@ function buildReplacementParagraphNodes( wrapper.type.create(wrapper.attrs, content ?? undefined, wrapper.marks); } - if (!content) { + if (!content || content instanceof Fragment) { throw planError('INVALID_INPUT', 'could not build inline wrapper content', stepId); } @@ -1659,7 +1663,7 @@ function buildReplacementParagraphNodes( const defaultWrappers = leadingWrappers.length > 0 ? leadingWrappers : trailingWrappers; return replacementBlocks.map((text, index) => { - const textNode = text.length > 0 ? schema.text(text, asProseMirrorMarks(marks)) : null; + const textContent = text.length > 0 ? buildTextWithTabs(schema, text, asProseMirrorMarks(marks)) : null; const wrappers = index === 0 ? leadingWrappers.length > 0 @@ -1671,12 +1675,17 @@ function buildReplacementParagraphNodes( : defaultWrappers : defaultWrappers; const content = - textNode == null + textContent == null ? wrappers.length > 0 ? wrapInlineContent(null, wrappers) : undefined - : wrapInlineContent(textNode, wrappers); - return paragraphType.createAndFill(paragraphAttrs, content) ?? paragraphType.create(paragraphAttrs, content); + : wrappers.length > 0 + ? wrapInlineContent(textContent, wrappers) + : textContent; + return ( + paragraphType.createAndFill(paragraphAttrs, content ?? undefined) ?? + paragraphType.create(paragraphAttrs, content ?? undefined) + ); }); } @@ -1921,7 +1930,7 @@ function resolveScopedTextForAssert( if (!scope.ok) return ''; if (!scope.range) return doc.textContent; - return doc.textBetween(scope.range.start, scope.range.end, '\n', '\ufffc'); + return textBetweenWithTabs(doc, scope.range.start, scope.range.end, '\n', '\ufffc'); } function executeAssertStep( @@ -1984,7 +1993,7 @@ export function executeCreateStep( const sdBlockId = args.sdBlockId as string | undefined; const text = (args.text as string) ?? ''; - const textNode = text.length > 0 ? editor.state.schema.text(text) : null; + const textContent = text.length > 0 ? buildTextWithTabs(editor.state.schema, text, undefined) : null; let attrs: Record | undefined; if (step.op === 'create.heading') { @@ -1998,8 +2007,8 @@ export function executeCreateStep( } const node = - paragraphType.createAndFill(attrs, textNode ?? undefined) ?? - paragraphType.create(attrs, textNode ? [textNode] : undefined); + paragraphType.createAndFill(attrs, textContent ?? undefined) ?? + paragraphType.create(attrs, textContent ?? undefined); if (!node) { throw planError('INVALID_INPUT', `could not create ${step.op} node`, step.id); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts index ebd5aa5cb8..6b038a2210 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/node-materializer.ts @@ -17,6 +17,7 @@ import type { Mark as ProseMirrorMark, Node as ProseMirrorNode, Schema } from 'p import type { SDFragment, SDContentNode } from '@superdoc/document-api'; import { v4 as uuidv4 } from 'uuid'; import { DocumentApiAdapterError } from '../errors.js'; +import { buildTextWithTabs } from '../helpers/text-with-tabs.js'; // --------------------------------------------------------------------------- // Types @@ -842,16 +843,26 @@ function materializeInlineNode( // --------------------------------------------------------------------------- /** Materializes an SDM/1 run node: `{ kind: 'run', run: { text, styleRef?, props? } }` */ -function materializeRun(schema: Schema, node: any): ProseMirrorNode { +function materializeRun(schema: Schema, node: any): ProseMirrorNode | ProseMirrorNode[] { const payload = resolvePayload(node, 'run'); const marks = buildMarksFromRunPayload(schema, payload); - return schema.text(payload.text, marks.length > 0 ? marks : undefined); + return toMaterializedInlines(buildTextWithTabs(schema, payload.text, marks.length > 0 ? marks : undefined)); } /** Materializes a legacy text node: `{ type: 'text', text, bold?, italic?, ... }` */ -function materializeTextRun(schema: Schema, node: any): ProseMirrorNode { +function materializeTextRun(schema: Schema, node: any): ProseMirrorNode | ProseMirrorNode[] { const marks = buildMarksFromLegacyRun(schema, node); - return schema.text(node.text, marks.length > 0 ? marks : undefined); + return toMaterializedInlines(buildTextWithTabs(schema, node.text, marks.length > 0 ? marks : undefined)); +} + +/** Flattens a buildTextWithTabs result into the Node | Node[] shape expected by materializeInlineContent. */ +function toMaterializedInlines(content: ProseMirrorNode | Fragment): ProseMirrorNode | ProseMirrorNode[] { + if (content instanceof Fragment) { + const out: ProseMirrorNode[] = []; + content.forEach((child) => out.push(child)); + return out; + } + return content; } function materializeHyperlink( @@ -942,10 +953,10 @@ function materializeNoteRef(schema: Schema, node: any, kind: string): ProseMirro return nodeType.create(attrs); } -function materializeInlineFallback(schema: Schema, node: any): ProseMirrorNode { +function materializeInlineFallback(schema: Schema, node: any): ProseMirrorNode | ProseMirrorNode[] { // Best-effort: render as text if the node has text content const payload = node[resolveKind(node)] ?? node; - if (payload.text) return schema.text(payload.text); + if (payload.text) return toMaterializedInlines(buildTextWithTabs(schema, payload.text, undefined)); return schema.text('\ufffc'); } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts index c409aacdab..6559632baa 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/structural-write-engine/structural-write-engine.test.ts @@ -159,6 +159,29 @@ describe('executeStructuralInsert', () => { expect(new Set(result.insertedBlockIds).size).toBe(result.insertedBlockIds.length); }); + + it('splits \\t inside structural text into real tab nodes rather than raw characters', () => { + const fragment: SDFragment = { + type: 'paragraph', + content: [{ type: 'text', text: 'left\tright' }], + }; + + const result = executeStructuralInsert(editor, { content: fragment }); + expect(result.success).toBe(true); + + let tabCount = 0; + let sawText = ''; + editor.state.doc.descendants((node) => { + if (node.type.name === 'tab') tabCount++; + if (node.isText) sawText += node.text ?? ''; + }); + expect(tabCount).toBe(1); + // No raw '\t' remains inside any text node. + expect(sawText.includes('\t')).toBe(false); + // Neighbours still present. + expect(sawText).toContain('left'); + expect(sawText).toContain('right'); + }); }); // ---------------------------------------------------------------------------