fix: empty paragraphs not skipped in paragraph mode (#3361)

* fix(paragraph): skip empty paragraph ranges

* test(paragraph): cover empty paragraph filtering
This commit is contained in:
Mohammed Efaz
2026-02-24 14:05:35 +06:00
committed by GitHub
parent 4c5ff59bcf
commit e26f7e6a2c
2 changed files with 58 additions and 9 deletions
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { ParagraphIterator } from '@/utils/paragraph';
const createDoc = (body: string): Document =>
new DOMParser().parseFromString(`<html><body>${body}</body></html>`, 'text/html');
describe('ParagraphIterator', () => {
it('skips whitespace-only paragraphs', async () => {
const doc = createDoc(`
<p>First</p>
<p> </p>
<p>&nbsp;</p>
<p>\u200b</p>
<p><span>\u00a0</span></p>
<p><br /></p>
<p>Second</p>
`);
const iterator = await ParagraphIterator.createAsync(doc, 1000);
expect(iterator.length).toBe(2);
expect(iterator.first()?.toString()).toContain('First');
expect(iterator.next()?.toString()).toContain('Second');
});
it('keeps paragraphs with non-text content', async () => {
const doc = createDoc(`
<p>Intro</p>
<p><img src="cover.png" alt="" /></p>
<p>Outro</p>
`);
const iterator = await ParagraphIterator.createAsync(doc, 1000);
expect(iterator.length).toBe(3);
iterator.first();
expect(iterator.current()?.toString()).toContain('Intro');
const imageRange = iterator.next();
const fragment = imageRange?.cloneContents();
expect(fragment?.querySelector('img')).not.toBeNull();
expect(iterator.next()?.toString()).toContain('Outro');
});
});
+12 -9
View File
@@ -31,16 +31,19 @@ const blockTags = new Set([
const MAX_BLOCKS = 5000;
const INVISIBLE_TEXT_PATTERN =
/[\s\u00a0\u1680\u180e\u2000-\u200a\u202f\u205f\u3000\u200b-\u200d\u2060\ufeff]/g;
const MEDIA_SELECTOR = 'img, svg, video, audio, canvas, math, iframe, object, embed, hr';
const hasMeaningfulText = (text?: string | null): boolean =>
(text ?? '').replace(INVISIBLE_TEXT_PATTERN, '').length > 0;
const rangeHasContent = (range: Range): boolean => {
try {
const container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
return (container.textContent?.trim().length ?? 0) > 0;
}
if (container.nodeType === Node.ELEMENT_NODE) {
return (container as Element).textContent?.trim().length !== 0;
}
return true;
const text = range.toString();
if (hasMeaningfulText(text)) return true;
const fragment = range.cloneContents();
return !!fragment.querySelector?.(MEDIA_SELECTOR);
} catch {
return false;
}
@@ -48,7 +51,7 @@ const rangeHasContent = (range: Range): boolean => {
const hasDirectText = (node: Element): boolean =>
Array.from(node.childNodes).some(
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim(),
(child) => child.nodeType === Node.TEXT_NODE && hasMeaningfulText(child.textContent),
);
const hasBlockChild = (node: Element): boolean =>