fix: Move content into to frontend directory

Doing this BEFORE the merge prevents a lot of merge conflicts.

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-11-11 11:16:18 +01:00
parent 4e18ce38f3
commit 762a0a850e
No known key found for this signature in database
GPG key ID: B97799103358209B
1051 changed files with 0 additions and 35 deletions

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useState } from 'react'
import styles from './markdown-document.module.scss'
import { ShowIf } from '../common/show-if/show-if'
import { WidthBasedTableOfContents } from './width-based-table-of-contents'
import type { TocAst } from 'markdown-it-toc-done-right'
import { useExtensionEventEmitterHandler } from '../markdown-renderer/hooks/use-extension-event-emitter'
import { TableOfContentsMarkdownExtension } from '../markdown-renderer/extensions/table-of-contents-markdown-extension'
export interface DocumentTocSidebarProps {
width: number
disableToc: boolean
baseUrl: string
}
export const DocumentTocSidebar: React.FC<DocumentTocSidebarProps> = ({ disableToc, width, baseUrl }) => {
const [tocAst, setTocAst] = useState<TocAst>()
useExtensionEventEmitterHandler(TableOfContentsMarkdownExtension.EVENT_NAME, setTocAst)
return (
<div className={`${styles['markdown-document-side']} pt-4`}>
<ShowIf condition={!!tocAst && !disableToc}>
<WidthBasedTableOfContents tocAst={tocAst as TocAst} baseUrl={baseUrl} width={width} />
</ShowIf>
</div>
)
}

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type React from 'react'
import { useCallback, useMemo, useState } from 'react'
import type { ScrollState } from '../../../editor-page/synced-scroll/scroll-props'
import { useOnUserScroll } from './use-on-user-scroll'
import { useScrollToLineMark } from './use-scroll-to-line-mark'
import type { LineMarkerPosition } from '../../../markdown-renderer/extensions/linemarker/types'
/**
* Synchronizes the scroll status of the given container with the given scroll state and posts changes if the user scrolls.
*
* @param outerContainerRef A reference for the outer container.
* @param rendererRef A reference for the renderer
* @param numberOfLines The number of lines
* @param scrollState The current {@link ScrollState}
* @param onScroll A callback that posts new scroll states
* @return A tuple of two callbacks.
* The first one should be executed if the {@link LineMarkerPosition line marker positions} are updated.
* The second one should be executed if the user actually scrolls. Usually it should be attached to the DOM element that the user scrolls.
*/
export const useDocumentSyncScrolling = (
outerContainerRef: React.RefObject<HTMLElement>,
rendererRef: React.RefObject<HTMLElement>,
numberOfLines: number,
scrollState?: ScrollState,
onScroll?: (scrollState: ScrollState) => void
): [(lineMarkers: LineMarkerPosition[]) => void, React.UIEventHandler<HTMLElement>] => {
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
const onLineMarkerPositionChanged = useCallback(
(linkMarkerPositions: LineMarkerPosition[]) => {
if (!outerContainerRef.current || !rendererRef.current) {
return
}
const documentRenderPaneTop = outerContainerRef.current.offsetTop ?? 0
const rendererTop = rendererRef.current.offsetTop ?? 0
const offset = rendererTop - documentRenderPaneTop
const adjustedLineMakerPositions = linkMarkerPositions.map((oldMarker) => ({
line: oldMarker.line,
position: oldMarker.position + offset
}))
setLineMarks(adjustedLineMakerPositions)
},
[outerContainerRef, rendererRef]
)
const onUserScroll = useOnUserScroll(lineMarks, outerContainerRef, onScroll)
useScrollToLineMark(scrollState, lineMarks, numberOfLines, outerContainerRef)
return useMemo(() => [onLineMarkerPositionChanged, onUserScroll], [onLineMarkerPositionChanged, onUserScroll])
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type React from 'react'
import { useCallback } from 'react'
import type { ScrollState } from '../../../editor-page/synced-scroll/scroll-props'
import type { LineMarkerPosition } from '../../../markdown-renderer/extensions/linemarker/types'
/**
* Provides a callback to handle user scrolling.
*
* @param lineMarks An array of the current {@link LineMarkerPosition LineMarkerPositions}
* @param scrollContainer The container to scroll in
* @param onScroll A callback that posts new scroll states.
* @return A callback that can be called when the user is scrolling.
*/
export const useOnUserScroll = (
lineMarks: LineMarkerPosition[] | undefined,
scrollContainer: React.RefObject<HTMLElement>,
onScroll: ((newScrollState: ScrollState) => void) | undefined
): React.UIEventHandler<HTMLElement> => {
return useCallback(() => {
if (!scrollContainer.current || !lineMarks || lineMarks.length === 0 || !onScroll) {
return
}
const scrollTop = scrollContainer.current.scrollTop
const lineMarksBeforeScrollTop = lineMarks.filter((lineMark) => lineMark.position <= scrollTop)
if (lineMarksBeforeScrollTop.length === 0) {
return
}
const lineMarksAfterScrollTop = lineMarks.filter((lineMark) => lineMark.position > scrollTop)
if (lineMarksAfterScrollTop.length === 0) {
return
}
const beforeLineMark = lineMarksBeforeScrollTop.reduce((prevLineMark, currentLineMark) =>
prevLineMark.line >= currentLineMark.line ? prevLineMark : currentLineMark
)
const afterLineMark = lineMarksAfterScrollTop.reduce((prevLineMark, currentLineMark) =>
prevLineMark.line < currentLineMark.line ? prevLineMark : currentLineMark
)
const componentHeight = afterLineMark.position - beforeLineMark.position
const distanceToBefore = scrollTop - beforeLineMark.position
const percentageRaw = distanceToBefore / componentHeight
const lineCount = afterLineMark.line - beforeLineMark.line
const line = Math.floor(lineCount * percentageRaw + beforeLineMark.line)
const lineHeight = componentHeight / lineCount
const innerScrolling = Math.floor(((distanceToBefore % lineHeight) / lineHeight) * 100)
const newScrollState: ScrollState = { firstLineInView: line, scrolledPercentage: innerScrolling }
onScroll(newScrollState)
}, [lineMarks, onScroll, scrollContainer])
}

View file

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RefObject } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import type { ScrollState } from '../../../editor-page/synced-scroll/scroll-props'
import { findLineMarks } from '../../../editor-page/synced-scroll/utils'
import type { LineMarkerPosition } from '../../../markdown-renderer/extensions/linemarker/types'
/**
* Scrolls the given container to the correct {@link LineMarkerPosition}.
*
* @param scrollState The current {@link ScrollState}
* @param lineMarks An array of the current {@link LineMarkerPosition LineMarkerPositions}.
* @param contentLineCount The number of lines
* @param scrollContainer The container to scroll in
*/
export const useScrollToLineMark = (
scrollState: ScrollState | undefined,
lineMarks: LineMarkerPosition[] | undefined,
contentLineCount: number,
scrollContainer: RefObject<HTMLElement>
): void => {
const lastScrollPosition = useRef<number>()
const scrollTo = useCallback(
(targetPosition: number): void => {
if (!scrollContainer.current || targetPosition === lastScrollPosition.current) {
return
}
lastScrollPosition.current = targetPosition
scrollContainer.current.scrollTo({
top: targetPosition
})
},
[scrollContainer]
)
useEffect(() => {
if (!scrollContainer.current || !lineMarks || lineMarks.length === 0 || !scrollState) {
return
}
if (scrollState.firstLineInView < lineMarks[0].line) {
scrollTo(0)
return
}
if (scrollState.firstLineInView > lineMarks[lineMarks.length - 1].line) {
scrollTo(scrollContainer.current.offsetHeight)
return
}
const { lastMarkBefore, firstMarkAfter } = findLineMarks(lineMarks, scrollState.firstLineInView)
const positionBefore = lastMarkBefore ? lastMarkBefore.position : lineMarks[0].position
const positionAfter = firstMarkAfter ? firstMarkAfter.position : scrollContainer.current.offsetHeight
const lastMarkBeforeLine = lastMarkBefore ? lastMarkBefore.line : 1
const firstMarkAfterLine = firstMarkAfter ? firstMarkAfter.line : contentLineCount
const linesBetweenMarkers = firstMarkAfterLine - lastMarkBeforeLine
const blockHeight = positionAfter - positionBefore
const lineHeight = blockHeight / linesBetweenMarkers
const position =
positionBefore +
(scrollState.firstLineInView - lastMarkBeforeLine) * lineHeight +
(scrollState.scrolledPercentage / 100) * lineHeight
const correctedPosition = Math.floor(position)
scrollTo(correctedPosition)
}, [contentLineCount, lineMarks, scrollContainer, scrollState, scrollTo])
}

View file

@ -0,0 +1,188 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ScrollState } from '../editor-page/synced-scroll/scroll-props'
import type { BaseConfiguration } from './window-post-message-communicator/rendering-message'
import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message'
import { setDarkModePreference } from '../../redux/dark-mode/methods'
import { MarkdownDocument } from './markdown-document'
import { countWords } from './word-counter'
import { useRendererToEditorCommunicator } from '../editor-page/render-context/renderer-to-editor-communicator-context-provider'
import { useRendererReceiveHandler } from './window-post-message-communicator/hooks/use-renderer-receive-handler'
import { SlideshowMarkdownRenderer } from '../markdown-renderer/slideshow-markdown-renderer'
import type { SlideOptions } from '../../redux/note-details/types/slide-show-options'
import EventEmitter2 from 'eventemitter2'
import { eventEmitterContext } from '../markdown-renderer/hooks/use-extension-event-emitter'
/**
* Wraps the markdown rendering in an iframe.
*/
export const IframeMarkdownRenderer: React.FC = () => {
const [markdownContentLines, setMarkdownContentLines] = useState<string[]>([])
const [scrollState, setScrollState] = useState<ScrollState>({ firstLineInView: 1, scrolledPercentage: 0 })
const [baseConfiguration, setBaseConfiguration] = useState<BaseConfiguration | undefined>(undefined)
const [slideOptions, setSlideOptions] = useState<SlideOptions>()
const communicator = useRendererToEditorCommunicator()
const sendScrolling = useRef<boolean>(false)
useRendererReceiveHandler(
CommunicationMessageType.SET_SLIDE_OPTIONS,
useCallback((values) => setSlideOptions(values.slideOptions), [])
)
useRendererReceiveHandler(
CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE,
useCallback(() => {
sendScrolling.current = false
}, [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_BASE_CONFIGURATION,
useCallback((values) => setBaseConfiguration(values.baseConfiguration), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_MARKDOWN_CONTENT,
useCallback((values) => setMarkdownContentLines(values.content), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_DARKMODE,
useCallback((values) => setDarkModePreference(values.preference), [])
)
useRendererReceiveHandler(
CommunicationMessageType.SET_SCROLL_STATE,
useCallback((values) => setScrollState(values.scrollState), [])
)
useRendererReceiveHandler(
CommunicationMessageType.GET_WORD_COUNT,
useCallback(() => {
const documentContainer = document.querySelector('[data-word-count-target]')
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED,
words: documentContainer ? countWords(documentContainer) : 0
})
}, [communicator])
)
const onFirstHeadingChange = useCallback(
(firstHeading?: string) => {
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ON_FIRST_HEADING_CHANGE,
firstHeading
})
},
[communicator]
)
const onMakeScrollSource = useCallback(() => {
sendScrolling.current = true
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE
})
}, [communicator])
const onScroll = useCallback(
(scrollState: ScrollState) => {
if (!sendScrolling.current) {
return
}
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.SET_SCROLL_STATE,
scrollState
})
},
[communicator]
)
const onHeightChange = useCallback(
(height: number) => {
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.ON_HEIGHT_CHANGE,
height
})
},
[communicator]
)
const renderer = useMemo(() => {
if (!baseConfiguration) {
return (
<span>
This is the render endpoint. If you can read this text then please check your HedgeDoc configuration.
</span>
)
}
switch (baseConfiguration.rendererType) {
case RendererType.DOCUMENT:
return (
<MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light'}
markdownContentLines={markdownContentLines}
onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={onMakeScrollSource}
scrollState={scrollState}
onScroll={onScroll}
baseUrl={baseConfiguration.baseUrl}
onHeightChange={onHeightChange}
/>
)
case RendererType.SLIDESHOW:
return (
<SlideshowMarkdownRenderer
markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl}
onFirstHeadingChange={onFirstHeadingChange}
scrollState={scrollState}
slideOptions={slideOptions}
/>
)
case RendererType.MOTD:
case RendererType.INTRO:
return (
<MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light overflow-y-hidden'}
markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl}
disableToc={true}
onHeightChange={onHeightChange}
/>
)
default:
return null
}
}, [
baseConfiguration,
markdownContentLines,
onFirstHeadingChange,
onHeightChange,
onMakeScrollSource,
onScroll,
scrollState,
slideOptions
])
const extensionEventEmitter = useMemo(() => new EventEmitter2({ wildcard: true }), [])
useEffect(() => {
extensionEventEmitter.onAny((event, values) => {
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.EXTENSION_EVENT,
eventName: typeof event === 'object' ? event.join('.') : event,
payload: values
})
})
}, [communicator, extensionEventEmitter])
return <eventEmitterContext.Provider value={extensionEventEmitter}>{renderer}</eventEmitterContext.Provider>
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.markdown-document {
width: 100%;
height: 100%;
margin: 0;
overflow: auto;
display: flex;
flex-direction: row;
.markdown-document-side {
flex: 1 0 0;
}
.markdown-document-content {
padding-left: 10px;
padding-right: 10px;
flex: 0 0 900px;
max-width: 100%;
width: 900px;
}
}

View file

@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MutableRefObject } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import useResizeObserver from '@react-hook/resize-observer'
import { useDocumentSyncScrolling } from './hooks/sync-scroll/use-document-sync-scrolling'
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
import { DocumentMarkdownRenderer } from '../markdown-renderer/document-markdown-renderer'
import styles from './markdown-document.module.scss'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { DocumentTocSidebar } from './document-toc-sidebar'
export interface RendererProps extends ScrollProps {
onFirstHeadingChange?: (firstHeading: string | undefined) => void
documentRenderPaneRef?: MutableRefObject<HTMLDivElement | null>
markdownContentLines: string[]
onHeightChange?: (height: number) => void
}
export interface MarkdownDocumentProps extends RendererProps {
additionalOuterContainerClasses?: string
additionalRendererClasses?: string
disableToc?: boolean
baseUrl: string
}
/**
* Renders a markdown document and handles scrolling, yaml metadata and a floating table of contents.
*
* @param additionalOuterContainerClasses Additional classes given to the outer container directly
* @param additionalRendererClasses Additional classes given {@link DocumentMarkdownRenderer} directly
* @param onFirstHeadingChange The callback to call when the first heading changes.
* @param onMakeScrollSource The callback to call if a change of the scroll source is requested-
* @param onTaskCheckedChange The callback to call if a task get's checked or unchecked.
* @param baseUrl The base url for the renderer
* @param markdownContentLines The current content of the markdown document.
* @param onImageClick The callback to call if an image is clicked.
* @param onScroll The callback to call if the renderer is scrolling.
* @param scrollState The current {@link ScrollState}
* @param onHeightChange The callback to call if the height of the document changes
* @param disableToc If the table of contents should be disabled.
* @see https://markdown-it.github.io/
*/
export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
additionalOuterContainerClasses,
additionalRendererClasses,
onFirstHeadingChange,
onMakeScrollSource,
baseUrl,
markdownContentLines,
onScroll,
scrollState,
onHeightChange,
disableToc
}) => {
const rendererRef = useRef<HTMLDivElement | null>(null)
const [rendererSize, setRendererSize] = useState<DOMRectReadOnly>()
useResizeObserver(rendererRef.current, (entry) => {
setRendererSize(entry.contentRect)
})
useEffect(() => onHeightChange?.((rendererSize?.height ?? 0) + 1), [rendererSize, onHeightChange])
const internalDocumentRenderPaneRef = useRef<HTMLDivElement>(null)
const [internalDocumentRenderPaneSize, setInternalDocumentRenderPaneSize] = useState<DOMRectReadOnly>()
useResizeObserver(internalDocumentRenderPaneRef.current, (entry) =>
setInternalDocumentRenderPaneSize(entry.contentRect)
)
const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks)
const contentLineCount = useMemo(() => markdownContentLines.length, [markdownContentLines])
const [onLineMarkerPositionChanged, onUserScroll] = useDocumentSyncScrolling(
internalDocumentRenderPaneRef,
rendererRef,
contentLineCount,
scrollState,
onScroll
)
return (
<div
className={`${styles['markdown-document']} ${additionalOuterContainerClasses ?? ''}`}
ref={internalDocumentRenderPaneRef}
onScroll={onUserScroll}
data-scroll-element={true}
onMouseEnter={onMakeScrollSource}
onTouchStart={onMakeScrollSource}>
<div className={styles['markdown-document-side']} />
<div className={styles['markdown-document-content']}>
<DocumentMarkdownRenderer
outerContainerRef={rendererRef}
className={`mb-3 ${additionalRendererClasses ?? ''}`}
markdownContentLines={markdownContentLines}
onFirstHeadingChange={onFirstHeadingChange}
onLineMarkerPositionChanged={onLineMarkerPositionChanged}
baseUrl={baseUrl}
newlinesAreBreaks={newlinesAreBreaks}
/>
</div>
<DocumentTocSidebar
width={internalDocumentRenderPaneSize?.width ?? 0}
baseUrl={baseUrl}
disableToc={disableToc ?? false}
/>
</div>
)
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.markdown-toc-sidebar-button {
position: fixed;
right: 70px;
bottom: 30px;
& > :global(.dropup) {
position: sticky;
bottom: 20px;
right: 0;
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Dropdown } from 'react-bootstrap'
import type { TocAst } from 'markdown-it-toc-done-right'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { TableOfContents } from '../../editor-page/table-of-contents/table-of-contents'
import styles from './markdown-toc-button.module.scss'
export interface MarkdownTocButtonProps {
tocAst: TocAst
baseUrl: string
}
/**
* Renders a button that is hovering over the parent and shows a {@link TableOfContents table of contents list} as overlay if clicked.
*
* @param tocAst the {@link TocAst AST} that should be rendered.
* @param baseUrl the base url that will be used to generate the links
*/
export const TableOfContentsHoveringButton: React.FC<MarkdownTocButtonProps> = ({ tocAst, baseUrl }) => {
return (
<div className={styles['markdown-toc-sidebar-button']}>
<Dropdown drop={'up'}>
<Dropdown.Toggle id='toc-overlay-button' variant={'secondary'} className={'no-arrow'}>
<ForkAwesomeIcon icon={'list-ol'} />
</Dropdown.Toggle>
<Dropdown.Menu>
<div className={'p-2'}>
<TableOfContents ast={tocAst} baseUrl={baseUrl} />
</div>
</Dropdown.Menu>
</Dropdown>
</div>
)
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import type { TocAst } from 'markdown-it-toc-done-right'
import { TableOfContents } from '../editor-page/table-of-contents/table-of-contents'
import { TableOfContentsHoveringButton } from './markdown-toc-button/table-of-contents-hovering-button'
export interface DocumentExternalTocProps {
tocAst: TocAst
width: number
baseUrl: string
}
const MAX_WIDTH_FOR_BUTTON_VISIBILITY = 1100
/**
* Renders the {@link TableOfContents table of contents list} for the given {@link TocAst AST}.
* If the given width is below {@link MAX_WIDTH_FOR_BUTTON_VISIBILITY the width limit} then a {@link TableOfContentsHoveringButton button} with an overlay will be shown instead.
*
* @param tocAst the {@link TocAst AST} that should be rendered.
* @param width the width that should be used to determine if the button should be shown.
* @param baseUrl the base url that will be used to generate the links //TODO: replace with consumer/provider
*/
export const WidthBasedTableOfContents: React.FC<DocumentExternalTocProps> = ({ tocAst, width, baseUrl }) => {
if (width >= MAX_WIDTH_FOR_BUTTON_VISIBILITY) {
return <TableOfContents ast={tocAst} className={'sticky'} baseUrl={baseUrl} />
} else {
return <TableOfContentsHoveringButton tocAst={tocAst} baseUrl={baseUrl} />
}
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WindowPostMessageCommunicator } from './window-post-message-communicator'
import type {
CommunicationMessages,
EditorToRendererMessageType,
RendererToEditorMessageType
} from './rendering-message'
import { Logger } from '../../../utils/logger'
/**
* The communicator that is used to send messages from the editor to the renderer.
*/
export class EditorToRendererCommunicator extends WindowPostMessageCommunicator<
RendererToEditorMessageType,
EditorToRendererMessageType,
CommunicationMessages
> {
protected createLogger(): Logger {
return new Logger('EditorToRendererCommunicator')
}
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import type { CommunicationMessages, RendererToEditorMessageType } from '../rendering-message'
import { useEditorToRendererCommunicator } from '../../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
import type { Handler } from '../window-post-message-communicator'
/**
* Sets the handler for the given message type in the current editor to renderer communicator.
*
* @param messageType The message type that should be used to listen to.
* @param handler The handler that should be called if a message with the given message type was received.
*/
export const useEditorReceiveHandler = <R extends RendererToEditorMessageType>(
messageType: R,
handler?: Handler<CommunicationMessages, R>
): void => {
const editorToRendererCommunicator = useEditorToRendererCommunicator()
useEffect(() => {
if (!handler) {
return
}
editorToRendererCommunicator.on(messageType, handler)
return () => editorToRendererCommunicator.off(messageType, handler)
}, [editorToRendererCommunicator, handler, messageType])
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Extracts the ready status of the renderer from the global application state.
*
* @return The current ready status of the renderer.
*/
export const useIsRendererReady = (): boolean => useApplicationState((state) => state.rendererStatus.rendererReady)

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import type { CommunicationMessages, EditorToRendererMessageType } from '../rendering-message'
import type { Handler } from '../window-post-message-communicator'
import { useRendererToEditorCommunicator } from '../../../editor-page/render-context/renderer-to-editor-communicator-context-provider'
export type CommunicationMessageHandler<MESSAGE_TYPE extends EditorToRendererMessageType> = Handler<
CommunicationMessages,
MESSAGE_TYPE
>
/**
* Sets the handler for the given message type in the current renderer to editor communicator.
*
* @param messageType The message type that should be used to listen to.
* @param handler The handler that should be called if a message with the given message type was received.
*/
export const useRendererReceiveHandler = <MESSAGE_TYPE extends EditorToRendererMessageType>(
messageType: MESSAGE_TYPE,
handler: CommunicationMessageHandler<MESSAGE_TYPE>
): void => {
const editorToRendererCommunicator = useRendererToEditorCommunicator()
useEffect(() => {
editorToRendererCommunicator.on(messageType, handler)
return () => editorToRendererCommunicator.off(messageType, handler)
}, [editorToRendererCommunicator, handler, messageType])
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import type { CommunicationMessages, EditorToRendererMessageType } from '../rendering-message'
import { useEditorToRendererCommunicator } from '../../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
import type { MessagePayload } from '../window-post-message-communicator'
/**
* Sends the given message to the renderer.
*
* @param message The message to send
* @param rendererReady Defines if the target renderer is ready
*/
export const useSendToRenderer = (
message: undefined | Extract<CommunicationMessages, MessagePayload<EditorToRendererMessageType>>,
rendererReady: boolean
): void => {
const iframeCommunicator = useEditorToRendererCommunicator()
useEffect(() => {
if (message && rendererReady) {
iframeCommunicator.sendMessageToOtherSide(message)
}
}, [iframeCommunicator, message, rendererReady])
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WindowPostMessageCommunicator } from './window-post-message-communicator'
import type {
CommunicationMessages,
EditorToRendererMessageType,
RendererToEditorMessageType
} from './rendering-message'
import { Logger } from '../../../utils/logger'
/**
* The communicator that is used to send messages from the renderer to the editor.
*/
export class RendererToEditorCommunicator extends WindowPostMessageCommunicator<
EditorToRendererMessageType,
RendererToEditorMessageType,
CommunicationMessages
> {
protected createLogger(): Logger {
return new Logger('RendererToEditorCommunicator')
}
}

View file

@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ScrollState } from '../../editor-page/synced-scroll/scroll-props'
import type { SlideOptions } from '../../../redux/note-details/types/slide-show-options'
import type { DarkModePreference } from '../../../redux/dark-mode/types'
export enum CommunicationMessageType {
SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT',
RENDERER_READY = 'RENDERER_READY',
SET_DARKMODE = 'SET_DARKMODE',
ON_FIRST_HEADING_CHANGE = 'ON_FIRST_HEADING_CHANGE',
ENABLE_RENDERER_SCROLL_SOURCE = 'ENABLE_RENDERER_SCROLL_SOURCE',
DISABLE_RENDERER_SCROLL_SOURCE = 'DISABLE_RENDERER_SCROLL_SOURCE',
SET_SCROLL_STATE = 'SET_SCROLL_STATE',
ON_HEIGHT_CHANGE = 'ON_HEIGHT_CHANGE',
SET_BASE_CONFIGURATION = 'SET_BASE_CONFIGURATION',
GET_WORD_COUNT = 'GET_WORD_COUNT',
ON_WORD_COUNT_CALCULATED = 'ON_WORD_COUNT_CALCULATED',
SET_SLIDE_OPTIONS = 'SET_SLIDE_OPTIONS',
IMAGE_UPLOAD = 'IMAGE_UPLOAD',
EXTENSION_EVENT = 'EXTENSION_EVENT'
}
export interface NoPayloadMessage<TYPE extends CommunicationMessageType> {
type: TYPE
}
export interface SetDarkModeMessage {
type: CommunicationMessageType.SET_DARKMODE
preference: DarkModePreference
}
export interface ExtensionEvent {
type: CommunicationMessageType.EXTENSION_EVENT
eventName: string
payload: unknown
}
export interface ImageDetails {
alt?: string
src: string
title?: string
}
export interface ImageUploadMessage {
type: CommunicationMessageType.IMAGE_UPLOAD
dataUri: string
fileName: string
lineIndex?: number
placeholderIndexInLine?: number
}
export interface SetBaseUrlMessage {
type: CommunicationMessageType.SET_BASE_CONFIGURATION
baseConfiguration: BaseConfiguration
}
export interface GetWordCountMessage {
type: CommunicationMessageType.GET_WORD_COUNT
}
export interface SetMarkdownContentMessage {
type: CommunicationMessageType.SET_MARKDOWN_CONTENT
content: string[]
}
export interface SetScrollStateMessage {
type: CommunicationMessageType.SET_SCROLL_STATE
scrollState: ScrollState
}
export interface OnFirstHeadingChangeMessage {
type: CommunicationMessageType.ON_FIRST_HEADING_CHANGE
firstHeading: string | undefined
}
export interface SetSlideOptionsMessage {
type: CommunicationMessageType.SET_SLIDE_OPTIONS
slideOptions: SlideOptions
}
export interface OnHeightChangeMessage {
type: CommunicationMessageType.ON_HEIGHT_CHANGE
height: number
}
export interface OnWordCountCalculatedMessage {
type: CommunicationMessageType.ON_WORD_COUNT_CALCULATED
words: number
}
export type CommunicationMessages =
| NoPayloadMessage<CommunicationMessageType.RENDERER_READY>
| NoPayloadMessage<CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE>
| NoPayloadMessage<CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE>
| SetDarkModeMessage
| SetBaseUrlMessage
| GetWordCountMessage
| SetMarkdownContentMessage
| SetScrollStateMessage
| OnFirstHeadingChangeMessage
| SetSlideOptionsMessage
| OnHeightChangeMessage
| OnWordCountCalculatedMessage
| ImageUploadMessage
| ExtensionEvent
export type EditorToRendererMessageType =
| CommunicationMessageType.SET_MARKDOWN_CONTENT
| CommunicationMessageType.SET_DARKMODE
| CommunicationMessageType.SET_SCROLL_STATE
| CommunicationMessageType.SET_BASE_CONFIGURATION
| CommunicationMessageType.GET_WORD_COUNT
| CommunicationMessageType.SET_SLIDE_OPTIONS
| CommunicationMessageType.DISABLE_RENDERER_SCROLL_SOURCE
export type RendererToEditorMessageType =
| CommunicationMessageType.RENDERER_READY
| CommunicationMessageType.ENABLE_RENDERER_SCROLL_SOURCE
| CommunicationMessageType.ON_FIRST_HEADING_CHANGE
| CommunicationMessageType.SET_SCROLL_STATE
| CommunicationMessageType.ON_HEIGHT_CHANGE
| CommunicationMessageType.ON_WORD_COUNT_CALCULATED
| CommunicationMessageType.IMAGE_UPLOAD
| CommunicationMessageType.EXTENSION_EVENT
export enum RendererType {
DOCUMENT = 'document',
INTRO = 'intro',
SLIDESHOW = 'slideshow',
MOTD = 'motd'
}
export interface BaseConfiguration {
baseUrl: string
rendererType: RendererType
}

View file

@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Logger } from '../../../utils/logger'
import { Optional } from '@mrdrogdrog/optional'
import EventEmitter2 from 'eventemitter2'
/**
* Error that will be thrown if a message couldn't be sent.
*/
export class IframeCommunicatorSendingError extends Error {}
export type Handler<MESSAGES, MESSAGE_TYPE extends string> = (
values: Extract<MESSAGES, MessagePayload<MESSAGE_TYPE>>
) => void
export interface MessagePayloadWithUuid<MESSAGE_TYPE extends string> {
uuid: string
payload: MessagePayload<MESSAGE_TYPE>
}
export interface MessagePayload<MESSAGE_TYPE extends string> {
type: MESSAGE_TYPE
}
/**
* Base class for communication between renderer and editor.
*/
export abstract class WindowPostMessageCommunicator<
RECEIVE_TYPE extends string,
SEND_TYPE extends string,
MESSAGES extends MessagePayload<RECEIVE_TYPE | SEND_TYPE>
> {
private messageTarget?: Window
private communicationEnabled: boolean
private readonly emitter: EventEmitter2 = new EventEmitter2()
private readonly log: Logger
private readonly boundListener: (event: MessageEvent) => void
public constructor(private readonly uuid: string, private readonly targetOrigin: string) {
this.boundListener = this.handleEvent.bind(this)
this.communicationEnabled = false
this.log = this.createLogger()
}
public getUuid(): string {
return this.uuid
}
protected abstract createLogger(): Logger
/**
* Registers the event listener on the current global {@link window}.
*/
public registerEventListener(): void {
window.addEventListener('message', this.boundListener, { passive: true })
}
/**
* Removes the message event listener from the {@link window}.
*/
public unregisterEventListener(): void {
window.removeEventListener('message', this.boundListener)
}
/**
* Sets the target for message sending.
* Messages can be sent as soon as the communication is enabled.
*
* @param otherSide The target {@link Window} that should receive the messages.
* @see enableCommunication
*/
public setMessageTarget(otherSide: Window): void {
this.messageTarget = otherSide
this.communicationEnabled = false
}
/**
* Unsets the message target. Should be used if the old target isn't available anymore.
*/
public unsetMessageTarget(): void {
this.messageTarget = undefined
this.communicationEnabled = false
}
/**
* Enables the message communication.
* Should be called as soon as the other sides is ready to receive messages.
*/
public enableCommunication(): void {
this.communicationEnabled = true
}
/**
* Sends a message to the message target.
*
* @param message The message to send.
*/
public sendMessageToOtherSide(message: Extract<MESSAGES, MessagePayload<SEND_TYPE>>): void {
if (this.messageTarget === undefined || this.targetOrigin === undefined) {
throw new IframeCommunicatorSendingError(`Other side is not set.\nMessage was: ${JSON.stringify(message)}`)
}
if (!this.communicationEnabled) {
throw new IframeCommunicatorSendingError(
`Communication isn't enabled. Maybe the other side is not ready?\nMessage was: ${JSON.stringify(message)}`
)
}
this.log.debug('Sent event', message)
this.messageTarget.postMessage(
{
uuid: this.uuid,
payload: message
} as MessagePayloadWithUuid<SEND_TYPE>,
this.targetOrigin
)
}
/**
* Registers a handler for the given message type.
*
* @param messageType The message type for which the handler should be called
* @param handler The handler that processes messages with the given message type.
*/
public on<R extends RECEIVE_TYPE>(messageType: R, handler: Handler<MESSAGES, R>): void {
this.log.debug('Set handler for', messageType)
this.emitter.on(messageType, handler)
}
/**
* Deletes a handler for the given message type.
*
* @param messageType The message type for which the handler should be removed
* @param handler The handler that should be removed.
*/
public off<R extends RECEIVE_TYPE>(messageType: R, handler: Handler<MESSAGES, R>): void {
this.log.debug('Unset handler for', messageType)
this.emitter.off(messageType, handler)
}
/**
* Receives the message events and calls the handler that is mapped to the correct type.
*
* @param event The received event
* @return {@link true} if the event was processed.
*/
protected handleEvent(event: MessageEvent<MessagePayloadWithUuid<RECEIVE_TYPE>>): void {
if (event.origin !== this.targetOrigin) {
this.log.error(
`message declined. origin was "${event.origin}" but expected "${String(this.targetOrigin)}"`,
event.data
)
return
}
Optional.ofNullable(event.data)
.filter((value) => value.uuid === this.uuid)
.ifPresent((payload) => {
event.stopPropagation()
event.preventDefault()
this.emitter.emit(payload.payload.type, payload.payload)
})
}
}

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import wordsCount from 'words-count'
/** List of HTML tag names that should not be counted. */
const EXCLUDED_TAGS = ['img', 'pre', 'nav']
/** List of class names that should not be counted. */
const EXCLUDED_CLASSES = ['katex-mathml']
/**
* Checks whether the given node is an excluded HTML tag and therefore should be
* excluded from counting.
*
* @param node The node to test.
* @return {@link true} if the node should be excluded, {@link false} otherwise.
*/
const isExcludedTag = (node: Element | ChildNode): boolean => {
return EXCLUDED_TAGS.includes(node.nodeName.toLowerCase())
}
/**
* Checks whether the given node is a HTML element with an excluded class name,
* so that it should be excluded.
*
* @param node The node to test.
* @return {@link true} if the node should be excluded, {@link false} otherwise.
*/
const isExcludedClass = (node: Element | ChildNode): boolean => {
return EXCLUDED_CLASSES.some((excludedClass) => (node as HTMLElement).classList?.contains(excludedClass))
}
/**
* Counts the words of the given node while ignoring empty nodes and excluded
* nodes. Child nodes will recursively be counted as well.
*
* @param node The node whose content's words should be counted.
* @return The number of words counted in this node and its children.
*/
export const countWords = (node: Element | ChildNode): number => {
if (!node.textContent || isExcludedTag(node) || isExcludedClass(node)) {
return 0
}
if (!node.hasChildNodes()) {
return wordsCount(node.textContent)
}
return [...node.childNodes].reduce((words, childNode) => {
return words + countWords(childNode)
}, 0)
}