2 * The time to wait after a row is changed before it is linkified. This prevents
3 * the costly operation of searching every row multiple times, pntentially a
6 const TIME_BEFORE_LINKIFY = 200;
8 const protocolClause = '(https?:\\/\\/)';
9 const domainCharacterSet = '[\\da-z\\.-]+';
10 const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
11 const domainBodyClause = '(' + domainCharacterSet + ')';
12 const tldClause = '([a-z\\.]{2,6})';
13 const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
14 const portClause = '(:\\d{1,5})';
15 const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?';
16 const pathClause = '(\\/[\\/\\w\\.-]*)*';
17 const negatedPathCharacterSet = '[^\\/\\w\\.-]+';
18 const bodyClause = hostClause + pathClause;
19 const start = '(?:^|' + negatedDomainCharacterSet + ')(';
20 const end = ')($|' + negatedPathCharacterSet + ')';
21 const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
23 export type LinkHandler = (uri: string) => void;
25 type LinkMatcher = {id: number, regex: RegExp, handler: LinkHandler};
27 const HYPERTEXT_LINK_MATCHER_ID = 0;
30 * The Linkifier applies links to rows shortly after they have been refreshed.
32 export class Linkifier {
33 private static _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
35 private _rows: HTMLElement[];
36 private _rowTimeoutIds: number[];
37 private _linkMatchers: LinkMatcher[];
39 constructor(rows: HTMLElement[]) {
41 this._rowTimeoutIds = [];
42 this._linkMatchers = [];
43 this.registerLinkMatcher(strictUrlRegex, null);
47 * Queues a row for linkification.
48 * @param {number} rowIndex The index of the row to linkify.
50 public linkifyRow(rowIndex: number): void {
51 const timeoutId = this._rowTimeoutIds[rowIndex];
53 clearTimeout(timeoutId);
55 this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
59 * Attaches a handler for hypertext links, overriding default <a> behavior.
60 * @param {LinkHandler} handler The handler to use, this can be cleared with
63 public attachHypertextLinkHandler(handler: LinkHandler): void {
64 this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
68 * Registers a link matcher, allowing custom link patterns to be matched and
70 * @param {RegExp} regex The regular expression the search for.
71 * @param {LinkHandler} handler The callback when the link is called.
72 * @return {number} The ID of the new matcher, this can be used to deregister.
74 public registerLinkMatcher(regex: RegExp, handler: LinkHandler): number {
75 if (Linkifier._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
76 throw new Error('handler cannot be falsy');
78 const matcher: LinkMatcher = {
79 id: Linkifier._nextLinkMatcherId++,
83 this._linkMatchers.push(matcher);
88 * Deregisters a link matcher if it has been registered.
89 * @param {number} matcherId The link matcher's ID (returned after register)
91 public deregisterLinkMatcher(matcherId: number): void {
92 // ID 0 is the hypertext link matcher which cannot be deregistered
93 for (let i = 1; i < this._linkMatchers.length; i++) {
94 if (this._linkMatchers[i].id === matcherId) {
95 this._linkMatchers.splice(i, 1);
102 * @param {number} rowIndex The index of the row to linkify.
104 private _linkifyRow(rowIndex: number): void {
105 const rowHtml = this._rows[rowIndex].innerHTML;
106 for (let i = 0; i < this._linkMatchers.length; i++) {
107 const matcher = this._linkMatchers[i];
108 const uri = this._findLinkMatch(rowHtml, matcher.regex);
110 this._doLinkifyRow(rowIndex, uri, matcher.handler);
111 // Only allow a single LinkMatcher to trigger on any given row.
118 * Linkifies a row given a specific handler.
119 * @param {number} rowIndex The index of the row to linkify.
120 * @param {string} uri The uri that has been found.
121 * @param {handler} handler The handler to trigger when the link is triggered.
123 private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void {
124 // Iterate over nodes as we want to consider text nodes
125 const nodes = this._rows[rowIndex].childNodes;
126 for (let i = 0; i < nodes.length; i++) {
127 const node = nodes[i];
128 const searchIndex = node.textContent.indexOf(uri);
129 if (searchIndex >= 0) {
130 if (node.childNodes.length > 0) {
131 // This row has already been linkified
135 const linkElement = this._createAnchorElement(uri, handler);
136 // TODO: Check if childNodes check is needed
137 if (node.textContent.trim().length === uri.length) {
138 // Matches entire string
139 if (node.nodeType === Node.TEXT_NODE) {
140 this._replaceNode(node, linkElement);
142 const element = (<HTMLElement>node);
143 element.innerHTML = '';
144 element.appendChild(linkElement);
147 // Matches part of string
148 this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
155 * Finds a link match in a piece of HTML.
156 * @param {string} html The HTML to search.
157 * @return {string} The matching URI or null if not found.
159 private _findLinkMatch(html: string, regex: RegExp): string {
160 const match = html.match(regex);
161 if (!match || match.length === 0) {
168 * Creates a link anchor element.
169 * @param {string} uri The uri of the link.
170 * @return {HTMLAnchorElement} The link.
172 private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement {
173 const element = document.createElement('a');
174 element.textContent = uri;
176 element.addEventListener('click', () => handler(uri));
179 // Force link on another tab so work is not lost
180 element.target = '_blank';
186 * Replace a node with 1 or more other nodes.
187 * @param {Node} oldNode The node to replace.
188 * @param {Node[]} newNodes The new nodes to insert in order.
190 private _replaceNode(oldNode: Node, ...newNodes: Node[]): void {
191 const parent = oldNode.parentNode;
192 for (let i = 0; i < newNodes.length; i++) {
193 parent.insertBefore(newNodes[i], oldNode);
195 parent.removeChild(oldNode);
199 * Replace a substring within a node with a new node.
200 * @param {Node} targetNode The target node; either a text node or a <span>
201 * containing a single text node.
202 * @param {Node} newNode The new node to insert.
203 * @param {string} substring The substring to replace.
204 * @param {number} substringIndex The index of the substring within the string.
206 private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
207 let node = targetNode;
208 if (node.nodeType !== Node.TEXT_NODE) {
209 node = node.childNodes[0];
212 // The targetNode will be either a text node or a <span>. The text node
213 // (targetNode or its only-child) needs to be replaced with newNode plus new
214 // text nodes potentially on either side.
215 if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) {
216 throw new Error('targetNode must be a text node or only contain a single text node');
219 const fullText = node.textContent;
221 if (substringIndex === 0) {
222 // Replace with <newNode><textnode>
223 const rightText = fullText.substring(substring.length);
224 const rightTextNode = document.createTextNode(rightText);
225 this._replaceNode(node, newNode, rightTextNode);
226 } else if (substringIndex === targetNode.textContent.length - substring.length) {
227 // Replace with <textnode><newNode>
228 const leftText = fullText.substring(0, substringIndex);
229 const leftTextNode = document.createTextNode(leftText);
230 this._replaceNode(node, leftTextNode, newNode);
232 // Replace with <textnode><newNode><textnode>
233 const leftText = fullText.substring(0, substringIndex);
234 const leftTextNode = document.createTextNode(leftText);
235 const rightText = fullText.substring(substringIndex + substring.length);
236 const rightTextNode = document.createTextNode(rightText);
237 this._replaceNode(node, leftTextNode, newNode, rightTextNode);