Skip to content

Commit bc6e5bf

Browse files
feat(executor): add tab character to tab node conversion in text insert (#2832)
* feat(executor): add tab character to tab node conversion in text insert * feat(executor): enhance text insertion to handle restrictive parent content for tab nodes - Introduced a new integration test for the `executeTextInsert` function to validate behavior when inserting text into nodes that disallow tab nodes, specifically for the `total-page-number` type. - Updated the `executeTextInsert` function to check if the parent node allows tab nodes before creating them, ensuring that raw tab characters are preserved as text when necessary. - Added a utility function `parentAllowsNode` to determine if a node type can be inserted based on the parent's content match. * feat(tabs): implement tab handling in text insertion and document extraction - Introduced `buildTextWithTabs` and `textBetweenWithTabs` functions to manage tab characters, converting them to tab nodes during text insertion and ensuring they are correctly represented in document extraction. - Updated existing commands (`insertHeadingAt`, `insertListItemAt`, `insertParagraphAt`) to utilize the new tab handling functions, enhancing text formatting capabilities. - Modified tests to validate the new tab handling behavior, ensuring that tab characters are preserved and correctly processed in various scenarios. - Added comprehensive tests for the new helper functions to ensure robust functionality across different document structures. This update enhances the editor's ability to handle tab characters, improving the overall user experience when working with formatted text.
1 parent bbb7e94 commit bc6e5bf

19 files changed

Lines changed: 882 additions & 84 deletions

packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
2+
13
/**
24
* Insert a heading node at an absolute document position.
35
*
@@ -25,13 +27,13 @@ export const insertHeadingAt =
2527
},
2628
};
2729
const normalizedText = typeof text === 'string' ? text : '';
28-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null;
30+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>.
31+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null;
2932

3033
let paragraphNode;
3134
try {
3235
paragraphNode =
33-
paragraphType.createAndFill(attrs, textNode ?? undefined) ??
34-
paragraphType.create(attrs, textNode ? [textNode] : undefined);
36+
paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined);
3537
} catch {
3638
return false;
3739
}

packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('insertHeadingAt', () => {
145145

146146
insertHeadingAt({ pos: 0, level: 1, text: 'Hello' })({ state, dispatch });
147147

148-
expect(state.schema.text).toHaveBeenCalledWith('Hello');
148+
expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined);
149149
});
150150

151151
// --- tracked mode ---

packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js';
2+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
23

34
/**
45
* Insert a list-item paragraph before/after a target list paragraph position.
@@ -42,12 +43,12 @@ export const insertListItemAt =
4243
};
4344

4445
const normalizedText = typeof text === 'string' ? text : '';
45-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : undefined;
46+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>.
47+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : undefined;
4648

4749
let paragraphNode;
4850
try {
49-
paragraphNode =
50-
paragraphType.createAndFill(attrs, textNode) ?? paragraphType.create(attrs, textNode ? [textNode] : undefined);
51+
paragraphNode = paragraphType.createAndFill(attrs, content) ?? paragraphType.create(attrs, content ?? undefined);
5152
} catch {
5253
return false;
5354
}

packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ describe('insertListItemAt', () => {
175175
dispatch,
176176
});
177177

178-
expect(state.schema.text).toHaveBeenCalledWith('New item');
178+
expect(state.schema.text).toHaveBeenCalledWith('New item', undefined);
179179
});
180180

181181
it('does not inherit sdBlockId from the target node when omitted', () => {

packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
2+
13
/**
24
* Insert a paragraph node at an absolute document position.
35
*
@@ -16,13 +18,14 @@ export const insertParagraphAt =
1618
...(paraId ? { paraId } : undefined),
1719
};
1820
const normalizedText = typeof text === 'string' ? text : '';
19-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null;
21+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>
22+
// instead of a raw tab character inside <w:t>.
23+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null;
2024

2125
let paragraphNode;
2226
try {
2327
paragraphNode =
24-
paragraphType.createAndFill(attrs, textNode ?? undefined) ??
25-
paragraphType.create(attrs, textNode ? [textNode] : undefined);
28+
paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined);
2629
} catch {
2730
return false;
2831
}

packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('insertParagraphAt', () => {
100100

101101
insertParagraphAt({ pos: 0, text: 'Hello' })({ state, dispatch });
102102

103-
expect(state.schema.text).toHaveBeenCalledWith('Hello');
103+
expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined);
104104
});
105105

106106
it('sets forceTrackChanges meta when tracked is true', () => {
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import type { Editor } from '../core/Editor.js';
22
import type { GetTextInput } from '@superdoc/document-api';
33
import { resolveStoryRuntime } from './story-runtime/resolve-story-runtime.js';
4+
import { textBetweenWithTabs } from './helpers/text-with-tabs.js';
45

56
/**
67
* Return the full document text content from the ProseMirror document.
78
*
9+
* Tab nodes are rendered as real '\t' so the extracted text round-trips with
10+
* what the write APIs accept. Other inline leaves fall back to '\n' (matching
11+
* the legacy behavior for non-text nodes).
12+
*
813
* @param editor - The editor instance.
914
* @returns Plain text content of the document.
1015
*/
1116
export function getTextAdapter(editor: Editor, input: GetTextInput): string {
1217
const runtime = resolveStoryRuntime(editor, input.in);
1318
const doc = runtime.editor.state.doc;
14-
return doc.textBetween(0, doc.content.size, '\n', '\n');
19+
return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n');
1520
}

packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mut
2121
import type { Transaction } from 'prosemirror-state';
2222
import type { Editor } from '../../core/Editor.js';
2323
import { DocumentApiAdapterError } from '../errors.js';
24+
import { buildTextWithTabs } from './text-with-tabs.js';
2425

2526
export type WithinResult = { ok: true; range: { start: number; end: number } | undefined } | { ok: false };
2627
export type ResolvedTextTarget = { from: number; to: number };
@@ -228,8 +229,8 @@ export function insertParagraphAtEnd(
228229
applyMeta?: (tr: Transaction) => Transaction,
229230
): void {
230231
const schema = editor.state.schema;
231-
const textNode = schema.text(text);
232-
const paragraph = schema.nodes.paragraph.create(null, textNode);
232+
const content = buildTextWithTabs(schema, text, undefined);
233+
const paragraph = schema.nodes.paragraph.create(null, content);
233234
const tr = editor.state.tr;
234235
tr.insert(pos, paragraph);
235236
if (applyMeta) applyMeta(tr);

packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { describe, expect, it, vi } from 'vitest';
12
import type { TextAddress } from '@superdoc/document-api';
23
import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mutation-resolution.js';
34
import type { Editor } from '../../core/Editor.js';
45

56
function makeEditor(text: string): Editor {
7+
// textBetweenWithTabs uses nodesBetween in real PM docs and falls back to
8+
// textBetween for mocked docs that don't provide nodesBetween.
69
return {
710
state: {
811
doc: {
@@ -13,7 +16,7 @@ function makeEditor(text: string): Editor {
1316
}
1417

1518
describe('readTextAtResolvedRange', () => {
16-
it('delegates to textBetween with canonical separators', () => {
19+
it('reads text between resolved positions with canonical separators', () => {
1720
const editor = makeEditor('Hello');
1821
const result = readTextAtResolvedRange(editor, { from: 1, to: 6 });
1922

packages/super-editor/src/editors/v1/document-api-adapters/helpers/text-mutation-resolution.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TextAddress, TextMutationResolution } from '@superdoc/document-api';
22
import type { Editor } from '../../core/Editor.js';
33
import type { ResolvedTextTarget } from './adapter-utils.js';
4+
import { textBetweenWithTabs } from './text-with-tabs.js';
45

56
/** Unicode Object Replacement Character — used as placeholder for leaf inline nodes in textBetween(). */
67
const OBJECT_REPLACEMENT_CHAR = '\ufffc';
@@ -9,14 +10,16 @@ const OBJECT_REPLACEMENT_CHAR = '\ufffc';
910
* Reads the canonical flattened text between two resolved document positions.
1011
*
1112
* Uses `\n` as the block separator and `\ufffc` (Object Replacement Character) as the
12-
* leaf-inline placeholder, matching the offset model used by `TextAddress`.
13+
* leaf-inline placeholder, matching the offset model used by `TextAddress`. Tab
14+
* nodes round-trip to a real '\t' so no-op comparisons against caller-supplied
15+
* text (e.g. `write-adapter`'s replace NO_OP check) continue to match.
1316
*
1417
* @param editor - The editor instance to read from.
1518
* @param range - Resolved absolute document positions.
1619
* @returns The text content between the resolved positions.
1720
*/
1821
export function readTextAtResolvedRange(editor: Editor, range: ResolvedTextTarget): string {
19-
return editor.state.doc.textBetween(range.from, range.to, '\n', OBJECT_REPLACEMENT_CHAR);
22+
return textBetweenWithTabs(editor.state.doc, range.from, range.to, '\n', OBJECT_REPLACEMENT_CHAR);
2023
}
2124

2225
/**

0 commit comments

Comments
 (0)