Wrap markdown rendering in iframe (#837)

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
Tilman Vatteroth 2021-01-24 20:50:51 +01:00 committed by GitHub
parent bd31076928
commit 586969f368
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1014 additions and 287 deletions

View file

@ -5,7 +5,7 @@
*/
import { TocAst } from 'markdown-it-toc-done-right'
import React, { RefObject, useCallback, useMemo, useRef, useState } from 'react'
import React, { Ref, useCallback, useMemo, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { InternalLink } from '../common/links/internal-link'
@ -17,6 +17,7 @@ import { usePostMetaDataOnChange } from './hooks/use-post-meta-data-on-change'
import { usePostTocAstOnChange } from './hooks/use-post-toc-ast-on-change'
import { useReplacerInstanceListCreator } from './hooks/use-replacer-instance-list-creator'
import { FullMarkdownItConfigurator } from './markdown-it-configurator/FullMarkdownItConfigurator'
import { ImageClickHandler } from './replace-components/image/image-replacer'
import { LineMarkers } from './replace-components/linemarker/line-number-marker'
import { AdditionalMarkdownRendererProps, LineMarkerPosition } from './types'
import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions'
@ -27,21 +28,26 @@ export interface FullMarkdownRendererProps {
onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
onTocChange?: (ast: TocAst) => void
rendererRef?: RefObject<HTMLDivElement>
rendererRef?: Ref<HTMLDivElement>
baseUrl?: string
onImageClick?: ImageClickHandler
}
export const FullMarkdownRenderer: React.FC<FullMarkdownRendererProps & AdditionalMarkdownRendererProps> = ({
onFirstHeadingChange,
onLineMarkerPositionChanged,
onMetaDataChange,
onTaskCheckedChange,
onTocChange,
content,
className,
wide,
rendererRef
}) => {
const allReplacers = useReplacerInstanceListCreator(onTaskCheckedChange)
export const FullMarkdownRenderer: React.FC<FullMarkdownRendererProps & AdditionalMarkdownRendererProps> = (
{
onFirstHeadingChange,
onLineMarkerPositionChanged,
onMetaDataChange,
onTaskCheckedChange,
onTocChange,
content,
className,
wide,
rendererRef,
baseUrl,
onImageClick
}) => {
const allReplacers = useReplacerInstanceListCreator(onTaskCheckedChange, onImageClick, baseUrl)
useTranslation()
const [showYamlError, setShowYamlError] = useState(false)

View file

@ -16,14 +16,14 @@ export const useConvertMarkdownToReactDom = (
markdownCode: string,
markdownIt: MarkdownIt,
componentReplacers?: () => ComponentReplacer[],
onPreRendering?: () => void,
onPostRendering?: () => void): ReactElement[] => {
onBeforeRendering?: () => void,
onAfterRendering?: () => void): ReactElement[] => {
const oldMarkdownLineKeys = useRef<LineKeys[]>()
const lastUsedLineId = useRef<number>(0)
return useMemo(() => {
if (onPreRendering) {
onPreRendering()
if (onBeforeRendering) {
onBeforeRendering()
}
const html = markdownIt.render(markdownCode)
const contentLines = markdownCode.split('\n')
@ -35,9 +35,9 @@ export const useConvertMarkdownToReactDom = (
lastUsedLineId.current = newLastUsedLineId
const transformer = componentReplacers ? buildTransformer(newLines, componentReplacers()) : undefined
const rendering = ReactHtmlParser(html, { transform: transformer })
if (onPostRendering) {
onPostRendering()
if (onAfterRendering) {
onAfterRendering()
}
return rendering
}, [onPreRendering, onPostRendering, markdownCode, markdownIt, componentReplacers])
}, [onBeforeRendering, onAfterRendering, markdownCode, markdownIt, componentReplacers])
}

View file

@ -9,14 +9,14 @@ import { AbcReplacer } from '../replace-components/abc/abc-replacer'
import { AsciinemaReplacer } from '../replace-components/asciinema/asciinema-replacer'
import { ComponentReplacer } from '../replace-components/ComponentReplacer'
import { CsvReplacer } from '../replace-components/csv/csv-replacer'
import { LinkInNewTabReplacer } from '../replace-components/external-links-in-new-tabs/external-links-in-new-tabs'
import { FlowchartReplacer } from '../replace-components/flow/flowchart-replacer'
import { GistReplacer } from '../replace-components/gist/gist-replacer'
import { GraphvizReplacer } from '../replace-components/graphviz/graphviz-replacer'
import { HighlightedCodeReplacer } from '../replace-components/highlighted-fence/highlighted-fence-replacer'
import { ImageReplacer } from '../replace-components/image/image-replacer'
import { ImageClickHandler, ImageReplacer } from '../replace-components/image/image-replacer'
import { KatexReplacer } from '../replace-components/katex/katex-replacer'
import { LinemarkerReplacer } from '../replace-components/linemarker/linemarker-replacer'
import { LinkReplacer } from '../replace-components/link-replacer/link-replacer'
import { MarkmapReplacer } from '../replace-components/markmap/markmap-replacer'
import { MermaidReplacer } from '../replace-components/mermaid/mermaid-replacer'
import { PdfReplacer } from '../replace-components/pdf/pdf-replacer'
@ -28,9 +28,10 @@ import { VegaReplacer } from '../replace-components/vega-lite/vega-replacer'
import { VimeoReplacer } from '../replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from '../replace-components/youtube/youtube-replacer'
export const useReplacerInstanceListCreator = (onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void): () => ComponentReplacer[] => {
return useMemo(() => () => [
new LinkInNewTabReplacer(),
export const useReplacerInstanceListCreator = (onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void,
onImageClick?: ImageClickHandler, baseUrl?: string): () => ComponentReplacer[] => useMemo(() =>
() => [
new LinkReplacer(baseUrl),
new LinemarkerReplacer(),
new PossibleWiderReplacer(),
new GistReplacer(),
@ -39,7 +40,7 @@ export const useReplacerInstanceListCreator = (onTaskCheckedChange?: (lineInMark
new AsciinemaReplacer(),
new AbcReplacer(),
new PdfReplacer(),
new ImageReplacer(),
new ImageReplacer(onImageClick),
new SequenceDiagramReplacer(),
new CsvReplacer(),
new FlowchartReplacer(),
@ -51,5 +52,4 @@ export const useReplacerInstanceListCreator = (onTaskCheckedChange?: (lineInMark
new QuoteOptionsReplacer(),
new KatexReplacer(),
new TaskListReplacer(onTaskCheckedChange)
], [onTaskCheckedChange])
}
], [onImageClick, onTaskCheckedChange, baseUrl])

View file

@ -1,8 +1,8 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MarkdownIt from 'markdown-it'
import { TocAst } from 'markdown-it-toc-done-right'
@ -45,9 +45,9 @@ export class FullMarkdownItConfigurator extends BasicMarkdownItConfigurator {
!this.useFrontmatter
? undefined
: {
onYamlError: (hasError: boolean) => this.passYamlErrorState(hasError),
onRawMeta: (rawMeta: RawYAMLMetadata) => this.onRawMeta(rawMeta)
})
onYamlError: (hasError: boolean) => this.passYamlErrorState(hasError),
onRawMeta: (rawMeta: RawYAMLMetadata) => this.onRawMeta(rawMeta)
})
},
headlineAnchors,
KatexReplacer.markdownItPlugin,

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
import { ComponentReplacer, SubNodeTransform } from '../ComponentReplacer'
export class LinkInNewTabReplacer extends ComponentReplacer {
public getReplacement (node: DomElement, subNodeTransform: SubNodeTransform): (ReactElement | null | undefined) {
const isJumpMark = node.attribs?.href?.substr(0, 1) === '#'
if (node.name !== 'a' || isJumpMark) {
return undefined
}
return <a className={node.attribs?.class} title={node.attribs?.title} href={node.attribs?.href} rel='noopener noreferrer' target='_blank'>
{
node.children?.map((child, index) => subNodeTransform(child, index))
}
</a>
}
}

View file

@ -1,8 +1,8 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, ReactElement, useEffect, useState } from 'react'
import ReactHtmlParser from 'react-html-parser'
@ -59,7 +59,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
{ dom }
</code>
<div className={'text-right button-inside'}>
<CopyToClipboardButton content={code}/>
<CopyToClipboardButton content={code} data-cy="copy-code-button"/>
</div>
</Fragment>)
}

View file

@ -8,6 +8,7 @@ import React from 'react'
import { Modal } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import "./lightbox.scss"
import { ProxyImageFrame } from './proxy-image-frame'
export interface ImageLightboxModalProps {
show: boolean
@ -33,7 +34,7 @@ export const ImageLightboxModal: React.FC<ImageLightboxModalProps> = ({ show, on
<span>{alt ?? title ?? ''}</span>
</Modal.Title>
</Modal.Header>
<img alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide}/>
<ProxyImageFrame alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide}/>
</Modal>
)
}

View file

@ -9,17 +9,27 @@ import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ProxyImageFrame } from './proxy-image-frame'
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void;
export class ImageReplacer extends ComponentReplacer {
private readonly clickHandler?: ImageClickHandler
constructor (clickHandler?: ImageClickHandler) {
super()
this.clickHandler = clickHandler
}
public getReplacement (node: DomElement): React.ReactElement | undefined {
if (node.name === 'img' && node.attribs) {
return <ProxyImageFrame
id={node.attribs.id}
className={node.attribs.class}
className={`${node.attribs.class} cursor-zoom-in`}
src={node.attribs.src}
alt={node.attribs.alt}
title={node.attribs.title}
width={node.attribs.width}
height={node.attribs.height}
onClick={this.clickHandler}
/>
}
}

View file

@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useState } from 'react'
import { ImageLightboxModal } from './image-lightbox-modal'
import "./lightbox.scss"
export const LightboxImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = (
{
alt,
title,
src,
...props
}) => {
const [showFullscreenImage, setShowFullscreenImage] = useState(false)
return (
<Fragment>
<img alt={alt} src={src} title={title} {...props} className={'cursor-zoom-in'}
onClick={() => setShowFullscreenImage(true)}/>
<ImageLightboxModal
show={showFullscreenImage}
onHide={() => setShowFullscreenImage(false)} title={title} src={src} alt={alt}/>
</Fragment>
)
}

View file

@ -8,7 +8,6 @@ import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { getProxiedUrl } from '../../../../api/media'
import { ApplicationState } from '../../../../redux'
import { LightboxImageFrame } from './lightbox-image-frame'
export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = (
{
@ -29,13 +28,6 @@ export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>
.catch(err => console.error(err))
}, [imageProxyEnabled, src])
if (imageProxyEnabled) {
return (
<LightboxImageFrame src={imageUrl} title={title ?? alt ?? ''} alt={alt} {...props}/>
)
}
return (
<LightboxImageFrame src={src ?? ''} title={title ?? alt ?? ''} alt={alt} {...props}/>
)
return <img src={imageProxyEnabled ? imageUrl : (src ?? '')} title={title ?? alt ?? ''} alt={alt} {...props}/>
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DomElement } from 'domhandler'
import React, { ReactElement } from 'react'
import { ComponentReplacer, NativeRenderer, SubNodeTransform } from '../ComponentReplacer'
export const createJumpToMarkClickEventHandler = (id: string) => {
return (event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
document.getElementById(id)?.scrollIntoView()
event.preventDefault()
}
}
export class LinkReplacer extends ComponentReplacer {
constructor (private baseUrl?: string) {
super()
}
public getReplacement (node: DomElement, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): (ReactElement | null | undefined) {
if (node.name !== 'a' || !node.attribs || !node.attribs.href) {
return undefined
}
const url = node.attribs.href
const isJumpMark = url.substr(0, 1) === '#'
const id = url.substr(1)
try {
node.attribs.href = new URL(url, this.baseUrl).toString()
} catch (e) {
node.attribs.href = url
}
if (isJumpMark) {
return <span onClick={createJumpToMarkClickEventHandler(id)}>
{nativeRenderer()}
</span>
} else {
node.attribs.rel = "noreferer noopener"
node.attribs.target = "_blank"
return nativeRenderer()
}
}
}

View file

@ -15,7 +15,7 @@ export interface TextDifferenceResult {
lastUsedLineId: number
}
export const calculateKeyFromLineMarker = (node: DomElement, lineKeys?: LineKeys[]): string|undefined => {
export const calculateKeyFromLineMarker = (node: DomElement, lineKeys?: LineKeys[]): string | undefined => {
if (!node.attribs || lineKeys === undefined) {
return
}
@ -60,7 +60,7 @@ export const renderNativeNode = (node: DomElement, key: string, transform: Trans
return convertNodeToElement(node, key as unknown as number, transform)
}
export const buildTransformer = (lineKeys: (LineKeys[] | undefined), allReplacers: ComponentReplacer[]):Transform => {
export const buildTransformer = (lineKeys: (LineKeys[] | undefined), allReplacers: ComponentReplacer[]): Transform => {
const transform: Transform = (node, index) => {
const nativeRenderer: NativeRenderer = () => renderNativeNode(node, key, transform)
const subNodeTransform: SubNodeTransform = (subNode, subIndex) => transform(subNode, subIndex, transform)