Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';

/**
* Insert a heading node at an absolute document position.
*
Expand Down Expand Up @@ -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 <w:tab/>.
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 <w:tab/>.
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';

/**
* Insert a paragraph node at an absolute document position.
*
Expand All @@ -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 <w:tab/>
// instead of a raw tab character inside <w:t>.
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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 });

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
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 <w:rPr> around <w:tab/>.
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');
});
});
Loading
Loading