From 0cefc48ec0cd1aded42d04f3a18ccd64f997d471 Mon Sep 17 00:00:00 2001 From: Mariia Kovsharova Date: Thu, 21 May 2026 09:13:32 +0200 Subject: [PATCH 1/6] Markdown component: cutOff property --- CHANGELOG.md | 2 + src/cmem/markdown/Markdown.stories.tsx | 38 ++++++++++++++ src/cmem/markdown/Markdown.tsx | 24 ++++++++- src/cmem/markdown/markdown.utils.ts | 64 ++++++++++++++++++++++ src/cmem/markdown/markdownutils.test.ts | 70 +++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af20b6b78..17e7bf66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `searchListPredicate` property: Allows to filter the complete list of search options at once. - Following optional BlueprintJs properties are forwarded now to override default behaviour: `noResults`, `createNewItemRenderer` and `itemRenderer` - `isValidNewOption` property: Checks if an input string is or can be turned into a valid new option. +- `` + - Added `cutOff` property to set maximum number of raw Markdown characters to render ### Fixed diff --git a/src/cmem/markdown/Markdown.stories.tsx b/src/cmem/markdown/Markdown.stories.tsx index ee7adef3e..ee6ffc3f7 100644 --- a/src/cmem/markdown/Markdown.stories.tsx +++ b/src/cmem/markdown/Markdown.stories.tsx @@ -67,3 +67,41 @@ A line with some HTML code inside. [^1]: This is the text related to the the footnote referrer. `, }; + +export const CutOff = Template.bind({}); + +CutOff.args = { + children: ` +This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists. + +You can: + * configure _link targets_ + * add custom __rehype__ plugins + * and filter content through an allowed elements list + +A third paragraph that will not appear once the cutOff limit is reached. + `, + cutOff: 300, +}; + +export const CutOffWithCodeFence = Template.bind({}); + +CutOffWithCodeFence.args = { + children: ` +A short paragraph before the code block. + +Here is an important code example: + +\`\`\`json +{ + "host": "localhost", + "port": 8080, + "debug": true +} +\`\`\` + +This paragraph comes after the code block and should not appear when the cutOff limit falls inside the fence above. + `, + cutOff: 110, + cutOffSuffix: "...", +}; diff --git a/src/cmem/markdown/Markdown.tsx b/src/cmem/markdown/Markdown.tsx index 1821f1ef5..451a3ae34 100644 --- a/src/cmem/markdown/Markdown.tsx +++ b/src/cmem/markdown/Markdown.tsx @@ -12,6 +12,10 @@ import { TestableComponent } from "../../components"; import { HtmlContentBlock, HtmlContentBlockProps } from "../../components/Typography"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import utils from "./markdown.utils"; + +const DEFAULT_CUTOFF_SUFFIX = "..."; + export interface MarkdownProps extends TestableComponent { children: string; /** @@ -47,6 +51,19 @@ export interface MarkdownProps extends TestableComponent { * Configure the `HtmlContentBlock` component that is automatically used as wrapper for the parsed Markdown content. */ htmlContentBlockProps?: Omit; + /** + * Maximum number of raw Markdown characters to render. + * Content exceeding this limit is truncated at the nearest safe paragraph + * boundary (or word boundary as fallback) to preserve Markdown structure. + * No truncation when absent or ≤ 0. + */ + cutOff?: number; + /** + * Text appended as a trailing paragraph when content is truncated by `cutOff`. + * Set to `""` to suppress the indicator entirely. + * Defaults to `"..."`. + */ + cutOffSuffix?: string; } const configDefault = { @@ -109,8 +126,13 @@ export const Markdown = ({ reHypePlugins, linkTargetName = "_mdref", htmlContentBlockProps, + cutOff, + cutOffSuffix = DEFAULT_CUTOFF_SUFFIX, ...otherProps }: MarkdownProps) => { + const renderContent = + cutOff !== undefined && cutOff > 0 ? utils.truncateMarkdown(children, cutOff, cutOffSuffix) : children; + const configHtmlExternalLinks = { rel: ["nofollow"], target: linkTargetName, @@ -136,7 +158,7 @@ export const Markdown = ({ : {}; const reactMarkdownProperties = { - children: children.trim(), + children: renderContent.trim(), ...configDefault, ...configHtml, ...configTextOnly, diff --git a/src/cmem/markdown/markdown.utils.ts b/src/cmem/markdown/markdown.utils.ts index 30fb94744..a6141f9af 100644 --- a/src/cmem/markdown/markdown.utils.ts +++ b/src/cmem/markdown/markdown.utils.ts @@ -11,8 +11,72 @@ const extractNamedAnchors = (markdown: string): string[] => { return namedAnchors; }; +/** + * Truncates a markdown string at a safe block boundary before the cutOff character limit. + * Avoids cutting inside code fences. Falls back to word boundary or hard cut if no + * safe paragraph boundary exists. + */ +const truncateMarkdown = (content: string, cutOff: number, suffix?: string): string => { + if (!cutOff || cutOff <= 0 || content.length <= cutOff) { + return content; + } + + // Collect [start, end] index pairs of all triple-backtick code fence regions + const codeFenceRegex = /^(`{3,})[^\n]*\n[\s\S]*?\n\1/gm; + const fenceRanges: [number, number][] = []; + let m: RegExpExecArray | null; + while ((m = codeFenceRegex.exec(content)) !== null) { + fenceRanges.push([m.index, m.index + m[0].length]); + } + + // Also handle unclosed fences (opener with no matching close, or closed with + // a different-length backtick run than what this regex requires) + const openMarkerRegex = /^`{3,}[^\n]*/gm; + let lastUnclosedStart = -1; + let om: RegExpExecArray | null; + while ((om = openMarkerRegex.exec(content)) !== null) { + const pos = om.index; + if (!fenceRanges.some(([s, e]) => pos >= s && pos < e)) { + lastUnclosedStart = pos; + } + } + if (lastUnclosedStart !== -1) { + fenceRanges.push([lastUnclosedStart, content.length]); + } + + const isInsideFence = (pos: number): boolean => fenceRanges.some(([start, end]) => pos >= start && pos < end); + + // Walk backward from cutOff to find the last \n\n not inside a code fence + let searchFrom = cutOff; + let cutPoint = -1; + while (searchFrom > 0) { + const idx = content.lastIndexOf("\n\n", searchFrom); + if (idx === -1) break; + if (!isInsideFence(idx)) { + cutPoint = idx; + break; + } + searchFrom = idx - 1; + } + + // Fallback: last word boundary before cutOff + if (cutPoint === -1) { + const lastSpace = content.lastIndexOf(" ", cutOff); + cutPoint = lastSpace > 0 ? lastSpace : cutOff; + } + + // Avoid returning just the suffix with no content + if (cutPoint <= 0) { + cutPoint = cutOff; + } + + const truncated = content.slice(0, cutPoint).trimEnd(); + return suffix ? `${truncated}\n\n${suffix}` : truncated; +}; + const utils = { extractNamedAnchors, + truncateMarkdown, }; export default utils; diff --git a/src/cmem/markdown/markdownutils.test.ts b/src/cmem/markdown/markdownutils.test.ts index f8e78b894..e8745ad00 100644 --- a/src/cmem/markdown/markdownutils.test.ts +++ b/src/cmem/markdown/markdownutils.test.ts @@ -15,3 +15,73 @@ describe("Markdown utils", () => { expect(namedAnchors).toStrictEqual([]); }); }); + +describe("truncateMarkdown", () => { + const { truncateMarkdown } = utils; + + it("returns content unchanged when length is less than cutOff", () => { + const content = "Short content."; + expect(truncateMarkdown(content, 1000)).toBe(content); + }); + + it("cuts at the last paragraph boundary before the cutOff", () => { + const content = "First paragraph.\n\nSecond paragraph that is longer."; + // cutOff at 30 — inside "Second paragraph", should cut after first \n\n + const result = truncateMarkdown(content, 30, "..."); + expect(result).toBe("First paragraph.\n\n..."); + }); + + it("cuts at the nearest paragraph boundary when multiple exist", () => { + const content = "Para one.\n\nPara two.\n\nPara three that pushes past the limit."; + const result = truncateMarkdown(content, 35, "..."); + expect(result).toBe("Para one.\n\nPara two.\n\n..."); + }); + + it("appends nothing when suffix is empty string", () => { + const content = "First paragraph.\n\nSecond paragraph that exceeds the limit."; + const result = truncateMarkdown(content, 30, ""); + expect(result).toBe("First paragraph."); + }); + + it("falls back to word boundary when no paragraph boundary exists", () => { + const content = "This is a single long line with no paragraph breaks anywhere."; + const result = truncateMarkdown(content, 25, "..."); + expect(result).toBe("This is a single long\n\n..."); + }); + + it("hard-cuts at cutOff when no word boundary exists", () => { + const content = "abcdefghijklmnopqrstuvwxyz"; + const result = truncateMarkdown(content, 10, "..."); + expect(result).toBe("abcdefghij\n\n..."); + }); + + it("skips \\n\\n inside a code fence and backs up to pre-fence boundary", () => { + const content = ["Safe paragraph.", "", "```", "line one", "", "line two", "```", "", "After fence."].join( + "\n" + ); + const fenceStart = content.indexOf("```"); + const cutOff = fenceStart + 15; // somewhere inside the fence + const result = truncateMarkdown(content, cutOff, "..."); + expect(result).toBe("Safe paragraph.\n\n..."); + }); + + it("backs up past the fence when cutOff falls on the closing fence marker", () => { + const content = ["Intro.", "", "```", "some code", "```", "", "Outro."].join("\n"); + const closingFenceIdx = content.lastIndexOf("```"); + const result = truncateMarkdown(content, closingFenceIdx, "..."); + expect(result).toBe("Intro.\n\n..."); + }); + + it("backs up past the fence when cutOff falls on the opening fence marker", () => { + const content = ["Before.", "", "```", "code here", "```"].join("\n"); + const openingFenceIdx = content.indexOf("```"); + const result = truncateMarkdown(content, openingFenceIdx, "..."); + expect(result).toBe("Before.\n\n..."); + }); + + it("falls back to word boundary when content is entirely one code fence", () => { + const content = "```\nsome code line here\n```"; + const result = truncateMarkdown(content, 15, "..."); + expect(result).toBe("```\nsome code\n\n..."); + }); +}); From 4923a846e7e03546a706ab9794df9b5afe19325a Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 3 Jun 2026 18:05:07 +0200 Subject: [PATCH 2/6] split the default config for allowed html elements between inline and block elements --- src/cmem/markdown/Markdown.tsx | 78 +++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/src/cmem/markdown/Markdown.tsx b/src/cmem/markdown/Markdown.tsx index 451a3ae34..32c0643f1 100644 --- a/src/cmem/markdown/Markdown.tsx +++ b/src/cmem/markdown/Markdown.tsx @@ -66,6 +66,48 @@ export interface MarkdownProps extends TestableComponent { cutOffSuffix?: string; } +export const markdownAllowedInlineElements = [ + // default markdown + "a", + "code", + "em", + "img", + "strong", + // gfm (Github Flavoured Markdown) extensions + "del", + // other stuff + "mark", +]; + +export const markdownAllowedBlockElements = [ + // default markdown + "blockquote", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "li", + "ol", + "p", + "pre", + "ul", + // gfm (Github Flavoured Markdown) extensions + "input", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + // other stuff + "dl", + "dt", + "dd", +]; + const configDefault = { /* Using React Markdown configuration @@ -75,41 +117,7 @@ const configDefault = { remarkPlugins: [remarkGfm, remarkDefinitionList] as PluggableList, // @see https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins rehypePlugins: [] as PluggableList, - allowedElements: [ - // default markdown - "a", - "blockquote", - "code", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "img", - "li", - "ol", - "p", - "pre", - "strong", - "ul", - // gfm (Github Flavoured Markdown) extensions - "del", - "input", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - // other stuff - "mark", - "dl", - "dt", - "dd", - ], + allowedElements: [...markdownAllowedInlineElements, ...markdownAllowedBlockElements], // remove all unwanted HTML markup unwrapDisallowed: true, // show escaped HTML From 8b97c59ec0522fbf5946e42fec1da4a51e3ce443 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 3 Jun 2026 18:32:58 +0200 Subject: [PATCH 3/6] use MArkdown cutOff to improve preview when MArkdown is allowed --- .../StringPreviewContentBlobToggler.tsx | 17 ++++++++++++++--- .../StringPreviewContentBlobToggler.stories.tsx | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx index fb8247ead..9f396b813 100644 --- a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx +++ b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx @@ -2,7 +2,7 @@ import React from "react"; import { utils } from "../../common"; import InlineText from "../../components/Typography/InlineText"; -import { Markdown } from "../markdown/Markdown"; +import { Markdown, markdownAllowedInlineElements } from "../markdown/Markdown"; import { ContentBlobToggler, ContentBlobTogglerProps } from "./ContentBlobToggler"; @@ -57,7 +57,7 @@ export function StringPreviewContentBlobToggler({ startExtended, useOnly, renderPreviewAsMarkdown = false, - allowedHtmlElementsInPreview, + allowedHtmlElementsInPreview = markdownAllowedInlineElements, noTogglerContentSuffix, firstNonEmptyLineOnly, ...otherContentBlobTogglerProps @@ -90,7 +90,18 @@ export function StringPreviewContentBlobToggler({ previewMaxLength && utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength ) { - previewContent = utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength); + previewContent = renderPreviewAsMarkdown ? ( + + {previewString} + + ) : ( + utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength) + ); enableToggler = true; } diff --git a/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx b/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx index d196bd308..22d8211f8 100644 --- a/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx +++ b/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx @@ -14,7 +14,7 @@ const Template: StoryFn = (args) => ( ); const initialTeststring = - "A library for GUI elements.\nIn order to create graphical user interfaces, please have look at the documentation at [Github](https://github.com/eccenca/gui-elements)."; + "# A library for [GUI elements](https://github.com/eccenca/gui-elements).\nIn order to create graphical user interfaces, please\n* have look at the documentation at [Github](https://github.com/eccenca/gui-elements)."; export const Default = Template.bind({}); Default.args = { From 903e6137f1dbedc0fd9fefde30550fe8f5f9312d Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 3 Jun 2026 18:38:06 +0200 Subject: [PATCH 4/6] update changelog --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e7bf66d..2aa7fb51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `searchListPredicate` property: Allows to filter the complete list of search options at once. - Following optional BlueprintJs properties are forwarded now to override default behaviour: `noResults`, `createNewItemRenderer` and `itemRenderer` - `isValidNewOption` property: Checks if an input string is or can be turned into a valid new option. -- `` - - Added `cutOff` property to set maximum number of raw Markdown characters to render +- `` + - Added `cutOff` property to set maximum number of raw Markdown characters to render ### Fixed @@ -33,6 +33,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - if you forward properties then they cannot have `Color` as type, use `ColorLike` - `` - by default, if no searchPredicate or searchListPredicate is defined, the filtering is done via case-insensitive multi-word filtering. +- `` uses now the `Markdown.cutOff` property + - this enables Markdown rendering even if the preview need to be shortened + - this may lead to slightly different preview lengths ### Deprecated From da048466969b4a71cb64c4e5b6206cd821018358 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Thu, 4 Jun 2026 15:41:55 +0200 Subject: [PATCH 5/6] add helper function to iterate over Markdown rendering to improve the experienced cutOff value --- CHANGELOG.md | 4 +- src/common/index.ts | 2 + .../utils/truncateMarkdownDisplay.test.tsx | 74 +++++++++++++++ src/common/utils/truncateMarkdownDisplay.ts | 95 +++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/common/utils/truncateMarkdownDisplay.test.tsx create mode 100644 src/common/utils/truncateMarkdownDisplay.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa7fb51b..50670e0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `isValidNewOption` property: Checks if an input string is or can be turned into a valid new option. - `` - Added `cutOff` property to set maximum number of raw Markdown characters to render +- new `utils` methods: + - `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value ### Fixed @@ -204,7 +206,7 @@ This is a major release, and it might be not compatible with your current usage - Add `ModalContext` to track open/close state of all used application modals. - Add `modalId` property to give a modal a unique ID for tracking purposes. - `preventReactFlowEvents`: adds 'nopan', 'nowheel' and 'nodrag' classes to overlay classes in order to prevent react-flow to react to drag and pan actions in modals. - - new `utils` methods +- new `utils` methods - `colorCalculateDistance()`: calculates the difference between 2 colors using the simple CIE76 formula - `textToColorHash()`: calculates a color from a text string - `reduceToText`: shrinks HTML content and React elements to plain text, used for `` diff --git a/src/common/index.ts b/src/common/index.ts index ed8eb78a9..d1eca28ef 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -9,6 +9,7 @@ import { getScrollParent } from "./utils/getScrollParent"; import { getGlobalVar, setGlobalVar } from "./utils/globalVars"; import { openInNewTab } from "./utils/openInNewTab"; import { reduceToText } from "./utils/reduceToText"; +import { truncateMarkdownDisplay } from "./utils/truncateMarkdownDisplay"; export type { DecodeOptions as DecodeHtmlEntitiesOptions } from "he"; export type { IntentTypes as IntentBaseTypes } from "./Intent"; @@ -25,5 +26,6 @@ export const utils = { getEnabledColorPropertiesFromPalette, textToColorHash, reduceToText, + truncateMarkdownDisplay, decodeHtmlEntities: decode, }; diff --git a/src/common/utils/truncateMarkdownDisplay.test.tsx b/src/common/utils/truncateMarkdownDisplay.test.tsx new file mode 100644 index 000000000..a073c3766 --- /dev/null +++ b/src/common/utils/truncateMarkdownDisplay.test.tsx @@ -0,0 +1,74 @@ +import React from "react"; + +import { Markdown, MarkdownProps } from "../../cmem/markdown/Markdown"; + +import { reduceToText } from "./reduceToText"; +import { truncateMarkdownDisplay } from "./truncateMarkdownDisplay"; + +const measureLength = (node: React.ReactElement): number => reduceToText(node).length; + +const makeMarkdown = (children: string, cutOff: number, extra?: Partial) => + React.createElement(Markdown, { children, cutOff, ...extra }) as React.ReactElement< + MarkdownProps & { cutOff: number } + >; + +describe("truncateMarkdownDisplay", () => { + it("returns the untruncated element when the rendered content is already shorter than cutOff", () => { + const input = makeMarkdown("Short text.", 1000); + const result = truncateMarkdownDisplay(input); + expect((result.props as MarkdownProps).cutOff).toBeUndefined(); + }); + + it("returns an element whose rendered text length is closer to cutOff than the raw cutOff would yield", () => { + // Markdown link syntax: rendered text "click" is 5 chars, raw syntax is 30+ chars. + const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" "); + const cutOff = 60; + + const rawTruncatedLength = measureLength(makeMarkdown(linkHeavy, cutOff)); + const refined = truncateMarkdownDisplay(makeMarkdown(linkHeavy, cutOff)); + const refinedLength = measureLength(refined); + + expect(rawTruncatedLength).toBeLessThan(cutOff); + expect(refinedLength).toBeGreaterThan(rawTruncatedLength); + expect(Math.abs(refinedLength - cutOff)).toBeLessThanOrEqual(Math.abs(rawTruncatedLength - cutOff)); + }); + + it("preserves other props of the input element on the returned element", () => { + const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" "); + const input = makeMarkdown(linkHeavy, 40, { "data-test-id": "md-x", allowHtml: true }); + const result = truncateMarkdownDisplay(input); + const props = result.props as MarkdownProps; + expect(props["data-test-id"]).toBe("md-x"); + expect(props.allowHtml).toBe(true); + }); + + it("returns an element whose cutOff differs from the initial cutOff when iteration adjusts it", () => { + const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" "); + const initialCutOff = 50; + const result = truncateMarkdownDisplay(makeMarkdown(linkHeavy, initialCutOff)); + const props = result.props as MarkdownProps; + // Either the element was kept (initial was already best) or cutOff was raised to compensate for syntax overhead. + expect(props.cutOff === undefined || (typeof props.cutOff === "number" && props.cutOff >= initialCutOff)).toBe( + true, + ); + }); + + it("passes reduceToTextOptions through to the internal text measurement", () => { + // With decodeHtmlEntities enabled, entity-heavy content reduces to a shorter measured length, + // so the function should treat its length as shorter and may take the early-exit path. + const content = "& & & & & & & &"; + const cutOff = 30; + const input = makeMarkdown(content, cutOff); + const resultDecoded = truncateMarkdownDisplay(input, { decodeHtmlEntities: true }); + expect((resultDecoded.props as MarkdownProps).cutOff).toBeUndefined(); + }); + + it("respects maxRounds by not iterating when set to 0", () => { + const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" "); + const initialCutOff = 50; + const input = makeMarkdown(linkHeavy, initialCutOff); + const result = truncateMarkdownDisplay(input, undefined, 0); + // With no iterations allowed, the result should be the initial element (same cutOff as the input). + expect((result.props as MarkdownProps).cutOff).toBe(initialCutOff); + }); +}); diff --git a/src/common/utils/truncateMarkdownDisplay.ts b/src/common/utils/truncateMarkdownDisplay.ts new file mode 100644 index 000000000..2754ee049 --- /dev/null +++ b/src/common/utils/truncateMarkdownDisplay.ts @@ -0,0 +1,95 @@ +import React from "react"; + +import { MarkdownProps } from "../../cmem/markdown/Markdown"; + +import { reduceToText, ReduceToTextFuncType } from "./reduceToText"; + +interface MarkdownWithCutOffProps extends Omit { + cutOff: NonNullable; +} + +interface TruncateMarkdownDisplayType { + ( + /** + * Markdown element with mandatory `cutOff` property. + */ + input: React.ReactElement, + /** + * Options given to the internal used `reduceToText` function. + */ + reduceToTextOptions?: Pick< + NonNullable[1]>, + "decodeHtmlEntities" | "decodeHtmlEntitiesOptions" + >, + /** + * Maximum number of rounds to iterate over text length of the rendered Markdown display. + */ + maxRounds?: number, + ): React.ReactElement; +} + +/** + * The internal `truncateMarkdown` function cuts off the raw Markdown content. + * Because of the Markdown syntax, the rendered Markdown content can be much shorter (e.g., Markdown link syntax is + * longer than the rendered link text). + * + * This method iterates over a series of Markdown displays, updating the internally used `cutOff` value to create a + * Markdown result whose text length is closer to the initial `cutOff` value. + * + * As a fast path, if the Markdown rendered without any `cutOff` is already shorter than or equal to the initial + * `cutOff`, the untruncated element is returned without iteration. + * + * Otherwise, the algorithm: + * + * * calculates a factor from the given `cutOff` and the text length of the returned Markdown element + * * uses this factor to adjust the `cutOff` value applied in the next iteration + * * loops over the iterations and tracks the result whose rendered text length is closest to the initial `cutOff` + * + * The loop will stop when: + * + * * the text length of the Markdown result does not change over one iteration step + * * the adjusted `cutOff` value does not change over one iteration step (no further progress possible) + * * the text length of the Markdown result is exactly the given initial `cutOff` + * * the maximum number of iteration rounds is reached (defaults to `5`) + * + * The returned element is the iteration whose rendered text length is closest in absolute distance to the initial + * `cutOff`. This may be slightly over or under the initial `cutOff` value. + */ +export const truncateMarkdownDisplay: TruncateMarkdownDisplayType = (input, reduceToTextOptions, maxRounds = 5) => { + const initialCutOff = input.props.cutOff; + + const untruncated = React.cloneElement(input, { cutOff: undefined }); + const untruncatedLength = reduceToText(untruncated, reduceToTextOptions).length; + if (untruncatedLength <= initialCutOff) { + return untruncated; + } + + let currentCutOff = initialCutOff; + let currentLength = reduceToText(input, reduceToTextOptions).length; + + let bestElement: React.ReactElement = input; + let bestDistance = Math.abs(currentLength - initialCutOff); + + for (let round = 0; round < maxRounds; round++) { + if (currentLength === initialCutOff || currentLength === 0) break; + + const nextCutOff = Math.max(1, Math.round(currentCutOff * (initialCutOff / currentLength))); + if (nextCutOff === currentCutOff) break; + + const nextElement = React.cloneElement(input, { cutOff: nextCutOff }); + const nextLength = reduceToText(nextElement, reduceToTextOptions).length; + + const nextDistance = Math.abs(nextLength - initialCutOff); + if (nextDistance < bestDistance) { + bestDistance = nextDistance; + bestElement = nextElement; + } + + if (nextLength === currentLength) break; + + currentCutOff = nextCutOff; + currentLength = nextLength; + } + + return bestElement; +}; From 43a9a69876b57d05870bf501ebbda1718fa8cf77 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Thu, 4 Jun 2026 15:43:01 +0200 Subject: [PATCH 6/6] update toggler component, use new truncateMarkdownDisplay helper --- .../StringPreviewContentBlobToggler.tsx | 25 +++++++------- .../StringPreviewContentBlobToggler.test.tsx | 33 +++++++------------ 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx index 9f396b813..911463c6f 100644 --- a/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx +++ b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx @@ -90,18 +90,19 @@ export function StringPreviewContentBlobToggler({ previewMaxLength && utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength ) { - previewContent = renderPreviewAsMarkdown ? ( - - {previewString} - - ) : ( - utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength) - ); + previewContent = renderPreviewAsMarkdown + ? utils.truncateMarkdownDisplay( + + {previewString} + , + { decodeHtmlEntities: true }, + ) + : utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength); enableToggler = true; } diff --git a/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx b/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx index cc0b5fdb8..3f7368f50 100644 --- a/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx +++ b/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx @@ -23,11 +23,8 @@ describe("StringPreviewContentBlobToggler", () => { {...(StringPreviewContentBlobTogglerStory.args as StringPreviewContentBlobTogglerProps)} />, ); - textMustExist(queryByText, "A library for GUI elements."); - textMustNotExist( - queryByText, - "In order to create graphical user interfaces, please have look at the documentation at", - ); + textMustExist(queryByText, "A library for"); + textMustNotExist(queryByText, "documentation at"); textMustExist(queryByText, "show more"); }); it("should display full view if `startExtended` is enabled, and show toggler to reduce", () => { @@ -37,10 +34,8 @@ describe("StringPreviewContentBlobToggler", () => { startExtended />, ); - textMustExist( - queryByText, - "In order to create graphical user interfaces, please have look at the documentation at", - ); + textMustExist(queryByText, "In order to create graphical user interfaces, please"); + textMustExist(queryByText, "have look at the documentation at"); textMustExist(queryByText, "show less"); }); it('should display only first content line on `useOnly={"firstNonEmptyLine"}`', () => { @@ -50,7 +45,7 @@ describe("StringPreviewContentBlobToggler", () => { useOnly={"firstNonEmptyLine"} />, ); - textMustExist(queryByText, "A library for GUI elements."); + textMustExist(queryByText, "A library for"); textMustNotExist(queryByText, "In order to create"); }); it('should use first Markdown paragraph as preview content on `useOnly={"firstMarkdownSection"}` but shorten it', () => { @@ -60,9 +55,9 @@ describe("StringPreviewContentBlobToggler", () => { useOnly={"firstMarkdownSection"} />, ); - textMustExist(queryByText, "A library for GUI elements."); + textMustExist(queryByText, "A library for"); textMustExist(queryByText, "In order to create"); - textMustNotExist(queryByText, "please have look at the documentation at"); + textMustNotExist(queryByText, "documentation at"); }); it("should display full preview and no toggler if content is short enough", () => { const { queryByText } = render( @@ -71,11 +66,9 @@ describe("StringPreviewContentBlobToggler", () => { previewMaxLength={144} />, ); - textMustExist(queryByText, "A library for GUI elements."); - textMustExist( - queryByText, - "In order to create graphical user interfaces, please have look at the documentation at", - ); + textMustExist(queryByText, "A library for"); + textMustExist(queryByText, "In order to create graphical user interfaces, please"); + textMustExist(queryByText, "have look at the documentation at"); textMustNotExist(queryByText, "https://github.com/"); // test if Markdown was rendered textMustNotExist(queryByText, "show more"); }); @@ -87,11 +80,7 @@ describe("StringPreviewContentBlobToggler", () => { renderPreviewAsMarkdown={false} />, ); - textMustExist(queryByText, "A library for GUI elements."); - textMustExist( - queryByText, - "In order to create graphical user interfaces, please have look at the documentation at", - ); + textMustExist(queryByText, "A library for [GUI elements]"); // raw Markdown link syntax visible textMustExist(queryByText, "https://github.com/"); // test if Markdown was rendered textMustExist(queryByText, "show more"); });