* @license MIT
*/
-export type LinkHandler = (uri: string) => void;
+import { LinkMatcherOptions } from './Interfaces';
+import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';
-type LinkMatcher = {id: number, regex: RegExp, matchIndex?: number, handler: LinkHandler};
+const INVALID_LINK_CLASS = 'xterm-invalid-link';
const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
const domainBodyClause = '(' + domainCharacterSet + ')';
const tldClause = '([a-z\\.]{2,6})';
const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
+const localHostClause = '(localhost)';
const portClause = '(:\\d{1,5})';
-const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?';
-const pathClause = '(\\/[\\/\\w\\.-]*)*';
-const negatedPathCharacterSet = '[^\\/\\w\\.-]+';
-const bodyClause = hostClause + pathClause;
+const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
+const pathClause = '(\\/[\\/\\w\\.\\-%~]*)*';
+const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
+const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
+const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
+const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
+const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
const start = '(?:^|' + negatedDomainCharacterSet + ')(';
const end = ')($|' + negatedPathCharacterSet + ')';
const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
*/
const HYPERTEXT_LINK_MATCHER_ID = 0;
-/**
- * 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.
- */
-const TIME_BEFORE_LINKIFY = 200;
-
/**
* The Linkifier applies links to rows shortly after they have been refreshed.
*/
export class Linkifier {
- private static _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
+ /**
+ * The time to wait after a row is changed before it is linkified. This prevents
+ * the costly operation of searching every row multiple times, potentially a
+ * huge amount of times.
+ */
+ protected static TIME_BEFORE_LINKIFY = 200;
+
+ protected _linkMatchers: LinkMatcher[];
+ private _document: Document;
private _rows: HTMLElement[];
private _rowTimeoutIds: number[];
- private _linkMatchers: LinkMatcher[];
+ private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
- constructor(rows: HTMLElement[]) {
- this._rows = rows;
+ constructor() {
this._rowTimeoutIds = [];
this._linkMatchers = [];
- this.registerLinkMatcher(strictUrlRegex, null, 1);
+ 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;
}
/**
* @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);
}
- this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
+ this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), Linkifier.TIME_BEFORE_LINKIFY);
}
/**
* @param {LinkHandler} handler The handler to use, this can be cleared with
* null.
*/
- public attachHypertextLinkHandler(handler: LinkHandler): 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.
- * @param {RegExp} regex The regular expression the search for.
+ * @param {RegExp} regex The regular expression to search for, specifically
+ * this searches the textContent of the rows. You will want to use \s to match
+ * a space ' ' character for example.
* @param {LinkHandler} handler The callback when the link is called.
- * @param {number} matchIndex The index of the link from the regex.match(html)
- * call. This defaults to 0 (for regular expressions without capture groups).
+ * @param {LinkMatcherOptions} [options] Options for the link matcher.
* @return {number} The ID of the new matcher, this can be used to deregister.
*/
- public registerLinkMatcher(regex: RegExp, handler: LinkHandler, matchIndex?: number): number {
- if (Linkifier._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
- throw new Error('handler cannot be falsy');
+ public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number {
+ if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
+ throw new Error('handler must be defined');
}
const matcher: LinkMatcher = {
- id: Linkifier._nextLinkMatcherId++,
+ id: this._nextLinkMatcherId++,
regex,
handler,
- matchIndex
+ matchIndex: options.matchIndex,
+ validationCallback: options.validationCallback,
+ priority: options.priority || 0
};
- this._linkMatchers.push(matcher);
+ this._addLinkMatcherToList(matcher);
return matcher.id;
}
+ /**
+ * Inserts a link matcher to the list in the correct position based on the
+ * priority of each link matcher. New link matchers of equal priority are
+ * considered after older link matchers.
+ * @param matcher The link matcher to be added.
+ */
+ private _addLinkMatcherToList(matcher: LinkMatcher): void {
+ if (this._linkMatchers.length === 0) {
+ this._linkMatchers.push(matcher);
+ return;
+ }
+
+ for (let i = this._linkMatchers.length - 1; i >= 0; i--) {
+ if (matcher.priority <= this._linkMatchers[i].priority) {
+ this._linkMatchers.splice(i + 1, 0, matcher);
+ return;
+ }
+ }
+
+ this._linkMatchers.splice(0, 0, matcher);
+ }
+
/**
* Deregisters a link matcher if it has been registered.
* @param {number} matcherId The link matcher's ID (returned after register)
+ * @return {boolean} Whether a link matcher was found and deregistered.
*/
- public deregisterLinkMatcher(matcherId: number): void {
+ public deregisterLinkMatcher(matcherId: number): boolean {
// ID 0 is the hypertext link matcher which cannot be deregistered
for (let i = 1; i < this._linkMatchers.length; i++) {
if (this._linkMatchers[i].id === matcherId) {
this._linkMatchers.splice(i, 1);
+ return true;
}
}
+ return false;
}
/**
* @param {number} rowIndex The index of the row to linkify.
*/
private _linkifyRow(rowIndex: number): void {
- const rowHtml = this._rows[rowIndex].innerHTML;
+ const row = this._rows[rowIndex];
+ if (!row) {
+ return;
+ }
+ const text = row.textContent;
for (let i = 0; i < this._linkMatchers.length; i++) {
const matcher = this._linkMatchers[i];
- const uri = this._findLinkMatch(rowHtml, matcher.regex, matcher.matchIndex);
- if (uri) {
- this._doLinkifyRow(rowIndex, uri, matcher.handler);
+ const linkElements = this._doLinkifyRow(row, matcher);
+ if (linkElements.length > 0) {
+ // Fire validation callback
+ 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.
+ * @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?: LinkHandler): void {
+ 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);
- if (node.textContent.trim().length === uri.length) {
+ const linkElement = this._createAnchorElement(uri, matcher.handler, isHttpLinkMatcher);
+ if (node.textContent.length === uri.length) {
// Matches entire string
- if (node.nodeType === Node.TEXT_NODE) {
+ 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;
}
- }
- }
- }
+ result.push(linkElement);
- /**
- * Finds a link match in a piece of HTML.
- * @param {string} html The HTML to search.
- * @param {number} matchIndex The regex match index of the link.
- * @return {string} The matching URI or null if not found.
- */
- private _findLinkMatch(html: string, regex: RegExp, matchIndex?: number): string {
- const match = html.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: LinkHandler): HTMLAnchorElement {
- const element = document.createElement('a');
+ private _createAnchorElement(uri: string, handler: LinkMatcherHandler, isHypertextLinkHandler: boolean): HTMLAnchorElement {
+ const element = this._document.createElement('a');
element.textContent = uri;
- if (handler) {
- element.addEventListener('click', () => 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: 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 !== 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 = document.createTextNode(rightText);
- this._replaceNode(node, newNode, rightTextNode);
- } else if (substringIndex === targetNode.textContent.length - substring.length) {
+ const rightTextNode = this._document.createTextNode(rightText);
+ 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 = document.createTextNode(leftText);
- this._replaceNode(node, leftTextNode, newNode);
- } else {
- // Replace with <textnode><newNode><textnode>
- const leftText = fullText.substring(0, substringIndex);
- const leftTextNode = document.createTextNode(leftText);
- const rightText = fullText.substring(substringIndex + substring.length);
- const rightTextNode = document.createTextNode(rightText);
- this._replaceNode(node, leftTextNode, newNode, rightTextNode);
+ const leftTextNode = this._document.createTextNode(leftText);
+ 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;
}
}