mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-20 10:15:17 -04:00
feat: move first heading title extraction into an app extension
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
d8c1e35819
commit
8de8a50bec
17 changed files with 126 additions and 168 deletions
|
@ -6,7 +6,6 @@
|
|||
import type { Ref } from 'react'
|
||||
|
||||
export interface CommonMarkdownRendererProps {
|
||||
onFirstHeadingChange?: (firstHeading: string | undefined) => void
|
||||
baseUrl: string
|
||||
outerContainerRef?: Ref<HTMLDivElement>
|
||||
newlinesAreBreaks?: boolean
|
||||
|
|
|
@ -10,10 +10,9 @@ import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdo
|
|||
import { LinemarkerMarkdownExtension } from './extensions/linemarker/linemarker-markdown-extension'
|
||||
import type { LineMarkerPosition } from './extensions/linemarker/types'
|
||||
import { useCalculateLineMarkerPosition } from './hooks/use-calculate-line-marker-positions'
|
||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||
import { MarkdownToReact } from './markdown-to-react/markdown-to-react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps {
|
||||
|
@ -25,9 +24,7 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro
|
|||
*
|
||||
* @param className Additional class names directly given to the div
|
||||
* @param markdownContentLines The markdown lines
|
||||
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
||||
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
||||
* @param onTaskCheckedChange The callback to call if a task is checked or unchecked.
|
||||
* @param baseUrl The base url of the renderer
|
||||
* @param outerContainerRef A reference for the outer container
|
||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||
|
@ -35,7 +32,6 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro
|
|||
export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({
|
||||
className,
|
||||
markdownContentLines,
|
||||
onFirstHeadingChange,
|
||||
onLineMarkerPositionChanged,
|
||||
baseUrl,
|
||||
outerContainerRef,
|
||||
|
@ -57,10 +53,6 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
|
|||
|
||||
useTranslation()
|
||||
useCalculateLineMarkerPosition(markdownBodyRef, currentLineMarkers.current, onLineMarkerPositionChanged)
|
||||
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
||||
useEffect(() => {
|
||||
extractFirstHeadline()
|
||||
}, [extractFirstHeadline, markdownContentLines])
|
||||
|
||||
return (
|
||||
<div ref={outerContainerRef} className={`position-relative`}>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { MarkdownRendererExtensionOptions } from '../../../../extensions/base/app-extension'
|
||||
import { AppExtension } from '../../../../extensions/base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import { ExtractFirstHeadlineEditorExtension } from './extract-first-headline-editor-extension'
|
||||
import { ExtractFirstHeadlineMarkdownExtension } from './extract-first-headline-markdown-extension'
|
||||
|
||||
/**
|
||||
* Provides first headline extraction
|
||||
*/
|
||||
export class ExtractFirstHeadlineAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
|
||||
return [new ExtractFirstHeadlineMarkdownExtension(options.eventEmitter)]
|
||||
}
|
||||
|
||||
buildEditorExtensionComponent(): React.FC {
|
||||
return ExtractFirstHeadlineEditorExtension
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { updateNoteTitleByFirstHeading } from '../../../../redux/note-details/methods'
|
||||
import { useExtensionEventEmitterHandler } from '../../hooks/use-extension-event-emitter'
|
||||
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
|
||||
import type React from 'react'
|
||||
|
||||
/**
|
||||
* Receives the {@link ExtractFirstHeadlineNodeProcessor.EVENT_NAME first heading extraction event}
|
||||
* and saves the title in the global application state.
|
||||
*/
|
||||
export const ExtractFirstHeadlineEditorExtension: React.FC = () => {
|
||||
useExtensionEventEmitterHandler(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, updateNoteTitleByFirstHeading)
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import { EventMarkdownRendererExtension } from '../base/event-markdown-renderer-extension'
|
||||
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
|
||||
|
||||
/**
|
||||
* Adds first heading extraction to the renderer
|
||||
*/
|
||||
export class ExtractFirstHeadlineMarkdownExtension extends EventMarkdownRendererExtension {
|
||||
buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new ExtractFirstHeadlineNodeProcessor(this.eventEmitter)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { Document, Node, Element } from 'domhandler'
|
||||
import { isTag, isText } from 'domhandler'
|
||||
import type { EventEmitter2 } from 'eventemitter2'
|
||||
|
||||
const headlineTagRegex = /^h[1-6]$/gi
|
||||
|
||||
/**
|
||||
* Searches for the first headline tag and extracts its plain text content.
|
||||
*/
|
||||
export class ExtractFirstHeadlineNodeProcessor extends NodeProcessor {
|
||||
public static readonly EVENT_NAME = 'HeadlineExtracted'
|
||||
|
||||
constructor(private eventEmitter: EventEmitter2) {
|
||||
super()
|
||||
}
|
||||
|
||||
process(nodes: Document): Document {
|
||||
Optional.ofNullable(this.checkNodesForHeadline(nodes.children))
|
||||
.map((foundHeadlineNode) => this.extractInnerTextFromNode(foundHeadlineNode).trim())
|
||||
.filter((text) => text !== '')
|
||||
.ifPresent((text) => this.eventEmitter.emit(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, text))
|
||||
return nodes
|
||||
}
|
||||
|
||||
private checkNodesForHeadline(nodes: Node[]): Node | undefined {
|
||||
return nodes.find((node) => isTag(node) && node.name.match(headlineTagRegex))
|
||||
}
|
||||
|
||||
private extractInnerTextFromNode(node: Node): string {
|
||||
if (isText(node)) {
|
||||
return node.nodeValue
|
||||
} else if (isTag(node)) {
|
||||
return this.extractInnerTextFromTag(node)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private extractInnerTextFromTag(node: Element): string {
|
||||
if (node.name === 'a' && this.findAttribute(node, 'class')?.value.includes('heading-anchor')) {
|
||||
return ''
|
||||
} else if (node.name === 'img') {
|
||||
return this.findAttribute(node, 'alt')?.value ?? ''
|
||||
} else {
|
||||
return node.children.reduce((state, child) => {
|
||||
return state + this.extractInnerTextFromNode(child)
|
||||
}, '')
|
||||
}
|
||||
}
|
||||
|
||||
private findAttribute(node: Element, attributeName: string) {
|
||||
return node.attributes.find((attribute) => attribute.name === attributeName)
|
||||
}
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, 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 || isKatexMathMlElement(node) || isHeadlineLinkElement(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')
|
||||
|
||||
/**
|
||||
* Determines if the given {@link ChildNode node} is the link icon of a heading.
|
||||
* @param node The node to check
|
||||
*/
|
||||
const isHeadlineLinkElement = (node: ChildNode): boolean => (node as HTMLElement).classList?.contains('heading-anchor')
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
onFirstHeadingChange?: (firstHeading: string | undefined) => void
|
||||
): (() => void) => {
|
||||
const lastFirstHeadingContent = useRef<string | undefined>()
|
||||
const currentFirstHeadingElement = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
const extractHeaderText = useCallback(() => {
|
||||
if (!onFirstHeadingChange) {
|
||||
return
|
||||
}
|
||||
const headingText = extractInnerText(currentFirstHeadingElement.current).trim()
|
||||
if (headingText !== lastFirstHeadingContent.current) {
|
||||
lastFirstHeadingContent.current = headingText
|
||||
onFirstHeadingChange(headingText)
|
||||
}
|
||||
}, [onFirstHeadingChange])
|
||||
|
||||
const mutationObserver = useMemo(() => new MutationObserver(() => extractHeaderText()), [extractHeaderText])
|
||||
useEffect(() => () => mutationObserver.disconnect(), [mutationObserver])
|
||||
|
||||
return useCallback(() => {
|
||||
const foundFirstHeading = Optional.ofNullable(documentElement.current)
|
||||
.map((currentDocumentElement) => currentDocumentElement.getElementsByTagName('h1').item(0))
|
||||
.orElse(null)
|
||||
if (foundFirstHeading === currentFirstHeadingElement.current) {
|
||||
return
|
||||
}
|
||||
mutationObserver.disconnect()
|
||||
currentFirstHeadingElement.current = foundFirstHeading
|
||||
if (foundFirstHeading !== null) {
|
||||
mutationObserver.observe(foundFirstHeading, { subtree: true, childList: true })
|
||||
}
|
||||
extractHeaderText()
|
||||
}, [documentElement, extractHeaderText, mutationObserver])
|
||||
}
|
|
@ -6,13 +6,12 @@
|
|||
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
|
||||
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
||||
import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension'
|
||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
|
||||
import { LoadingSlide } from './loading-slide'
|
||||
import { MarkdownToReact } from './markdown-to-react/markdown-to-react'
|
||||
import type { SlideOptions } from '@hedgedoc/commons'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
|
||||
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
|
||||
slideOptions?: SlideOptions
|
||||
|
@ -23,8 +22,6 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr
|
|||
*
|
||||
* @param className Additional class names directly given to the div
|
||||
* @param markdownContentLines The markdown lines
|
||||
* @param onFirstHeadingChange The callback to call if the first heading changes.
|
||||
* @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers}
|
||||
* @param baseUrl The base url of the renderer
|
||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||
* @param slideOptions The {@link SlideOptions} to use
|
||||
|
@ -32,7 +29,6 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr
|
|||
export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps & ScrollProps> = ({
|
||||
className,
|
||||
markdownContentLines,
|
||||
onFirstHeadingChange,
|
||||
baseUrl,
|
||||
newlinesAreBreaks,
|
||||
slideOptions
|
||||
|
@ -46,13 +42,6 @@ export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps
|
|||
|
||||
const revealStatus = useReveal(markdownContentLines, slideOptions)
|
||||
|
||||
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
||||
useEffect(() => {
|
||||
if (revealStatus === REVEAL_STATUS.INITIALISED) {
|
||||
extractFirstHeadline()
|
||||
}
|
||||
}, [extractFirstHeadline, markdownContentLines, revealStatus])
|
||||
|
||||
const slideShowDOM = useMemo(
|
||||
() =>
|
||||
revealStatus === REVEAL_STATUS.INITIALISED ? (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue