diff --git a/.changeset/rte-comment-long-word-wrap.md b/.changeset/rte-comment-long-word-wrap.md
new file mode 100644
index 0000000000..cfa38c4957
--- /dev/null
+++ b/.changeset/rte-comment-long-word-wrap.md
@@ -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.
diff --git a/packages/rte/src/components/RichTextEditor/LongContentWrapping.stories.tsx b/packages/rte/src/components/RichTextEditor/LongContentWrapping.stories.tsx
new file mode 100644
index 0000000000..3cebf2169f
--- /dev/null
+++ b/packages/rte/src/components/RichTextEditor/LongContentWrapping.stories.tsx
@@ -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 = () => ;
+
+const Resizable = ({ label, children }: { label: string; children: ReactNode }) => (
+
+
+ {label} — drag the bottom-right corner to resize the width.
+
+
+ {children}
+
+
+);
+
+// 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 = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default {
+ title: 'Legacy Components/Rich Text Editor/Long Content Wrapping',
+ component: RichTextEditor,
+} as Meta;
+
+export const LongUnbreakableContent = Template.bind({});
diff --git a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx
index b8cc37cca1..e9805ad71e 100644
--- a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx
+++ b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx
@@ -48,7 +48,7 @@ const ListBullet = () => {
export const ListItemContentMarkupElementNode = ({ attributes, children, element }: PlateRenderElementProps) => {
return (
-
+
{children}
diff --git a/packages/rte/src/components/RichTextEditor/Plugins/TextStylePlugin/TextStyles/paragraphPlugin.tsx b/packages/rte/src/components/RichTextEditor/Plugins/TextStylePlugin/TextStyles/paragraphPlugin.tsx
index 0dd907fba2..9a99d3c2f9 100644
--- a/packages/rte/src/components/RichTextEditor/Plugins/TextStylePlugin/TextStyles/paragraphPlugin.tsx
+++ b/packages/rte/src/components/RichTextEditor/Plugins/TextStylePlugin/TextStyles/paragraphPlugin.tsx
@@ -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;
diff --git a/packages/rte/src/components/RichTextEditor/__tests__/LongContentWrapping.cy.tsx b/packages/rte/src/components/RichTextEditor/__tests__/LongContentWrapping.cy.tsx
new file mode 100644
index 0000000000..a427d0913c
--- /dev/null
+++ b/packages/rte/src/components/RichTextEditor/__tests__/LongContentWrapping.cy.tsx
@@ -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 }) => (
+
+);
+
+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', () => {
+ cy.mount(
+
+
+ ,
+ );
+
+ expectNoHorizontalOverflow();
+ });
+
+ it('wraps a long token while editing a list item', () => {
+ cy.mount(
+
+
+ ,
+ );
+
+ expectNoHorizontalOverflow();
+ });
+
+ it('wraps a long token in the serialized (posted) list item', () => {
+ const html = serializeRawToHtml(unorderedListValue, listPlugins);
+
+ cy.mount(
+
+ {/* serializer output of a fixed test string — no untrusted input */}
+ {/* eslint-disable-next-line @eslint-react/dom-no-dangerously-set-innerhtml */}
+
+ ,
+ );
+
+ expectNoHorizontalOverflow();
+ });
+});
diff --git a/packages/rte/src/components/RichTextEditor/serializer/serializeToHtml.spec.ts b/packages/rte/src/components/RichTextEditor/serializer/serializeToHtml.spec.ts
index 4e39538af8..2fbc27cc97 100644
--- a/packages/rte/src/components/RichTextEditor/serializer/serializeToHtml.spec.ts
+++ b/packages/rte/src/components/RichTextEditor/serializer/serializeToHtml.spec.ts
@@ -108,7 +108,7 @@ describe('serializeNodesToHtml()', () => {
const result = serializeNodesToHtml(node);
expect(result).to.equal(
- '
',
+ '
',
);
});
@@ -130,7 +130,7 @@ describe('serializeNodesToHtml()', () => {
const result = serializeNodesToHtml(node);
expect(result).to.equal(
- 'First paragraph
Third paragraph
',
+ 'First paragraph
Third paragraph
',
);
});
});
diff --git a/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.spec.ts b/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.spec.ts
index 7fa3932538..d778245e9e 100644
--- a/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.spec.ts
+++ b/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.spec.ts
@@ -70,7 +70,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});
expect(result).to.be.equal(
- `First item
Second item
`,
+ `First item
Second item
`,
);
});
@@ -102,7 +102,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});
expect(result).to.be.equal(
- `First item
Second item
`,
+ `First item
Second item
`,
);
});
@@ -192,7 +192,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});
expect(result).to.be.equal(
- ``,
+ ``,
);
});
@@ -418,7 +418,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});
expect(result).to.be.equal(
- 'This is a Link.
',
+ 'This is a Link.
',
);
});
@@ -592,7 +592,7 @@ describe('serializeNodeToHtmlRecursive()', () => {
const result = serializeNodeToHtmlRecursive(node, defaultStyles, {});
expect(result).to.be.equal(
- 'This is .
',
+ 'This is .
',
);
});
});
diff --git a/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.ts b/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.ts
index 112b3c22dc..120479c2d9 100644
--- a/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.ts
+++ b/packages/rte/src/components/RichTextEditor/serializer/utils/serializeNodeToHtmlRecursive.ts
@@ -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]);
};