]> git.proxmox.com Git - mirror_xterm.js.git/blob - src/Linkifier.ts
Improve node insertion, support custom link handlers
[mirror_xterm.js.git] / src / Linkifier.ts
1 /**
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
4 * huge aount of times.
5 */
6 const TIME_BEFORE_LINKIFY = 200;
7
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 lenientUrlClause = start + protocolClause + '?' + bodyClause + end;
22 const strictUrlClause = start + protocolClause + bodyClause + end;
23 const lenientUrlRegex = new RegExp(lenientUrlClause);
24 const strictUrlRegex = new RegExp(strictUrlClause);
25
26 export type LinkHandler = (uri: string) => void;
27
28 export class Linkifier {
29 private _rows: HTMLElement[];
30 private _rowTimeoutIds: number[];
31 private _webLinkHandler: LinkHandler;
32
33 constructor(rows: HTMLElement[]) {
34 this._rows = rows;
35 this._rowTimeoutIds = [];
36 }
37
38 /**
39 * Queues a row for linkification.
40 * @param {number} rowIndex The index of the row to linkify.
41 */
42 public linkifyRow(rowIndex: number): void {
43 const timeoutId = this._rowTimeoutIds[rowIndex];
44 if (timeoutId) {
45 clearTimeout(timeoutId);
46 }
47 this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
48 }
49
50 // TODO: Support local links
51 public attachWebLinkHandler(handler: LinkHandler): void {
52 this._webLinkHandler = handler;
53 // TODO: Refresh links if a handler is attached?
54 }
55
56 /**
57 * Linkifies a row.
58 * @param {number} rowIndex The index of the row to linkify.
59 */
60 private _linkifyRow(rowIndex: number): void {
61 const rowHtml = this._rows[rowIndex].innerHTML;
62 const uri = this._findLinkMatch(rowHtml);
63 if (!uri) {
64 return;
65 }
66
67 // Iterate over nodes as we want to consider text nodes
68 const nodes = this._rows[rowIndex].childNodes;
69 for (let i = 0; i < nodes.length; i++) {
70 const node = nodes[i];
71 const searchIndex = node.textContent.indexOf(uri);
72 if (searchIndex >= 0) {
73 if (node.childNodes.length > 0) {
74 // This row has already been linkified
75 return;
76 }
77
78 console.log('found uri: ' + uri);
79 const linkElement = this._createAnchorElement(uri);
80 // TODO: Check if childNodes check is needed
81 if (node.textContent.trim().length === uri.length) {
82 // Matches entire string
83 console.log('match entire string');
84 if (node.nodeType === Node.TEXT_NODE) {
85 console.log('text node');
86 this._replaceNode(node, linkElement);
87 } else {
88 console.log('element');
89 const element = (<HTMLElement>node);
90 element.innerHTML = '';
91 element.appendChild(linkElement);
92 }
93 } else {
94 // Matches part of string
95 console.log('part of string');
96 this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
97 }
98 }
99 // Continue searching in case multiple URIs exist on a single
100 // const link = '<a href="' + uri + '">' + uri + '</a>';
101 // const newHtml = rowHtml.replace(uri, link);
102 // this._rows[rowIndex].innerHTML = newHtml;
103 }
104 }
105
106 /**
107 * Finds a link match in a piece of HTML.
108 * @param {string} html The HTML to search.
109 * @return {string} The matching URI or null if not found.
110 */
111 private _findLinkMatch(html: string): string {
112 const match = html.match(strictUrlRegex);
113 if (!match || match.length === 0) {
114 return null;
115 }
116 return match[1];
117 }
118
119 /**
120 * Creates a link anchor element.
121 * @param {string} uri The uri of the link.
122 * @return {HTMLAnchorElement} The link.
123 */
124 private _createAnchorElement(uri: string): HTMLAnchorElement {
125 const element = document.createElement('a');
126 element.textContent = uri;
127 // Force link on another tab so work is not lost
128 element.target = '_blank';
129 if (this._webLinkHandler) {
130 element.href = '#';
131 element.addEventListener('click', () => this._webLinkHandler(uri));
132 } else {
133 element.href = uri;
134 }
135 return element;
136 }
137
138 /**
139 * Replace a node with 1 or more other nodes.
140 * @param {Node} oldNode The node to replace.
141 * @param {Node[]} newNodes The new nodes to insert in order.
142 */
143 private _replaceNode(oldNode: Node, ...newNodes: Node[]): void {
144 const parent = oldNode.parentNode;
145 for (let i = 0; i < newNodes.length; i++) {
146 parent.insertBefore(newNodes[i], oldNode);
147 }
148 parent.removeChild(oldNode);
149 }
150
151 /**
152 * Replace a substring within a node with a new node.
153 * @param {Node} targetNode The target node.
154 * @param {Node} newNode The new node to insert.
155 * @param {string} substring The substring to replace.
156 * @param {number} substringIndex The index of the substring within the string.
157 */
158 private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
159 let node = targetNode;
160 if (node.nodeType !== Node.TEXT_NODE) {
161 node = node.childNodes[0];
162 }
163 // The targetNode will be either a text node or a <span>. The targetNode is
164 // assumed to have no children. In either case, the targetNode's text node
165 // must be split into 2 text nodes surrounding the newNode.
166 if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) {
167 throw new Error('targetNode must be a text node or only contain a single text node');
168 }
169
170 const fullText = node.textContent;
171
172 if (substringIndex === 0) {
173 // Replace with <newNode><textnode>
174 console.log('Replace with <newNode><textnode>');
175 const rightText = fullText.substring(substring.length);
176 const rightTextNode = document.createTextNode(rightText);
177 this._replaceNode(node, newNode, rightTextNode);
178 } else if (substringIndex === targetNode.textContent.length - substring.length) {
179 // Replace with <textnode><newNode>
180 console.log('Replace with <textnode><newNode>');
181 const leftText = fullText.substring(0, substringIndex);
182 const leftTextNode = document.createTextNode(leftText);
183 this._replaceNode(node, leftTextNode, newNode);
184 } else {
185 // Replace with <textnode><newNode><textnode>
186 console.log('Replace with <textnode><newNode><textnode>');
187 const leftText = fullText.substring(0, substringIndex);
188 const leftTextNode = document.createTextNode(leftText);
189 const rightText = fullText.substring(substringIndex + substring.length);
190 const rightTextNode = document.createTextNode(rightText);
191 this._replaceNode(node, leftTextNode, newNode, rightTextNode);
192 }
193 }
194 }