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');
+ });
});
// ---------------------------------------------------------------------------