Skip to content
Merged
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
49 changes: 49 additions & 0 deletions packages/ui/src/components/editor/utils/validate-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,53 @@ describe('validateAndFixTipTapContent', () => {
groupEndSpy.mockRestore();
});
});

describe('empty text node handling', () => {
const strip = (s: string) => s.replace(/[\u00A0\u200B\u202F]/g, '').trim();

const hasEmptyTextNodes = (node: any): boolean => {
if (!node || typeof node !== 'object') return false;
if (node.type === 'text') {
const txt = typeof node.text === 'string' ? node.text : '';
return strip(txt).length === 0;
}
if (Array.isArray(node.content)) {
return node.content.some((child: any) => hasEmptyTextNodes(child));
}
return false;
};

it('removes empty and whitespace-only (including NBSP/ZWSP) text nodes in paragraphs', () => {
const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '' },
{ type: 'text', text: ' ' },
{ type: 'text', text: '\u00A0' },
{ type: 'text', text: '\u200B' },
{ type: 'text', text: 'Hello' },
{ text: 'World' },
],
},
],
};

const fixed = validateAndFixTipTapContent(content);
expect(fixed.type).toBe('doc');
expect(hasEmptyTextNodes(fixed)).toBe(false);

const paragraph = (fixed.content as any[])[0];
const texts = paragraph.content.map((n: any) => n.text);
expect(texts).toEqual(['Hello', 'World']);
});

it('does not introduce empty text nodes when creating empty structures', () => {
const fixed = validateAndFixTipTapContent({});
expect(fixed.type).toBe('doc');
expect(hasEmptyTextNodes(fixed)).toBe(false);
});
});
});
45 changes: 31 additions & 14 deletions packages/ui/src/components/editor/utils/validate-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ function fixContentArray(contentArray: any[]): JSONContent[] {
return [createEmptyParagraph()];
}

const fixedContent = contentArray
.map(fixNode)
.filter((node): node is JSONContent => node !== null) as JSONContent[];
const fixedContent = contentArray.map(fixNode).filter((node): node is JSONContent => {
if (!node) return false;
if (node.type === 'text') {
const value = typeof (node as any).text === 'string' ? (node as any).text : '';
return isNonEmptyText(value);
}
return true;
}) as JSONContent[];

// Ensure we have at least one paragraph
if (fixedContent.length === 0) {
Expand All @@ -58,6 +63,13 @@ function fixContentArray(contentArray: any[]): JSONContent[] {
return fixedContent;
}

function isNonEmptyText(value: string): boolean {
// Consider normal whitespace, NBSP, zero-width space and narrow no-break space
if (typeof value !== 'string') return false;
const normalized = value.replace(/[\u00A0\u200B\u202F]/g, '');
return normalized.trim().length > 0;
}

/**
* Fixes a single node and its content
*/
Expand Down Expand Up @@ -124,12 +136,17 @@ function fixParagraph(node: any): JSONContent {
}
return fixNode(item);
})
.filter(Boolean) as JSONContent[];
.filter((n): n is JSONContent => {
if (!n) return false;
// Drop empty text nodes entirely
if (n.type === 'text') {
const txt = typeof (n as any).text === 'string' ? (n as any).text : '';
return txt.trim().length > 0;
}
return true;
});

// If no valid content, create empty text node
if (fixedContent.length === 0) {
fixedContent.push({ type: 'text', text: '' });
}
// If no valid content, keep an empty paragraph (no empty text nodes)

return {
type: 'paragraph',
Expand Down Expand Up @@ -199,9 +216,11 @@ function fixListItem(node: any): JSONContent {
function fixTextNode(node: any): JSONContent {
const { text, marks, ...rest } = node;

const value = typeof text === 'string' ? text : '';
// If the resulting text is empty, return a text node with non-empty check handled by callers.
return {
type: 'text',
text: typeof text === 'string' ? text : '',
text: value,
...(marks && Array.isArray(marks) && { marks: fixMarks(marks) }),
...rest,
};
Expand All @@ -219,8 +238,7 @@ function fixHeading(node: any): JSONContent {
return {
type: 'heading',
attrs: { level: validLevel, ...(attrs && typeof attrs === 'object' ? attrs : {}) },
content:
content && Array.isArray(content) ? fixContentArray(content) : [{ type: 'text', text: '' }],
content: content && Array.isArray(content) ? fixContentArray(content) : [],
...rest,
};
}
Expand Down Expand Up @@ -248,8 +266,7 @@ function fixCodeBlock(node: any): JSONContent {

return {
type: 'codeBlock',
content:
content && Array.isArray(content) ? fixContentArray(content) : [{ type: 'text', text: '' }],
content: content && Array.isArray(content) ? fixContentArray(content) : [],
...(attrs && typeof attrs === 'object' && { attrs }),
...rest,
};
Expand Down Expand Up @@ -287,7 +304,7 @@ function createEmptyDocument(): JSONContent {
function createEmptyParagraph(): JSONContent {
return {
type: 'paragraph',
content: [{ type: 'text', text: '' }],
content: [],
};
}

Expand Down
Loading