Add toc sidebar+dropdown (#272)

* Replace markdown-it-table-of-contents with markdown-it-toc-done-right

Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Philip Molares <philip.molares@udo.edu>

Extract render window code

Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Philip Molares <philip.molares@udo.edu>

add new package

fix stickyness

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

show toc sidebar only if there is enough space

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

* add min height class

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

* Move markdown toc into own component

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

* add sidebar buttons

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

* Use other button color

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

* Change name of component

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

* Fix merge issues and make toc work again

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

* pin dependencies

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

* remove blank line

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

* pin dependency

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

* Fix anchors

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

* Add use memo

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

* Add change log entry for removal of custom slugify
This commit is contained in:
mrdrogdrog 2020-06-29 17:51:40 +02:00 committed by GitHub
parent 8ab7776a82
commit 50b04c8403
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 505 additions and 110 deletions

View file

@ -54,7 +54,7 @@ export const Splitter: React.FC<SplitterProps> = ({ containerClassName, left, ri
</div>
</ShowIf>
<ShowIf condition={showRight}>
<div className='splitter right'>
<div className='splitter right overflow-y-scroll'>
{right}
</div>
</ShowIf>

View file

@ -6,7 +6,7 @@ import { setEditorModeConfig } from '../../redux/editor/methods'
import { Splitter } from '../common/splitter/splitter'
import { InfoBanner } from '../landing/layout/info-banner'
import { EditorWindow } from './editor-window/editor-window'
import { MarkdownRenderer } from './markdown-renderer/markdown-renderer'
import { MarkdownRenderWindow } from './renderer-window/markdown-render-window'
import { EditorMode } from './task-bar/editor-view-mode'
import { TaskBar } from './task-bar/task-bar'
@ -78,7 +78,7 @@ let a = 1
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
left={<EditorWindow onContentChange={content => setMarkdownContent(content)} content={markdownContent}/>}
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
right={<MarkdownRenderer content={markdownContent} wide={editorMode === EditorMode.PREVIEW}/>}
right={<MarkdownRenderWindow content={markdownContent} wide={editorMode === EditorMode.PREVIEW}/>}
containerClassName={'overflow-hidden'}/>
</div>
</Fragment>

View file

@ -1,6 +1,7 @@
@import '../../../../node_modules/github-markdown-css/github-markdown.css';
.markdown-body {
position: relative;
font-family: 'Source Sans Pro', "twemoji", sans-serif;
.alert > p, .alert > ul {
@ -42,4 +43,5 @@
}
}
}
}

View file

@ -1,3 +1,4 @@
import equal from 'deep-equal'
import { DomElement } from 'domhandler'
import MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
@ -13,11 +14,13 @@ import mathJax from 'markdown-it-mathjax'
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 toc from 'markdown-it-toc-done-right'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
import MathJaxReact from 'react-mathjax'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { slugify } from '../../../utils/slugify'
import { createRenderContainer, validAlertLevels } from './container-plugins/alert'
import { highlightedCode } from './markdown-it-plugins/highlighted-code'
import { linkifyExtra } from './markdown-it-plugins/linkify-extra'
@ -38,87 +41,102 @@ import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
import { ComponentReplacer, SubNodeConverter } from './replace-components/ComponentReplacer'
import { GistReplacer } from './replace-components/gist/gist-replacer'
import { HighlightedCodeReplacer } from './replace-components/highlighted-fence/highlighted-fence-replacer'
import { PossibleWiderReplacer } from './replace-components/possible-wider/possible-wider-replacer'
import { ImageReplacer } from './replace-components/image/image-replacer'
import { MathjaxReplacer } from './replace-components/mathjax/mathjax-replacer'
import { PdfReplacer } from './replace-components/pdf/pdf-replacer'
import { PossibleWiderReplacer } from './replace-components/possible-wider/possible-wider-replacer'
import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-options-replacer'
import { TocReplacer } from './replace-components/toc/toc-replacer'
import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer'
export interface MarkdownPreviewProps {
export interface MarkdownRendererProps {
content: string
wide?: boolean
className?: string
onTocChange?: (ast: TocAst) => void
}
const createMarkdownIt = (): MarkdownIt => {
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
typographer: true
})
md.use(taskList)
md.use(emoji)
md.use(abbreviation)
md.use(definitionList)
md.use(subscript)
md.use(superscript)
md.use(inserted)
md.use(marked)
md.use(footnote)
md.use(imsize)
// noinspection CheckTagEmptyBody
md.use(anchor, {
permalink: true,
permalinkBefore: true,
permalinkClass: 'heading-anchor text-dark',
permalinkSymbol: '<i class="fa fa-link"></i>'
})
md.use(toc, {
includeLevel: [1, 2, 3],
markerPattern: /^\[TOC]$/i
})
md.use(mathJax({
beforeMath: '<codimd-mathjax>',
afterMath: '</codimd-mathjax>',
beforeInlineMath: '<codimd-mathjax inline>',
afterInlineMath: '</codimd-mathjax>',
beforeDisplayMath: '<codimd-mathjax>',
afterDisplayMath: '</codimd-mathjax>'
}))
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
md.use(markdownItRegex, replaceLegacyGistShortCode)
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
md.use(markdownItRegex, replacePdfShortCode)
md.use(markdownItRegex, replaceYouTubeLink)
md.use(markdownItRegex, replaceVimeoLink)
md.use(markdownItRegex, replaceGistLink)
md.use(highlightedCode)
md.use(markdownItRegex, replaceQuoteExtraAuthor)
md.use(markdownItRegex, replaceQuoteExtraColor)
md.use(markdownItRegex, replaceQuoteExtraTime)
md.use(linkifyExtra)
md.use(MarkdownItParserDebugger)
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className, onTocChange, wide }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const [lastTocAst, setLastTocAst] = useState<TocAst>()
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
const markdownIt = useMemo(() => {
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
typographer: true
})
md.use(taskList)
md.use(emoji)
md.use(abbreviation)
md.use(definitionList)
md.use(subscript)
md.use(superscript)
md.use(inserted)
md.use(marked)
md.use(footnote)
md.use(imsize)
// noinspection CheckTagEmptyBody
md.use(anchor, {
permalink: true,
permalinkBefore: true,
permalinkClass: 'heading-anchor text-dark',
permalinkSymbol: '<i class="fa fa-link"></i>'
})
md.use(mathJax({
beforeMath: '<codimd-mathjax>',
afterMath: '</codimd-mathjax>',
beforeInlineMath: '<codimd-mathjax inline>',
afterInlineMath: '</codimd-mathjax>',
beforeDisplayMath: '<codimd-mathjax>',
afterDisplayMath: '</codimd-mathjax>'
}))
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
md.use(markdownItRegex, replaceLegacyGistShortCode)
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
md.use(markdownItRegex, replacePdfShortCode)
md.use(markdownItRegex, replaceYouTubeLink)
md.use(markdownItRegex, replaceVimeoLink)
md.use(markdownItRegex, replaceGistLink)
md.use(highlightedCode)
md.use(markdownItRegex, replaceQuoteExtraAuthor)
md.use(markdownItRegex, replaceQuoteExtraColor)
md.use(markdownItRegex, replaceQuoteExtraTime)
md.use(toc, {
placeholder: '(\\[TOC\\]|\\[toc\\])',
listType: 'ul',
level: [1, 2, 3],
callback: (code: string, ast: TocAst): void => {
setTocAst(ast)
},
slugify: slugify
})
md.use(linkifyExtra)
md.use(MarkdownItParserDebugger)
return md
}
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
const tryToReplaceNode = (node: DomElement, index: number, allReplacers: ComponentReplacer[], nodeConverter: SubNodeConverter) => {
return allReplacers
.map((componentReplacer) => componentReplacer.getReplacement(node, index, nodeConverter))
.find((replacement) => !!replacement)
}
return md
}, [])
const MarkdownRenderer: React.FC<MarkdownPreviewProps> = ({ content, wide }) => {
const markdownIt = useMemo(createMarkdownIt, [])
useEffect(() => {
if (onTocChange && tocAst && !equal(tocAst, lastTocAst)) {
onTocChange(tocAst)
setLastTocAst(tocAst)
}
}, [tocAst, onTocChange, lastTocAst])
const tryToReplaceNode = (node: DomElement, index: number, allReplacers: ComponentReplacer[], nodeConverter: SubNodeConverter) => {
return allReplacers
.map((componentReplacer) => componentReplacer.getReplacement(node, index, nodeConverter))
.find((replacement) => !!replacement)
}
const result: ReactElement[] = useMemo(() => {
const allReplacers: ComponentReplacer[] = [
@ -134,6 +152,7 @@ const MarkdownRenderer: React.FC<MarkdownPreviewProps> = ({ content, wide }) =>
new MathjaxReplacer()
]
const html: string = markdownIt.render(content)
const transform: Transform = (node, index) => {
const subNodeConverter = (subNode: DomElement, subIndex: number) => convertNodeToElement(subNode, subIndex, transform)
return tryToReplaceNode(node, index, allReplacers, subNodeConverter) || convertNodeToElement(node, index, transform)
@ -142,14 +161,10 @@ const MarkdownRenderer: React.FC<MarkdownPreviewProps> = ({ content, wide }) =>
}, [content, markdownIt])
return (
<div className={'bg-light container-fluid flex-fill h-100 overflow-y-scroll pb-5'}>
<div className={`markdown-body d-flex flex-column align-items-center container-fluid ${wide ? 'wider' : ''}`}>
<MathJaxReact.Provider>
{result}
</MathJaxReact.Provider>
</div>
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`}>
<MathJaxReact.Provider>
{result}
</MathJaxReact.Provider>
</div>
)
}
export { MarkdownRenderer }

View file

@ -0,0 +1,67 @@
.markdown-toc {
width: 100%;
max-width: 200px;
overflow-y: auto;
overflow-x: hidden;
&.sticky {
position: sticky;
top: 0;
left: 0;
}
>ul>li {
>a {
padding: 4px 20px;
}
>ul>li {
> a {
padding: 1px 0 1px 30px;
}
>ul>li {
> a {
padding: 1px 0 1px 38px;
}
}
}
}
ul {
padding: 0;
margin: 0;
list-style: none;
font-size: 0.9em;
a {
color: #767676;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
display: block;
}
li.active {
a {
margin-top: 2px;
margin-bottom: 2px;
color: #000000;
font-weight: bold;
}
}
}
}
.markdown-toc-sidebar-button {
position: absolute;
height: 100%;
right: 20px;
display: flex;
justify-content: flex-end;
flex-direction: column;
&>.dropup {
position: sticky;
bottom: 20px;
right: 0px;
}
}

View file

@ -0,0 +1,59 @@
import React, { Fragment, ReactElement, useMemo } from 'react'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { slugify } from '../../../utils/slugify'
import { ShowIf } from '../../common/show-if/show-if'
import './markdown-toc.scss'
export interface MarkdownTocProps {
ast: TocAst
maxDepth?: number
sticky?: boolean
}
const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts: Map<string, number>, wrapInListItem: boolean): ReactElement | null => {
if (levelsToShowUnderThis < 0) {
return null
}
const rawName = toc.n.trim()
const nameCount = (headerCounts.get(rawName) ?? 0) + 1
const slug = `#${slugify(rawName)}${nameCount > 1 ? `-${nameCount}` : ''}`
headerCounts.set(rawName, nameCount)
const content = (
<Fragment>
<ShowIf condition={toc.l > 0}>
<a href={slug}>{rawName}</a>
</ShowIf>
<ShowIf condition={toc.c.length > 0}>
<ul>
{
toc.c.map(child =>
(convertLevel(child, levelsToShowUnderThis - 1, headerCounts, true)))
}
</ul>
</ShowIf>
</Fragment>
)
if (wrapInListItem) {
return (
<li key={slug}>
{content}
</li>
)
} else {
return content
}
}
export const MarkdownToc: React.FC<MarkdownTocProps> = ({ ast, maxDepth = 3, sticky }) => {
const tocTree = useMemo(() => convertLevel(ast, maxDepth, new Map<string, number>(), false), [ast, maxDepth])
return (
<div className={`markdown-toc ${sticky ? 'sticky' : ''}`}>
{tocTree}
</div>
)
}

View file

@ -0,0 +1,52 @@
import React, { useRef, useState } from 'react'
import { Dropdown } from 'react-bootstrap'
import useResizeObserver from 'use-resize-observer'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../common/show-if/show-if'
import { MarkdownRenderer } from '../markdown-renderer/markdown-renderer'
import { MarkdownToc } from '../markdown-toc/markdown-toc'
interface RenderWindowProps {
content: string
wide?: boolean
}
export const MarkdownRenderWindow: React.FC<RenderWindowProps> = ({ content, wide }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const renderer = useRef<HTMLDivElement>(null)
const { width } = useResizeObserver({ ref: renderer })
const realWidth = width || 0
return (
<div className={'bg-light flex-fill pb-5 flex-row d-flex min-h-100'} ref={renderer}>
<div className={'col-md'}/>
<MarkdownRenderer
className={'flex-fill'}
content={content}
wide={wide}
onTocChange={(tocAst) => setTocAst(tocAst)}/>
<div className={`col-md d-flex flex-column ${realWidth < 1280 ? 'justify-content-end' : ''}`}>
<ShowIf condition={realWidth >= 1280 && !!tocAst}>
<MarkdownToc ast={tocAst as TocAst} sticky={true}/>
</ShowIf>
<ShowIf condition={realWidth < 1280 && !!tocAst}>
<div className={'markdown-toc-sidebar-button'}>
<Dropdown drop={'up'}>
<Dropdown.Toggle id="toc-overlay-button" variant={'secondary'} className={'no-arrow'}>
<ForkAwesomeIcon icon={'bars'}/>
</Dropdown.Toggle>
<Dropdown.Menu>
<div className={'p-2'}>
<MarkdownToc ast={tocAst as TocAst}/>
</div>
</Dropdown.Menu>
</Dropdown>
</div>
</ShowIf>
</div>
</div>
)
}

View file

@ -13,6 +13,9 @@
}
}
.dropdown-toggle.no-arrow::after {
content: initial;
.dropup .dropdown-toggle, .dropdown-toggle {
&.no-arrow::after {
content: initial;
}
}

View file

@ -1,6 +0,0 @@
declare module 'markdown-it-table-of-contents' {
import MarkdownIt from 'markdown-it/lib'
import { TOCOptions } from './interface'
const markdownItTableOfContents: MarkdownIt.PluginWithOptions<TOCOptions>
export = markdownItTableOfContents
}

View file

@ -1,12 +0,0 @@
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
}

View file

@ -0,0 +1,6 @@
declare module 'markdown-it-toc-done-right' {
import MarkdownIt from 'markdown-it/lib'
import { TocOptions } from './interface'
const markdownItTocDoneRight: MarkdownIt.PluginWithOptions<TocOptions>
export = markdownItTocDoneRight
}

View file

@ -0,0 +1,19 @@
export interface TocOptions {
placeholder: string
slugify: (s: string) => string
containerClass: string
containerId: string
listClass: string
itemClass: string
linkClass: string
level: number | number[]
listType: 'ol' | 'ul'
format: (s: string) => string
callback: (tocCode: string, ast: TocAst) => void
}
export interface TocAst {
l: number
n: string
c: TocAst[]
}

View file

@ -36,3 +36,7 @@ body {
.overflow-y-scroll {
overflow-y: scroll;
}
.min-h-100 {
min-height: 100%;
}

3
src/utils/slugify.ts Normal file
View file

@ -0,0 +1,3 @@
export const slugify = (url:string) => {
return encodeURIComponent(String(url).trim().toLowerCase().replace(/\s+/g, '-'))
}