Skip to content
Draft
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,50 @@
import { Fragment } from 'prosemirror-model';

/**
* Materialize plain text into inline ProseMirror content, upgrading literal tab
* characters to real tab nodes when the schema supports them.
*
* @param {import('prosemirror-model').Schema} schema
* @param {string} text
* @param {import('prosemirror-model').Mark[]} [marks]
* @returns {{
* content: import('prosemirror-model').Node | import('prosemirror-model').Fragment,
* nodes: import('prosemirror-model').Node[],
* size: number,
* }}
*/
export function buildInlineContentFromText(schema, text, marks = []) {
const normalizedMarks = Array.isArray(marks) && marks.length > 0 ? marks : undefined;
const tabType = schema?.nodes?.tab;

if (!text.includes('\t') || !tabType) {
const textNode = schema.text(text, normalizedMarks);
return {
content: textNode,
nodes: [textNode],
size: textNode.nodeSize,
};
}

const nodes = [];
const parts = text.split('\t');

parts.forEach((part, index) => {
if (part.length > 0) {
nodes.push(schema.text(part, normalizedMarks));
}

if (index < parts.length - 1) {
nodes.push(tabType.create(null, undefined, normalizedMarks));
}
});

const content = nodes.length === 1 ? nodes[0] : Fragment.fromArray(nodes);
const size = nodes.reduce((sum, node) => sum + node.nodeSize, 0);

return {
content,
nodes,
size,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { computeTextContentLength, resolveTextRangeInBlock } from './text-offset
import { buildTextMutationResolution, readTextAtResolvedRange } from './text-mutation-resolution.js';
import type { Transaction } from 'prosemirror-state';
import type { Editor } from '../../core/Editor.js';
import { buildInlineContentFromText } from '../../core/helpers/buildInlineContentFromText.js';
import { DocumentApiAdapterError } from '../errors.js';

export type WithinResult = { ok: true; range: { start: number; end: number } | undefined } | { ok: false };
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 inlineContent = buildInlineContentFromText(schema, text);
const paragraph = schema.nodes.paragraph.create(null, inlineContent.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
Expand Up @@ -50,6 +50,7 @@ import { captureRunsInRange, resolveInlineStyle } from './style-resolver.js';
import { TOGGLE_MARK_SPECS } from './mark-directives.js';
import { mapBlockNodeType } from '../helpers/node-address-resolver.js';
import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js';
import { buildInlineContentFromText } from '../../core/helpers/buildInlineContentFromText.js';
import { normalizeReplacementText } from './replacement-normalizer.js';
import { Fragment, Slice } from 'prosemirror-model';
import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model';
Expand Down Expand Up @@ -746,9 +747,12 @@ export function executeTextRewrite(

const replacementText = getReplacementText(step.args.replacement);
const marks = resolveMarksForRange(editor, target, step);

const textNode = editor.state.schema.text(replacementText, asProseMirrorMarks(marks));
tr.replaceWith(absFrom, absTo, textNode);
const replacementContent = buildInlineContentFromText(
editor.state.schema,
replacementText,
asProseMirrorMarks(marks) as ProseMirrorMark[],
);
tr.replaceWith(absFrom, absTo, replacementContent.content);

return { changed: replacementText !== target.text };
}
Expand Down Expand Up @@ -782,8 +786,8 @@ export function executeTextInsert(
marks = resolvedPos.marks();
}

const textNode = editor.state.schema.text(text, marks);
tr.insert(absPos, textNode);
const inlineContent = buildInlineContentFromText(editor.state.schema, text, marks as ProseMirrorMark[]);
tr.insert(absPos, inlineContent.content);

return { changed: true };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options:
child(index: number) {
return children[index]!;
},
sameMarkup(other: ProseMirrorNode) {
return other?.type?.name === typeName;
},
descendants(callback: (node: ProseMirrorNode, pos: number) => void) {
let offset = 0;
for (const child of children) {
Expand All @@ -59,6 +62,7 @@ function makeEditor(text = 'Hello'): {
textBetween: ReturnType<typeof vi.fn>;
tr: {
insertText: ReturnType<typeof vi.fn>;
replaceWith: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
setMeta: ReturnType<typeof vi.fn>;
addMark: ReturnType<typeof vi.fn>;
Expand All @@ -74,11 +78,13 @@ function makeEditor(text = 'Hello'): {

const tr = {
insertText: vi.fn(),
replaceWith: vi.fn(),
delete: vi.fn(),
setMeta: vi.fn(),
addMark: vi.fn(),
};
tr.insertText.mockReturnValue(tr);
tr.replaceWith.mockReturnValue(tr);
tr.delete.mockReturnValue(tr);
tr.setMeta.mockReturnValue(tr);
tr.addMark.mockReturnValue(tr);
Expand All @@ -96,8 +102,19 @@ function makeEditor(text = 'Hello'): {
doc: {
...doc,
textBetween,
resolve: vi.fn(() => ({ marks: () => [] })),
},
tr,
schema: {
text: vi.fn((value: string, marks?: unknown[]) => createNode('text', [], { text: value, attrs: { marks } })),
nodes: {
tab: {
create: vi.fn((_attrs?: unknown, _content?: unknown, marks?: unknown[]) =>
createNode('tab', [], { attrs: { marks }, isInline: true, isLeaf: true, nodeSize: 1 }),
),
},
},
},
},
commands: {
insertTrackedChange,
Expand Down Expand Up @@ -487,6 +504,29 @@ describe('writeAdapter', () => {
expect(dispatch).toHaveBeenCalledTimes(1);
});

it('materializes tab characters as structured inline content for direct writes', () => {
const { editor, dispatch, tr } = makeEditor('Hello');

const receipt = writeAdapter(
editor,
{
kind: 'replace',
target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } },
text: 'A\tB',
},
{ changeMode: 'direct' },
);

expect(receipt.success).toBe(true);
expect(tr.insertText).not.toHaveBeenCalled();
expect(tr.replaceWith).toHaveBeenCalledTimes(1);
const replacement = tr.replaceWith.mock.calls[0]?.[2];
expect(replacement?.content?.[0]?.type?.name).toBe('text');
expect(replacement?.content?.[1]?.type?.name).toBe('tab');
expect(replacement?.content?.[2]?.type?.name).toBe('text');
expect(dispatch).toHaveBeenCalledTimes(1);
});

it('sets skipTrackChanges metadata for direct writes to preserve direct mutation semantics', () => {
const { editor, tr } = makeEditor('Hello');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import type { Editor } from '../core/Editor.js';
import type { MutationOptions, ReceiptFailure, TextAddress, TextMutationReceipt } from '@superdoc/document-api';
import { buildInlineContentFromText } from '../core/helpers/buildInlineContentFromText.js';
import { DocumentApiAdapterError } from './errors.js';
import { ensureTrackedCapability } from './helpers/mutation-helpers.js';
import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js';
Expand Down Expand Up @@ -218,8 +219,19 @@ function applyDirectWrite(
}

// text is guaranteed non-empty for insert/replace after validateWriteRequest
const insertionText = request.text ?? '';
if (insertionText.includes('\t')) {
const marks = editor.state.doc.resolve(resolvedTarget.range.from).marks();
const inlineContent = buildInlineContentFromText(editor.state.schema, insertionText, marks);
const tr = applyDirectMutationMeta(
editor.state.tr.replaceWith(resolvedTarget.range.from, resolvedTarget.range.to, inlineContent.content),
);
editor.dispatch(tr);
return { success: true, resolution: resolvedTarget.resolution };
}

const tr = applyDirectMutationMeta(
editor.state.tr.insertText(request.text ?? '', resolvedTarget.range.from, resolvedTarget.range.to),
editor.state.tr.insertText(insertionText, resolvedTarget.range.from, resolvedTarget.range.to),
);
editor.dispatch(tr);
return { success: true, resolution: resolvedTarget.resolution };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/* @vitest-environment jsdom */

import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
import DocxZipper from '@core/DocxZipper.js';
import type { Editor } from '../core/Editor.js';
import { writeAdapter } from './write-adapter.js';

type LoadedDocData = Awaited<ReturnType<typeof loadTestDataForEditorTests>>;

function mapExportedFiles(files: Array<{ name: string; content: string }>): Record<string, string> {
const byName: Record<string, string> = {};
for (const file of files) {
byName[file.name] = file.content;
}
return byName;
}

async function exportDocxFiles(editor: Editor): Promise<Record<string, string>> {
const zipper = new DocxZipper();
const exportedBuffer = await editor.exportDocx();
const exportedFiles = await zipper.getDocxData(exportedBuffer, true);
return mapExportedFiles(exportedFiles);
}

describe('doc API tab export integration', () => {
let docData: LoadedDocData;
let editor: Editor | undefined;

beforeAll(async () => {
docData = await loadTestDataForEditorTests('blank-doc.docx');
});

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

it('exports literal tab insertion as w:tab instead of a raw tab character in w:t', async () => {
({ editor } = initTestEditor({
content: docData.docx,
media: docData.media,
mediaFiles: docData.mediaFiles,
fonts: docData.fonts,
user: { email: 'reviewer@example.com', name: 'Reviewer' },
useImmediateSetTimeout: false,
}));

const insertResult = await Promise.resolve(
editor.doc.insert({
value: 'Left\tRight',
}),
);

expect(insertResult.success).toBe(true);

const exportedFiles = await exportDocxFiles(editor);
const documentXml = exportedFiles['word/document.xml'];

expect(documentXml).toMatch(/<w:tab\s*\/>/);
expect(documentXml).not.toMatch(/<w:t[^>]*>[^<]*\t[^<]*<\/w:t>/);
});

it('exports tracked adapter tab insertion as tracked w:tab content instead of a raw tab character in w:t', async () => {
({ editor } = initTestEditor({
content: docData.docx,
media: docData.media,
mediaFiles: docData.mediaFiles,
fonts: docData.fonts,
user: { email: 'reviewer@example.com', name: 'Reviewer' },
useImmediateSetTimeout: false,
}));

const insertResult = await Promise.resolve(
writeAdapter(
editor,
{
kind: 'insert',
text: 'Left\tRight',
},
{ changeMode: 'tracked' },
),
);

expect(insertResult.success).toBe(true);

const exportedFiles = await exportDocxFiles(editor);
const documentXml = exportedFiles['word/document.xml'];

expect(documentXml).toMatch(/<w:ins\b[\s\S]*<w:tab\s*\/>[\s\S]*<\/w:ins>/);
expect(documentXml).not.toMatch(/<w:t[^>]*>[^<]*\t[^<]*<\/w:t>/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1865,6 +1865,42 @@ describe('TrackChanges extension commands', () => {
expect(hasInsertMark).toBe(true);
});

it('materializes tab characters as tab nodes for tracked insertions', () => {
const doc = createDoc('Hello');
const state = createState(doc);

let nextState;
const dispatch = vi.fn((tr) => {
nextState = state.apply(tr);
});

const result = commands.insertTrackedChange({
from: 6,
to: 6,
text: '\tworld',
})({
state,
dispatch,
editor: {
options: { user: { name: 'Test', email: 'test@example.com' } },
commands: { addCommentReply: vi.fn() },
},
});

expect(result).toBe(true);
expect(nextState.doc.textContent).toBe('Helloworld');

let tabCount = 0;
let hasInsertMark = false;
nextState.doc.descendants((node) => {
if (node.type.name === 'tab') tabCount += 1;
if (node.marks.some((m) => m.type.name === TrackInsertMarkName)) hasInsertMark = true;
});

expect(tabCount).toBe(1);
expect(hasInsertMark).toBe(true);
});

it('replacement marks share the same ID for proper comment linking', () => {
const doc = createDoc('Hello world');
const state = createState(doc);
Expand Down
Loading