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

@ -61,6 +61,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0
- A toggle in the editor preferences for turning ligatures on and off.
- Easier possibility to share notes via native share-buttons on supported devices.
- Surround selected text with a link via shortcut (ctrl+k or cmd+k).
- Improved security by wrapping the markdown rendering into an iframe
### Changed

View file

@ -26,7 +26,8 @@ describe('Autocompletion', () => {
.should('have.text', '```abnf')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '```')
cy.get('.markdown-body > pre > code')
cy.getMarkdownBody()
.find('pre > code')
.should('exist')
})
it('via doubleclick', () => {
@ -40,7 +41,8 @@ describe('Autocompletion', () => {
.should('have.text', '```abnf')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '```')
cy.get('.markdown-body > pre > code')
cy.getMarkdownBody()
.find('pre > code')
.should('exist')
})
})
@ -58,7 +60,8 @@ describe('Autocompletion', () => {
.should('have.text', ':::success')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '::: ')
cy.get('.markdown-body > div.alert')
cy.getMarkdownBody()
.find('div.alert')
.should('exist')
})
it('via doubleclick', () => {
@ -72,7 +75,8 @@ describe('Autocompletion', () => {
.should('have.text', ':::success')
cy.get('.CodeMirror-code > div:nth-of-type(3) > .CodeMirror-line > span span')
.should('have.text', '::: ')
cy.get('.markdown-body > div.alert')
cy.getMarkdownBody()
.find('div.alert')
.should('exist')
})
})
@ -80,8 +84,7 @@ describe('Autocompletion', () => {
describe('emoji', () => {
describe('normal emoji', () => {
it('via Enter', () => {
cy.get('@codeinput')
.fill(':hedg')
cy.codemirrorFill(':hedg')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('@codeinput')
@ -90,12 +93,11 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':hedgehog:')
cy.get('.markdown-body')
cy.getMarkdownBody()
.should('have.text', '🦔')
})
it('via doubleclick', () => {
cy.get('@codeinput')
.fill(':hedg')
cy.codemirrorFill(':hedg')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
@ -103,15 +105,14 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':hedgehog:')
cy.get('.markdown-body')
cy.getMarkdownBody()
.should('have.text', '🦔')
})
})
describe('fork-awesome-icon', () => {
it('via Enter', () => {
cy.get('@codeinput')
.fill(':fa-face')
cy.codemirrorFill(':fa-face')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('@codeinput')
@ -120,12 +121,12 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
cy.getMarkdownBody()
.find('p > i.fa.fa-facebook')
.should('exist')
})
it('via doubleclick', () => {
cy.get('@codeinput')
.fill(':fa-face')
cy.codemirrorFill(':fa-face')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
@ -133,7 +134,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', ':fa-facebook:')
cy.get('.markdown-body > p > i.fa.fa-facebook')
cy.getMarkdownBody()
.find('p > i.fa.fa-facebook')
.should('exist')
})
})
@ -150,7 +152,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '# ')
cy.get('.markdown-body > h1 ')
cy.getMarkdownBody()
.find('h1 ')
.should('have.text', ' ')
})
it('via doubleclick', () => {
@ -162,7 +165,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '# ')
cy.get('.markdown-body > h1')
cy.getMarkdownBody()
.find('h1')
.should('have.text', ' ')
})
})
@ -178,7 +182,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '![image alt](https:// "title")')
cy.get('.markdown-body > p > img')
cy.getMarkdownBody()
.find('p > img')
.should('have.attr', 'alt', 'image alt')
.should('have.attr', 'src', 'https://')
.should('have.attr', 'title', 'title')
@ -192,7 +197,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '![image alt](https:// "title")')
cy.get('.markdown-body > p > img')
cy.getMarkdownBody()
.find('p > img')
.should('have.attr', 'alt', 'image alt')
.should('have.attr', 'src', 'https://')
.should('have.attr', 'title', 'title')
@ -210,7 +216,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '[link text](https:// "title") ')
cy.get('.markdown-body > p > a')
cy.getMarkdownBody()
.find('p > a')
.should('have.text', 'link text')
.should('have.attr', 'href', 'https://')
.should('have.attr', 'title', 'title')
@ -224,7 +231,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '[link text](https:// "title") ')
cy.get('.markdown-body > p > a')
cy.getMarkdownBody()
.find('p > a')
.should('have.text', 'link text')
.should('have.attr', 'href', 'https://')
.should('have.attr', 'title', 'title')
@ -242,7 +250,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '{%pdf https:// %}')
cy.get('.markdown-body > p')
cy.getMarkdownBody()
.find('p')
.should('exist')
})
it('via doubleclick', () => {
@ -254,7 +263,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '{%pdf https:// %}')
cy.get('.markdown-body > p')
cy.getMarkdownBody()
.find('p')
.should('exist')
})
})
@ -270,7 +280,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '</details>') // after selecting the hint, the last line of the inserted suggestion is active
cy.get('.markdown-body > details')
cy.getMarkdownBody()
.find('details')
.should('exist')
})
it('via doubleclick', () => {
@ -282,7 +293,8 @@ describe('Autocompletion', () => {
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '</details>')
cy.get('.markdown-body > details')
cy.getMarkdownBody()
.find('details')
.should('exist')
})
})

View file

@ -87,7 +87,8 @@ describe('Document Title', () => {
it('katex code looks right', () => {
cy.codemirrorFill(`# $\\alpha$-foo`)
cy.get('.markdown-body > h1')
cy.getMarkdownRenderer()
.find('h1')
.should('contain', 'α')
cy.get('.CodeMirror textarea')
.type('{Enter}{Enter}{Enter}{Enter}{Enter}') //This is a workaround because I don't know how to make sure, that the title gets updated in time.

View file

@ -4,33 +4,37 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
const findHljsCodeBlock = () => {
return cy.getMarkdownBody()
.find('pre > code.hljs')
.should('be.visible')
}
describe('Code', () => {
beforeEach(() => {
cy.visit('/n/test', {
onBeforeLoad (win: Window): void {
cy.spy(win.navigator.clipboard, 'writeText').as('copy')
}
})
cy.visit('/n/test')
})
describe('with just the language', () => {
it('doesn\'t show a gutter', () => {
cy.codemirrorFill('```javascript \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('not.have.class', 'showGutter')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('not.be.visible')
})
describe('and line wrapping', () => {
it('doesn\'t show a gutter', () => {
cy.codemirrorFill('```javascript! \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('not.have.class', 'showGutter')
.should('have.class', 'wrapLines')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('not.be.visible')
})
})
@ -39,10 +43,11 @@ describe('Code', () => {
describe('with the language and show gutter', () => {
it('shows the correct line number', () => {
cy.codemirrorFill('```javascript= \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('be.visible')
.text()
.should('eq', '1')
@ -51,11 +56,12 @@ describe('Code', () => {
describe('and line wrapping', () => {
it('shows the correct line number', () => {
cy.codemirrorFill('```javascript=! \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
.should('have.class', 'wrapLines')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('be.visible')
.text()
.should('eq', '1')
@ -66,10 +72,11 @@ describe('Code', () => {
describe('with the language, show gutter with a start number', () => {
it('shows the correct line number', () => {
cy.codemirrorFill('```javascript=100 \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('be.visible')
.text()
.should('eq', '100')
@ -77,8 +84,7 @@ describe('Code', () => {
it('shows the correct line number and continues in another codeblock', () => {
cy.codemirrorFill('```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
.first()
.find('.linenumber')
@ -86,14 +92,14 @@ describe('Code', () => {
.should('be.visible')
.text()
.should('eq', '100')
cy.get('.markdown-body > pre > code.hljs')
findHljsCodeBlock()
.first()
.find('.linenumber')
.last()
.should('be.visible')
.text()
.should('eq', '101')
cy.get('.markdown-body > pre > code.hljs')
findHljsCodeBlock()
.last()
.find('.linenumber')
.first()
@ -105,11 +111,11 @@ describe('Code', () => {
describe('and line wrapping', () => {
it('shows the correct line number', () => {
cy.codemirrorFill('```javascript=100! \nlet x = 0\n```')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
.should('have.class', 'wrapLines')
cy.get('.markdown-body > pre > code.hljs > .linenumber')
findHljsCodeBlock()
.find('.linenumber')
.should('be.visible')
.text()
.should('eq', '100')
@ -117,8 +123,7 @@ describe('Code', () => {
it('shows the correct line number and continues in another codeblock', () => {
cy.codemirrorFill('```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n')
cy.get('.markdown-body > pre > code.hljs')
.should('be.visible')
findHljsCodeBlock()
.should('have.class', 'showGutter')
.should('have.class', 'wrapLines')
.first()
@ -127,14 +132,14 @@ describe('Code', () => {
.should('be.visible')
.text()
.should('eq', '100')
cy.get('.markdown-body > pre > code.hljs')
findHljsCodeBlock()
.first()
.find('.linenumber')
.last()
.should('be.visible')
.text()
.should('eq', '101')
cy.get('.markdown-body > pre > code.hljs')
findHljsCodeBlock()
.last()
.find('.linenumber')
.first()
@ -147,9 +152,22 @@ describe('Code', () => {
it('has a working copy button', () => {
cy.codemirrorFill('```javascript \nlet x = 0\n```')
cy.get('.markdown-body > pre > div > button > i')
.should('have.class', 'fa-files-o')
cy.get(`iframe[data-cy="documentIframe"]`)
.then(($element: JQuery) => {
const frame = $element[0] as HTMLIFrameElement
if (frame === null || frame.contentWindow === null) {
return cy.wrap(null)
}
cy.spy(frame.contentWindow.navigator.clipboard, 'writeText').as("copy")
})
cy.getMarkdownRenderer()
.find('[data-cy="copy-code-button"]')
.click()
cy.get('@copy').should('be.calledWithExactly', 'let x = 0\n')
cy.get("@copy")
.should('be.calledWithExactly', 'let x = 0\n')
})
})

View file

@ -35,7 +35,8 @@ describe('The status bar text length info', () => {
cy.codemirrorFill(tooMuchTestContent)
cy.get('[data-cy="limitReachedModal"]')
.should('be.visible')
cy.get('[data-cy="limitReachedMessage"]')
cy.getMarkdownRenderer()
.find('[data-cy="limitReachedMessage"]')
.should('be.visible')
})

View file

@ -11,19 +11,22 @@ describe('YAML Array for deprecated syntax of document tags in frontmatter', ()
it('is shown when using old syntax', () => {
cy.codemirrorFill('---\ntags: a, b, c\n---')
cy.get('[data-cy="yamlArrayDeprecationAlert"]')
cy.getMarkdownRenderer()
.find('[data-cy="yamlArrayDeprecationAlert"]')
.should('be.visible')
})
it('isn\'t shown when using inline yaml-array', () => {
cy.codemirrorFill('---\ntags: [\'a\', \'b\', \'c\']\n---')
cy.get('[data-cy="yamlArrayDeprecationAlert"]')
cy.getMarkdownRenderer()
.find('[data-cy="yamlArrayDeprecationAlert"]')
.should('not.exist')
})
it('isn\'t shown when using multi line yaml-array', () => {
cy.codemirrorFill('---\ntags:\n - a\n - b\n - c\n---')
cy.get('[data-cy="yamlArrayDeprecationAlert"]')
cy.getMarkdownRenderer()
.find('[data-cy="yamlArrayDeprecationAlert"]')
.should('not.exist')
})
})

View file

@ -49,6 +49,10 @@ beforeEach(() => {
version: 'mock',
sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
},
"iframeCommunication": {
"editorOrigin": "http://127.0.0.1:3001",
"rendererOrigin": "http://127.0.0.1:3001"
}
}
})

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare namespace Cypress {
interface Chainable {
getMarkdownRenderer (): Chainable<Element>
getMarkdownBody (): Chainable<Element>
}
}
Cypress.Commands.add('getMarkdownRenderer', () => {
return cy.get(`iframe[data-cy="documentIframe"]`)
.its('0.contentDocument')
.should('exist')
.its('body')
.should('not.be.undefined')
.then(cy.wrap.bind(cy))
})
Cypress.Commands.add('getMarkdownBody', () => {
return cy.getMarkdownRenderer()
.find('.markdown-body')
})

View file

@ -24,4 +24,5 @@ import 'cypress-file-upload'
import './checkLinks'
import './config'
import './fill'
import './getMarkdownRenderer'
import './login'

View file

@ -39,5 +39,9 @@
"version": "mock",
"sourceCodeUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"issueTrackerUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
},
"iframeCommunication": {
"editorOrigin": "http://localhost:3001",
"rendererOrigin": "http://localhost:3001"
}
}

View file

@ -16,6 +16,12 @@ export interface Config {
version: BackendVersion,
plantumlServer: string | null,
maxDocumentLength: number,
iframeCommunication: iframeCommunicationConfig
}
export interface iframeCommunicationConfig {
editorOrigin: string,
rendererOrigin: string
}
export interface BrandingConfig {

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, useRef } from 'react'
import { Button } from 'react-bootstrap'
@ -15,15 +15,22 @@ export interface CopyToClipboardButtonProps {
content: string
size?: 'sm' | 'lg'
variant?: Variant
"data-cy"?: string
}
export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({ content, size = 'sm', variant = 'dark' }) => {
export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
content,
size = 'sm',
variant = 'dark',
...props
}) => {
const { t } = useTranslation()
const button = useRef<HTMLButtonElement>(null)
return (
<Fragment>
<Button ref={button} size={size} variant={variant} title={t('renderer.highlightCode.copyCode')}>
<Button ref={button} size={size} variant={variant} title={t('renderer.highlightCode.copyCode')}
data-cy={props["data-cy"]}>
<ForkAwesomeIcon icon='files-o'/>
</Button>
<CopyOverlay content={content} clickComponent={button}/>

View file

@ -6,8 +6,12 @@
export const download = (data: BlobPart, fileName: string, mimeType: string): void => {
const file = new Blob([data], { type: mimeType })
downloadLink(URL.createObjectURL(file), fileName)
}
export const downloadLink = (url: string, fileName: string): void => {
const helperElement = document.createElement('a')
helperElement.href = URL.createObjectURL(file)
helperElement.href = url
helperElement.download = fileName
document.body.appendChild(helperElement)
helperElement.click()

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated'
import { ApplicationState } from '../../../redux'
import { isTestMode } from '../../../utils/is-test-mode'
import { ImageLightboxModal } from '../../markdown-renderer/replace-components/image/image-lightbox-modal'
import { IframeEditorToRendererCommunicator } from '../../render-page/iframe-editor-to-renderer-communicator'
import { ImageDetails } from '../../render-page/rendering-message'
import { ScrollingDocumentRenderPaneProps } from './scrolling-document-render-pane'
export const DocumentIframe: React.FC<ScrollingDocumentRenderPaneProps> = (
{
markdownContent,
onTaskCheckedChange,
onMetadataChange,
scrollState,
onFirstHeadingChange,
wide,
onScroll,
onMakeScrollSource,
extraClasses
}) => {
const frameReference = useRef<HTMLIFrameElement>(null)
const darkMode = useIsDarkModeActivated()
const [lightboxDetails, setLightboxDetails] = useState<ImageDetails | undefined>(undefined)
const rendererOrigin = useSelector((state: ApplicationState) => state.config.iframeCommunication.rendererOrigin)
const renderPageUrl = `${rendererOrigin}/render`
const iframeCommunicator = useMemo(() => new IframeEditorToRendererCommunicator(), [])
useEffect(() => () => iframeCommunicator.unregisterEventListener(), [iframeCommunicator])
const [rendererReady, setRendererReady] = useState<boolean>(false)
useEffect(() => iframeCommunicator.onFirstHeadingChange(onFirstHeadingChange), [iframeCommunicator, onFirstHeadingChange])
useEffect(() => iframeCommunicator.onMetaDataChange(onMetadataChange), [iframeCommunicator, onMetadataChange])
useEffect(() => iframeCommunicator.onSetScrollState(onScroll), [iframeCommunicator, onScroll])
useEffect(() => iframeCommunicator.onSetScrollSourceToRenderer(onMakeScrollSource), [iframeCommunicator, onMakeScrollSource])
useEffect(() => iframeCommunicator.onTaskCheckboxChange(onTaskCheckedChange), [iframeCommunicator, onTaskCheckedChange])
useEffect(() => iframeCommunicator.onImageClicked(setLightboxDetails), [iframeCommunicator])
useEffect(() => iframeCommunicator.onRendererReady(() => setRendererReady(true)), [darkMode, iframeCommunicator, scrollState, wide])
useEffect(() => {
if (rendererReady) {
iframeCommunicator.sendSetMarkdownContent(markdownContent)
}
}, [iframeCommunicator, markdownContent, rendererReady])
useEffect(() => {
if (rendererReady) {
iframeCommunicator.sendSetDarkmode(darkMode)
}
}, [darkMode, iframeCommunicator, rendererReady])
useEffect(() => {
if (rendererReady) {
iframeCommunicator.sendScrollState(scrollState)
}
}, [iframeCommunicator, rendererReady, scrollState])
useEffect(() => {
if (rendererReady) {
iframeCommunicator.sendSetWide(wide ?? false)
}
}, [iframeCommunicator, rendererReady, wide])
useEffect(() => {
if (rendererReady) {
iframeCommunicator.sendSetBaseUrl(window.location.toString())
}
}, [iframeCommunicator, rendererReady,])
const sendToRenderPage = useRef<boolean>(true)
const onLoad = useCallback(() => {
const frame = frameReference.current
if (!frame || !frame.contentWindow) {
iframeCommunicator.unsetOtherSide()
return
}
if (sendToRenderPage.current) {
iframeCommunicator.setOtherSide(frame.contentWindow, rendererOrigin)
sendToRenderPage.current = false
return
} else {
setRendererReady(false)
console.error("Navigated away from unknown URL")
frame.src = renderPageUrl
sendToRenderPage.current = true
}
}, [iframeCommunicator, renderPageUrl, rendererOrigin])
const hideLightbox = useCallback(() => {
setLightboxDetails(undefined)
}, [])
return <Fragment>
<ImageLightboxModal show={!!lightboxDetails} onHide={hideLightbox} src={lightboxDetails?.src}
alt={lightboxDetails?.alt} title={lightboxDetails?.title}/>
<iframe data-cy={'documentIframe'} onLoad={onLoad} title="render" src={renderPageUrl}
{...isTestMode() ? {} : { sandbox: 'allow-downloads allow-same-origin allow-scripts allow-popups' }}
ref={frameReference} className={`h-100 w-100 border-0 ${extraClasses ?? ''}`}/>
</Fragment>
}

View file

@ -5,14 +5,13 @@
*/
import { TocAst } from 'markdown-it-toc-done-right'
import React, { RefObject, useRef, useState } from 'react'
import React, { MutableRefObject, useCallback, useRef, useState } from 'react'
import { Dropdown } from 'react-bootstrap'
import { useSelector } from 'react-redux'
import useResizeObserver from 'use-resize-observer'
import { ApplicationState } from '../../../redux'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../common/show-if/show-if'
import { FullMarkdownRenderer } from '../../markdown-renderer/full-markdown-renderer'
import { ImageClickHandler } from '../../markdown-renderer/replace-components/image/image-replacer'
import { LineMarkerPosition } from '../../markdown-renderer/types'
import { TableOfContents } from '../table-of-contents/table-of-contents'
import { YAMLMetaData } from '../yaml-metadata/yaml-metadata'
@ -21,14 +20,17 @@ import { YamlArrayDeprecationAlert } from './yaml-array-deprecation-alert'
export interface DocumentRenderPaneProps {
extraClasses?: string
onFirstHeadingChange: (firstHeading: string | undefined) => void
onFirstHeadingChange?: (firstHeading: string | undefined) => void
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
onMetadataChange: (metaData: YAMLMetaData | undefined) => void
onMetadataChange?: (metaData: YAMLMetaData | undefined) => void
onMouseEnterRenderer?: () => void
onScrollRenderer?: () => void
onTaskCheckedChange: (lineInMarkdown: number, checked: boolean) => void
documentRenderPaneRef?: RefObject<HTMLDivElement>
wide?: boolean
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
documentRenderPaneRef?: MutableRefObject<HTMLDivElement | null>
wide?: boolean,
markdownContent: string,
baseUrl?: string
onImageClick?: ImageClickHandler
}
export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = (
@ -41,25 +43,34 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = (
onScrollRenderer,
onTaskCheckedChange,
documentRenderPaneRef,
wide
wide,
baseUrl,
markdownContent,
onImageClick
}) => {
const [tocAst, setTocAst] = useState<TocAst>()
const { width } = useResizeObserver(documentRenderPaneRef ? { ref: documentRenderPaneRef } : undefined)
const internalDocumentRenderPaneRef = useRef<HTMLDivElement>()
const { width } = useResizeObserver({ ref: internalDocumentRenderPaneRef.current })
const realWidth = width || 0
const rendererRef = useRef<HTMLDivElement | null>(null)
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
const changeLineMarker = useAdaptedLineMarkerCallback(documentRenderPaneRef, rendererRef, onLineMarkerPositionChanged)
const setContainerReference = useCallback((instance: HTMLDivElement | null) => {
if (documentRenderPaneRef) {
documentRenderPaneRef.current = instance || null
}
internalDocumentRenderPaneRef.current = instance || undefined
}, [documentRenderPaneRef])
return (
<div className={`bg-light flex-fill pb-5 flex-row d-flex w-100 h-100 ${extraClasses ?? ''}`}
ref={documentRenderPaneRef} onScroll={onScrollRenderer} onMouseEnter={onMouseEnterRenderer}>
<div className={'col-md'}/>
<div className={'bg-light flex-fill'}>
<div className={`bg-light m-0 pb-5 row ${extraClasses ?? ''}`}
ref={setContainerReference} onScroll={onScrollRenderer} onMouseEnter={onMouseEnterRenderer}>
<div className={'col-md d-none d-md-block'}/>
<div className={'bg-light col'}>
<YamlArrayDeprecationAlert/>
<div>
<FullMarkdownRenderer
rendererRef={rendererRef}
className={'flex-fill mb-3'}
className={'flex-fill pt-4 mb-3'}
content={markdownContent}
onFirstHeadingChange={onFirstHeadingChange}
onLineMarkerPositionChanged={changeLineMarker}
@ -67,13 +78,14 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = (
onTaskCheckedChange={onTaskCheckedChange}
onTocChange={(tocAst) => setTocAst(tocAst)}
wide={wide}
/>
baseUrl={baseUrl}
onImageClick={onImageClick}/>
</div>
</div>
<div className={'col-md'}>
<div className={'col-md pt-4'}>
<ShowIf condition={realWidth >= 1280 && !!tocAst}>
<TableOfContents ast={tocAst as TocAst} className={'position-fixed'}/>
<TableOfContents ast={tocAst as TocAst} className={'sticky'} baseUrl={baseUrl}/>
</ShowIf>
<ShowIf condition={realWidth < 1280 && !!tocAst}>
<div className={'markdown-toc-sidebar-button'}>
@ -83,7 +95,7 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps> = (
</Dropdown.Toggle>
<Dropdown.Menu>
<div className={'p-2'}>
<TableOfContents ast={tocAst as TocAst}/>
<TableOfContents ast={tocAst as TocAst} baseUrl={baseUrl}/>
</div>
</Dropdown.Menu>
</Dropdown>

View file

@ -1,38 +1,48 @@
/*
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, { useMemo, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { ApplicationState } from '../../../redux'
import { LineMarkerPosition } from '../../markdown-renderer/types'
import { useOnUserScroll } from '../scroll/hooks/use-on-user-scroll'
import { useScrollToLineMark } from '../scroll/hooks/use-scroll-to-line-mark'
import { useUserScroll } from '../scroll/hooks/use-user-scroll'
import { ScrollProps } from '../scroll/scroll-props'
import { DocumentRenderPane, DocumentRenderPaneProps } from './document-render-pane'
export const ScrollingDocumentRenderPane: React.FC<DocumentRenderPaneProps & ScrollProps> = ({
scrollState,
wide,
onFirstHeadingChange,
onMakeScrollSource,
onMetadataChange,
onScroll,
onTaskCheckedChange
}) => {
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
type ImplementedProps =
'onLineMarkerPositionChanged'
| 'onScrollRenderer'
| 'rendererReference'
| 'onMouseEnterRenderer'
export type ScrollingDocumentRenderPaneProps = Omit<(DocumentRenderPaneProps & ScrollProps), ImplementedProps>
export const ScrollingDocumentRenderPane: React.FC<ScrollingDocumentRenderPaneProps> = (
{
scrollState,
wide,
onFirstHeadingChange,
onMakeScrollSource,
onMetadataChange,
onScroll,
onTaskCheckedChange,
markdownContent,
extraClasses,
baseUrl,
onImageClick
}) => {
const renderer = useRef<HTMLDivElement>(null)
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
const contentLineCount = useMemo(() => markdownContent.split('\n').length, [markdownContent])
useScrollToLineMark(scrollState, lineMarks, contentLineCount, renderer)
const userScroll = useUserScroll(lineMarks, renderer, onScroll)
const userScroll = useOnUserScroll(lineMarks, renderer, onScroll)
return (
<DocumentRenderPane
extraClasses={'overflow-y-scroll'}
extraClasses={`overflow-y-scroll h-100 ${extraClasses || ''}`}
documentRenderPaneRef={renderer}
wide={wide}
onFirstHeadingChange={onFirstHeadingChange}
@ -41,6 +51,9 @@ export const ScrollingDocumentRenderPane: React.FC<DocumentRenderPaneProps & Scr
onMouseEnterRenderer={onMakeScrollSource}
onScrollRenderer={userScroll}
onTaskCheckedChange={onTaskCheckedChange}
markdownContent={markdownContent}
baseUrl={baseUrl}
onImageClick={onImageClick}
/>
)
}

View file

@ -8,14 +8,14 @@ import { RefObject, useCallback } from 'react'
import { LineMarkerPosition } from '../../markdown-renderer/types'
export const useAdaptedLineMarkerCallback = (documentRenderPaneRef: RefObject<HTMLDivElement> | undefined,
rendererRef: RefObject<HTMLDivElement | null>,
rendererRef: RefObject<HTMLDivElement>,
onLineMarkerPositionChanged: ((lineMarkerPosition: LineMarkerPosition[]) => void) | undefined): ((lineMarkerPosition: LineMarkerPosition[]) => void) => {
return useCallback((linkMarkerPositions) => {
if (!onLineMarkerPositionChanged) {
if (!onLineMarkerPositionChanged || !documentRenderPaneRef || !documentRenderPaneRef.current || !rendererRef.current) {
return
}
const documentRenderPaneTop = (documentRenderPaneRef?.current?.offsetTop ?? 0)
const rendererTop = (rendererRef.current?.offsetTop ?? 0)
const documentRenderPaneTop = (documentRenderPaneRef.current.offsetTop ?? 0)
const rendererTop = (rendererRef.current.offsetTop ?? 0)
const offset = rendererTop - documentRenderPaneTop
onLineMarkerPositionChanged(linkMarkerPositions.map(oldMarker => ({
line: oldMarker.line,

View file

@ -18,9 +18,11 @@ export const YamlArrayDeprecationAlert: React.FC = () => {
const yamlDeprecatedTags = useSelector((state: ApplicationState) => state.documentContent.metadata.deprecatedTagsSyntax)
return <ShowIf condition={yamlDeprecatedTags}>
<Alert data-cy={'yamlArrayDeprecationAlert'} variant='warning' dir='auto'>
<span className={'text-wrap'}>
<Trans i18nKey='editor.deprecatedTags'/>
<Alert data-cy={'yamlArrayDeprecationAlert'} className={'text-wrap'} variant='warning' dir='auto'>
<span className={'text-wrap'}>
<span className={'text-wrap'}>
<Trans i18nKey='editor.deprecatedTags' />
</span>
</span>
<br/>
<TranslatedExternalLink i18nKey={'common.readForMoreInfo'} href={links.faq} className={'text-primary'}/>

View file

@ -19,7 +19,7 @@ import { MotdBanner } from '../common/motd-banner/motd-banner'
import { AppBar, AppBarMode } from './app-bar/app-bar'
import { EditorMode } from './app-bar/editor-view-mode'
import { DocumentBar } from './document-bar/document-bar'
import { ScrollingDocumentRenderPane } from './document-renderer-pane/scrolling-document-render-pane'
import { DocumentIframe } from './document-renderer-pane/document-iframe'
import { EditorPane } from './editor-pane/editor-pane'
import { editorTestContent } from './editorTestContent'
import { useViewModeShortcuts } from './hooks/useViewModeShortcuts'
@ -121,6 +121,10 @@ export const Editor: React.FC = () => {
useApplyDarkMode()
useDocumentTitle(documentTitle)
const setRendererToScrollSource = useCallback(() => {
scrollSource.current = ScrollSource.RENDERER
}, [])
return (
<Fragment>
<MotdBanner/>
@ -140,16 +144,14 @@ export const Editor: React.FC = () => {
}
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
right={
<ScrollingDocumentRenderPane
onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={() => {
scrollSource.current = ScrollSource.RENDERER
}}
onMetadataChange={onMetadataChange}
onScroll={onMarkdownRendererScroll}
onTaskCheckedChange={onTaskCheckedChange}
scrollState={scrollState.rendererScrollState}
wide={editorMode === EditorMode.PREVIEW}
<DocumentIframe markdownContent={markdownContent}
onMakeScrollSource={setRendererToScrollSource}
onFirstHeadingChange={onFirstHeadingChange}
onTaskCheckedChange={onTaskCheckedChange}
onMetadataChange={onMetadataChange}
onScroll={onMarkdownRendererScroll}
wide={editorMode === EditorMode.PREVIEW}
scrollState={scrollState.rendererScrollState}
/>
}
containerClassName={'overflow-hidden'}/>
@ -157,5 +159,4 @@ export const Editor: React.FC = () => {
</Fragment>
)
}
export default Editor

View file

@ -8,7 +8,7 @@ import { RefObject, useCallback } from 'react'
import { LineMarkerPosition } from '../../../markdown-renderer/types'
import { ScrollState } from '../scroll-props'
export const useUserScroll = (lineMarks: LineMarkerPosition[] | undefined, renderer: RefObject<HTMLElement>, onScroll: ((newScrollState: ScrollState) => void)|undefined): () => void =>
export const useOnUserScroll = (lineMarks: LineMarkerPosition[] | undefined, renderer: RefObject<HTMLElement>, onScroll: ((newScrollState: ScrollState) => void) | undefined): () => void =>
useCallback(() => {
if (!renderer.current || !lineMarks || lineMarks.length === 0 || !onScroll) {
return

View file

@ -1,10 +1,10 @@
/*
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { ReactElement, useRef, useState } from 'react'
import React, { ReactElement, useCallback, useRef, useState } from 'react'
import { ShowIf } from '../../common/show-if/show-if'
import { SplitDivider } from './split-divider/split-divider'
import './splitter.scss'
@ -33,28 +33,35 @@ export const Splitter: React.FC<SplitterProps> = ({ containerClassName, left, ri
setSplit(newSize * 100)
}
const stopResizing = useCallback(() => {
setDoResizing(false)
}, [])
const onMouseMove = useCallback((mouseEvent: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (doResizing) {
recalculateSize(mouseEvent.pageX)
mouseEvent.preventDefault()
}
}, [doResizing])
const onTouchMove = useCallback((touchEvent: React.TouchEvent<HTMLDivElement>) => {
if (doResizing) {
recalculateSize(touchEvent.touches[0].pageX)
touchEvent.preventDefault()
}
}, [doResizing])
const onGrab = useCallback(() => setDoResizing(true), [])
return (
<div ref={splitContainer} className={`flex-fill flex-row d-flex ${containerClassName || ''}`}
onMouseUp={() => setDoResizing(false)}
onTouchEnd={() => setDoResizing(false)}
onMouseMove={(mouseEvent) => {
if (doResizing) {
recalculateSize(mouseEvent.pageX)
mouseEvent.preventDefault()
}
}}
onTouchMove={(touchEvent) => {
if (doResizing) {
recalculateSize(touchEvent.touches[0].pageX)
}
}}
>
onMouseUp={stopResizing} onTouchEnd={stopResizing} onMouseMove={onMouseMove} onTouchMove={onTouchMove}>
<div className={`splitter left ${!showLeft ? 'd-none' : ''}`} style={{ flexBasis: `calc(${realSplit}% - 5px)` }}>
{left}
</div>
<ShowIf condition={showLeft && showRight}>
<div className='splitter separator'>
<SplitDivider onGrab={() => setDoResizing(true)}/>
<SplitDivider onGrab={onGrab}/>
</div>
</ShowIf>
<div className={`splitter right ${!showRight ? 'd-none' : ''}`}>

View file

@ -1,4 +1,4 @@
/*
/*!
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
@ -13,6 +13,7 @@
&.sticky {
position: fixed;
top: 0;
}
> ul > li {

View file

@ -1,26 +1,29 @@
/*
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 { TocAst } from 'markdown-it-toc-done-right'
import React, { Fragment, ReactElement, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { TocAst } from 'markdown-it-toc-done-right'
import { ShowIf } from '../../common/show-if/show-if'
import { createJumpToMarkClickEventHandler } from '../../markdown-renderer/replace-components/link-replacer/link-replacer'
import './table-of-contents.scss'
export interface TableOfContentsProps {
ast: TocAst
maxDepth?: number
className?: string
baseUrl?: string
}
export const slugify = (content: string): string => {
return encodeURIComponent(String(content).trim().toLowerCase().replace(/\s+/g, '-'))
return encodeURIComponent(content.trim().toLowerCase().replace(/\s+/g, '-'))
}
const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts: Map<string, number>, wrapInListItem: boolean): ReactElement | null => {
const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts: Map<string, number>,
wrapInListItem: boolean, baseUrl?: string): ReactElement | null => {
if (levelsToShowUnderThis < 0) {
return null
}
@ -28,19 +31,20 @@ const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts:
const rawName = toc.n.trim()
const nameCount = (headerCounts.get(rawName) ?? -1) + 1
const slug = `#${slugify(rawName)}${nameCount > 0 ? `-${nameCount}` : ''}`
const headlineUrl = new URL(slug, baseUrl).toString()
headerCounts.set(rawName, nameCount)
const content = (
<Fragment>
<ShowIf condition={toc.l > 0}>
<a href={slug}>{rawName}</a>
<a href={headlineUrl} title={rawName} onClick={createJumpToMarkClickEventHandler(slug.substr(1))}>{rawName}</a>
</ShowIf>
<ShowIf condition={toc.c.length > 0}>
<ul>
{
toc.c.map(child =>
(convertLevel(child, levelsToShowUnderThis - 1, headerCounts, true)))
(convertLevel(child, levelsToShowUnderThis - 1, headerCounts, true, baseUrl)))
}
</ul>
</ShowIf>
@ -49,7 +53,7 @@ const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts:
if (wrapInListItem) {
return (
<li key={slug}>
<li key={headlineUrl}>
{content}
</li>
)
@ -58,16 +62,21 @@ const convertLevel = (toc: TocAst, levelsToShowUnderThis: number, headerCounts:
}
}
export const TableOfContents: React.FC<TableOfContentsProps> = ({ ast, maxDepth = 3, className }) => {
export const TableOfContents: React.FC<TableOfContentsProps> = ({
ast,
maxDepth = 3,
className,
baseUrl
}) => {
useTranslation()
const tocTree = useMemo(() => convertLevel(ast, maxDepth, new Map<string, number>(), false), [ast, maxDepth])
const tocTree = useMemo(() => convertLevel(ast, maxDepth, new Map<string, number>(), false, baseUrl), [ast, maxDepth, baseUrl])
return (
<div className={`markdown-toc ${className ?? ''}`}>
<ShowIf condition={ast.c.length === 0}>
<Trans i18nKey={'editor.infoToc'}/>
</ShowIf>
{ tocTree }
{tocTree}
</div>
)
}

View file

@ -227,5 +227,3 @@ export const HistoryPage: React.FC = () => {
/>
</Fragment>
}
export default HistoryPage

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)

View file

@ -1,22 +1,24 @@
/*
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, { useCallback, useEffect, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { useParams } from 'react-router'
import { getNote, Note } from '../../api/notes'
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
import { useDocumentTitle } from '../../hooks/common/use-document-title'
import { ApplicationState } from '../../redux'
import { setDocumentContent, setDocumentMetadata } from '../../redux/document-content/methods'
import { extractNoteTitle } from '../common/document-title/note-title-extractor'
import { MotdBanner } from '../common/motd-banner/motd-banner'
import { ShowIf } from '../common/show-if/show-if'
import { AppBar, AppBarMode } from '../editor/app-bar/app-bar'
import { DocumentRenderPane } from '../editor/document-renderer-pane/document-render-pane'
import { DocumentIframe } from '../editor/document-renderer-pane/document-iframe'
import { EditorPathParams } from '../editor/editor'
import { YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata'
import { DocumentInfobar } from './document-infobar'
@ -60,6 +62,7 @@ export const PadViewOnly: React.FC = () => {
useApplyDarkMode()
useDocumentTitle(documentTitle)
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
return (
<div className={'d-flex flex-column mvh-100 bg-light'}>
@ -80,7 +83,7 @@ export const PadViewOnly: React.FC = () => {
</ShowIf>
</div>
<ShowIf condition={!error && !loading}>
{ /* TODO set editable and created author properly */ }
{ /* TODO set editable and created author properly */}
<DocumentInfobar
changedAuthor={noteData?.lastChange.userId ?? ''}
changedTime={noteData?.lastChange.timestamp ?? 0}
@ -90,11 +93,10 @@ export const PadViewOnly: React.FC = () => {
noteId={id}
viewCount={noteData?.viewcount ?? 0}
/>
<DocumentRenderPane
onFirstHeadingChange={onFirstHeadingChange}
onMetadataChange={onMetadataChange}
onTaskCheckedChange={() => false}
/>
<DocumentIframe extraClasses={"flex-fill"}
markdownContent={markdownContent}
onFirstHeadingChange={onFirstHeadingChange}
onMetadataChange={onMetadataChange}/>
</ShowIf>
</div>
)

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export abstract class IframeCommunicator<SEND, RECEIVE> {
protected otherSide?: Window
protected otherOrigin?: string
constructor () {
window.addEventListener("message", this.handleEvent.bind(this))
}
public unregisterEventListener (): void {
window.removeEventListener("message", this.handleEvent.bind(this))
}
public setOtherSide (otherSide: Window, otherOrigin: string): void {
this.otherSide = otherSide
this.otherOrigin = otherOrigin
}
public unsetOtherSide (): void {
this.otherSide = undefined
this.otherOrigin = undefined
}
public getOtherSide (): Window | undefined {
return this.otherSide
}
protected sendMessageToOtherSide (message: SEND): void {
if (this.otherSide === undefined || this.otherOrigin === undefined) {
console.error("Can't send message because otherSide is null", message)
return
}
this.otherSide.postMessage(message, this.otherOrigin)
}
protected abstract handleEvent (event: MessageEvent<RECEIVE>): void;
}

View file

@ -0,0 +1,118 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ScrollState } from "../editor/scroll/scroll-props"
import { YAMLMetaData } from "../editor/yaml-metadata/yaml-metadata"
import { IframeCommunicator } from "./iframe-communicator"
import {
EditorToRendererIframeMessage,
ImageDetails,
RendererToEditorIframeMessage,
RenderIframeMessageType
} from "./rendering-message"
export class IframeEditorToRendererCommunicator extends IframeCommunicator<EditorToRendererIframeMessage, RendererToEditorIframeMessage> {
private onSetScrollSourceToRendererHandler?: () => void
private onTaskCheckboxChangeHandler?: (lineInMarkdown: number, checked: boolean) => void
private onFirstHeadingChangeHandler?: (heading?: string) => void
private onMetaDataChangeHandler?: (metaData?: YAMLMetaData) => void
private onSetScrollStateHandler?: (scrollState: ScrollState) => void
private onRendererReadyHandler?: () => void
private onImageClickedHandler?: (details: ImageDetails) => void
protected handleEvent (event: MessageEvent<RendererToEditorIframeMessage>): boolean | undefined {
const renderMessage = event.data
switch (renderMessage.type) {
case RenderIframeMessageType.RENDERER_READY:
this.onRendererReadyHandler?.()
return false
case RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER:
this.onSetScrollSourceToRendererHandler?.()
return false
case RenderIframeMessageType.SET_SCROLL_STATE:
this.onSetScrollStateHandler?.(renderMessage.scrollState)
return false
case RenderIframeMessageType.ON_FIRST_HEADING_CHANGE:
this.onFirstHeadingChangeHandler?.(renderMessage.firstHeading)
return false
case RenderIframeMessageType.ON_TASK_CHECKBOX_CHANGE:
this.onTaskCheckboxChangeHandler?.(renderMessage.lineInMarkdown, renderMessage.checked)
return false
case RenderIframeMessageType.ON_SET_META_DATA:
this.onMetaDataChangeHandler?.(renderMessage.metaData)
return false
case RenderIframeMessageType.IMAGE_CLICKED:
this.onImageClickedHandler?.(renderMessage.details)
return false
}
}
public onImageClicked (handler?: (details: ImageDetails) => void): void {
this.onImageClickedHandler = handler
}
public onRendererReady (handler?: () => void): void {
this.onRendererReadyHandler = handler
}
public onSetScrollSourceToRenderer (handler?: () => void): void {
this.onSetScrollSourceToRendererHandler = handler
}
public onTaskCheckboxChange (handler?: (lineInMarkdown: number, checked: boolean) => void): void {
this.onTaskCheckboxChangeHandler = handler
}
public onFirstHeadingChange (handler?: (heading?: string) => void): void {
this.onFirstHeadingChangeHandler = handler
}
public onMetaDataChange (handler?: (metaData?: YAMLMetaData) => void): void {
this.onMetaDataChangeHandler = handler
}
public onSetScrollState (handler?: (scrollState: ScrollState) => void): void {
this.onSetScrollStateHandler = handler
}
public sendSetBaseUrl (baseUrl: string): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_BASE_URL,
baseUrl
})
}
public sendSetMarkdownContent (markdownContent: string): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_MARKDOWN_CONTENT,
content: markdownContent
})
}
public sendSetDarkmode (darkModeActivated: boolean): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_DARKMODE,
activated: darkModeActivated
})
}
public sendSetWide (isWide: boolean): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_WIDE,
activated: isWide
})
}
public sendScrollState (scrollState?: ScrollState): void {
if (!scrollState) {
return
}
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_SCROLL_STATE,
scrollState
})
}
}

View file

@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ScrollState } from "../editor/scroll/scroll-props"
import { YAMLMetaData } from "../editor/yaml-metadata/yaml-metadata"
import { IframeCommunicator } from "./iframe-communicator"
import {
EditorToRendererIframeMessage,
ImageDetails,
RendererToEditorIframeMessage,
RenderIframeMessageType
} from "./rendering-message"
export class IframeRendererToEditorCommunicator extends IframeCommunicator<RendererToEditorIframeMessage, EditorToRendererIframeMessage> {
private onSetMarkdownContentHandler?: ((markdownContent: string) => void)
private onSetDarkModeHandler?: ((darkModeActivated: boolean) => void)
private onSetWideHandler?: ((wide: boolean) => void)
private onSetScrollStateHandler?: ((scrollState: ScrollState) => void)
private onSetBaseUrlHandler?: ((baseUrl: string) => void)
public onSetBaseUrl (handler?: (baseUrl: string) => void): void {
this.onSetBaseUrlHandler = handler
}
public onSetMarkdownContent (handler?: (markdownContent: string) => void): void {
this.onSetMarkdownContentHandler = handler
}
public onSetDarkMode (handler?: (darkModeActivated: boolean) => void): void {
this.onSetDarkModeHandler = handler
}
public onSetWide (handler?: (wide: boolean) => void): void {
this.onSetWideHandler = handler
}
public onSetScrollState (handler?: (scrollState: ScrollState) => void): void {
this.onSetScrollStateHandler = handler
}
public sendRendererReady (): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.RENDERER_READY
})
}
public sendTaskCheckBoxChange (lineInMarkdown: number, checked: boolean): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.ON_TASK_CHECKBOX_CHANGE,
checked,
lineInMarkdown
})
}
public sendFirstHeadingChanged (firstHeading: string | undefined): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.ON_FIRST_HEADING_CHANGE,
firstHeading
})
}
public sendSetScrollSourceToRenderer (): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER
})
}
public sendSetMetaData (metaData: YAMLMetaData | undefined): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.ON_SET_META_DATA,
metaData
})
}
public sendSetScrollState (scrollState: ScrollState): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.SET_SCROLL_STATE,
scrollState
})
}
protected handleEvent (event: MessageEvent<EditorToRendererIframeMessage>): boolean | undefined {
const renderMessage = event.data
switch (renderMessage.type) {
case RenderIframeMessageType.SET_MARKDOWN_CONTENT:
this.onSetMarkdownContentHandler?.(renderMessage.content)
return false
case RenderIframeMessageType.SET_DARKMODE:
this.onSetDarkModeHandler?.(renderMessage.activated)
return false
case RenderIframeMessageType.SET_WIDE:
this.onSetWideHandler?.(renderMessage.activated)
return false
case RenderIframeMessageType.SET_SCROLL_STATE:
this.onSetScrollStateHandler?.(renderMessage.scrollState)
return false
case RenderIframeMessageType.SET_BASE_URL:
this.onSetBaseUrlHandler?.(renderMessage.baseUrl)
return false
}
}
public sendClickedImageUrl (details: ImageDetails): void {
this.sendMessageToOtherSide({
type: RenderIframeMessageType.IMAGE_CLICKED,
details: details
})
}
}

View file

@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import equal from "fast-deep-equal"
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
import { ApplicationState } from '../../redux'
import { setDarkMode } from '../../redux/dark-mode/methods'
import { setDocumentMetadata } from '../../redux/document-content/methods'
import { ScrollingDocumentRenderPane } from '../editor/document-renderer-pane/scrolling-document-render-pane'
import { ScrollState } from '../editor/scroll/scroll-props'
import { YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata'
import { ImageClickHandler } from '../markdown-renderer/replace-components/image/image-replacer'
import { IframeRendererToEditorCommunicator } from './iframe-renderer-to-editor-communicator'
export const RenderPage: React.FC = () => {
useApplyDarkMode()
const [markdownContent, setMarkdownContent] = useState('')
const [isWide, setWide] = useState(false)
const [scrollState, setScrollState] = useState<ScrollState>({ firstLineInView: 1, scrolledPercentage: 0 })
const [baseUrl, setBaseUrl] = useState<string>()
const editorOrigin = useSelector((state: ApplicationState) => state.config.iframeCommunication.editorOrigin)
const iframeCommunicator = useMemo(() => {
const newCommunicator = new IframeRendererToEditorCommunicator()
newCommunicator.setOtherSide(window.parent, editorOrigin)
return newCommunicator
}, [editorOrigin])
useEffect(() => {
iframeCommunicator.sendRendererReady()
return () => iframeCommunicator.unregisterEventListener()
}, [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetBaseUrl(setBaseUrl), [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetMarkdownContent(setMarkdownContent), [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetDarkMode(setDarkMode), [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetWide(setWide), [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetScrollState((newScrollState) => {
if (!equal(scrollState, newScrollState)) {
setScrollState(newScrollState)
}
}), [iframeCommunicator, scrollState])
const onTaskCheckedChange = useCallback((lineInMarkdown: number, checked: boolean) => {
iframeCommunicator.sendTaskCheckBoxChange(lineInMarkdown, checked)
}, [iframeCommunicator])
const onFirstHeadingChange = useCallback((firstHeading?: string) => {
iframeCommunicator.sendFirstHeadingChanged(firstHeading)
}, [iframeCommunicator])
const onMakeScrollSource = useCallback(() => {
iframeCommunicator.sendSetScrollSourceToRenderer()
}, [iframeCommunicator])
const onMetaDataChange = useCallback((metaData?: YAMLMetaData) => {
setDocumentMetadata(metaData)
iframeCommunicator.sendSetMetaData(metaData)
}, [iframeCommunicator])
const onScroll = useCallback((scrollState: ScrollState) => {
iframeCommunicator.sendSetScrollState(scrollState)
}, [iframeCommunicator])
const onImageClick: ImageClickHandler = useCallback((event) => {
const image = event.target as HTMLImageElement
if (image.src === '') {
return
}
iframeCommunicator.sendClickedImageUrl({
src: image.src,
alt: image.alt,
title: image.title
})
}, [iframeCommunicator])
if (!baseUrl) {
return null
}
return (
<div className={"vh-100 w-100"}>
<ScrollingDocumentRenderPane
extraClasses={'w-100'}
markdownContent={markdownContent}
wide={isWide}
onTaskCheckedChange={onTaskCheckedChange}
onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={onMakeScrollSource}
onMetadataChange={onMetaDataChange}
scrollState={scrollState}
onScroll={onScroll}
baseUrl={baseUrl}
onImageClick={onImageClick}/>
</div>
)
}
export default RenderPage

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ScrollState } from '../editor/scroll/scroll-props'
import { YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata'
export enum RenderIframeMessageType {
SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT',
RENDERER_READY = 'RENDERER_READY',
SET_DARKMODE = 'SET_DARKMODE',
SET_WIDE = 'SET_WIDE',
ON_TASK_CHECKBOX_CHANGE = 'ON_TASK_CHECKBOX_CHANGE',
ON_FIRST_HEADING_CHANGE = 'ON_FIRST_HEADING_CHANGE',
SET_SCROLL_SOURCE_TO_RENDERER = 'SET_SCROLL_SOURCE_TO_RENDERER',
SET_SCROLL_STATE = 'SET_SCROLL_STATE',
ON_SET_META_DATA = 'ON_SET_META_DATA',
IMAGE_CLICKED = 'IMAGE_CLICKED',
SET_BASE_URL = 'SET_BASE_URL'
}
export interface RendererToEditorSimpleMessage {
type: RenderIframeMessageType.RENDERER_READY | RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER
}
export interface SetDarkModeMessage {
type: RenderIframeMessageType.SET_DARKMODE,
activated: boolean
}
export interface ImageDetails {
alt?: string
src: string
title?: string
}
export interface SetBaseUrlMessage {
type: RenderIframeMessageType.SET_BASE_URL,
baseUrl: string
}
export interface ImageClickedMessage {
type: RenderIframeMessageType.IMAGE_CLICKED,
details: ImageDetails
}
export interface SetWideMessage {
type: RenderIframeMessageType.SET_WIDE,
activated: boolean
}
export interface SetMarkdownContentMessage {
type: RenderIframeMessageType.SET_MARKDOWN_CONTENT,
content: string
}
export interface SetScrollStateMessage {
type: RenderIframeMessageType.SET_SCROLL_STATE,
scrollState: ScrollState
}
export interface OnTaskCheckboxChangeMessage {
type: RenderIframeMessageType.ON_TASK_CHECKBOX_CHANGE,
lineInMarkdown: number,
checked: boolean
}
export interface OnFirstHeadingChangeMessage {
type: RenderIframeMessageType.ON_FIRST_HEADING_CHANGE,
firstHeading: string | undefined
}
export interface OnMetadataChangeMessage {
type: RenderIframeMessageType.ON_SET_META_DATA,
metaData: YAMLMetaData | undefined
}
export type EditorToRendererIframeMessage =
SetMarkdownContentMessage |
SetDarkModeMessage |
SetWideMessage |
SetScrollStateMessage |
SetBaseUrlMessage
export type RendererToEditorIframeMessage =
RendererToEditorSimpleMessage |
OnFirstHeadingChangeMessage |
OnTaskCheckboxChangeMessage |
OnMetadataChangeMessage |
SetScrollStateMessage |
ImageClickedMessage

View file

@ -26,6 +26,7 @@ import './style/index.scss'
import { isTestMode } from './utils/is-test-mode'
const Editor = React.lazy(() => import(/* webpackPrefetch: true */ './components/editor/editor'))
const RenderPage = React.lazy(() => import (/* webpackPrefetch: true */ './components/render-page/render-page'))
ReactDOM.render(
<Provider store={store}>
@ -58,6 +59,9 @@ ReactDOM.render(
<ProfilePage/>
</LandingLayout>
</Route>
<Route path="/render">
<RenderPage/>
</Route>
<Route path="/n/:id">
<Editor/>
</Route>
@ -82,10 +86,11 @@ ReactDOM.render(
)
if (isTestMode()) {
console.log("This build runs in test mode. This means:\n - No default content in the editor")
console.log("This build runs in test mode. This means:\n - No default content in the editor\n - no sandboxed iframe")
}
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorkerRegistration.unregister()

View file

@ -49,6 +49,10 @@ export const initialState: Config = {
version: '',
sourceCodeUrl: '',
issueTrackerUrl: ''
},
iframeCommunication: {
editorOrigin: '',
rendererOrigin: ''
}
}

View file

@ -16,6 +16,10 @@
color: $black;
}
.cursor-pointer {
cursor: pointer;
}
body {
background-color: darken($dark, 8%);
}