OpenSlides/client/src/app/core/ui-services/linenumbering.service.ts
Tobias Hößl 5978868c37 Creating multi-paragraph amendments
- new config option to enable/disable multiple paragraphs
2019-04-11 17:00:25 +02:00

1099 lines
40 KiB
TypeScript

import { Injectable } from '@angular/core';
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
/**
* Specifies a point within a HTML Text Node where a line break might be possible, if the following word
* exceeds the maximum line length.
*/
interface BreakablePoint {
/**
* The Text node which is a candidate to be split into two.
*/
node: Node;
/**
* The exact offset of the found breakable point.
*/
offset: number;
}
/**
* An object specifying a range of line numbers.
*/
export interface LineNumberRange {
/**
* The first line number to be included.
*/
from: number;
/**
* The end line number.
* HINT: As this object is usually referring to actual line numbers, not lines,
* the line starting by `to` is not included in the extracted content anymore, only the text between `from` and `to`.
*/
to: number;
}
/**
* Specifies a heading element (H1, H2, H3, H4, H5, H6) within a HTML document.
*/
interface SectionHeading {
/**
* The first line number of this element.
*/
lineNumber: number;
/**
* The nesting level. H1 = 1, H2 = 2, etc.
*/
level: number;
/**
* The text content of this heading.
*/
text: string;
}
/**
* Functionality regarding adding and removing line numbers and highlighting single lines.
*
* ## Examples:
*
* Adding line numbers to an HTML string:
*
* ```ts
* const lineLength = 80;
* const originalHtml = '<p>Lorem ipsum dolorsit amet</p>';
* const lineNumberedHtml = this.lineNumbering.insertLineNumbers(inHtml, lineLength);
* ```
*
* Removing line numbers from a line-numbered string:
* ```ts
* const lineNumberedHtml = '<p><span class="os-line-number line-number-1" data-line-number="1" contenteditable="false">&nbsp;</span>Lorem ipsum dolorsit amet</p>';
* const originalHtml = this.lineNumbering.stripLineNumbers(inHtml);
* ```
*
* Splitting a HTML string into an array of paragraphs:
*
* ```ts
* const htmlIn = '<p>Paragraph 1</p><ul><li>Paragraph 2</li><li>Paragraph 3</li></ul>';
* const paragraphs = this.lineNumbering.splitToParagraphs(htmlIn);
* ```
*
* Retrieving all section headings from an HTML string:
*
* ```ts
* const html = '<h1>Heading 1</h1><p>Some introductional paragraph</p><h2>Subheading 1.1</h2><p>Another paragraph</p>
* const headings = this.lineNumbering.getHeadingsWithLineNumbers(html);
* ```
*/
@Injectable({
providedIn: 'root'
})
export class LinenumberingService {
/**
* @TODO Decide on a more sophisticated implementation
* This is just a stub for a caching system. The original code from Angular1 was:
* var lineNumberCache = $cacheFactory('linenumbering.service');
* This should be replaced by a real cache once we have decided on a caching service for OpenSlides 3
*/
private lineNumberCache = {
_cache: {},
get: (key: string): any => {
return this.lineNumberCache._cache[key] === undefined ? null : this.lineNumberCache._cache[key];
},
put: (key: string, val: any): void => {
this.lineNumberCache._cache[key] = val;
}
};
// Counts the number of characters in the current line, beyond singe nodes.
// Needs to be resetted after each line break and after entering a new block node.
private currentInlineOffset: number = null;
// The last position of a point suitable for breaking the line. null or an object with the following values:
// - node: the node that contains the position. Guaranteed to be a TextNode
// - offset: the offset of the breaking characters (like the space)
// Needs to be resetted after each line break and after entering a new block node.
private lastInlineBreakablePoint: BreakablePoint = null;
// The line number counter
private currentLineNumber: number = null;
// Indicates that we just entered a block element and we want to add a line number without line break at the beginning.
private prependLineNumberToFirstText = false;
// A workaround to prevent double line numbers
private ignoreNextRegularLineNumber = false;
// Decides if the content of inserted nodes should count as well. This is used so we can use the algorithm on a
// text with inline diff annotations and get the same line numbering as with the original text (when set to false)
private ignoreInsertedText = false;
/**
* Creates a hash of a given string. This is not meant to be specifically secure, but rather as quick as possible.
*
* @param {string} str
* @returns {string}
*/
public djb2hash(str: string): string {
let hash = 5381;
let char;
for (let i = 0; i < str.length; i++) {
char = str.charCodeAt(i);
// tslint:disable-next-line:no-bitwise
hash = (hash << 5) + hash + char;
}
return hash.toString();
}
/**
* Returns true, if the provided element is an inline element (hard-coded list of known elements).
*
* @param {Element} element
* @returns {boolean}
*/
private isInlineElement(element: Element): boolean {
const inlineElements = [
'SPAN',
'A',
'EM',
'S',
'B',
'I',
'STRONG',
'U',
'BIG',
'SMALL',
'SUB',
'SUP',
'TT',
'INS',
'DEL',
'STRIKE'
];
if (element) {
return inlineElements.indexOf(element.nodeName) > -1;
} else {
return false;
}
}
/**
* Returns true, if the given node is a OpenSlides-specific line breaking node.
*
* @param {Node} node
* @returns {boolean}
*/
public isOsLineBreakNode(node: Node): boolean {
let isLineBreak = false;
if (node && node.nodeType === ELEMENT_NODE) {
const element = <Element>node;
if (element.nodeName === 'BR' && element.hasAttribute('class')) {
const classes = element.getAttribute('class').split(' ');
if (classes.indexOf('os-line-break') > -1) {
isLineBreak = true;
}
}
}
return isLineBreak;
}
/**
* Returns true, if the given node is a OpenSlides-specific line numbering node.
*
* @param {Node} node
* @returns {boolean}
*/
public isOsLineNumberNode(node: Node): boolean {
let isLineNumber = false;
if (node && node.nodeType === ELEMENT_NODE) {
const element = <Element>node;
if (node.nodeName === 'SPAN' && element.hasAttribute('class')) {
const classes = element.getAttribute('class').split(' ');
if (classes.indexOf('os-line-number') > -1) {
isLineNumber = true;
}
}
}
return isLineNumber;
}
/**
* Searches for the line breaking node within the given Document specified by the given lineNumber.
*
* @param {DocumentFragment} fragment
* @param {number} lineNumber
* @returns {Element}
*/
private getLineNumberNode(fragment: DocumentFragment, lineNumber: number): Element {
return fragment.querySelector('.os-line-number.line-number-' + lineNumber);
}
/**
* This converts the given HTML string into a DOM tree contained by a DocumentFragment, which is reqturned.
*
* @param {string} html
* @return {DocumentFragment}
*/
private htmlToFragment(html: string): DocumentFragment {
const fragment: DocumentFragment = document.createDocumentFragment(),
div = document.createElement('DIV');
div.innerHTML = html;
while (div.childElementCount) {
const child = div.childNodes[0];
div.removeChild(child);
fragment.appendChild(child);
}
return fragment;
}
/**
* Converts a HTML Document Fragment into HTML string, using the browser's internal mechanisms.
* HINT: special characters might get escaped / html-encoded in the process of this.
*
* @param {DocumentFragment} fragment
* @returns string
*/
private fragmentToHtml(fragment: DocumentFragment): string {
const div: Element = document.createElement('DIV');
while (fragment.firstChild) {
const child = fragment.firstChild;
fragment.removeChild(child);
div.appendChild(child);
}
return div.innerHTML;
}
/**
* Creates a OpenSlides-specific line break Element
*
* @returns {Element}
*/
private createLineBreak(): Element {
const br = document.createElement('br');
br.setAttribute('class', 'os-line-break');
return br;
}
/**
* Moves line breaking and line numbering markup before inline elements
*
* @param {Element} innerNode
* @param {Element} outerNode
* @private
*/
private moveLeadingLineBreaksToOuterNode(innerNode: Element, outerNode: Element): void {
if (this.isInlineElement(innerNode)) {
const firstChild = <Element>innerNode.firstChild;
if (this.isOsLineBreakNode(firstChild)) {
const br = innerNode.firstChild;
innerNode.removeChild(br);
outerNode.appendChild(br);
}
if (this.isOsLineNumberNode(firstChild)) {
const span = innerNode.firstChild;
innerNode.removeChild(span);
outerNode.appendChild(span);
}
}
}
/**
* As some elements add extra paddings/margins, the maximum line length of the contained text is not as big
* as for text outside of this element. Based on the outside line length, this returns the new (reduced) maximum
* line length for the given block element.
* HINT: this makes quite some assumtions about the styling of the CSS / PDFs. But there is no way around this,
* as line numbers have to be fixed and not depend on styling.
*
* @param {Element} node
* @param {number} oldLength
* @returns {number}
*/
public calcBlockNodeLength(node: Element, oldLength: number): number {
let newLength = oldLength;
switch (node.nodeName) {
case 'LI':
newLength -= 5;
break;
case 'BLOCKQUOTE':
newLength -= 20;
break;
case 'DIV':
case 'P':
const styles = node.getAttribute('style');
let padding = 0;
if (styles) {
const leftpad = styles.split('padding-left:');
if (leftpad.length > 1) {
padding += parseInt(leftpad[1], 10);
}
const rightpad = styles.split('padding-right:');
if (rightpad.length > 1) {
padding += parseInt(rightpad[1], 10);
}
newLength -= padding / 5;
}
break;
case 'H1':
newLength *= 0.66;
break;
case 'H2':
newLength *= 0.75;
break;
case 'H3':
newLength *= 0.85;
break;
}
return Math.ceil(newLength);
}
/**
* This converts an array of HTML elements into a string
*
* @param {Element[]} nodes
* @returns {string}
*/
public nodesToHtml(nodes: Element[]): string {
const root = document.createElement('div');
nodes.forEach(node => {
root.appendChild(node);
});
return root.innerHTML;
}
/**
* Given a HTML string augmented with line number nodes, this function detects the line number range of this text.
* This method assumes that the line number node indicating the beginning of the next line is not included anymore.
*
* @param {string} html
* @returns {LineNumberRange}
*/
public getLineNumberRange(html: string): LineNumberRange {
const fragment = this.htmlToFragment(html);
const range = {
from: null,
to: null
};
const lineNumbers = fragment.querySelectorAll('.os-line-number');
for (let i = 0; i < lineNumbers.length; i++) {
const node = lineNumbers.item(i);
const number = parseInt(node.getAttribute('data-line-number'), 10);
if (range.from === null || number < range.from) {
range.from = number;
}
if (range.to === null || number + 1 > range.to) {
range.to = number + 1;
}
}
return range;
}
/**
* Seaches for all H1-H6 elements within the given text and returns information about them.
*
* @param {string} html
* @returns {SectionHeading[]}
*/
public getHeadingsWithLineNumbers(html: string): SectionHeading[] {
const fragment = this.htmlToFragment(html);
const headings = [];
const headingNodes = fragment.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (let i = 0; i < headingNodes.length; i++) {
const heading = <HTMLElement>headingNodes.item(i);
const linenumbers = heading.querySelectorAll('.os-line-number');
if (linenumbers.length > 0) {
const number = parseInt(linenumbers.item(0).getAttribute('data-line-number'), 10);
headings.push({
lineNumber: number,
level: parseInt(heading.nodeName.substr(1), 10),
text: heading.innerText.replace(/^\s/, '').replace(/\s$/, '')
});
}
}
return headings.sort(
(heading1: SectionHeading, heading2: SectionHeading): number => {
if (heading1.lineNumber < heading2.lineNumber) {
return 0;
} else if (heading1.lineNumber > heading2.lineNumber) {
return 1;
} else {
return 0;
}
}
);
}
/**
* Given a big element containing a whole document, this method splits it into editable paragraphs.
* Each first-level LI element gets its own paragraph, as well as all root-level block elements (except for lists).
*
* @param {Element|DocumentFragment} node
* @returns {Element[]}
* @private
*/
public splitNodeToParagraphs(node: Element | DocumentFragment): Element[] {
const elements = [];
for (let i = 0; i < node.childNodes.length; i++) {
const childNode = node.childNodes.item(i);
if (childNode.nodeType === TEXT_NODE) {
continue;
}
if (childNode.nodeName === 'UL' || childNode.nodeName === 'OL') {
const childElement = <Element>childNode;
let start = 1;
if (childElement.getAttribute('start') !== null) {
start = parseInt(childElement.getAttribute('start'), 10);
}
for (let j = 0; j < childElement.childNodes.length; j++) {
if (childElement.childNodes.item(j).nodeType === TEXT_NODE) {
continue;
}
const newParent = <Element>childElement.cloneNode(false);
if (childElement.nodeName === 'OL') {
newParent.setAttribute('start', start.toString());
}
newParent.appendChild(childElement.childNodes.item(j).cloneNode(true));
elements.push(newParent);
start++;
}
} else {
elements.push(childNode);
}
}
return elements;
}
/**
* Splitting the text into paragraphs:
* - Each root-level-element is considered as a paragraph.
* Inline-elements at root-level are not expected and treated as block elements.
* Text-nodes at root-level are not expected and ignored. Every text needs to be wrapped e.g. by <p> or <div>.
* - If a UL or OL is encountered, paragraphs are defined by the child-LI-elements.
* List items of nested lists are not considered as a paragraph of their own.
*
* @param {string} html
* @return {string[]}
*/
public splitToParagraphs(html: string): string[] {
const fragment = this.htmlToFragment(html);
return this.splitNodeToParagraphs(fragment).map(
(node: Element): string => {
return node.outerHTML;
}
);
}
/**
* Test function, only called by the tests, never from the actual app
*
* @param {number} offset
* @param {number} lineNumber
*/
public setInlineOffsetLineNumberForTests(offset: number, lineNumber: number): void {
this.currentInlineOffset = offset;
this.currentLineNumber = lineNumber;
}
/**
* Returns debug information for the test cases
*
* @returns {number}
*/
public getInlineOffsetForTests(): number {
return this.currentInlineOffset;
}
/**
* When calculating line numbers on a diff-marked-up text, some elements should not be considered:
* inserted text and line numbers. This identifies such elements.
*
* @param {Element} element
* @returns {boolean}
*/
private isIgnoredByLineNumbering(element: Element): boolean {
if (element.nodeName === 'INS') {
return this.ignoreInsertedText;
} else if (this.isOsLineNumberNode(element)) {
return true;
} else if (element.classList && element.classList.contains('insert')) {
return true;
} else {
return false;
}
}
/**
* This creates a line number node with the next free line number.
* If the internal flag is set, this step is skipped.
*
* @returns {Element}
*/
private createLineNumber(): Element {
if (this.ignoreNextRegularLineNumber) {
this.ignoreNextRegularLineNumber = false;
return;
}
const node = document.createElement('span');
const lineNumber = this.currentLineNumber;
this.currentLineNumber++;
node.setAttribute('class', 'os-line-number line-number-' + lineNumber);
node.setAttribute('data-line-number', lineNumber + '');
node.setAttribute('contenteditable', 'false');
node.innerHTML = '&nbsp;'; // Prevent ckeditor from stripping out empty span's
return node;
}
/**
* Splits a TEXT_NODE into an array of TEXT_NODEs and BR-Elements separating them into lines.
* Each line has a maximum length of 'length', with one exception: spaces are accepted to exceed the length.
* Otherwise the string is split by the last space or dash in the line.
*
* @param {Node} node
* @param {number} length
* @param {number} highlight
*/
public textNodeToLines(node: Node, length: number, highlight: number = -1): Element[] {
const out = [];
let currLineStart = 0,
i = 0,
firstTextNode = true;
const addLine = (text: string) => {
let lineNode;
if (firstTextNode) {
if (highlight === this.currentLineNumber - 1) {
lineNode = document.createElement('span');
lineNode.setAttribute('class', 'highlight');
lineNode.innerHTML = text;
} else {
lineNode = document.createTextNode(text);
}
firstTextNode = false;
} else {
if (this.currentLineNumber === highlight && highlight !== null) {
lineNode = document.createElement('span');
lineNode.setAttribute('class', 'highlight');
lineNode.innerHTML = text;
} else {
lineNode = document.createTextNode(text);
}
out.push(this.createLineBreak());
if (this.currentLineNumber !== null) {
out.push(this.createLineNumber());
}
}
out.push(lineNode);
return lineNode;
};
const addLinebreakToPreviousNode = (lineNode: Element, offset: number) => {
const firstText = lineNode.nodeValue.substr(0, offset + 1),
secondText = lineNode.nodeValue.substr(offset + 1);
const lineBreak = this.createLineBreak();
const firstNode = document.createTextNode(firstText);
lineNode.parentNode.insertBefore(firstNode, lineNode);
lineNode.parentNode.insertBefore(lineBreak, lineNode);
if (this.currentLineNumber !== null) {
lineNode.parentNode.insertBefore(this.createLineNumber(), lineNode);
}
lineNode.nodeValue = secondText;
};
if (node.nodeValue === '\n') {
out.push(node);
} else {
// This happens if a previous inline element exactly stretches to the end of the line
if (this.currentInlineOffset >= length) {
out.push(this.createLineBreak());
if (this.currentLineNumber !== null) {
out.push(this.createLineNumber());
}
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
} else if (this.prependLineNumberToFirstText) {
if (this.ignoreNextRegularLineNumber) {
this.ignoreNextRegularLineNumber = false;
} else if (this.currentLineNumber !== null) {
out.push(this.createLineNumber());
}
}
this.prependLineNumberToFirstText = false;
while (i < node.nodeValue.length) {
let lineBreakAt = null;
if (this.currentInlineOffset >= length) {
if (this.lastInlineBreakablePoint !== null) {
lineBreakAt = this.lastInlineBreakablePoint;
} else {
lineBreakAt = {
node: node,
offset: i - 1
};
}
}
if (lineBreakAt !== null && (node.nodeValue[i] !== ' ' && node.nodeValue[i] !== '\n')) {
if (lineBreakAt.node === node) {
// The last possible breaking point is in this text node
const currLine = node.nodeValue.substring(currLineStart, lineBreakAt.offset + 1);
addLine(currLine);
currLineStart = lineBreakAt.offset + 1;
this.currentInlineOffset = i - lineBreakAt.offset - 1;
this.lastInlineBreakablePoint = null;
} else {
// The last possible breaking point was not in this text not, but one we have already passed
const remainderOfPrev = lineBreakAt.node.nodeValue.length - lineBreakAt.offset - 1;
addLinebreakToPreviousNode(lineBreakAt.node, lineBreakAt.offset);
this.currentInlineOffset = i + remainderOfPrev;
this.lastInlineBreakablePoint = null;
}
}
if (node.nodeValue[i] === ' ' || node.nodeValue[i] === '-' || node.nodeValue[i] === '\n') {
this.lastInlineBreakablePoint = {
node: node,
offset: i
};
}
this.currentInlineOffset++;
i++;
}
const lastLine = addLine(node.nodeValue.substring(currLineStart));
if (this.lastInlineBreakablePoint !== null) {
this.lastInlineBreakablePoint.node = lastLine;
}
}
return out;
}
/**
* Searches recursively for the first textual word in a node and returns its length. Handy for detecting if
* the next nested element will break the current line.
*
* @param {Node} node
* @returns {number}
*/
private lengthOfFirstInlineWord(node: Node): number {
if (!node.firstChild) {
return 0;
}
if (node.firstChild.nodeType === TEXT_NODE) {
const parts = node.firstChild.nodeValue.split(' ');
return parts[0].length;
} else {
return this.lengthOfFirstInlineWord(node.firstChild);
}
}
/**
* Given an inline node, this method adds line numbers to it based on the current state.
*
* @param {Element} element
* @param {number} length
* @param {number} highlight
*/
private insertLineNumbersToInlineNode(element: Element, length: number, highlight?: number): Element {
const oldChildren: Node[] = [];
for (let i = 0; i < element.childNodes.length; i++) {
oldChildren.push(element.childNodes[i]);
}
while (element.firstChild) {
element.removeChild(element.firstChild);
}
for (let i = 0; i < oldChildren.length; i++) {
if (oldChildren[i].nodeType === TEXT_NODE) {
const ret = this.textNodeToLines(oldChildren[i], length, highlight);
for (let j = 0; j < ret.length; j++) {
element.appendChild(ret[j]);
}
} else if (oldChildren[i].nodeType === ELEMENT_NODE) {
const childElement = <Element>oldChildren[i],
firstword = this.lengthOfFirstInlineWord(childElement),
overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0;
if (overlength && this.isInlineElement(childElement)) {
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
element.appendChild(this.createLineBreak());
if (this.currentLineNumber !== null) {
element.appendChild(this.createLineNumber());
}
}
const changedNode = this.insertLineNumbersToNode(childElement, length, highlight);
this.moveLeadingLineBreaksToOuterNode(changedNode, element);
element.appendChild(changedNode);
} else {
throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]);
}
}
return element;
}
/**
* Given a block node, this method adds line numbers to it based on the current state.
*
* @param {Element} element
* @param {number} length
* @param {number} highlight
*/
public insertLineNumbersToBlockNode(element: Element, length: number, highlight?: number): Element {
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
this.prependLineNumberToFirstText = true;
const oldChildren = [];
for (let i = 0; i < element.childNodes.length; i++) {
oldChildren.push(element.childNodes[i]);
}
while (element.firstChild) {
element.removeChild(element.firstChild);
}
for (let i = 0; i < oldChildren.length; i++) {
if (oldChildren[i].nodeType === TEXT_NODE) {
if (!oldChildren[i].nodeValue.match(/\S/)) {
// White space nodes between block elements should be ignored
const prevIsBlock = i > 0 && !this.isInlineElement(oldChildren[i - 1]);
const nextIsBlock = i < oldChildren.length - 1 && !this.isInlineElement(oldChildren[i + 1]);
if (
(prevIsBlock && nextIsBlock) ||
(i === 0 && nextIsBlock) ||
(i === oldChildren.length - 1 && prevIsBlock)
) {
element.appendChild(oldChildren[i]);
continue;
}
}
const ret = this.textNodeToLines(oldChildren[i], length, highlight);
for (let j = 0; j < ret.length; j++) {
element.appendChild(ret[j]);
}
} else if (oldChildren[i].nodeType === ELEMENT_NODE) {
const firstword = this.lengthOfFirstInlineWord(oldChildren[i]),
overlength = this.currentInlineOffset + firstword > length && this.currentInlineOffset > 0;
if (
overlength &&
this.isInlineElement(oldChildren[i]) &&
!this.isIgnoredByLineNumbering(oldChildren[i])
) {
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
element.appendChild(this.createLineBreak());
if (this.currentLineNumber !== null) {
element.appendChild(this.createLineNumber());
}
}
const changedNode = this.insertLineNumbersToNode(oldChildren[i], length, highlight);
this.moveLeadingLineBreaksToOuterNode(changedNode, element);
element.appendChild(changedNode);
} else {
throw new Error('Unknown nodeType: ' + i + ': ' + oldChildren[i]);
}
}
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
this.prependLineNumberToFirstText = true;
this.ignoreNextRegularLineNumber = false;
return element;
}
/**
* Given any node, this method adds line numbers to it based on the current state.
*
* @param {Element} element
* @param {number} length
* @param {number} highlight
*/
public insertLineNumbersToNode(element: Element, length: number, highlight?: number): Element {
if (element.nodeType !== ELEMENT_NODE) {
throw new Error('This method may only be called for ELEMENT-nodes: ' + element.nodeValue);
}
if (this.isIgnoredByLineNumbering(element)) {
if (this.currentInlineOffset === 0 && this.currentLineNumber !== null && this.isInlineElement(element)) {
const lineNumberNode = this.createLineNumber();
if (lineNumberNode) {
element.insertBefore(lineNumberNode, element.firstChild);
this.ignoreNextRegularLineNumber = true;
}
}
return element;
} else if (this.isInlineElement(element)) {
return this.insertLineNumbersToInlineNode(element, length, highlight);
} else {
const newLength = this.calcBlockNodeLength(element, length);
return this.insertLineNumbersToBlockNode(element, newLength, highlight);
}
}
/**
* Removes all line number nodes from the given Node.
*
* @param {Node} node
*/
public stripLineNumbersFromNode(node: Node): void {
for (let i = 0; i < node.childNodes.length; i++) {
if (this.isOsLineBreakNode(node.childNodes[i]) || this.isOsLineNumberNode(node.childNodes[i])) {
// If a newline character follows a line break, it's been very likely inserted by the WYSIWYG-editor
if (node.childNodes.length > i + 1 && node.childNodes[i + 1].nodeType === TEXT_NODE) {
if (node.childNodes[i + 1].nodeValue[0] === '\n') {
node.childNodes[i + 1].nodeValue = ' ' + node.childNodes[i + 1].nodeValue.substring(1);
}
}
node.removeChild(node.childNodes[i]);
i--;
} else {
this.stripLineNumbersFromNode(node.childNodes[i]);
}
}
}
/**
* Adds line number nodes to the given Node.
*
* @param {string} html
* @param {number|string} lineLength
* @param {number|null} highlight - optional
* @param {number|null} firstLine
*/
public insertLineNumbersNode(html: string, lineLength: number, highlight: number, firstLine: number = 1): Element {
// Removing newlines after BRs, as they lead to problems like #3410
if (html) {
html = html.replace(/(<br[^>]*>)[\n\r]+/gi, '$1');
}
const root = document.createElement('div');
root.innerHTML = html;
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
this.currentLineNumber = firstLine;
this.prependLineNumberToFirstText = true;
this.ignoreNextRegularLineNumber = false;
this.ignoreInsertedText = true;
return this.insertLineNumbersToNode(root, lineLength, highlight);
}
/**
* Adds line number nodes to the given html string.
* @param {string} html
* @param {number} lineLength
* @param {number} highlight
* @param {function} callback
* @param {number} firstLine
* @returns {string}
*/
public insertLineNumbers(
html: string,
lineLength: number,
highlight?: number,
callback?: () => void,
firstLine?: number
): string {
let newHtml, newRoot;
if (highlight > 0) {
// Caching versions with highlighted line numbers is probably not worth it
newRoot = this.insertLineNumbersNode(html, lineLength, highlight, firstLine);
newHtml = newRoot.innerHTML;
} else {
const firstLineStr = firstLine === undefined ? '' : firstLine.toString();
const cacheKey = this.djb2hash(firstLineStr + '-' + lineLength.toString() + html);
newHtml = this.lineNumberCache.get(cacheKey);
if (!newHtml) {
newRoot = this.insertLineNumbersNode(html, lineLength, null, firstLine);
newHtml = newRoot.innerHTML;
this.lineNumberCache.put(cacheKey, newHtml);
}
}
if (callback !== undefined && callback !== null) {
callback();
}
return newHtml;
}
/**
* This enforces that no line is longer than the given line Length. However, this method does not care about
* line numbers, diff-markup etc.
* It's mainly used to display diff-formatted text with the original line numbering, that may have longer lines
* than the line length because of inserted text, in a context where really only a given width is available.
*
* @param {string} html
* @param {number} lineLength
* @param {boolean} countInserted
* @returns {string}
*/
public insertLineBreaksWithoutNumbers(html: string, lineLength: number, countInserted: boolean = false): string {
const root = document.createElement('div');
root.innerHTML = html;
this.currentInlineOffset = 0;
this.lastInlineBreakablePoint = null;
this.currentLineNumber = null;
this.prependLineNumberToFirstText = true;
this.ignoreNextRegularLineNumber = false;
this.ignoreInsertedText = !countInserted;
const newRoot = this.insertLineNumbersToNode(root, lineLength, null);
return newRoot.innerHTML;
}
/**
* Strips line numbers from a HTML string
*
* @param {string} html
* @returns {string}
*/
public stripLineNumbers(html: string): string {
const root = document.createElement('div');
root.innerHTML = html;
this.stripLineNumbersFromNode(root);
return root.innerHTML;
}
/**
* Traverses up the DOM tree until it finds a node with a nextSibling, then returns that sibling
*
* @param {Node} node
* @returns {Node}
*/
public findNextAuntNode(node: Node): Node {
if (node.nextSibling) {
return node.nextSibling;
} else if (node.parentNode) {
return this.findNextAuntNode(node.parentNode);
} else {
return null;
}
}
/**
* Highlights (span[class=highlight]) all text starting from the given line number Node to the next one found.
*
* @param {Element} lineNumberNode
*/
public highlightUntilNextLine(lineNumberNode: Element): void {
let currentNode: Node = lineNumberNode,
foundNextLineNumber = false;
do {
let wasHighlighted = false;
if (currentNode.nodeType === TEXT_NODE) {
const node = document.createElement('span');
node.setAttribute('class', 'highlight');
node.innerHTML = currentNode.nodeValue;
currentNode.parentNode.insertBefore(node, currentNode);
currentNode.parentNode.removeChild(currentNode);
currentNode = node;
wasHighlighted = true;
} else {
wasHighlighted = false;
}
if (currentNode.childNodes.length > 0 && !this.isOsLineNumberNode(currentNode) && !wasHighlighted) {
currentNode = currentNode.childNodes[0];
} else if (currentNode.nextSibling) {
currentNode = currentNode.nextSibling;
} else {
currentNode = this.findNextAuntNode(currentNode);
}
if (this.isOsLineNumberNode(currentNode)) {
foundNextLineNumber = true;
}
} while (!foundNextLineNumber && currentNode !== null);
}
/**
* Highlights (span[class=highlight]) a specific line.
*
* @param {string} html
* @param {number} lineNumber
* @return {string}
*/
public highlightLine(html: string, lineNumber: number): string {
const fragment = this.htmlToFragment(html),
lineNumberNode = this.getLineNumberNode(fragment, lineNumber);
if (lineNumberNode) {
this.highlightUntilNextLine(lineNumberNode);
html = this.fragmentToHtml(fragment);
}
return html;
}
/**
* Helper function that does the actual work for `splitInlineElementsAtLineBreaks`
*
* @param {Element} lineNumber
*/
private splitInlineElementsAtLineBreak(lineNumber: Element): void {
const parentIsInline = (el: Element) => this.isInlineElement(el.parentElement);
while (parentIsInline(lineNumber)) {
const parent: Element = lineNumber.parentElement;
const beforeParent: Element = <Element>parent.cloneNode(false);
// If the node right before the line number is a line break, move it outside along with the line number
let lineBreak: Element = null;
if (this.isOsLineBreakNode(lineNumber.previousSibling)) {
lineBreak = <Element>lineNumber.previousSibling;
lineBreak.remove();
}
// All nodes before the line break / number are moved into beforeParent
while (lineNumber.previousSibling) {
const previousSibling = lineNumber.previousSibling;
parent.removeChild(previousSibling);
if (beforeParent.childNodes.length > 0) {
beforeParent.insertBefore(previousSibling, beforeParent.firstChild);
} else {
beforeParent.appendChild(previousSibling);
}
}
// Insert beforeParent before the parent
if (beforeParent.childNodes.length > 0) {
parent.parentElement.insertBefore(beforeParent, parent);
}
// Add the line number and (if found) the line break inbetween
if (lineBreak) {
parent.parentElement.insertBefore(lineBreak, parent);
}
parent.parentElement.insertBefore(lineNumber, parent);
}
}
/**
* This splits all inline elements so that each line number (including preceding line breaks)
* are located directly in a block-level element.
* `<p><span>...[linebreak]...</span></p>`
* is therefore converted into
* `<p><span>...</span>[linebreak]<span>...</span></p>
*
* This function is mainly provided for the PDF generation
*
* @param {string} html
* @returns {string}
*/
public splitInlineElementsAtLineBreaks(html: string): string {
const fragment = this.htmlToFragment(html);
const lineNumbers = fragment.querySelectorAll('span.os-line-number');
lineNumbers.forEach((lineNumber: Element) => {
this.splitInlineElementsAtLineBreak(lineNumber);
});
return this.fragmentToHtml(fragment);
}
}