Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rte-comment-long-word-wrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@frontify/fondue-rte": patch
---

fix(RichTextEditor): wrap long words and URLs instead of scrolling horizontally

Long unbreakable strings (URLs, long words) in rich text no longer force a sideways scroll. Paragraphs and list items — while editing and in the serialized rendition — use `overflow-wrap: anywhere`, which lowers the reported min-content width so the text wraps even inside a shrink-wrapping container (such as a `ScrollArea` viewport). Genuinely wide, unshrinkable content (fixed-width media, `nowrap`) still scrolls horizontally.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* (c) Copyright Frontify Ltd., all rights reserved. */

import { ScrollArea } from '@frontify/fondue-components';
import { type Meta, type StoryFn } from '@storybook/react-vite';
import { ELEMENT_LI, ELEMENT_LIC, ELEMENT_UL } from '@udecode/plate-list';
import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph';
import { type CSSProperties, type ReactNode } from 'react';

import {
OrderedListPlugin,
ParagraphPlugin,
PluginComposer,
SoftBreakPlugin,
TextStylePlugin,
UnorderedListPlugin,
} from './Plugins';
import { RichTextEditor } from './RichTextEditor';

const LONG_URL = `https://example.com/some/really/long/path/${'segment'.repeat(20)}?token=${'x'.repeat(80)}`;
const LONG_WORD = 'a'.repeat(120);

const plugins = new PluginComposer()
.setPlugin(new SoftBreakPlugin())
.setPlugin(new TextStylePlugin({ textStyles: [new ParagraphPlugin()] }))
.setPlugin([new UnorderedListPlugin(), new OrderedListPlugin()]);

const value = JSON.stringify([
{ type: ELEMENT_PARAGRAPH, children: [{ text: `Paragraph with a long URL: ${LONG_URL}` }] },
{
type: ELEMENT_UL,
children: [
{ type: ELEMENT_LI, children: [{ type: ELEMENT_LIC, children: [{ text: `List item, long word: ${LONG_WORD}` }] }] },
{ type: ELEMENT_LI, children: [{ type: ELEMENT_LIC, children: [{ text: `List item, long URL: ${LONG_URL}` }] }] },
],
},
]);

const Editor = () => <RichTextEditor border={false} plugins={plugins} value={value} />;

const Resizable = ({ label, children }: { label: string; children: ReactNode }) => (
<div style={{ marginBottom: 32 }}>
<p style={{ marginBottom: 8, fontFamily: 'sans-serif', fontSize: 13, color: '#555' }}>
{label} — drag the bottom-right corner to resize the width.
</p>
<div
style={{
resize: 'horizontal',
overflow: 'auto',
width: 360,
minWidth: 120,
maxWidth: '100%',
border: '1px solid #d1d1d1',
borderRadius: 4,
padding: 8,
}}
>
{children}
</div>
</div>
);

// Radix ScrollArea's viewport wraps its content in `display: table; min-width: 100%`, which
// shrink-wraps to the widest token — the context in which the overflow bug appears.
const SHRINK_WRAP_CONTEXT: CSSProperties = { display: 'table', minWidth: '100%' };

const Template: StoryFn = () => (
<div style={{ maxWidth: 680 }}>
<Resizable label="Shrink-wrapping context (reproduces the bug — mirrors Radix ScrollArea's viewport)">
<div style={SHRINK_WRAP_CONTEXT}>
<Editor />
</div>
</Resizable>

<Resizable label="Real Fondue ScrollArea">
<ScrollArea maxHeight="200px">
<Editor />
</ScrollArea>
</Resizable>

<Resizable label="Plain block (baseline — always wraps)">
<Editor />
</Resizable>
</div>
);

export default {
title: 'Legacy Components/Rich Text Editor/Long Content Wrapping',
component: RichTextEditor,
} as Meta;

export const LongUnbreakableContent = Template.bind({});
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const ListBullet = () => {

export const ListItemContentMarkupElementNode = ({ attributes, children, element }: PlateRenderElementProps) => {
return (
<p className={getLicElementClassNames(element)} {...attributes}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

break-word doesn't affect the reported min-content width of the text element, it only wraps the text visually, therefore causing the width to still grow.

overflow-wrap: anywhere also lowers the reported min width, but might have other implications that need to be checked

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought so too, but I couldn't test it manually. How can we test this manually? Maybe it's right somehow as the tests seem to be correct – but I don't believe them in this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could now test it manually and fixed it. You were right (unsurprisingly). The overflow-wrap: anywhere seems to fix it. See the updated description for the fix.

<p className={merge([getLicElementClassNames(element), '[overflow-wrap:anywhere]'])} {...attributes}>
<ListBullet />
<span className={LIST_ITEM_SPAN_CLASSES}>{children}</span>
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class ParagraphPlugin extends Plugin {
}
}

export const PARAGRAPH_CLASSES = 'tw-m-0 tw-px-0 tw-py-0';
export const PARAGRAPH_CLASSES = 'tw-m-0 tw-px-0 tw-py-0 [overflow-wrap:anywhere]';

export const ParagraphMarkupElementNode = ({ element, attributes, children, styles }: TextStyleRenderElementProps) => {
const align = element.align as string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/* (c) Copyright Frontify Ltd., all rights reserved. */

import { ELEMENT_LI, ELEMENT_LIC, ELEMENT_UL } from '@udecode/plate-list';
import { ELEMENT_PARAGRAPH } from '@udecode/plate-paragraph';
import { type CSSProperties, type ReactElement } from 'react';

import { OrderedListPlugin, PluginComposer, UnorderedListPlugin } from '../Plugins';
import { RichTextEditor } from '../RichTextEditor';
import { serializeRawToHtml } from '../serializer';

const CONTAINER_WIDTH = 240;
const LONG_UNBREAKABLE_TOKEN = `https://example.com/${'a'.repeat(160)}`;
const SCROLL_CONTAINER_TEST_ID = 'wrap-scroll-container';

const listPlugins = new PluginComposer().setPlugin([new UnorderedListPlugin(), new OrderedListPlugin()]);

// Mirrors Radix ScrollArea's viewport wrapper (`display: table; min-width: 100%`): it shrink-wraps to
// the widest token, the context where `overflow-wrap: break-word` fails to wrap and only `anywhere`
// (which lowers the reported min-content width) does.
const RADIX_VIEWPORT_CONTENT: CSSProperties = { display: 'table', minWidth: '100%' };

const InShrinkWrappingScrollContainer = ({ children }: { children: ReactElement }) => (
<div data-test-id={SCROLL_CONTAINER_TEST_ID} style={{ width: CONTAINER_WIDTH, overflow: 'auto' }}>
<div style={RADIX_VIEWPORT_CONTENT}>{children}</div>
</div>
);

const paragraphValue = JSON.stringify([{ type: ELEMENT_PARAGRAPH, children: [{ text: LONG_UNBREAKABLE_TOKEN }] }]);

const unorderedListValue = JSON.stringify([
{
type: ELEMENT_UL,
children: [{ type: ELEMENT_LI, children: [{ type: ELEMENT_LIC, children: [{ text: LONG_UNBREAKABLE_TOKEN }] }] }],
},
]);

const expectNoHorizontalOverflow = () => {
cy.get(`[data-test-id=${SCROLL_CONTAINER_TEST_ID}]`).should('contain.text', 'https://example.com/');
cy.get(`[data-test-id=${SCROLL_CONTAINER_TEST_ID}]`).should(($container) => {
const { scrollWidth, clientWidth } = $container[0];
expect(
scrollWidth,
`content width (${scrollWidth}px) stays within its container (${clientWidth}px) — the long token wrapped instead of forcing a horizontal scrollbar`,
).to.be.at.most(clientWidth + 1);
});
};

describe('RichTextEditor wraps long unbreakable content in a shrink-wrapping container', () => {
it('wraps a long token while editing a paragraph', () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also have the should house rule in Fondue? Then I'll update this and add it to the harness.

cy.mount(
<InShrinkWrappingScrollContainer>
<RichTextEditor border={false} value={paragraphValue} />
</InShrinkWrappingScrollContainer>,
);

expectNoHorizontalOverflow();
});

it('wraps a long token while editing a list item', () => {
cy.mount(
<InShrinkWrappingScrollContainer>
<RichTextEditor border={false} plugins={listPlugins} value={unorderedListValue} />
</InShrinkWrappingScrollContainer>,
);

expectNoHorizontalOverflow();
});

it('wraps a long token in the serialized (posted) list item', () => {
const html = serializeRawToHtml(unorderedListValue, listPlugins);

cy.mount(
<InShrinkWrappingScrollContainer>
{/* serializer output of a fixed test string — no untrusted input */}
{/* eslint-disable-next-line @eslint-react/dom-no-dangerously-set-innerhtml */}
<div dangerouslySetInnerHTML={{ __html: html }} />
</InShrinkWrappingScrollContainer>,
);

expectNoHorizontalOverflow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('serializeNodesToHtml()', () => {

const result = serializeNodesToHtml(node);
expect(result).to.equal(
'<p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;">&#xFEFF;</p>',
'<p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;">&#xFEFF;</p>',
);
});

Expand All @@ -130,7 +130,7 @@ describe('serializeNodesToHtml()', () => {

const result = serializeNodesToHtml(node);
expect(result).to.equal(
'<p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;">First paragraph</p><p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;">&#xFEFF;</p><p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;">Third paragraph</p>',
'<p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;">First paragraph</p><p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;">&#xFEFF;</p><p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;">Third paragraph</p>',
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});

expect(result).to.be.equal(
`<ol dir="auto" class="${OL_CLASSES} tw-break-words"><li dir="auto" class="tw-break-words !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="tw-break-words tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">First item</span></p></li><li dir="auto" class="tw-break-words !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="tw-break-words tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">Second item</span></p></li></ol>`,
`<ol dir="auto" class="${OL_CLASSES} [overflow-wrap:anywhere]"><li dir="auto" class="[overflow-wrap:anywhere] !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="[overflow-wrap:anywhere] tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">First item</span></p></li><li dir="auto" class="[overflow-wrap:anywhere] !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="[overflow-wrap:anywhere] tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">Second item</span></p></li></ol>`,
);
});

Expand Down Expand Up @@ -102,7 +102,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});

expect(result).to.be.equal(
`<ol dir="auto" class="${OL_CLASSES} tw-break-words"><li dir="auto" class="tw-break-words !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="tw-break-words tw-break-after-column tw-break-inside-avoid-column tw-justify-start tw-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">First item</span></p></li><li dir="auto" class="tw-break-words !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="tw-break-words tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">Second item</span></p></li></ol>`,
`<ol dir="auto" class="${OL_CLASSES} [overflow-wrap:anywhere]"><li dir="auto" class="[overflow-wrap:anywhere] !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="[overflow-wrap:anywhere] tw-break-after-column tw-break-inside-avoid-column tw-justify-start tw-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">First item</span></p></li><li dir="auto" class="[overflow-wrap:anywhere] !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="[overflow-wrap:anywhere] tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: counter(list-counter, decimal) '.'; --bullet-color: currentColor;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">Second item</span></p></li></ol>`,
);
});

Expand Down Expand Up @@ -192,7 +192,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});

expect(result).to.be.equal(
`<ul dir="auto" class="${UL_CLASSES} tw-break-words"><li dir="auto" class="tw-break-words !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="tw-break-words tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: '\u2022'; --bullet-color: currentColor; --bullet-size: 1em;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">This comes first.</span></p></li></ul>`,
`<ul dir="auto" class="${UL_CLASSES} [overflow-wrap:anywhere]"><li dir="auto" class="[overflow-wrap:anywhere] !tw-no-underline tw-flex tw-flex-col [--parent-lh:1lh]" style="font-size: 14px; font-style: normal; font-weight: normal; counter-increment: list-counter;"><p dir="auto" class="[overflow-wrap:anywhere] tw-justify-start tw-inline-flex"><span class="${LIST_BULLET_CONTAINER_CLASSES}" style="--bullet-content: '\u2022'; --bullet-color: currentColor; --bullet-size: 1em;"></span><span class="${LIST_ITEM_SPAN_CLASSES}">This comes first.</span></p></li></ul>`,
);
});

Expand Down Expand Up @@ -418,7 +418,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});

expect(result).to.be.equal(
'<p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;"><a dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; color: rgba(182, 10, 227, 1); text-decoration: underline; cursor: pointer;" target="_self" href="https://frontify.com">This is a Link.</a></p>',
'<p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;"><a dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; color: rgba(182, 10, 227, 1); text-decoration: underline; cursor: pointer;" target="_self" href="https://frontify.com">This is a Link.</a></p>',
);
});

Expand Down Expand Up @@ -592,7 +592,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});

expect(result).to.be.equal(
'<p dir="auto" class="tw-break-words" style="font-size: 14px; font-style: normal; font-weight: normal;">This is &#xFEFF;.</p>',
'<p dir="auto" class="[overflow-wrap:anywhere]" style="font-size: 14px; font-style: normal; font-weight: normal;">This is &#xFEFF;.</p>',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ const getTextStyleHtml = (
) => `<${htmlTag} dir="auto" class="${classNames}" style="${reactCssPropsToCss(styles[tag])}">${children}</${htmlTag}>`;

const getClassNames = (breakAfterColumn?: string, align?: string) => {
const breakWordsClass = 'tw-break-words';
const overflowWrapClass = '[overflow-wrap:anywhere]';
const columnBreakClasses =
breakAfterColumn === 'active' ? 'tw-break-after-column tw-break-inside-avoid-column' : '';
const alignClass = align ? alignmentClassnames[align] : '';
return merge([alignClass, breakWordsClass, columnBreakClasses]);
return merge([alignClass, overflowWrapClass, columnBreakClasses]);
};