Added TOC and anchors for headings (#243)

* Added TOC support and anchors for headings

* Moved @types/markdown-it-anchor from devDependencies to dependencies

* Add subnode renderer

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>

* Added node-replacer for toc generation

ul lists may not be nested inside a p element. Therefore replaces this replacer every p that has a div.table-of-contents inside of it with the div directly.

* Add index to replacer function

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>

* Add TOC to example code

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>

* Remove unused import

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>

* Removed unnecessary div wrapper of toc

* Fixed toc-renderer

Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
Erik Michelson 2020-06-21 14:04:37 +02:00 committed by GitHub
parent cb2ea5fa6e
commit fd378cf89b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 93 additions and 13 deletions

View file

@ -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 %}

View file

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

View file

@ -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<string, number>, nodeConverter: SubNodeConverter) => (ReactElement | undefined);
const allComponentReplacers: ComponentReplacer[] = [getYouTubeReplacement, getVimeoReplacement, getGistReplacement, getPDFReplacement]
export type ComponentReplacer = (node: DomElement, index: number, counterMap: Map<string, number>, nodeConverter: SubNodeConverter) => (ReactElement | undefined);
type ComponentReplacer2Identifier2CounterMap = Map<ComponentReplacer, Map<string, number>>
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<string, number>()
return componentReplacer(node, identifier2CounterMap, nodeConverter)
return componentReplacer(node, index, identifier2CounterMap, nodeConverter)
})
.find((replacement) => !!replacement)
}
@ -65,6 +69,15 @@ const MarkdownRenderer: React.FC<MarkdownPreviewProps> = ({ content }) => {
md.use(inserted)
md.use(marked)
md.use(footnote)
md.use(anchor, {
permalink: true,
permalinkBefore: true,
permalinkClass: 'heading-anchor text-dark',
permalinkSymbol: '<i class="fa fa-link"></i>'
})
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<MarkdownPreviewProps> = ({ content }) => {
const componentReplacer2Identifier2CounterMap = new Map<ComponentReplacer, Map<string, number>>()
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)
}

View file

@ -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

View file

@ -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<string, number>): (ReactElement | undefined) => {
const getElementReplacement = (node: DomElement, index:number, counterMap: Map<string, number>): (ReactElement | undefined) => {
const attributes = getAttributesFromCodiMdTag(node, 'pdf')
if (attributes && attributes.url) {
const pdfUrl = attributes.url

View file

@ -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<string, number>, 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 }

View file

@ -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

View file

@ -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