Skip to content

Commit 2748154

Browse files
committed
feat(text-editor): add removeEmptyParagraphs prop
To enable consumer to keep or remove empty paragraphs from their HTML `value` input, before rendering the content.
1 parent 3a53571 commit 2748154

File tree

7 files changed

+314
-1
lines changed

7 files changed

+314
-1
lines changed

etc/lime-elements.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,7 @@ export namespace Components {
560560
}
561561
export interface LimelMarkdown {
562562
"lazyLoadImages": boolean;
563+
"removeEmptyParagraphs": boolean;
563564
"value": string;
564565
// @alpha
565566
"whitelist"?: CustomElementDefinition[];
@@ -1752,6 +1753,7 @@ export namespace JSX {
17521753
}
17531754
export interface LimelMarkdown {
17541755
"lazyLoadImages"?: boolean;
1756+
"removeEmptyParagraphs"?: boolean;
17551757
"value"?: string;
17561758
// @alpha
17571759
"whitelist"?: CustomElementDefinition[];
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
:host(limel-example-markdown-remove-empty-paragraphs) {
2+
display: grid;
3+
gap: 1rem;
4+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
5+
}
6+
7+
limel-markdown {
8+
display: block;
9+
padding: 0.5rem 1rem;
10+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Component, h, Host } from '@stencil/core';
2+
3+
const markdownWithEmptyParagraphs = `
4+
<p>In some use cases, empty paragraphs may be desired to keep certain spacing
5+
in the content, while in other cases they may be unwanted and just add
6+
unnecessary vertical space.</p>
7+
<p></p>
8+
<p><p></p></p>
9+
<p>Having an empty paragraph like the above, or like the one below this paragraph</p>
10+
<p>&nbsp;</p>
11+
<p>can easily happen when the <code>value</code> is simply some HTML output
12+
that comes for instance from an email.</p>
13+
<p class="something"><br></p>
14+
<p>Many email editors do not have standard typographic margins between paragraphs,
15+
which force users to manually add empty spaces between their lines,
16+
by pressing the <kbd>Enter</kbd> key multiple times, while typing an email.</p>
17+
<p><span style="font-size:10.5pt; color:#333333">&nbsp;</span></p>
18+
<p><span></span></p>
19+
<p> </p>
20+
<p>Rendering all the empty paragraphs that you see in this example would just add lots
21+
of vertical scrolling to large content.</p>
22+
`;
23+
24+
/**
25+
* Removing empty paragraphs
26+
*
27+
* Sometimes, when a `value` in HTML format is imported from an external source, it
28+
* may contain undesired data. This could be for instance malicious scripts,
29+
* or styles that may be used to visually hide unwanted content from the reader.
30+
*
31+
* This component does its best to sanitize the input HTML, and clean it up
32+
* before rendering the content.
33+
*
34+
* However, one of the things that the component cannot fully decide by its own is
35+
* rendering empty paragraphs. Empty paragraphs are paragraphs that do not contain
36+
* any meaningful content (text, images, etc.), or only contain whitespace
37+
* (`<br />` or `&nbsp;`).
38+
*
39+
* By setting the `removeEmptyParagraphs` property to `false`, all empty paragraphs
40+
* will be preserved before rendering the content.
41+
*/
42+
@Component({
43+
tag: 'limel-example-markdown-remove-empty-paragraphs',
44+
shadow: true,
45+
styleUrl: 'markdown-remove-empty-paragraphs.scss',
46+
})
47+
export class MarkdownRemoveEmptyParagraphsExample {
48+
public render() {
49+
return (
50+
<Host>
51+
<section>
52+
<limel-header
53+
icon="multiline_text"
54+
heading="Default rendering"
55+
subheading="Empty paragraphs removed"
56+
/>
57+
<limel-markdown value={markdownWithEmptyParagraphs} />
58+
</section>
59+
<section>
60+
<limel-header
61+
icon="space_after_paragraph"
62+
heading="Empty paragraphs preserved"
63+
subheading="`removeEmptyParagraphs={false}`"
64+
/>
65+
<limel-markdown
66+
value={markdownWithEmptyParagraphs}
67+
removeEmptyParagraphs={false}
68+
/>
69+
</section>
70+
</Host>
71+
);
72+
}
73+
}

src/components/markdown/markdown-parser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Schema } from 'rehype-sanitize/lib';
1313
import { createLazyLoadImagesPlugin } from './image-markdown-plugin';
1414
import { CustomElementDefinition } from '../../global/shared-types/custom-element.types';
1515
import { createLinksPlugin } from './link-markdown-plugin';
16+
import { createRemoveEmptyParagraphsPlugin } from './remove-empty-paragraphs-plugin';
1617

1718
/**
1819
* Takes a string as input and returns a new string
@@ -52,6 +53,7 @@ export async function markdownToHTML(
5253
visit(tree, 'element', sanitizeStyle);
5354
};
5455
})
56+
.use(createRemoveEmptyParagraphsPlugin(options?.removeEmptyParagraphs))
5557
.use(createLazyLoadImagesPlugin(options?.lazyLoadImages))
5658
.use(rehypeStringify)
5759
.process(text);
@@ -129,4 +131,5 @@ export interface MarkdownToHTMLOptions {
129131
forceHardLineBreaks?: boolean;
130132
whitelist?: CustomElementDefinition[];
131133
lazyLoadImages?: boolean;
134+
removeEmptyParagraphs?: boolean;
132135
}

src/components/markdown/markdown.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import { ImageIntersectionObserver } from './image-intersection-observer';
2020
* @exampleComponent limel-example-markdown-keys
2121
* @exampleComponent limel-example-markdown-blockquotes
2222
* @exampleComponent limel-example-markdown-horizontal-rule
23-
* @exampleComponent limel-example-markdown-composite
2423
* @exampleComponent limel-example-markdown-custom-component
24+
* @exampleComponent limel-example-markdown-remove-empty-paragraphs
25+
* @exampleComponent limel-example-markdown-composite
2526
*/
2627
@Component({
2728
tag: 'limel-markdown',
@@ -55,6 +56,15 @@ export class Markdown {
5556
@Prop({ reflect: true })
5657
public lazyLoadImages = false;
5758

59+
/**
60+
* Set to `false` to preserve empty paragraphs before rendering.
61+
* Empty paragraphs are paragraphs that do not contain
62+
* any meaningful content (text, images, etc.), or only contain
63+
* whitespace (`<br />` or `&nbsp;`).
64+
*/
65+
@Prop({ reflect: true })
66+
public removeEmptyParagraphs = true;
67+
5868
@Watch('value')
5969
public async textChanged() {
6070
try {
@@ -64,6 +74,7 @@ export class Markdown {
6474
forceHardLineBreaks: true,
6575
whitelist: this.whitelist ?? [],
6676
lazyLoadImages: this.lazyLoadImages,
77+
removeEmptyParagraphs: this.removeEmptyParagraphs,
6778
});
6879

6980
this.rootElement.innerHTML = html;
@@ -74,6 +85,11 @@ export class Markdown {
7485
}
7586
}
7687

88+
@Watch('removeEmptyParagraphs')
89+
public handleRemoveEmptyParagraphsChange() {
90+
return this.textChanged();
91+
}
92+
7793
private rootElement: HTMLDivElement;
7894
private imageIntersectionObserver: ImageIntersectionObserver | null = null;
7995

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { createRemoveEmptyParagraphsPlugin } from './remove-empty-paragraphs-plugin';
2+
3+
describe('remove empty paragraphs plugin', () => {
4+
it('keeps empty paragraphs when disabled', () => {
5+
const tree = createRoot([createParagraph()]);
6+
7+
runPlugin(tree, false);
8+
9+
expect(tree.children).toHaveLength(1);
10+
});
11+
12+
it('removes empty paragraphs with only whitespace content', () => {
13+
const tree = createRoot([
14+
createParagraph(),
15+
createParagraph([createText(' ')]),
16+
createParagraph([createText('\n')]),
17+
createParagraph([createText('\u00A0')]),
18+
createParagraph([createElement('span')]),
19+
createParagraph([createElement('span', [createText('\u00A0')])]),
20+
]);
21+
22+
runPlugin(tree, true);
23+
24+
expect(tree.children).toHaveLength(0);
25+
});
26+
27+
it('keeps paragraphs with meaningful content', () => {
28+
const paragraph = createParagraph([
29+
createElement('img', undefined, { src: 'test.jpg' }),
30+
]);
31+
const tree = createRoot([paragraph]);
32+
33+
runPlugin(tree, true);
34+
35+
expect(tree.children).toHaveLength(1);
36+
expect(tree.children[0]).toBe(paragraph);
37+
});
38+
39+
it('keeps text content inside paragraphs', () => {
40+
const paragraph = createParagraph([
41+
createElement('span', [createText('Meaningful text')]),
42+
]);
43+
const tree = createRoot([paragraph]);
44+
45+
runPlugin(tree, true);
46+
47+
expect(tree.children).toHaveLength(1);
48+
expect(tree.children[0]).toBe(paragraph);
49+
});
50+
});
51+
52+
const runPlugin = (tree: any, enabled: boolean) => {
53+
const plugin = createRemoveEmptyParagraphsPlugin(enabled);
54+
const transformer = plugin.call(mockProcessor);
55+
56+
if (typeof transformer === 'function') {
57+
transformer(tree);
58+
}
59+
};
60+
61+
const mockProcessor: any = {};
62+
mockProcessor.data = () => mockProcessor;
63+
64+
const createRoot = (children: any[] = []) => ({
65+
type: 'root',
66+
children,
67+
});
68+
69+
const createParagraph = (children: any[] = []) => ({
70+
type: 'element',
71+
tagName: 'p',
72+
properties: {},
73+
children,
74+
});
75+
76+
const createElement = (
77+
tagName: string,
78+
children: any[] = [],
79+
properties: Record<string, any> = {}
80+
) => ({
81+
type: 'element',
82+
tagName,
83+
properties,
84+
children,
85+
});
86+
87+
const createText = (value: string) => ({
88+
type: 'text',
89+
value,
90+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Plugin, Transformer } from 'unified';
2+
import { Node } from 'unist';
3+
4+
export const createRemoveEmptyParagraphsPlugin = (enabled = false): Plugin => {
5+
return (): Transformer => {
6+
if (!enabled) {
7+
return (tree: Node) => tree;
8+
}
9+
10+
return (tree: Node) => {
11+
pruneEmptyParagraphs(tree, null);
12+
13+
return tree;
14+
};
15+
};
16+
};
17+
18+
const NBSP_REGEX = /\u00A0/g;
19+
const MEANINGFUL_VOID_ELEMENTS = new Set([
20+
'audio',
21+
'canvas',
22+
'embed',
23+
'iframe',
24+
'img',
25+
'input',
26+
'object',
27+
'svg',
28+
'video',
29+
]);
30+
31+
const TREAT_AS_EMPTY_ELEMENTS = new Set(['br']);
32+
33+
const pruneEmptyParagraphs = (node: any, parent: any) => {
34+
if (!node || typeof node !== 'object') {
35+
return;
36+
}
37+
38+
if (
39+
node.type === 'element' &&
40+
node.tagName === 'p' &&
41+
parent &&
42+
isParagraphEffectivelyEmpty(node) &&
43+
Array.isArray(parent.children)
44+
) {
45+
const index = parent.children.indexOf(node);
46+
47+
if (index !== -1) {
48+
parent.children.splice(index, 1);
49+
return;
50+
}
51+
}
52+
53+
if (!Array.isArray(node.children) || node.children.length === 0) {
54+
return;
55+
}
56+
57+
for (let i = node.children.length - 1; i >= 0; i--) {
58+
pruneEmptyParagraphs(node.children[i], node);
59+
}
60+
};
61+
62+
const isParagraphEffectivelyEmpty = (element: any): boolean => {
63+
if (!Array.isArray(element.children) || element.children.length === 0) {
64+
return true;
65+
}
66+
67+
return element.children.every((child: any) =>
68+
isNodeEffectivelyEmpty(child)
69+
);
70+
};
71+
72+
const isNodeEffectivelyEmpty = (node: any): boolean => {
73+
if (!node) {
74+
return true;
75+
}
76+
77+
if (node.type === 'text') {
78+
return isWhitespace(typeof node.value === 'string' ? node.value : '');
79+
}
80+
81+
if (node.type === 'comment') {
82+
return true;
83+
}
84+
85+
if (node.type === 'element') {
86+
const element = node;
87+
const tagName = element.tagName;
88+
89+
if (typeof tagName !== 'string') {
90+
return true;
91+
}
92+
93+
if (MEANINGFUL_VOID_ELEMENTS.has(tagName)) {
94+
return false;
95+
}
96+
97+
if (TREAT_AS_EMPTY_ELEMENTS.has(tagName)) {
98+
return true;
99+
}
100+
101+
if (!Array.isArray(element.children) || element.children.length === 0) {
102+
return true;
103+
}
104+
105+
return element.children.every((child: any) =>
106+
isNodeEffectivelyEmpty(child)
107+
);
108+
}
109+
110+
return true;
111+
};
112+
113+
const isWhitespace = (value: string): boolean => {
114+
if (!value) {
115+
return true;
116+
}
117+
118+
return value.replaceAll(NBSP_REGEX, ' ').trim() === '';
119+
};

0 commit comments

Comments
 (0)