From ba735e4dce27cf8a0fc5db4ba6e611b9c95085b2 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 12 Aug 2025 18:53:29 -0400 Subject: [PATCH] feat: enhance content validation by removing empty text nodes - Added tests for handling empty and whitespace-only text nodes in paragraphs. - Updated `fixContentArray` and related functions to filter out empty text nodes, ensuring cleaner document structures. - Introduced `isNonEmptyText` utility to check for non-empty text, accounting for various whitespace characters. --- .../editor/utils/validate-content.test.ts | 49 +++++++++++++++++++ .../editor/utils/validate-content.ts | 45 +++++++++++------ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/editor/utils/validate-content.test.ts b/packages/ui/src/components/editor/utils/validate-content.test.ts index 36fd00e1b..6fdafe336 100644 --- a/packages/ui/src/components/editor/utils/validate-content.test.ts +++ b/packages/ui/src/components/editor/utils/validate-content.test.ts @@ -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); + }); + }); }); diff --git a/packages/ui/src/components/editor/utils/validate-content.ts b/packages/ui/src/components/editor/utils/validate-content.ts index de9051600..1e38f9cf8 100644 --- a/packages/ui/src/components/editor/utils/validate-content.ts +++ b/packages/ui/src/components/editor/utils/validate-content.ts @@ -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) { @@ -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 */ @@ -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', @@ -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, }; @@ -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, }; } @@ -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, }; @@ -287,7 +304,7 @@ function createEmptyDocument(): JSONContent { function createEmptyParagraph(): JSONContent { return { type: 'paragraph', - content: [{ type: 'text', text: '' }], + content: [], }; }