From abdc1f52f09c9f05c792b2f292cfaee153b31aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bernhardsgr=C3=BCtter?= Date: Sun, 5 Jul 2026 14:39:14 +0200 Subject: [PATCH 1/4] fix(RichTextEditor, ScrollArea): wrap long words and URLs instead of scrolling horizontally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long unbreakable strings (URLs, long words) in rich text did not line-break; they overflowed horizontally and forced a sideways scroll — visible in the comment sidebar both while typing and after posting. Two causes: - The live-editor paragraph and list-item markup elements carried no break rule, so a long word could not wrap. List items also render as `inline-flex`, which shrink-wraps to max-content and defeats `overflow-wrap` even where the rule is present (the same reason the serialized rendition of a list item overflowed) — capping them at the container width lets the text wrap. - ScrollArea's Radix viewport wraps children in an inline `display: table; min-width: 100%` element that shrink-wraps to the widest unbreakable token, so a break-enabled paragraph never wrapped. Forcing that wrapper to block bounds it to the viewport width. Genuinely wide, unshrinkable content (fixed-width media, `nowrap`) still scrolls horizontally. Added a ScrollArea component test (real browser) asserting long unbreakable text wraps rather than overflowing; the existing horizontal-scroll tests guard no regression. Co-Authored-By: Claude Opus 4.8 --- .changeset/rte-comment-long-word-wrap.md | 8 ++++++++ .../ScrollArea/__tests__/ScrollArea.ct.tsx | 20 +++++++++++++++++++ .../ScrollArea/styles/scrollArea.module.scss | 10 ++++++++++ .../ListItemContentMarkupElement.tsx | 6 +++++- .../TextStyles/paragraphPlugin.tsx | 2 +- 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .changeset/rte-comment-long-word-wrap.md diff --git a/.changeset/rte-comment-long-word-wrap.md b/.changeset/rte-comment-long-word-wrap.md new file mode 100644 index 0000000000..84e7fa4e4f --- /dev/null +++ b/.changeset/rte-comment-long-word-wrap.md @@ -0,0 +1,8 @@ +--- +"@frontify/fondue-components": patch +"@frontify/fondue-rte": patch +--- + +fix(RichTextEditor, ScrollArea): wrap long words and URLs instead of scrolling horizontally + +Long unbreakable strings in rich text no longer force a sideways scroll. `RichTextEditor` paragraphs and list items now carry a break rule so typed text wraps, and list items are capped at the container width so their `inline-flex` box can no longer shrink-wrap to a long word. `ScrollArea` no longer lets its Radix viewport shrink-wrap to content width, so a break-enabled paragraph wraps to the visible width. Genuinely wide, unshrinkable content (fixed-width media, `nowrap`) still scrolls horizontally. diff --git a/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx index e408bec704..2e5b1248c0 100644 --- a/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx +++ b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx @@ -109,6 +109,26 @@ test('renders scrollbars only when content overflows (and it does)', async ({ mo await expect(horizontalScrollbar).toBeVisible(); }); +test('wraps long unbreakable text instead of overflowing horizontally', async ({ mount }) => { + const longUrl = `https://example.com/${'a'.repeat(200)}`; + const wrapper = await mount( + +

+ {longUrl} +

+
, + ); + const viewport = wrapper.getByTestId(SCROLLAREA_VIEWPORT_TEST_ID); + + const { scrollWidth, clientWidth } = await viewport.evaluate((element) => ({ + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + })); + + // A break-enabled paragraph must wrap to the viewport width, not widen it (long words/URLs). + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); +}); + test('renders scrollbars only when scrolling', async ({ mount }) => { const wrapper = await mount( diff --git a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss index 9a89c62342..73a56ae35c 100644 --- a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss +++ b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss @@ -16,6 +16,16 @@ height: 100%; box-sizing: border-box; + /* Radix wraps the viewport children in an inline `display: table; min-width: 100%` element that + shrink-wraps to max-content. That widens the region to fit the longest unbreakable token, so + `overflow-wrap`/`word-break` on descendants never engages and long words/URLs scroll sideways + instead of wrapping. Forcing the wrapper to block caps it at the viewport width so text wraps; + genuinely-wide content (fixed-width media, nowrap) still overflows and scrolls. `!important` + is required to beat Radix's inline style. */ + > div { + display: block !important; + } + &[data-scroll-padding='tight'] { padding: sizeToken.get(2); } diff --git a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx index b8cc37cca1..4044c24d57 100644 --- a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx +++ b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx @@ -23,6 +23,9 @@ export const getLicElementClassNames = (element: TElement, includeColumnBreakCla includeColumnBreakClasses && getColumnBreakClasses(element), element.align ? justifyClassNames[element.align as string] : 'tw-justify-start', element.breakAfterColumn ? 'tw-flex' : 'tw-inline-flex', + // inline-flex shrink-wraps to max-content, so a long word/URL widens the item and defeats + // break-words; cap it at the container width so the text wraps instead of scrolling sideways. + 'tw-max-w-full', ]); const ListBullet = () => { @@ -48,7 +51,8 @@ const ListBullet = () => { export const ListItemContentMarkupElementNode = ({ attributes, children, element }: PlateRenderElementProps) => { return ( -

+ // break-words matches the serialized rendition (getClassNames); the live editor node lacks it otherwise. +

{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..bed20b0790 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 tw-break-words'; export const ParagraphMarkupElementNode = ({ element, attributes, children, styles }: TextStyleRenderElementProps) => { const align = element.align as string; From 48e30223348106b56f0200b87841557807d1a7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bernhardsgr=C3=BCtter?= Date: Mon, 6 Jul 2026 07:35:53 +0200 Subject: [PATCH 2/4] test(RichTextEditor): update serializer fixtures for tw-max-w-full on list items getLicElementClassNames now emits tw-max-w-full, which the serializer also uses; the three serializeNodeToHtmlRecursive exact-HTML fixtures asserted the pre-change markup and failed. Match them to the intended serialized output. Co-Authored-By: Claude Opus 4.8 --- .../serializer/utils/serializeNodeToHtmlRecursive.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..8cfde4dd59 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( - `
  1. First item

  2. Second item

`, + `
  1. First item

  2. Second item

`, ); }); @@ -102,7 +102,7 @@ describe('serializeNodeToHtmlRecursive()', () => { const result = serializeNodeToHtmlRecursive(node, defaultStyles, {}); expect(result).to.be.equal( - `
  1. First item

  2. Second item

`, + `
  1. First item

  2. Second item

`, ); }); @@ -192,7 +192,7 @@ describe('serializeNodeToHtmlRecursive()', () => { const result = serializeNodeToHtmlRecursive(node, defaultStyles, {}); expect(result).to.be.equal( - `
  • This comes first.

`, + `
  • This comes first.

`, ); }); From a285f4a276b74e26de67958bfac3860158387367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bernhardsgr=C3=BCtter?= Date: Tue, 7 Jul 2026 09:41:22 +0200 Subject: [PATCH 3/4] refactor(RichTextEditor, ScrollArea): cut restating comments; keep only the Radix-override rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply comment scrutiny (default-to-removed, per-clause sole-means): the rte comments on tw-max-w-full / tw-break-words restated the code or general CSS, and the ScrollArea test comment restated its assertion — all removed. The ScrollArea scss comment collapses to one line: why an anonymous `> div` is force-blocked with !important (Radix's inline `display: table` wrapper) is the one fact the code cannot otherwise state. Co-Authored-By: Claude Opus 4.8 --- .../src/components/ScrollArea/__tests__/ScrollArea.ct.tsx | 1 - .../components/ScrollArea/styles/scrollArea.module.scss | 8 ++------ .../Plugins/ListPlugin/ListItemContentMarkupElement.tsx | 3 --- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx index 2e5b1248c0..ab814a25b0 100644 --- a/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx +++ b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx @@ -125,7 +125,6 @@ test('wraps long unbreakable text instead of overflowing horizontally', async ({ clientWidth: element.clientWidth, })); - // A break-enabled paragraph must wrap to the viewport width, not widen it (long words/URLs). expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); }); diff --git a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss index 73a56ae35c..35de58d541 100644 --- a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss +++ b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss @@ -16,12 +16,8 @@ height: 100%; box-sizing: border-box; - /* Radix wraps the viewport children in an inline `display: table; min-width: 100%` element that - shrink-wraps to max-content. That widens the region to fit the longest unbreakable token, so - `overflow-wrap`/`word-break` on descendants never engages and long words/URLs scroll sideways - instead of wrapping. Forcing the wrapper to block caps it at the viewport width so text wraps; - genuinely-wide content (fixed-width media, nowrap) still overflows and scrolls. `!important` - is required to beat Radix's inline style. */ + /* Radix sets this viewport wrapper to inline `display: table`, which shrink-wraps to the widest + token and stops descendants wrapping; force block (via !important to beat the inline style). */ > div { display: block !important; } diff --git a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx index 4044c24d57..2e14277b4b 100644 --- a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx +++ b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx @@ -23,8 +23,6 @@ export const getLicElementClassNames = (element: TElement, includeColumnBreakCla includeColumnBreakClasses && getColumnBreakClasses(element), element.align ? justifyClassNames[element.align as string] : 'tw-justify-start', element.breakAfterColumn ? 'tw-flex' : 'tw-inline-flex', - // inline-flex shrink-wraps to max-content, so a long word/URL widens the item and defeats - // break-words; cap it at the container width so the text wraps instead of scrolling sideways. 'tw-max-w-full', ]); @@ -51,7 +49,6 @@ const ListBullet = () => { export const ListItemContentMarkupElementNode = ({ attributes, children, element }: PlateRenderElementProps) => { return ( - // break-words matches the serialized rendition (getClassNames); the live editor node lacks it otherwise.

{children} From 26c03745a479ff794b1e32824682697f05f6d123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Bernhardsgr=C3=BCtter?= Date: Tue, 7 Jul 2026 13:21:09 +0200 Subject: [PATCH 4/4] fix(RichTextEditor): wrap long content via overflow-wrap:anywhere; add behavioral test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit break-word wraps visually but does not lower min-content width, so long tokens still overflowed inside a shrink-wrapping ScrollArea viewport. Editor paragraphs/list items and the serialized rendition now use overflow-wrap:anywhere, which wraps even in that context; the now-redundant tw-max-w-full is dropped. Reverts the ScrollArea display:block change — the defect lives in RTE, and forcing block on the shared Radix viewport had a large blast radius. Adds LongContentWrapping.cy.tsx (editor + rendition, red on break-word, green on anywhere) and a resizable Storybook story. Co-Authored-By: Claude Opus 4.8 --- .changeset/rte-comment-long-word-wrap.md | 5 +- .../ScrollArea/__tests__/ScrollArea.ct.tsx | 19 ---- .../ScrollArea/styles/scrollArea.module.scss | 6 -- .../LongContentWrapping.stories.tsx | 91 +++++++++++++++++++ .../ListItemContentMarkupElement.tsx | 3 +- .../TextStyles/paragraphPlugin.tsx | 2 +- .../__tests__/LongContentWrapping.cy.tsx | 82 +++++++++++++++++ .../serializer/serializeToHtml.spec.ts | 4 +- .../serializeNodeToHtmlRecursive.spec.ts | 10 +- .../utils/serializeNodeToHtmlRecursive.ts | 4 +- 10 files changed, 186 insertions(+), 40 deletions(-) create mode 100644 packages/rte/src/components/RichTextEditor/LongContentWrapping.stories.tsx create mode 100644 packages/rte/src/components/RichTextEditor/__tests__/LongContentWrapping.cy.tsx diff --git a/.changeset/rte-comment-long-word-wrap.md b/.changeset/rte-comment-long-word-wrap.md index 84e7fa4e4f..cfa38c4957 100644 --- a/.changeset/rte-comment-long-word-wrap.md +++ b/.changeset/rte-comment-long-word-wrap.md @@ -1,8 +1,7 @@ --- -"@frontify/fondue-components": patch "@frontify/fondue-rte": patch --- -fix(RichTextEditor, ScrollArea): wrap long words and URLs instead of scrolling horizontally +fix(RichTextEditor): wrap long words and URLs instead of scrolling horizontally -Long unbreakable strings in rich text no longer force a sideways scroll. `RichTextEditor` paragraphs and list items now carry a break rule so typed text wraps, and list items are capped at the container width so their `inline-flex` box can no longer shrink-wrap to a long word. `ScrollArea` no longer lets its Radix viewport shrink-wrap to content width, so a break-enabled paragraph wraps to the visible width. Genuinely wide, unshrinkable content (fixed-width media, `nowrap`) still scrolls 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/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx index ab814a25b0..e408bec704 100644 --- a/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx +++ b/packages/components/src/components/ScrollArea/__tests__/ScrollArea.ct.tsx @@ -109,25 +109,6 @@ test('renders scrollbars only when content overflows (and it does)', async ({ mo await expect(horizontalScrollbar).toBeVisible(); }); -test('wraps long unbreakable text instead of overflowing horizontally', async ({ mount }) => { - const longUrl = `https://example.com/${'a'.repeat(200)}`; - const wrapper = await mount( - -

- {longUrl} -

-
, - ); - const viewport = wrapper.getByTestId(SCROLLAREA_VIEWPORT_TEST_ID); - - const { scrollWidth, clientWidth } = await viewport.evaluate((element) => ({ - scrollWidth: element.scrollWidth, - clientWidth: element.clientWidth, - })); - - expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); -}); - test('renders scrollbars only when scrolling', async ({ mount }) => { const wrapper = await mount( diff --git a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss index 35de58d541..9a89c62342 100644 --- a/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss +++ b/packages/components/src/components/ScrollArea/styles/scrollArea.module.scss @@ -16,12 +16,6 @@ height: 100%; box-sizing: border-box; - /* Radix sets this viewport wrapper to inline `display: table`, which shrink-wraps to the widest - token and stops descendants wrapping; force block (via !important to beat the inline style). */ - > div { - display: block !important; - } - &[data-scroll-padding='tight'] { padding: sizeToken.get(2); } 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 2e14277b4b..e9805ad71e 100644 --- a/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx +++ b/packages/rte/src/components/RichTextEditor/Plugins/ListPlugin/ListItemContentMarkupElement.tsx @@ -23,7 +23,6 @@ export const getLicElementClassNames = (element: TElement, includeColumnBreakCla includeColumnBreakClasses && getColumnBreakClasses(element), element.align ? justifyClassNames[element.align as string] : 'tw-justify-start', element.breakAfterColumn ? 'tw-flex' : 'tw-inline-flex', - 'tw-max-w-full', ]); const ListBullet = () => { @@ -49,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 bed20b0790..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 tw-break-words'; +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 }) => ( +
+
{children}
+
+); + +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 8cfde4dd59..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( - `
  1. First item

  2. Second item

`, + `
  1. First item

  2. Second item

`, ); }); @@ -102,7 +102,7 @@ describe('serializeNodeToHtmlRecursive()', () => { const result = serializeNodeToHtmlRecursive(node, defaultStyles, {}); expect(result).to.be.equal( - `
  1. First item

  2. Second item

`, + `
  1. First item

  2. Second item

`, ); }); @@ -192,7 +192,7 @@ describe('serializeNodeToHtmlRecursive()', () => { const result = serializeNodeToHtmlRecursive(node, defaultStyles, {}); expect(result).to.be.equal( - `
  • This comes first.

`, + `
  • This comes first.

`, ); }); @@ -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}`; 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]); };