diff --git a/packages/super-editor/src/assets/styles/layout/global.css b/packages/super-editor/src/assets/styles/layout/global.css index 88af04c038..f993ff6b04 100644 --- a/packages/super-editor/src/assets/styles/layout/global.css +++ b/packages/super-editor/src/assets/styles/layout/global.css @@ -14,3 +14,8 @@ a { text-decoration: auto; } + +u:has(u.underline-hidden), +u.underline-hidden { + text-decoration: none; +} diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 56920b9b97..f685049f71 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -1414,7 +1414,11 @@ function translateMark(mark) { case 'underline': markElement.type = 'element'; - markElement.attributes['w:val'] = attrs.underlineType; + // Only add w:val if it's not null + // Some word documents have underline nodes but no val (Word ignores them) + if (attrs.underlineType && attrs.underlineType !== 'none') { + markElement.attributes['w:val'] = attrs.underlineType; + } break; // Text style cases diff --git a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js index 3ab7718a95..8b8c7a0a3c 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/importerHelpers.js @@ -18,6 +18,10 @@ export function parseProperties(node) { const { nodes, paragraphProperties = {}, runProperties = {} } = splitElementsAndProperties(elements); const hasRun = elements.find((element) => element.name === 'w:r'); + if (paragraphProperties && paragraphProperties.elements?.length) { + marks.push(...parseMarks(paragraphProperties, unknownMarks)); + } + if (hasRun) paragraphProperties.elements = paragraphProperties?.elements?.filter((el) => el.name !== 'w:rPr'); // Get the marks from the run properties @@ -25,10 +29,7 @@ export function parseProperties(node) { marks.push(...parseMarks(runProperties, unknownMarks)); } - if (paragraphProperties && paragraphProperties.elements?.length) { - marks.push(...parseMarks(paragraphProperties, unknownMarks)); - } - //add style change marks + // add style change marks marks.push(...handleStyleChangeMarks(runProperties, marks)); // Maintain any extra properties @@ -57,7 +58,8 @@ export function parseProperties(node) { */ function splitElementsAndProperties(elements) { const pPr = elements.find((el) => el.name === 'w:pPr'); - const rPr = elements.find((el) => el.name === 'w:rPr'); + const run = elements?.find((el) => el.name === 'w:r'); + const rPr = run?.elements?.find((el) => el.name === 'w:rPr'); const sectPr = elements.find((el) => el.name === 'w:sectPr'); const els = elements.filter((el) => el.name !== 'w:pPr' && el.name !== 'w:rPr' && el.name !== 'w:sectPr'); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js index 5c97b1c0bb..d81dead019 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/markImporter.js @@ -89,6 +89,16 @@ export function parseMarks(property, unknownMarks = [], docx = null) { if (Object.keys(attributes).length) { const value = getMarkValue(m.type, attributes, docx); + // Handle a case here where Underline could have a color and other attributes + // but not a value, in which case it should not be expressed in Word and needs + // to override the parent underline + if (m.type === 'underline' && !attributes['w:val'] && Object.keys(attributes).length >= 1) { + newMark.attrs = attributes || {}; + newMark.attrs['underlineType'] = 'none'; + marks.push(newMark); + return; + } + // If there is no value for mark it can't be applied if (value === null || value === undefined) return; diff --git a/packages/super-editor/src/extensions/underline/underline.js b/packages/super-editor/src/extensions/underline/underline.js index 0fd88be7f5..45d1987fd9 100644 --- a/packages/super-editor/src/extensions/underline/underline.js +++ b/packages/super-editor/src/extensions/underline/underline.js @@ -17,8 +17,14 @@ export const Underline = Mark.create({ ]; }, - renderDOM({ htmlAttributes }) { - return ['u', Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes), 0]; + renderDOM({ htmlAttributes, mark }) { + const baseAttributes = Attribute.mergeAttributes(this.options.htmlAttributes, htmlAttributes); + // Add conditional class for hidden underlines (when w:u has no w:val) + if (mark.attrs.underlineType === 'none') { + baseAttributes.class = baseAttributes.class ? `${baseAttributes.class} underline-hidden` : 'underline-hidden'; + } + + return ['u', baseAttributes, 0]; }, addAttributes() { diff --git a/packages/super-editor/src/tests/import/underlineImporter.test.js b/packages/super-editor/src/tests/import/underlineImporter.test.js new file mode 100644 index 0000000000..8f1d68d2ea --- /dev/null +++ b/packages/super-editor/src/tests/import/underlineImporter.test.js @@ -0,0 +1,116 @@ +import { expect, describe, it } from 'vitest'; +import { parseMarks } from '@core/super-converter/v2/importer/markImporter.js'; +import { handleParagraphNode } from '@core/super-converter/v2/importer/paragraphNodeImporter.js'; +import { defaultNodeListHandler } from '@core/super-converter/v2/importer/docxImporter.js'; +import { Underline } from '@extensions/underline/underline.js'; + +const createMockDocx = (styles = []) => ({ + 'word/styles.xml': { + elements: [ + { + name: 'w:styles', + elements: [ + { + name: 'w:docDefaults', + elements: [], + }, + ...styles, + ], + }, + ], + }, +}); + +const createMockRunProperty = (name, attributes = {}) => ({ + name, + attributes, +}); + +// Simple NodeListHandler that delegates run/paragraph nodes to defaults +const nodeListHandler = defaultNodeListHandler(); + +// Underline specific helpers +const createUnderlineNoneNoVal = (extraAttrs = { 'w:color': '000000' }) => createMockRunProperty('w:u', extraAttrs); + +describe('underlineImporter', () => { + it('should override paragraph underline (single) with run underline (none)', () => { + const mockDocx = createMockDocx([]); + // Run node directly contains w:u with no w:val, only color + const result = handleParagraphNode({ + nodes: [ + { + name: 'w:p', + elements: [ + { + name: 'w:pPr', + elements: [ + { + name: 'w:u', + elements: [ + { + name: 'w:val', + attributes: { 'w:val': 'single' }, + }, + ], + }, + ], + }, + { + name: 'w:r', + elements: [ + { + name: 'w:rPr', + elements: [ + { + name: 'w:u', + attributes: { 'w:color': '000000' }, + }, + ], + }, + { + name: 'w:t', + elements: [{ text: 'Underlined text' }], + }, + ], + }, + ], + }, + ], + nodeListHandler, + docx: mockDocx, + }); + + expect(result.nodes).toHaveLength(1); + const paragraph = result.nodes[0]; + const textNode = paragraph.content[0]; + + const noneUnderline = textNode.marks.find((m) => m.type === 'underline' && m.attrs?.underlineType === 'none'); + expect(noneUnderline).toBeDefined(); + // Ensure underlineType 'single' from paragraph isn't present on any underline mark + const singleUnderline = textNode.marks.find((m) => m.type === 'underline' && m.attrs?.underlineType === 'single'); + expect(singleUnderline).toBeUndefined(); + + // Render DOM for underline mark to verify class + const underlineDom = Underline.config.renderDOM.call( + { ...Underline, options: Underline.config.addOptions() }, + { + htmlAttributes: {}, + mark: noneUnderline, + }, + ); + // underlineDom = ['u', attrs, 0] + expect(underlineDom[1].class).toContain('underline-hidden'); + }); + + it('should add underlineType none via parseMarks when w:u lacks w:val', () => { + const propertyNode = { + name: 'w:rPr', + elements: [createUnderlineNoneNoVal()], + }; + + const marks = parseMarks(propertyNode, [], null); + const underlineMark = marks.find((m) => m.type === 'underline'); + expect(underlineMark).toBeDefined(); + expect(underlineMark.attrs.underlineType).toBe('none'); + }); +});