mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-20 10:15:17 -04:00
fix: Move content into to frontend directory
Doing this BEFORE the merge prevents a lot of merge conflicts. Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
4e18ce38f3
commit
762a0a850e
1051 changed files with 0 additions and 35 deletions
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Ref } from 'react'
|
||||
|
||||
export interface CommonMarkdownRendererProps {
|
||||
onFirstHeadingChange?: (firstHeading: string | undefined) => void
|
||||
baseUrl: string
|
||||
outerContainerRef?: Ref<HTMLDivElement>
|
||||
newlinesAreBreaks?: boolean
|
||||
lineOffset?: number
|
||||
className?: string
|
||||
markdownContentLines: string[]
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions'
|
||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||
import { cypressId } from '../../utils/cypress-attribute'
|
||||
import { HeadlineAnchorsMarkdownExtension } from './extensions/headline-anchors-markdown-extension'
|
||||
import type { LineMarkerPosition } from './extensions/linemarker/types'
|
||||
import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||
|
||||
export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps {
|
||||
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the note as normal document.
|
||||
*
|
||||
* @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 onTocChange The callback to call if the toc changes.
|
||||
* @param baseUrl The base url of the renderer
|
||||
* @param onImageClick The callback to call if a image is clicked
|
||||
* @param outerContainerRef A reference for the outer container
|
||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||
*/
|
||||
export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> = ({
|
||||
className,
|
||||
markdownContentLines,
|
||||
onFirstHeadingChange,
|
||||
onLineMarkerPositionChanged,
|
||||
baseUrl,
|
||||
outerContainerRef,
|
||||
newlinesAreBreaks
|
||||
}) => {
|
||||
const markdownBodyRef = useRef<HTMLDivElement>(null)
|
||||
const currentLineMarkers = useRef<LineMarkers[]>()
|
||||
|
||||
const extensions = useMarkdownExtensions(
|
||||
baseUrl,
|
||||
currentLineMarkers,
|
||||
useMemo(() => [new HeadlineAnchorsMarkdownExtension()], [])
|
||||
)
|
||||
|
||||
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
|
||||
|
||||
useTranslation()
|
||||
useCalculateLineMarkerPosition(
|
||||
markdownBodyRef,
|
||||
currentLineMarkers.current,
|
||||
onLineMarkerPositionChanged,
|
||||
markdownBodyRef.current?.offsetTop ?? 0
|
||||
)
|
||||
const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange)
|
||||
useEffect(() => {
|
||||
extractFirstHeadline()
|
||||
}, [extractFirstHeadline, markdownContentLines])
|
||||
|
||||
return (
|
||||
<div ref={outerContainerRef} className={`position-relative`}>
|
||||
<div
|
||||
{...cypressId('markdown-body')}
|
||||
ref={markdownBodyRef}
|
||||
data-word-count-target={true}
|
||||
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>
|
||||
{markdownReactDom}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentMarkdownRenderer
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import type { RuleCore } from 'markdown-it/lib/parser_core'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { parseCodeBlockParameters } from './code-block-parameters'
|
||||
|
||||
const ruleName = 'code-highlighter'
|
||||
|
||||
/**
|
||||
* Extracts the language name and additional flags from the code fence parameter and sets them as attributes in the token.
|
||||
*
|
||||
* @param state The current state of the processing {@link MarkdownIt} instance.
|
||||
* @see MarkdownIt.RuleCore
|
||||
*/
|
||||
const rule: RuleCore = (state): void => {
|
||||
state.tokens.forEach((token) => {
|
||||
if (token.type === 'fence') {
|
||||
const highlightInfos = parseCodeBlockParameters(token.info)
|
||||
Optional.ofNullable(highlightInfos.language).ifPresent((language) =>
|
||||
token.attrJoin('data-highlight-language', language)
|
||||
)
|
||||
Optional.ofNullable(highlightInfos.codeFenceParameters).ifPresent((language) =>
|
||||
token.attrJoin('data-extra', language)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the rule to the given {@link MarkdownIt markdown-it instance} if it hasn't been added yet.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown-it instance} to which the rule should be added
|
||||
*/
|
||||
export const codeBlockMarkdownPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
|
||||
if (markdownIt.core.ruler.getRules(ruleName).length === 0) {
|
||||
markdownIt.core.ruler.push(ruleName, rule, { alt: [ruleName] })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { codeBlockMarkdownPlugin } from './code-block-markdown-plugin'
|
||||
import type { ComponentReplacer } from '../../../replace-components/component-replacer'
|
||||
import { MarkdownRendererExtension } from '../markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* A {@link MarkdownRendererExtension markdown extension} that is used for code fence replacements.
|
||||
*/
|
||||
export abstract class CodeBlockMarkdownRendererExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
codeBlockMarkdownPlugin(markdownIt)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parseCodeBlockParameters } from './code-block-parameters'
|
||||
|
||||
describe('Code block parameter parsing', () => {
|
||||
it('should detect just the language', () => {
|
||||
const result = parseCodeBlockParameters('esperanto')
|
||||
expect(result.language).toBe('esperanto')
|
||||
expect(result.codeFenceParameters).toBe('')
|
||||
})
|
||||
it('should detect an empty string', () => {
|
||||
const result = parseCodeBlockParameters('')
|
||||
expect(result.language).toBe('')
|
||||
expect(result.codeFenceParameters).toBe('')
|
||||
})
|
||||
it('should detect additional information after the language', () => {
|
||||
const result = parseCodeBlockParameters('esperanto!!!!!')
|
||||
expect(result.language).toBe('esperanto')
|
||||
expect(result.codeFenceParameters).toBe('!!!!!')
|
||||
})
|
||||
it('should detect just the additional information if no language is given', () => {
|
||||
const result = parseCodeBlockParameters('!!!!!esperanto')
|
||||
expect(result.language).toBe('')
|
||||
expect(result.codeFenceParameters).toBe('!!!!!esperanto')
|
||||
})
|
||||
it('should detect additional information if separated from the language with a space', () => {
|
||||
const result = parseCodeBlockParameters('esperanto sed multe')
|
||||
expect(result.language).toBe('esperanto')
|
||||
expect(result.codeFenceParameters).toBe('sed multe')
|
||||
})
|
||||
it('should ignore spaces at the beginning and the end', () => {
|
||||
const result = parseCodeBlockParameters(' esperanto sed multe ')
|
||||
expect(result.language).toBe('esperanto')
|
||||
expect(result.codeFenceParameters).toBe('sed multe')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const codeFenceArguments = /^ *([\w-]*)(.*)$/
|
||||
|
||||
interface CodeBlockParameters {
|
||||
language: string
|
||||
codeFenceParameters: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the language name and additional parameters from a code block name input.
|
||||
*
|
||||
* @param text The text to parse
|
||||
* @return The parsed parameters
|
||||
*/
|
||||
export const parseCodeBlockParameters = (text: string): CodeBlockParameters => {
|
||||
const parsedText = codeFenceArguments.exec(text)
|
||||
return {
|
||||
language: parsedText?.[1].trim() ?? '',
|
||||
codeFenceParameters: parsedText?.[2].trim() ?? ''
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { findLanguageByCodeBlockName } from './find-language-by-code-block-name'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import type { LanguageDescription } from '@codemirror/language'
|
||||
|
||||
describe('filter language name', () => {
|
||||
const mockedLanguage1 = Mock.of<LanguageDescription>({ name: 'Mocky', alias: ['mocky'] })
|
||||
const mockedLanguage2 = Mock.of<LanguageDescription>({ name: 'Blocky', alias: ['blocky'] })
|
||||
const mockedLanguage3 = Mock.of<LanguageDescription>({ name: 'Rocky', alias: ['rocky'] })
|
||||
const mockedLanguage4 = Mock.of<LanguageDescription>({ name: 'Zocky', alias: ['zocky'] })
|
||||
const mockedLanguages = [mockedLanguage1, mockedLanguage2, mockedLanguage3, mockedLanguage4]
|
||||
|
||||
it('should detect just the name of a language', () => {
|
||||
expect(findLanguageByCodeBlockName(mockedLanguages, 'Mocky')).toBe(mockedLanguage1)
|
||||
})
|
||||
|
||||
it('should detect the name of a language with parameters', () => {
|
||||
expect(findLanguageByCodeBlockName(mockedLanguages, 'Blocky!!!')).toBe(mockedLanguage2)
|
||||
})
|
||||
|
||||
it('should detect just the alias of a language', () => {
|
||||
expect(findLanguageByCodeBlockName(mockedLanguages, 'rocky')).toBe(mockedLanguage3)
|
||||
})
|
||||
|
||||
it('should detect the alias of a language with parameters', () => {
|
||||
expect(findLanguageByCodeBlockName(mockedLanguages, 'zocky!!!')).toBe(mockedLanguage4)
|
||||
})
|
||||
|
||||
it("shouldn't return a language if no match", () => {
|
||||
expect(findLanguageByCodeBlockName(mockedLanguages, 'Docky')).toBe(null)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { LanguageDescription } from '@codemirror/language'
|
||||
import { parseCodeBlockParameters } from './code-block-parameters'
|
||||
|
||||
/**
|
||||
* Finds the {@link LanguageDescription code mirror language descriptions} that matches the given language name or any alias.
|
||||
* It ignores additional code block name parameters.
|
||||
*
|
||||
* @param languages The languages in which the description should be found
|
||||
* @param inputLanguageName The input from the code block
|
||||
* @return The found language description or null if no language could be found by name or alias
|
||||
*/
|
||||
export const findLanguageByCodeBlockName = (
|
||||
languages: LanguageDescription[],
|
||||
inputLanguageName: string
|
||||
): LanguageDescription | null => {
|
||||
return Optional.ofNullable(parseCodeBlockParameters(inputLanguageName).language)
|
||||
.map((filteredLanguage) =>
|
||||
languages.find((language) => language.name === filteredLanguage || language.alias.includes(filteredLanguage))
|
||||
)
|
||||
.orElse(null)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type EventEmitter2 from 'eventemitter2'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
|
||||
/**
|
||||
* Base class for Markdown extensions.
|
||||
*/
|
||||
export abstract class MarkdownRendererExtension {
|
||||
constructor(protected readonly eventEmitter?: EventEmitter2) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
|
||||
return
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return []
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return []
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { isDevMode } from '../../../utils/test-modes'
|
||||
|
||||
const log = new Logger('DebuggerMarkdownExtension')
|
||||
|
||||
/**
|
||||
* Adds console debug logging to the markdown rendering.
|
||||
*/
|
||||
export class DebuggerMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
|
||||
if (isDevMode) {
|
||||
markdownIt.core.ruler.push('printStateToConsole', (state) => {
|
||||
log.debug('Current state', state)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Emoji Markdown Extension renders a fork awesome code 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<i
|
||||
class="fa fa-circle-thin"
|
||||
/>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Emoji Markdown Extension renders a skin tone code 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
🏽
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Emoji Markdown Extension renders an emoji code 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
😄
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../test-utils/mock-i18n'
|
||||
import { render } from '@testing-library/react'
|
||||
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
|
||||
import { EmojiMarkdownExtension } from './emoji-markdown-extension'
|
||||
|
||||
describe('Emoji Markdown Extension', () => {
|
||||
beforeAll(async () => {
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders an emoji code', () => {
|
||||
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':smile:'} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders a fork awesome code', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':fa-circle-thin:'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders a skin tone code', () => {
|
||||
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':skin-tone-3:'} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import emoji from 'markdown-it-emoji/bare'
|
||||
import { combinedEmojiData } from './mapping'
|
||||
|
||||
/**
|
||||
* Adds support for utf-8 emojis.
|
||||
*/
|
||||
export class EmojiMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownIt.use(emoji, {
|
||||
defs: combinedEmojiData
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
|
||||
import { ForkAwesomeIcons } from '../../../common/fork-awesome/fork-awesome-icons'
|
||||
|
||||
interface EmojiEntry {
|
||||
shortcodes: string[]
|
||||
emoji: string
|
||||
}
|
||||
|
||||
type ShortCodeMap = { [key: string]: string }
|
||||
|
||||
const shortCodeMap = (emojiData as unknown as EmojiEntry[]).reduce((reduceObject, emoji) => {
|
||||
emoji.shortcodes.forEach((shortcode) => {
|
||||
reduceObject[shortcode] = emoji.emoji
|
||||
})
|
||||
return reduceObject
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
const emojiSkinToneModifierMap = [1, 2, 3, 4, 5].reduce((reduceObject, modifierValue) => {
|
||||
const lightSkinCode = 127995
|
||||
const codepoint = lightSkinCode + (modifierValue - 1)
|
||||
const shortcode = `skin-tone-${modifierValue}`
|
||||
reduceObject[shortcode] = `&#${codepoint};`
|
||||
return reduceObject
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
const forkAwesomeIconMap = ForkAwesomeIcons.reduce((reduceObject, icon) => {
|
||||
const shortcode = `fa-${icon}`
|
||||
// noinspection CheckTagEmptyBody
|
||||
reduceObject[shortcode] = `<i class='fa fa-${icon}'></i>`
|
||||
return reduceObject
|
||||
}, {} as ShortCodeMap)
|
||||
|
||||
export const combinedEmojiData = {
|
||||
...shortCodeMap,
|
||||
...emojiSkinToneModifierMap,
|
||||
...forkAwesomeIconMap
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import abbreviation from 'markdown-it-abbr'
|
||||
import definitionList from 'markdown-it-deflist'
|
||||
import subscript from 'markdown-it-sub'
|
||||
import superscript from 'markdown-it-sup'
|
||||
import inserted from 'markdown-it-ins'
|
||||
import marked from 'markdown-it-mark'
|
||||
import footnote from 'markdown-it-footnote'
|
||||
import { imageSize } from '@hedgedoc/markdown-it-plugins'
|
||||
|
||||
/**
|
||||
* Adds some common markdown syntaxes to the markdown rendering.
|
||||
*/
|
||||
export class GenericSyntaxMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
abbreviation(markdownIt)
|
||||
definitionList(markdownIt)
|
||||
subscript(markdownIt)
|
||||
superscript(markdownIt)
|
||||
inserted(markdownIt)
|
||||
marked(markdownIt)
|
||||
footnote(markdownIt)
|
||||
imageSize(markdownIt)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import anchor from 'markdown-it-anchor'
|
||||
|
||||
/**
|
||||
* Adds headline anchors to the markdown rendering.
|
||||
*/
|
||||
export class HeadlineAnchorsMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
anchor(markdownIt, {
|
||||
permalink: anchor.permalink.ariaHidden({
|
||||
symbol: '<i class="fa fa-link"></i>',
|
||||
class: 'heading-anchor text-dark',
|
||||
renderHref: (slug: string): string => `#${slug}`,
|
||||
placement: 'before'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import { IframeCapsuleReplacer } from './iframe-capsule-replacer'
|
||||
|
||||
/**
|
||||
* Adds a replacer that capsules iframes in a click shield.
|
||||
*/
|
||||
export class IframeCapsuleMarkdownExtension extends MarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new IframeCapsuleReplacer()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return ['iframe']
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import type { Element } from 'domhandler'
|
||||
import { ClickShield } from '../../replace-components/click-shield/click-shield'
|
||||
|
||||
/**
|
||||
* Capsules <iframe> elements with a click shield.
|
||||
*
|
||||
* @see ClickShield
|
||||
*/
|
||||
export class IframeCapsuleReplacer extends ComponentReplacer {
|
||||
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||
return node.name !== 'iframe' ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<ClickShield
|
||||
hoverIcon={'globe'}
|
||||
targetDescription={node.attribs.src}
|
||||
data-cypress-id={'iframe-capsule-click-shield'}>
|
||||
{nativeRenderer()}
|
||||
</ClickShield>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||
|
||||
/**
|
||||
* A {@link MarkdownIt.PluginSimple markdown it plugin} that adds the line number of the markdown code to every placeholder image.
|
||||
*
|
||||
* @param markdownIt The markdown it instance to which the plugin should be added
|
||||
*/
|
||||
export const addLineToPlaceholderImageTags: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
|
||||
markdownIt.core.ruler.push('image-placeholder', (state) => {
|
||||
state.tokens.forEach((token) => {
|
||||
if (token.type !== 'inline') {
|
||||
return
|
||||
}
|
||||
token.children?.forEach((childToken) => {
|
||||
if (
|
||||
childToken.type === 'image' &&
|
||||
childToken.attrGet('src') === ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL
|
||||
) {
|
||||
const line = token.map?.[0]
|
||||
if (line !== undefined) {
|
||||
childToken.attrSet('data-line', String(line))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useRendererToEditorCommunicator } from '../../../../editor-page/render-context/renderer-to-editor-communicator-context-provider'
|
||||
import { useCallback } from 'react'
|
||||
import { CommunicationMessageType } from '../../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { FileContentFormat, readFile } from '../../../../../utils/read-file'
|
||||
|
||||
const log = new Logger('useOnImageUpload')
|
||||
|
||||
/**
|
||||
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
|
||||
*
|
||||
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||
* @param placeholderIndexInLine The index of the placeholder in the markdown content line
|
||||
*/
|
||||
export const useOnImageUpload = (
|
||||
lineIndex: number | undefined,
|
||||
placeholderIndexInLine: number | undefined
|
||||
): ((file: File) => void) => {
|
||||
const communicator = useRendererToEditorCommunicator()
|
||||
|
||||
return useCallback(
|
||||
(file: File) => {
|
||||
readFile(file, FileContentFormat.DATA_URL)
|
||||
.then((dataUri) => {
|
||||
communicator.sendMessageToOtherSide({
|
||||
type: CommunicationMessageType.IMAGE_UPLOAD,
|
||||
dataUri,
|
||||
fileName: file.name,
|
||||
lineIndex,
|
||||
placeholderIndexInLine
|
||||
})
|
||||
})
|
||||
.catch((error: ProgressEvent) => log.error('Error while uploading image', error))
|
||||
},
|
||||
[communicator, placeholderIndexInLine, lineIndex]
|
||||
)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { calculatePlaceholderContainerSize } from '../utils/build-placeholder-size-css'
|
||||
|
||||
/**
|
||||
* Creates the style attribute for a placeholder container with width and height.
|
||||
*
|
||||
* @param width The wanted width
|
||||
* @param height The wanted height
|
||||
* @return The created style attributes
|
||||
*/
|
||||
export const usePlaceholderSizeStyle = (width?: string | number, height?: string | number): CSSProperties => {
|
||||
return useMemo(() => {
|
||||
const [convertedWidth, convertedHeight] = calculatePlaceholderContainerSize(width, height)
|
||||
|
||||
return {
|
||||
width: `${convertedWidth}px`,
|
||||
height: `${convertedHeight}px`
|
||||
}
|
||||
}, [height, width])
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import { addLineToPlaceholderImageTags } from './add-line-to-placeholder-image-tags'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import { ImagePlaceholderReplacer } from './image-placeholder-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for {@link ImagePlaceholder}.
|
||||
*/
|
||||
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly PLACEHOLDER_URL = 'https://'
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
addLineToPlaceholderImageTags(markdownIt)
|
||||
}
|
||||
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new ImagePlaceholderReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NodeReplacement } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import type { Element } from 'domhandler'
|
||||
import { ImagePlaceholder } from './image-placeholder'
|
||||
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
|
||||
|
||||
/**
|
||||
* Replaces every image tag that has the {@link ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL placeholder url} with the {@link ImagePlaceholder image placeholder element}.
|
||||
*/
|
||||
export class ImagePlaceholderReplacer extends ComponentReplacer {
|
||||
private countPerSourceLine = new Map<number, number>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.countPerSourceLine = new Map<number, number>()
|
||||
}
|
||||
|
||||
replace(node: Element): NodeReplacement {
|
||||
if (node.name !== 'img' || node.attribs?.src !== ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
const lineIndex = Number(node.attribs['data-line'])
|
||||
const indexInLine = this.countPerSourceLine.get(lineIndex) ?? 0
|
||||
this.countPerSourceLine.set(lineIndex, indexInLine + 1)
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
alt={node.attribs.alt}
|
||||
title={node.attribs.title}
|
||||
width={node.attribs.width}
|
||||
height={node.attribs.height}
|
||||
lineIndex={isNaN(lineIndex) ? undefined : lineIndex}
|
||||
placeholderIndexInLine={indexInLine}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.image-drop {
|
||||
border: 3px dashed var(--bs-dark);
|
||||
|
||||
border-radius: 3px;
|
||||
transition: background-color 50ms, color 50ms;
|
||||
|
||||
.altText {
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import styles from './image-placeholder.module.scss'
|
||||
import { acceptedMimeTypes } from '../../../common/upload-image-mimetypes'
|
||||
import { useOnImageUpload } from './hooks/use-on-image-upload'
|
||||
import { usePlaceholderSizeStyle } from './hooks/use-placeholder-size-style'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
export interface PlaceholderImageFrameProps {
|
||||
alt?: string
|
||||
title?: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
lineIndex?: number
|
||||
placeholderIndexInLine?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a placeholder for an actual image with the possibility to upload images via button or drag'n'drop.
|
||||
*
|
||||
* @param alt The alt text of the image. Will be shown in the placeholder
|
||||
* @param title The title text of the image. Will be shown in the placeholder
|
||||
* @param width The width of the placeholder
|
||||
* @param height The height of the placeholder
|
||||
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
|
||||
* @param placeholderIndexInLine The index of the placeholder in the markdown line
|
||||
*/
|
||||
export const ImagePlaceholder: React.FC<PlaceholderImageFrameProps> = ({
|
||||
alt,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
lineIndex,
|
||||
placeholderIndexInLine
|
||||
}) => {
|
||||
useTranslation()
|
||||
const fileInputReference = useRef<HTMLInputElement>(null)
|
||||
const onImageUpload = useOnImageUpload(lineIndex, placeholderIndexInLine)
|
||||
|
||||
const [showDragStatus, setShowDragStatus] = useState(false)
|
||||
|
||||
const onDropHandler = useCallback(
|
||||
(event: React.DragEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault()
|
||||
if (event?.dataTransfer?.files?.length > 0) {
|
||||
onImageUpload(event.dataTransfer.files[0])
|
||||
}
|
||||
},
|
||||
[onImageUpload]
|
||||
)
|
||||
|
||||
const onDragOverHandler = useCallback((event: React.DragEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault()
|
||||
setShowDragStatus(true)
|
||||
}, [])
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
setShowDragStatus(false)
|
||||
}, [])
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = event.target.files
|
||||
if (!fileList || fileList.length < 1) {
|
||||
return
|
||||
}
|
||||
onImageUpload(fileList[0])
|
||||
},
|
||||
[onImageUpload]
|
||||
)
|
||||
|
||||
const uploadButtonClicked = useCallback(() => fileInputReference.current?.click(), [])
|
||||
const containerStyle = usePlaceholderSizeStyle(width, height)
|
||||
|
||||
const containerDragClasses = useMemo(() => (showDragStatus ? 'bg-primary text-white' : 'text-dark'), [showDragStatus])
|
||||
|
||||
return (
|
||||
<span
|
||||
{...cypressId('image-placeholder-image-drop')}
|
||||
className={`${styles['image-drop']} d-inline-flex flex-column align-items-center ${containerDragClasses} p-1`}
|
||||
style={containerStyle}
|
||||
onDrop={onDropHandler}
|
||||
onDragOver={onDragOverHandler}
|
||||
onDragLeave={onDragLeave}>
|
||||
<input
|
||||
type='file'
|
||||
className='d-none'
|
||||
accept={acceptedMimeTypes}
|
||||
onChange={onChangeHandler}
|
||||
ref={fileInputReference}
|
||||
/>
|
||||
<div className={'align-items-center flex-column justify-content-center flex-fill d-flex'}>
|
||||
<div className={'d-flex flex-column'}>
|
||||
<span className='my-2'>
|
||||
<Trans i18nKey={'editor.embeddings.placeholderImage.placeholderText'} />
|
||||
</span>
|
||||
<span className={styles['altText']}>{alt ?? title ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size={'sm'} variant={'primary'} onClick={uploadButtonClicked}>
|
||||
<ForkAwesomeIcon icon={'upload'} fixedWidth={true} className='my-2' />
|
||||
<Trans i18nKey={'editor.embeddings.placeholderImage.upload'} className='my-2' />
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { calculatePlaceholderContainerSize, parseSizeNumber } from './build-placeholder-size-css'
|
||||
|
||||
describe('parseSizeNumber', () => {
|
||||
it('undefined', () => {
|
||||
expect(parseSizeNumber(undefined)).toBe(undefined)
|
||||
})
|
||||
it('zero as number', () => {
|
||||
expect(parseSizeNumber(0)).toBe(0)
|
||||
})
|
||||
it('positive number', () => {
|
||||
expect(parseSizeNumber(234)).toBe(234)
|
||||
})
|
||||
it('negative number', () => {
|
||||
expect(parseSizeNumber(-123)).toBe(-123)
|
||||
})
|
||||
it('zero as string', () => {
|
||||
expect(parseSizeNumber('0')).toBe(0)
|
||||
})
|
||||
it('negative number as string', () => {
|
||||
expect(parseSizeNumber('-123')).toBe(-123)
|
||||
})
|
||||
it('positive number as string', () => {
|
||||
expect(parseSizeNumber('345')).toBe(345)
|
||||
})
|
||||
it('positive number with px as string', () => {
|
||||
expect(parseSizeNumber('456px')).toBe(456)
|
||||
})
|
||||
it('negative number with px as string', () => {
|
||||
expect(parseSizeNumber('-456px')).toBe(-456)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculatePlaceholderContainerSize', () => {
|
||||
it('width undefined | height undefined', () => {
|
||||
expect(calculatePlaceholderContainerSize(undefined, undefined)).toStrictEqual([500, 200])
|
||||
})
|
||||
it('width 200 | height undefined', () => {
|
||||
expect(calculatePlaceholderContainerSize(200, undefined)).toStrictEqual([200, 80])
|
||||
})
|
||||
it('width undefined | height 100', () => {
|
||||
expect(calculatePlaceholderContainerSize(undefined, 100)).toStrictEqual([250, 100])
|
||||
})
|
||||
it('width "0" | height 0', () => {
|
||||
expect(calculatePlaceholderContainerSize('0', 0)).toStrictEqual([0, 0])
|
||||
})
|
||||
it('width 0 | height "0"', () => {
|
||||
expect(calculatePlaceholderContainerSize(0, '0')).toStrictEqual([0, 0])
|
||||
})
|
||||
it('width -345 | height 234', () => {
|
||||
expect(calculatePlaceholderContainerSize(-345, 234)).toStrictEqual([-345, 234])
|
||||
})
|
||||
it('width 345 | height -234', () => {
|
||||
expect(calculatePlaceholderContainerSize(345, -234)).toStrictEqual([345, -234])
|
||||
})
|
||||
it('width "-345" | height -234', () => {
|
||||
expect(calculatePlaceholderContainerSize('-345', -234)).toStrictEqual([-345, -234])
|
||||
})
|
||||
it('width -345 | height "-234"', () => {
|
||||
expect(calculatePlaceholderContainerSize(-345, '-234')).toStrictEqual([-345, -234])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const regex = /^(-?[0-9]+)px$/
|
||||
|
||||
/**
|
||||
* Inspects the given value and checks if it is a number or a pixel size string.
|
||||
*
|
||||
* @param value The value to check
|
||||
* @return the number representation of the string or undefined if it couldn't be parsed
|
||||
*/
|
||||
export const parseSizeNumber = (value: string | number | undefined): number | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
const regexMatches = regex.exec(value)
|
||||
if (regexMatches !== null) {
|
||||
if (regexMatches && regexMatches.length > 1) {
|
||||
return parseInt(regexMatches[1])
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
return parseInt(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the final width and height for a placeholder container.
|
||||
* Every parameter that is empty will be defaulted using a 500:200 ratio.
|
||||
*
|
||||
* @param width The wanted width
|
||||
* @param height The wanted height
|
||||
* @return the calculated size
|
||||
*/
|
||||
export const calculatePlaceholderContainerSize = (
|
||||
width: string | number | undefined,
|
||||
height: string | number | undefined
|
||||
): [width: number, height: number] => {
|
||||
const defaultWidth = 500
|
||||
const defaultHeight = 200
|
||||
const ratio = defaultWidth / defaultHeight
|
||||
|
||||
const convertedWidth = parseSizeNumber(width)
|
||||
const convertedHeight = parseSizeNumber(height)
|
||||
|
||||
if (convertedWidth === undefined && convertedHeight !== undefined) {
|
||||
return [convertedHeight * ratio, convertedHeight]
|
||||
} else if (convertedWidth !== undefined && convertedHeight === undefined) {
|
||||
return [convertedWidth, convertedWidth * (1 / ratio)]
|
||||
} else if (convertedWidth !== undefined && convertedHeight !== undefined) {
|
||||
return [convertedWidth, convertedHeight]
|
||||
} else {
|
||||
return [defaultWidth, defaultHeight]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ImageDetails } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
|
||||
import { ImageLightboxModal } from './image-lightbox-modal'
|
||||
import { useExtensionEventEmitterHandler } from '../../hooks/use-extension-event-emitter'
|
||||
import { SHOW_IMAGE_LIGHTBOX_EVENT_NAME } from './event-emitting-proxy-image-frame'
|
||||
|
||||
/**
|
||||
* Handles messages from the render in the iframe to open a {@link ImageLightboxModal}.
|
||||
*/
|
||||
export const CommunicatorImageLightbox: React.FC = () => {
|
||||
const [lightboxDetails, setLightboxDetails] = useState<ImageDetails | undefined>(undefined)
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
|
||||
const handler = useCallback(
|
||||
(values: ImageDetails) => {
|
||||
setLightboxDetails?.(values)
|
||||
showModal()
|
||||
},
|
||||
[showModal]
|
||||
)
|
||||
|
||||
useExtensionEventEmitterHandler(SHOW_IMAGE_LIGHTBOX_EVENT_NAME, handler)
|
||||
|
||||
return (
|
||||
<ImageLightboxModal
|
||||
show={modalVisibility}
|
||||
onHide={closeModal}
|
||||
src={lightboxDetails?.src}
|
||||
alt={lightboxDetails?.alt}
|
||||
title={lightboxDetails?.title}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { useExtensionEventEmitter } from '../../hooks/use-extension-event-emitter'
|
||||
import { ProxyImageFrame } from './proxy-image-frame'
|
||||
import type { ImageDetails } from '../../../render-page/window-post-message-communicator/rendering-message'
|
||||
|
||||
type EventEmittingProxyImageFrameProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onClick'>
|
||||
|
||||
export const SHOW_IMAGE_LIGHTBOX_EVENT_NAME = 'ImageClick'
|
||||
|
||||
/**
|
||||
* Renders a {@link ProxyImageFrame} but claims the `onClick` event to send image information to the current event emitter.
|
||||
*
|
||||
* @param props props that will be forwarded to the inner image frame
|
||||
*/
|
||||
export const EventEmittingProxyImageFrame: React.FC<EventEmittingProxyImageFrameProps> = (props) => {
|
||||
const eventEmitter = useExtensionEventEmitter()
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
|
||||
const image = event.target as HTMLImageElement
|
||||
if (image.src === '') {
|
||||
return
|
||||
}
|
||||
eventEmitter?.emit(SHOW_IMAGE_LIGHTBOX_EVENT_NAME, {
|
||||
src: image.src,
|
||||
alt: image.alt,
|
||||
title: image.title
|
||||
} as ImageDetails)
|
||||
},
|
||||
[eventEmitter]
|
||||
)
|
||||
|
||||
return <ProxyImageFrame {...props} onClick={onClick} />
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import styles from './lightbox.module.scss'
|
||||
import { ProxyImageFrame } from './proxy-image-frame'
|
||||
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../common/modals/common-modal'
|
||||
|
||||
export interface ImageLightboxModalProps extends ModalVisibilityProps {
|
||||
alt?: string
|
||||
src?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a lightbox modal for images.
|
||||
*
|
||||
* @param show If the modal should be shown
|
||||
* @param onHide The callback to hide the modal
|
||||
* @param src The image source
|
||||
* @param alt The alt text of the image
|
||||
* @param title The title of the image
|
||||
*/
|
||||
export const ImageLightboxModal: React.FC<ImageLightboxModalProps> = ({ show, onHide, src, alt, title }) => {
|
||||
return (
|
||||
<CommonModal
|
||||
modalSize={'xl'}
|
||||
show={show && !!src}
|
||||
onHide={onHide}
|
||||
showCloseButton={true}
|
||||
additionalClasses={styles.lightbox}
|
||||
title={alt ?? title ?? ''}
|
||||
titleIsI18nKey={false}>
|
||||
<ProxyImageFrame alt={alt} src={src} title={title} className={'w-100 cursor-zoom-out'} onClick={onHide} />
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.lightbox img {
|
||||
max-width: calc(100vw - 3.5rem);
|
||||
max-height: calc(100vh - 3.5rem - 75px);
|
||||
object-fit: contain;
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getProxiedUrl } from '../../../../api/media'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
|
||||
const log = new Logger('ProxyImageFrame')
|
||||
|
||||
/**
|
||||
* Renders an image using the image proxy.
|
||||
*
|
||||
* @param src The image source
|
||||
* @param title The title of the image
|
||||
* @param alt The alt text of the image
|
||||
* @param props Additional props directly given to the image
|
||||
*/
|
||||
export const ProxyImageFrame: React.FC<React.ImgHTMLAttributes<HTMLImageElement>> = ({ src, title, alt, ...props }) => {
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
const imageProxyEnabled = useApplicationState((state) => state.config.useImageProxy)
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageProxyEnabled || !src) {
|
||||
return
|
||||
}
|
||||
getProxiedUrl(src)
|
||||
.then((proxyResponse) => setImageUrl(proxyResponse.url))
|
||||
.catch((err) => log.error(err))
|
||||
}, [imageProxyEnabled, src])
|
||||
|
||||
// The next image processor works with a whitelist of origins. Therefore, we can't use it for general images.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={imageProxyEnabled ? imageUrl : src ?? ''} title={title ?? alt ?? ''} alt={alt} {...props} />
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import { ProxyImageReplacer } from './proxy-image-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for image lightbox and image proxy redirection.
|
||||
*/
|
||||
export class ProxyImageMarkdownExtension extends MarkdownRendererExtension {
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new ProxyImageReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
import type { NodeReplacement } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import { EventEmittingProxyImageFrame } from './event-emitting-proxy-image-frame'
|
||||
|
||||
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void
|
||||
|
||||
/**
|
||||
* Detects image tags and loads them via image proxy if configured.
|
||||
*/
|
||||
export class ProxyImageReplacer extends ComponentReplacer {
|
||||
public replace(node: Element): NodeReplacement {
|
||||
return node.name !== 'img' ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<EventEmittingProxyImageFrame
|
||||
id={node.attribs.id}
|
||||
className={`${node.attribs.class} cursor-zoom-in`}
|
||||
src={node.attribs.src}
|
||||
alt={node.attribs.alt}
|
||||
title={node.attribs.title}
|
||||
width={node.attribs.width}
|
||||
height={node.attribs.height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import Token from 'markdown-it/lib/token'
|
||||
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
|
||||
|
||||
export interface LineMarkers {
|
||||
startLine: number
|
||||
endLine: number
|
||||
}
|
||||
|
||||
const insertNewLineMarker = (
|
||||
startLineNumber: number,
|
||||
endLineNumber: number,
|
||||
tokenPosition: number,
|
||||
level: number,
|
||||
tokens: Token[]
|
||||
) => {
|
||||
const startToken = new Token('app_linemarker', LinemarkerMarkdownExtension.tagName, 0)
|
||||
startToken.level = level
|
||||
startToken.attrPush(['data-start-line', `${startLineNumber}`])
|
||||
startToken.attrPush(['data-end-line', `${endLineNumber}`])
|
||||
tokens.splice(tokenPosition, 0, startToken)
|
||||
}
|
||||
|
||||
const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[]) => {
|
||||
for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) {
|
||||
const token = tokens[tokenPosition]
|
||||
if (token.hidden || !token.map) {
|
||||
continue
|
||||
}
|
||||
|
||||
const startLineNumber = token.map[0] + 1
|
||||
const endLineNumber = token.map[1] + 1
|
||||
|
||||
if (token.level === 0) {
|
||||
lineMarkers.push({ startLine: startLineNumber, endLine: endLineNumber })
|
||||
insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens)
|
||||
tokenPosition += 1
|
||||
}
|
||||
|
||||
if (token.children) {
|
||||
tagTokens(token.children, lineMarkers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin adds markers to the dom, that are used to map line numbers to dom elements.
|
||||
* It also provides a list of line numbers for the top level dom elements.
|
||||
*/
|
||||
export const addLineMarkerMarkdownItPlugin: (
|
||||
markdownIt: MarkdownIt,
|
||||
onLineMarkerChange?: (lineMarkers: LineMarkers[]) => void
|
||||
) => void = (md, onLineMarkerChange) => {
|
||||
md.core.ruler.push('line_number_marker', (state) => {
|
||||
const lineMarkers: LineMarkers[] = []
|
||||
tagTokens(state.tokens, lineMarkers)
|
||||
if (onLineMarkerChange) {
|
||||
onLineMarkerChange(lineMarkers)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
md.renderer.rules.app_linemarker = (tokens: Token[], index: number): string => {
|
||||
const startLineNumber = tokens[index].attrGet('data-start-line')
|
||||
const endLineNumber = tokens[index].attrGet('data-end-line')
|
||||
|
||||
return startLineNumber && endLineNumber
|
||||
? `<${LinemarkerMarkdownExtension.tagName} data-start-line='${startLineNumber}' data-end-line='${endLineNumber}'></${LinemarkerMarkdownExtension.tagName}>`
|
||||
: ''
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import { LinemarkerReplacer } from './linemarker-replacer'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import type { LineMarkers } from './add-line-marker-markdown-it-plugin'
|
||||
import { addLineMarkerMarkdownItPlugin } from './add-line-marker-markdown-it-plugin'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
|
||||
/**
|
||||
* Adds support for the generation of line marker elements which are needed for synced scrolling.
|
||||
*/
|
||||
export class LinemarkerMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-linemarker'
|
||||
|
||||
constructor(private onLineMarkers?: (lineMarkers: LineMarkers[]) => void) {
|
||||
super()
|
||||
}
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
addLineMarkerMarkdownItPlugin(markdownIt, this.onLineMarkers)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new LinemarkerReplacer()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [LinemarkerMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import type { NodeReplacement } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
|
||||
|
||||
/**
|
||||
* Detects line markers and suppresses them in the resulting DOM.
|
||||
*/
|
||||
export class LinemarkerReplacer extends ComponentReplacer {
|
||||
public replace(codeNode: Element): NodeReplacement {
|
||||
return codeNode.name === LinemarkerMarkdownExtension.tagName ? null : DO_NOT_REPLACE
|
||||
}
|
||||
}
|
15
frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts
vendored
Normal file
15
frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface LineWithId {
|
||||
line: string
|
||||
id: number
|
||||
}
|
||||
|
||||
export interface LineMarkerPosition {
|
||||
line: number
|
||||
position: number
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
|
||||
import type { Node } from 'domhandler'
|
||||
import { isTag } from 'domhandler'
|
||||
|
||||
/**
|
||||
* A preprocessor for links. It filters script and data links, converts relative URLs into absolute URLs and fixes jump marks.
|
||||
*/
|
||||
export class AnchorNodePreprocessor extends TravelerNodeProcessor {
|
||||
constructor(private baseUrl: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
protected processNode(node: Node): void {
|
||||
if (!isTag(node) || node.name !== 'a' || !node.attribs || !node.attribs.href) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = node.attribs.href.trim()
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (url.startsWith('data:') || url.startsWith('javascript:') || url.startsWith('vbscript:')) {
|
||||
delete node.attribs.href
|
||||
return
|
||||
}
|
||||
|
||||
const isJumpMark = url.slice(0, 1) === '#'
|
||||
|
||||
if (isJumpMark) {
|
||||
node.attribs['data-jump-target-id'] = url.slice(1)
|
||||
} else {
|
||||
node.attribs.rel = 'noreferer noopener'
|
||||
node.attribs.target = '_blank'
|
||||
}
|
||||
|
||||
try {
|
||||
node.attribs.href = new URL(url, this.baseUrl).toString()
|
||||
} catch (e) {
|
||||
node.attribs.href = url
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Element } from 'domhandler'
|
||||
import type { AllHTMLAttributes } from 'react'
|
||||
import React from 'react'
|
||||
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import { JumpAnchor } from './jump-anchor'
|
||||
|
||||
/**
|
||||
* Detects anchors that should jump to scroll to another element.
|
||||
*/
|
||||
export class JumpAnchorReplacer extends ComponentReplacer {
|
||||
public replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
|
||||
if (node.name !== 'a' || !node.attribs || !node.attribs['data-jump-target-id']) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
const jumpId = node.attribs['data-jump-target-id']
|
||||
delete node.attribs['data-jump-target-id']
|
||||
const replacement = nativeRenderer()
|
||||
return replacement === null || typeof replacement === 'string' ? (
|
||||
replacement
|
||||
) : (
|
||||
<JumpAnchor {...(replacement.props as AllHTMLAttributes<HTMLAnchorElement>)} jumpTargetId={jumpId} />
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { AllHTMLAttributes } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
export interface JumpAnchorProps extends AllHTMLAttributes<HTMLAnchorElement> {
|
||||
jumpTargetId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders jump anchors.
|
||||
*
|
||||
* @param jumpTargetId The target id
|
||||
* @param children Children rendered into the link.
|
||||
* @param props Additional props directly given to the link
|
||||
*/
|
||||
export const JumpAnchor: React.FC<JumpAnchorProps> = ({ jumpTargetId, children, ...props }) => {
|
||||
const jumpToTargetId = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
|
||||
const intoViewElement = document.getElementById(jumpTargetId)
|
||||
const scrollElement = document.querySelector('[data-scroll-element]')
|
||||
if (!intoViewElement || !scrollElement) {
|
||||
return
|
||||
}
|
||||
//It would be much easier to use scrollIntoView here but since the code mirror also uses smooth scroll and bugs like
|
||||
// https://stackoverflow.com/a/63563437/13103995 exist, we must use scrollTo.
|
||||
scrollElement.scrollTo({ behavior: 'smooth', top: intoViewElement.offsetTop })
|
||||
event.preventDefault()
|
||||
},
|
||||
[jumpTargetId]
|
||||
)
|
||||
|
||||
return (
|
||||
<a {...props} onClick={jumpToTargetId}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import { JumpAnchorReplacer } from './jump-anchor-replacer'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import { AnchorNodePreprocessor } from './anchor-node-preprocessor'
|
||||
|
||||
/**
|
||||
* Adds tweaks for anchor tags which are needed for the use in the secured iframe.
|
||||
*/
|
||||
export class LinkAdjustmentMarkdownExtension extends MarkdownRendererExtension {
|
||||
constructor(private baseUrl: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new AnchorNodePreprocessor(this.baseUrl)]
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new JumpAnchorReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Linkify markdown extensions renders a .rocks link correctly 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="http://example.rocks"
|
||||
>
|
||||
example.rocks
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="http://example.com"
|
||||
>
|
||||
example.com
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="http://example.de"
|
||||
>
|
||||
example.de
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { mockI18n } from '../../test-utils/mock-i18n'
|
||||
import { render } from '@testing-library/react'
|
||||
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
|
||||
import { LinkifyFixMarkdownExtension } from './linkify-fix-markdown-extension'
|
||||
|
||||
describe('Linkify markdown extensions', () => {
|
||||
beforeAll(async () => {
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
it('renders a .rocks link correctly', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LinkifyFixMarkdownExtension()]}
|
||||
content={'example.rocks\n\nexample.com\n\nexample.de'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import linkify from 'markdown-it/lib/rules_core/linkify'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import tlds from 'tlds'
|
||||
|
||||
/**
|
||||
* A markdown extension that detects plain text URLs and converts them into links.
|
||||
*/
|
||||
export class LinkifyFixMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
|
||||
markdownIt.linkify.tlds(tlds)
|
||||
markdownIt.core.ruler.push('linkify', (state) => {
|
||||
try {
|
||||
state.md.options.linkify = true
|
||||
return linkify(state)
|
||||
} finally {
|
||||
state.md.options.linkify = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { DataNode, Element, Node } from 'domhandler'
|
||||
import { isComment, isTag } from 'domhandler'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
|
||||
|
||||
const log = new Logger('reveal.js > Comment Node Preprocessor')
|
||||
const revealCommandSyntax = /^\s*\.(\w*):(.*)$/g
|
||||
const dataAttributesSyntax = /\s*(data-[\w-]*|class)=(?:"((?:[^"\\]|\\"|\\)*)"|'([^']*)')/g
|
||||
|
||||
/**
|
||||
* Travels through the given {@link Document}, searches for reveal command comments and applies them.
|
||||
*
|
||||
* @param doc The document that should be changed
|
||||
* @return The edited document
|
||||
*/
|
||||
export class RevealCommentCommandNodePreprocessor extends TravelerNodeProcessor {
|
||||
protected processNode(node: Node): void {
|
||||
if (isComment(node)) {
|
||||
processCommentNode(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the given {@link DataNode html comment} by parsing it, finding the element that should be changed and applies the contained changes.
|
||||
*
|
||||
* @param node The node that contains the reveal command.
|
||||
*/
|
||||
const processCommentNode = (node: DataNode): void => {
|
||||
const regexResult = node.data.split(revealCommandSyntax)
|
||||
if (regexResult.length === 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentNode: Element | null = findTargetElement(node, regexResult[1])
|
||||
if (!parentNode) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const dataAttribute of [...regexResult[2].matchAll(dataAttributesSyntax)]) {
|
||||
const attributeName = dataAttribute[1]
|
||||
const attributeValue = dataAttribute[2] ?? dataAttribute[3]
|
||||
if (attributeValue) {
|
||||
log.debug(
|
||||
`Add attribute "${attributeName}"=>"${attributeValue}" to node`,
|
||||
parentNode,
|
||||
'because of',
|
||||
regexResult[1],
|
||||
'selector'
|
||||
)
|
||||
parentNode.attribs[attributeName] = attributeValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the ancestor element that should be changed based on the given selector.
|
||||
*
|
||||
* @param node The node whose ancestor should be found.
|
||||
* @param selector The found ancestor node or null if no node could be found.
|
||||
* @return The ancestor element, if it exists. {@link undefined} otherwise.
|
||||
*/
|
||||
const findTargetElement = (node: Node, selector: string): Element | null => {
|
||||
if (selector === 'slide') {
|
||||
return findNearestAncestorSection(node)
|
||||
} else if (selector === 'element') {
|
||||
return findParentElement(node)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent node if it is an {@link Element}.
|
||||
*
|
||||
* @param node the found node or null if no parent node exists or if the parent node isn't an {@link Element}.
|
||||
* @return The parent node, if it exists. {@link undefined} otherwise.
|
||||
*/
|
||||
const findParentElement = (node: Node): Element | null => {
|
||||
return node.parentNode !== null && isTag(node.parentNode) ? node.parentNode : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for the nearest ancestor of the node that is a section element.
|
||||
*
|
||||
* @param node the found section node or null if no section ancestor could be found.
|
||||
* @return The nearest ancestor element, if it exists. {@link undefined} otherwise.
|
||||
*/
|
||||
const findNearestAncestorSection = (node: Node): Element | null => {
|
||||
let currentNode = node.parentNode
|
||||
while (currentNode != null) {
|
||||
if (isTag(currentNode) && currentNode.tagName === 'section') {
|
||||
break
|
||||
}
|
||||
currentNode = node.parentNode
|
||||
}
|
||||
return currentNode
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { addSlideSectionsMarkdownItPlugin } from './reveal-sections'
|
||||
import { RevealCommentCommandNodePreprocessor } from './process-reveal-comment-nodes'
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
|
||||
/**
|
||||
* Adds support for reveal.js to the markdown rendering.
|
||||
* This includes the generation of sections and the manipulation of elements using reveal comments.
|
||||
*/
|
||||
export class RevealMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
addSlideSectionsMarkdownItPlugin(markdownIt)
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new RevealCommentCommandNodePreprocessor()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import Token from 'markdown-it/lib/token'
|
||||
import type StateCore from 'markdown-it/lib/rules_core/state_core'
|
||||
|
||||
/**
|
||||
* This functions adds a 'section close' token at currentTokenIndex in the state's token array,
|
||||
* replacing the current token, if replaceCurrentToken is true.
|
||||
* It also returns the currentTokenIndex, that will be increased only if the previous token was not replaced.
|
||||
*
|
||||
* @param {number} currentTokenIndex - the current position in the tokens array
|
||||
* @param {StateCore} state - the state core
|
||||
* @param {boolean} replaceCurrentToken - if the currentToken should be replaced
|
||||
*/
|
||||
const addSectionClose = (currentTokenIndex: number, state: StateCore, replaceCurrentToken: boolean): void => {
|
||||
const sectionCloseToken = new Token('section', 'section', -1)
|
||||
state.tokens.splice(currentTokenIndex, replaceCurrentToken ? 1 : 0, sectionCloseToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* This functions adds a 'section open' token at insertIndex in the state's token array.
|
||||
*
|
||||
* @param {number} insertIndex - the index at which the token should be added
|
||||
* @param {StateCore} state - the state core
|
||||
*/
|
||||
const addSectionOpen = (insertIndex: number, state: StateCore): void => {
|
||||
const sectionOpenToken = new Token('section', 'section', 1)
|
||||
state.tokens.splice(insertIndex, 0, sectionOpenToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a plugin to the given {@link MarkdownIt markdown it instance} that
|
||||
* replaces splits the content by horizontal lines and groups these blocks into
|
||||
* html section tags.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown it instance} to which the plugin should be added
|
||||
*/
|
||||
export const addSlideSectionsMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt): void => {
|
||||
markdownIt.core.ruler.push('reveal.sections', (state) => {
|
||||
let sectionBeginIndex = 0
|
||||
let lastSectionWasBranch = false
|
||||
|
||||
for (let currentTokenIndex = 0; currentTokenIndex < state.tokens.length; currentTokenIndex++) {
|
||||
const currentToken = state.tokens[currentTokenIndex]
|
||||
|
||||
if (currentToken.type !== 'hr') {
|
||||
continue
|
||||
}
|
||||
|
||||
addSectionOpen(sectionBeginIndex, state)
|
||||
currentTokenIndex += 1
|
||||
|
||||
if (currentToken.markup === '---' && lastSectionWasBranch) {
|
||||
lastSectionWasBranch = false
|
||||
addSectionClose(currentTokenIndex, state, false)
|
||||
currentTokenIndex += 1
|
||||
} else if (currentToken.markup === '----' && !lastSectionWasBranch) {
|
||||
lastSectionWasBranch = true
|
||||
addSectionOpen(sectionBeginIndex, state)
|
||||
currentTokenIndex += 1
|
||||
}
|
||||
|
||||
addSectionClose(currentTokenIndex, state, true)
|
||||
sectionBeginIndex = currentTokenIndex + 1
|
||||
}
|
||||
|
||||
addSectionOpen(sectionBeginIndex, state)
|
||||
addSectionClose(state.tokens.length, state, false)
|
||||
if (lastSectionWasBranch) {
|
||||
addSectionClose(state.tokens.length, state, false)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Document } from 'domhandler'
|
||||
import render from 'dom-serializer'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { parseDocument } from 'htmlparser2'
|
||||
import { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
|
||||
/**
|
||||
* Sanitizes the given {@link Document document}.
|
||||
*
|
||||
* @see https://cure53.de/purify
|
||||
*/
|
||||
export class SanitizerNodePreprocessor extends NodeProcessor {
|
||||
constructor(private tagNameWhiteList: string[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
process(nodes: Document): Document {
|
||||
const sanitizedHtml = DOMPurify.sanitize(render(nodes), {
|
||||
ADD_TAGS: this.tagNameWhiteList
|
||||
})
|
||||
return parseDocument(sanitizedHtml)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { SanitizerNodePreprocessor } from './dom-purifier-node-preprocessor'
|
||||
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for html sanitizing using dompurify to the markdown rendering.
|
||||
*/
|
||||
export class SanitizerMarkdownExtension extends MarkdownRendererExtension {
|
||||
constructor(private tagNameWhiteList: string[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new SanitizerNodePreprocessor(this.tagNameWhiteList)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import type { TocAst } from 'markdown-it-toc-done-right'
|
||||
import toc from 'markdown-it-toc-done-right'
|
||||
import { tocSlugify } from '../../editor-page/table-of-contents/toc-slugify'
|
||||
import { MarkdownRendererExtension } from './base/markdown-renderer-extension'
|
||||
import equal from 'fast-deep-equal'
|
||||
|
||||
/**
|
||||
* Adds table of content to the markdown rendering.
|
||||
*/
|
||||
export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly EVENT_NAME = 'TocChange'
|
||||
private lastAst: TocAst | undefined = undefined
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
toc(markdownIt, {
|
||||
placeholder: '(\\[TOC\\]|\\[toc\\])',
|
||||
listType: 'ul',
|
||||
level: [1, 2, 3],
|
||||
callback: (code: string, ast: TocAst): void => {
|
||||
if (equal(ast, this.lastAst)) {
|
||||
return
|
||||
}
|
||||
this.lastAst = ast
|
||||
this.eventEmitter?.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
|
||||
},
|
||||
slugify: tocSlugify
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { usePlaceholderSizeStyle } from '../image-placeholder/hooks/use-placeholder-size-style'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
export interface UploadIndicatingFrameProps {
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a placeholder frame for images that are currently uploaded.
|
||||
*
|
||||
* @param width The frame width
|
||||
* @param height The frame height
|
||||
*/
|
||||
export const UploadIndicatingFrame: React.FC<UploadIndicatingFrameProps> = ({ width, height }) => {
|
||||
const containerStyle = usePlaceholderSizeStyle(width, height)
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<span
|
||||
className='image-drop d-inline-flex flex-column align-items-center justify-content-center bg-primary text-white p-4'
|
||||
style={containerStyle}>
|
||||
<span className={'h1 border-bottom-0 my-2'}>
|
||||
<Trans i18nKey={'renderer.uploadIndicator.uploadMessage'} />
|
||||
</span>
|
||||
<ForkAwesomeIcon icon={'cog'} size={'5x'} fixedWidth={true} className='my-2 fa-spin' />
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MarkdownRendererExtension } from '../base/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../replace-components/component-replacer'
|
||||
import { UploadIndicatingImageFrameReplacer } from './upload-indicating-image-frame-replacer'
|
||||
|
||||
/**
|
||||
* A markdown extension that shows {@link UploadIndicatingFrame} for images that are getting uploaded.
|
||||
*/
|
||||
export class UploadIndicatingImageFrameMarkdownExtension extends MarkdownRendererExtension {
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new UploadIndicatingImageFrameReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NodeReplacement } from '../../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
|
||||
import type { Element } from 'domhandler'
|
||||
import { UploadIndicatingFrame } from './upload-indicating-frame'
|
||||
|
||||
const uploadIdRegex = /^upload-(.+)$/
|
||||
|
||||
/**
|
||||
* Replaces an image tag whose url is an upload-id with the {@link UploadIndicatingFrame upload indicating frame}.
|
||||
*/
|
||||
export class UploadIndicatingImageFrameReplacer extends ComponentReplacer {
|
||||
replace(node: Element): NodeReplacement {
|
||||
return node.name !== 'img' || !uploadIdRegex.test(node.attribs.src) ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<UploadIndicatingFrame width={node.attribs.width} height={node.attribs.height} />
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { Document } from 'domhandler'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Creates a function that applies the node preprocessors of every given {@link MarkdownRendererExtension} to a {@link Document}.
|
||||
*
|
||||
* @param extensions The extensions who provide node processors
|
||||
* @return The created apply function
|
||||
*/
|
||||
export const useCombinedNodePreprocessor = (extensions: MarkdownRendererExtension[]): ((nodes: Document) => Document) =>
|
||||
useMemo(() => {
|
||||
return extensions
|
||||
.flatMap((extension) => extension.buildNodeProcessors())
|
||||
.reduce(
|
||||
(state, processor) => (document: Document) => state(processor.process(document)),
|
||||
(document: Document) => document
|
||||
)
|
||||
}, [extensions])
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import MarkdownIt from 'markdown-it/lib'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Creates a new {@link MarkdownIt markdown-it instance} and configures it using the given {@link MarkdownRendererExtension markdown renderer extensions}.
|
||||
*
|
||||
* @param extensions The extensions that configure the new markdown-it instance
|
||||
* @param allowHtml Defines if html in markdown is allowed
|
||||
* @param newlinesAreBreaks Defines if new lines should be treated as line breaks or paragraphs
|
||||
* @return the created markdown-it instance
|
||||
*/
|
||||
export const useConfiguredMarkdownIt = (
|
||||
extensions: MarkdownRendererExtension[],
|
||||
allowHtml: boolean,
|
||||
newlinesAreBreaks: boolean
|
||||
): MarkdownIt => {
|
||||
return useMemo(() => {
|
||||
const newMarkdownIt = new MarkdownIt('default', {
|
||||
html: allowHtml,
|
||||
breaks: newlinesAreBreaks,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
extensions.forEach((extension) => newMarkdownIt.use((markdownIt) => extension.configureMarkdownIt(markdownIt)))
|
||||
extensions.forEach((extension) => newMarkdownIt.use((markdownIt) => extension.configureMarkdownItPost(markdownIt)))
|
||||
return newMarkdownIt
|
||||
}, [allowHtml, extensions, newlinesAreBreaks])
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import type { ValidReactDomElement } from '../replace-components/component-replacer'
|
||||
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
||||
import { NodeToReactTransformer } from '../utils/node-to-react-transformer'
|
||||
import { LineIdMapper } from '../utils/line-id-mapper'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
import { SanitizerMarkdownExtension } from '../extensions/sanitizer/sanitizer-markdown-extension'
|
||||
import { useCombinedNodePreprocessor } from './use-combined-node-preprocessor'
|
||||
import { useConfiguredMarkdownIt } from './use-configured-markdown-it'
|
||||
|
||||
/**
|
||||
* Renders Markdown-Code into react elements.
|
||||
*
|
||||
* @param markdownContentLines The Markdown code lines that should be rendered
|
||||
* @param additionalMarkdownExtensions A list of {@link MarkdownRendererExtension markdown extensions} that should be used
|
||||
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
|
||||
* @param allowHtml Defines if html is allowed in markdown
|
||||
* @return The React DOM that represents the rendered Markdown code
|
||||
*/
|
||||
export const useConvertMarkdownToReactDom = (
|
||||
markdownContentLines: string[],
|
||||
additionalMarkdownExtensions: MarkdownRendererExtension[],
|
||||
newlinesAreBreaks = true,
|
||||
allowHtml = true
|
||||
): ValidReactDomElement => {
|
||||
const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
|
||||
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
|
||||
const markdownExtensions = useMemo(() => {
|
||||
const tagWhiteLists = additionalMarkdownExtensions.flatMap((extension) => extension.buildTagNameAllowList())
|
||||
return [...additionalMarkdownExtensions, new SanitizerMarkdownExtension(tagWhiteLists)]
|
||||
}, [additionalMarkdownExtensions])
|
||||
|
||||
useMemo(() => {
|
||||
htmlToReactTransformer.setReplacers(markdownExtensions.flatMap((extension) => extension.buildReplacers()))
|
||||
}, [htmlToReactTransformer, markdownExtensions])
|
||||
|
||||
useMemo(() => {
|
||||
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines))
|
||||
}, [htmlToReactTransformer, lineNumberMapper, markdownContentLines])
|
||||
|
||||
const nodePreProcessor = useCombinedNodePreprocessor(markdownExtensions)
|
||||
const markdownIt = useConfiguredMarkdownIt(markdownExtensions, allowHtml, newlinesAreBreaks)
|
||||
|
||||
return useMemo(() => {
|
||||
const html = markdownIt.render(markdownContentLines.join('\n'))
|
||||
htmlToReactTransformer.resetReplacers()
|
||||
|
||||
return (
|
||||
<Fragment key={'root'}>
|
||||
{convertHtmlToReact(html, {
|
||||
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
|
||||
preprocessNodes: (document) => nodePreProcessor(document)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react'
|
||||
import EventEmitter2 from 'eventemitter2'
|
||||
|
||||
export const eventEmitterContext = createContext<EventEmitter2 | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Provides the {@link EventEmitter2 event emitter} from the current {@link eventEmitterContext context}.
|
||||
*/
|
||||
export const useExtensionEventEmitter = () => {
|
||||
return useContext(eventEmitterContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link EventEmitter2 event emitter} and provides it as {@link eventEmitterContext context}.
|
||||
*
|
||||
* @param children The elements that should receive the context value
|
||||
*/
|
||||
export const ExtensionEventEmitterProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const eventEmitter = useMemo(() => new EventEmitter2(), [])
|
||||
return <eventEmitterContext.Provider value={eventEmitter}>{children}</eventEmitterContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler callback on the current {@link EventEmitter2 event emitter} that is provided in the {@link eventEmitterContext context}.
|
||||
*
|
||||
* @param eventName The name of the event which should be subscribed
|
||||
* @param handler The callback that should be executed. If undefined the event will be unsubscribed.
|
||||
*/
|
||||
export const useExtensionEventEmitterHandler = <T,>(
|
||||
eventName: string,
|
||||
handler: ((values: T) => void) | undefined
|
||||
): void => {
|
||||
const eventEmitter = useExtensionEventEmitter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventEmitter || !handler) {
|
||||
return
|
||||
}
|
||||
eventEmitter.on(eventName, handler)
|
||||
return () => {
|
||||
eventEmitter.off(eventName, handler)
|
||||
}
|
||||
}, [eventEmitter, eventName, handler])
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
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])
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { GenericSyntaxMarkdownExtension } from '../extensions/generic-syntax-markdown-extension'
|
||||
import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||
import { DebuggerMarkdownExtension } from '../extensions/debugger-markdown-extension'
|
||||
import { UploadIndicatingImageFrameMarkdownExtension } from '../extensions/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension'
|
||||
import { IframeCapsuleMarkdownExtension } from '../extensions/iframe-capsule/iframe-capsule-markdown-extension'
|
||||
import { LinkifyFixMarkdownExtension } from '../extensions/linkify-fix/linkify-fix-markdown-extension'
|
||||
import { LinkAdjustmentMarkdownExtension } from '../extensions/link-replacer/link-adjustment-markdown-extension'
|
||||
import { EmojiMarkdownExtension } from '../extensions/emoji/emoji-markdown-extension'
|
||||
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
|
||||
import { TableOfContentsMarkdownExtension } from '../extensions/table-of-contents-markdown-extension'
|
||||
import { ImagePlaceholderMarkdownExtension } from '../extensions/image-placeholder/image-placeholder-markdown-extension'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
import { useExtensionEventEmitter } from './use-extension-event-emitter'
|
||||
import { optionalAppExtensions } from '../../../extensions/extra-integrations/optional-app-extensions'
|
||||
import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension'
|
||||
|
||||
const optionalMarkdownRendererExtensions = optionalAppExtensions.flatMap((value) =>
|
||||
value.buildMarkdownRendererExtensions()
|
||||
)
|
||||
|
||||
/**
|
||||
* Provides a list of {@link MarkdownRendererExtension markdown extensions} that is a combination of the common extensions and the given additional.
|
||||
*
|
||||
* @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension}
|
||||
* @param currentLineMarkers A {@link MutableRefObject reference} to {@link LineMarkers} for the {@link LinemarkerMarkdownExtension}
|
||||
* @param additionalExtensions The additional extensions that should be included in the list
|
||||
* @return The created list of markdown extensions
|
||||
*/
|
||||
export const useMarkdownExtensions = (
|
||||
baseUrl: string,
|
||||
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
|
||||
additionalExtensions: MarkdownRendererExtension[]
|
||||
): MarkdownRendererExtension[] => {
|
||||
const extensionEventEmitter = useExtensionEventEmitter()
|
||||
//replace with global list
|
||||
return useMemo(() => {
|
||||
return [
|
||||
...optionalMarkdownRendererExtensions,
|
||||
...additionalExtensions,
|
||||
new TableOfContentsMarkdownExtension(extensionEventEmitter),
|
||||
new LinemarkerMarkdownExtension(
|
||||
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined
|
||||
),
|
||||
new IframeCapsuleMarkdownExtension(),
|
||||
new ImagePlaceholderMarkdownExtension(),
|
||||
new UploadIndicatingImageFrameMarkdownExtension(),
|
||||
new LinkAdjustmentMarkdownExtension(baseUrl),
|
||||
new EmojiMarkdownExtension(),
|
||||
new GenericSyntaxMarkdownExtension(),
|
||||
new LinkifyFixMarkdownExtension(),
|
||||
new DebuggerMarkdownExtension(),
|
||||
new ProxyImageMarkdownExtension()
|
||||
]
|
||||
}, [additionalExtensions, baseUrl, currentLineMarkers, extensionEventEmitter])
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import equal from 'fast-deep-equal'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Check if the given reference changes and then calls the callback onChange.
|
||||
*
|
||||
* @param reference The reference to observe
|
||||
* @param onChange The callback to call if something changes
|
||||
*/
|
||||
export const useOnRefChange = <T>(reference: MutableRefObject<T>, onChange?: (newValue: T) => void): void => {
|
||||
const lastValue = useRef<T>()
|
||||
useEffect(() => {
|
||||
if (onChange && !equal(reference.current, lastValue.current)) {
|
||||
lastValue.current = reference.current
|
||||
onChange(reference.current)
|
||||
}
|
||||
})
|
||||
}
|
100
frontend/src/components/markdown-renderer/hooks/use-reveal.ts
Normal file
100
frontend/src/components/markdown-renderer/hooks/use-reveal.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type Reveal from 'reveal.js'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import type { SlideOptions } from '../../../redux/note-details/types/slide-show-options'
|
||||
|
||||
const log = new Logger('reveal.js')
|
||||
|
||||
export enum REVEAL_STATUS {
|
||||
NOT_INITIALISED,
|
||||
INITIALISING,
|
||||
INITIALISED
|
||||
}
|
||||
|
||||
export interface SlideState {
|
||||
indexHorizontal: number
|
||||
indexVertical: number
|
||||
}
|
||||
|
||||
const initialSlideState: SlideState = {
|
||||
indexHorizontal: 0,
|
||||
indexVertical: 0
|
||||
}
|
||||
/**
|
||||
* Initialize reveal.js and renders the document as a reveal.js presentation.
|
||||
*
|
||||
* @param markdownContentLines An array of markdown lines.
|
||||
* @param slideOptions The slide options.
|
||||
* @return The current state of reveal.js
|
||||
* @see https://revealjs.com/
|
||||
*/
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (revealStatus !== REVEAL_STATUS.NOT_INITIALISED) {
|
||||
return
|
||||
}
|
||||
setRevealStatus(REVEAL_STATUS.INITIALISING)
|
||||
log.debug('Initialize with slide options', slideOptions)
|
||||
|
||||
import(/* webpackChunkName: "reveal" */ 'reveal.js')
|
||||
.then((revealImport) => {
|
||||
const reveal = new revealImport.default({})
|
||||
reveal
|
||||
.initialize()
|
||||
.then(() => {
|
||||
reveal.layout()
|
||||
reveal.slide(0, 0, 0)
|
||||
reveal.addEventListener('slidechanged', (event) => {
|
||||
currentSlideState.current = {
|
||||
indexHorizontal: event.indexh,
|
||||
indexVertical: event.indexv ?? 0
|
||||
} as SlideState
|
||||
})
|
||||
|
||||
setDeck(reveal)
|
||||
setRevealStatus(REVEAL_STATUS.INITIALISED)
|
||||
log.debug('Initialisation finished')
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
log.error('Error while initializing reveal.js', error)
|
||||
})
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
log.error('Error while loading reveal.js', error)
|
||||
})
|
||||
}, [revealStatus, slideOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!deck || revealStatus !== REVEAL_STATUS.INITIALISED) {
|
||||
return
|
||||
}
|
||||
log.debug('Sync deck')
|
||||
deck.sync()
|
||||
deck.slide(currentSlideState.current.indexHorizontal, currentSlideState.current.indexVertical)
|
||||
}, [markdownContentLines, deck, revealStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!deck ||
|
||||
slideOptions === undefined ||
|
||||
Object.keys(slideOptions).length === 0 ||
|
||||
revealStatus !== REVEAL_STATUS.INITIALISED
|
||||
) {
|
||||
return
|
||||
}
|
||||
log.debug('Apply config', slideOptions)
|
||||
deck.configure(slideOptions)
|
||||
}, [deck, revealStatus, slideOptions])
|
||||
|
||||
return revealStatus
|
||||
}
|
22
frontend/src/components/markdown-renderer/loading-slide.tsx
Normal file
22
frontend/src/components/markdown-renderer/loading-slide.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Shows a static text placeholder while reveal.js is loading.
|
||||
*/
|
||||
export const LoadingSlide: React.FC = () => {
|
||||
useTranslation()
|
||||
return (
|
||||
<section>
|
||||
<h1>
|
||||
<Trans i18nKey={'common.loading'} />
|
||||
</h1>
|
||||
</section>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Document } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Base class for node processors.
|
||||
*/
|
||||
export abstract class NodeProcessor {
|
||||
public abstract process(document: Document): Document
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { NodeProcessor } from './node-processor'
|
||||
import type { Document, Node } from 'domhandler'
|
||||
import { hasChildren } from 'domhandler'
|
||||
|
||||
/**
|
||||
* Base class for traveler node processors whose processing is executed on the given node and every child.
|
||||
*/
|
||||
export abstract class TravelerNodeProcessor extends NodeProcessor {
|
||||
process(nodes: Document): Document {
|
||||
this.processNodes(nodes.children)
|
||||
return nodes
|
||||
}
|
||||
|
||||
private processNodes(nodes: Node[]): void {
|
||||
nodes.forEach((node) => {
|
||||
this.processNode(node)
|
||||
if (hasChildren(node)) {
|
||||
this.processNodes(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected abstract processNode(node: Node): void
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.click-shield {
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-hover {
|
||||
color: white;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
text-shadow: #000000 0 0 5px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-hover-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.preview-hover-text {
|
||||
opacity: 1;
|
||||
}
|
||||
.preview-hover {
|
||||
opacity: 1;
|
||||
text-shadow: #000000 0 0 5px, #000000 0 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-background {
|
||||
background: none;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
|
||||
box-shadow: inset rgba(var(--bs-dark), 0.5) 0 0 20px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { IconName } from '../../../common/fork-awesome/types'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import styles from './click-shield.module.scss'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
import type { Property } from 'csstype'
|
||||
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { ProxyImageFrame } from '../../extensions/image/proxy-image-frame'
|
||||
|
||||
const log = new Logger('OneClickEmbedding')
|
||||
|
||||
export interface ClickShieldProps extends PropsWithChildren<PropsWithDataCypressId> {
|
||||
onImageFetch?: () => Promise<string>
|
||||
fallbackPreviewImageUrl?: string
|
||||
hoverIcon: IconName
|
||||
targetDescription: string
|
||||
containerClassName?: string
|
||||
fallbackBackgroundColor?: Property.BackgroundColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents loading of the children elements until the user unlocks the content by e.g. clicking.
|
||||
*
|
||||
* @param containerClassName Additional CSS classes for the complete component
|
||||
* @param onImageFetch A callback that is used to get an URL for the preview image
|
||||
* @param fallbackPreviewImageUrl The URL for an image that should be shown. If onImageFetch is defined then this image will be shown until onImageFetch provides another URL.
|
||||
* @param targetDescription The name of the target service
|
||||
* @param hoverIcon The name of an icon that should be shown in the preview
|
||||
* @param fallbackBackgroundColor A color that should be used if no background image was provided or could be loaded.
|
||||
* @param children The children element that should be shielded.
|
||||
*/
|
||||
export const ClickShield: React.FC<ClickShieldProps> = ({
|
||||
containerClassName,
|
||||
onImageFetch,
|
||||
fallbackPreviewImageUrl,
|
||||
children,
|
||||
targetDescription,
|
||||
hoverIcon,
|
||||
fallbackBackgroundColor,
|
||||
...props
|
||||
}) => {
|
||||
const [showChildren, setShowChildren] = useState(false)
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState(fallbackPreviewImageUrl)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const doShowChildren = useCallback(() => {
|
||||
setShowChildren(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onImageFetch) {
|
||||
return
|
||||
}
|
||||
onImageFetch()
|
||||
.then((imageLink) => {
|
||||
setPreviewImageUrl(imageLink)
|
||||
})
|
||||
.catch((message) => {
|
||||
log.error(message)
|
||||
})
|
||||
}, [onImageFetch])
|
||||
|
||||
const fallbackBackgroundStyle = useMemo<React.CSSProperties>(
|
||||
() =>
|
||||
!fallbackBackgroundColor
|
||||
? {}
|
||||
: {
|
||||
backgroundColor: fallbackBackgroundColor
|
||||
},
|
||||
[fallbackBackgroundColor]
|
||||
)
|
||||
|
||||
const previewHoverText = useMemo(() => {
|
||||
return targetDescription ? t('renderer.clickShield.previewHoverText', { target: targetDescription }) : ''
|
||||
}, [t, targetDescription])
|
||||
|
||||
const previewBackground = useMemo(() => {
|
||||
return previewImageUrl === undefined ? (
|
||||
<span
|
||||
className={`${styles['preview-background']} embed-responsive-item`}
|
||||
{...cypressId('preview-background')}
|
||||
style={fallbackBackgroundStyle}
|
||||
/>
|
||||
) : (
|
||||
<ProxyImageFrame
|
||||
{...cypressId('preview-background')}
|
||||
className={`${styles['preview-background']} embed-responsive-item`}
|
||||
style={fallbackBackgroundStyle}
|
||||
src={previewImageUrl}
|
||||
alt={previewHoverText}
|
||||
title={previewHoverText}
|
||||
/>
|
||||
)
|
||||
}, [fallbackBackgroundStyle, previewHoverText, previewImageUrl])
|
||||
|
||||
const hoverTextTranslationValues = useMemo(() => ({ target: targetDescription }), [targetDescription])
|
||||
|
||||
return (
|
||||
<span className={containerClassName} {...cypressId(props['data-cypress-id'])}>
|
||||
<ShowIf condition={showChildren}>{children}</ShowIf>
|
||||
<ShowIf condition={!showChildren}>
|
||||
<span className={`${styles['click-shield']} d-inline-block ratio ratio-16x9`} onClick={doShowChildren}>
|
||||
{previewBackground}
|
||||
<span className={`${styles['preview-hover']}`}>
|
||||
<span className={`${styles['preview-hover-text']}`}>
|
||||
<Trans i18nKey={'renderer.clickShield.previewHoverText'} values={hoverTextTranslationValues} />
|
||||
</span>
|
||||
<ForkAwesomeIcon icon={hoverIcon} size={'5x'} className={'mb-2'} />
|
||||
</span>
|
||||
</span>
|
||||
</ShowIf>
|
||||
</span>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NodeReplacement } from './component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from './component-replacer'
|
||||
import type { FunctionComponent } from 'react'
|
||||
import React from 'react'
|
||||
import type { Element } from 'domhandler'
|
||||
|
||||
export interface CodeProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given checked node is a code block with a specific language attribute and creates an react-element that receives the code.
|
||||
*/
|
||||
export class CodeBlockComponentReplacer extends ComponentReplacer {
|
||||
constructor(private component: FunctionComponent<CodeProps>, private language: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
replace(node: Element): NodeReplacement {
|
||||
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(node, this.language)
|
||||
return code ? React.createElement(this.component, { code: code }) : DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the text content if the given {@link Element} is a code block with a specific language.
|
||||
*
|
||||
* @param element The {@link Element} to check.
|
||||
* @param language The language that code block should be assigned to.
|
||||
* @return The text content or undefined if the element isn't a code block or has the wrong language attribute.
|
||||
*/
|
||||
public static extractTextFromCodeNode(element: Element, language: string): string | undefined {
|
||||
return element.name === 'code' && element.attribs['data-highlight-language'] === language && element.children[0]
|
||||
? ComponentReplacer.extractTextChildContent(element)
|
||||
: undefined
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element, Node } from 'domhandler'
|
||||
import { isText } from 'domhandler'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
export type ValidReactDomElement = ReactElement | string | null
|
||||
|
||||
export type SubNodeTransform = (node: Node, subKey: number | string) => ValidReactDomElement
|
||||
|
||||
export type NativeRenderer = () => ValidReactDomElement
|
||||
|
||||
export const DO_NOT_REPLACE = Symbol()
|
||||
|
||||
export type NodeReplacement = ValidReactDomElement | typeof DO_NOT_REPLACE
|
||||
|
||||
/**
|
||||
* Base class for all component replacers.
|
||||
* Component replacers detect structures in the HTML DOM from markdown it
|
||||
* and replace them with some special react components.
|
||||
*/
|
||||
export abstract class ComponentReplacer {
|
||||
/**
|
||||
* Extracts the content of the first text child node.
|
||||
*
|
||||
* @param node the node with the text node child
|
||||
* @return the string content
|
||||
*/
|
||||
protected static extractTextChildContent(node: Element): string {
|
||||
const childrenTextNode = node.children[0]
|
||||
return isText(childrenTextNode) ? childrenTextNode.data : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the given {@link SubNodeTransform sub node transformer} to every children of the given {@link Node}.
|
||||
*
|
||||
* @param node The node whose children should be transformed
|
||||
* @param subNodeTransform The transformer that should be used.
|
||||
* @return The children as react elements.
|
||||
*/
|
||||
protected static transformChildren(node: Element, subNodeTransform: SubNodeTransform): ValidReactDomElement[] {
|
||||
return node.children.map((value, index) => subNodeTransform(value, index))
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be used to reset the replacers internal state before rendering.
|
||||
*/
|
||||
public reset(): void {
|
||||
// left blank for overrides
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current node should be altered or replaced and does if needed.
|
||||
*
|
||||
* @param node The current html dom node
|
||||
* @param subNodeTransform should be used to convert child elements of the current node
|
||||
* @param nativeRenderer renders the current node without any replacement
|
||||
* @return the replacement for the current node or undefined if the current replacer replacer hasn't done anything.
|
||||
*/
|
||||
public abstract replace(
|
||||
node: Element,
|
||||
subNodeTransform: SubNodeTransform,
|
||||
nativeRenderer: NativeRenderer
|
||||
): NodeReplacement
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NodeReplacement } from './component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from './component-replacer'
|
||||
import type { FunctionComponent } from 'react'
|
||||
import React from 'react'
|
||||
import type { Element } from 'domhandler'
|
||||
|
||||
export interface IdProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces custom tags that have just an id (<app-something id="something"/>) with react elements.
|
||||
*/
|
||||
export class CustomTagWithIdComponentReplacer extends ComponentReplacer {
|
||||
constructor(private component: FunctionComponent<IdProps>, private tagName: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
public replace(node: Element): NodeReplacement {
|
||||
const id = this.extractId(node)
|
||||
return id ? React.createElement(this.component, { id: id }) : DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given {@link Element} is a custom tag and extracts its `id` attribute.
|
||||
*
|
||||
* @param element The element to check.
|
||||
* @return the extracted id or undefined if the element isn't a custom tag or has no id attribute.
|
||||
*/
|
||||
private extractId(element: Element): string | undefined {
|
||||
return element.name === this.tagName && element.attribs && element.attribs.id ? element.attribs.id : undefined
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
|
||||
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
|
||||
import { REVEAL_STATUS, useReveal } from './hooks/use-reveal'
|
||||
import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props'
|
||||
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
|
||||
import { LoadingSlide } from './loading-slide'
|
||||
import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
|
||||
import type { SlideOptions } from '../../redux/note-details/types/slide-show-options'
|
||||
import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension'
|
||||
|
||||
export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps {
|
||||
slideOptions?: SlideOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the note as a reveal.js presentation.
|
||||
*
|
||||
* @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 onTocChange The callback to call if the toc changes.
|
||||
* @param baseUrl The base url of the renderer
|
||||
* @param onImageClick The callback to call if a image is clicked
|
||||
* @param newlinesAreBreaks If newlines are rendered as breaks or not
|
||||
* @param slideOptions The {@link SlideOptions} to use
|
||||
*/
|
||||
export const SlideshowMarkdownRenderer: React.FC<SlideshowMarkdownRendererProps & ScrollProps> = ({
|
||||
className,
|
||||
markdownContentLines,
|
||||
onFirstHeadingChange,
|
||||
baseUrl,
|
||||
newlinesAreBreaks,
|
||||
slideOptions
|
||||
}) => {
|
||||
const markdownBodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const extensions = useMarkdownExtensions(
|
||||
baseUrl,
|
||||
undefined,
|
||||
useMemo(() => [new RevealMarkdownExtension()], [])
|
||||
)
|
||||
|
||||
const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true)
|
||||
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 ? markdownReactDom : <LoadingSlide />),
|
||||
[markdownReactDom, revealStatus]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={'reveal'}>
|
||||
<div ref={markdownBodyRef} className={`${className ?? ''} slides`}>
|
||||
{slideShowDOM}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { TFunction } from 'i18next'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Initializes i18n with minimal settings and without any data, so it just returns the used key as translation.
|
||||
*
|
||||
* @return A promise that resolves if i18n has been initialized
|
||||
*/
|
||||
export const mockI18n = (): Promise<TFunction> => {
|
||||
return i18n.use(initReactI18next).init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
ns: ['translationsNS'],
|
||||
defaultNS: 'translationsNS',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
resources: { en: { translationsNS: {} } }
|
||||
})
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { useConvertMarkdownToReactDom } from '../hooks/use-convert-markdown-to-react-dom'
|
||||
import { StoreProvider } from '../../../redux/store-provider'
|
||||
import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension'
|
||||
|
||||
export interface SimpleMarkdownRendererProps {
|
||||
content: string
|
||||
extensions: MarkdownRendererExtension[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A markdown renderer for tests.
|
||||
*
|
||||
* @param content The content to be rendered.
|
||||
* @param extensions The {@link MarkdownRendererExtension MarkdownExtensions} to use for rendering.
|
||||
*/
|
||||
export const TestMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
|
||||
const lines = useMemo(() => content.split('\n'), [content])
|
||||
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
|
||||
|
||||
return <StoreProvider>{dom}</StoreProvider>
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import equal from 'fast-deep-equal'
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import useResizeObserver from '@react-hook/resize-observer'
|
||||
import type { LineMarkerPosition } from '../extensions/linemarker/types'
|
||||
import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin'
|
||||
|
||||
const calculateLineMarkerPositions = (
|
||||
documentElement: HTMLDivElement,
|
||||
currentLineMarkers: LineMarkers[],
|
||||
offset?: number
|
||||
): LineMarkerPosition[] => {
|
||||
const lineMarkers = currentLineMarkers
|
||||
const children: HTMLCollection = documentElement.children
|
||||
const lineMarkerPositions: LineMarkerPosition[] = []
|
||||
|
||||
Array.from(children).forEach((child, childIndex) => {
|
||||
const htmlChild = child as HTMLElement
|
||||
if (htmlChild.offsetTop === undefined) {
|
||||
return
|
||||
}
|
||||
const currentLineMarker = lineMarkers[childIndex]
|
||||
if (currentLineMarker === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastPosition = lineMarkerPositions[lineMarkerPositions.length - 1]
|
||||
if (!lastPosition || lastPosition.line !== currentLineMarker.startLine) {
|
||||
lineMarkerPositions.push({
|
||||
line: currentLineMarker.startLine,
|
||||
position: htmlChild.offsetTop + (offset ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
lineMarkerPositions.push({
|
||||
line: currentLineMarker.endLine,
|
||||
position: htmlChild.offsetTop + htmlChild.offsetHeight + (offset ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return lineMarkerPositions
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the positions of the given {@link LineMarkers} in the given {@link Document}.
|
||||
*
|
||||
* @param documentElement A reference to the rendered document.
|
||||
* @param lineMarkers A list of {@link LineMarkers}
|
||||
* @param onLineMarkerPositionChanged The callback to call if the {@link LineMarkerPosition line marker positions} change e.g. by rendering or resizing.
|
||||
* @param offset The optional offset
|
||||
*/
|
||||
export const useCalculateLineMarkerPosition = (
|
||||
documentElement: RefObject<HTMLDivElement>,
|
||||
lineMarkers?: LineMarkers[],
|
||||
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void,
|
||||
offset?: number
|
||||
): void => {
|
||||
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
|
||||
|
||||
const calculateNewLineMarkerPositions = useCallback(() => {
|
||||
if (!documentElement.current || !onLineMarkerPositionChanged || !lineMarkers) {
|
||||
return
|
||||
}
|
||||
|
||||
const newLines = calculateLineMarkerPositions(documentElement.current, lineMarkers, offset)
|
||||
|
||||
if (!equal(newLines, lastLineMarkerPositions)) {
|
||||
lastLineMarkerPositions.current = newLines
|
||||
onLineMarkerPositionChanged(newLines)
|
||||
}
|
||||
}, [documentElement, lineMarkers, offset, onLineMarkerPositionChanged])
|
||||
|
||||
useEffect(() => {
|
||||
calculateNewLineMarkerPositions()
|
||||
}, [calculateNewLineMarkerPositions])
|
||||
|
||||
useResizeObserver(documentElement, calculateNewLineMarkerPositions)
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { LineIdMapper } from './line-id-mapper'
|
||||
|
||||
describe('line id mapper', () => {
|
||||
let lineIdMapper: LineIdMapper
|
||||
|
||||
beforeEach(() => {
|
||||
lineIdMapper = new LineIdMapper()
|
||||
})
|
||||
|
||||
it('should be case sensitive', () => {
|
||||
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
|
||||
expect(lineIdMapper.updateLineMapping(['this', 'is', 'Text'])).toEqual([
|
||||
{
|
||||
line: 'this',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
line: 'is',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
line: 'Text',
|
||||
id: 4
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should not update line ids of shifted lines', () => {
|
||||
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
|
||||
expect(lineIdMapper.updateLineMapping(['this', 'is', 'more', 'text'])).toEqual([
|
||||
{
|
||||
line: 'this',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
line: 'is',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
line: 'more',
|
||||
id: 4
|
||||
},
|
||||
{
|
||||
line: 'text',
|
||||
id: 3
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should not update line ids if nothing changes', () => {
|
||||
lineIdMapper.updateLineMapping(['this', 'is', 'text'])
|
||||
expect(lineIdMapper.updateLineMapping(['this', 'is', 'text'])).toEqual([
|
||||
{
|
||||
line: 'this',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
line: 'is',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
line: 'text',
|
||||
id: 3
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should not reuse line ids of removed lines', () => {
|
||||
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
|
||||
lineIdMapper.updateLineMapping(['this', 'is'])
|
||||
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
|
||||
{
|
||||
line: 'this',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
line: 'is',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
line: 'new',
|
||||
id: 4
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should update line ids for changed lines', () => {
|
||||
lineIdMapper.updateLineMapping(['this', 'is', 'old'])
|
||||
expect(lineIdMapper.updateLineMapping(['this', 'is', 'new'])).toEqual([
|
||||
{
|
||||
line: 'this',
|
||||
id: 1
|
||||
},
|
||||
{
|
||||
line: 'is',
|
||||
id: 2
|
||||
},
|
||||
{
|
||||
line: 'new',
|
||||
id: 4
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ArrayChange } from 'diff'
|
||||
import { diffArrays } from 'diff'
|
||||
import type { LineWithId } from '../extensions/linemarker/types'
|
||||
|
||||
type NewLine = string
|
||||
type LineChange = ArrayChange<NewLine | LineWithId>
|
||||
|
||||
/**
|
||||
* Calculates ids for every line in a given text and memorized the state of the last given text.
|
||||
* It also assigns ids for new lines on every update.
|
||||
*/
|
||||
export class LineIdMapper {
|
||||
private lastLines: LineWithId[] = []
|
||||
private lastUsedLineId = 0
|
||||
|
||||
/**
|
||||
* Calculates a line id mapping for the given line based text by creating a diff
|
||||
* with the last lines code.
|
||||
*
|
||||
* @param newMarkdownContentLines The markdown content for which the line ids should be calculated
|
||||
* @return the calculated {@link LineWithId lines with unique ids}
|
||||
*/
|
||||
public updateLineMapping(newMarkdownContentLines: string[]): LineWithId[] {
|
||||
const lineDifferences = this.diffNewLinesWithLastLineKeys(newMarkdownContentLines)
|
||||
const newLineKeys = this.convertChangesToLinesWithIds(lineDifferences)
|
||||
this.lastLines = newLineKeys
|
||||
return newLineKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a diff between the given {@link string lines} and the existing {@link LineWithId lines with unique ids}.
|
||||
* The diff is based on the line content.
|
||||
*
|
||||
* @param lines The plain lines that describe the new state.
|
||||
* @return {@link LineChange line changes} that describe the difference between the given and the old lines. Because of the way the used diff-lib works, the ADDED lines will be tagged as "removed", because if two lines are the same the lib takes the line from the NEW lines, which results in a loss of the unique id.
|
||||
*/
|
||||
private diffNewLinesWithLastLineKeys(lines: string[]): LineChange[] {
|
||||
return diffArrays<NewLine, LineWithId>(lines, this.lastLines, {
|
||||
comparator: (left: NewLine | LineWithId, right: NewLine | LineWithId) => {
|
||||
const leftLine = (left as LineWithId).line ?? (left as NewLine)
|
||||
const rightLine = (right as LineWithId).line ?? (right as NewLine)
|
||||
return leftLine === rightLine
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given {@link LineChange line changes} to {@link lines with unique ids}.
|
||||
* Only not changed or added lines will be processed.
|
||||
*
|
||||
* @param changes The {@link LineChange changes} whose lines should be converted.
|
||||
* @return The created or reused {@link LineWithId lines with ids}
|
||||
*/
|
||||
private convertChangesToLinesWithIds(changes: LineChange[]): LineWithId[] {
|
||||
return changes
|
||||
.filter((change) => LineIdMapper.changeIsNotChangingLines(change) || LineIdMapper.changeIsAddingLines(change))
|
||||
.reduce(
|
||||
(previousLineKeys, currentChange) => [...previousLineKeys, ...this.convertChangeToLinesWithIds(currentChange)],
|
||||
[] as LineWithId[]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines if the given {@link LineChange change} is neither adding or removing lines.
|
||||
*
|
||||
* @param change The {@link LineChange change} to check.
|
||||
* @return {@link true} if the given change is neither adding nor removing lines.
|
||||
*/
|
||||
private static changeIsNotChangingLines(change: LineChange): boolean {
|
||||
return change.added === undefined && change.removed === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines if the given {@link LineChange change} contains new, not existing lines.
|
||||
*
|
||||
* @param change The {@link LineChange change} to check.
|
||||
* @return {@link true} if the given change contains {@link NewLine new lines}
|
||||
*/
|
||||
private static changeIsAddingLines(change: LineChange): change is ArrayChange<NewLine> {
|
||||
return change.removed === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given {@link LineChange change} into {@link LineWithId lines with unique ids} by inspecting the contained lines.
|
||||
* This is done by either reusing the existing ids (if the line wasn't added),
|
||||
* or by assigning new, unused line ids.
|
||||
*
|
||||
* @param change The {@link LineChange change} whose lines should be converted.
|
||||
* @return The created or reused {@link LineWithId lines with ids}
|
||||
*/
|
||||
private convertChangeToLinesWithIds(change: LineChange): LineWithId[] {
|
||||
if (LineIdMapper.changeIsAddingLines(change)) {
|
||||
return change.value.map((line) => {
|
||||
this.lastUsedLineId += 1
|
||||
return { line: line, id: this.lastUsedLineId }
|
||||
})
|
||||
} else {
|
||||
return change.value as LineWithId[]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { NodeToReactTransformer } from './node-to-react-transformer'
|
||||
import { Element } from 'domhandler'
|
||||
import type { ReactElement, ReactHTMLElement } from 'react'
|
||||
import type { NodeReplacement } from '../replace-components/component-replacer'
|
||||
import { ComponentReplacer, DO_NOT_REPLACE } from '../replace-components/component-replacer'
|
||||
|
||||
describe('node to react transformer', () => {
|
||||
let nodeToReactTransformer: NodeToReactTransformer
|
||||
let defaultTestSpanElement: Element
|
||||
|
||||
beforeEach(() => {
|
||||
defaultTestSpanElement = new Element('span', { 'data-test': 'test' })
|
||||
nodeToReactTransformer = new NodeToReactTransformer()
|
||||
})
|
||||
|
||||
describe('replacement', () => {
|
||||
it('can translate an element without any replacer', () => {
|
||||
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
|
||||
expect(translation.type).toEqual('span')
|
||||
expect(translation.props).toEqual({ children: [], 'data-test': 'test' })
|
||||
})
|
||||
|
||||
it('can replace an element nothing', () => {
|
||||
nodeToReactTransformer.setReplacers([
|
||||
new (class extends ComponentReplacer {
|
||||
replace(): NodeReplacement {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
])
|
||||
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
|
||||
expect(translation).toEqual(null)
|
||||
})
|
||||
|
||||
it('can translate an element with no matching replacer', () => {
|
||||
nodeToReactTransformer.setReplacers([
|
||||
new (class extends ComponentReplacer {
|
||||
replace(): NodeReplacement {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
})()
|
||||
])
|
||||
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
|
||||
|
||||
expect(translation.type).toEqual('span')
|
||||
expect(translation.props).toEqual({ children: [], 'data-test': 'test' })
|
||||
})
|
||||
|
||||
it('can replace an element', () => {
|
||||
nodeToReactTransformer.setReplacers([
|
||||
new (class extends ComponentReplacer {
|
||||
replace(): NodeReplacement {
|
||||
return <div data-test2={'test2'} />
|
||||
}
|
||||
})()
|
||||
])
|
||||
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
|
||||
|
||||
expect(translation.type).toEqual('div')
|
||||
expect(translation.props).toEqual({ 'data-test2': 'test2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('key calculation', () => {
|
||||
beforeEach(() => {
|
||||
nodeToReactTransformer.setLineIds([
|
||||
{
|
||||
id: 1,
|
||||
line: 'test'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('can calculate a fallback key', () => {
|
||||
const result = nodeToReactTransformer.translateNodeToReactElement(
|
||||
defaultTestSpanElement,
|
||||
1
|
||||
) as ReactHTMLElement<HTMLDivElement>
|
||||
|
||||
expect(result.type).toEqual('span')
|
||||
expect(result.key).toEqual('-1')
|
||||
})
|
||||
|
||||
it('can calculate a key based on line markers and line keys', () => {
|
||||
const lineMarker = new Element('app-linemarker', { 'data-start-line': '1', 'data-end-line': '2' })
|
||||
defaultTestSpanElement.prev = lineMarker
|
||||
const rootElement: Element = new Element('div', {}, [lineMarker, defaultTestSpanElement])
|
||||
|
||||
const result = nodeToReactTransformer.translateNodeToReactElement(
|
||||
rootElement,
|
||||
1
|
||||
) as ReactHTMLElement<HTMLDivElement>
|
||||
const resultSpanTag = (result.props.children as ReactElement[])[1]
|
||||
|
||||
expect(result.type).toEqual('div')
|
||||
expect(resultSpanTag.type).toEqual('span')
|
||||
expect(resultSpanTag.key).toEqual('1_1')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element, Node } from 'domhandler'
|
||||
import { isTag } from 'domhandler'
|
||||
import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
|
||||
import type { ComponentReplacer, NodeReplacement, ValidReactDomElement } from '../replace-components/component-replacer'
|
||||
import { DO_NOT_REPLACE } from '../replace-components/component-replacer'
|
||||
import React from 'react'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension'
|
||||
import type { LineWithId } from '../extensions/linemarker/types'
|
||||
|
||||
type LineIndexPair = [startLineIndex: number, endLineIndex: number]
|
||||
|
||||
/**
|
||||
* Converts {@link Node domhandler nodes} to react elements by using direct translation or {@link ComponentReplacer replacers}.
|
||||
*/
|
||||
export class NodeToReactTransformer {
|
||||
private lineIds: LineWithId[] = []
|
||||
private replacers: ComponentReplacer[] = []
|
||||
|
||||
public setLineIds(lineIds: LineWithId[]): void {
|
||||
this.lineIds = lineIds
|
||||
}
|
||||
|
||||
public setReplacers(replacers: ComponentReplacer[]): void {
|
||||
this.replacers = replacers
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all replacers before rendering.
|
||||
*/
|
||||
public resetReplacers(): void {
|
||||
this.replacers.forEach((replacer) => replacer.reset())
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given {@link Node} to a react element.
|
||||
*
|
||||
* @param node The {@link Node DOM node} that should be translated.
|
||||
* @param index The index of the node within its parents child list.
|
||||
* @return the created react element
|
||||
*/
|
||||
public translateNodeToReactElement(node: Node, index: number | string): ValidReactDomElement {
|
||||
return isTag(node)
|
||||
? this.translateElementToReactElement(node, index)
|
||||
: convertNodeToReactElement(node, index, this.translateNodeToReactElement.bind(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the given {@link Element} to a react element.
|
||||
*
|
||||
* @param element The {@link Element DOM element} that should be translated.
|
||||
* @param index The index of the element within its parents child list.
|
||||
* @return the created react element
|
||||
*/
|
||||
private translateElementToReactElement(element: Element, index: number | string): ValidReactDomElement {
|
||||
const elementKey = this.calculateUniqueKey(element).orElseGet(() => (-index).toString())
|
||||
const replacement = this.findElementReplacement(element, elementKey)
|
||||
if (replacement === null) {
|
||||
return null
|
||||
} else if (replacement === DO_NOT_REPLACE) {
|
||||
return this.renderNativeNode(element, elementKey)
|
||||
} else if (typeof replacement === 'string') {
|
||||
return replacement
|
||||
} else {
|
||||
return React.cloneElement(replacement, {
|
||||
...(replacement.props as Record<string, unknown>),
|
||||
key: elementKey
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the unique key for the given {@link Element}.
|
||||
*
|
||||
* @param element The element for which the unique key should be calculated.
|
||||
* @return An {@link Optional} that contains the unique key or is empty if no key could be found.
|
||||
*/
|
||||
private calculateUniqueKey(element: Element): Optional<string> {
|
||||
if (!element.attribs) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
return Optional.ofNullable(element.prev)
|
||||
.map((lineMarker) => NodeToReactTransformer.extractLineIndexFromLineMarker(lineMarker))
|
||||
.map(([startLineIndex, endLineIndex]) =>
|
||||
NodeToReactTransformer.convertMarkdownItLineIndexesToInternalLineIndexes(startLineIndex, endLineIndex)
|
||||
)
|
||||
.flatMap((adjustedLineIndexes) => this.findLineIdsByIndex(adjustedLineIndexes))
|
||||
.map(([startLine, endLine]) => `${startLine.id}_${endLine.id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks every saved replacer if the given {@link Element element} should be
|
||||
* replaced with another react element or not.
|
||||
*
|
||||
* @param element The {@link Element} that should be checked.
|
||||
* @param elementKey The unique key for the element
|
||||
* @return The replacement or {@link DO_NOT_REPLACE} if the element shouldn't be replaced with a custom component.
|
||||
*/
|
||||
private findElementReplacement(element: Element, elementKey: string): NodeReplacement {
|
||||
const transformer = this.translateNodeToReactElement.bind(this)
|
||||
const nativeRenderer = () => this.renderNativeNode(element, elementKey)
|
||||
for (const componentReplacer of this.replacers) {
|
||||
const replacement = componentReplacer.replace(element, transformer, nativeRenderer)
|
||||
if (replacement !== DO_NOT_REPLACE) {
|
||||
return replacement
|
||||
}
|
||||
}
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the start and end line indexes that are saved in a line marker element
|
||||
* and describe in which line, in the markdown code, the node before the marker ends
|
||||
* and which the node after the marker starts.
|
||||
*
|
||||
* @param lineMarker The line marker that saves a start and end line index.
|
||||
* @return the extracted line indexes
|
||||
*/
|
||||
private static extractLineIndexFromLineMarker(lineMarker: Node): LineIndexPair | undefined {
|
||||
if (!isTag(lineMarker) || lineMarker.tagName !== LinemarkerMarkdownExtension.tagName || !lineMarker.attribs) {
|
||||
return
|
||||
}
|
||||
const startLineInMarkdown = lineMarker.attribs['data-start-line']
|
||||
const endLineInMarkdown = lineMarker.attribs['data-end-line']
|
||||
if (startLineInMarkdown === undefined || endLineInMarkdown === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const startLineIndex = Number(startLineInMarkdown)
|
||||
const endLineIndex = Number(endLineInMarkdown)
|
||||
|
||||
if (isNaN(startLineIndex) || isNaN(endLineIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
return [startLineIndex, endLineIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts markdown it line indexes to internal line indexes.
|
||||
* The differences are:
|
||||
* - Markdown it starts to count at 1, but we start at 0
|
||||
* - Line indexes in markdown it are start(inclusive) to end(exclusive). But we need start(inclusive) to end(inclusive).
|
||||
*
|
||||
* @param startLineIndex The start line index from markdown it
|
||||
* @param endLineIndex The end line index from markdown it
|
||||
* @return The adjusted start and end line index
|
||||
*/
|
||||
private static convertMarkdownItLineIndexesToInternalLineIndexes(
|
||||
startLineIndex: number,
|
||||
endLineIndex: number
|
||||
): LineIndexPair {
|
||||
return [startLineIndex - 1, endLineIndex - 2]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the given node without any replacement
|
||||
*
|
||||
* @param node The node to render
|
||||
* @param key The unique key for the node
|
||||
* @return The rendered {@link ValidReactDomElement}
|
||||
*/
|
||||
private renderNativeNode = (node: Element, key: string): ValidReactDomElement => {
|
||||
if (node.attribs === undefined) {
|
||||
node.attribs = {}
|
||||
}
|
||||
|
||||
return convertNodeToReactElement(node, key, this.translateNodeToReactElement.bind(this))
|
||||
}
|
||||
|
||||
private findLineIdsByIndex([startLineIndex, endLineIndex]: LineIndexPair): Optional<[LineWithId, LineWithId]> {
|
||||
const startLine = this.lineIds[startLineIndex]
|
||||
const endLine = this.lineIds[endLineIndex]
|
||||
return startLine === undefined || endLine === undefined ? Optional.empty() : Optional.of([startLine, endLine])
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue