From 311528fd456638ead229f9cc453ea3b6c06f8f28 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Wed, 3 Mar 2021 09:52:18 +0100 Subject: [PATCH] Add permalink button to markdown headings (#1564) * add permalink button to markdown headings --- .../changelog/markdown-heading-permalink.yaml | 2 + scm-ui/ui-components/src/Icon.tsx | 2 +- .../src/MarkdownHeadingRenderer.tsx | 73 ++- .../src/MarkdownView.stories.tsx | 12 +- scm-ui/ui-components/src/MarkdownView.tsx | 13 +- .../src/__snapshots__/storyshots.test.ts.snap | 583 ++++++++++++++++++ .../components/content/MarkdownViewer.tsx | 11 +- 7 files changed, 672 insertions(+), 24 deletions(-) create mode 100644 gradle/changelog/markdown-heading-permalink.yaml diff --git a/gradle/changelog/markdown-heading-permalink.yaml b/gradle/changelog/markdown-heading-permalink.yaml new file mode 100644 index 0000000000..9ac84d0d6f --- /dev/null +++ b/gradle/changelog/markdown-heading-permalink.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add permalink button to markdown headings ([#1564](https://github.com/scm-manager/scm-manager/pull/1564)) diff --git a/scm-ui/ui-components/src/Icon.tsx b/scm-ui/ui-components/src/Icon.tsx index 8e87871f83..81493cf2f2 100644 --- a/scm-ui/ui-components/src/Icon.tsx +++ b/scm-ui/ui-components/src/Icon.tsx @@ -31,7 +31,7 @@ type Props = { name: string; color: string; className?: string; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; testId?: string; }; diff --git a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx index a2e1a67707..cda694ffdc 100644 --- a/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx +++ b/scm-ui/ui-components/src/MarkdownHeadingRenderer.tsx @@ -21,9 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { ReactNode } from "react"; -import { withRouter, RouteComponentProps } from "react-router-dom"; +import React, { FC, ReactNode, useState } from "react"; +import { useHistory, useLocation } from "react-router-dom"; import { urls } from "@scm-manager/ui-api"; +import styled from "styled-components"; +import Icon from "./Icon"; +import Tooltip from "./Tooltip"; +import { useTranslation } from "react-i18next"; +import copyToClipboard from "./CopyToClipboard"; /** * Adds anchor links to markdown headings. @@ -31,9 +36,26 @@ import { urls } from "@scm-manager/ui-api"; * @see Headings are missing anchors / ids */ -type Props = RouteComponentProps & { +const Link = styled.a` + i { + font-size: 1rem; + visibility: hidden; + margin-left: 10px; + } + + i:hover { + cursor: pointer; + } + + &:hover i { + visibility: visible; + } +`; + +type Props = { children: ReactNode; level: number; + permalink: string; }; function flatten(text: string, child: any): any { @@ -49,18 +71,45 @@ 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 MarkdownHeadingRenderer: FC = ({ children, level, permalink }) => { + const [copying, setCopying] = useState(false); + const [t] = useTranslation("repos"); + const location = useLocation(); + const history = useHistory(); + const reactChildren = React.Children.toArray(children); + const heading = reactChildren.reduce(flatten, ""); const anchorId = headingToAnchorId(heading); - const headingElement = React.createElement("h" + props.level, {}, props.children); - const href = urls.withContextPath(props.location.pathname + "#" + anchorId); + const copyPermalink = (event: React.MouseEvent) => { + event.preventDefault(); + setCopying(true); + copyToClipboard(permalinkHref) + .then(() => history.replace("#" + anchorId)) + .finally(() => setCopying(false)); + }; + const CopyButton = copying ? ( + + ) : ( + + + + ); + const headingElement = React.createElement("h" + level, {}, [...reactChildren, CopyButton]); + const href = urls.withContextPath(location.pathname + "#" + anchorId); + const permalinkHref = + window.location.protocol + + "//" + + window.location.host + + urls.withContextPath((permalink || location.pathname) + "#" + anchorId); return ( - + {headingElement} - + ); -} +}; -export default withRouter(MarkdownHeadingRenderer); +export const create = (permalink: string): FC => { + return props => ; +}; + +export default MarkdownHeadingRenderer; diff --git a/scm-ui/ui-components/src/MarkdownView.stories.tsx b/scm-ui/ui-components/src/MarkdownView.stories.tsx index 9327d76381..1bc4ea4048 100644 --- a/scm-ui/ui-components/src/MarkdownView.stories.tsx +++ b/scm-ui/ui-components/src/MarkdownView.stories.tsx @@ -43,8 +43,8 @@ const Spacing = styled.div` `; storiesOf("MarkdownView", module) - .addDecorator((story) => {story()}) - .addDecorator((story) => {story()}) + .addDecorator(story => {story()}) + .addDecorator(story => {story()}) .add("Default", () => ) .add("Code without Lang", () => ) .add("Xml Code Block", () => ) @@ -56,6 +56,14 @@ storiesOf("MarkdownView", module) )) .add("Links", () => ) + .add("Header Anchor Links", () => ( + + )) .add("Commit Links", () => ) .add("Custom code renderer", () => { const binder = new Binder("custom code renderer"); diff --git a/scm-ui/ui-components/src/MarkdownView.tsx b/scm-ui/ui-components/src/MarkdownView.tsx index 19dc074cc0..fceff2efe1 100644 --- a/scm-ui/ui-components/src/MarkdownView.tsx +++ b/scm-ui/ui-components/src/MarkdownView.tsx @@ -27,8 +27,8 @@ import { RouteComponentProps, withRouter } from "react-router-dom"; import Markdown from "react-markdown/with-html"; import { binder } from "@scm-manager/ui-extensions"; import ErrorBoundary from "./ErrorBoundary"; -import MarkdownHeadingRenderer from "./MarkdownHeadingRenderer"; -import { create } from "./MarkdownLinkRenderer"; +import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRenderer"; +import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer"; import { useTranslation, WithTranslation, withTranslation } from "react-i18next"; import Notification from "./Notification"; import { createTransformer } from "./remarkChangesetShortLinkParser"; @@ -43,6 +43,7 @@ type Props = RouteComponentProps & enableAnchorHeadings?: boolean; // basePath for markdown links basePath?: string; + permalink?: string; }; type State = { @@ -115,7 +116,7 @@ class MarkdownView extends React.Component { } render() { - const { content, renderers, renderContext, enableAnchorHeadings, skipHtml, basePath, t } = this.props; + const { content, renderers, renderContext, enableAnchorHeadings, skipHtml, basePath, permalink, t } = this.props; const rendererFactory = binder.getExtension("markdown-renderer-factory"); let rendererList = renderers; @@ -128,12 +129,12 @@ class MarkdownView extends React.Component { rendererList = {}; } - if (enableAnchorHeadings) { - rendererList.heading = MarkdownHeadingRenderer; + if (enableAnchorHeadings && permalink && !rendererList.heading) { + rendererList.heading = createMarkdownHeadingRenderer(permalink); } if (basePath && !rendererList.link) { - rendererList.link = create(basePath); + rendererList.link = createMarkdownLinkRenderer(basePath); } if (!rendererList.code) { diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 41f36697c9..25ca1fde0b 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -93129,6 +93129,589 @@ func main() { ] `; +exports[`Storyshots MarkdownView Header Anchor Links 1`] = ` +Array [ +
+
+ +
+

+ markdownErrorNotification.title +

+

+ markdownErrorNotification.description +

+
+          
+            \`\`\`xml
+<your>
+  <xml>
+    <content/>
+  </xml>
+</your>
+\`\`\`
+          
+        
+

+ markdownErrorNotification.spec + : + + + GitHub Flavored Markdown Spec + +

+
+
+
, +