Skip to content
Closed
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
5 changes: 5 additions & 0 deletions packages/super-editor/src/assets/styles/layout/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@
a {
text-decoration: auto;
}

u:has(u.underline-hidden),
u.underline-hidden {
text-decoration: none;
}
6 changes: 5 additions & 1 deletion packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ 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
if (runProperties && runProperties?.elements?.length) {
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
Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 8 additions & 2 deletions packages/super-editor/src/extensions/underline/underline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
116 changes: 116 additions & 0 deletions packages/super-editor/src/tests/import/underlineImporter.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading