Improve render performance (#511)

Massive improvement of render performance by:
- replacing the codimd-line-marker with an in-memory map
- an observation of the changed markdown code to identify changed lines
- a unique react-key calculation
This commit is contained in:
mrdrogdrog 2020-09-02 20:51:47 +02:00 committed by GitHub
parent df7c4cb19e
commit 3a0e35a9f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 360 additions and 161 deletions

View file

@ -270,18 +270,18 @@ describe('Toolbar', () => {
describe('emoji', () => {
it('picker is show when clicked', () => {
cy.get('.emoji-mart.emoji-mart-light')
cy.get('.emoji-mart')
.should('not.exist')
cy.get('.fa-smile-o')
.click()
cy.get('.emoji-mart.emoji-mart-light')
cy.get('.emoji-mart')
.should('exist')
})
it('picker is show when clicked', () => {
cy.get('.fa-smile-o')
.click()
cy.get('.emoji-mart.emoji-mart-light')
cy.get('.emoji-mart')
.should('exist')
cy.get('.emoji-mart-emoji-native')
.first()

View file

@ -9,6 +9,7 @@
"@testing-library/react": "10.4.9",
"@testing-library/user-event": "12.1.3",
"@types/codemirror": "0.0.97",
"@types/diff": "4.0.2",
"@types/emoji-mart": "3.0.2",
"@types/highlight.js": "9.12.4",
"@types/jest": "26.0.10",
@ -30,6 +31,7 @@
"@typescript-eslint/parser": "3.10.1",
"bootstrap": "4.5.2",
"codemirror": "5.57.0",
"diff": "4.0.2",
"emoji-mart": "3.0.0",
"eslint-config-react-app": "5.2.1",
"eslint-config-standard": "14.1.1",

View file

@ -36,12 +36,30 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps & ScrollProps>
const realWidth = width || 0
const scrollTo = useCallback((targetPosition:number):void => {
if (!renderer.current || targetPosition === lastScrollPosition.current) {
return
}
lastScrollPosition.current = targetPosition
renderer.current.scrollTo({
top: targetPosition
})
}, [])
useEffect(() => {
if (!renderer.current || !lineMarks || !scrollState) {
if (!renderer.current || !lineMarks || lineMarks.length === 0 || !scrollState) {
return
}
if (scrollState.firstLineInView < lineMarks[0].line) {
scrollTo(0)
return
}
if (scrollState.firstLineInView > lineMarks[lineMarks.length - 1].line) {
scrollTo(renderer.current.offsetHeight)
return
}
const { lastMarkBefore, firstMarkAfter } = findLineMarks(lineMarks, scrollState.firstLineInView)
const positionBefore = lastMarkBefore ? lastMarkBefore.position : 0
const positionBefore = lastMarkBefore ? lastMarkBefore.position : lineMarks[0].position
const positionAfter = firstMarkAfter ? firstMarkAfter.position : renderer.current.offsetHeight
const lastMarkBeforeLine = lastMarkBefore ? lastMarkBefore.line : 1
const firstMarkAfterLine = firstMarkAfter ? firstMarkAfter.line : content.split('\n').length
@ -50,16 +68,11 @@ export const DocumentRenderPane: React.FC<DocumentRenderPaneProps & ScrollProps>
const lineHeight = blockHeight / lineCount
const position = positionBefore + (scrollState.firstLineInView - lastMarkBeforeLine) * lineHeight + scrollState.scrolledPercentage / 100 * lineHeight
const correctedPosition = Math.floor(position)
if (correctedPosition !== lastScrollPosition.current) {
lastScrollPosition.current = correctedPosition
renderer.current.scrollTo({
top: correctedPosition
})
}
}, [content, lineMarks, scrollState])
scrollTo(correctedPosition)
}, [content, lineMarks, scrollState, scrollTo])
const userScroll = useCallback(() => {
if (!renderer.current || !lineMarks || !onScroll) {
if (!renderer.current || !lineMarks || lineMarks.length === 0 || !onScroll) {
return
}

View file

@ -56,7 +56,7 @@ function arrayCommonCheck<T> (array1: T[], array2: T[]): boolean {
function filterByKeywordSearch (entries: LocatedHistoryEntry[], keywords: string): LocatedHistoryEntry[] {
const searchTerm = keywords.toLowerCase()
return entries.filter(entry => entry.title.toLowerCase().indexOf(searchTerm) !== -1)
return entries.filter(entry => entry.title.toLowerCase().includes(searchTerm))
}
function sortEntries (entries: LocatedHistoryEntry[], viewState: HistoryToolbarState): LocatedHistoryEntry[] {

View file

@ -1,55 +1,76 @@
import MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
export const lineNumberMarker: () => MarkdownIt.PluginSimple = () => {
const endMarkers: number[] = []
export interface LineMarkers {
startLine: number
endLine: number
}
return (md: MarkdownIt) => {
export interface LineNumberMarkerOptions {
postLineMarkers: (lineMarkers: LineMarkers[]) => void
}
/**
* 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 lineNumberMarker: () => MarkdownIt.PluginWithOptions<LineNumberMarkerOptions> = () => {
return (md: MarkdownIt, options) => {
// add codimd_linemarker token before each opening or self-closing level-0 tag
md.core.ruler.push('line_number_marker', (state) => {
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i]
if (token.level !== 0) {
continue
}
if (token.nesting !== -1) {
if (!token.map) {
continue
}
const lineNumber = token.map[0] + 1
if (token.nesting === 1) {
endMarkers.push(token.map[1] + 1)
}
const tokenBefore = state.tokens[i - 1]
if (tokenBefore === undefined || tokenBefore.type !== 'codimd_linemarker' || tokenBefore.tag !== 'codimd-linemarker' ||
tokenBefore.attrGet('data-linenumber') !== `${lineNumber}`) {
const startToken = new Token('codimd_linemarker', 'codimd-linemarker', 0)
startToken.attrPush(['data-linenumber', `${lineNumber}`])
state.tokens.splice(i, 0, startToken)
i += 1
}
} else {
const lineNumber = endMarkers.pop()
if (lineNumber) {
const startToken = new Token('codimd_linemarker', 'codimd-linemarker', 0)
startToken.attrPush(['data-linenumber', `${lineNumber}`])
state.tokens.splice(i + 1, 0, startToken)
i += 1
}
}
const lineMarkers: LineMarkers[] = []
tagTokens(state.tokens, lineMarkers)
if (options?.postLineMarkers) {
options.postLineMarkers(lineMarkers)
}
return true
})
// render codimd_linemarker token to <codimd-linemarker></codimd-linemarker>
md.renderer.rules.codimd_linemarker = (tokens: Token[], index: number): string => {
const lineNumber = tokens[index].attrGet('data-linenumber')
if (!lineNumber) {
const startLineNumber = tokens[index].attrGet('data-start-line')
const endLineNumber = tokens[index].attrGet('data-end-line')
if (!startLineNumber || !endLineNumber) {
// don't render broken linemarkers without a linenumber
return ''
}
// noinspection CheckTagEmptyBody
return `<codimd-linemarker data-linenumber='${lineNumber}'></codimd-linemarker>`
return `<codimd-linemarker data-start-line='${startLineNumber}' data-end-line='${endLineNumber}'></codimd-linemarker>`
}
const insertNewLineMarker = (startLineNumber: number, endLineNumber: number, tokenPosition: number, level: number, tokens: Token[]) => {
const startToken = new Token('codimd_linemarker', 'codimd-linemarker', 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) {
continue
}
if (!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)
}
}
}
}
}

View file

@ -20,14 +20,6 @@
background-color: unset;
}
&.wider {
max-width: 1500px;
& > .wider-possible {
max-width: 1500px;
}
}
a.heading-anchor {
margin-left: -1.25em;
font-size: 0.75em;

View file

@ -1,7 +1,6 @@
import equal from 'fast-deep-equal'
import { DomElement } from 'domhandler'
import emojiData from 'emoji-mart/data/twitter.json'
import { Data } from 'emoji-mart/dist-es/utils/data'
import equal from 'fast-deep-equal'
import yaml from 'js-yaml'
import MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
@ -23,7 +22,7 @@ import toc from 'markdown-it-toc-done-right'
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import markdownItTaskLists from '@hedgedoc/markdown-it-task-lists'
import { Alert } from 'react-bootstrap'
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
import ReactHtmlParser from 'react-html-parser'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import useResizeObserver from 'use-resize-observer'
@ -36,7 +35,7 @@ import { slugify } from '../editor/table-of-contents/table-of-contents'
import { RawYAMLMetadata, YAMLMetaData } from '../editor/yaml-metadata/yaml-metadata'
import { createRenderContainer, validAlertLevels } from './markdown-it-plugins/alert-container'
import { highlightedCode } from './markdown-it-plugins/highlighted-code'
import { lineNumberMarker } from './markdown-it-plugins/line-number-marker'
import { LineMarkers, lineNumberMarker } from './markdown-it-plugins/line-number-marker'
import { linkifyExtra } from './markdown-it-plugins/linkify-extra'
import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger'
import { plantumlError } from './markdown-it-plugins/plantuml-error'
@ -53,8 +52,10 @@ import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-colo
import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time'
import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
import { buildTransformer, calculateNewLineNumberMapping, LineKeys } from './renderer-utils'
import { AsciinemaReplacer } from './replace-components/asciinema/asciinema-replacer'
import { ComponentReplacer, SubNodeConverter } from './replace-components/ComponentReplacer'
import { CodimdLinemarkerReplacer } from './replace-components/codimd-linemarker/codimd-linemarker-replacer'
import { ComponentReplacer } from './replace-components/ComponentReplacer'
import { CsvReplacer } from './replace-components/csv/csv-replacer'
import { FlowchartReplacer } from './replace-components/flow/flowchart-replacer'
import { GistReplacer } from './replace-components/gist/gist-replacer'
@ -131,27 +132,52 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const oldFirstHeadingRef = useRef<string>()
const documentElement = useRef<HTMLDivElement>(null)
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
const currentLineMarkers = useRef<LineMarkers[]>()
const calculateLineMarkerPositions = useCallback(() => {
if (documentElement.current && onLineMarkerPositionChanged) {
// noinspection CssInvalidHtmlTagReference
const lineMarkers: NodeListOf<HTMLDivElement> = documentElement.current.querySelectorAll('codimd-linemarker')
const lineMarkerPositions: LineMarkerPosition[] = Array.from(lineMarkers).map((marker) => {
return {
line: Number(marker.getAttribute('data-linenumber')),
position: marker.offsetTop
} as LineMarkerPosition
if (!(documentElement.current && onLineMarkerPositionChanged)) {
return
}
if (currentLineMarkers.current === undefined) {
return
}
const lineMarkers = currentLineMarkers.current
const children: HTMLCollection = documentElement.current.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
})
}
lineMarkerPositions.push({
line: currentLineMarker.endLine,
position: htmlChild.offsetTop + htmlChild.offsetHeight
})
})
if (!equal(lineMarkerPositions, lastLineMarkerPositions.current)) {
lastLineMarkerPositions.current = lineMarkerPositions
onLineMarkerPositionChanged(lineMarkerPositions)
}
}
}, [onLineMarkerPositionChanged])
useEffect(() => {
calculateLineMarkerPositions()
}, [calculateLineMarkerPositions])
}, [calculateLineMarkerPositions, content])
useResizeObserver({
ref: documentElement,
@ -286,14 +312,17 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
slugify: slugify
})
md.use(linkifyExtra)
md.use(lineNumberMarker())
if (process.env.NODE_ENV !== 'production') {
md.use(MarkdownItParserDebugger)
}
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
md.use(lineNumberMarker(), {
postLineMarkers: (lineMarkers) => {
currentLineMarkers.current = lineMarkers
}
})
if (process.env.NODE_ENV !== 'production') {
md.use(MarkdownItParserDebugger)
}
return md
}, [onMetaDataChange, onFirstHeadingChange, plantumlServer])
@ -305,14 +334,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
}
}, [tocAst, onTocChange])
const tryToReplaceNode = (node: DomElement, index: number, allReplacers: ComponentReplacer[], nodeConverter: SubNodeConverter) => {
return allReplacers
.map((componentReplacer) => componentReplacer.getReplacement(node, index, nodeConverter))
.find((replacement) => !!replacement)
}
const oldMarkdownLineKeys = useRef<LineKeys[]>()
const lastUsedLineId = useRef<number>(0)
const result: ReactElement[] = useMemo(() => {
const markdownReactDom: ReactElement[] = useMemo(() => {
const allReplacers: ComponentReplacer[] = [
new CodimdLinemarkerReplacer(),
new PossibleWiderReplacer(),
new GistReplacer(),
new YoutubeReplacer(),
@ -332,17 +359,16 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
rawMetaRef.current = undefined
}
const html: string = markdownIt.render(content)
const transform: Transform = (node, index) => {
const subNodeConverter = (subNode: DomElement, subIndex: number) => convertNodeToElement(subNode, subIndex, transform)
return tryToReplaceNode(node, index, allReplacers, subNodeConverter) || convertNodeToElement(node, index, transform)
}
return ReactHtmlParser(html, { transform: transform })
const contentLines = content.split('\n')
const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping(contentLines, oldMarkdownLineKeys.current ?? [], lastUsedLineId.current)
oldMarkdownLineKeys.current = newLines
lastUsedLineId.current = newLastUsedLineId
return ReactHtmlParser(html, { transform: buildTransformer(newLines, allReplacers) })
}, [content, markdownIt, onMetaDataChange, onTaskCheckedChange])
return (
<div className={'bg-light flex-fill'}>
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`} ref={documentElement}>
<div className={`${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`} >
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
@ -350,7 +376,9 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
</Trans>
</Alert>
</ShowIf>
{result}
<div ref={documentElement} className={'markdown-body d-flex flex-column align-items-center'}>
{markdownReactDom}
</div>
</div>
</div>
)

View file

@ -0,0 +1,109 @@
import { diffArrays } from 'diff'
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
import { convertNodeToElement, Transform } from 'react-html-parser'
import {
ComponentReplacer,
NativeRenderer,
SubNodeTransform
} from './replace-components/ComponentReplacer'
export interface TextDifferenceResult {
lines: LineKeys[],
lastUsedLineId: number
}
export interface LineKeys {
line: string,
id: number
}
export const calculateNewLineNumberMapping = (newMarkdownLines: string[], oldLineKeys: LineKeys[], lastUsedLineId: number): TextDifferenceResult => {
const lineDifferences = diffArrays<string, LineKeys>(newMarkdownLines, oldLineKeys, {
comparator: (left:string|LineKeys, right:string|LineKeys) => {
const leftLine = (left as LineKeys).line ?? (left as string)
const rightLine = (right as LineKeys).line ?? (right as string)
return leftLine === rightLine
}
})
const newLines: LineKeys[] = []
lineDifferences
.filter((change) => change.added === undefined || !change.added)
.forEach((value) => {
if (value.removed) {
(value.value as string[])
.forEach(line => {
lastUsedLineId += 1
newLines.push({ line: line, id: lastUsedLineId })
})
} else {
(value.value as LineKeys[])
.forEach((line) => newLines.push(line))
}
})
return { lines: newLines, lastUsedLineId: lastUsedLineId }
}
export const calculateKeyFromLineMarker = (node: DomElement, lineKeys?: LineKeys[]): number|undefined => {
if (!node.attribs || lineKeys === undefined) {
return
}
const key = node.attribs['data-key']
if (key) {
return Number(key)
}
const lineMarker = node.prev
if (!lineMarker || !lineMarker.attribs) {
return
}
const lineInMarkdown = lineMarker.attribs['data-start-line']
if (lineInMarkdown === undefined) {
return
}
const line = Number(lineInMarkdown)
if (lineKeys[line] === undefined) {
return
}
return lineKeys[line].id
}
export const findNodeReplacement = (node: DomElement, index: number, allReplacers: ComponentReplacer[], transform: SubNodeTransform, nativeRenderer: NativeRenderer): ReactElement|null|undefined => {
return allReplacers
.map((componentReplacer) => componentReplacer.getReplacement(node, index, transform, nativeRenderer))
.find((replacement) => replacement !== undefined)
}
export const renderNativeNode = (node: DomElement, key: number, transform: Transform): ReactElement => {
if (node.attribs === undefined) {
node.attribs = {}
}
delete node.attribs['data-key']
return convertNodeToElement(node, key, transform)
}
export const buildTransformer = (lineKeys: (LineKeys[] | undefined), allReplacers: ComponentReplacer[]):Transform => {
const transform: Transform = (node, index) => {
const nativeRenderer = (subNode: DomElement, subKey: number) => renderNativeNode(subNode, subKey, transform)
const subNodeTransform:SubNodeTransform = (subNode, subIndex) => transform(subNode, subIndex, transform)
const key = calculateKeyFromLineMarker(node, lineKeys) ?? -index
const tryReplacement = findNodeReplacement(node, key, allReplacers, subNodeTransform, nativeRenderer)
if (tryReplacement === null) {
return null
} else if (tryReplacement === undefined) {
return nativeRenderer(node, key)
} else {
return tryReplacement
}
}
return transform
}

View file

@ -1,8 +1,10 @@
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
export type SubNodeConverter = (node: DomElement, index: number) => ReactElement
export type SubNodeTransform = (node: DomElement, index: number) => ReactElement | void | null
export interface ComponentReplacer {
getReplacement: (node: DomElement, index:number, subNodeConverter: SubNodeConverter) => (ReactElement|undefined)
export type NativeRenderer = (node: DomElement, key: number) => ReactElement
export abstract class ComponentReplacer {
public abstract getReplacement(node: DomElement, index: number, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): (ReactElement | null | undefined);
}

View file

@ -4,17 +4,17 @@ import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { AsciinemaFrame } from './asciinema-frame'
export class AsciinemaReplacer implements ComponentReplacer {
export class AsciinemaReplacer extends ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'asciinema')
if (attributes && attributes.id) {
const asciinemaId = attributes.id
const count = (this.counterMap.get(asciinemaId) || 0) + 1
this.counterMap.set(asciinemaId, count)
return (
<AsciinemaFrame key={`asciinema_${asciinemaId}_${count}`} id={asciinemaId}/>
<AsciinemaFrame key={index} id={asciinemaId}/>
)
}
}

View file

@ -0,0 +1,8 @@
import { DomElement } from 'domhandler'
import { ComponentReplacer } from '../ComponentReplacer'
export class CodimdLinemarkerReplacer extends ComponentReplacer {
public getReplacement (codeNode: DomElement, index: number): null | undefined {
return codeNode.name === 'codimd-linemarker' ? null : undefined
}
}

View file

@ -3,8 +3,8 @@ import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { CsvTable } from './csv-table'
export class CsvReplacer implements ComponentReplacer {
getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
export class CsvReplacer extends ComponentReplacer {
public getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || codeNode.attribs['data-highlight-language'] !== 'csv' || !codeNode.children || !codeNode.children[0]) {
return
}

View file

@ -3,8 +3,8 @@ import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { FlowChart } from './flowchart/flowchart'
export class FlowchartReplacer implements ComponentReplacer {
getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
export class FlowchartReplacer extends ComponentReplacer {
public getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || codeNode.attribs['data-highlight-language'] !== 'flow' || !codeNode.children || !codeNode.children[0]) {
return
}

View file

@ -6,17 +6,17 @@ import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { GistFrame } from './gist-frame'
import preview from './gist-preview.png'
export class GistReplacer implements ComponentReplacer {
export class GistReplacer extends ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'gist')
if (attributes && attributes.id) {
const gistId = attributes.id
const count = (this.counterMap.get(gistId) || 0) + 1
this.counterMap.set(gistId, count)
return (
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={`gist_${gistId}_${count}`} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={index} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
<GistFrame id={gistId}/>
</OneClickEmbedding>
)

View file

@ -20,7 +20,7 @@ export const escapeHtml = (unsafe: string): string => {
}
const checkIfLanguageIsSupported = (language: string): boolean => {
return hljs.listLanguages().indexOf(language) > -1
return hljs.listLanguages().includes(language)
}
const correctLanguage = (language: string | undefined): string | undefined => {

View file

@ -3,10 +3,10 @@ import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { HighlightedCode } from './highlighted-code/highlighted-code'
export class HighlightedCodeReplacer implements ComponentReplacer {
export class HighlightedCodeReplacer extends ComponentReplacer {
private lastLineNumber = 0;
getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
public getReplacement (codeNode: DomElement, index: number): React.ReactElement | undefined {
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || !codeNode.children || !codeNode.children[0]) {
return
}

View file

@ -3,8 +3,8 @@ import React from 'react'
import { ComponentReplacer } from '../ComponentReplacer'
import { ImageFrame } from './image-frame'
export class ImageReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
export class ImageReplacer extends ComponentReplacer {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
if (node.name === 'img' && node.attribs) {
return <ImageFrame
key={index}

View file

@ -5,19 +5,20 @@ import TeX from '@matejmazur/react-katex'
import { ComponentReplacer } from '../ComponentReplacer'
const getNodeIfKatexBlock = (node: DomElement): (DomElement|undefined) => {
if (node.name !== 'p' || !node.children || node.children.length !== 1) {
if (node.name !== 'p' || !node.children || node.children.length === 0) {
return
}
const katexNode = node.children[0]
return (katexNode.name === 'codimd-katex' && katexNode.attribs?.inline === undefined) ? katexNode : undefined
return node.children.find((subnode) => {
return (subnode.name === 'codimd-katex' && subnode.attribs?.inline === undefined)
})
}
const getNodeIfInlineKatex = (node: DomElement): (DomElement|undefined) => {
return (node.name === 'codimd-katex' && node.attribs?.inline !== undefined) ? node : undefined
}
export class KatexReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
export class KatexReplacer extends ComponentReplacer {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const katex = getNodeIfKatexBlock(node) || getNodeIfInlineKatex(node)
if (katex?.children && katex.children[0]) {
const mathJaxContent = katex.children[0]?.data as string

View file

@ -1,19 +1,19 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
import { ComponentReplacer } from '../ComponentReplacer'
import { PdfFrame } from './pdf-frame'
export class PdfReplacer implements ComponentReplacer {
export class PdfReplacer extends ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'pdf')
if (attributes && attributes.url) {
const pdfUrl = attributes.url
const count = (this.counterMap.get(pdfUrl) || 0) + 1
this.counterMap.set(pdfUrl, count)
return <PdfFrame key={`pdf_${pdfUrl}_${count}`} url={pdfUrl}/>
return <PdfFrame key={index} url={pdfUrl}/>
}
}
}

View file

@ -0,0 +1,8 @@
.wider .markdown-body {
max-width: 1500px;
width: 100%;
&>.wider-possible {
max-width: 1500px;
}
}

View file

@ -1,26 +1,29 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
import { ComponentReplacer, NativeRenderer, SubNodeTransform } from '../ComponentReplacer'
import './possible-wider-replacer.scss'
export class PossibleWiderReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
const enabledTags = ['img', 'codimd-youtube', 'codimd-vimeo', 'codimd-asciinema', 'codimd-pdf']
/**
* This replacer doesn't actually replace something.
* It just uses the ComponentReplacer-Class to get access to the DOM and
* appends the "wider-possible" class to paragraphs with special content.
*/
export class PossibleWiderReplacer extends ComponentReplacer {
public getReplacement (node: DomElement, index: number, subNodeTransformer: SubNodeTransform, nativeRenderer: NativeRenderer): (undefined) {
if (node.name !== 'p') {
return
}
if (!node.children || node.children.length !== 1) {
return
}
const childIsImage = node.children[0].name === 'img'
const childIsYoutube = node.children[0].name === 'codimd-youtube'
const childIsVimeo = node.children[0].name === 'codimd-vimeo'
const childIsAsciinema = node.children[0].name === 'codimd-asciinema'
const childIsPDF = node.children[0].name === 'codimd-pdf'
if (!(childIsImage || childIsYoutube || childIsVimeo || childIsAsciinema || childIsPDF)) {
if (!node.children || node.children.length === 0) {
return
}
// This appends the 'wider-possible' class to the node for a wider view in view-mode
node.attribs = Object.assign(node.attribs || {}, { class: `wider-possible ${node.attribs?.class || ''}` })
return subNodeConverter(node, index)
if (node.children.find((subNode) => subNode.name && enabledTags.includes(subNode.name))) {
if (!node.attribs) {
node.attribs = {}
}
node.attribs.class = `${node.attribs.class ?? ''} wider-possible`
}
}
}

View file

@ -1,5 +1,6 @@
import { DomElement } from 'domhandler'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
import { ReactElement } from 'react'
import { ComponentReplacer, NativeRenderer, SubNodeTransform } from '../ComponentReplacer'
const isColorExtraElement = (node: DomElement | undefined): boolean => {
if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) {
@ -17,8 +18,8 @@ const findQuoteOptionsParent = (nodes: DomElement[]): DomElement | undefined =>
})
}
export class QuoteOptionsReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
export class QuoteOptionsReplacer extends ComponentReplacer {
public getReplacement (node: DomElement, index: number, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer):ReactElement|undefined {
if (node.name !== 'blockquote' || !node.children || node.children.length < 1) {
return
}
@ -37,6 +38,6 @@ export class QuoteOptionsReplacer implements ComponentReplacer {
return
}
node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${attributes['data-color']};` })
return subNodeConverter(node, index)
return nativeRenderer(node, index)
}
}

View file

@ -1,11 +1,12 @@
import React, { ReactElement } from 'react'
import { DomElement } from 'domhandler'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
import { ComponentReplacer } from '../ComponentReplacer'
export class TaskListReplacer implements ComponentReplacer {
export class TaskListReplacer extends ComponentReplacer {
onTaskCheckedChange: (lineInMarkdown: number, checked: boolean) => void
constructor (onTaskCheckedChange: (lineInMarkdown: number, checked: boolean) => void) {
super()
this.onTaskCheckedChange = onTaskCheckedChange
}
@ -14,7 +15,7 @@ export class TaskListReplacer implements ComponentReplacer {
this.onTaskCheckedChange(lineNum, event.currentTarget.checked)
}
getReplacement (node: DomElement, index:number, subNodeConverter: SubNodeConverter): (ReactElement|undefined) {
public getReplacement (node: DomElement, index:number): (ReactElement|undefined) {
if (node.attribs?.class === 'task-list-item-checkbox') {
return (
<input

View file

@ -4,16 +4,16 @@ import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { VimeoFrame } from './vimeo-frame'
export class VimeoReplacer implements ComponentReplacer {
export class VimeoReplacer extends ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'vimeo')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (this.counterMap.get(videoId) || 0) + 1
this.counterMap.set(videoId, count)
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
return <VimeoFrame key={index} id={videoId}/>
}
}
}

View file

@ -4,16 +4,16 @@ import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { YouTubeFrame } from './youtube-frame'
export class YoutubeReplacer implements ComponentReplacer {
export class YoutubeReplacer extends ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
public getReplacement (node: DomElement, index: number): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'youtube')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (this.counterMap.get(videoId) || 0) + 1
this.counterMap.set(videoId, count)
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
return <YouTubeFrame key={index} id={videoId}/>
}
}
}

View file

@ -105,7 +105,7 @@ function checkValidServiceWorker (swUrl: string, config?: Config) {
const contentType = response.headers.get('content-type')
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
(contentType != null && !contentType.includes('javascript'))
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {

View file

@ -1678,6 +1678,11 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/diff@4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c"
integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ==
"@types/domhandler@*":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@types/domhandler/-/domhandler-2.4.1.tgz#7b3b347f7762180fbcb1ece1ce3dd0ebbb8c64cf"
@ -3709,10 +3714,10 @@ compression@^1.7.4:
safe-buffer "5.1.2"
vary "~1.1.2"
compute-scroll-into-view@^1.0.14:
version "1.0.14"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.14.tgz#80e3ebb25d6aa89f42e533956cb4b16a04cfe759"
integrity sha512-mKDjINe3tc6hGelUMNDzuhorIUZ7kS7BwyY0r2wQd2HOH2tRuJykiC06iSEX8y1TuhNzvz4GcJnK16mM2J1NMQ==
compute-scroll-into-view@^1.0.16:
version "1.0.16"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088"
integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ==
concat-map@0.0.1:
version "0.0.1"
@ -4456,6 +4461,11 @@ diff-sequences@^25.2.6:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==
diff@4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diffie-hellman@^5.0.0:
version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -11236,11 +11246,11 @@ screenfull@^5.0.0:
integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ==
scroll-into-view-if-needed@^2.2.20:
version "2.2.25"
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.25.tgz#117b7bc7c61bc7a2b7872a0984bc73a19bc6e961"
integrity sha512-C8RKJPq9lK7eubwGpLbUkw3lklcG3Ndjmea2PyauzrA0i4DPlzAmVMGxaZrBFqCrVLfvJmP80IyHnv4jxvg1OQ==
version "2.2.26"
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.26.tgz#e4917da0c820135ff65ad6f7e4b7d7af568c4f13"
integrity sha512-SQ6AOKfABaSchokAmmaxVnL9IArxEnLEX9j4wAZw+x4iUTb40q7irtHG3z4GtAWz5veVZcCnubXDBRyLVQaohw==
dependencies:
compute-scroll-into-view "^1.0.14"
compute-scroll-into-view "^1.0.16"
scss-tokenizer@^0.2.3:
version "0.2.3"