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') cy.getById('remainingCharacters').should('have.class', 'text-danger')
}) })
it('shows a warning and opens a modal', () => { it('opens a modal', () => {
cy.setCodemirrorContent(tooMuchTestContent) cy.setCodemirrorContent(tooMuchTestContent)
cy.getById('limitReachedModal').should('be.visible') 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 React, { Fragment } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods' 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 { useApplicationState } from '../../hooks/common/use-application-state'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer'
import { DocumentInfobar } from './document-infobar' import { DocumentInfobar } from './document-infobar'
import { RenderIframe } from '../editor-page/renderer-pane/render-iframe' import { RenderIframe } from '../editor-page/renderer-pane/render-iframe'
import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' 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 = () => { export const DocumentReadOnlyPageContent: React.FC = () => {
useTranslation() useTranslation()
const markdownContent = useNoteMarkdownContentWithoutFrontmatter() const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
const noteDetails = useApplicationState((state) => state.noteDetails) const noteDetails = useApplicationState((state) => state.noteDetails)
useSendFrontmatterInfoFromReduxToRenderer() useSendFrontmatterInfoFromReduxToRenderer()
@ -34,7 +34,7 @@ export const DocumentReadOnlyPageContent: React.FC = () => {
/> />
<RenderIframe <RenderIframe
frameClasses={'flex-fill h-100 w-100'} frameClasses={'flex-fill h-100 w-100'}
markdownContent={markdownContent} markdownContentLines={markdownContentLines}
onFirstHeadingChange={updateNoteTitleByFirstHeading} onFirstHeadingChange={updateNoteTitleByFirstHeading}
rendererType={RendererType.DOCUMENT} rendererType={RendererType.DOCUMENT}
/> />

View file

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

View file

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

View file

@ -7,10 +7,10 @@
import React from 'react' import React from 'react'
import type { RenderIframeProps } from '../renderer-pane/render-iframe' import type { RenderIframeProps } from '../renderer-pane/render-iframe'
import { RenderIframe } 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 { 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. * 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. * @param props Every property from the {@link RenderIframe} except the markdown content.
*/ */
export const EditorDocumentRenderer: React.FC<EditorDocumentRendererProps> = (props) => { export const EditorDocumentRenderer: React.FC<EditorDocumentRendererProps> = (props) => {
const markdownContent = useNoteMarkdownContentWithoutFrontmatter()
useSendFrontmatterInfoFromReduxToRenderer() 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. * 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( return useSendToRenderer(
useMemo( useMemo(
() => ({ () => ({
type: CommunicationMessageType.SET_MARKDOWN_CONTENT, 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') const log = new Logger('RenderIframe')
export const RenderIframe: React.FC<RenderIframeProps> = ({ export const RenderIframe: React.FC<RenderIframeProps> = ({
markdownContent, markdownContentLines,
onTaskCheckedChange, onTaskCheckedChange,
scrollState, scrollState,
onFirstHeadingChange, onFirstHeadingChange,
@ -133,7 +133,7 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
useEffectOnRenderTypeChange(rendererType, onIframeLoad) useEffectOnRenderTypeChange(rendererType, onIframeLoad)
useSendScrollState(scrollState) useSendScrollState(scrollState)
useSendDarkModeStatusToRenderer(forcedDarkMode) useSendDarkModeStatusToRenderer(forcedDarkMode)
useSendMarkdownToRenderer(markdownContent) useSendMarkdownToRenderer(markdownContentLines)
return ( return (
<Fragment> <Fragment>

View file

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

View file

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

View file

@ -18,5 +18,5 @@ export interface CommonMarkdownRendererProps {
newlinesAreBreaks?: boolean newlinesAreBreaks?: boolean
lineOffset?: number lineOffset?: number
className?: string 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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React, { useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom' import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import './markdown-renderer.scss' import './markdown-renderer.scss'
import type { LineMarkerPosition } from './markdown-extension/linemarker/types' 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 { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right' import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change' import { useOnRefChange } from './hooks/use-on-ref-change'
import { useTrimmedContent } from './hooks/use-trimmed-content'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props' import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { useMarkdownExtensions } from './hooks/use-markdown-extensions' import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
import { HeadlineAnchorsMarkdownExtension } from './markdown-extension/headline-anchors-markdown-extension' import { HeadlineAnchorsMarkdownExtension } from './markdown-extension/headline-anchors-markdown-extension'
@ -26,7 +24,7 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro
export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({
className, className,
content, markdownContentLines,
onFirstHeadingChange, onFirstHeadingChange,
onLineMarkerPositionChanged, onLineMarkerPositionChanged,
onTaskCheckedChange, onTaskCheckedChange,
@ -40,7 +38,6 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
const markdownBodyRef = useRef<HTMLDivElement>(null) const markdownBodyRef = useRef<HTMLDivElement>(null)
const currentLineMarkers = useRef<LineMarkers[]>() const currentLineMarkers = useRef<LineMarkers[]>()
const tocAst = useRef<TocAst>() const tocAst = useRef<TocAst>()
const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content)
const extensions = useMarkdownExtensions( const extensions = useMarkdownExtensions(
baseUrl, baseUrl,
@ -51,7 +48,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
onImageClick, onImageClick,
onTocChange onTocChange
) )
const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, extensions, newlinesAreBreaks) const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks)
useTranslation() useTranslation()
useCalculateLineMarkerPosition( useCalculateLineMarkerPosition(
@ -60,12 +57,15 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
onLineMarkerPositionChanged, onLineMarkerPositionChanged,
markdownBodyRef.current?.offsetTop ?? 0 markdownBodyRef.current?.offsetTop ?? 0
) )
useExtractFirstHeadline(markdownBodyRef, content, onFirstHeadingChange) const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
useEffect(() => {
extractFirstHeadline()
}, [extractFirstHeadline, markdownContentLines])
useOnRefChange(tocAst, onTocChange) useOnRefChange(tocAst, onTocChange)
return ( return (
<div ref={outerContainerRef} className={'position-relative'}> <div ref={outerContainerRef} className={'position-relative'}>
<DocumentLengthLimitReachedAlert show={contentExceedsLimit} />
<div <div
ref={markdownBodyRef} ref={markdownBodyRef}
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}> 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 * @return The React DOM that represents the rendered markdown code
*/ */
export const useConvertMarkdownToReactDom = ( export const useConvertMarkdownToReactDom = (
markdownCode: string, markdownContentLines: string[],
additionalMarkdownExtensions: MarkdownExtension[], additionalMarkdownExtensions: MarkdownExtension[],
newlinesAreBreaks?: boolean newlinesAreBreaks?: boolean
): ValidReactDomElement[] => { ): ValidReactDomElement[] => {
@ -63,8 +63,8 @@ export const useConvertMarkdownToReactDom = (
}, [htmlToReactTransformer, markdownExtensions]) }, [htmlToReactTransformer, markdownExtensions])
useMemo(() => { useMemo(() => {
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownCode)) htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
}, [htmlToReactTransformer, lineNumberMapper, markdownCode]) }, [htmlToReactTransformer, lineNumberMapper, markdownContentLines])
const nodePreProcessor = useMemo(() => { const nodePreProcessor = useMemo(() => {
return markdownExtensions return markdownExtensions
@ -76,7 +76,7 @@ export const useConvertMarkdownToReactDom = (
}, [markdownExtensions]) }, [markdownExtensions])
return useMemo(() => { return useMemo(() => {
const html = markdownIt.render(markdownCode) const html = markdownIt.render(markdownContentLines.join('\n'))
htmlToReactTransformer.resetReplacers() htmlToReactTransformer.resetReplacers()
@ -84,5 +84,5 @@ export const useConvertMarkdownToReactDom = (
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index), transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: (document) => nodePreProcessor(document) preprocessNodes: (document) => nodePreProcessor(document)
}) })
}, [htmlToReactTransformer, markdownCode, markdownIt, nodePreProcessor]) }, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
} }

View file

@ -5,46 +5,65 @@
*/ */
import type React from 'react' 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 = ( export const useExtractFirstHeadline = (
documentElement: React.RefObject<HTMLDivElement>, documentElement: React.RefObject<HTMLDivElement>,
content: string | undefined,
onFirstHeadingChange?: (firstHeading: string | undefined) => void onFirstHeadingChange?: (firstHeading: string | undefined) => void
): 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
}, [])
const lastFirstHeading = useRef<string | undefined>() const lastFirstHeading = useRef<string | undefined>()
useEffect(() => { return useCallback(() => {
if (onFirstHeadingChange && documentElement.current) { if (!onFirstHeadingChange || !documentElement.current) {
const firstHeading = documentElement.current.getElementsByTagName('h1').item(0) return
const headingText = extractInnerText(firstHeading).trim()
if (headingText !== lastFirstHeading.current) {
lastFirstHeading.current = headingText
onFirstHeadingChange(headingText)
}
} }
}, [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 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 [deck, setDeck] = useState<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED) const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState) const currentSlideState = useRef<SlideState>(initialSlideState)
@ -67,7 +67,7 @@ export const useReveal = (content: string, slideOptions?: SlideOptions): REVEAL_
log.debug('Sync deck') log.debug('Sync deck')
deck.sync() deck.sync()
deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical) deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical)
}, [content, deck, revealStatus]) }, [markdownContentLines, deck, revealStatus])
useEffect(() => { useEffect(() => {
if ( 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 * 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 { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import './markdown-renderer.scss' import './markdown-renderer.scss'
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right' import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change' import { useOnRefChange } from './hooks/use-on-ref-change'
import { useTrimmedContent } from './hooks/use-trimmed-content'
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal' import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
import './slideshow.scss' import './slideshow.scss'
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props' 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 type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { LoadingSlide } from './loading-slide' import { LoadingSlide } from './loading-slide'
import { RevealMarkdownExtension } from './markdown-extension/reveal/reveal-markdown-extension' 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> = ({ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps & ScrollProps> = ({
className, className,
content, markdownContentLines,
onFirstHeadingChange, onFirstHeadingChange,
onTaskCheckedChange, onTaskCheckedChange,
onTocChange, onTocChange,
@ -39,7 +37,6 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
}) => { }) => {
const markdownBodyRef = useRef<HTMLDivElement>(null) const markdownBodyRef = useRef<HTMLDivElement>(null)
const tocAst = useRef<TocAst>() const tocAst = useRef<TocAst>()
const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content)
const extensions = useMarkdownExtensions( const extensions = useMarkdownExtensions(
baseUrl, baseUrl,
@ -51,14 +48,18 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
onTocChange onTocChange
) )
const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, extensions, newlinesAreBreaks) const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks)
const revealStatus = useReveal(content, slideOptions) 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) useOnRefChange(tocAst, onTocChange)
const slideShowDOM = useMemo( const slideShowDOM = useMemo(
@ -67,14 +68,11 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
) )
return ( return (
<Fragment> <div className={'reveal'}>
<DocumentLengthLimitReachedAlert show={contentExceedsLimit} /> <div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
<div className={'reveal'}> {slideShowDOM}
<div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
{slideShowDOM}
</div>
</div> </div>
</Fragment> </div>
) )
} }

View file

@ -14,8 +14,8 @@ describe('line id mapper', () => {
}) })
it('should be case sensitive', () => { it('should be case sensitive', () => {
lineIdMapper.updateLineMapping('this\nis\ntext') lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping('this\nis\nText')).toEqual([ expect(lineIdMapper.updateLineMapping(['this', 'is', 'Text'])).toEqual([
{ {
line: 'this', line: 'this',
id: 1 id: 1
@ -32,8 +32,8 @@ describe('line id mapper', () => {
}) })
it('should not update line ids of shifted lines', () => { it('should not update line ids of shifted lines', () => {
lineIdMapper.updateLineMapping('this\nis\ntext') lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping('this\nis\nmore\ntext')).toEqual([ expect(lineIdMapper.updateLineMapping(['this', 'is', 'more', 'text'])).toEqual([
{ {
line: 'this', line: 'this',
id: 1 id: 1
@ -54,8 +54,8 @@ describe('line id mapper', () => {
}) })
it('should not update line ids if nothing changes', () => { it('should not update line ids if nothing changes', () => {
lineIdMapper.updateLineMapping('this\nis\ntext') lineIdMapper.updateLineMapping(['this', 'is', 'text'])
expect(lineIdMapper.updateLineMapping('this\nis\ntext')).toEqual([ expect(lineIdMapper.updateLineMapping(['this', 'is', 'text'])).toEqual([
{ {
line: 'this', line: 'this',
id: 1 id: 1
@ -72,9 +72,9 @@ describe('line id mapper', () => {
}) })
it('should not reuse line ids of removed lines', () => { it('should not reuse line ids of removed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold') lineIdMapper.updateLineMapping(['this', 'is', 'old'])
lineIdMapper.updateLineMapping('this\nis') lineIdMapper.updateLineMapping(['this', 'is'])
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([ expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{ {
line: 'this', line: 'this',
id: 1 id: 1
@ -91,8 +91,8 @@ describe('line id mapper', () => {
}) })
it('should update line ids for changed lines', () => { it('should update line ids for changed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold') lineIdMapper.updateLineMapping(['this', 'is', 'old'])
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([ expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
{ {
line: 'this', line: 'this',
id: 1 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 * Calculates a line id mapping for the given line based text by creating a diff
* with the last lines code. * 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} * @return the calculated {@link LineWithId lines with unique ids}
*/ */
public updateLineMapping(newText: string): LineWithId[] { public updateLineMapping(newMarkdownContentLines: string[]): LineWithId[] {
const lines = newText.split('\n') const lineDifferences = this.diffNewLinesWithLastLineKeys(newMarkdownContentLines)
const lineDifferences = this.diffNewLinesWithLastLineKeys(lines)
const newLineKeys = this.convertChangesToLinesWithIds(lineDifferences) const newLineKeys = this.convertChangesToLinesWithIds(lineDifferences)
this.lastLines = newLineKeys this.lastLines = newLineKeys
return 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' import type { RendererFrontmatterInfo } from '../../redux/note-details/types/note-details'
export const IframeMarkdownRenderer: React.FC = () => { export const IframeMarkdownRenderer: React.FC = () => {
const [markdownContent, setMarkdownContent] = useState('') const [markdownContentLines, setMarkdownContentLines] = useState<string[]>([])
const [scrollState, setScrollState] = useState<ScrollState>({ firstLineInView: 1, scrolledPercentage: 0 }) const [scrollState, setScrollState] = useState<ScrollState>({ firstLineInView: 1, scrolledPercentage: 0 })
const [baseConfiguration, setBaseConfiguration] = useState<BaseConfiguration | undefined>(undefined) const [baseConfiguration, setBaseConfiguration] = useState<BaseConfiguration | undefined>(undefined)
const [frontmatterInfo, setFrontmatterInfo] = useState<RendererFrontmatterInfo>(initialState.frontmatterRendererInfo) const [frontmatterInfo, setFrontmatterInfo] = useState<RendererFrontmatterInfo>(initialState.frontmatterRendererInfo)
@ -39,7 +39,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
setBaseConfiguration(values.baseConfiguration) setBaseConfiguration(values.baseConfiguration)
) )
useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) => useRendererReceiveHandler(CommunicationMessageType.SET_MARKDOWN_CONTENT, (values) =>
setMarkdownContent(values.content) setMarkdownContentLines(values.content)
) )
useRendererReceiveHandler(CommunicationMessageType.SET_DARKMODE, (values) => setDarkMode(values.activated)) useRendererReceiveHandler(CommunicationMessageType.SET_DARKMODE, (values) => setDarkMode(values.activated))
useRendererReceiveHandler(CommunicationMessageType.SET_SCROLL_STATE, (values) => setScrollState(values.scrollState)) useRendererReceiveHandler(CommunicationMessageType.SET_SCROLL_STATE, (values) => setScrollState(values.scrollState))
@ -106,7 +106,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
return ( return (
<MarkdownDocument <MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light'} additionalOuterContainerClasses={'vh-100 bg-light'}
markdownContent={markdownContent} markdownContentLines={markdownContentLines}
onTaskCheckedChange={onTaskCheckedChange} onTaskCheckedChange={onTaskCheckedChange}
onFirstHeadingChange={onFirstHeadingChange} onFirstHeadingChange={onFirstHeadingChange}
onMakeScrollSource={onMakeScrollSource} onMakeScrollSource={onMakeScrollSource}
@ -120,7 +120,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
case RendererType.SLIDESHOW: case RendererType.SLIDESHOW:
return ( return (
<SlideshowMarkdownRenderer <SlideshowMarkdownRenderer
content={markdownContent} markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl} baseUrl={baseConfiguration.baseUrl}
onFirstHeadingChange={onFirstHeadingChange} onFirstHeadingChange={onFirstHeadingChange}
onImageClick={onImageClick} onImageClick={onImageClick}
@ -133,7 +133,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
return ( return (
<MarkdownDocument <MarkdownDocument
additionalOuterContainerClasses={'vh-100 bg-light overflow-y-hidden'} additionalOuterContainerClasses={'vh-100 bg-light overflow-y-hidden'}
markdownContent={markdownContent} markdownContentLines={markdownContentLines}
baseUrl={baseConfiguration.baseUrl} baseUrl={baseConfiguration.baseUrl}
onImageClick={onImageClick} onImageClick={onImageClick}
disableToc={true} disableToc={true}

View file

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

View file

@ -62,7 +62,7 @@ export interface ImageClickedMessage {
export interface SetMarkdownContentMessage { export interface SetMarkdownContentMessage {
type: CommunicationMessageType.SET_MARKDOWN_CONTENT type: CommunicationMessageType.SET_MARKDOWN_CONTENT
content: string content: string[]
} }
export interface SetScrollStateMessage { 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 { updateNoteTitleByFirstHeading } from '../../redux/note-details/methods'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSendFrontmatterInfoFromReduxToRenderer } from '../editor-page/renderer-pane/hooks/use-send-frontmatter-info-from-redux-to-renderer' 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 = () => { export const SlideShowPageContent: React.FC = () => {
const markdownContent = useNoteMarkdownContentWithoutFrontmatter() const markdownContentLines = useTrimmedNoteMarkdownContentWithoutFrontmatter()
useTranslation() useTranslation()
useSendFrontmatterInfoFromReduxToRenderer() useSendFrontmatterInfoFromReduxToRenderer()
@ -21,7 +21,7 @@ export const SlideShowPageContent: React.FC = () => {
<div className={'vh-100 vw-100'}> <div className={'vh-100 vw-100'}>
<RenderIframe <RenderIframe
frameClasses={'h-100 w-100'} frameClasses={'h-100 w-100'}
markdownContent={markdownContent} markdownContentLines={markdownContentLines}
rendererType={RendererType.SLIDESHOW} rendererType={RendererType.SLIDESHOW}
onFirstHeadingChange={updateNoteTitleByFirstHeading} 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('frontmatter extraction', () => {
describe('isPresent property', () => { describe('isPresent property', () => {
it('is false when note does not contain three dashes at all', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) expect(extraction.isPresent).toBe(false)
}) })
it('is false when note does not start with three dashes', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) expect(extraction.isPresent).toBe(false)
}) })
it('is false when note start with less than three dashes', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) expect(extraction.isPresent).toBe(false)
}) })
it('is false when note starts with three dashes but contains other characters in the same line', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) expect(extraction.isPresent).toBe(false)
}) })
it('is false when note has no ending marker for frontmatter', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) 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', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(false) expect(extraction.isPresent).toBe(false)
}) })
it('is true when note end marker is present with the same amount of dashes as start marker', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true) 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', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true) expect(extraction.isPresent).toBe(true)
}) })
it('is true when note end marker is present with the same amount of dots as start marker', () => { 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) const extraction = extractFrontmatter(testNote)
expect(extraction.isPresent).toBe(true) expect(extraction.isPresent).toBe(true)
}) })
@ -58,22 +58,22 @@ describe('frontmatter extraction', () => {
describe('lineOffset property', () => { describe('lineOffset property', () => {
it('is correct for single line frontmatter without content', () => { 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 const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(3) expect(extraction.lineOffset).toEqual(3)
}) })
it('is correct for single line frontmatter with content', () => { 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 const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(3) expect(extraction.lineOffset).toEqual(3)
}) })
it('is correct for multi-line frontmatter without content', () => { 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 const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(5) expect(extraction.lineOffset).toEqual(5)
}) })
it('is correct for multi-line frontmatter with content', () => { 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 const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.lineOffset).toEqual(5) expect(extraction.lineOffset).toEqual(5)
}) })
@ -81,12 +81,12 @@ describe('frontmatter extraction', () => {
describe('rawText property', () => { describe('rawText property', () => {
it('contains single-line frontmatter text', () => { it('contains single-line frontmatter text', () => {
const testNote = '---\nsingle-line\n...\ncontent' const testNote = ['---', 'single-line', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.rawText).toEqual('single-line') expect(extraction.rawText).toEqual('single-line')
}) })
it('contains multi-line frontmatter text', () => { it('contains multi-line frontmatter text', () => {
const testNote = '---\nmulti\nline\n...\ncontent' const testNote = ['---', 'multi', 'line', '...', 'content']
const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult const extraction = extractFrontmatter(testNote) as PresentFrontmatterExtractionResult
expect(extraction.rawText).toEqual('multi\nline') 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. * 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. * 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. * 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. * @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. * { 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 * { 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. * given multiline string for retrieving the non-frontmatter content.
*/ */
export const extractFrontmatter = (content: string): FrontmatterExtractionResult => { export const extractFrontmatter = (lines: string[]): FrontmatterExtractionResult => {
const lines = content.split('\n')
if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) { if (lines.length < 2 || !FRONTMATTER_BEGIN_REGEX.test(lines[0])) {
return { return {
isPresent: false isPresent: false

View file

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

View file

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

View file

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