Refactor replacers and line id mapping

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-10-25 00:13:40 +02:00 committed by GitHub
parent 3591c90f9f
commit ec77e672f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 899 additions and 750 deletions

View file

@ -1,117 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import { isTag } from 'domhandler'
import React, { Suspense } from 'react'
import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement'
import type {
ComponentReplacer,
NativeRenderer,
SubNodeTransform,
ValidReactDomElement
} from '../replace-components/ComponentReplacer'
import type { LineKeys } from '../types'
import type { NodeToReactElementTransformer } from '@hedgedoc/html-to-react/dist/NodeToReactElementTransformer'
export interface TextDifferenceResult {
lines: LineKeys[]
lastUsedLineId: number
}
export const calculateKeyFromLineMarker = (node: Element, lineKeys?: LineKeys[]): string | undefined => {
if (!node.attribs || lineKeys === undefined) {
return
}
const key = node.attribs['data-key']
if (key) {
return key
}
const lineMarker = node.prev
if (!lineMarker || !isTag(lineMarker) || !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)
const startLine = lineKeys[startLineIndex - 1]
const endLine = lineKeys[endLineIndex - 2]
if (startLine === undefined || endLine === undefined) {
return
}
return `${startLine.id}_${endLine.id}`
}
export const findNodeReplacement = (
node: Element,
allReplacers: ComponentReplacer[],
subNodeTransform: SubNodeTransform,
nativeRenderer: NativeRenderer
): ValidReactDomElement | undefined => {
for (const componentReplacer of allReplacers) {
const replacement = componentReplacer.getReplacement(node, subNodeTransform, nativeRenderer)
if (replacement !== undefined) {
return replacement
}
}
}
/**
* Renders the given node without any replacement
*
* @param node The node to render
* @param key The unique key for the node
* @param transform The transform function that should be applied to the child nodes
*/
export const renderNativeNode = (
node: Element,
key: string,
transform: NodeToReactElementTransformer
): ValidReactDomElement => {
if (node.attribs === undefined) {
node.attribs = {}
}
delete node.attribs['data-key']
return convertNodeToReactElement(node, key, transform)
}
export const buildTransformer = (
lineKeys: LineKeys[] | undefined,
allReplacers: ComponentReplacer[]
): NodeToReactElementTransformer => {
const transform: NodeToReactElementTransformer = (node, index) => {
if (!isTag(node)) {
return convertNodeToReactElement(node, index)
}
const nativeRenderer: NativeRenderer = () => renderNativeNode(node, key, transform)
const subNodeTransform: SubNodeTransform = (subNode, subKey) => transform(subNode, subKey)
const key = calculateKeyFromLineMarker(node, lineKeys) ?? (-index).toString()
const tryReplacement = findNodeReplacement(node, allReplacers, subNodeTransform, nativeRenderer)
if (tryReplacement === null) {
return null
} else if (tryReplacement === undefined) {
return nativeRenderer()
} else {
return (
<Suspense key={key} fallback={<span>Loading...</span>}>
{tryReplacement}
</Suspense>
)
}
}
return transform
}

View file

@ -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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nText')).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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\nmore\ntext')).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\nis\ntext')
expect(lineIdMapper.updateLineMapping('this\nis\ntext')).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\nis\nold')
lineIdMapper.updateLineMapping('this\nis')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
it('should update line ids for changed lines', () => {
lineIdMapper.updateLineMapping('this\nis\nold')
expect(lineIdMapper.updateLineMapping('this\nis\nnew')).toEqual([
{
line: 'this',
id: 1
},
{
line: 'is',
id: 2
},
{
line: 'new',
id: 4
}
])
})
})

View file

@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LineWithId } from '../types'
import type { ArrayChange } from 'diff'
import { diffArrays } from 'diff'
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 newText The new text for which the line ids should be calculated
* @return the calculated {@link LineWithId lines with unique ids}
*/
public updateLineMapping(newText: string): LineWithId[] {
const lines = newText.split('\n')
const lineDifferences = this.diffNewLinesWithLastLineKeys(lines)
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 {@code true} if the given change is neither adding or 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 {@code 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[]
}
}
}

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { diffArrays } from 'diff'
import type { TextDifferenceResult } from './html-react-transformer'
import type { LineKeys } from '../types'
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 }
}

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2021 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 { DO_NOT_REPLACE, REPLACE_WITH_NOTHING } 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([
{
replace(): NodeReplacement {
return REPLACE_WITH_NOTHING
}
}
])
const translation = nodeToReactTransformer.translateNodeToReactElement(defaultTestSpanElement, 1) as ReactElement
expect(translation).toEqual(null)
})
it('can translate an element with no matching replacer', () => {
nodeToReactTransformer.setReplacers([
{
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([
{
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')
})
})
})

View file

@ -0,0 +1,174 @@
/*
* SPDX-FileCopyrightText: 2021 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, REPLACE_WITH_NOTHING } from '../replace-components/component-replacer'
import React from 'react'
import type { LineWithId } from '../types'
import Optional from 'optional-js'
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
}
/**
* 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 | undefined {
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 | undefined {
const elementKey = this.calculateUniqueKey(element).orElseGet(() => (-index).toString())
const replacement = this.findElementReplacement(element, elementKey)
if (replacement === REPLACE_WITH_NOTHING) {
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 or {@link REPLACE_WITH_NOTHING} if the node shouldn't be rendered at all.
*/
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 !== 'app-linemarker' || !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
*/
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])
}
}