diff --git a/packages/super-editor/src/editors/v1/core/helpers/buildInlineContentFromText.js b/packages/super-editor/src/editors/v1/core/helpers/buildInlineContentFromText.js new file mode 100644 index 0000000000..727238ede5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/buildInlineContentFromText.js @@ -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, + }; +} 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..6755ebef24 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 @@ -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 }; @@ -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); 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 696e17eb4e..23206afcd1 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 @@ -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'; @@ -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 }; } @@ -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 }; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts index c72c4b2362..1be89b707a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.test.ts @@ -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) { @@ -59,6 +62,7 @@ function makeEditor(text = 'Hello'): { textBetween: ReturnType; tr: { insertText: ReturnType; + replaceWith: ReturnType; delete: ReturnType; setMeta: ReturnType; addMark: ReturnType; @@ -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); @@ -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, @@ -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'); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts index 264007aa17..6d919c168a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-adapter.ts @@ -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'; @@ -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 }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/write-tabs-export.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/write-tabs-export.integration.test.ts new file mode 100644 index 0000000000..00ddb1efb5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/write-tabs-export.integration.test.ts @@ -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>; + +function mapExportedFiles(files: Array<{ name: string; content: string }>): Record { + const byName: Record = {}; + for (const file of files) { + byName[file.name] = file.content; + } + return byName; +} + +async function exportDocxFiles(editor: Editor): Promise> { + 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(//); + expect(documentXml).not.toMatch(/]*>[^<]*\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(/[\s\S]*<\/w:ins>/); + expect(documentXml).not.toMatch(/]*>[^<]*\t[^<]*<\/w:t>/); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 96498b49f9..b10911c7b3 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -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); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js index 11d0b9636f..bea5496d00 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes.js @@ -10,6 +10,7 @@ import { markInsertion } from './trackChangesHelpers/markInsertion.js'; import { collectTrackedChanges, isTrackedChangeActionAllowed } from './permission-helpers.js'; import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from '../comment/comments-plugin.js'; import { findMarkInRangeBySnapshot } from './trackChangesHelpers/markSnapshotHelpers.js'; +import { buildInlineContentFromText } from '../../core/helpers/buildInlineContentFromText.js'; import { hasExpandedSelection } from '@utils/selectionUtils.js'; export const TrackChanges = Extension.create({ @@ -344,14 +345,17 @@ export const TrackChanges = Extension.create({ // Step 2: Insert the new text after the deleted content let insertedMark = null; - let insertedNode = null; + let insertedNodes = []; + let insertedSize = 0; if (text) { - insertedNode = state.schema.text(text, marks); - tr.insert(insertPos, insertedNode); + const inlineContent = buildInlineContentFromText(state.schema, text, marks); + insertedNodes = inlineContent.nodes; + insertedSize = inlineContent.size; + tr.insert(insertPos, inlineContent.content); // Step 3: Mark the insertion const insertedFrom = insertPos; - const insertedTo = insertPos + insertedNode.nodeSize; + const insertedTo = insertPos + insertedSize; insertedMark = markInsertion({ tr, from: insertedFrom, @@ -368,11 +372,12 @@ export const TrackChanges = Extension.create({ // Store metadata for external consumers (pass full mark objects for comments plugin) // Create a mock step with slice for the comments plugin to extract nodes - const mockStep = insertedNode - ? { - slice: { content: { content: [insertedNode] } }, - } - : null; + const mockStep = + insertedNodes.length > 0 + ? { + slice: { content: { content: insertedNodes } }, + } + : null; tr.setMeta(TrackChangesBasePluginKey, { insertedMark: insertedMark || null,