diff --git a/packages/rich-text-html-renderer/src/__test__/index.test.ts b/packages/rich-text-html-renderer/src/__test__/index.test.ts index a25db3d4..ea70f06e 100644 --- a/packages/rich-text-html-renderer/src/__test__/index.test.ts +++ b/packages/rich-text-html-renderer/src/__test__/index.test.ts @@ -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 = '

hello    world

'; + + 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 = '

hello
world

'; + + 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 = '

hello   
  world

'; + + expect(documentToHtmlString(document, options)).toEqual(expected); + }); }); diff --git a/packages/rich-text-html-renderer/src/index.ts b/packages/rich-text-html-renderer/src/index.ts index 3da4e2ae..136253b6 100644 --- a/packages/rich-text-html-renderer/src/index.ts +++ b/packages/rich-text-html-renderer/src/index.ts @@ -80,6 +80,10 @@ export interface Options { * Mark renderers */ renderMark?: RenderMark; + /** + * Keep line breaks and multiple spaces + */ + preserveWhitespace?: boolean; } /** @@ -102,16 +106,33 @@ export function documentToHtmlString( ...defaultMarkRenderers, ...options.renderMark, }, + preserveWhitespace: options.preserveWhitespace, }); } -function nodeListToHtmlString(nodes: CommonNode[], { renderNode, renderMark }: Options): string { - return nodes.map((node) => nodeToHtmlString(node, { renderNode, renderMark })).join(''); +function nodeListToHtmlString( + nodes: CommonNode[], + { renderNode, renderMark, preserveWhitespace }: Options, +): string { + return nodes + .map((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, '
') + .replace(/ {2,}/g, (match) => ' '.repeat(match.length)); + } + if (node.marks.length > 0) { return node.marks.reduce((value: string, mark: Mark) => { if (!renderMark[mark.type]) { @@ -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 ''; diff --git a/packages/rich-text-react-renderer/src/__test__/__snapshots__/index.test.tsx.snap b/packages/rich-text-react-renderer/src/__test__/__snapshots__/index.test.tsx.snap index fec753eb..93e4f364 100644 --- a/packages/rich-text-react-renderer/src/__test__/__snapshots__/index.test.tsx.snap +++ b/packages/rich-text-react-renderer/src/__test__/__snapshots__/index.test.tsx.snap @@ -11,14 +11,14 @@ Array [ exports[`documentToReactComponents renders asset hyperlink 1`] = ` Array [

- + - type: + type: asset-hyperlink - id: + id: 9mpxT4zsRi6Iwukey8KeM - +

, ] `; @@ -49,14 +49,14 @@ Array [ exports[`documentToReactComponents renders embedded entry 1`] = ` Array [

- + - type: + type: embedded-entry-inline - id: + id: 9mpxT4zsRi6Iwukey8KeM - +

, ] `; @@ -72,14 +72,14 @@ Array [ exports[`documentToReactComponents renders entry hyperlink 1`] = ` Array [

- + - type: + type: entry-hyperlink - id: + id: 9mpxT4zsRi6Iwukey8KeM - +

, ] `; @@ -91,7 +91,7 @@ Array [

,
,

- +

, ] `; @@ -99,7 +99,7 @@ Array [ exports[`documentToReactComponents renders hyperlink 1`] = ` Array [

- Some text + Some text @@ -244,7 +244,7 @@ Array [ ,

- +

, ] `; @@ -346,7 +346,7 @@ Array [ ,

- +

, ] `; @@ -358,7 +358,7 @@ Array [

,
,

- +

, ] `; @@ -421,3 +421,21 @@ exports[`nodeToReactComponent renders valid nodes 1`] = ` hello world

`; + +exports[`preserveWhitespace preserves new lines 1`] = ` +Array [ +

+ hello +
+ world +

, +] +`; + +exports[`preserveWhitespace preserves spaces between words 1`] = ` +Array [ +

+ hello    world +

, +] +`; diff --git a/packages/rich-text-react-renderer/src/__test__/index.test.tsx b/packages/rich-text-react-renderer/src/__test__/index.test.tsx index 10dfa9de..37e98ca1 100644 --- a/packages/rich-text-react-renderer/src/__test__/index.test.tsx +++ b/packages/rich-text-react-renderer/src/__test__/index.test.tsx @@ -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(); + }); +}); diff --git a/packages/rich-text-react-renderer/src/index.tsx b/packages/rich-text-react-renderer/src/index.tsx index c6fd10c3..56723722 100644 --- a/packages/rich-text-react-renderer/src/index.tsx +++ b/packages/rich-text-react-renderer/src/index.tsx @@ -80,6 +80,10 @@ export interface Options { * Text renderer */ renderText?: RenderText; + /** + * Keep line breaks and multiple spaces + */ + preserveWhitespace?: boolean; } /** @@ -103,5 +107,6 @@ export function documentToReactComponents( ...options.renderMark, }, renderText: options.renderText, + preserveWhitespace: options.preserveWhitespace, }); } diff --git a/packages/rich-text-react-renderer/src/util/nodeListToReactComponents.tsx b/packages/rich-text-react-renderer/src/util/nodeListToReactComponents.tsx index b01875d4..800a79f5 100644 --- a/packages/rich-text-react-renderer/src/util/nodeListToReactComponents.tsx +++ b/packages/rich-text-react-renderer/src/util/nodeListToReactComponents.tsx @@ -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) => ' '.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(
); } - 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]) {