* @license MIT
*/
-import { isMac } from './utils/Browser';
import { LinkMatcherOptions } from './Interfaces';
import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';
const localHostClause = '(localhost)';
const portClause = '(:\\d{1,5})';
const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
-const pathClause = '(\\/[\\/\\w\\.\\-%]*)*';
-const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;\\=\\.\\-]*';
+const pathClause = '(\\/[\\/\\w\\.\\-%~]*)*';
+const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
export class Linkifier {
/**
* The time to wait after a row is changed before it is linkified. This prevents
- * the costly operation of searching every row multiple times, pntentially a
- * huge aount of times.
+ * the costly operation of searching every row multiple times, potentially a
+ * huge amount of times.
*/
protected static TIME_BEFORE_LINKIFY = 200;
private _rowTimeoutIds: number[];
private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
- constructor(document: Document, rows: HTMLElement[]) {
- this._document = document;
- this._rows = rows;
+ constructor() {
this._rowTimeoutIds = [];
this._linkMatchers = [];
this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 });
}
+ /**
+ * Attaches the linkifier to the DOM, enabling linkification.
+ * @param document The document object.
+ * @param rows The array of rows to apply links to.
+ */
+ public attachToDom(document: Document, rows: HTMLElement[]) {
+ this._document = document;
+ this._rows = rows;
+ }
+
/**
* Queues a row for linkification.
* @param {number} rowIndex The index of the row to linkify.
*/
public linkifyRow(rowIndex: number): void {
+ // Don't attempt linkify if not yet attached to DOM
+ if (!this._document) {
+ return;
+ }
+
const timeoutId = this._rowTimeoutIds[rowIndex];
if (timeoutId) {
clearTimeout(timeoutId);
* @param {LinkHandler} handler The handler to use, this can be cleared with
* null.
*/
- public attachHypertextLinkHandler(handler: LinkMatcherHandler): void {
+ public setHypertextLinkHandler(handler: LinkMatcherHandler): void {
this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
}
+ /**
+ * Attaches a validation callback for hypertext links.
+ * @param {LinkMatcherValidationCallback} callback The callback to use, this
+ * can be cleared with null.
+ */
+ public setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void {
+ this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].validationCallback = callback;
+ }
+
/**
* Registers a link matcher, allowing custom link patterns to be matched and
* handled.
*/
public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number {
if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
- throw new Error('handler cannot be falsy');
+ throw new Error('handler must be defined');
}
const matcher: LinkMatcher = {
id: this._nextLinkMatcherId++,
const text = row.textContent;
for (let i = 0; i < this._linkMatchers.length; i++) {
const matcher = this._linkMatchers[i];
- const uri = this._findLinkMatch(text, matcher.regex, matcher.matchIndex);
- if (uri) {
- const linkElement = this._doLinkifyRow(rowIndex, uri, matcher.handler);
+ const linkElements = this._doLinkifyRow(row, matcher);
+ if (linkElements.length > 0) {
// Fire validation callback
- if (linkElement && matcher.validationCallback) {
- matcher.validationCallback(uri, isValid => {
- if (!isValid) {
- linkElement.classList.add(INVALID_LINK_CLASS);
- }
- });
+ if (matcher.validationCallback) {
+ for (let j = 0; j < linkElements.length; j++) {
+ const element = linkElements[j];
+ matcher.validationCallback(element.textContent, element, isValid => {
+ if (!isValid) {
+ element.classList.add(INVALID_LINK_CLASS);
+ }
+ });
+ }
}
// Only allow a single LinkMatcher to trigger on any given row.
return;
/**
* Linkifies a row given a specific handler.
- * @param {number} rowIndex The index of the row to linkify.
- * @param {string} uri The uri that has been found.
- * @param {handler} handler The handler to trigger when the link is triggered.
- * @return The link element if it was added, otherwise undefined.
+ * @param {HTMLElement} row The row to linkify.
+ * @param {LinkMatcher} matcher The link matcher for this line.
+ * @return The link element(s) that were added.
*/
- private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkMatcherHandler): HTMLElement {
+ private _doLinkifyRow(row: HTMLElement, matcher: LinkMatcher): HTMLElement[] {
// Iterate over nodes as we want to consider text nodes
- const nodes = this._rows[rowIndex].childNodes;
+ let result = [];
+ const isHttpLinkMatcher = matcher.id === HYPERTEXT_LINK_MATCHER_ID;
+ const nodes = row.childNodes;
+
+ // Find the first match
+ let match = row.textContent.match(matcher.regex);
+ if (!match || match.length === 0) {
+ return result;
+ }
+ let uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
+ // Set the next searches start index
+ let rowStartIndex = match.index + uri.length;
+
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const searchIndex = node.textContent.indexOf(uri);
if (searchIndex >= 0) {
- const linkElement = this._createAnchorElement(uri, handler);
+ const linkElement = this._createAnchorElement(uri, matcher.handler, isHttpLinkMatcher);
if (node.textContent.length === uri.length) {
// Matches entire string
-
if (node.nodeType === 3 /*Node.TEXT_NODE*/) {
this._replaceNode(node, linkElement);
} else {
const element = (<HTMLElement>node);
if (element.nodeName === 'A') {
// This row has already been linkified
- return;
+ return result;
}
element.innerHTML = '';
element.appendChild(linkElement);
}
+ } else if (node.childNodes.length > 1) {
+ // Matches part of string in an element with multiple child nodes
+ for (let j = 0; j < node.childNodes.length; j++) {
+ const childNode = node.childNodes[j];
+ const childSearchIndex = childNode.textContent.indexOf(uri);
+ if (childSearchIndex !== -1) {
+ // Match found in currentNode
+ this._replaceNodeSubstringWithNode(childNode, linkElement, uri, childSearchIndex);
+ // Don't need to count nodesAdded by replacing the node as this
+ // is a child node, not a top-level node.
+ break;
+ }
+ }
} else {
- // Matches part of string
- this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
+ // Matches part of string in a single text node
+ const nodesAdded = this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
+ // No need to consider the new nodes
+ i += nodesAdded;
}
- return linkElement;
- }
- }
- }
+ result.push(linkElement);
- /**
- * Finds a link match in a piece of text.
- * @param {string} text The text to search.
- * @param {number} matchIndex The regex match index of the link.
- * @return {string} The matching URI or null if not found.
- */
- private _findLinkMatch(text: string, regex: RegExp, matchIndex?: number): string {
- const match = text.match(regex);
- if (!match || match.length === 0) {
- return null;
+ // Find the next match
+ match = row.textContent.substring(rowStartIndex).match(matcher.regex);
+ if (!match || match.length === 0) {
+ return result;
+ }
+ uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
+ rowStartIndex += match.index + uri.length;
+ }
}
- return match[typeof matchIndex !== 'number' ? 0 : matchIndex];
+ return result;
}
/**
* @param {string} uri The uri of the link.
* @return {HTMLAnchorElement} The link.
*/
- private _createAnchorElement(uri: string, handler: LinkMatcherHandler): HTMLAnchorElement {
+ private _createAnchorElement(uri: string, handler: LinkMatcherHandler, isHypertextLinkHandler: boolean): HTMLAnchorElement {
const element = this._document.createElement('a');
element.textContent = uri;
- if (handler) {
- element.addEventListener('click', (event: KeyboardEvent) => {
- // Don't execute the handler if the link is flagged as invalid
- if (element.classList.contains(INVALID_LINK_CLASS)) {
- return;
- }
- // Require ctrl on click
- if (isMac ? event.metaKey : event.ctrlKey) {
- handler(uri);
- }
- });
- } else {
+ element.draggable = false;
+ if (isHypertextLinkHandler) {
element.href = uri;
// Force link on another tab so work is not lost
element.target = '_blank';
- element.addEventListener('click', (event: KeyboardEvent) => {
- // Require ctrl on click
- if (isMac ? !event.metaKey : !event.ctrlKey) {
- event.preventDefault();
- return false;
+ element.addEventListener('click', (event: MouseEvent) => {
+ if (handler) {
+ return handler(event, uri);
}
});
+ } else {
+ element.addEventListener('click', (event: MouseEvent) => {
+ // Don't execute the handler if the link is flagged as invalid
+ if (element.classList.contains(INVALID_LINK_CLASS)) {
+ return;
+ }
+ return handler(event, uri);
+ });
}
return element;
}
* @param {Node} newNode The new node to insert.
* @param {string} substring The substring to replace.
* @param {number} substringIndex The index of the substring within the string.
+ * @return The number of nodes to skip when searching for the next uri.
*/
- private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
- let node = targetNode;
- if (node.nodeType !== 3/*Node.TEXT_NODE*/) {
- node = node.childNodes[0];
+ private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): number {
+ // If the targetNode is a non-text node with a single child, make the child
+ // the new targetNode.
+ if (targetNode.childNodes.length === 1) {
+ targetNode = targetNode.childNodes[0];
}
// The targetNode will be either a text node or a <span>. The text node
// (targetNode or its only-child) needs to be replaced with newNode plus new
// text nodes potentially on either side.
- if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) {
+ if (targetNode.nodeType !== 3/*Node.TEXT_NODE*/) {
throw new Error('targetNode must be a text node or only contain a single text node');
}
- const fullText = node.textContent;
+ const fullText = targetNode.textContent;
if (substringIndex === 0) {
// Replace with <newNode><textnode>
const rightText = fullText.substring(substring.length);
const rightTextNode = this._document.createTextNode(rightText);
- this._replaceNode(node, newNode, rightTextNode);
- } else if (substringIndex === targetNode.textContent.length - substring.length) {
+ this._replaceNode(targetNode, newNode, rightTextNode);
+ return 0;
+ }
+
+ if (substringIndex === targetNode.textContent.length - substring.length) {
// Replace with <textnode><newNode>
const leftText = fullText.substring(0, substringIndex);
const leftTextNode = this._document.createTextNode(leftText);
- this._replaceNode(node, leftTextNode, newNode);
- } else {
- // Replace with <textnode><newNode><textnode>
- const leftText = fullText.substring(0, substringIndex);
- const leftTextNode = this._document.createTextNode(leftText);
- const rightText = fullText.substring(substringIndex + substring.length);
- const rightTextNode = this._document.createTextNode(rightText);
- this._replaceNode(node, leftTextNode, newNode, rightTextNode);
+ this._replaceNode(targetNode, leftTextNode, newNode);
+ return 0;
}
+
+ // Replace with <textnode><newNode><textnode>
+ const leftText = fullText.substring(0, substringIndex);
+ const leftTextNode = this._document.createTextNode(leftText);
+ const rightText = fullText.substring(substringIndex + substring.length);
+ const rightTextNode = this._document.createTextNode(rightText);
+ this._replaceNode(targetNode, leftTextNode, newNode, rightTextNode);
+ return 1;
}
}