]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/Linkifier.ts
Revert "Add fail handler"
[mirror_xterm.js.git] / src / Linkifier.ts
CommitLineData
2207d356 1/**
55cb43d7 2 * @license MIT
2207d356 3 */
55cb43d7 4
15a867d2 5import { isMac } from './utils/Browser';
6198556e 6import { LinkMatcherOptions } from './Interfaces';
7ac4f1a9 7import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback } from './Types';
6198556e
DI
8
9const INVALID_LINK_CLASS = 'xterm-invalid-link';
2207d356 10
2207d356
DI
11const protocolClause = '(https?:\\/\\/)';
12const domainCharacterSet = '[\\da-z\\.-]+';
13const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
14const domainBodyClause = '(' + domainCharacterSet + ')';
15const tldClause = '([a-z\\.]{2,6})';
16const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
28d4ec77 17const localHostClause = '(localhost)';
2207d356 18const portClause = '(:\\d{1,5})';
28d4ec77 19const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
7279ee0f 20const pathClause = '(\\/[\\/\\w\\.\\-%]*)*';
ccadf3fc 21const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;\\=\\.\\-]*';
9aae4396
DI
22const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
23const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
7279ee0f 24const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
9aae4396 25const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
2207d356
DI
26const start = '(?:^|' + negatedDomainCharacterSet + ')(';
27const end = ')($|' + negatedPathCharacterSet + ')';
f7bc0fba 28const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
2207d356 29
55cb43d7
DI
30/**
31 * The ID of the built in http(s) link matcher.
32 */
7167b06b
DI
33const HYPERTEXT_LINK_MATCHER_ID = 0;
34
f7bc0fba
DI
35/**
36 * The Linkifier applies links to rows shortly after they have been refreshed.
37 */
2207d356 38export class Linkifier {
15d79143
DI
39 /**
40 * The time to wait after a row is changed before it is linkified. This prevents
41 * the costly operation of searching every row multiple times, pntentially a
42 * huge aount of times.
43 */
44 protected static TIME_BEFORE_LINKIFY = 200;
45
7ac4f1a9
DI
46 protected _linkMatchers: LinkMatcher[];
47
26ebc3d9 48 private _document: Document;
2207d356
DI
49 private _rows: HTMLElement[];
50 private _rowTimeoutIds: number[];
5183332f 51 private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
2207d356 52
26ebc3d9
DI
53 constructor(document: Document, rows: HTMLElement[]) {
54 this._document = document;
2207d356
DI
55 this._rows = rows;
56 this._rowTimeoutIds = [];
7167b06b 57 this._linkMatchers = [];
012051c1 58 this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 });
2207d356
DI
59 }
60
61 /**
62 * Queues a row for linkification.
63 * @param {number} rowIndex The index of the row to linkify.
64 */
65 public linkifyRow(rowIndex: number): void {
66 const timeoutId = this._rowTimeoutIds[rowIndex];
67 if (timeoutId) {
68 clearTimeout(timeoutId);
69 }
15d79143 70 this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), Linkifier.TIME_BEFORE_LINKIFY);
2207d356
DI
71 }
72
7167b06b 73 /**
3bf31aa4
DI
74 * Attaches a handler for hypertext links, overriding default <a> behavior
75 * for standard http(s) links.
7167b06b
DI
76 * @param {LinkHandler} handler The handler to use, this can be cleared with
77 * null.
78 */
4c99c032 79 public attachHypertextLinkHandler(handler: LinkMatcherHandler): void {
7167b06b
DI
80 this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
81 }
82
83 /**
84 * Registers a link matcher, allowing custom link patterns to be matched and
85 * handled.
3b62aa44 86 * @param {RegExp} regex The regular expression to search for, specifically
1ee774d0
DI
87 * this searches the textContent of the rows. You will want to use \s to match
88 * a space ' ' character for example.
7167b06b 89 * @param {LinkHandler} handler The callback when the link is called.
6198556e 90 * @param {LinkMatcherOptions} [options] Options for the link matcher.
7167b06b
DI
91 * @return {number} The ID of the new matcher, this can be used to deregister.
92 */
4c99c032 93 public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: LinkMatcherOptions = {}): number {
5183332f 94 if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
7167b06b
DI
95 throw new Error('handler cannot be falsy');
96 }
97 const matcher: LinkMatcher = {
5183332f 98 id: this._nextLinkMatcherId++,
7167b06b 99 regex,
c8bb3216 100 handler,
6198556e 101 matchIndex: options.matchIndex,
7ac4f1a9
DI
102 validationCallback: options.validationCallback,
103 priority: options.priority || 0
7167b06b 104 };
7ac4f1a9 105 this._addLinkMatcherToList(matcher);
7167b06b
DI
106 return matcher.id;
107 }
108
7ac4f1a9
DI
109 /**
110 * Inserts a link matcher to the list in the correct position based on the
111 * priority of each link matcher. New link matchers of equal priority are
112 * considered after older link matchers.
113 * @param matcher The link matcher to be added.
114 */
115 private _addLinkMatcherToList(matcher: LinkMatcher): void {
116 if (this._linkMatchers.length === 0) {
117 this._linkMatchers.push(matcher);
118 return;
119 }
120
121 for (let i = this._linkMatchers.length - 1; i >= 0; i--) {
78d5fc95 122 if (matcher.priority <= this._linkMatchers[i].priority) {
7ac4f1a9
DI
123 this._linkMatchers.splice(i + 1, 0, matcher);
124 return;
125 }
126 }
127
78d5fc95 128 this._linkMatchers.splice(0, 0, matcher);
7ac4f1a9
DI
129 }
130
7167b06b
DI
131 /**
132 * Deregisters a link matcher if it has been registered.
133 * @param {number} matcherId The link matcher's ID (returned after register)
1c030f57 134 * @return {boolean} Whether a link matcher was found and deregistered.
7167b06b 135 */
1c030f57 136 public deregisterLinkMatcher(matcherId: number): boolean {
7167b06b
DI
137 // ID 0 is the hypertext link matcher which cannot be deregistered
138 for (let i = 1; i < this._linkMatchers.length; i++) {
139 if (this._linkMatchers[i].id === matcherId) {
140 this._linkMatchers.splice(i, 1);
1c030f57 141 return true;
7167b06b
DI
142 }
143 }
1c030f57 144 return false;
2207d356
DI
145 }
146
147 /**
148 * Linkifies a row.
149 * @param {number} rowIndex The index of the row to linkify.
150 */
151 private _linkifyRow(rowIndex: number): void {
c4f43184
DI
152 const row = this._rows[rowIndex];
153 if (!row) {
154 return;
155 }
156 const text = row.textContent;
e6fc80c1 157 for (let i = 0; i < this._linkMatchers.length; i++) {
7167b06b 158 const matcher = this._linkMatchers[i];
1ee774d0 159 const uri = this._findLinkMatch(text, matcher.regex, matcher.matchIndex);
7167b06b 160 if (uri) {
5e2791f9 161 const linkElement = this._doLinkifyRow(rowIndex, uri, matcher.handler);
6198556e 162 // Fire validation callback
dcffaf1c 163 if (linkElement && matcher.validationCallback) {
6198556e
DI
164 matcher.validationCallback(uri, isValid => {
165 if (!isValid) {
166 linkElement.classList.add(INVALID_LINK_CLASS);
167 }
168 });
169 }
7167b06b
DI
170 // Only allow a single LinkMatcher to trigger on any given row.
171 return;
172 }
a489037e 173 }
7167b06b 174 }
a489037e 175
7167b06b
DI
176 /**
177 * Linkifies a row given a specific handler.
178 * @param {number} rowIndex The index of the row to linkify.
179 * @param {string} uri The uri that has been found.
180 * @param {handler} handler The handler to trigger when the link is triggered.
6198556e 181 * @return The link element if it was added, otherwise undefined.
7167b06b 182 */
5e2791f9 183 private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkMatcherHandler): HTMLElement {
a489037e
DI
184 // Iterate over nodes as we want to consider text nodes
185 const nodes = this._rows[rowIndex].childNodes;
186 for (let i = 0; i < nodes.length; i++) {
187 const node = nodes[i];
188 const searchIndex = node.textContent.indexOf(uri);
189 if (searchIndex >= 0) {
5e2791f9 190 const linkElement = this._createAnchorElement(uri, handler);
99a27021 191 if (node.textContent.length === uri.length) {
a489037e 192 // Matches entire string
26ebc3d9
DI
193
194 if (node.nodeType === 3 /*Node.TEXT_NODE*/) {
a489037e
DI
195 this._replaceNode(node, linkElement);
196 } else {
a489037e 197 const element = (<HTMLElement>node);
c8bb3216
DI
198 if (element.nodeName === 'A') {
199 // This row has already been linkified
200 return;
201 }
a489037e
DI
202 element.innerHTML = '';
203 element.appendChild(linkElement);
204 }
205 } else {
206 // Matches part of string
a489037e
DI
207 this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
208 }
6198556e 209 return linkElement;
a489037e 210 }
2207d356
DI
211 }
212 }
213
214 /**
1ee774d0
DI
215 * Finds a link match in a piece of text.
216 * @param {string} text The text to search.
c8bb3216 217 * @param {number} matchIndex The regex match index of the link.
a489037e 218 * @return {string} The matching URI or null if not found.
2207d356 219 */
1ee774d0
DI
220 private _findLinkMatch(text: string, regex: RegExp, matchIndex?: number): string {
221 const match = text.match(regex);
2207d356
DI
222 if (!match || match.length === 0) {
223 return null;
224 }
c8bb3216 225 return match[typeof matchIndex !== 'number' ? 0 : matchIndex];
2207d356 226 }
a489037e
DI
227
228 /**
229 * Creates a link anchor element.
230 * @param {string} uri The uri of the link.
231 * @return {HTMLAnchorElement} The link.
232 */
5e2791f9 233 private _createAnchorElement(uri: string, handler: LinkMatcherHandler): HTMLAnchorElement {
26ebc3d9 234 const element = this._document.createElement('a');
a489037e 235 element.textContent = uri;
7167b06b 236 if (handler) {
5e2791f9 237 element.addEventListener('click', (event: KeyboardEvent) => {
c7b4c2be
DI
238 // Don't execute the handler if the link is flagged as invalid
239 if (element.classList.contains(INVALID_LINK_CLASS)) {
240 return;
241 }
242 // Require ctrl on click
15a867d2 243 if (isMac ? event.metaKey : event.ctrlKey) {
6198556e
DI
244 handler(uri);
245 }
246 });
a489037e
DI
247 } else {
248 element.href = uri;
0f3ee21d
DI
249 // Force link on another tab so work is not lost
250 element.target = '_blank';
5e2791f9 251 element.addEventListener('click', (event: KeyboardEvent) => {
c7b4c2be 252 // Require ctrl on click
15a867d2 253 if (isMac ? !event.metaKey : !event.ctrlKey) {
c7b4c2be
DI
254 event.preventDefault();
255 return false;
256 }
257 });
a489037e
DI
258 }
259 return element;
260 }
261
262 /**
263 * Replace a node with 1 or more other nodes.
264 * @param {Node} oldNode The node to replace.
265 * @param {Node[]} newNodes The new nodes to insert in order.
266 */
267 private _replaceNode(oldNode: Node, ...newNodes: Node[]): void {
268 const parent = oldNode.parentNode;
269 for (let i = 0; i < newNodes.length; i++) {
270 parent.insertBefore(newNodes[i], oldNode);
271 }
272 parent.removeChild(oldNode);
273 }
274
275 /**
276 * Replace a substring within a node with a new node.
0f3ee21d
DI
277 * @param {Node} targetNode The target node; either a text node or a <span>
278 * containing a single text node.
a489037e
DI
279 * @param {Node} newNode The new node to insert.
280 * @param {string} substring The substring to replace.
281 * @param {number} substringIndex The index of the substring within the string.
282 */
283 private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
284 let node = targetNode;
26ebc3d9 285 if (node.nodeType !== 3/*Node.TEXT_NODE*/) {
a489037e
DI
286 node = node.childNodes[0];
287 }
0f3ee21d
DI
288
289 // The targetNode will be either a text node or a <span>. The text node
290 // (targetNode or its only-child) needs to be replaced with newNode plus new
291 // text nodes potentially on either side.
a489037e
DI
292 if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) {
293 throw new Error('targetNode must be a text node or only contain a single text node');
294 }
295
296 const fullText = node.textContent;
297
298 if (substringIndex === 0) {
299 // Replace with <newNode><textnode>
a489037e 300 const rightText = fullText.substring(substring.length);
26ebc3d9 301 const rightTextNode = this._document.createTextNode(rightText);
a489037e
DI
302 this._replaceNode(node, newNode, rightTextNode);
303 } else if (substringIndex === targetNode.textContent.length - substring.length) {
304 // Replace with <textnode><newNode>
a489037e 305 const leftText = fullText.substring(0, substringIndex);
26ebc3d9 306 const leftTextNode = this._document.createTextNode(leftText);
a489037e
DI
307 this._replaceNode(node, leftTextNode, newNode);
308 } else {
309 // Replace with <textnode><newNode><textnode>
a489037e 310 const leftText = fullText.substring(0, substringIndex);
26ebc3d9 311 const leftTextNode = this._document.createTextNode(leftText);
a489037e 312 const rightText = fullText.substring(substringIndex + substring.length);
26ebc3d9 313 const rightTextNode = this._document.createTextNode(rightText);
a489037e
DI
314 this._replaceNode(node, leftTextNode, newNode, rightTextNode);
315 }
316 }
2207d356 317}