Move markdown split into redux (#1681)

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-12-14 10:16:25 +01:00 committed by GitHub
parent 71e668cd17
commit 6594e1bb86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 217 additions and 226 deletions

View file

@ -27,9 +27,8 @@ describe('The status bar text length info', () => {
cy.getById('remainingCharacters').should('have.class', 'text-danger')
})
it('shows a warning and opens a modal', () => {
it('opens a modal', () => {
cy.setCodemirrorContent(tooMuchTestContent)
cy.getById('limitReachedModal').should('be.visible')
cy.getIframeBody().findById('limitReachedMessage').should('be.visible')
})
})

View file

@ -7,17 +7,17 @@
import React, { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
import { useNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-note-markdown-content-without-frontmatter'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
import { DocumentInfobar } from './document-infobar'
import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
export const DocumentReadOnlyPageContent: React.FC = () => {
useTranslation()
const markdownContent = useNoteMarkdownContentWithoutFrontmatter()
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
const noteDetails = useApplicationState((state) => state.noteDetails)
useSendFrontmatterInfoFromReduxToRenderer()
@ -34,7 +34,7 @@ export const DocumentReadOnlyPageContent: React.FC = () => {
/>
<RenderIframe
frameClasses={'flex-fill h-100 w-100'}
markdownContent={markdownContent}
markdownContentLines={markdownContentLines}
onFirstHeadingChange={updateNoteTitleByFirstHeading}
rendererType={RendererType.DOCUMENT}
/>

View file

@ -4,11 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Suspense, useCallback } from 'react'
import React, { Suspense, useCallback, useMemo } from 'react'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
export interface CheatsheetLineProps {
code: string
markdown: string
onTaskCheckedChange: (newValue: boolean) => void
}
@ -17,7 +17,8 @@ const HighlightedCode = React.lazy(
)
const DocumentMarkdownRenderer = React.lazy(() => import('../../../markdown-renderer/document-markdown-renderer'))
export const CheatsheetLine: React.FC<CheatsheetLineProps> = ({ code, onTaskCheckedChange }) => {
export const CheatsheetLine: React.FC<CheatsheetLineProps> = ({ markdown, onTaskCheckedChange }) => {
const lines = useMemo(() => markdown.split('\n'), [markdown])
const checkboxClick = useCallback(
(lineInMarkdown: number, newValue: boolean) => {
onTaskCheckedChange(newValue)
@ -37,13 +38,13 @@ export const CheatsheetLine: React.FC<CheatsheetLineProps> = ({ code, onTaskChec
<tr>
<td>
<DocumentMarkdownRenderer
content={code}
markdownContentLines={lines}
baseUrl={'https://example.org'}
onTaskCheckedChange={checkboxClick}
/>
</td>
<td className={'markdown-body'}>
<HighlightedCode code={code} wrapLines={true} startLineNumber={1} language={'markdown'} />
<HighlightedCode code={markdown} wrapLines={true} startLineNumber={1} language={'markdown'} />
</td>
</tr>
</Suspense>

View file

@ -51,7 +51,7 @@ export const CheatsheetTabContent: React.FC = () => {
</thead>
<tbody>
{codes.map((code) => (
<CheatsheetLine code={code} key={code} onTaskCheckedChange={setChecked} />
<CheatsheetLine markdown={code} key={code} onTaskCheckedChange={setChecked} />
))}
</tbody>
</Table>

View file

@ -7,10 +7,10 @@
import React from 'react'
import type { RenderIframeProps } from '../renderer-pane/render-iframe'
import { RenderIframe } from '../renderer-pane/render-iframe'
import { useNoteMarkdownContentWithoutFrontmatter } from '../../../hooks/common/use-note-markdown-content-without-frontmatter'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
export type EditorDocumentRendererProps = Omit<RenderIframeProps, 'markdownContent'>
export type EditorDocumentRendererProps = Omit<RenderIframeProps, 'markdownContentLines'>
/**
* Renders the markdown content from the global application state with the iframe renderer.
@ -18,9 +18,8 @@ export type EditorDocumentRendererProps = Omit<RenderIframeProps, 'markdownConte
* @param props Every property from the {@link RenderIframe} except the markdown content.
*/
export const EditorDocumentRenderer: React.FC<EditorDocumentRendererProps> = (props) => {
const markdownContent = useNoteMarkdownContentWithoutFrontmatter()
useSendFrontmatterInfoFromReduxToRenderer()
const trimmedContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
return <RenderIframe markdownContent={markdownContent} {...props} />
return <RenderIframe {...props} markdownContentLines={trimmedContentLines} />
}

View file

@ -11,16 +11,16 @@ import { CommunicationMessageType } from '../../../render-page/window-post-messa
/**
* Sends the given markdown content to the renderer.
*
* @param markdownContent The markdown content to send.
* @param markdownContentLines The markdown content to send.
*/
export const useSendMarkdownToRenderer = (markdownContent: string): void => {
export const useSendMarkdownToRenderer = (markdownContentLines: string[]): void => {
return useSendToRenderer(
useMemo(
() => ({
type: CommunicationMessageType.SET_MARKDOWN_CONTENT,
content: markdownContent
content: markdownContentLines
}),
[markdownContent]
[markdownContentLines]
)
)
}

View file

@ -37,7 +37,7 @@ export interface RenderIframeProps extends RendererProps {
const log = new Logger('RenderIframe')
export const RenderIframe: React.FC<RenderIframeProps> = ({
markdownContent,
markdownContentLines,
onTaskCheckedChange,
scrollState,
onFirstHeadingChange,
@ -133,7 +133,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
useEffectOnRenderTypeChange(rendererType, onIframeLoad)
useSendScrollState(scrollState)
useSendDarkModeStatusToRenderer(forcedDarkMode)
useSendMarkdownToRenderer(markdownContent)
useSendMarkdownToRenderer(markdownContentLines)
return (
<Fragment>

View file

@ -9,14 +9,14 @@ import { useTranslation } from 'react-i18next'
import { fetchFrontPageContent } from '../requests'
import { useCustomizeAssetsUrl } from '../../../hooks/common/use-customize-assets-url'
export const useIntroPageContent = (): string | undefined => {
export const useIntroPageContent = (): string[] | undefined => {
const { t } = useTranslation()
const [content, setContent] = useState<string | undefined>(undefined)
const [content, setContent] = useState<string[] | undefined>(undefined)
const customizeAssetsUrl = useCustomizeAssetsUrl()
useEffect(() => {
fetchFrontPageContent(customizeAssetsUrl)
.then((content) => setContent(content))
.then((content) => setContent(content.split('\n')))
.catch(() => setContent(undefined))
}, [customizeAssetsUrl, t])

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import React, { useMemo } from 'react'
import { Trans } from 'react-i18next'
import { Branding } from '../common/branding/branding'
import {
@ -16,7 +16,6 @@ import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
import { CoverButtons } from './cover-buttons/cover-buttons'
import { FeatureLinks } from './feature-links'
import { useIntroPageContent } from './hooks/use-intro-page-content'
import { ShowIf } from '../common/show-if/show-if'
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message'
import { WaitSpinner } from '../common/wait-spinner/wait-spinner'
import { useApplicationState } from '../../hooks/common/use-application-state'
@ -26,6 +25,25 @@ export const IntroPage: React.FC = () => {
const introPageContent = useIntroPageContent()
const rendererReady = useApplicationState((state) => state.rendererStatus.rendererReady)
const spinner = useMemo(() => {
if (!rendererReady && introPageContent !== undefined) {
return <WaitSpinner />
}
}, [introPageContent, rendererReady])
const introContent = useMemo(() => {
if (introPageContent !== undefined) {
return (
<RenderIframe
frameClasses={'w-100 overflow-y-hidden'}
markdownContentLines={introPageContent}
rendererType={RendererType.INTRO}
forcedDarkMode={true}
/>
)
}
}, [introPageContent])
return (
<EditorToRendererCommunicatorContextProvider>
<div className={'flex-fill mt-3'}>
@ -39,17 +57,8 @@ export const IntroPage: React.FC = () => {
<Branding delimiter={false} />
</div>
<CoverButtons />
<ShowIf condition={!rendererReady && introPageContent !== undefined}>
<WaitSpinner />
</ShowIf>
<ShowIf condition={!!introPageContent}>
<RenderIframe
frameClasses={'w-100 overflow-y-hidden'}
markdownContent={introPageContent as string}
rendererType={RendererType.INTRO}
forcedDarkMode={true}
/>
</ShowIf>
{spinner}
{introContent}
<hr className={'mb-5'} />
</div>
<FeatureLinks />

View file

@ -18,5 +18,5 @@ export interface CommonMarkdownRendererProps {
newlinesAreBreaks?: boolean
lineOffset?: number
className?: string
content: string
markdownContentLines: string[]
}

View file

@ -1,27 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useApplicationState } from '../../hooks/common/use-application-state'
import { cypressId } from '../../utils/cypress-attribute'
import { ShowIf } from '../common/show-if/show-if'
import type { SimpleAlertProps } from '../common/simple-alert/simple-alert-props'
export const DocumentLengthLimitReachedAlert: React.FC<SimpleAlertProps> = ({ show }) => {
useTranslation()
const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
return (
<ShowIf condition={show}>
<Alert variant='danger' dir={'auto'} {...cypressId('limitReachedMessage')}>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }} />
</Alert>
</ShowIf>
)
}

View file

@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo, useRef } from 'react'
import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert'
import React, { useEffect, useMemo, useRef } from 'react'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import './markdown-renderer.scss'
import type { LineMarkerPosition } from './markdown-extension/linemarker/types'
@ -15,7 +14,6 @@ import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-po
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change'
import { useTrimmedContent } from './hooks/use-trimmed-content'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
import { HeadlineAnchorsMarkdownExtension } from './markdown-extension/headline-anchors-markdown-extension'
@ -26,7 +24,7 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro
export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({
className,
content,
markdownContentLines,
onFirstHeadingChange,
onLineMarkerPositionChanged,
onTaskCheckedChange,
@ -40,7 +38,6 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
const markdownBodyRef = useRef<HTMLDivElement>(null)
const currentLineMarkers = useRef<LineMarkers[]>()
const tocAst = useRef<TocAst>()
const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content)
const extensions = useMarkdownExtensions(
baseUrl,
@ -51,7 +48,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
onImageClick,
onTocChange
)
const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, extensions, newlinesAreBreaks)
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks)
useTranslation()
useCalculateLineMarkerPosition(
@ -60,12 +57,15 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
onLineMarkerPositionChanged,
markdownBodyRef.current?.offsetTop ?? 0
)
useExtractFirstHeadline(markdownBodyRef, content, onFirstHeadingChange)
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
useEffect(() => {
extractFirstHeadline()
}, [extractFirstHeadline, markdownContentLines])
useOnRefChange(tocAst, onTocChange)
return (
<div ref={outerContainerRef} className={'position-relative'}>
<DocumentLengthLimitReachedAlert show={contentExceedsLimit} />
<div
ref={markdownBodyRef}
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>

View file

@ -24,7 +24,7 @@ import { SanitizerMarkdownExtension } from '../markdown-extension/sanitizer/sani
* @return The React DOM that represents the rendered markdown code
*/
export const useConvertMarkdownToReactDom = (
markdownCode: string,
markdownContentLines: string[],
additionalMarkdownExtensions: MarkdownExtension[],
newlinesAreBreaks?: boolean
): ValidReactDomElement[] => {
@ -63,8 +63,8 @@ export const useConvertMarkdownToReactDom = (
}, [htmlToReactTransformer, markdownExtensions])
useMemo(() => {
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownCode))
}, [htmlToReactTransformer, lineNumberMapper, markdownCode])
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
}, [htmlToReactTransformer, lineNumberMapper, markdownContentLines])
const nodePreProcessor = useMemo(() => {
return markdownExtensions
@ -76,7 +76,7 @@ export const useConvertMarkdownToReactDom = (
}, [markdownExtensions])
return useMemo(() => {
const html = markdownIt.render(markdownCode)
const html = markdownIt.render(markdownContentLines.join('\n'))
htmlToReactTransformer.resetReplacers()
@ -84,5 +84,5 @@ export const useConvertMarkdownToReactDom = (
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: (document) => nodePreProcessor(document)
})
}, [htmlToReactTransformer, markdownCode, markdownIt, nodePreProcessor])
}, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
}

View file

@ -5,46 +5,65 @@
*/
import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useRef } from 'react'
/**
* Extracts the plain text content of a {@link ChildNode node}.
*
* @param node The node whose text content should be extracted.
* @return the plain text content
*/
const extractInnerText = (node: ChildNode | null): string => {
if (!node) {
return ''
} else if (isKatexMathMlElement(node)) {
return ''
} else if (node.childNodes && node.childNodes.length > 0) {
return extractInnerTextFromChildren(node)
} else if (node.nodeName.toLowerCase() === 'img') {
return (node as HTMLImageElement).getAttribute('alt') ?? ''
} else {
return node.textContent ?? ''
}
}
/**
* Determines if the given {@link ChildNode node} is the mathml part of a KaTeX rendering.
* @param node The node that might be a katex mathml element
*/
const isKatexMathMlElement = (node: ChildNode): boolean => (node as HTMLElement).classList?.contains('katex-mathml')
/**
* Extracts the text content of the children of the given {@link ChildNode node}.
* @param node The node whose children should be processed. The content of the node itself won't be included.
* @return the concatenated text content of the child nodes
*/
const extractInnerTextFromChildren = (node: ChildNode): string =>
Array.from(node.childNodes).reduce((state, child) => {
return state + extractInnerText(child)
}, '')
/**
* Extracts the plain text content of the first level 1 heading in the document.
*
* @param documentElement The root element of (sub)dom that should be inspected
* @param onFirstHeadingChange A callback that will be executed with the new level 1 heading
*/
export const useExtractFirstHeadline = (
documentElement: React.RefObject<HTMLDivElement>,
content: string | undefined,
onFirstHeadingChange?: (firstHeading: string | undefined) => void
): void => {
const extractInnerText = useCallback((node: ChildNode | null): string => {
if (!node) {
return ''
}
if ((node as HTMLElement).classList?.contains('katex-mathml')) {
return ''
}
let innerText = ''
if (node.childNodes && node.childNodes.length > 0) {
node.childNodes.forEach((child) => {
innerText += extractInnerText(child)
})
} else if (node.nodeName === 'IMG') {
innerText += (node as HTMLImageElement).getAttribute('alt')
} else {
innerText += node.textContent
}
return innerText
}, [])
): (() => void) => {
const lastFirstHeading = useRef<string | undefined>()
useEffect(() => {
if (onFirstHeadingChange && documentElement.current) {
const firstHeading = documentElement.current.getElementsByTagName('h1').item(0)
const headingText = extractInnerText(firstHeading).trim()
if (headingText !== lastFirstHeading.current) {
lastFirstHeading.current = headingText
onFirstHeadingChange(headingText)
}
return useCallback(() => {
if (!onFirstHeadingChange || !documentElement.current) {
return
}
}, [documentElement, extractInnerText, onFirstHeadingChange, content])
const firstHeading = documentElement.current.getElementsByTagName('h1').item(0)
const headingText = extractInnerText(firstHeading).trim()
if (headingText !== lastFirstHeading.current) {
lastFirstHeading.current = headingText
onFirstHeadingChange(headingText)
}
}, [documentElement, onFirstHeadingChange])
}

View file

@ -27,7 +27,7 @@ const initialSlideState: SlideState = {
indexVertical: 0
}
export const useReveal = (content: string, slideOptions?: SlideOptions): REVEAL_STATUS => {
export const useReveal = (markdownContentLines: string[], slideOptions?: SlideOptions): REVEAL_STATUS => {
const [deck, setDeck] = useState<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState)
@ -67,7 +67,7 @@ export const useReveal = (content: string, slideOptions?: SlideOptions): REVEAL_
log.debug('Sync deck')
deck.sync()
deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical)
}, [content, deck, revealStatus])
}, [markdownContentLines, deck, revealStatus])
useEffect(() => {
if (

View file

@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { useApplicationState } from '../../../hooks/common/use-application-state'
export const useTrimmedContent = (content: string): [trimmedContent: string, contentExceedsLimit: boolean] => {
const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
const contentExceedsLimit = content.length > maxLength
const trimmedContent = useMemo(
() => (contentExceedsLimit ? content.substr(0, maxLength) : content),
[content, contentExceedsLimit, maxLength]
)
return [trimmedContent, contentExceedsLimit]
}

View file

@ -4,17 +4,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo, useRef } from 'react'
import React, { useEffect, useMemo, useRef } from 'react'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import './markdown-renderer.scss'
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change'
import { useTrimmedContent } from './hooks/use-trimmed-content'
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
import './slideshow.scss'
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { LoadingSlide } from './loading-slide'
import { RevealMarkdownExtension } from './markdown-extension/reveal/reveal-markdown-extension'
@ -27,7 +25,7 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr
export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps & ScrollProps> = ({
className,
content,
markdownContentLines,
onFirstHeadingChange,
onTaskCheckedChange,
onTocChange,
@ -39,7 +37,6 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
}) => {
const markdownBodyRef = useRef<HTMLDivElement>(null)
const tocAst = useRef<TocAst>()
const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content)
const extensions = useMarkdownExtensions(
baseUrl,
@ -51,14 +48,18 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
onTocChange
)
const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, extensions, newlinesAreBreaks)
const revealStatus = useReveal(content, slideOptions)
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks)
const revealStatus = useReveal(markdownContentLines, slideOptions)
useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
useEffect(() => {
if (revealStatus === REVEAL_STATUS.INITIALISED) {
extractFirstHeadline()
}
}, [extractFirstHeadline, markdownContentLines, revealStatus])
useExtractFirstHeadline(
markdownBodyRef,
revealStatus === REVEAL_STATUS.INITIALISED ? content : undefined,
onFirstHeadingChange
)
useOnRefChange(tocAst, onTocChange)
const slideShowDOM = useMemo(
@ -67,14 +68,11 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
)
return (
<Fragment>
<DocumentLengthLimitReachedAlert show={contentExceedsLimit} />
<div className={'reveal'}>
<div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
{slideShowDOM}
</div>
<div className={'reveal'}>
<div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
{slideShowDOM}
</div>
</Fragment>
</div>
)
}

View file

@ -14,8 +14,8 @@ describe('line id mapper', () => {
})
it('should be case sensitive', () => {
lineIdMapper.updateLineMapping('this\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nText')).toEqual([
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'Text'])).toEqual([
{
line: 'this',
id: 1
@ -32,8 +32,8 @@ describe('line id mapper', () => {
})
it('should not update line ids of shifted lines', () => {
lineIdMapper.updateLineMapping('this\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nmore\ntext')).toEqual([
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'more', 'text'])).toEqual([
{
line: 'this',
id: 1
@ -54,8 +54,8 @@ describe('line id mapper', () => {
})
it('should not update line ids if nothing changes', () => {
lineIdMapper.updateLineMapping('this\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\ntext')).toEqual([
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'text'])).toEqual([
{
line: 'this',
id: 1
@ -72,9 +72,9 @@ describe('line id mapper', () => {
})
it('should not reuse line ids of removed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold')
lineIdMapper.updateLineMapping('this\nis')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
lineIdMapper.updateLineMapping(['this', 'is'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{
line: 'this',
id: 1
@ -91,8 +91,8 @@ describe('line id mapper', () => {
})
it('should update line ids for changed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{
line: 'this',
id: 1

View file

@ -23,12 +23,11 @@ export class LineIdMapper {
* Calculates a line id mapping for the given line based text by creating a diff
* with the last lines code.
*
* @param newText The new text for which the line ids should be calculated
* @param newMarkdownContentLines The markdown content for which the line ids should be calculated
* @return the calculated {@link LineWithId lines with unique ids}
*/
public updateLineMapping(newText: string): LineWithId[] {
const lines = newText.split('\n')
const lineDifferences = this.diffNewLinesWithLastLineKeys(lines)
public updateLineMapping(newMarkdownContentLines: string[]): LineWithId[] {
const lineDifferences = this.diffNewLinesWithLastLineKeys(newMarkdownContentLines)
const newLineKeys = this.convertChangesToLinesWithIds(lineDifferences)
this.lastLines = newLineKeys
return newLineKeys

View file

@ -20,7 +20,7 @@ import { initialState } from '../../redux/note-details/initial-state'
import type { RendererFrontmatterInfo } from '../../redux/note-details/types/note-details'
export const IframeMarkdownRenderer: React.FC = () => {
const [markdownContent, setMarkdownContent] = useState('')
const [markdownContentLines, setMarkdownContentLines] = useState<string[]>([])
const [scrollState, setScrollState] = useState<ScrollState>({ firstLineInView: 1, scrolledPercentage: 0 })
const [baseConfiguration, setBaseConfiguration] = useState<BaseConfiguration | undefined>(undefined)
const [frontmatterInfo, setFrontmatterInfo] = useState<RendererFrontmatterInfo>(initialState.frontmatterRendererInfo)
@ -39,7 +39,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
setBaseConfiguration(values.baseConfiguration)
)
useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) =>
setMarkdownContent(values.content)
setMarkdownContentLines(values.content)
)
useRendererReceiveHandler(CommunicationMessageType.SET_DARKMODE, (values) => setDarkMode(values.activated))
useRendererReceiveHandler(CommunicationMessageType.SET_SCROLL_STATE, (values) => setScrollState(values.scrollState))
@ -106,7 +106,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
return (
<MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light'}
markdownContent={markdownContent}
markdownContentLines={markdownContentLines}
onTaskCheckedChange={onTaskCheckedChange}
onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={onMakeScrollSource}
@ -120,7 +120,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
case RendererType.SLIDESHOW:
return (
<SlideshowMarkdownRenderer
content={markdownContent}
markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl}
onFirstHeadingChange={onFirstHeadingChange}
onImageClick={onImageClick}
@ -133,7 +133,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
return (
<MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light overflow-y-hidden'}
markdownContent={markdownContent}
markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl}
onImageClick={onImageClick}
disableToc={true}

View file

@ -24,7 +24,7 @@ export interface RendererProps extends ScrollProps {
onFirstHeadingChange?: (firstHeading: string | undefined) => void
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
documentRenderPaneRef?: MutableRefObject<HTMLDivElement | null>
markdownContent: string
markdownContentLines: string[]
onImageClick?: ImageClickHandler
onHeightChange?: (height: number) => void
}
@ -44,7 +44,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
onMakeScrollSource,
onTaskCheckedChange,
baseUrl,
markdownContent,
markdownContentLines,
onImageClick,
onScroll,
scrollState,
@ -70,7 +70,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
onHeightChange(rendererSize.height ? rendererSize.height + 1 : 0)
}, [rendererSize.height, onHeightChange])
const contentLineCount = useMemo(() => markdownContent.split('\n').length, [markdownContent])
const contentLineCount = useMemo(() => markdownContentLines.length, [markdownContentLines])
const [onLineMarkerPositionChanged, onUserScroll] = useDocumentSyncScrolling(
internalDocumentRenderPaneRef,
rendererRef,
@ -92,7 +92,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
<DocumentMarkdownRenderer
outerContainerRef={rendererRef}
className={`mb-3 ${additionalRendererClasses ?? ''}`}
content={markdownContent}
markdownContentLines={markdownContentLines}
onFirstHeadingChange={onFirstHeadingChange}
onLineMarkerPositionChanged={onLineMarkerPositionChanged}
onTaskCheckedChange={onTaskCheckedChange}

View file

@ -62,7 +62,7 @@ export interface ImageClickedMessage {
export interface SetMarkdownContentMessage {
type: CommunicationMessageType.SET_MARKDOWN_CONTENT
content: string
content: string[]
}
export interface SetScrollStateMessage {

View file

@ -10,10 +10,10 @@ import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
import { useTranslation } from 'react-i18next'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
import { useNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-note-markdown-content-without-frontmatter'
import { useTrimmedNoteMarkdownContentWithoutFrontmatter } from '../../hooks/common/use-trimmed-note-markdown-content-without-frontmatter'
export const SlideShowPageContent: React.FC = () => {
const markdownContent = useNoteMarkdownContentWithoutFrontmatter()
const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
useTranslation()
useSendFrontmatterInfoFromReduxToRenderer()
@ -21,7 +21,7 @@ export const SlideShowPageContent: React.FC = () => {
<div className={'vh-100 vw-100'}>
<RenderIframe
frameClasses={'h-100 w-100'}
markdownContent={markdownContent}
markdownContentLines={markdownContentLines}
rendererType={RendererType.SLIDESHOW}
onFirstHeadingChange={updateNoteTitleByFirstHeading}
/>

View file

@ -1,20 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useNoteMarkdownContent } from './use-note-markdown-content'
import { useApplicationState } from './use-application-state'
import { useMemo } from 'react'
/**
* Extracts the markdown content of the current note from the global application state and removes the frontmatter.
* @return the markdown content of the note without frontmatter
*/
export const useNoteMarkdownContentWithoutFrontmatter = (): string => {
const markdownContent = useNoteMarkdownContent()
const lineOffset = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo.lineOffset)
return useMemo(() => markdownContent.split('\n').slice(lineOffset).join('\n'), [markdownContent, lineOffset])
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import { useApplicationState } from './use-application-state'
import { useNoteMarkdownContent } from './use-note-markdown-content'
export const useTrimmedNoteMarkdownContentWithoutFrontmatter = (): string[] => {
const maxLength = useApplicationState((state) => state.config.maxDocumentLength)
const markdownContent = useNoteMarkdownContent()
const markdownContentLines = useApplicationState((state) => state.noteDetails.markdownContentLines)
const lineOffset = useApplicationState((state) => state.noteDetails.frontmatterRendererInfo.lineOffset)
const trimmedLines = useMemo(() => {
if (markdownContent.length > maxLength) {
return markdownContent.slice(0, maxLength).split('\n')
} else {
return markdownContentLines
}
}, [markdownContent, markdownContentLines, maxLength])
return useMemo(() => {
return trimmedLines.slice(lineOffset)
}, [lineOffset, trimmedLines])
}

View file

@ -10,47 +10,47 @@ import type { PresentFrontmatterExtractionResult } from './types'
describe('frontmatter extraction', () => {
describe('isPresent property', () => {
it('is false when note does not contain three dashes at all', () => {
const testNote = 'abcdef\nmore text'
const testNote = ['abcdef', 'more text']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is false when note does not start with three dashes', () => {
const testNote = '\n---\nthis is not frontmatter'
const testNote = ['', '---', 'this is not frontmatter']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is false when note start with less than three dashes', () => {
const testNote = '--\nthis is not frontmatter'
const testNote = ['--', 'this is not frontmatter']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is false when note starts with three dashes but contains other characters in the same line', () => {
const testNote = '--- a\nthis is not frontmatter'
const testNote = ['--- a', 'this is not frontmatter']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is false when note has no ending marker for frontmatter', () => {
const testNote = '---\nthis is not frontmatter\nbecause\nthere is no\nend marker'
const testNote = ['---', 'this is not frontmatter', 'because', 'there is no', 'end marker']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is false when note end marker is present but with not the same amount of dashes as start marker', () => {
const testNote = '---\nthis is not frontmatter\n----\ncontent'
const testNote = ['---', 'this is not frontmatter', '----', 'content']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false)
})
it('is true when note end marker is present with the same amount of dashes as start marker', () => {
const testNote = '---\nthis is frontmatter\n---\ncontent'
const testNote = ['---', 'this is frontmatter', '---', 'content']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true)
})
it('is true when note end marker is present with the same amount of dashes as start marker but without content', () => {
const testNote = '---\nthis is frontmatter\n---'
const testNote = ['---', 'this is frontmatter', '---']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true)
})
it('is true when note end marker is present with the same amount of dots as start marker', () => {
const testNote = '---\nthis is frontmatter\n...\ncontent'
const testNote = ['---', 'this is frontmatter', '...', 'content']
const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true)
})
@ -58,22 +58,22 @@ describe('frontmatter extraction', () => {
describe('lineOffset property', () => {
it('is correct for single line frontmatter without content', () => {
const testNote = '---\nsingle line frontmatter\n...'
const testNote = ['---', 'single line frontmatter', '...']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(3)
})
it('is correct for single line frontmatter with content', () => {
const testNote = '---\nsingle line frontmatter\n...\ncontent'
const testNote = ['---', 'single line frontmatter', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(3)
})
it('is correct for multi-line frontmatter without content', () => {
const testNote = '---\nabc\n123\ndef\n...'
const testNote = ['---', 'abc', '123', 'def', '...']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(5)
})
it('is correct for multi-line frontmatter with content', () => {
const testNote = '---\nabc\n123\ndef\n...\ncontent'
const testNote = ['---', 'abc', '123', 'def', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(5)
})
@ -81,12 +81,12 @@ describe('frontmatter extraction', () => {
describe('rawText property', () => {
it('contains single-line frontmatter text', () => {
const testNote = '---\nsingle-line\n...\ncontent'
const testNote = ['---', 'single-line', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.rawText).toEqual('single-line')
})
it('contains multi-line frontmatter text', () => {
const testNote = '---\nmulti\nline\n...\ncontent'
const testNote = ['---', 'multi', 'line', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.rawText).toEqual('multi\nline')
})

View file

@ -12,14 +12,13 @@ const FRONTMATTER_END_REGEX = /^(?:-{3,}|\.{3,})$/
* Extracts a frontmatter block from a given multiline string.
* A valid frontmatter block requires the content to start with a line containing at least three dashes.
* The block is terminated by a line containing the same amount of dashes or dots as the first line.
* @param content The multiline string from which the frontmatter should be extracted.
* @param lines The lines from which the frontmatter should be extracted.
* @return { isPresent } false if no frontmatter block could be found, true if a block was found.
* { rawFrontmatterText } if a block was found, this property contains the extracted text without the fencing.
* { frontmatterLines } if a block was found, this property contains the number of lines to skip from the
* given multiline string for retrieving the non-frontmatter content.
*/
export const extractFrontmatter = (content: string): FrontmatterExtractionResult => {
const lines = content.split('\n')
export const extractFrontmatter = (lines: string[]): FrontmatterExtractionResult => {
if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) {
return {
isPresent: false

View file

@ -19,6 +19,7 @@ export const initialSlideOptions: SlideOptions = {
export const initialState: NoteDetails = {
markdownContent: '',
markdownContentLines: [],
rawFrontmatter: '',
frontmatterRendererInfo: {
frontmatterInvalid: false,

View file

@ -74,7 +74,7 @@ const buildStateFromTaskListUpdate = (
changedLine: number,
checkboxChecked: boolean
): NoteDetails => {
const lines = state.markdownContent.split('\n')
const lines = state.markdownContentLines
const results = TASK_REGEX.exec(lines[changedLine])
if (results) {
const before = results[1]
@ -88,23 +88,26 @@ const buildStateFromTaskListUpdate = (
/**
* Builds a {@link NoteDetails} redux state from a fresh document content.
* @param state The previous redux state.
* @param markdownContent The fresh document content consisting of the frontmatter and markdown part.
* @param newMarkdownContent The fresh document content consisting of the frontmatter and markdown part.
* @return An updated {@link NoteDetails} redux state.
*/
const buildStateFromMarkdownContentUpdate = (state: NoteDetails, markdownContent: string): NoteDetails => {
const frontmatterExtraction = extractFrontmatter(markdownContent)
const buildStateFromMarkdownContentUpdate = (state: NoteDetails, newMarkdownContent: string): NoteDetails => {
const markdownContentLines = newMarkdownContent.split('\n')
const frontmatterExtraction = extractFrontmatter(markdownContentLines)
if (frontmatterExtraction.isPresent) {
return buildStateFromFrontmatterUpdate(
{
...state,
markdownContent: markdownContent
markdownContent: newMarkdownContent,
markdownContentLines: markdownContentLines
},
frontmatterExtraction
)
} else {
return {
...state,
markdownContent: markdownContent,
markdownContent: newMarkdownContent,
markdownContentLines: markdownContentLines,
rawFrontmatter: '',
noteTitle: generateNoteTitle(initialState.frontmatter, state.firstHeading),
frontmatter: initialState.frontmatter,
@ -193,6 +196,7 @@ const generateNoteTitle = (frontmatter: NoteFrontmatter, firstHeading?: string)
const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
return {
markdownContent: note.content,
markdownContentLines: note.content.split('\n'),
rawFrontmatter: '',
frontmatterRendererInfo: initialState.frontmatterRendererInfo,
frontmatter: initialState.frontmatter,

View file

@ -13,6 +13,7 @@ import type { ISO6391 } from './iso6391'
*/
export interface NoteDetails {
markdownContent: string
markdownContentLines: string[]
rawFrontmatter: string
frontmatter: NoteFrontmatter
frontmatterRendererInfo: RendererFrontmatterInfo