forked from akai/readest
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:
@@ -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> </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');
|
||||
});
|
||||
});
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user