mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-27 13:34:28 -04:00
Basic markdown renderer (#197)
* Add basic markdown it rendering * Add markdown preview * Add embedings for vimeo, youtube, gist * Add support for legacy shortcodes and link detection * Set "both" as editor default * Add markdown-it-task-lists * Add twemoji * Changed SlideShare short-code behaviour from embedding to generating a link * Extract markdown it parser debugger into separate component * Deactivate markdown it linkify for now * Add link safety attributes * Add one-click-embedding component and use it * Added embedding changes and deprecations to CHANGELOG.md Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de> Co-authored-by: Philip Molares <philip@mauricedoepke.de> Co-authored-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
8ba2be7c70
commit
7189a63618
31 changed files with 637 additions and 16 deletions
|
@ -11,18 +11,22 @@ import 'codemirror/addon/search/match-highlighter'
|
|||
import 'codemirror/addon/selection/active-line'
|
||||
import 'codemirror/keymap/sublime.js'
|
||||
import 'codemirror/mode/gfm/gfm.js'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import './editor-window.scss'
|
||||
|
||||
const EditorWindow: React.FC = () => {
|
||||
export interface EditorWindowProps {
|
||||
onContentChange: (content: string) => void
|
||||
content: string
|
||||
}
|
||||
|
||||
const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, content }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [content, setContent] = useState<string>('')
|
||||
return (
|
||||
<ControlledCodeMirror
|
||||
className="h-100 w-100"
|
||||
className="h-100 w-100 flex-fill"
|
||||
value={content}
|
||||
options={{
|
||||
mode: 'gfm',
|
||||
|
@ -58,10 +62,7 @@ const EditorWindow: React.FC = () => {
|
|||
}
|
||||
}
|
||||
onBeforeChange={(editor, data, value) => {
|
||||
setContent(value)
|
||||
}}
|
||||
onChange={(editor, data, value) => {
|
||||
console.log('change!')
|
||||
onContentChange(value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -12,6 +12,7 @@ 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\n\n## Slideshare\n{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps://gist.github.com/schacon/1\n\n## YouTube\nhttps://www.youtube.com/watch?v=KgMpKsp23yY\n\n## Vimeo\nhttps://vimeo.com/23237102')
|
||||
const isWide = useMedia({ minWidth: 576 })
|
||||
const [firstDraw, setFirstDraw] = useState(true)
|
||||
|
||||
|
@ -32,9 +33,9 @@ const Editor: React.FC = () => {
|
|||
<TaskBar/>
|
||||
<Splitter
|
||||
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
|
||||
left={<EditorWindow/>}
|
||||
left={<EditorWindow onContentChange={content => setMarkdownContent(content)} content={markdownContent}/>}
|
||||
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
|
||||
right={<MarkdownPreview/>}
|
||||
right={<MarkdownPreview content={markdownContent}/>}
|
||||
containerClassName={'overflow-hidden'}/>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
|
|
@ -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,6 @@
|
|||
@import '../../../../node_modules/github-markdown-css/github-markdown.css';
|
||||
|
||||
.markdown-body {
|
||||
max-width: 758px;
|
||||
font-family: 'Source Sans Pro', "twemoji", sans-serif;
|
||||
}
|
|
@ -1,9 +1,81 @@
|
|||
import React from 'react'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import emoji from 'markdown-it-emoji'
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import taskList from 'markdown-it-task-lists'
|
||||
import React, { ReactElement, useMemo } from 'react'
|
||||
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
|
||||
import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger'
|
||||
import './markdown-preview.scss'
|
||||
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 { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
|
||||
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
|
||||
import { getGistReplacement } from './replace-components/gist/gist-frame'
|
||||
import { getVimeoReplacement } from './replace-components/vimeo/vimeo-frame'
|
||||
import { getYouTubeReplacement } from './replace-components/youtube/youtube-frame'
|
||||
|
||||
export interface MarkdownPreviewProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ content }) => {
|
||||
const markdownIt = useMemo(() => {
|
||||
const md = new MarkdownIt('default', {
|
||||
html: true,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
linkify: false,
|
||||
typographer: true
|
||||
})
|
||||
md.use(taskList)
|
||||
md.use(emoji)
|
||||
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
|
||||
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
|
||||
md.use(markdownItRegex, replaceLegacyGistShortCode)
|
||||
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
|
||||
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
|
||||
md.use(markdownItRegex, replaceYouTubeLink)
|
||||
md.use(markdownItRegex, replaceVimeoLink)
|
||||
md.use(markdownItRegex, replaceGistLink)
|
||||
md.use(MarkdownItParserDebugger)
|
||||
return md
|
||||
}, [])
|
||||
|
||||
const result: ReactElement[] = useMemo(() => {
|
||||
const youtubeIdCounterMap = new Map<string, number>()
|
||||
const vimeoIdCounterMap = new Map<string, number>()
|
||||
const gistIdCounterMap = new Map<string, number>()
|
||||
|
||||
const html: string = markdownIt.render(content)
|
||||
const transform: Transform = (node, index) => {
|
||||
const resultYT = getYouTubeReplacement(node, youtubeIdCounterMap)
|
||||
if (resultYT) {
|
||||
return resultYT
|
||||
}
|
||||
|
||||
const resultVimeo = getVimeoReplacement(node, vimeoIdCounterMap)
|
||||
if (resultVimeo) {
|
||||
return resultVimeo
|
||||
}
|
||||
|
||||
const resultGist = getGistReplacement(node, gistIdCounterMap)
|
||||
if (resultGist) {
|
||||
return resultGist
|
||||
}
|
||||
|
||||
return convertNodeToElement(node, index, transform)
|
||||
}
|
||||
const ret: ReactElement[] = ReactHtmlParser(html, { transform: transform })
|
||||
return ret
|
||||
}, [content, markdownIt])
|
||||
|
||||
const MarkdownPreview: React.FC = () => {
|
||||
return (
|
||||
<div className='h-100 px-2 py-1 bg-white'>
|
||||
Hello, MarkdownPreview!
|
||||
<div className={'bg-light container-fluid flex-fill h-100 overflow-y-scroll pb-5'}>
|
||||
<div className={'markdown-body container-fluid'}>{result}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,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,5 @@
|
|||
.gist-frame {
|
||||
.one-click-embedding-preview {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
import { getIdFromCodiMdTag } from '../video-util'
|
||||
import './gist-frame.scss'
|
||||
import preview from './gist-preview.png'
|
||||
|
||||
export interface GistFrameProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface resizeEvent {
|
||||
size: number
|
||||
id: string
|
||||
}
|
||||
|
||||
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
|
||||
const gistId = getIdFromCodiMdTag(node, 'gist')
|
||||
if (gistId) {
|
||||
const count = (counterMap.get(gistId) || 0) + 1
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)}`}/>
|
||||
)
|
||||
}
|
||||
|
||||
export { getElementReplacement as getGistReplacement }
|
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
|
@ -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 > i {
|
||||
opacity: 0.8;
|
||||
text-shadow: #000000 0 0 10px;
|
||||
}
|
||||
|
||||
.one-click-embedding-preview {
|
||||
background: none;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
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
|
||||
tooltip?: string
|
||||
containerClassName?: string
|
||||
previewContainerClassName?: string
|
||||
}
|
||||
|
||||
export const OneClickEmbedding: React.FC<OneClickFrameProps> = ({ previewContainerClassName, containerClassName, onImageFetch, loadingImageUrl, children, tooltip, hoverIcon }) => {
|
||||
const [showFrame, setShowFrame] = useState(false)
|
||||
const [previewImageLink, setPreviewImageLink] = useState<string>(loadingImageUrl)
|
||||
|
||||
const showChildren = () => {
|
||||
setShowFrame(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!onImageFetch) {
|
||||
return
|
||||
}
|
||||
onImageFetch().then((imageLink) => {
|
||||
setPreviewImageLink(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}>
|
||||
<img className={'one-click-embedding-preview'} src={previewImageLink} alt={tooltip || ''} title={tooltip || ''}/>
|
||||
<ShowIf condition={!!hoverIcon}>
|
||||
<i className={`one-click-embedding-icon fa fa-${hoverIcon as string} fa-5x`}/>
|
||||
</ShowIf>
|
||||
</span>
|
||||
</ShowIf>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
|
||||
export const getIdFromCodiMdTag = (node: DomElement, tagName: string): (string | undefined) => {
|
||||
if (node.name !== `codimd-${tagName}` || !node.attribs || !node.attribs.id) {
|
||||
return
|
||||
}
|
||||
return node.attribs.id
|
||||
}
|
||||
|
||||
export interface VideoFrameProps {
|
||||
id: string
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React, { ReactElement, useCallback } from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
import { getIdFromCodiMdTag, VideoFrameProps } from '../video-util'
|
||||
|
||||
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
|
||||
const videoId = getIdFromCodiMdTag(node, 'vimeo')
|
||||
if (videoId) {
|
||||
const count = (counterMap.get(videoId) || 0) + 1
|
||||
counterMap.set(videoId, count)
|
||||
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
|
||||
}
|
||||
}
|
||||
|
||||
interface VimeoApiResponse {
|
||||
// Vimeo uses strange names for their fields. ESLint doesn't like that.
|
||||
// eslint-disable-next-line camelcase
|
||||
thumbnail_large?: string
|
||||
}
|
||||
|
||||
export const VimeoFrame: React.FC<VideoFrameProps> = ({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
export { getElementReplacement as getVimeoReplacement }
|
|
@ -0,0 +1,25 @@
|
|||
import { DomElement } from 'domhandler'
|
||||
import React, { ReactElement } from 'react'
|
||||
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
|
||||
import { getIdFromCodiMdTag, VideoFrameProps } from '../video-util'
|
||||
|
||||
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
|
||||
const videoId = getIdFromCodiMdTag(node, 'youtube')
|
||||
if (videoId) {
|
||||
const count = (counterMap.get(videoId) || 0) + 1
|
||||
counterMap.set(videoId, count)
|
||||
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeFrame: React.FC<VideoFrameProps> = ({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
export { getElementReplacement as getYouTubeReplacement }
|
6
src/external-types/markdown-it-emoji/index.d.ts
vendored
Normal file
6
src/external-types/markdown-it-emoji/index.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
declare module 'markdown-it-emoji' {
|
||||
import MarkdownIt from 'markdown-it/lib'
|
||||
const markdownItEmoji: MarkdownIt.PluginSimple
|
||||
export = markdownItEmoji
|
||||
}
|
6
src/external-types/markdown-it-regex/index.d.ts
vendored
Normal file
6
src/external-types/markdown-it-regex/index.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
declare module 'markdown-it-regex' {
|
||||
import MarkdownIt from 'markdown-it/lib'
|
||||
import { RegexOptions } from './interface'
|
||||
const markdownItRegex: MarkdownIt.PluginWithOptions<RegexOptions>
|
||||
export = markdownItRegex
|
||||
}
|
5
src/external-types/markdown-it-regex/interface.ts
Normal file
5
src/external-types/markdown-it-regex/interface.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface RegexOptions {
|
||||
name: string,
|
||||
regex: RegExp,
|
||||
replace: (match: string) => string
|
||||
}
|
6
src/external-types/markdown-it-task-lists/index.d.ts
vendored
Normal file
6
src/external-types/markdown-it-task-lists/index.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
declare module 'markdown-it-task-lists' {
|
||||
import MarkdownIt from 'markdown-it/lib'
|
||||
const markdownItTaskLists: MarkdownIt.PluginSimple
|
||||
export = markdownItTaskLists
|
||||
}
|
BIN
src/global-style/TwemojiMozilla.ttf
Normal file
BIN
src/global-style/TwemojiMozilla.ttf
Normal file
Binary file not shown.
|
@ -31,3 +31,12 @@ body {
|
|||
.mvh-100 {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.overflow-y-scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "twemoji";
|
||||
src: url("TwemojiMozilla.ttf") format("truetype");
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { EditorMode } from '../../components/editor/task-bar/editor-view-mode'
|
|||
import { EditorConfig, EditorConfigActions, EditorConfigActionType, SetEditorConfigAction } from './types'
|
||||
|
||||
export const initialState: EditorConfig = {
|
||||
editorMode: EditorMode.EDITOR
|
||||
editorMode: EditorMode.BOTH
|
||||
}
|
||||
|
||||
export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (state: EditorConfig = initialState, action: EditorConfigActions) => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue