From 3a0e35a9f353e4ea35417cdff37824150bdb4eeb Mon Sep 17 00:00:00 2001 From: mrdrogdrog Date: Wed, 2 Sep 2020 20:51:47 +0200 Subject: [PATCH] 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 --- cypress/integration/toolbar.spec.ts | 6 +- package.json | 2 + .../document-render-pane.tsx | 33 ++++-- src/components/history-page/utils.ts | 2 +- .../markdown-it-plugins/line-number-marker.ts | 95 +++++++++------ .../markdown-renderer/markdown-renderer.scss | 8 -- .../markdown-renderer/markdown-renderer.tsx | 102 ++++++++++------ .../markdown-renderer/renderer-utils.ts | 109 ++++++++++++++++++ .../replace-components/ComponentReplacer.ts | 8 +- .../asciinema/asciinema-replacer.tsx | 6 +- .../codimd-linemarker-replacer.tsx | 8 ++ .../replace-components/csv/csv-replacer.tsx | 4 +- .../flow/flowchart-replacer.tsx | 4 +- .../replace-components/gist/gist-replacer.tsx | 6 +- .../highlighted-code/highlighted-code.tsx | 2 +- .../highlighted-fence-replacer.tsx | 4 +- .../image/image-replacer.tsx | 4 +- .../katex/katex-replacer.tsx | 11 +- .../replace-components/pdf/pdf-replacer.tsx | 8 +- .../possible-wider-replacer.scss | 8 ++ .../possible-wider-replacer.tsx | 35 +++--- .../quote-options/quote-options-replacer.tsx | 9 +- .../task-list/task-list-replacer.tsx | 7 +- .../vimeo/vimeo-replacer.tsx | 6 +- .../youtube/youtube-replacer.tsx | 6 +- src/service-worker.ts | 2 +- yarn.lock | 26 +++-- 27 files changed, 360 insertions(+), 161 deletions(-) create mode 100644 src/components/markdown-renderer/renderer-utils.ts create mode 100644 src/components/markdown-renderer/replace-components/codimd-linemarker/codimd-linemarker-replacer.tsx create mode 100644 src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.scss diff --git a/cypress/integration/toolbar.spec.ts b/cypress/integration/toolbar.spec.ts index d7eb421b6..c6b09429e 100644 --- a/cypress/integration/toolbar.spec.ts +++ b/cypress/integration/toolbar.spec.ts @@ -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() diff --git a/package.json b/package.json index 3a73dac47..30abb4276 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/editor/document-renderer-pane/document-render-pane.tsx b/src/components/editor/document-renderer-pane/document-render-pane.tsx index 332ffb59c..71557a74e 100644 --- a/src/components/editor/document-renderer-pane/document-render-pane.tsx +++ b/src/components/editor/document-renderer-pane/document-render-pane.tsx @@ -36,12 +36,30 @@ export const DocumentRenderPane: React.FC 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 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 } diff --git a/src/components/history-page/utils.ts b/src/components/history-page/utils.ts index b7f64369a..14a8f7f25 100644 --- a/src/components/history-page/utils.ts +++ b/src/components/history-page/utils.ts @@ -56,7 +56,7 @@ function arrayCommonCheck (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[] { diff --git a/src/components/markdown-renderer/markdown-it-plugins/line-number-marker.ts b/src/components/markdown-renderer/markdown-it-plugins/line-number-marker.ts index 16e8d1e20..68330885a 100644 --- a/src/components/markdown-renderer/markdown-it-plugins/line-number-marker.ts +++ b/src/components/markdown-renderer/markdown-it-plugins/line-number-marker.ts @@ -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 = () => { + 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 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 `` + return `` + } + + 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) + } + } } } } diff --git a/src/components/markdown-renderer/markdown-renderer.scss b/src/components/markdown-renderer/markdown-renderer.scss index 00ba88c47..6226f82de 100644 --- a/src/components/markdown-renderer/markdown-renderer.scss +++ b/src/components/markdown-renderer/markdown-renderer.scss @@ -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; diff --git a/src/components/markdown-renderer/markdown-renderer.tsx b/src/components/markdown-renderer/markdown-renderer.tsx index f3e8f9a94..3e825cb03 100644 --- a/src/components/markdown-renderer/markdown-renderer.tsx +++ b/src/components/markdown-renderer/markdown-renderer.tsx @@ -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 = ({ const oldFirstHeadingRef = useRef() const documentElement = useRef(null) const lastLineMarkerPositions = useRef() + const currentLineMarkers = useRef() const calculateLineMarkerPositions = useCallback(() => { - if (documentElement.current && onLineMarkerPositionChanged) { - // noinspection CssInvalidHtmlTagReference - const lineMarkers: NodeListOf = 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 (!equal(lineMarkerPositions, lastLineMarkerPositions.current)) { - lastLineMarkerPositions.current = lineMarkerPositions - onLineMarkerPositionChanged(lineMarkerPositions) + 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 = ({ 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 = ({ } }, [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() + const lastUsedLineId = useRef(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 = ({ 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 (
-
+
@@ -350,7 +376,9 @@ export const MarkdownRenderer: React.FC = ({ - {result} +
+ {markdownReactDom} +
) diff --git a/src/components/markdown-renderer/renderer-utils.ts b/src/components/markdown-renderer/renderer-utils.ts new file mode 100644 index 000000000..ef1e65912 --- /dev/null +++ b/src/components/markdown-renderer/renderer-utils.ts @@ -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(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 +} diff --git a/src/components/markdown-renderer/replace-components/ComponentReplacer.ts b/src/components/markdown-renderer/replace-components/ComponentReplacer.ts index c7f226862..60e11ee7f 100644 --- a/src/components/markdown-renderer/replace-components/ComponentReplacer.ts +++ b/src/components/markdown-renderer/replace-components/ComponentReplacer.ts @@ -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); } diff --git a/src/components/markdown-renderer/replace-components/asciinema/asciinema-replacer.tsx b/src/components/markdown-renderer/replace-components/asciinema/asciinema-replacer.tsx index e352838be..5ceb80bc5 100644 --- a/src/components/markdown-renderer/replace-components/asciinema/asciinema-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/asciinema/asciinema-replacer.tsx @@ -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 = new Map() - 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 ( - + ) } } diff --git a/src/components/markdown-renderer/replace-components/codimd-linemarker/codimd-linemarker-replacer.tsx b/src/components/markdown-renderer/replace-components/codimd-linemarker/codimd-linemarker-replacer.tsx new file mode 100644 index 000000000..43bb63fca --- /dev/null +++ b/src/components/markdown-renderer/replace-components/codimd-linemarker/codimd-linemarker-replacer.tsx @@ -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 + } +} diff --git a/src/components/markdown-renderer/replace-components/csv/csv-replacer.tsx b/src/components/markdown-renderer/replace-components/csv/csv-replacer.tsx index 83b375e69..8749386fd 100644 --- a/src/components/markdown-renderer/replace-components/csv/csv-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/csv/csv-replacer.tsx @@ -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 } diff --git a/src/components/markdown-renderer/replace-components/flow/flowchart-replacer.tsx b/src/components/markdown-renderer/replace-components/flow/flowchart-replacer.tsx index f7c537e34..91be3ee7e 100644 --- a/src/components/markdown-renderer/replace-components/flow/flowchart-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/flow/flowchart-replacer.tsx @@ -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 } diff --git a/src/components/markdown-renderer/replace-components/gist/gist-replacer.tsx b/src/components/markdown-renderer/replace-components/gist/gist-replacer.tsx index 5463c1758..791e4b807 100644 --- a/src/components/markdown-renderer/replace-components/gist/gist-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/gist/gist-replacer.tsx @@ -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 = new Map() - 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 ( - + ) diff --git a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.tsx b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.tsx index 101d12e24..6fe09bf8a 100644 --- a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.tsx +++ b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code.tsx @@ -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 => { diff --git a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-fence-replacer.tsx b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-fence-replacer.tsx index e8fafa264..7b4ab112d 100644 --- a/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-fence-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/highlighted-fence/highlighted-fence-replacer.tsx @@ -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 } diff --git a/src/components/markdown-renderer/replace-components/image/image-replacer.tsx b/src/components/markdown-renderer/replace-components/image/image-replacer.tsx index 09df181f7..2926248ee 100644 --- a/src/components/markdown-renderer/replace-components/image/image-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/image/image-replacer.tsx @@ -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 { - 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 diff --git a/src/components/markdown-renderer/replace-components/pdf/pdf-replacer.tsx b/src/components/markdown-renderer/replace-components/pdf/pdf-replacer.tsx index 0f9dc00b4..93cf8c39e 100644 --- a/src/components/markdown-renderer/replace-components/pdf/pdf-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/pdf/pdf-replacer.tsx @@ -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 = new Map() - 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 + return } } } diff --git a/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.scss b/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.scss new file mode 100644 index 000000000..5b5bd2b04 --- /dev/null +++ b/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.scss @@ -0,0 +1,8 @@ +.wider .markdown-body { + max-width: 1500px; + width: 100%; + + &>.wider-possible { + max-width: 1500px; + } + } diff --git a/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.tsx b/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.tsx index 6133388a4..8cd02e7c6 100644 --- a/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/possible-wider/possible-wider-replacer.tsx @@ -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` + } } } diff --git a/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx b/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx index 8876bfe39..c23dc168c 100644 --- a/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/quote-options/quote-options-replacer.tsx @@ -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) } } diff --git a/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx b/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx index ebd561616..b4a3f7b74 100644 --- a/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/task-list/task-list-replacer.tsx @@ -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 ( = new Map() - 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 + return } } } diff --git a/src/components/markdown-renderer/replace-components/youtube/youtube-replacer.tsx b/src/components/markdown-renderer/replace-components/youtube/youtube-replacer.tsx index 07453c15d..696af7dd1 100644 --- a/src/components/markdown-renderer/replace-components/youtube/youtube-replacer.tsx +++ b/src/components/markdown-renderer/replace-components/youtube/youtube-replacer.tsx @@ -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 = new Map() - 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 + return } } } diff --git a/src/service-worker.ts b/src/service-worker.ts index 861ac5423..d00f97880 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -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 => { diff --git a/yarn.lock b/yarn.lock index 2fd2d00e0..07379008f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"