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 }) => ( +
+
{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 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( - `
  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]); };