Skip to content

Commit

Permalink
feat: preserve formatting linebreak whitespace (#497)
Browse files Browse the repository at this point in the history
  • Loading branch information
YvesRijckaert authored Oct 2, 2023
1 parent 389cd33 commit e62ab92
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 31 deletions.
81 changes: 81 additions & 0 deletions packages/rich-text-html-renderer/src/__test__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,85 @@ describe('documentToHtmlString', () => {
it('does not crash with undefined documents', () => {
expect(documentToHtmlString(undefined as Document)).toEqual('');
});

it('preserves whitespace with preserveWhitespace option', () => {
const document: Document = {
nodeType: BLOCKS.DOCUMENT,
data: {},
content: [
{
nodeType: BLOCKS.PARAGRAPH,
data: {},
content: [
{
nodeType: 'text',
value: 'hello world',
marks: [],
data: {},
},
],
},
],
};
const options: Options = {
preserveWhitespace: true,
};
const expected = '<p>hello&nbsp;&nbsp;&nbsp;&nbsp;world</p>';

expect(documentToHtmlString(document, options)).toEqual(expected);
});

it('preserves line breaks with preserveWhitespace option', () => {
const document: Document = {
nodeType: BLOCKS.DOCUMENT,
data: {},
content: [
{
nodeType: BLOCKS.PARAGRAPH,
data: {},
content: [
{
nodeType: 'text',
value: 'hello\nworld',
marks: [],
data: {},
},
],
},
],
};
const options: Options = {
preserveWhitespace: true,
};
const expected = '<p>hello<br/>world</p>';

expect(documentToHtmlString(document, options)).toEqual(expected);
});

it('preserves both spaces and line breaks with preserveWhitespace option', () => {
const document: Document = {
nodeType: BLOCKS.DOCUMENT,
data: {},
content: [
{
nodeType: BLOCKS.PARAGRAPH,
data: {},
content: [
{
nodeType: 'text',
value: 'hello \n world',
marks: [],
data: {},
},
],
},
],
};
const options: Options = {
preserveWhitespace: true,
};
const expected = '<p>hello&nbsp;&nbsp;&nbsp;<br/>&nbsp;&nbsp;world</p>';

expect(documentToHtmlString(document, options)).toEqual(expected);
});
});
32 changes: 27 additions & 5 deletions packages/rich-text-html-renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export interface Options {
* Mark renderers
*/
renderMark?: RenderMark;
/**
* Keep line breaks and multiple spaces
*/
preserveWhitespace?: boolean;
}

/**
Expand All @@ -102,16 +106,33 @@ export function documentToHtmlString(
...defaultMarkRenderers,
...options.renderMark,
},
preserveWhitespace: options.preserveWhitespace,
});
}

function nodeListToHtmlString(nodes: CommonNode[], { renderNode, renderMark }: Options): string {
return nodes.map<string>((node) => nodeToHtmlString(node, { renderNode, renderMark })).join('');
function nodeListToHtmlString(
nodes: CommonNode[],
{ renderNode, renderMark, preserveWhitespace }: Options,
): string {
return nodes
.map<string>((node) => nodeToHtmlString(node, { renderNode, renderMark, preserveWhitespace }))
.join('');
}

function nodeToHtmlString(node: CommonNode, { renderNode, renderMark }: Options): string {
function nodeToHtmlString(
node: CommonNode,
{ renderNode, renderMark, preserveWhitespace }: Options,
): string {
if (helpers.isText(node)) {
const nodeValue = escape(node.value);
let nodeValue = escape(node.value);

// If preserveWhitespace is true, handle line breaks and spaces.
if (preserveWhitespace) {
nodeValue = nodeValue
.replace(/\n/g, '<br/>')
.replace(/ {2,}/g, (match) => '&nbsp;'.repeat(match.length));
}

if (node.marks.length > 0) {
return node.marks.reduce((value: string, mark: Mark) => {
if (!renderMark[mark.type]) {
Expand All @@ -123,7 +144,8 @@ function nodeToHtmlString(node: CommonNode, { renderNode, renderMark }: Options)

return nodeValue;
} else {
const nextNode: Next = (nodes) => nodeListToHtmlString(nodes, { renderMark, renderNode });
const nextNode: Next = (nodes) =>
nodeListToHtmlString(nodes, { renderMark, renderNode, preserveWhitespace });
if (!node.nodeType || !renderNode[node.nodeType]) {
// TODO: Figure what to return when passed an unrecognized node.
return '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ Array [
exports[`documentToReactComponents renders asset hyperlink 1`] = `
Array [
<p>
<span>
type:
type:
asset-hyperlink
id:
id:
9mpxT4zsRi6Iwukey8KeM
</span>
</p>,
]
`;
Expand Down Expand Up @@ -49,14 +49,14 @@ Array [
exports[`documentToReactComponents renders embedded entry 1`] = `
Array [
<p>
<span>
type:
type:
embedded-entry-inline
id:
id:
9mpxT4zsRi6Iwukey8KeM
</span>
</p>,
]
`;
Expand All @@ -72,14 +72,14 @@ Array [
exports[`documentToReactComponents renders entry hyperlink 1`] = `
Array [
<p>
<span>
type:
type:
entry-hyperlink
id:
id:
9mpxT4zsRi6Iwukey8KeM
</span>
</p>,
]
`;
Expand All @@ -91,15 +91,15 @@ Array [
</p>,
<hr />,
<p>
</p>,
]
`;

exports[`documentToReactComponents renders hyperlink 1`] = `
Array [
<p>
Some text
Some text
<a
href="https://url.org"
>
Expand Down Expand Up @@ -244,7 +244,7 @@ Array [
</li>
</ol>,
<p>
</p>,
]
`;
Expand Down Expand Up @@ -346,7 +346,7 @@ Array [
</li>
</ul>,
<p>
</p>,
]
`;
Expand All @@ -358,7 +358,7 @@ Array [
</p>,
<hr />,
<p>
</p>,
]
`;
Expand Down Expand Up @@ -421,3 +421,21 @@ exports[`nodeToReactComponent renders valid nodes 1`] = `
hello world
</p>
`;

exports[`preserveWhitespace preserves new lines 1`] = `
Array [
<p>
hello
<br />
world
</p>,
]
`;

exports[`preserveWhitespace preserves spaces between words 1`] = `
Array [
<p>
hello&nbsp;&nbsp;&nbsp;&nbsp;world
</p>,
]
`;
52 changes: 52 additions & 0 deletions packages/rich-text-react-renderer/src/__test__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,55 @@ describe('nodeListToReactComponents', () => {
expect(renderedNodes).toMatchSnapshot();
});
});

describe.only('preserveWhitespace', () => {
it('preserves spaces between words', () => {
const options: Options = {
preserveWhitespace: true,
};
const document: Document = {
nodeType: BLOCKS.DOCUMENT,
data: {},
content: [
{
nodeType: BLOCKS.PARAGRAPH,
data: {},
content: [
{
nodeType: 'text',
value: 'hello world',
marks: [],
data: {},
},
],
},
],
};
expect(documentToReactComponents(document, options)).toMatchSnapshot();
});

it('preserves new lines', () => {
const options: Options = {
preserveWhitespace: true,
};
const document: Document = {
nodeType: BLOCKS.DOCUMENT,
data: {},
content: [
{
nodeType: BLOCKS.PARAGRAPH,
data: {},
content: [
{
nodeType: 'text',
value: 'hello\nworld',
marks: [],
data: {},
},
],
},
],
};
expect(documentToReactComponents(document, options)).toMatchSnapshot();
});
});
5 changes: 5 additions & 0 deletions packages/rich-text-react-renderer/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export interface Options {
* Text renderer
*/
renderText?: RenderText;
/**
* Keep line breaks and multiple spaces
*/
preserveWhitespace?: boolean;
}

/**
Expand All @@ -103,5 +107,6 @@ export function documentToReactComponents(
...options.renderMark,
},
renderText: options.renderText,
preserveWhitespace: options.preserveWhitespace,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,33 @@ export function nodeListToReactComponents(nodes: CommonNode[], options: Options)
}

export function nodeToReactComponent(node: CommonNode, options: Options): ReactNode {
const { renderNode, renderMark, renderText } = options;
const { renderNode, renderMark, renderText, preserveWhitespace } = options;

if (helpers.isText(node)) {
return node.marks.reduce(
(value: ReactNode, mark: Mark): ReactNode => {
if (!renderMark[mark.type]) {
return value;
let nodeValue: ReactNode = renderText ? renderText(node.value) : node.value;

if (preserveWhitespace) {
// Preserve multiple spaces.
nodeValue = (nodeValue as string).replace(/ {2,}/g, (match) => '&nbsp;'.repeat(match.length));

// Preserve line breaks.
let lines = (nodeValue as string).split('\n');
let jsxLines: (string | JSX.Element)[] = [];
lines.forEach((line, index) => {
jsxLines.push(line);
if (index !== lines.length - 1) {
jsxLines.push(<br />);
}
return renderMark[mark.type](value);
},
renderText ? renderText(node.value) : node.value,
);
});
nodeValue = jsxLines;
}

return node.marks.reduce((value: ReactNode, mark: Mark): ReactNode => {
if (!renderMark[mark.type]) {
return value;
}
return renderMark[mark.type](value);
}, nodeValue);
} else {
const children: ReactNode = nodeListToReactComponents(node.content, options);
if (!node.nodeType || !renderNode[node.nodeType]) {
Expand Down

0 comments on commit e62ab92

Please sign in to comment.