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;
+};