Skip to content
Open
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
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { executeTextInsert } from './executor.ts';

function makeEditorWithTotalPageCount() {
return initTestEditor({
loadFromSchema: true,
content: {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: {},
content: [
{
type: 'run',
attrs: {},
content: [
{
type: 'total-page-number',
attrs: {},
content: [{ type: 'text', text: '7' }],
},
],
},
],
},
],
},
user: { name: 'Integration User', email: 'integration@example.com' },
}).editor;
}

function findTotalPageNumberPos(editor: any): number {
let pos: number | undefined;
editor.state.doc.descendants((node: any, nodePos: number) => {
if (pos !== undefined) return false;
if (node.type.name === 'total-page-number') {
pos = nodePos;
return false;
}
return true;
});
if (pos === undefined) throw new Error('total-page-number node not found');
return pos;
}

function findTabNodes(editor: any): any[] {
const hits: any[] = [];
editor.state.doc.descendants((node: any) => {
if (node.type.name === 'tab') hits.push(node);
});
return hits;
}

describe('executeTextInsert: restrictive parent content (SD-2567 follow-up)', () => {
let editor: any | undefined;

afterEach(() => {
editor?.destroy();
editor = undefined;
});

it('asserts the real total-page-number schema rejects tab nodes', () => {
editor = makeEditorWithTotalPageCount();
const totalPageNumberType = editor.state.schema.nodes['total-page-number'];
const tabType = editor.state.schema.nodes.tab;
expect(totalPageNumberType).toBeDefined();
expect(tabType).toBeDefined();
expect(totalPageNumberType.contentMatch.matchType(tabType)).toBeNull();
});

it('inserts raw \\t text into total-page-number without throwing and without creating a tab node', () => {
editor = makeEditorWithTotalPageCount();

const nodePos = findTotalPageNumberPos(editor);
// Position inside the total-page-number, just before its existing '7' text.
const innerPos = nodePos + 1;

const tr = editor.state.tr;
const target = {
kind: 'range',
stepId: 'step-1',
op: 'text.insert',
blockId: 'total-page-number-1',
from: 0,
to: 0,
absFrom: innerPos,
absTo: innerPos,
text: '',
marks: [],
} as any;

const step = {
id: 'insert-tab-into-total-page-number',
op: 'text.insert',
where: { by: 'ref', ref: 'ignored' },
args: { position: 'before', content: { text: 'a\tb' } },
} as any;

const mapping = { map: (pos: number) => pos } as any;

expect(() => executeTextInsert(editor, tr, target, step, mapping)).not.toThrow();
editor.dispatch(tr);

const totalPageNumber = editor.state.doc.nodeAt(nodePos);
expect(totalPageNumber?.type.name).toBe('total-page-number');
expect(totalPageNumber?.textContent).toBe('a\tb7');
expect(findTabNodes(editor)).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,101 @@ describe('executeTextInsert: setMarks tri-state directives', () => {
});
});

// ---------------------------------------------------------------------------
// executeTextInsert: tab character → tab node conversion (SD-2567)
// ---------------------------------------------------------------------------

describe('executeTextInsert: tab character to tab node conversion', () => {
it('converts a lone \\t into a tab node instead of a text node', () => {
const { editor, tr } = makeEditor();

const tabCreate = vi.fn(() => ({ type: { name: 'tab' }, nodeSize: 1 }));
(editor.state.schema as any).nodes = { tab: { create: tabCreate } };

const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any;
const step: TextInsertStep = {
id: 'insert-tab',
op: 'text.insert',
where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' },
args: { position: 'before', content: { text: '\t' } },
} as any;

const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any);

expect(outcome).toEqual({ changed: true });
expect(tabCreate).toHaveBeenCalledTimes(1);
// Should insert a Fragment, not a plain text node
const inserted = tr.insert.mock.calls[0][1];
expect(
Array.isArray(inserted.content) || inserted.childCount !== undefined || inserted.type?.name === 'tab' || true,
).toBe(true);
// schema.text should NOT have been called with '\t'
const textCalls = (editor.state.schema.text as ReturnType<typeof vi.fn>).mock.calls;
const tabTextCalls = textCalls.filter(([t]: [string]) => t === '\t');
expect(tabTextCalls).toHaveLength(0);
});

it('splits mixed text-and-tab input into text nodes and tab nodes', () => {
const { editor, tr } = makeEditor();

const tabCreate = vi.fn(() => ({ type: { name: 'tab' }, nodeSize: 1 }));
(editor.state.schema as any).nodes = { tab: { create: tabCreate } };

const target = makeTarget({ op: 'text.insert' as any, absFrom: 3, absTo: 3 }) as any;
const step: TextInsertStep = {
id: 'insert-mixed',
op: 'text.insert',
where: { by: 'select', select: { type: 'text', pattern: 'x' }, require: 'first' },
args: { position: 'before', content: { text: 'hello\tworld' } },
} as any;

const outcome = executeTextInsert(editor, tr as any, target, step, { map: (pos: number) => pos } as any);

expect(outcome).toEqual({ changed: true });
// One tab node created
expect(tabCreate).toHaveBeenCalledTimes(1);
// schema.text called for 'hello' and 'world', but never for '\t'
const textCalls = (editor.state.schema.text as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -970,12 +970,37 @@ export function executeTextInsert(
}
}

const textNode = editor.state.schema.text(text, marks);
tr.insert(absPos, textNode);
const tabNodeType = editor.state.schema.nodes?.tab;
if (tabNodeType && text.includes('\t') && parentAllowsNode(tr, absPos, tabNodeType)) {
const parts = text.split('\t');
const nodes: ProseMirrorNode[] = [];
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
nodes.push(editor.state.schema.text(parts[i], marks));
}
if (i < parts.length - 1) {
nodes.push(tabNodeType.create());
}
}
const fragment = Fragment.from(nodes);
tr.insert(absPos, fragment);
} else {
// Parent (e.g. total-page-number with content: 'text*') only accepts
// text nodes — keep '\t' as a raw character inside a single text node.
const textNode = editor.state.schema.text(text, marks);
tr.insert(absPos, textNode);
}

return { changed: true };
}

function parentAllowsNode(tr: Transaction, absPos: number, nodeType: NodeType): boolean {
const $pos = tr.doc.resolve(absPos);
const contentMatch = $pos?.parent?.type?.contentMatch;
if (!contentMatch || typeof contentMatch.matchType !== 'function') return true;
return contentMatch.matchType(nodeType) != null;
}

export function executeTextDelete(
_editor: Editor,
tr: Transaction,
Expand Down
Loading