diff --git a/src/EscapeSequences.ts b/src/EscapeSequences.ts index 34dfde90..9542658f 100644 --- a/src/EscapeSequences.ts +++ b/src/EscapeSequences.ts @@ -1,3 +1,7 @@ +/** + * @license MIT + */ + /** * C0 control codes * See = https://en.wikipedia.org/wiki/C0_and_C1_control_codes diff --git a/src/Linkifier.ts b/src/Linkifier.ts new file mode 100644 index 00000000..04871e80 --- /dev/null +++ b/src/Linkifier.ts @@ -0,0 +1,259 @@ +/** + * @license MIT + */ + +export type LinkHandler = (uri: string) => void; + +type LinkMatcher = {id: number, regex: RegExp, matchIndex?: number, handler: LinkHandler}; + +const protocolClause = '(https?:\\/\\/)'; +const domainCharacterSet = '[\\da-z\\.-]+'; +const negatedDomainCharacterSet = '[^\\da-z\\.-]+'; +const domainBodyClause = '(' + domainCharacterSet + ')'; +const tldClause = '([a-z\\.]{2,6})'; +const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})'; +const portClause = '(:\\d{1,5})'; +const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?'; +const pathClause = '(\\/[\\/\\w\\.-]*)*'; +const queryStringClause = '(\\?[\\w\\[\\]\\(\\)\\/\\?\\!#@$&\'*+,:;]*)?'; +const negatedPathCharacterSet = '[^\\/\\w\\.-]+'; +const bodyClause = hostClause + pathClause + queryStringClause; +const start = '(?:^|' + negatedDomainCharacterSet + ')('; +const end = ')($|' + negatedPathCharacterSet + ')'; +const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end); + +/** + * The ID of the built in http(s) link matcher. + */ +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 _rows: HTMLElement[]; + private _rowTimeoutIds: number[]; + private _linkMatchers: LinkMatcher[]; + private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; + + constructor(rows: HTMLElement[]) { + this._rows = rows; + this._rowTimeoutIds = []; + this._linkMatchers = []; + this.registerLinkMatcher(strictUrlRegex, null, 1); + } + + /** + * Queues a row for linkification. + * @param {number} rowIndex The index of the row to linkify. + */ + public linkifyRow(rowIndex: number): void { + const timeoutId = this._rowTimeoutIds[rowIndex]; + if (timeoutId) { + clearTimeout(timeoutId); + } + this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY); + } + + /** + * Attaches a handler for hypertext links, overriding default behavior + * for standard http(s) links. + * @param {LinkHandler} handler The handler to use, this can be cleared with + * null. + */ + public attachHypertextLinkHandler(handler: LinkHandler): void { + this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler; + } + + /** + * Registers a link matcher, allowing custom link patterns to be matched and + * handled. + * @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(text) + * call. This defaults to 0 (for regular expressions without capture groups). + * @return {number} The ID of the new matcher, this can be used to deregister. + */ + public registerLinkMatcher(regex: RegExp, handler: LinkHandler, matchIndex?: number): number { + if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) { + throw new Error('handler cannot be falsy'); + } + const matcher: LinkMatcher = { + id: this._nextLinkMatcherId++, + regex, + handler, + matchIndex + }; + this._linkMatchers.push(matcher); + return matcher.id; + } + + /** + * 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): 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; + } + + /** + * Linkifies a row. + * @param {number} rowIndex The index of the row to linkify. + */ + private _linkifyRow(rowIndex: number): void { + 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(text, matcher.regex, matcher.matchIndex); + if (uri) { + this._doLinkifyRow(rowIndex, uri, matcher.handler); + // 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. + */ + private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void { + // Iterate over nodes as we want to consider text nodes + const nodes = this._rows[rowIndex].childNodes; + 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) { + // Matches entire string + if (node.nodeType === Node.TEXT_NODE) { + this._replaceNode(node, linkElement); + } else { + const element = (node); + if (element.nodeName === 'A') { + // This row has already been linkified + return; + } + element.innerHTML = ''; + element.appendChild(linkElement); + } + } else { + // Matches part of string + this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex); + } + } + } + } + + /** + * 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; + } + return match[typeof matchIndex !== 'number' ? 0 : matchIndex]; + } + + /** + * Creates a link anchor element. + * @param {string} uri The uri of the link. + * @return {HTMLAnchorElement} The link. + */ + private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement { + const element = document.createElement('a'); + element.textContent = uri; + if (handler) { + element.addEventListener('click', () => handler(uri)); + } else { + element.href = uri; + // Force link on another tab so work is not lost + element.target = '_blank'; + } + return element; + } + + /** + * Replace a node with 1 or more other nodes. + * @param {Node} oldNode The node to replace. + * @param {Node[]} newNodes The new nodes to insert in order. + */ + private _replaceNode(oldNode: Node, ...newNodes: Node[]): void { + const parent = oldNode.parentNode; + for (let i = 0; i < newNodes.length; i++) { + parent.insertBefore(newNodes[i], oldNode); + } + parent.removeChild(oldNode); + } + + /** + * Replace a substring within a node with a new node. + * @param {Node} targetNode The target node; either a text node or a + * containing a single text node. + * @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. + */ + private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void { + let node = targetNode; + if (node.nodeType !== Node.TEXT_NODE) { + node = node.childNodes[0]; + } + + // The targetNode will be either a text node or a . 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) { + throw new Error('targetNode must be a text node or only contain a single text node'); + } + + const fullText = node.textContent; + + if (substringIndex === 0) { + // Replace with + const rightText = fullText.substring(substring.length); + const rightTextNode = document.createTextNode(rightText); + this._replaceNode(node, newNode, rightTextNode); + } else if (substringIndex === targetNode.textContent.length - substring.length) { + // Replace with + const leftText = fullText.substring(0, substringIndex); + const leftTextNode = document.createTextNode(leftText); + this._replaceNode(node, leftTextNode, newNode); + } else { + // Replace with + 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); + } + } +} diff --git a/src/addons/linkify/index.html b/src/addons/linkify/index.html deleted file mode 100644 index 5f429326..00000000 --- a/src/addons/linkify/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - -
- - - \ No newline at end of file diff --git a/src/addons/linkify/linkify.js b/src/addons/linkify/linkify.js deleted file mode 100644 index d2391026..00000000 --- a/src/addons/linkify/linkify.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Methods for turning URL subscrings in the terminal's content into links (`a` DOM elements). - * @module xterm/addons/linkify/linkify - * @license MIT - */ - -(function (linkify) { - if (typeof exports === 'object' && typeof module === 'object') { - /* - * CommonJS environment - */ - module.exports = linkify(require('../../xterm')); - } else if (typeof define == 'function') { - /* - * Require.js is available - */ - define(['../../xterm'], linkify); - } else { - /* - * Plain browser environment - */ - linkify(window.Terminal); - } -})(function (Xterm) { - 'use strict'; - - var exports = {}, - protocolClause = '(https?:\\/\\/)', - domainCharacterSet = '[\\da-z\\.-]+', - negatedDomainCharacterSet = '[^\\da-z\\.-]+', - domainBodyClause = '(' + domainCharacterSet + ')', - tldClause = '([a-z\\.]{2,6})', - ipClause = '((\\d{1,3}\\.){3}\\d{1,3})', - portClause = '(:\\d{1,5})', - hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?', - pathClause = '(\\/[\\/\\w\\.-]*)*', - negatedPathCharacterSet = '[^\\/\\w\\.-]+', - bodyClause = hostClause + pathClause, - start = '(?:^|' + negatedDomainCharacterSet + ')(', - end = ')($|' + negatedPathCharacterSet + ')', - lenientUrlClause = start + protocolClause + '?' + bodyClause + end, - strictUrlClause = start + protocolClause + bodyClause + end, - lenientUrlRegex = new RegExp(lenientUrlClause), - strictUrlRegex = new RegExp(strictUrlClause); - - /** - * Converts all valid URLs found in the given terminal line into - * hyperlinks. The terminal line can be either the HTML element itself - * or the index of the termina line in the children of the terminal - * rows container. - * - * @param {Xterm} terminal - The terminal that owns the given line. - * @param {number|HTMLDivElement} line - The terminal line that should get - * "linkified". - * @param {boolean} lenient - The regex type that will be used to identify links. If lenient is - * false, the regex requires a protocol clause. Defaults to true. - * @param {string} target - Sets target="" attribute with value provided to links. - * Default doesn't set target attribute - * @emits linkify - * @emits linkify:line - */ - exports.linkifyTerminalLine = function (terminal, line, lenient, target) { - if (typeof line == 'number') { - line = terminal.rowContainer.children[line]; - } else if (! (line instanceof HTMLDivElement)) { - var message = 'The "line" argument should be either a number'; - message += ' or an HTMLDivElement'; - - throw new TypeError(message); - } - - if (typeof target === 'undefined') { - target = ''; - } else { - target = 'target="' + target + '"'; - } - - var buffer = document.createElement('span'), - nodes = line.childNodes; - - for (var j=0; j' + url + '
', - newHTML = nodeHTML.replace(url, link); - - line.innerHTML = line.innerHTML.replace(nodeHTML, newHTML); - } - - /** - * This event gets emitted when conversion of all URL susbtrings - * to HTML anchor elements (links) has finished, for a specific - * line of the current Xterm instance. - * - * @event linkify:line - */ - terminal.emit('linkify:line', line); - }; - - /** - * Finds a link within a block of text. - * - * @param {string} text - The text to search . - * @param {boolean} lenient - Whether to use the lenient search. - * @return {string} A URL. - */ - exports.findLinkMatch = function (text, lenient) { - var match = text.match(lenient ? lenientUrlRegex : strictUrlRegex); - if (!match || match.length === 0) { - return null; - } - return match[1]; - } - - /** - * Converts all valid URLs found in the terminal view into hyperlinks. - * - * @param {Xterm} terminal - The terminal that should get "linkified". - * @param {boolean} lenient - The regex type that will be used to identify links. If lenient is - * false, the regex requires a protocol clause. Defaults to true. - * @param {string} target - Sets target="" attribute with value provided to links. - * Default doesn't set target attribute - * @emits linkify - * @emits linkify:line - */ - exports.linkify = function (terminal, lenient, target) { - var rows = terminal.rowContainer.children; - - lenient = (typeof lenient == "boolean") ? lenient : true; - for (var i=0; i tags. This will trigger a refresh as links potentially need to be + * reconstructed. Calling this with null will remove the handler. + * @param {LinkHandler} handler The handler callback function. + */ +Terminal.prototype.attachHypertextLinkHandler = function(handler) { + if (!this.linkifier) { + throw new Error('Cannot attach a hypertext link handler before Terminal.open is called'); + } + this.linkifier.attachHypertextLinkHandler(handler); + // Refresh to force links to refresh + this.refresh(0, this.rows - 1); +} + + +/** + * Registers a link matcher, allowing custom link patterns to be matched and + * handled. + * @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(text) + * call. This defaults to 0 (for regular expressions without capture groups). + * @return {number} The ID of the new matcher, this can be used to deregister. + */ +Terminal.prototype.registerLinkMatcher = function(regex, handler, matchIndex) { + if (this.linkifier) { + var matcherId = this.linkifier.registerLinkMatcher(regex, handler, matchIndex); + this.refresh(0, this.rows - 1); + return matcherId; + } +} + +/** + * Deregisters a link matcher if it has been registered. + * @param {number} matcherId The link matcher's ID (returned after register) + */ +Terminal.prototype.deregisterLinkMatcher = function(matcherId) { + if (this.linkifier) { + if (this.linkifier.deregisterLinkMatcher(matcherId)) { + this.refresh(0, this.rows - 1); + } + } +} + /** * Handle a keydown event * Key Resources: