5978868c37
- new config option to enable/disable multiple paragraphs
1099 lines
40 KiB
TypeScript
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"> </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 = ' '; // 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);
|
|
}
|
|
}
|