diff --git a/package.json b/package.json index f4e895fae..1dc496caa 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@types/codemirror": "0.0.96", "@types/jest": "26.0.0", "@types/markdown-it": "10.0.1", + "@types/markdown-it-anchor": "4.0.4", "@types/markdown-it-container": "2.0.3", "@types/node": "12.12.47", "@types/node-sass": "4.11.1", @@ -40,15 +41,17 @@ "i18next-http-backend": "1.0.15", "markdown-it": "11.0.0", "markdown-it-abbr": "1.0.4", - "markdown-it-deflist": "2.0.3", + "markdown-it-anchor": "5.3.0", "markdown-it-container": "3.0.0", + "markdown-it-deflist": "2.0.3", "markdown-it-emoji": "1.4.0", + "markdown-it-footnote": "3.0.2", "markdown-it-ins": "3.0.0", "markdown-it-mark": "3.0.0", - "markdown-it-footnote": "3.0.2", "markdown-it-regex": "0.2.0", "markdown-it-sub": "1.0.0", "markdown-it-sup": "1.0.0", + "markdown-it-table-of-contents": "0.4.4", "markdown-it-task-lists": "2.1.1", "moment": "2.27.0", "node-sass": "4.14.1", diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index c3f6eb6cc..a2549e1b2 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -12,8 +12,9 @@ import { TaskBar } from './task-bar/task-bar' const Editor: React.FC = () => { const editorMode: EditorMode = useSelector((state: ApplicationState) => state.editorConfig.editorMode) - const [markdownContent, setMarkdownContent] = useState(` -# Embedding demo + const [markdownContent, setMarkdownContent] = useState(`# Embedding demo +[TOC] + ## Slideshare {%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %} diff --git a/src/components/editor/markdown-renderer/markdown-renderer.scss b/src/components/editor/markdown-renderer/markdown-renderer.scss index 12267c020..a4e286a4e 100644 --- a/src/components/editor/markdown-renderer/markdown-renderer.scss +++ b/src/components/editor/markdown-renderer/markdown-renderer.scss @@ -7,4 +7,15 @@ .alert > p, .alert > ul { margin-bottom: 0; } + + a.heading-anchor { + margin-left: -1.25em; + font-size: 0.75em; + margin-top: 0.25em; + opacity: 0; + + &:hover { + opacity: 1; + } + } } diff --git a/src/components/editor/markdown-renderer/markdown-renderer.tsx b/src/components/editor/markdown-renderer/markdown-renderer.tsx index 23ffd2cf1..c2f64d5f7 100644 --- a/src/components/editor/markdown-renderer/markdown-renderer.tsx +++ b/src/components/editor/markdown-renderer/markdown-renderer.tsx @@ -1,6 +1,7 @@ import { DomElement } from 'domhandler' import MarkdownIt from 'markdown-it' import abbreviation from 'markdown-it-abbr' +import anchor from 'markdown-it-anchor' import markdownItContainer from 'markdown-it-container' import definitionList from 'markdown-it-deflist' import emoji from 'markdown-it-emoji' @@ -10,6 +11,7 @@ import marked from 'markdown-it-mark' import markdownItRegex from 'markdown-it-regex' import subscript from 'markdown-it-sub' import superscript from 'markdown-it-sup' +import toc from 'markdown-it-table-of-contents' import taskList from 'markdown-it-task-lists' import React, { ReactElement, useMemo } from 'react' import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser' @@ -27,6 +29,7 @@ import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link' import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link' import { getGistReplacement } from './replace-components/gist/gist-frame' import { getPDFReplacement } from './replace-components/pdf/pdf-frame' +import { getTOCReplacement } from './replace-components/toc/toc-replacer' import { getVimeoReplacement } from './replace-components/vimeo/vimeo-frame' import { getYouTubeReplacement } from './replace-components/youtube/youtube-frame' @@ -35,15 +38,16 @@ export interface MarkdownPreviewProps { } export type SubNodeConverter = (node: DomElement, index: number) => ReactElement -export type ComponentReplacer = (node: DomElement, counterMap: Map, nodeConverter: SubNodeConverter) => (ReactElement | undefined); -const allComponentReplacers: ComponentReplacer[] = [getYouTubeReplacement, getVimeoReplacement, getGistReplacement, getPDFReplacement] +export type ComponentReplacer = (node: DomElement, index: number, counterMap: Map, nodeConverter: SubNodeConverter) => (ReactElement | undefined); type ComponentReplacer2Identifier2CounterMap = Map> -const tryToReplaceNode = (node: DomElement, componentReplacer2Identifier2CounterMap: ComponentReplacer2Identifier2CounterMap, nodeConverter: SubNodeConverter) => { +const allComponentReplacers: ComponentReplacer[] = [getYouTubeReplacement, getVimeoReplacement, getGistReplacement, getPDFReplacement, getTOCReplacement] + +const tryToReplaceNode = (node: DomElement, index:number, componentReplacer2Identifier2CounterMap: ComponentReplacer2Identifier2CounterMap, nodeConverter: SubNodeConverter) => { return allComponentReplacers .map((componentReplacer) => { const identifier2CounterMap = componentReplacer2Identifier2CounterMap.get(componentReplacer) || new Map() - return componentReplacer(node, identifier2CounterMap, nodeConverter) + return componentReplacer(node, index, identifier2CounterMap, nodeConverter) }) .find((replacement) => !!replacement) } @@ -65,6 +69,15 @@ const MarkdownRenderer: React.FC = ({ content }) => { md.use(inserted) md.use(marked) md.use(footnote) + md.use(anchor, { + permalink: true, + permalinkBefore: true, + permalinkClass: 'heading-anchor text-dark', + permalinkSymbol: '' + }) + md.use(toc, { + markerPattern: /^\[TOC]$/i + }) md.use(markdownItRegex, replaceLegacyYoutubeShortCode) md.use(markdownItRegex, replaceLegacyVimeoShortCode) md.use(markdownItRegex, replaceLegacyGistShortCode) @@ -87,7 +100,7 @@ const MarkdownRenderer: React.FC = ({ content }) => { const componentReplacer2Identifier2CounterMap = new Map>() const html: string = markdownIt.render(content) const transform: Transform = (node, index) => { - const maybeReplacement = tryToReplaceNode(node, componentReplacer2Identifier2CounterMap, + const maybeReplacement = tryToReplaceNode(node, index, componentReplacer2Identifier2CounterMap, (subNode, subIndex) => convertNodeToElement(subNode, subIndex, transform)) return maybeReplacement || convertNodeToElement(node, index, transform) } diff --git a/src/components/editor/markdown-renderer/replace-components/gist/gist-frame.tsx b/src/components/editor/markdown-renderer/replace-components/gist/gist-frame.tsx index 4327610f3..6ac0360ee 100644 --- a/src/components/editor/markdown-renderer/replace-components/gist/gist-frame.tsx +++ b/src/components/editor/markdown-renderer/replace-components/gist/gist-frame.tsx @@ -14,7 +14,7 @@ interface resizeEvent { id: string } -const getElementReplacement:ComponentReplacer = (node, counterMap) => { +const getElementReplacement:ComponentReplacer = (node, index:number, counterMap) => { const attributes = getAttributesFromCodiMdTag(node, 'gist') if (attributes && attributes.id) { const gistId = attributes.id diff --git a/src/components/editor/markdown-renderer/replace-components/pdf/pdf-frame.tsx b/src/components/editor/markdown-renderer/replace-components/pdf/pdf-frame.tsx index 89674b02a..9b7f9a43f 100644 --- a/src/components/editor/markdown-renderer/replace-components/pdf/pdf-frame.tsx +++ b/src/components/editor/markdown-renderer/replace-components/pdf/pdf-frame.tsx @@ -5,7 +5,7 @@ import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils' import { OneClickEmbedding } from '../one-click-frame/one-click-embedding' import './pdf-frame.scss' -const getElementReplacement = (node: DomElement, counterMap: Map): (ReactElement | undefined) => { +const getElementReplacement = (node: DomElement, index:number, counterMap: Map): (ReactElement | undefined) => { const attributes = getAttributesFromCodiMdTag(node, 'pdf') if (attributes && attributes.url) { const pdfUrl = attributes.url diff --git a/src/components/editor/markdown-renderer/replace-components/toc/toc-replacer.tsx b/src/components/editor/markdown-renderer/replace-components/toc/toc-replacer.tsx new file mode 100644 index 000000000..7a0e12dd1 --- /dev/null +++ b/src/components/editor/markdown-renderer/replace-components/toc/toc-replacer.tsx @@ -0,0 +1,17 @@ +import { DomElement } from 'domhandler' +import { ReactElement } from 'react' +import { SubNodeConverter } from '../../markdown-renderer' + +const getElementReplacement = (node: DomElement, index: number, counterMap: Map, nodeConverter: SubNodeConverter): (ReactElement | undefined) => { + if (node.name === 'p' && node.children && node.children.length === 1) { + const possibleTocDiv = node.children[0] + if (possibleTocDiv.name === 'div' && possibleTocDiv.attribs && possibleTocDiv.attribs.class && + possibleTocDiv.attribs.class === 'table-of-contents' && possibleTocDiv.children && possibleTocDiv.children.length === 1) { + const listElement = possibleTocDiv.children[0] + listElement.attribs = Object.assign(listElement.attribs || {}, { class: 'table-of-contents' }) + return nodeConverter(listElement, index) + } + } +} + +export { getElementReplacement as getTOCReplacement } diff --git a/src/components/editor/markdown-renderer/replace-components/vimeo/vimeo-frame.tsx b/src/components/editor/markdown-renderer/replace-components/vimeo/vimeo-frame.tsx index 9738401d2..0d5f7a910 100644 --- a/src/components/editor/markdown-renderer/replace-components/vimeo/vimeo-frame.tsx +++ b/src/components/editor/markdown-renderer/replace-components/vimeo/vimeo-frame.tsx @@ -3,7 +3,7 @@ import { ComponentReplacer } from '../../markdown-renderer' import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils' import { OneClickEmbedding } from '../one-click-frame/one-click-embedding' -const getElementReplacement:ComponentReplacer = (node, counterMap) => { +const getElementReplacement:ComponentReplacer = (node, index:number, counterMap) => { const attributes = getAttributesFromCodiMdTag(node, 'vimeo') if (attributes && attributes.id) { const videoId = attributes.id diff --git a/src/components/editor/markdown-renderer/replace-components/youtube/youtube-frame.tsx b/src/components/editor/markdown-renderer/replace-components/youtube/youtube-frame.tsx index f4a933a8c..aab82d174 100644 --- a/src/components/editor/markdown-renderer/replace-components/youtube/youtube-frame.tsx +++ b/src/components/editor/markdown-renderer/replace-components/youtube/youtube-frame.tsx @@ -3,7 +3,7 @@ import { ComponentReplacer } from '../../markdown-renderer' import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils' import { OneClickEmbedding } from '../one-click-frame/one-click-embedding' -const getElementReplacement: ComponentReplacer = (node, counterMap) => { +const getElementReplacement: ComponentReplacer = (node, index:number, counterMap) => { const attributes = getAttributesFromCodiMdTag(node, 'youtube') if (attributes && attributes.id) { const videoId = attributes.id diff --git a/src/external-types/markdown-it-table-of-contents/index.d.ts b/src/external-types/markdown-it-table-of-contents/index.d.ts new file mode 100644 index 000000000..2c708fceb --- /dev/null +++ b/src/external-types/markdown-it-table-of-contents/index.d.ts @@ -0,0 +1,6 @@ +declare module 'markdown-it-table-of-contents' { + import MarkdownIt from 'markdown-it/lib' + import { TOCOptions } from './interface' + const markdownItTableOfContents: MarkdownIt.PluginWithOptions + export = markdownItTableOfContents +} diff --git a/src/external-types/markdown-it-table-of-contents/interface.ts b/src/external-types/markdown-it-table-of-contents/interface.ts new file mode 100644 index 000000000..9f470233b --- /dev/null +++ b/src/external-types/markdown-it-table-of-contents/interface.ts @@ -0,0 +1,12 @@ +export interface TOCOptions { + includeLevel: number[] + containerClass: string + slugify: (s: string) => string + markerPattern: RegExp + listType: 'ul' | 'ol' + format: (headingAsString: string) => string + forceFullToc: boolean + containerHeaderHtml: string + containerFooterHtml: string + transformLink: (link: string) => string +} diff --git a/yarn.lock b/yarn.lock index 9f0bad8f6..a4a2f0413 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1587,6 +1587,13 @@ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-2.1.0.tgz#ea3dd64c4805597311790b61e872cbd1ed2cd806" integrity sha512-Q7DYAOi9O/+cLLhdaSvKdaumWyHbm7HAk/bFwwyTuU0arR5yyCeW5GOoqt4tJTpDRxhpx9Q8kQL6vMpuw9hDSw== +"@types/markdown-it-anchor@4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/markdown-it-anchor/-/markdown-it-anchor-4.0.4.tgz#f36b67608d238d15024fb6508efd7ad3990209f6" + integrity sha512-3CFeYzh8q7DaQboo1g+nnyXjPIvvtzoM+iBQU25sfcWVnjXckAvZIwAmPSVwHxnIblcTVcGq1XjEJjiBZFKkRA== + dependencies: + "@types/markdown-it" "*" + "@types/markdown-it-container@2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/markdown-it-container/-/markdown-it-container-2.0.3.tgz#436de4c019d7d71b60f759037fd4d03611569eb8" @@ -7282,6 +7289,11 @@ markdown-it-abbr@1.0.4: resolved "https://registry.yarnpkg.com/markdown-it-abbr/-/markdown-it-abbr-1.0.4.tgz#d66b5364521cbb3dd8aa59dadfba2fb6865c8fd8" integrity sha1-1mtTZFIcuz3Yqlna37ovtoZcj9g= +markdown-it-anchor@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-5.3.0.tgz#d549acd64856a8ecd1bea58365ef385effbac744" + integrity sha512-/V1MnLL/rgJ3jkMWo84UR+K+jF1cxNG1a+KwqeXqTIJ+jtA8aWSHuigx8lTzauiIjBDbwF3NcWQMotd0Dm39jA== + markdown-it-container@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b" @@ -7327,6 +7339,11 @@ markdown-it-sup@1.0.0: resolved "https://registry.yarnpkg.com/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz#cb9c9ff91a5255ac08f3fd3d63286e15df0a1fc3" integrity sha1-y5yf+RpSVawI8/09YyhuFd8KH8M= +markdown-it-table-of-contents@0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.4.4.tgz#3dc7ce8b8fc17e5981c77cc398d1782319f37fbc" + integrity sha512-TAIHTHPwa9+ltKvKPWulm/beozQU41Ab+FIefRaQV1NRnpzwcV9QOe6wXQS5WLivm5Q/nlo0rl6laGkMDZE7Gw== + markdown-it-task-lists@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz#f68f4d2ac2bad5a2c373ba93081a1a6848417088"