.
* - 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 = ' '; // 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 =
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(/(
]*>)[\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 = 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 = 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.
* `...[linebreak]...
`
* is therefore converted into
* `...[linebreak]...
*
* 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);
}
}