Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `<Markdown />`
- Added `cutOff` property to set maximum number of raw Markdown characters to render
- `<Markdown />`
- 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

Expand All @@ -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`
- `<MultiSelect />`
- by default, if no searchPredicate or searchListPredicate is defined, the filtering is done via case-insensitive multi-word filtering.
- `<StringPreviewContentBlobToggler />` 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

Expand Down Expand Up @@ -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 `<TextReducer />`
Expand Down
18 changes: 15 additions & 3 deletions src/cmem/ContentBlobToggler/StringPreviewContentBlobToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -57,7 +57,7 @@ export function StringPreviewContentBlobToggler({
startExtended,
useOnly,
renderPreviewAsMarkdown = false,
allowedHtmlElementsInPreview,
allowedHtmlElementsInPreview = markdownAllowedInlineElements,
noTogglerContentSuffix,
firstNonEmptyLineOnly,
...otherContentBlobTogglerProps
Expand Down Expand Up @@ -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(
<Markdown
key="markdown-content"
allowedElements={allowedHtmlElementsInPreview}
cutOff={previewMaxLength}
cutOffSuffix={""}
>
{previewString}
</Markdown>,
{ decodeHtmlEntities: true },
)
: utils.reduceToText(previewContent, { decodeHtmlEntities: true }).slice(0, previewMaxLength);
enableToggler = true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const Template: StoryFn<typeof StringPreviewContentBlobToggler> = (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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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"}`', () => {
Expand All @@ -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', () => {
Expand All @@ -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(
Expand All @@ -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");
});
Expand All @@ -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");
});
Expand Down
78 changes: 43 additions & 35 deletions src/cmem/markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,5 +26,6 @@ export const utils = {
getEnabledColorPropertiesFromPalette,
textToColorHash,
reduceToText,
truncateMarkdownDisplay,
decodeHtmlEntities: decode,
};
74 changes: 74 additions & 0 deletions src/common/utils/truncateMarkdownDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -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<MarkdownProps>) =>
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 = "&amp; &amp; &amp; &amp; &amp; &amp; &amp; &amp;";
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);
});
});
Loading
Loading