blob: cc7cd49e715ca48df6e0fc890540f5a784543c30 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
const ANNOTATION_TAG = 'HL';
// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
export const GrAnnotation = {
/**
* The DOM API textContent.length calculation is broken when the text
* contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
*
*/
getLength(node: Node) {
if (node instanceof Comment) return 0;
return this.getStringLength(node.textContent || '');
},
getStringLength(str: string) {
return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
},
/**
* Annotates the [offset, offset+length) text segment in the parent with the
* element definition provided as arguments.
*
* @param parent the node whose contents will be annotated.
* If parent is Text then parent.parentNode must not be null
* @param offset the 0-based offset from which the annotation will
* start.
* @param length of the annotated text.
* @param elementSpec the spec to create the
* annotating element.
*/
annotateWithElement(
parent: Node,
offset: number,
length: number,
elSpec: ElementSpec
) {
const tagName = elSpec.tagName;
const attributes = elSpec.attributes || {};
let childNodes: Node[];
if (parent instanceof Element) {
childNodes = Array.from(parent.childNodes);
} else if (parent instanceof Text) {
childNodes = [parent];
parent = parent.parentNode!;
} else {
return;
}
const nestedNodes: Node[] = [];
for (let node of childNodes) {
const initialNodeLength = this.getLength(node);
// If the current node is completely before the offset.
if (offset > 0 && initialNodeLength <= offset) {
offset -= initialNodeLength;
continue;
}
if (offset > 0) {
node = this.splitNode(node, offset);
offset = 0;
}
if (this.getLength(node) > length) {
this.splitNode(node, length);
}
nestedNodes.push(node);
length -= this.getLength(node);
if (!length) break;
}
const wrapper = document.createElement(tagName);
const sanitizer = getSanitizeDOMValue();
for (let [name, value] of Object.entries(attributes)) {
if (!value) continue;
if (sanitizer) {
value = sanitizer(value, name, 'attribute', wrapper) as string;
}
wrapper.setAttribute(name, value);
}
for (const inner of nestedNodes) {
parent.replaceChild(wrapper, inner);
wrapper.appendChild(inner);
}
},
/**
* Surrounds the element's text at specified range in an ANNOTATION_TAG
* element. If the element has child elements, the range is split and
* applied as deeply as possible.
*/
annotateElement(
parent: HTMLElement,
offset: number,
length: number,
cssClass: string
) {
const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
let nodeLength;
let subLength;
for (const node of nodes) {
nodeLength = this.getLength(node);
// If the current node is completely before the offset.
if (nodeLength <= offset) {
offset -= nodeLength;
continue;
}
// Sublength is the annotation length for the current node.
subLength = Math.min(length, nodeLength - offset);
if (node instanceof Text) {
this._annotateText(node, offset, subLength, cssClass);
} else if (node instanceof Element) {
this.annotateElement(node, offset, subLength, cssClass);
}
// If there is still more to annotate, then shift the indices, otherwise
// work is done, so break the loop.
if (subLength < length) {
length -= subLength;
offset = 0;
} else {
break;
}
}
},
/**
* Wraps node in annotation tag with cssClass, replacing the node in DOM.
*/
wrapInHighlight(node: Element | Text, cssClass: string) {
let hl;
if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
hl = node;
hl.classList.add(cssClass);
} else {
hl = document.createElement(ANNOTATION_TAG);
hl.className = cssClass;
if (node.parentElement) node.parentElement.replaceChild(hl, node);
hl.appendChild(node);
}
return hl;
},
/**
* Splits Text Node and wraps it in hl with cssClass.
* Wraps trailing part after split, tailing one if firstPart is true.
*/
splitAndWrapInHighlight(
node: Text,
offset: number,
cssClass: string,
firstPart?: boolean
) {
if (this.getLength(node) === offset || offset === 0) {
return this.wrapInHighlight(node, cssClass);
} else {
if (firstPart) {
this.splitNode(node, offset);
// Node points to first part of the Text, second one is sibling.
} else {
// if node is Text then splitNode will return a Text
node = this.splitNode(node, offset) as Text;
}
return this.wrapInHighlight(node, cssClass);
}
},
/**
* Splits Node at offset.
* If Node is Element, it's cloned and the node at offset is split too.
*/
splitNode(element: Node, offset: number) {
if (element instanceof Text) {
return this.splitTextNode(element, offset);
}
const tail = element.cloneNode(false);
if (element.parentElement)
element.parentElement.insertBefore(tail, element.nextSibling);
// Skip nodes before offset.
let node = element.firstChild;
while (
node &&
(this.getLength(node) <= offset || this.getLength(node) === 0)
) {
offset -= this.getLength(node);
node = node.nextSibling;
}
if (node && this.getLength(node) > offset) {
tail.appendChild(this.splitNode(node, offset));
}
while (node && node.nextSibling) {
tail.appendChild(node.nextSibling);
}
return tail;
},
/**
* Node.prototype.splitText Unicode-valid alternative.
*
* DOM Api for splitText() is broken for Unicode:
* https://mathiasbynens.be/notes/javascript-unicode
*
* @return Trailing Text Node.
*/
splitTextNode(node: Text, offset: number) {
if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
// TODO (viktard): Polyfill Array.from for IE10.
const head = Array.from(node.textContent);
const tail = head.splice(offset);
const parent = node.parentNode;
// Split the content of the original node.
node.textContent = head.join('');
const tailNode = document.createTextNode(tail.join(''));
if (parent) {
parent.insertBefore(tailNode, node.nextSibling);
}
return tailNode;
} else {
return node.splitText(offset);
}
},
_annotateText(node: Text, offset: number, length: number, cssClass: string) {
const nodeLength = this.getLength(node);
// There are four cases:
// 1) Entire node is highlighted.
// 2) Highlight is at the start.
// 3) Highlight is at the end.
// 4) Highlight is in the middle.
if (offset === 0 && nodeLength === length) {
// Case 1.
this.wrapInHighlight(node, cssClass);
} else if (offset === 0) {
// Case 2.
this.splitAndWrapInHighlight(node, length, cssClass, true);
} else if (offset + length === nodeLength) {
// Case 3
this.splitAndWrapInHighlight(node, offset, cssClass, false);
} else {
// Case 4
this.splitAndWrapInHighlight(
this.splitTextNode(node, offset),
length,
cssClass,
true
);
}
},
};
/**
* Data used to construct an element.
*
*/
export interface ElementSpec {
tagName: string;
attributes?: {[attributeName: string]: string | undefined};
}