diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fae058e1..2aa7fa061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +### Deprecations +- This version of CodiMD is the last version that supports the following short-code syntaxes for embedding content. Embedding works now instead by putting the plain webpage link to the content into a single line. + - `{%youtube someid %}` -> https://youtube.com/watch?v=someid + - `{%vimeo 123456789 %}` -> https://vimeo.com/123456789 + - `{%gist user/12345 %}` -> https://gist.github.com/user/12345 + - `{%slideshare user/my-awesome-presentation %}` -> Embedding removed + - `{%speakerdeck foobar %}` -> Embedding removed + +### Removed + +- SlideShare embedding + - If a legacy embedding code is detected it will show the link to the presentation instead of the embedded presentation +- Speakerdeck embedding + - If a legacy embedding code is detected it will show the link to the presentation instead of the embedded presentation + ### Added - A new table view for the history (besides the card view) @@ -12,6 +27,7 @@ - The sign-in/sign-up functions are now on a separate page - The history shows both the entries saved in LocalStorage and the entries saved on the server together +- The gist and pdf embeddings now use a one-click aproach similar to vimeo and youtube --- diff --git a/package.json b/package.json index 09c288b96..568a21c94 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ "@testing-library/user-event": "11.4.2", "@types/codemirror": "0.0.96", "@types/jest": "26.0.0", + "@types/markdown-it": "^10.0.1", "@types/node": "12.12.47", "@types/node-sass": "4.11.1", "@types/react": "16.9.36", "@types/react-bootstrap": "1.0.1", "@types/react-bootstrap-typeahead": "3.4.6", "@types/react-dom": "16.9.8", + "@types/react-html-parser": "^2.0.1", "@types/react-redux": "7.1.9", "@types/react-router": "5.1.7", "@types/react-router-bootstrap": "0.24.5", @@ -31,9 +33,14 @@ "eslint-plugin-promise": "4.2.1", "eslint-plugin-standard": "4.0.1", "fork-awesome": "1.1.7", + "github-markdown-css": "^4.0.0", "i18next": "19.4.5", "i18next-browser-languagedetector": "4.2.0", "i18next-http-backend": "1.0.15", + "markdown-it": "^11.0.0", + "markdown-it-emoji": "^1.4.0", + "markdown-it-regex": "^0.2.0", + "markdown-it-task-lists": "^2.1.1", "moment": "2.26.0", "node-sass": "4.14.1", "react": "16.13.1", @@ -41,6 +48,7 @@ "react-bootstrap-typeahead": "5.0.0-rc.3", "react-codemirror2": "7.2.1", "react-dom": "16.13.1", + "react-html-parser": "^2.0.2", "react-i18next": "11.5.1", "react-redux": "7.2.0", "react-router": "5.2.0", diff --git a/src/components/editor/editor-window/editor-window.tsx b/src/components/editor/editor-window/editor-window.tsx index afa71ee85..04eaad732 100644 --- a/src/components/editor/editor-window/editor-window.tsx +++ b/src/components/editor/editor-window/editor-window.tsx @@ -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 = ({ onContentChange, content }) => { const { t } = useTranslation() - const [content, setContent] = useState('') return ( { } } onBeforeChange={(editor, data, value) => { - setContent(value) - }} - onChange={(editor, data, value) => { - console.log('change!') + onContentChange(value) }} /> ) diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index 4e3fa0fcc..10c5f7829 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -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 = () => { } + left={ setMarkdownContent(content)} content={markdownContent}/>} showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)} - right={} + right={} containerClassName={'overflow-hidden'}/> diff --git a/src/components/editor/markdown-preview/markdown-it-plugins/parser-debugger.ts b/src/components/editor/markdown-preview/markdown-it-plugins/parser-debugger.ts new file mode 100644 index 000000000..5a6ba13dc --- /dev/null +++ b/src/components/editor/markdown-preview/markdown-it-plugins/parser-debugger.ts @@ -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 + }) +} diff --git a/src/components/editor/markdown-preview/markdown-preview.scss b/src/components/editor/markdown-preview/markdown-preview.scss new file mode 100644 index 000000000..e5a7d5ece --- /dev/null +++ b/src/components/editor/markdown-preview/markdown-preview.scss @@ -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; +} diff --git a/src/components/editor/markdown-preview/markdown-preview.tsx b/src/components/editor/markdown-preview/markdown-preview.tsx index be7a5128b..a60349fcd 100644 --- a/src/components/editor/markdown-preview/markdown-preview.tsx +++ b/src/components/editor/markdown-preview/markdown-preview.tsx @@ -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 = ({ 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() + const vimeoIdCounterMap = new Map() + const gistIdCounterMap = new Map() + + 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 ( -
- Hello, MarkdownPreview! +
+
{result}
) } diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-gist-link.ts b/src/components/editor/markdown-preview/regex-plugins/replace-gist-link.ts new file mode 100644 index 000000000..5c49f51f7 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-gist-link.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-legacy-gist-short-code.ts b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-gist-short-code.ts new file mode 100644 index 000000000..f908dbfbc --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-gist-short-code.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-legacy-slideshare-short-code.ts b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-slideshare-short-code.ts new file mode 100644 index 000000000..f3574b5fd --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-slideshare-short-code.ts @@ -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 `https://www.slideshare.net/${match}` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-legacy-speakerdeck-short-code.ts b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-speakerdeck-short-code.ts new file mode 100644 index 000000000..badaf9eb4 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-speakerdeck-short-code.ts @@ -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 `https://speakerdeck.com/${match}` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-legacy-vimeo-short-code.ts b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-vimeo-short-code.ts new file mode 100644 index 000000000..4a6a076d1 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-vimeo-short-code.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-legacy-youtube-short-code.ts b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-youtube-short-code.ts new file mode 100644 index 000000000..fc0d34809 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-legacy-youtube-short-code.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-vimeo-link.ts b/src/components/editor/markdown-preview/regex-plugins/replace-vimeo-link.ts new file mode 100644 index 000000000..032efc494 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-vimeo-link.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/regex-plugins/replace-youtube-link.ts b/src/components/editor/markdown-preview/regex-plugins/replace-youtube-link.ts new file mode 100644 index 000000000..0fef18405 --- /dev/null +++ b/src/components/editor/markdown-preview/regex-plugins/replace-youtube-link.ts @@ -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 `` + } +} diff --git a/src/components/editor/markdown-preview/replace-components/gist/gist-frame.scss b/src/components/editor/markdown-preview/replace-components/gist/gist-frame.scss new file mode 100644 index 000000000..e53f508f9 --- /dev/null +++ b/src/components/editor/markdown-preview/replace-components/gist/gist-frame.scss @@ -0,0 +1,5 @@ +.gist-frame { + .one-click-embedding-preview { + filter: blur(3px); + } +} diff --git a/src/components/editor/markdown-preview/replace-components/gist/gist-frame.tsx b/src/components/editor/markdown-preview/replace-components/gist/gist-frame.tsx new file mode 100644 index 000000000..e6d7a6897 --- /dev/null +++ b/src/components/editor/markdown-preview/replace-components/gist/gist-frame.tsx @@ -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): (ReactElement | undefined) => { + const gistId = getIdFromCodiMdTag(node, 'gist') + if (gistId) { + const count = (counterMap.get(gistId) || 0) + 1 + counterMap.set(gistId, count) + return ( + + + + ) + } +} + +export const GistFrame: React.FC = ({ id }) => { + const iframeHtml = useMemo(() => { + return (` + + + + gist + + + + + + + `) + }, [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 ( +