mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-30 06:45:47 -04:00
Restructure repository (#426)
organized repository Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de> Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de> Co-authored-by: Philip Molares <git@molar.es>
This commit is contained in:
parent
66258ca615
commit
0fadc09f2b
254 changed files with 384 additions and 403 deletions
src/components/markdown-renderer
container-plugins
markdown-it-plugins
markdown-renderer.scssmarkdown-renderer.tsxregex-plugins
replace-asciinema-link.tsreplace-gist-link.tsreplace-legacy-gist-short-code.tsreplace-legacy-slideshare-short-code.tsreplace-legacy-speakerdeck-short-code.tsreplace-legacy-vimeo-short-code.tsreplace-legacy-youtube-short-code.tsreplace-pdf-short-code.tsreplace-quote-extra-author.tsreplace-quote-extra-color.tsreplace-quote-extra-time.tsreplace-vimeo-link.tsreplace-youtube-link.ts
replace-components
ComponentReplacer.ts
asciinema
codi-md-tag-utils.tsgist
highlighted-fence
image
mathjax
one-click-frame
pdf
possible-wider
quote-options
toc
vimeo
youtube
15
src/components/markdown-renderer/container-plugins/alert.ts
Normal file
15
src/components/markdown-renderer/container-plugins/alert.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import Renderer from 'markdown-it/lib/renderer'
|
||||
import Token from 'markdown-it/lib/token'
|
||||
|
||||
type RenderContainerReturn = (tokens: Token[], index: number, options: any, env: any, self: Renderer) => void;
|
||||
type ValidAlertLevels = ('warning' | 'danger' | 'success' | 'info')
|
||||
export const validAlertLevels: ValidAlertLevels[] = ['success', 'danger', 'info', 'warning']
|
||||
|
||||
export const createRenderContainer = (level: ValidAlertLevels): RenderContainerReturn => {
|
||||
return (tokens: Token[], index: number, options: any, env: any, self: Renderer) => {
|
||||
tokens[index].attrJoin('role', 'alert')
|
||||
tokens[index].attrJoin('class', 'alert')
|
||||
tokens[index].attrJoin('class', `alert-${level}`)
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
const highlightRegex = /^ *(\w*)(=(\d*|\+))?(!?)$/
|
||||
|
||||
export const highlightedCode: MarkdownIt.PluginSimple = (md: MarkdownIt) => {
|
||||
md.core.ruler.push('highlighted-code', (state) => {
|
||||
state.tokens.forEach(token => {
|
||||
if (token.type === 'fence') {
|
||||
const highlightInfos = highlightRegex.exec(token.info)
|
||||
if (!highlightInfos) {
|
||||
return
|
||||
}
|
||||
if (highlightInfos[1]) {
|
||||
token.attrJoin('data-highlight-language', highlightInfos[1])
|
||||
}
|
||||
if (highlightInfos[2]) {
|
||||
token.attrJoin('data-show-line-numbers', '')
|
||||
}
|
||||
if (highlightInfos[3]) {
|
||||
token.attrJoin('data-start-line-number', highlightInfos[3])
|
||||
}
|
||||
if (highlightInfos[4]) {
|
||||
token.attrJoin('data-wrap-lines', '')
|
||||
}
|
||||
}
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import MarkdownIt from 'markdown-it/lib'
|
||||
import linkify from 'markdown-it/lib/rules_core/linkify'
|
||||
|
||||
export const linkifyExtra: MarkdownIt.PluginSimple = (md) => {
|
||||
md.core.ruler.push('linkify', state => {
|
||||
try {
|
||||
state.md.options.linkify = true
|
||||
return linkify(state)
|
||||
} finally {
|
||||
state.md.options.linkify = false
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import MarkdownIt from 'markdown-it/lib'
|
||||
|
||||
export const MarkdownItParserDebugger: MarkdownIt.PluginSimple = (md: MarkdownIt) => {
|
||||
md.core.ruler.push('test', (state) => {
|
||||
console.log(state)
|
||||
return true
|
||||
})
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import MarkdownIt, { Options } from 'markdown-it/lib'
|
||||
import Renderer, { RenderRule } from 'markdown-it/lib/renderer'
|
||||
import Token from 'markdown-it/lib/token'
|
||||
|
||||
export const plantumlError: MarkdownIt.PluginSimple = (md) => {
|
||||
const defaultRenderer: RenderRule = md.renderer.rules.fence || (() => '')
|
||||
md.renderer.rules.fence = (tokens: Token[], idx: number, options: Options, env, slf: Renderer) => {
|
||||
const token = tokens[idx]
|
||||
if (token.info === 'plantuml') {
|
||||
return `
|
||||
<p class="alert alert-danger">
|
||||
PlantUML plugin is enabled but not properly configured.
|
||||
</p>
|
||||
`
|
||||
}
|
||||
return defaultRenderer(tokens, idx, options, env, slf)
|
||||
}
|
||||
}
|
62
src/components/markdown-renderer/markdown-renderer.scss
Normal file
62
src/components/markdown-renderer/markdown-renderer.scss
Normal file
|
@ -0,0 +1,62 @@
|
|||
@import '../../../node_modules/github-markdown-css/github-markdown.css';
|
||||
|
||||
.markdown-body {
|
||||
position: relative;
|
||||
font-family: 'Source Sans Pro', "twemoji", sans-serif;
|
||||
word-break: break-word;
|
||||
|
||||
.alert > p, .alert > ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// This is necessary since we need to set this for all DOM Element that could be children of .markdown-body and since we support all of HTML that would literally be everything
|
||||
& > * {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
& > img {
|
||||
width: unset;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
&.wider {
|
||||
max-width: 1500px;
|
||||
|
||||
& > .wider-possible {
|
||||
max-width: 1500px;
|
||||
}
|
||||
}
|
||||
|
||||
a.heading-anchor {
|
||||
margin-left: -1.25em;
|
||||
font-size: 0.75em;
|
||||
margin-top: 0.25em;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote .quote-extra {
|
||||
font-size: 0.85em;
|
||||
margin-inline-start: 0.5em;
|
||||
|
||||
&:first-of-type {
|
||||
&::before {
|
||||
content: '\2014 \00A0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: visible;
|
||||
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
307
src/components/markdown-renderer/markdown-renderer.tsx
Normal file
307
src/components/markdown-renderer/markdown-renderer.tsx
Normal file
|
@ -0,0 +1,307 @@
|
|||
import equal from 'deep-equal'
|
||||
import { DomElement } from 'domhandler'
|
||||
import emojiData from 'emoji-mart/data/twitter.json'
|
||||
import { Data } from 'emoji-mart/dist-es/utils/data'
|
||||
import yaml from 'js-yaml'
|
||||
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'
|
||||
import footnote from 'markdown-it-footnote'
|
||||
import frontmatter from 'markdown-it-front-matter'
|
||||
import imsize from 'markdown-it-imsize'
|
||||
import inserted from 'markdown-it-ins'
|
||||
import marked from 'markdown-it-mark'
|
||||
import mathJax from 'markdown-it-mathjax'
|
||||
import plantuml from 'markdown-it-plantuml'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import subscript from 'markdown-it-sub'
|
||||
import superscript from 'markdown-it-sup'
|
||||
import taskList from 'markdown-it-task-lists'
|
||||
import toc from 'markdown-it-toc-done-right'
|
||||
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
|
||||
import { Trans } from 'react-i18next'
|
||||
import MathJaxReact from 'react-mathjax'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { TocAst } from '../../external-types/markdown-it-toc-done-right/interface'
|
||||
import { ApplicationState } from '../../redux'
|
||||
import { InternalLink } from '../common/links/internal-link'
|
||||
import { ShowIf } from '../common/show-if/show-if'
|
||||
import { ForkAwesomeIcons } from '../editor/editor-pane/tool-bar/emoji-picker/icon-names'
|
||||
import { slugify } from '../editor/table-of-contents/table-of-contents'
|
||||
import { RawYAMLMetadata, YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata'
|
||||
import { createRenderContainer, validAlertLevels } from './container-plugins/alert'
|
||||
import { highlightedCode } from './markdown-it-plugins/highlighted-code'
|
||||
import { linkifyExtra } from './markdown-it-plugins/linkify-extra'
|
||||
import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger'
|
||||
import { plantumlError } from './markdown-it-plugins/plantuml-error'
|
||||
import './markdown-renderer.scss'
|
||||
import { replaceAsciinemaLink } from './regex-plugins/replace-asciinema-link'
|
||||
import { replaceGistLink } from './regex-plugins/replace-gist-link'
|
||||
import { replaceLegacyGistShortCode } from './regex-plugins/replace-legacy-gist-short-code'
|
||||
import { replaceLegacySlideshareShortCode } from './regex-plugins/replace-legacy-slideshare-short-code'
|
||||
import { replaceLegacySpeakerdeckShortCode } from './regex-plugins/replace-legacy-speakerdeck-short-code'
|
||||
import { replaceLegacyVimeoShortCode } from './regex-plugins/replace-legacy-vimeo-short-code'
|
||||
import { replaceLegacyYoutubeShortCode } from './regex-plugins/replace-legacy-youtube-short-code'
|
||||
import { replacePdfShortCode } from './regex-plugins/replace-pdf-short-code'
|
||||
import { replaceQuoteExtraAuthor } from './regex-plugins/replace-quote-extra-author'
|
||||
import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-color'
|
||||
import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time'
|
||||
import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
|
||||
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
|
||||
import { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer'
|
||||
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 { 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 MarkdownRendererProps {
|
||||
content: string
|
||||
wide?: boolean
|
||||
className?: string
|
||||
onTocChange?: (ast: TocAst) => void
|
||||
onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void
|
||||
onFirstHeadingChange?: (firstHeading: string | undefined) => void
|
||||
}
|
||||
|
||||
const markdownItTwitterEmojis = Object.keys((emojiData as unknown as Data).emojis)
|
||||
.reduce((reduceObject, emojiIdentifier) => {
|
||||
const emoji = (emojiData as unknown as Data).emojis[emojiIdentifier]
|
||||
const emojiCodes = emoji.unified ?? emoji.b
|
||||
if (emojiCodes) {
|
||||
reduceObject[emojiIdentifier] = emojiCodes.split('-').map(char => `&#x${char};`).join('')
|
||||
}
|
||||
return reduceObject
|
||||
}, {} as { [key: string]: string })
|
||||
|
||||
const emojiSkinToneModifierMap = [2, 3, 4, 5, 6]
|
||||
.reduce((reduceObject, modifierValue) => {
|
||||
const lightSkinCode = 127995
|
||||
const codepoint = lightSkinCode + (modifierValue - 2)
|
||||
const shortcode = `skin-tone-${modifierValue}`
|
||||
reduceObject[shortcode] = `&#${codepoint};`
|
||||
return reduceObject
|
||||
}, {} as { [key: string]: string })
|
||||
|
||||
const forkAwesomeIconMap = Object.keys(ForkAwesomeIcons)
|
||||
.reduce((reduceObject, icon) => {
|
||||
const shortcode = `fa-${icon}`
|
||||
// noinspection CheckTagEmptyBody
|
||||
reduceObject[shortcode] = `<i class="fa fa-${icon}"></i>`
|
||||
return reduceObject
|
||||
}, {} as { [key: string]: string })
|
||||
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide }) => {
|
||||
const [tocAst, setTocAst] = useState<TocAst>()
|
||||
const [lastTocAst, setLastTocAst] = useState<TocAst>()
|
||||
const [yamlError, setYamlError] = useState(false)
|
||||
const rawMetaRef = useRef<RawYAMLMetadata>()
|
||||
const oldMetaRef = useRef<RawYAMLMetadata>()
|
||||
const firstHeadingRef = useRef<string>()
|
||||
const oldFirstHeadingRef = useRef<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (onMetaDataChange && !equal(oldMetaRef.current, rawMetaRef.current)) {
|
||||
if (rawMetaRef.current) {
|
||||
const newMetaData = new YAMLMetaData(rawMetaRef.current)
|
||||
onMetaDataChange(newMetaData)
|
||||
} else {
|
||||
onMetaDataChange(undefined)
|
||||
}
|
||||
oldMetaRef.current = rawMetaRef.current
|
||||
}
|
||||
if (onFirstHeadingChange && !equal(firstHeadingRef.current, oldFirstHeadingRef.current)) {
|
||||
onFirstHeadingChange(firstHeadingRef.current || undefined)
|
||||
oldFirstHeadingRef.current = firstHeadingRef.current
|
||||
}
|
||||
})
|
||||
|
||||
const plantumlServer = useSelector((state: ApplicationState) => state.config.plantumlServer)
|
||||
|
||||
const markdownIt = useMemo(() => {
|
||||
const md = new MarkdownIt('default', {
|
||||
html: true,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
if (onFirstHeadingChange) {
|
||||
md.core.ruler.after('normalize', 'extract first L1 heading', (state) => {
|
||||
const lines = state.src.split('\n')
|
||||
const linkAltTextRegex = /!?\[([^\]]*)]\([^)]*\)/
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('# ')) {
|
||||
firstHeadingRef.current = line.replace('# ', '').replace(linkAltTextRegex, '$1')
|
||||
return true
|
||||
}
|
||||
}
|
||||
firstHeadingRef.current = undefined
|
||||
return true
|
||||
})
|
||||
}
|
||||
if (onMetaDataChange) {
|
||||
md.use(frontmatter, (rawMeta: string) => {
|
||||
try {
|
||||
const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata
|
||||
setYamlError(false)
|
||||
rawMetaRef.current = meta
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setYamlError(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
md.use(taskList)
|
||||
if (plantumlServer) {
|
||||
md.use(plantuml, {
|
||||
openMarker: '```plantuml',
|
||||
closeMarker: '```',
|
||||
server: plantumlServer
|
||||
})
|
||||
} else {
|
||||
md.use(plantumlError)
|
||||
}
|
||||
md.use(emoji, {
|
||||
defs: {
|
||||
...markdownItTwitterEmojis,
|
||||
...emojiSkinToneModifierMap,
|
||||
...forkAwesomeIconMap
|
||||
}
|
||||
})
|
||||
md.use(abbreviation)
|
||||
md.use(definitionList)
|
||||
md.use(subscript)
|
||||
md.use(superscript)
|
||||
md.use(inserted)
|
||||
md.use(marked)
|
||||
md.use(footnote)
|
||||
if (onMetaDataChange) {
|
||||
md.use(frontmatter, (rawMeta: string) => {
|
||||
try {
|
||||
const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata
|
||||
setYamlError(false)
|
||||
rawMetaRef.current = meta
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setYamlError(true)
|
||||
rawMetaRef.current = ({} as RawYAMLMetadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
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, replaceAsciinemaLink)
|
||||
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)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
md.use(MarkdownItParserDebugger)
|
||||
}
|
||||
|
||||
validAlertLevels.forEach(level => {
|
||||
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
|
||||
})
|
||||
|
||||
return md
|
||||
}, [onMetaDataChange, onFirstHeadingChange, plantumlServer])
|
||||
|
||||
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[] = [
|
||||
new PossibleWiderReplacer(),
|
||||
new GistReplacer(),
|
||||
new YoutubeReplacer(),
|
||||
new VimeoReplacer(),
|
||||
new AsciinemaReplacer(),
|
||||
new PdfReplacer(),
|
||||
new ImageReplacer(),
|
||||
new TocReplacer(),
|
||||
new HighlightedCodeReplacer(),
|
||||
new QuoteOptionsReplacer(),
|
||||
new MathjaxReplacer()
|
||||
]
|
||||
if (onMetaDataChange) {
|
||||
// This is used if the front-matter callback is never called, because the user deleted everything regarding metadata from the document
|
||||
rawMetaRef.current = undefined
|
||||
}
|
||||
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)
|
||||
}
|
||||
return ReactHtmlParser(html, { transform: transform })
|
||||
}, [content, markdownIt, onMetaDataChange])
|
||||
|
||||
return (
|
||||
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`}>
|
||||
<ShowIf condition={yamlError}>
|
||||
<Alert variant='warning' dir='auto'>
|
||||
<Trans i18nKey='editor.invalidYaml'>
|
||||
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
|
||||
</Trans>
|
||||
</Alert>
|
||||
</ShowIf>
|
||||
<MathJaxReact.Provider>
|
||||
{result}
|
||||
</MathJaxReact.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const protocolRegex = /(?:http(?:s)?:\/\/)?/
|
||||
const domainRegex = /(?:asciinema\.org\/a\/)/
|
||||
const idRegex = /(\d+)/
|
||||
const tailRegex = /(?:[./?#].*)?/
|
||||
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
|
||||
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
|
||||
|
||||
export const replaceAsciinemaLink: RegexOptions = {
|
||||
name: 'asciinema-link',
|
||||
regex: linkRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-asciinema id="${match}"></codimd-asciinema>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const protocolRegex = /(?:http(?:s)?:\/\/)?/
|
||||
const domainRegex = /(?:gist\.github\.com\/)/
|
||||
const idRegex = /(\w+\/\w+)/
|
||||
const tailRegex = /(?:[./?#].*)?/
|
||||
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
|
||||
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
|
||||
|
||||
export const replaceGistLink: RegexOptions = {
|
||||
name: 'gist-link',
|
||||
regex: linkRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-gist id="${match}"></codimd-gist>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const finalRegex = /^{%gist (\w+\/\w+) ?%}$/
|
||||
|
||||
export const replaceLegacyGistShortCode: RegexOptions = {
|
||||
name: 'legacy-gist-short-code',
|
||||
regex: finalRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-gist id="${match}"></codimd-gist>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const finalRegex = /^{%slideshare (\w+\/[\w-]+) ?%}$/
|
||||
|
||||
export const replaceLegacySlideshareShortCode: RegexOptions = {
|
||||
name: 'legacy-slideshare-short-code',
|
||||
regex: finalRegex,
|
||||
replace: (match) => {
|
||||
return `<a target="_blank" rel="noopener noreferrer" href="https://www.slideshare.net/${match}">https://www.slideshare.net/${match}</a>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const finalRegex = /^{%speakerdeck (\w+\/[\w-]+) ?%}$/
|
||||
|
||||
export const replaceLegacySpeakerdeckShortCode: RegexOptions = {
|
||||
name: 'legacy-speakerdeck-short-code',
|
||||
regex: finalRegex,
|
||||
replace: (match) => {
|
||||
return `<a target="_blank" rel="noopener noreferrer" href="https://speakerdeck.com//${match}">https://speakerdeck.com/${match}</a>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const replaceLegacyVimeoShortCode: RegexOptions = {
|
||||
name: 'legacy-vimeo-short-code',
|
||||
regex: /^{%vimeo ([\d]{6,11}) ?%}$/,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-vimeo id="${match}"></codimd-vimeo>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const replaceLegacyYoutubeShortCode: RegexOptions = {
|
||||
name: 'legacy-youtube-short-code',
|
||||
regex: /^{%youtube ([^"&?\\/\s]{11}) ?%}$/,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-youtube id="${match}"></codimd-youtube>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const replacePdfShortCode: RegexOptions = {
|
||||
name: 'pdf-short-code',
|
||||
regex: /^{%pdf (.*) ?%}$/,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-pdf url="${match}"></codimd-pdf>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const replaceQuoteExtraAuthor: RegexOptions = {
|
||||
name: 'quote-extra-name',
|
||||
regex: /\[name=([^\]]+)]/,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<span class="quote-extra"><i class="fa fa-user mx-1"></i> ${match}</span>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const cssColorRegex = /\[color=(#(?:[0-9a-f]{2}){2,4}|(?:#[0-9a-f]{3})|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)]/i
|
||||
|
||||
export const replaceQuoteExtraColor: RegexOptions = {
|
||||
name: 'quote-extra-color',
|
||||
regex: cssColorRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<span class="quote-extra" data-color='${match}' style='color: ${match}'><i class="fa fa-tag"></i></span>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const replaceQuoteExtraTime: RegexOptions = {
|
||||
name: 'quote-extra-time',
|
||||
regex: /\[time=([^\]]+)]/,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<span class="quote-extra"><i class="fa fa-clock-o mx-1"></i> ${match}</span>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const protocolRegex = /(?:http(?:s)?:\/\/)?/
|
||||
const domainRegex = /(?:player\.)?(?:vimeo\.com\/)(?:(?:channels|album|ondemand|groups)\/\w+\/)?(?:video\/)?/
|
||||
const idRegex = /([\d]{6,11})/
|
||||
const tailRegex = /(?:[?#].*)?/
|
||||
const vimeoVideoUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
|
||||
const linkRegex = new RegExp(`^${vimeoVideoUrlRegex.source}$`, 'i')
|
||||
|
||||
export const replaceVimeoLink: RegexOptions = {
|
||||
name: 'vimeo-link',
|
||||
regex: linkRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-vimeo id="${match}"></codimd-vimeo>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const protocolRegex = /(?:http(?:s)?:\/\/)?/
|
||||
const subdomainRegex = /(?:www.)?/
|
||||
const pathRegex = /(?:youtube(?:-nocookie)?\.com\/(?:[^\\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)/
|
||||
const idRegex = /([^"&?\\/\s]{11})/
|
||||
const tailRegex = /(?:[?&#].*)?/
|
||||
const youtubeVideoUrlRegex = new RegExp(`(?:${protocolRegex.source}${subdomainRegex.source}${pathRegex.source}${idRegex.source}${tailRegex.source})`)
|
||||
const linkRegex = new RegExp(`^${youtubeVideoUrlRegex.source}$`, 'i')
|
||||
|
||||
export const replaceYouTubeLink: RegexOptions = {
|
||||
name: 'youtube-link',
|
||||
regex: linkRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<codimd-youtube id="${match}"></codimd-youtube>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
export type SubNodeConverter = (node: DomElement, index: number) => ReactElement
|
||||
|
||||
export interface ComponentReplacer {
|
||||
getReplacement: (node: DomElement, index:number, subNodeConverter: SubNodeConverter) => (ReactElement|undefined)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
|
||||
export interface AsciinemaFrameProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const AsciinemaFrame: React.FC<AsciinemaFrameProps> = ({ id }) => {
|
||||
return (
|
||||
<OneClickEmbedding
|
||||
containerClassName={'embed-responsive embed-responsive-16by9'}
|
||||
previewContainerClassName={'embed-responsive-item'}
|
||||
hoverIcon={'play'}
|
||||
loadingImageUrl={`https://asciinema.org/a/${id}.png`}>
|
||||
<iframe className='embed-responsive-item' title={`asciinema cast ${id}`}
|
||||
src={`https://asciinema.org/a/${id}/embed?autoplay=1`}/>
|
||||
</OneClickEmbedding>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
import { AsciinemaFrame } from './asciinema-frame'
|
||||
|
||||
export class AsciinemaReplacer implements ComponentReplacer {
|
||||
private counterMap: Map<string, number> = new Map<string, number>()
|
||||
|
||||
getReplacement (node: DomElement): React.ReactElement | undefined {
|
||||
const attributes = getAttributesFromCodiMdTag(node, 'asciinema')
|
||||
if (attributes && attributes.id) {
|
||||
const asciinemaId = attributes.id
|
||||
const count = (this.counterMap.get(asciinemaId) || 0) + 1
|
||||
this.counterMap.set(asciinemaId, count)
|
||||
return (
|
||||
<AsciinemaFrame key={`asciinema_${asciinemaId}_${count}`} id={asciinemaId}/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
|
||||
export const getAttributesFromCodiMdTag = (node: DomElement, tagName: string): ({ [s: string]: string; }|undefined) => {
|
||||
if (node.name !== `codimd-${tagName}` || !node.attribs) {
|
||||
return
|
||||
}
|
||||
return node.attribs
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.gist-frame {
|
||||
.one-click-embedding-preview {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import './gist-frame.scss'
|
||||
|
||||
export interface GistFrameProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface resizeEvent {
|
||||
size: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
|
||||
const iframeHtml = useMemo(() => {
|
||||
return (`
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base target="_parent">
|
||||
<title>gist</title>
|
||||
<style>
|
||||
* { font-size:12px; }
|
||||
body{ overflow:hidden; margin: 0;}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
function doLoad() {
|
||||
window.parent.postMessage({eventType: 'gistResize', size: document.body.scrollHeight, id: '${id}'}, '*')
|
||||
tweakLinks();
|
||||
}
|
||||
function tweakLinks() {
|
||||
document.querySelectorAll(".gist-meta > a").forEach((link) => {
|
||||
link.rel="noopener noreferer"
|
||||
link.target="_blank"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body onload="doLoad()">
|
||||
<script type="text/javascript" src="https://gist.github.com/${id}.js"></script>
|
||||
</body>
|
||||
</html>`)
|
||||
}, [id])
|
||||
|
||||
const [frameHeight, setFrameHeight] = useState(0)
|
||||
|
||||
const sizeMessage = useCallback((message: MessageEvent) => {
|
||||
const data = message.data as resizeEvent
|
||||
if (data.id !== id) {
|
||||
return
|
||||
}
|
||||
setFrameHeight(data.size)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', sizeMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', sizeMessage)
|
||||
}
|
||||
}, [sizeMessage])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups"
|
||||
width='100%'
|
||||
height={`${frameHeight}px`}
|
||||
frameBorder='0'
|
||||
title={`gist ${id}`}
|
||||
src={`data:text/html;base64,${btoa(iframeHtml)}`}/>
|
||||
)
|
||||
}
|
Binary file not shown.
After ![]() (image error) Size: 30 KiB |
|
@ -0,0 +1,25 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
import { GistFrame } from './gist-frame'
|
||||
import preview from './gist-preview.png'
|
||||
|
||||
export class GistReplacer implements ComponentReplacer {
|
||||
private counterMap: Map<string, number> = new Map<string, number>()
|
||||
|
||||
getReplacement (node: DomElement): React.ReactElement | undefined {
|
||||
const attributes = getAttributesFromCodiMdTag(node, 'gist')
|
||||
if (attributes && attributes.id) {
|
||||
const gistId = attributes.id
|
||||
const count = (this.counterMap.get(gistId) || 0) + 1
|
||||
this.counterMap.set(gistId, count)
|
||||
return (
|
||||
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={`gist_${gistId}_${count}`} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
|
||||
<GistFrame id={gistId}/>
|
||||
</OneClickEmbedding>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
.markdown-body {
|
||||
@import '../../../../../../node_modules/highlight.js/styles/github-gist.css';
|
||||
}
|
||||
|
||||
.markdown-body pre code.hljs {
|
||||
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
||||
&.showGutter {
|
||||
.linenumber {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
z-index: 4;
|
||||
padding: 0 8px 0 0;
|
||||
min-width: 20px;
|
||||
box-sizing: content-box;
|
||||
color: #afafaf;
|
||||
border-right: 3px solid #6ce26c;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
&:before {
|
||||
content: attr(data-line-number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.showGutter .codeline {
|
||||
margin: 0 0 0 16px;
|
||||
}
|
||||
|
||||
&.wrapLines .codeline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import hljs from 'highlight.js'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import ReactHtmlParser from 'react-html-parser'
|
||||
import './highlighted-code.scss'
|
||||
|
||||
export interface HighlightedCodeProps {
|
||||
code: string,
|
||||
language?: string,
|
||||
startLineNumber?: number
|
||||
wrapLines: boolean
|
||||
}
|
||||
|
||||
export const escapeHtml = (unsafe: string): string => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const checkIfLanguageIsSupported = (language: string): boolean => {
|
||||
return hljs.listLanguages().indexOf(language) > -1
|
||||
}
|
||||
|
||||
const correctLanguage = (language: string | undefined): string | undefined => {
|
||||
switch (language) {
|
||||
case 'html':
|
||||
return 'xml'
|
||||
default:
|
||||
return language
|
||||
}
|
||||
}
|
||||
|
||||
export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language, startLineNumber, wrapLines }) => {
|
||||
const highlightedCode = useMemo(() => {
|
||||
const replacedLanguage = correctLanguage(language)
|
||||
return ((!!replacedLanguage && checkIfLanguageIsSupported(replacedLanguage)) ? hljs.highlight(replacedLanguage, code).value : escapeHtml(code))
|
||||
.split('\n')
|
||||
.filter(line => !!line)
|
||||
.map(line => ReactHtmlParser(line))
|
||||
}, [code, language])
|
||||
|
||||
return (
|
||||
<code className={`hljs ${startLineNumber !== undefined ? 'showGutter' : ''} ${wrapLines ? 'wrapLines' : ''}`}>
|
||||
{
|
||||
highlightedCode
|
||||
.map((line, index) => {
|
||||
return <Fragment key={index}>
|
||||
<span className={'linenumber'} data-line-number={(startLineNumber || 1) + index}/>
|
||||
<div className={'codeline'}>
|
||||
{line}
|
||||
</div>
|
||||
</Fragment>
|
||||
})
|
||||
}
|
||||
|
||||
</code>)
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { HighlightedCode } from './highlighted-code/highlighted-code'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
|
||||
export class HighlightedCodeReplacer implements ComponentReplacer {
|
||||
private lastLineNumber = 0;
|
||||
|
||||
getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
|
||||
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || !codeNode.children || !codeNode.children[0]) {
|
||||
return
|
||||
}
|
||||
|
||||
const language = codeNode.attribs['data-highlight-language']
|
||||
const showLineNumbers = codeNode.attribs['data-show-line-numbers'] !== undefined
|
||||
const startLineNumberAttribute = codeNode.attribs['data-start-line-number']
|
||||
|
||||
const startLineNumber = startLineNumberAttribute === '+' ? this.lastLineNumber : (parseInt(startLineNumberAttribute) || 1)
|
||||
const wrapLines = codeNode.attribs['data-wrap-lines'] !== undefined
|
||||
const code = codeNode.children[0].data as string
|
||||
|
||||
if (showLineNumbers) {
|
||||
this.lastLineNumber = startLineNumber + code.split('\n')
|
||||
.filter(line => !!line).length
|
||||
}
|
||||
|
||||
return <HighlightedCode key={index} language={language} startLineNumber={showLineNumbers ? startLineNumber : undefined} wrapLines={wrapLines} code={code}/>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getProxiedUrl } from '../../../../api/media'
|
||||
import { ApplicationState } from '../../../../redux'
|
||||
|
||||
export const ImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = ({ alt, src, ...props }) => {
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const imageProxyEnabled = useSelector((state: ApplicationState) => state.config.useImageProxy)
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageProxyEnabled || !src) {
|
||||
return
|
||||
}
|
||||
getProxiedUrl(src)
|
||||
.then(proxyResponse => setImageUrl(proxyResponse.src))
|
||||
.catch(err => console.error(err))
|
||||
}, [imageProxyEnabled, src])
|
||||
|
||||
if (imageProxyEnabled) {
|
||||
return (
|
||||
<img alt={alt} src={imageUrl} {...props}/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img alt={alt} src={src ?? ''} {...props}/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
import { ImageFrame } from './image-frame'
|
||||
|
||||
export class ImageReplacer implements ComponentReplacer {
|
||||
getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
|
||||
if (node.name === 'img' && node.attribs) {
|
||||
return <ImageFrame
|
||||
key={index}
|
||||
id={node.attribs.id}
|
||||
className={node.attribs.class}
|
||||
src={node.attribs.src}
|
||||
alt={node.attribs.alt}
|
||||
width={node.attribs.width}
|
||||
height={node.attribs.height}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export interface ImageProxyResponse {
|
||||
src: string
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import MathJax from 'react-mathjax'
|
||||
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
|
||||
|
||||
const getNodeIfMathJaxBlock = (node: DomElement): (DomElement|undefined) => {
|
||||
if (node.name !== 'p' || !node.children || node.children.length !== 1) {
|
||||
return
|
||||
}
|
||||
const mathJaxNode = node.children[0]
|
||||
return (mathJaxNode.name === 'codimd-mathjax' && mathJaxNode.attribs?.inline === undefined) ? mathJaxNode : undefined
|
||||
}
|
||||
|
||||
const getNodeIfInlineMathJax = (node: DomElement): (DomElement|undefined) => {
|
||||
return (node.name === 'codimd-mathjax' && node.attribs?.inline !== undefined) ? node : undefined
|
||||
}
|
||||
|
||||
export class MathjaxReplacer implements ComponentReplacer {
|
||||
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
|
||||
const mathJax = getNodeIfMathJaxBlock(node) || getNodeIfInlineMathJax(node)
|
||||
if (mathJax?.children && mathJax.children[0]) {
|
||||
const mathJaxContent = mathJax.children[0]?.data as string
|
||||
const isInline = (mathJax.attribs?.inline) !== undefined
|
||||
return <MathJax.Node key={index} inline={isInline} formula={mathJaxContent}/>
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
.one-click-embedding {
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.one-click-embedding-icon {
|
||||
display: inline;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
text-shadow: #000000 0 0 5px;
|
||||
}
|
||||
|
||||
&:hover > .one-click-embedding-icon {
|
||||
opacity: 0.8;
|
||||
text-shadow: #000000 0 0 10px;
|
||||
}
|
||||
|
||||
.one-click-embedding-preview {
|
||||
background: none;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { IconName } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import './one-click-embedding.scss'
|
||||
|
||||
interface OneClickFrameProps {
|
||||
onImageFetch?: () => Promise<string>
|
||||
loadingImageUrl?: string
|
||||
hoverIcon?: IconName
|
||||
hoverTextI18nKey?: string
|
||||
tooltip?: string
|
||||
containerClassName?: string
|
||||
previewContainerClassName?: string
|
||||
onActivate?: () => void
|
||||
}
|
||||
|
||||
export const OneClickEmbedding: React.FC<OneClickFrameProps> = ({ previewContainerClassName, containerClassName, onImageFetch, loadingImageUrl, children, tooltip, hoverIcon, hoverTextI18nKey, onActivate }) => {
|
||||
const [showFrame, setShowFrame] = useState(false)
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState(loadingImageUrl)
|
||||
|
||||
const showChildren = () => {
|
||||
setShowFrame(true)
|
||||
if (onActivate) {
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!onImageFetch) {
|
||||
return
|
||||
}
|
||||
onImageFetch().then((imageLink) => {
|
||||
setPreviewImageUrl(imageLink)
|
||||
}).catch((message) => {
|
||||
console.error(message)
|
||||
})
|
||||
}, [onImageFetch])
|
||||
|
||||
return (
|
||||
<span className={ containerClassName }>
|
||||
<ShowIf condition={showFrame}>
|
||||
{children}
|
||||
</ShowIf>
|
||||
<ShowIf condition={!showFrame}>
|
||||
<span className={`one-click-embedding ${previewContainerClassName || ''}`} onClick={showChildren}>
|
||||
<ShowIf condition={!!previewImageUrl}>
|
||||
<img className={'one-click-embedding-preview'} src={previewImageUrl} alt={tooltip || ''} title={tooltip || ''}/>
|
||||
</ShowIf>
|
||||
<ShowIf condition={!!hoverIcon}>
|
||||
<span className='one-click-embedding-icon text-center'>
|
||||
<i className={`fa fa-${hoverIcon as string} fa-5x mb-2`} />
|
||||
<ShowIf condition={!!hoverTextI18nKey}>
|
||||
<br />
|
||||
<Trans i18nKey={hoverTextI18nKey} />
|
||||
</ShowIf>
|
||||
</span>
|
||||
</ShowIf>
|
||||
</span>
|
||||
</ShowIf>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.pdf-frame {
|
||||
width: 100%;
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import React, { useState } from 'react'
|
||||
import { ExternalLink } from '../../../common/links/external-link'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
import './pdf-frame.scss'
|
||||
|
||||
export interface PdfFrameProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
export const PdfFrame: React.FC<PdfFrameProps> = ({ url }) => {
|
||||
const [activated, setActivated] = useState(false)
|
||||
|
||||
return (
|
||||
<OneClickEmbedding containerClassName={`embed-responsive embed-responsive-${activated ? '4by3' : '16by9'}`}
|
||||
previewContainerClassName={'embed-responsive-item bg-danger'}
|
||||
hoverIcon={'file-pdf-o'}
|
||||
hoverTextI18nKey={'editor.embeddings.clickToLoad'}
|
||||
onActivate={() => setActivated(true)}>
|
||||
<object type={'application/pdf'} data={url} className={'pdf-frame'}>
|
||||
<ExternalLink text={url} href={url}/>
|
||||
</object>
|
||||
</OneClickEmbedding>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
|
||||
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
|
||||
import { PdfFrame } from './pdf-frame'
|
||||
|
||||
export class PdfReplacer implements ComponentReplacer {
|
||||
private counterMap: Map<string, number> = new Map<string, number>()
|
||||
|
||||
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
|
||||
const attributes = getAttributesFromCodiMdTag(node, 'pdf')
|
||||
if (attributes && attributes.url) {
|
||||
const pdfUrl = attributes.url
|
||||
const count = (this.counterMap.get(pdfUrl) || 0) + 1
|
||||
this.counterMap.set(pdfUrl, count)
|
||||
return <PdfFrame key={`pdf_${pdfUrl}_${count}`} url={pdfUrl}/>
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
|
||||
|
||||
export class PossibleWiderReplacer implements ComponentReplacer {
|
||||
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
|
||||
if (node.name !== 'p') {
|
||||
return
|
||||
}
|
||||
if (!node.children || node.children.length !== 1) {
|
||||
return
|
||||
}
|
||||
const childIsImage = node.children[0].name === 'img'
|
||||
const childIsYoutube = node.children[0].name === 'codimd-youtube'
|
||||
const childIsVimeo = node.children[0].name === 'codimd-vimeo'
|
||||
const childIsAsciinema = node.children[0].name === 'codimd-asciinema'
|
||||
const childIsPDF = node.children[0].name === 'codimd-pdf'
|
||||
if (!(childIsImage || childIsYoutube || childIsVimeo || childIsAsciinema || childIsPDF)) {
|
||||
return
|
||||
}
|
||||
|
||||
// This appends the 'wider-possible' class to the node for a wider view in view-mode
|
||||
node.attribs = Object.assign(node.attribs || {}, { class: `wider-possible ${node.attribs?.class || ''}` })
|
||||
return subNodeConverter(node, index)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
|
||||
|
||||
const isColorExtraElement = (node: DomElement | undefined): boolean => {
|
||||
if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) {
|
||||
return false
|
||||
}
|
||||
return (node.name === 'span' && node.attribs.class === 'quote-extra')
|
||||
}
|
||||
|
||||
const findQuoteOptionsParent = (nodes: DomElement[]): DomElement | undefined => {
|
||||
return nodes.find((child) => {
|
||||
if (child.name !== 'p' || !child.children || child.children.length < 1) {
|
||||
return false
|
||||
}
|
||||
return child.children.find(isColorExtraElement) !== undefined
|
||||
})
|
||||
}
|
||||
|
||||
export class QuoteOptionsReplacer implements ComponentReplacer {
|
||||
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
|
||||
if (node.name !== 'blockquote' || !node.children || node.children.length < 1) {
|
||||
return
|
||||
}
|
||||
const paragraph = findQuoteOptionsParent(node.children)
|
||||
if (!paragraph) {
|
||||
return
|
||||
}
|
||||
const childElements = paragraph.children || []
|
||||
const optionsTag = childElements.find(isColorExtraElement)
|
||||
if (!optionsTag) {
|
||||
return
|
||||
}
|
||||
paragraph.children = childElements.filter(elem => !isColorExtraElement(elem))
|
||||
const attributes = optionsTag.attribs
|
||||
if (!attributes || !attributes['data-color']) {
|
||||
return
|
||||
}
|
||||
node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${attributes['data-color']};` })
|
||||
return subNodeConverter(node, index)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
|
||||
|
||||
export class TocReplacer implements ComponentReplacer {
|
||||
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
|
||||
if (node.name !== 'p' || node.children?.length !== 1) {
|
||||
return
|
||||
}
|
||||
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 subNodeConverter(listElement, index)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
|
||||
interface VimeoApiResponse {
|
||||
// Vimeo uses strange names for their fields. ESLint doesn't like that.
|
||||
// eslint-disable-next-line camelcase
|
||||
thumbnail_large?: string
|
||||
}
|
||||
|
||||
export interface VimeoFrameProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const VimeoFrame: React.FC<VimeoFrameProps> = ({ id }) => {
|
||||
const getPreviewImageLink = useCallback(async () => {
|
||||
const response = await fetch(`https://vimeo.com/api/v2/video/${id}.json`, {
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer'
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Error while loading data from vimeo api')
|
||||
}
|
||||
const vimeoResponse: VimeoApiResponse[] = await response.json() as VimeoApiResponse[]
|
||||
|
||||
if (vimeoResponse[0] && vimeoResponse[0].thumbnail_large) {
|
||||
return vimeoResponse[0].thumbnail_large
|
||||
} else {
|
||||
throw new Error('Invalid vimeo response')
|
||||
}
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<OneClickEmbedding containerClassName={'embed-responsive embed-responsive-16by9'} previewContainerClassName={'embed-responsive-item'} loadingImageUrl={'https://i.vimeocdn.com/video/'} hoverIcon={'vimeo-square'}
|
||||
onImageFetch={getPreviewImageLink}>
|
||||
<iframe className='embed-responsive-item' title={`vimeo video of ${id}`}
|
||||
src={`https://player.vimeo.com/video/${id}?autoplay=1`}
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"/>
|
||||
</OneClickEmbedding>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
import { VimeoFrame } from './vimeo-frame'
|
||||
|
||||
export class VimeoReplacer implements ComponentReplacer {
|
||||
private counterMap: Map<string, number> = new Map<string, number>()
|
||||
|
||||
getReplacement (node: DomElement): React.ReactElement | undefined {
|
||||
const attributes = getAttributesFromCodiMdTag(node, 'vimeo')
|
||||
if (attributes && attributes.id) {
|
||||
const videoId = attributes.id
|
||||
const count = (this.counterMap.get(videoId) || 0) + 1
|
||||
this.counterMap.set(videoId, count)
|
||||
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
|
||||
export interface YouTubeFrameProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const YouTubeFrame: React.FC<YouTubeFrameProps> = ({ id }) => {
|
||||
return (
|
||||
<OneClickEmbedding containerClassName={'embed-responsive embed-responsive-16by9'}
|
||||
previewContainerClassName={'embed-responsive-item'} hoverIcon={'youtube-play'}
|
||||
loadingImageUrl={`//i.ytimg.com/vi/${id}/maxresdefault.jpg`}>
|
||||
<iframe className='embed-responsive-item' title={`youtube video of ${id}`}
|
||||
src={`//www.youtube-nocookie.com/embed/${id}?autoplay=1`}
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"/>
|
||||
</OneClickEmbedding>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React from 'react'
|
||||
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
|
||||
import { ComponentReplacer } from '../ComponentReplacer'
|
||||
import { YouTubeFrame } from './youtube-frame'
|
||||
|
||||
export class YoutubeReplacer implements ComponentReplacer {
|
||||
private counterMap: Map<string, number> = new Map<string, number>()
|
||||
|
||||
getReplacement (node: DomElement): React.ReactElement | undefined {
|
||||
const attributes = getAttributesFromCodiMdTag(node, 'youtube')
|
||||
if (attributes && attributes.id) {
|
||||
const videoId = attributes.id
|
||||
const count = (this.counterMap.get(videoId) || 0) + 1
|
||||
this.counterMap.set(videoId, count)
|
||||
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue