diff --git a/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js new file mode 100644 index 0000000000..eab4bab05a --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.js @@ -0,0 +1,47 @@ +// @flow +import * as React from "react"; +import { withRouter } from "react-router-dom"; +import { withContextPath } from "./urls"; + +/** + * Adds anchor links to markdown headings. + * + * @see Headings are missing anchors / ids + */ + +type Props = { + children: React.Node, + level: number, + location: any +}; + +function flatten(text: string, child: any) { + return typeof child === "string" + ? text + child + : React.Children.toArray(child.props.children).reduce(flatten, text); +} + +/** + * Turns heading text into a anchor id + * + * @VisibleForTesting + */ +export function headingToAnchorId(heading: string) { + return heading.toLowerCase().replace(/\W/g, "-"); +} + +function MarkdownHeadingRenderer(props: Props) { + const children = React.Children.toArray(props.children); + const heading = children.reduce(flatten, ""); + const anchorId = headingToAnchorId(heading); + const headingElement = React.createElement("h" + props.level, {}, props.children); + const href = withContextPath(props.location.pathname + "#" + anchorId); + + return ( + + {headingElement} + + ); +} + +export default withRouter(MarkdownHeadingRenderer); diff --git a/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js new file mode 100644 index 0000000000..4fd8428e98 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/MarkdownHeadingRenderer.test.js @@ -0,0 +1,18 @@ +// @flow +import React from "react"; +import { headingToAnchorId } from "./MarkdownHeadingRenderer"; + +describe("headingToAnchorId tests", () => { + + it("should lower case the text", () => { + expect(headingToAnchorId("Hello")).toBe("hello"); + expect(headingToAnchorId("HeLlO")).toBe("hello"); + expect(headingToAnchorId("HELLO")).toBe("hello"); + }); + + it("should replace spaces with hyphen", () => { + expect(headingToAnchorId("awesome stuff")).toBe("awesome-stuff"); + expect(headingToAnchorId("a b c d e f")).toBe("a-b-c-d-e-f"); + }); + +}); diff --git a/scm-ui-components/packages/ui-components/src/MarkdownView.js b/scm-ui-components/packages/ui-components/src/MarkdownView.js index 4d2b2de92f..4620004f59 100644 --- a/scm-ui-components/packages/ui-components/src/MarkdownView.js +++ b/scm-ui-components/packages/ui-components/src/MarkdownView.js @@ -3,17 +3,49 @@ import React from "react"; import SyntaxHighlighter from "./SyntaxHighlighter"; import Markdown from "react-markdown/with-html"; import {binder} from "@scm-manager/ui-extensions"; +import MarkdownHeadingRenderer from "./MarkdownHeadingRenderer"; +import { withRouter } from "react-router-dom"; + type Props = { content: string, renderContext?: Object, renderers?: Object, + enableAnchorHeadings: boolean, + + // context props + location: any }; class MarkdownView extends React.Component { + static defaultProps = { + enableAnchorHeadings: false + }; + + contentRef: ?HTMLDivElement; + + constructor(props: Props) { + super(props); + } + + componentDidUpdate() { + // we have to use componentDidUpdate, because we have to wait until all + // children are rendered and componentDidMount is called before the + // markdown content was rendered. + const hash = this.props.location.hash; + if (this.contentRef && hash) { + // we query only child elements, to avoid strange scrolling with multiple + // markdown elements on one page. + const element = this.contentRef.querySelector(hash); + if (element && element.scrollIntoView) { + element.scrollIntoView(); + } + } + } + render() { - const {content, renderers, renderContext} = this.props; + const {content, renderers, renderContext, enableAnchorHeadings} = this.props; const rendererFactory = binder.getExtension("markdown-renderer-factory"); let rendererList = renderers; @@ -26,20 +58,26 @@ class MarkdownView extends React.Component { rendererList = {}; } + if (enableAnchorHeadings) { + rendererList.heading = MarkdownHeadingRenderer; + } + if (!rendererList.code){ rendererList.code = SyntaxHighlighter; } return ( - +
(this.contentRef = el)}> + +
); } } -export default MarkdownView; +export default withRouter(MarkdownView);