diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e7bf66..50670e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,10 @@ 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 +- new `utils` methods: + - `truncateMarkdownDisplay`: helper function to iterate over `Markdown` renderings to improve the experienced `cutOff` value ### Fixed @@ -33,6 +35,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 @@ -201,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/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx b/src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx index fb8247ea..911463c6 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,19 @@ export function StringPreviewContentBlobToggler({ previewMaxLength && utils.reduceToText(previewContent, { decodeHtmlEntities: true }).length > previewMaxLength ) { - previewContent = 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/stories/StringPreviewContentBlobToggler.stories.tsx b/src/cmem/ContentBlobToggler/stories/StringPreviewContentBlobToggler.stories.tsx index d196bd30..22d8211f 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 = { diff --git a/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx b/src/cmem/ContentBlobToggler/tests/StringPreviewContentBlobToggler.test.tsx index cc0b5fdb..3f7368f5 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"); }); diff --git a/src/cmem/markdown/Markdown.tsx b/src/cmem/markdown/Markdown.tsx index 451a3ae3..32c0643f 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 diff --git a/src/common/index.ts b/src/common/index.ts index ed8eb78a..d1eca28e 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 00000000..a073c376 --- /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 00000000..2754ee04 --- /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; +};