diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index a550cffc92..6188b378b1 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -344,6 +344,31 @@ function generateParagraphProperties(node) { pPrElements.push(sectPr); } + // Add tab stops + const { tabStops } = attrs; + if (tabStops && tabStops.length > 0) { + const tabElements = tabStops.map((tab) => { + const tabAttributes = { + 'w:val': tab.val || 'start', + 'w:pos': pixelsToTwips(tab.pos).toString(), + }; + + if (tab.leader) { + tabAttributes['w:leader'] = tab.leader; + } + + return { + name: 'w:tab', + attributes: tabAttributes, + }; + }); + + pPrElements.push({ + name: 'w:tabs', + elements: tabElements, + }); + } + const numPr = node.attrs?.paragraphProperties?.elements?.find((n) => n.name === 'w:numPr'); const hasNumPr = pPrElements.some((n) => n.name === 'w:numPr'); if (numPr && !hasNumPr) pPrElements.push(numPr); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index bdb09dea75..813bb31a82 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -131,6 +131,37 @@ export const handleParagraphNode = (params) => { schemaNode.attrs['filename'] = filename; + // Parse tab stops + const tabs = pPr?.elements?.find((el) => el.name === 'w:tabs'); + if (tabs && tabs.elements) { + const tabStops = tabs.elements + .filter((el) => el.name === 'w:tab') + .map((tab) => { + let val = tab.attributes['w:val'] || 'start'; + // Test files continue to contain "left" and "right" rather than "start" and "end" + if (val == 'left') { + val = 'start'; + } else if (val == 'right') { + val = 'end'; + } + const tabStop = { + val, + pos: twipsToPixels(tab.attributes['w:pos']), + }; + + // Add leader if present + if (tab.attributes['w:leader']) { + tabStop.leader = tab.attributes['w:leader']; + } + + return tabStop; + }); + + if (tabStops.length > 0) { + schemaNode.attrs.tabStops = tabStops; + } + } + // Normalize text nodes. if (schemaNode && schemaNode.content) { schemaNode = { diff --git a/packages/super-editor/src/extensions/heading/heading.js b/packages/super-editor/src/extensions/heading/heading.js index 59368d2aae..ed6e42e1f4 100644 --- a/packages/super-editor/src/extensions/heading/heading.js +++ b/packages/super-editor/src/extensions/heading/heading.js @@ -24,6 +24,7 @@ export const Heading = Node.create({ default: 1, rendered: false, }, + tabStops: { rendered: false }, }; }, diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index ea1932ab81..f0328555fa 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -110,6 +110,7 @@ export const Paragraph = Node.create({ return { style }; }, }, + tabStops: { rendered: false }, }; }, diff --git a/packages/super-editor/src/extensions/tab/tab.js b/packages/super-editor/src/extensions/tab/tab.js index 6aa34a5ffc..92e2b36118 100644 --- a/packages/super-editor/src/extensions/tab/tab.js +++ b/packages/super-editor/src/extensions/tab/tab.js @@ -1,6 +1,8 @@ import { Node, Attribute } from '@core/index.js'; import { Plugin, PluginKey } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; +import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform'; +import { DOMSerializer } from 'prosemirror-model'; export const TabNode = Node.create({ name: 'tab', @@ -44,25 +46,75 @@ export const TabNode = Node.create({ }, addPmPlugins() { - const { view } = this.editor; + const { view, schema } = this.editor; + const domSerializer = DOMSerializer.fromSchema(schema); + const tabPlugin = new Plugin({ name: 'tabPlugin', key: new PluginKey('tabPlugin'), state: { - init(_, state) { - let decorations = getTabDecorations(state, view); - return DecorationSet.create(state.doc, decorations); + init() { + return { decorations: false }; }, + apply(tr, { decorations }, _oldState, newState) { + if (!decorations) { + decorations = DecorationSet.create( + newState.doc, + getTabDecorations(newState.doc, StepMap.empty, view, domSerializer), + ); + } - apply(tr, oldDecorationSet, oldState, newState) { - if (!tr.docChanged) return oldDecorationSet; - const decorations = getTabDecorations(newState, view); - return DecorationSet.create(newState.doc, decorations); + if (!tr.docChanged) { + return { decorations }; + } + decorations = decorations.map(tr.mapping, tr.doc); + + let rangesToRecalculate = []; + tr.steps.forEach((step, index) => { + const stepMap = step.getMap(); + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + const $from = tr.docs[index].resolve(step.from); + const $to = tr.docs[index].resolve(step.to); + const start = $from.start(Math.min($from.depth, 1)); // start of node at level 1 + const end = $to.end(Math.min($to.depth, 1)); // end of node at level 1 + let addRange = false; + tr.docs[index].nodesBetween(start, end, (node) => { + if (node.type.name === 'tab') { + // Node contains or contained a tab + addRange = true; + } + }); + if (!addRange && step.slice?.content) { + step.slice.content.descendants((node) => { + if (node.type.name === 'tab') { + // A tab was added. + addRange = true; + } + }); + } + if (addRange) { + rangesToRecalculate.push([start, end]); + } + } + rangesToRecalculate = rangesToRecalculate.map(([from, to]) => { + const mappedFrom = stepMap.map(from, -1); + const mappedTo = stepMap.map(to, 1); + return [mappedFrom, mappedTo]; + }); + }); + rangesToRecalculate.forEach(([start, end]) => { + const oldDecorations = decorations.find(start, end); + decorations = decorations.remove(oldDecorations); + const invertMapping = tr.mapping.invert(); + const newDecorations = getTabDecorations(newState.doc, invertMapping, view, domSerializer, start, end); + decorations = decorations.add(newState.doc, newDecorations); + }); + return { decorations }; }, }, props: { decorations(state) { - return this.getState(state); + return this.getState(state).decorations; }, }, }); @@ -70,47 +122,174 @@ export const TabNode = Node.create({ }, }); -const tabWidthPx = 48; +const defaultTabDistance = 48; +const defaultLineLength = 816; -const getTabDecorations = (state, view) => { +const getTabDecorations = (doc, invertMapping, view, domSerializer, from = 0, to = null) => { + // TODO: Render "bar". + if (!to) { + to = doc.content.size; + } + const nodeWidthCache = {}; let decorations = []; - state.doc.descendants((node, pos) => { + doc.nodesBetween(from, to, (node, pos, parent) => { if (node.type.name === 'tab') { - let $pos = state.doc.resolve(pos); - const prevNodeSize = $pos.nodeBefore?.nodeSize || 0; - - let textWidth = 0; - - try { - state.doc.nodesBetween(pos - prevNodeSize - 1, pos - 1, (node, nodePos) => { - if (node.isText && node.textContent !== ' ') { - const textWidthForNode = calcTextWidth(view, nodePos); - textWidth += textWidthForNode; + let extraStyles = ''; + const $pos = doc.resolve(pos); + const tabIndex = $pos.index($pos.depth); + const fistlineIndent = parent.attrs?.indent?.firstLine || 0; + const currentWidth = + calcChildNodesWidth( + parent, + pos - $pos.parentOffset, + 0, + tabIndex, + domSerializer, + view, + invertMapping, + nodeWidthCache, + ) + fistlineIndent; + let tabWidth; + if ($pos.depth === 1 && parent.attrs.tabStops && parent.attrs.tabStops.length > 0) { + const tabStop = parent.attrs.tabStops.find((tabStop) => tabStop.pos > currentWidth && tabStop.val !== 'clear'); + if (tabStop) { + tabWidth = tabStop.pos - currentWidth; + if (['end', 'center'].includes(tabStop.val)) { + let nextTabIndex = tabIndex + 1; + while (nextTabIndex < parent.childCount && parent.child(nextTabIndex).type.name !== 'tab') { + nextTabIndex++; + } + const tabSectionWidth = calcChildNodesWidth( + parent, + pos - $pos.parentOffset, + tabIndex, + nextTabIndex, + domSerializer, + view, + invertMapping, + nodeWidthCache, + ); + tabWidth -= tabStop.val === 'end' ? tabSectionWidth : tabSectionWidth / 2; + } else if (['decimal', 'num'].includes(tabStop.val)) { + const breakChar = '.'; // TODO: The break character should likely be document language dependent. + let nodeIndex = tabIndex + 1; + let integralWidth = 0; + let nodePos = pos - $pos.parentOffset; + while (nodeIndex < parent.childCount) { + const node = parent.child(nodeIndex); + if (node.type.name === 'tab') { + break; + } + const oldPos = invertMapping.map(nodePos); + if (node.type.name === 'text' && node.text.includes(breakChar)) { + // Only include text before the break character + const modifiedNode = node.cut(0, node.text.indexOf(breakChar)); + integralWidth += calcNodeWidth(domSerializer, modifiedNode, view, oldPos); + break; + } + integralWidth += calcNodeWidth(domSerializer, node, view, oldPos); + nodeWidthCache[nodePos] = integralWidth; + nodePos += node.nodeSize; + nodeIndex += 1; + } + tabWidth -= integralWidth; + } + if (tabStop.leader) { + // TODO: The following styles will likely not correspond 1:1 to the original. Adjust as needed. + if (tabStop.leader === 'dot') { + extraStyles += `border-bottom: 1px dotted black;`; + } else if (tabStop.leader === 'heavy') { + extraStyles += `border-bottom: 2px solid black;`; + } else if (tabStop.leader === 'hyphen') { + extraStyles += `border-bottom: 1px solid black;`; + } else if (tabStop.leader === 'middleDot') { + extraStyles += `border-bottom: 1px dotted black; margin-bottom: 2px;`; + } else if (tabStop.leader === 'underscore') { + extraStyles += `border-bottom: 1px solid black;`; + } } - }); - } catch { - return; + } + } + + if (!tabWidth || tabWidth < 1) { + tabWidth = defaultTabDistance - ((currentWidth % defaultLineLength) % defaultTabDistance); + if (tabWidth === 0) { + tabWidth = defaultTabDistance; + } } - const tabWidth = $pos.nodeBefore?.type.name === 'tab' ? tabWidthPx : tabWidthPx - (textWidth % tabWidthPx); + nodeWidthCache[pos] = tabWidth; // Update width with final tab width - important for subsequent tabs. + const tabHeight = calcTabHeight($pos); decorations.push( - Decoration.node(pos, pos + node.nodeSize, { style: `width: ${tabWidth}px; height: ${tabHeight};` }), + Decoration.node(pos, pos + node.nodeSize, { + style: `width: ${tabWidth}px; height: ${tabHeight};${extraStyles}`, + }), ); } }); return decorations; }; -function calcTextWidth(view, pos) { - const domNode = view.nodeDOM(pos); - if (domNode) { - const range = document.createRange(); - range.selectNodeContents(domNode); - return range.getBoundingClientRect().width; +function calcNodeWidth(domSerializer, node, view, oldPos) { + // Create dom node of node. Then calculate width. + const oldDomNode = view.nodeDOM(oldPos); + const styleReference = oldDomNode ? (oldDomNode.nodeName === '#text' ? oldDomNode.parentNode : oldDomNode) : view.dom; + const temp = document.createElement('div'); + const style = window.getComputedStyle(styleReference); + // Copy relevant styles + temp.style.cssText = ` + position: absolute; + top: -9999px; + left: -9999px; + white-space: nowrap; + font-family: ${style.fontFamily}; + font-size: ${style.fontSize}; + font-weight: ${style.fontWeight}; + font-style: ${style.fontStyle}; + letter-spacing: ${style.letterSpacing}; + word-spacing: ${style.wordSpacing}; + text-transform: ${style.textTransform}; + display: inline-block; + `; + + const domNode = domSerializer.serializeNode(node); + + temp.appendChild(domNode); + document.body.appendChild(temp); + + const width = temp.offsetWidth; + document.body.removeChild(temp); + + return width; +} + +function calcChildNodesWidth( + parent, + parentPos, + startIndex, + endIndex, + domSerializer, + view, + invertMapping, + nodeWidthCache, +) { + let pos = parentPos; + let width = 0; + for (let i = 0; i < endIndex; i++) { + const node = parent.child(i); + if (i >= startIndex) { + if (!nodeWidthCache[pos]) { + nodeWidthCache[pos] = calcNodeWidth(domSerializer, node, view, invertMapping.map(pos)); + } + width += nodeWidthCache[pos]; + } + pos += node.nodeSize; + + // TODO: This assumes no space between inline sibling nodes. } - return 0; + return width; } function calcTabHeight(pos) { diff --git a/packages/super-editor/src/tests/export/lists/miscOrderedListExport.test.js b/packages/super-editor/src/tests/export/lists/miscOrderedListExport.test.js index 702381101d..7b238ae9da 100644 --- a/packages/super-editor/src/tests/export/lists/miscOrderedListExport.test.js +++ b/packages/super-editor/src/tests/export/lists/miscOrderedListExport.test.js @@ -65,7 +65,7 @@ describe('[custom_list1.docx] interrupted ordered list tests', async () => { const firstList = body.elements[0]; const firstListPprList = firstList.elements.filter((n) => (n.name = 'w:pPr' && n.elements.length)); const firstListPpr = firstListPprList[0]; - expect(firstListPpr.elements.length).toBe(4); + expect(firstListPpr.elements.length).toBe(5); const numPr = firstListPpr.elements.find((n) => n.name === 'w:numPr'); const numIdTag = numPr.elements.find((n) => n.name === 'w:numId'); diff --git a/packages/super-editor/src/tests/export/tabStopsExporter.test.js b/packages/super-editor/src/tests/export/tabStopsExporter.test.js new file mode 100644 index 0000000000..421199724e --- /dev/null +++ b/packages/super-editor/src/tests/export/tabStopsExporter.test.js @@ -0,0 +1,239 @@ +import { expect } from 'vitest'; +import { translateParagraphNode } from '@converter/exporter.js'; + +describe('Tab Stops Export Tests', () => { + // Create a minimal editor mock that has the required extensions property + const createMockEditor = () => ({ + extensions: { + find: vi.fn(() => null), + }, + schema: { + marks: {}, + }, + }); + + it('correctly exports paragraph with tab stops', () => { + const mockEditor = createMockEditor(); + const mockParagraphNode = { + type: 'paragraph', + attrs: { + tabStops: [ + { + val: 'start', + pos: 144, + }, + { + val: 'center', + pos: 336, + leader: 'dot', + }, + { + val: 'decimal', + pos: 480, + leader: 'underscore', + }, + ], + }, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + expect(result.name).toBe('w:p'); + expect(result.elements).toBeDefined(); + + // Find the pPr element + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + expect(pPr).toBeDefined(); + + // Find the tabs element within pPr + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeDefined(); + expect(tabs.elements).toBeDefined(); + expect(tabs.elements.length).toBe(3); + + // Check first tab stop + const firstTab = tabs.elements[0]; + expect(firstTab.name).toBe('w:tab'); + expect(firstTab.attributes['w:val']).toBe('start'); + expect(firstTab.attributes['w:pos']).toBe('2160'); + expect(firstTab.attributes['w:leader']).toBeUndefined(); + + // Check second tab stop + const secondTab = tabs.elements[1]; + expect(secondTab.name).toBe('w:tab'); + expect(secondTab.attributes['w:val']).toBe('center'); + expect(secondTab.attributes['w:pos']).toBe('5040'); + expect(secondTab.attributes['w:leader']).toBe('dot'); + + // Check third tab stop + const thirdTab = tabs.elements[2]; + expect(thirdTab.name).toBe('w:tab'); + expect(thirdTab.attributes['w:val']).toBe('decimal'); + expect(thirdTab.attributes['w:pos']).toBe('7200'); + expect(thirdTab.attributes['w:leader']).toBe('underscore'); + }); + + it('correctly exports paragraph without tab stops', () => { + const mockEditor = createMockEditor(); + const mockParagraphNode = { + type: 'paragraph', + attrs: {}, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + expect(result.name).toBe('w:p'); + expect(result.elements).toBeDefined(); + + // Find the pPr element (if it exists) + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + + if (pPr) { + // If pPr exists, it should not contain tabs + const tabs = pPr.elements?.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeUndefined(); + } + }); + + it('correctly exports paragraph with empty tab stops array', () => { + const mockEditor = createMockEditor(); + const mockParagraphNode = { + type: 'paragraph', + attrs: { + tabStops: [], + }, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + expect(result.name).toBe('w:p'); + expect(result.elements).toBeDefined(); + + // Find the pPr element (if it exists) + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + + if (pPr) { + // If pPr exists, it should not contain tabs for empty array + const tabs = pPr.elements?.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeUndefined(); + } + }); + + it('correctly exports tab stop with default val attribute', () => { + const mockEditor = createMockEditor(); + const mockParagraphNode = { + type: 'paragraph', + attrs: { + tabStops: [ + { + pos: 96, + // No val specified, should default to 'start' + }, + ], + }, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + expect(result.name).toBe('w:p'); + + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + expect(pPr).toBeDefined(); + + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeDefined(); + expect(tabs.elements.length).toBe(1); + + const tab = tabs.elements[0]; + expect(tab.name).toBe('w:tab'); + expect(tab.attributes['w:val']).toBe('start'); + expect(tab.attributes['w:pos']).toBe('1440'); + expect(tab.attributes['w:leader']).toBeUndefined(); + }); + + it('correctly exports tab stops with all supported val types', () => { + const mockEditor = createMockEditor(); + const supportedTypes = ['bar', 'center', 'clear', 'decimal', 'end', 'num', 'start']; + + const tabStops = supportedTypes.map((type, index) => ({ + val: type, + pos: (index + 1) * 96, // 1 inch intervals + })); + + const mockParagraphNode = { + type: 'paragraph', + attrs: { + tabStops, + }, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + + expect(tabs.elements.length).toBe(supportedTypes.length); + + supportedTypes.forEach((type, index) => { + const tab = tabs.elements[index]; + expect(tab.attributes['w:val']).toBe(type); + expect(tab.attributes['w:pos']).toBe(((index + 1) * 1440).toString()); + }); + }); + + it('correctly exports tab stops with all supported leader types', () => { + const mockEditor = createMockEditor(); + const supportedLeaders = ['dot', 'heavy', 'hyphen', 'middleDot', 'none', 'underscore']; + + const tabStops = supportedLeaders.map((leader, index) => ({ + val: 'start', + pos: (index + 1) * 96, + leader, + })); + + const mockParagraphNode = { + type: 'paragraph', + attrs: { + tabStops, + }, + content: [], + }; + + const result = translateParagraphNode({ + editor: mockEditor, + node: mockParagraphNode, + }); + + const pPr = result.elements.find((el) => el.name === 'w:pPr'); + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + + expect(tabs.elements.length).toBe(supportedLeaders.length); + + supportedLeaders.forEach((leader, index) => { + const tab = tabs.elements[index]; + expect(tab.attributes['w:val']).toBe('start'); + expect(tab.attributes['w:pos']).toBe(((index + 1) * 1440).toString()); + expect(tab.attributes['w:leader']).toBe(leader); + }); + }); +}); diff --git a/packages/super-editor/src/tests/import-export/tabStopsRoundTrip.test.js b/packages/super-editor/src/tests/import-export/tabStopsRoundTrip.test.js new file mode 100644 index 0000000000..e31a4a118a --- /dev/null +++ b/packages/super-editor/src/tests/import-export/tabStopsRoundTrip.test.js @@ -0,0 +1,300 @@ +import { expect } from 'vitest'; +import { handleParagraphNode } from '@converter/v2/importer/paragraphNodeImporter.js'; +import { defaultNodeListHandler } from '@converter/v2/importer/docxImporter.js'; +import { translateParagraphNode } from '@converter/exporter.js'; + +describe('Tab Stops Round Trip Tests', () => { + // Create a minimal editor mock that has the required extensions property + const createMockEditor = () => ({ + extensions: { + find: vi.fn(() => null), + }, + schema: { + marks: {}, + }, + }); + + it('correctly imports and exports tab stops with all attributes', () => { + // Create a mock DOCX paragraph with tab stops + const mockDocxParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [ + { + name: 'w:tab', + attributes: { + 'w:val': 'start', + 'w:pos': '2160', + }, + }, + { + name: 'w:tab', + attributes: { + 'w:val': 'center', + 'w:pos': '5040', + 'w:leader': 'dot', + }, + }, + { + name: 'w:tab', + attributes: { + 'w:val': 'decimal', + 'w:pos': '7200', + 'w:leader': 'underscore', + }, + }, + ], + }, + ], + }, + ], + }; + + // Step 1: Import the DOCX paragraph + const { nodes } = handleParagraphNode({ + nodes: [mockDocxParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const importedNode = nodes[0]; + expect(importedNode.type).toBe('paragraph'); + expect(importedNode.attrs.tabStops).toBeDefined(); + expect(importedNode.attrs.tabStops.length).toBe(3); + + // Verify imported tab stops + const firstTab = importedNode.attrs.tabStops[0]; + expect(firstTab.val).toBe('start'); + expect(firstTab.pos).toBe(144); + expect(firstTab.leader).toBeUndefined(); + + const secondTab = importedNode.attrs.tabStops[1]; + expect(secondTab.val).toBe('center'); + expect(secondTab.pos).toBe(336); + expect(secondTab.leader).toBe('dot'); + + const thirdTab = importedNode.attrs.tabStops[2]; + expect(thirdTab.val).toBe('decimal'); + expect(thirdTab.pos).toBe(480); + expect(thirdTab.leader).toBe('underscore'); + + // Step 2: Export the imported node back to DOCX + const mockEditor = createMockEditor(); + const exportedResult = translateParagraphNode({ + editor: mockEditor, + node: importedNode, + }); + + expect(exportedResult.name).toBe('w:p'); + + // Find the pPr element + const pPr = exportedResult.elements.find((el) => el.name === 'w:pPr'); + expect(pPr).toBeDefined(); + + // Find the tabs element within pPr + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeDefined(); + expect(tabs.elements.length).toBe(3); + + // Verify exported tab stops match the original + const exportedFirstTab = tabs.elements[0]; + expect(exportedFirstTab.name).toBe('w:tab'); + expect(exportedFirstTab.attributes['w:val']).toBe('start'); + expect(exportedFirstTab.attributes['w:pos']).toBe('2160'); + expect(exportedFirstTab.attributes['w:leader']).toBeUndefined(); + + const exportedSecondTab = tabs.elements[1]; + expect(exportedSecondTab.name).toBe('w:tab'); + expect(exportedSecondTab.attributes['w:val']).toBe('center'); + expect(exportedSecondTab.attributes['w:pos']).toBe('5040'); + expect(exportedSecondTab.attributes['w:leader']).toBe('dot'); + + const exportedThirdTab = tabs.elements[2]; + expect(exportedThirdTab.name).toBe('w:tab'); + expect(exportedThirdTab.attributes['w:val']).toBe('decimal'); + expect(exportedThirdTab.attributes['w:pos']).toBe('7200'); + expect(exportedThirdTab.attributes['w:leader']).toBe('underscore'); + }); + + it('correctly handles paragraphs without tab stops in round trip', () => { + // Create a mock DOCX paragraph without tab stops + const mockDocxParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [], + }, + ], + }; + + // Step 1: Import the DOCX paragraph + const { nodes } = handleParagraphNode({ + nodes: [mockDocxParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const importedNode = nodes[0]; + expect(importedNode.type).toBe('paragraph'); + expect(importedNode.attrs.tabStops).toBeUndefined(); + + // Step 2: Export the imported node back to DOCX + const mockEditor = createMockEditor(); + const exportedResult = translateParagraphNode({ + editor: mockEditor, + node: importedNode, + }); + + expect(exportedResult.name).toBe('w:p'); + + // Find the pPr element (if it exists) + const pPr = exportedResult.elements.find((el) => el.name === 'w:pPr'); + + if (pPr) { + // If pPr exists, it should not contain tabs + const tabs = pPr.elements?.find((el) => el.name === 'w:tabs'); + expect(tabs).toBeUndefined(); + } + }); + + it('correctly handles tab stops with default values in round trip', () => { + // Create a mock DOCX paragraph with tab stop that has default val + const mockDocxParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [ + { + name: 'w:tab', + attributes: { + 'w:pos': '1440', + // No w:val provided, should default to 'start' + }, + }, + ], + }, + ], + }, + ], + }; + + // Step 1: Import the DOCX paragraph + const { nodes } = handleParagraphNode({ + nodes: [mockDocxParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const importedNode = nodes[0]; + expect(importedNode.type).toBe('paragraph'); + expect(importedNode.attrs.tabStops).toBeDefined(); + expect(importedNode.attrs.tabStops.length).toBe(1); + + const tab = importedNode.attrs.tabStops[0]; + expect(tab.val).toBe('start'); // Should default to 'start' + expect(tab.pos).toBe(96); + expect(tab.leader).toBeUndefined(); + + // Step 2: Export the imported node back to DOCX + const mockEditor = createMockEditor(); + const exportedResult = translateParagraphNode({ + editor: mockEditor, + node: importedNode, + }); + + const pPr = exportedResult.elements.find((el) => el.name === 'w:pPr'); + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + expect(tabs.elements.length).toBe(1); + + const exportedTab = tabs.elements[0]; + expect(exportedTab.attributes['w:val']).toBe('start'); + expect(exportedTab.attributes['w:pos']).toBe('1440'); + expect(exportedTab.attributes['w:leader']).toBeUndefined(); + }); + + it('preserves tab stop order in round trip', () => { + // Create a mock DOCX paragraph with multiple tab stops in specific order + const mockDocxParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [ + { + name: 'w:tab', + attributes: { + 'w:val': 'end', + 'w:pos': '8640', + 'w:leader': 'hyphen', + }, + }, + { + name: 'w:tab', + attributes: { + 'w:val': 'bar', + 'w:pos': '1440', + }, + }, + { + name: 'w:tab', + attributes: { + 'w:val': 'num', + 'w:pos': '4320', + 'w:leader': 'middleDot', + }, + }, + ], + }, + ], + }, + ], + }; + + // Step 1: Import the DOCX paragraph + const { nodes } = handleParagraphNode({ + nodes: [mockDocxParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const importedNode = nodes[0]; + expect(importedNode.attrs.tabStops.length).toBe(3); + + // Step 2: Export the imported node back to DOCX + const mockEditor = createMockEditor(); + const exportedResult = translateParagraphNode({ + editor: mockEditor, + node: importedNode, + }); + + const pPr = exportedResult.elements.find((el) => el.name === 'w:pPr'); + const tabs = pPr.elements.find((el) => el.name === 'w:tabs'); + expect(tabs.elements.length).toBe(3); + + // Verify the order is preserved + expect(tabs.elements[0].attributes['w:val']).toBe('end'); + expect(tabs.elements[0].attributes['w:pos']).toBe('8640'); + expect(tabs.elements[0].attributes['w:leader']).toBe('hyphen'); + + expect(tabs.elements[1].attributes['w:val']).toBe('bar'); + expect(tabs.elements[1].attributes['w:pos']).toBe('1440'); + expect(tabs.elements[1].attributes['w:leader']).toBeUndefined(); + + expect(tabs.elements[2].attributes['w:val']).toBe('num'); + expect(tabs.elements[2].attributes['w:pos']).toBe('4320'); + expect(tabs.elements[2].attributes['w:leader']).toBe('middleDot'); + }); +}); diff --git a/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js b/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js index a7e16bea69..51758b9abe 100644 --- a/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js +++ b/packages/super-editor/src/tests/import/paragraphNodeImporter.test.js @@ -389,4 +389,149 @@ describe('Check that paragraph-level sectPr is retained', () => { const sectPr2 = pPr2.elements.find((el) => el.name === 'w:sectPr'); expect(p2sectPrData).toEqual(sectPr2); }); + + describe('paragraph tests to check tab stops', () => { + it('correctly handles paragraph with tab stops', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [ + { + name: 'w:tab', + attributes: { + 'w:val': 'start', + 'w:pos': '2160', + }, + }, + { + name: 'w:tab', + attributes: { + 'w:val': 'center', + 'w:pos': '5040', + 'w:leader': 'dot', + }, + }, + ], + }, + ], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const node = nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.attrs.tabStops).toBeDefined(); + expect(node.attrs.tabStops.length).toBe(2); + + const firstTab = node.attrs.tabStops[0]; + expect(firstTab.val).toBe('start'); + expect(firstTab.pos).toBe(144); + expect(firstTab.leader).toBeUndefined(); + + const secondTab = node.attrs.tabStops[1]; + expect(secondTab.val).toBe('center'); + expect(secondTab.pos).toBe(336); + expect(secondTab.leader).toBe('dot'); + }); + + it('correctly handles paragraph without tab stops', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const node = nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.attrs.tabStops).toBeUndefined(); + }); + + it('correctly handles empty tabs element', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [], + }, + ], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const node = nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.attrs.tabStops).toBeUndefined(); + }); + + it('correctly handles tab with default values', () => { + const mockParagraph = { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:tabs', + elements: [ + { + name: 'w:tab', + attributes: { + 'w:pos': '1440', + // No w:val provided, should default to 'start' + }, + }, + ], + }, + ], + }, + ], + }; + + const { nodes } = handleParagraphNode({ + nodes: [mockParagraph], + docx: {}, + nodeListHandler: defaultNodeListHandler(), + }); + + const node = nodes[0]; + expect(node.type).toBe('paragraph'); + expect(node.attrs.tabStops).toBeDefined(); + expect(node.attrs.tabStops.length).toBe(1); + + const tab = node.attrs.tabStops[0]; + expect(tab.val).toBe('start'); + expect(tab.pos).toBe(96); + }); + }); });