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-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 26ac837a9e..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 @@ -450,6 +450,106 @@ describe('executeTextInsert: setMarks tri-state directives', () => { }); }); +// --------------------------------------------------------------------------- +// executeTextInsert: tab character → tab node conversion +// --------------------------------------------------------------------------- + +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); + // Inserted value should be a Fragment holding exactly one tab node. + const inserted = tr.insert.mock.calls[0][1]; + 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'); + 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 }); + // 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']); + }); + + 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); + }); +}); + // --------------------------------------------------------------------------- // 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..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); @@ -970,8 +995,9 @@ export function executeTextInsert( } } - const textNode = editor.state.schema.text(text, marks); - tr.insert(absPos, textNode); + const tabNodeType = editor.state.schema.nodes?.tab; + const parentAllowsTab = tabNodeType && text.includes('\t') ? parentAllowsNodeAt(tr, absPos, tabNodeType) : false; + tr.insert(absPos, buildTextWithTabs(editor.state.schema, text, marks, { parentAllowsTab })); return { changed: true }; } @@ -1150,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 }; } @@ -1169,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); } @@ -1614,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]; @@ -1624,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); } @@ -1634,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 @@ -1646,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) + ); }); } @@ -1896,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( @@ -1959,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') { @@ -1973,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'); + }); }); // ---------------------------------------------------------------------------