]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/Linkifier.ts
Remove linkify test
[mirror_xterm.js.git] / src / Linkifier.ts
CommitLineData
2207d356 1/**
55cb43d7 2 * @license MIT
2207d356 3 */
55cb43d7
DI
4
5export type LinkHandler = (uri: string) => void;
6
c8bb3216 7type LinkMatcher = {id: number, regex: RegExp, matchIndex?: number, handler: LinkHandler};
2207d356 8
2207d356
DI
9const protocolClause = '(https?:\\/\\/)';
10const domainCharacterSet = '[\\da-z\\.-]+';
11const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
12const domainBodyClause = '(' + domainCharacterSet + ')';
13const tldClause = '([a-z\\.]{2,6})';
14const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
15const portClause = '(:\\d{1,5})';
16const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?';
17const pathClause = '(\\/[\\/\\w\\.-]*)*';
18const negatedPathCharacterSet = '[^\\/\\w\\.-]+';
19const bodyClause = hostClause + pathClause;
20const start = '(?:^|' + negatedDomainCharacterSet + ')(';
21const end = ')($|' + negatedPathCharacterSet + ')';
f7bc0fba 22const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
2207d356 23
55cb43d7
DI
24/**
25 * The ID of the built in http(s) link matcher.
26 */
7167b06b
DI
27const HYPERTEXT_LINK_MATCHER_ID = 0;
28
55cb43d7
DI
29/**
30 * The time to wait after a row is changed before it is linkified. This prevents
31 * the costly operation of searching every row multiple times, pntentially a
32 * huge aount of times.
33 */
34const TIME_BEFORE_LINKIFY = 200;
35
f7bc0fba
DI
36/**
37 * The Linkifier applies links to rows shortly after they have been refreshed.
38 */
2207d356 39export class Linkifier {
7167b06b
DI
40 private static _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
41
2207d356
DI
42 private _rows: HTMLElement[];
43 private _rowTimeoutIds: number[];
7167b06b 44 private _linkMatchers: LinkMatcher[];
2207d356
DI
45
46 constructor(rows: HTMLElement[]) {
47 this._rows = rows;
48 this._rowTimeoutIds = [];
7167b06b 49 this._linkMatchers = [];
c8bb3216 50 this.registerLinkMatcher(strictUrlRegex, null, 1);
2207d356
DI
51 }
52
53 /**
54 * Queues a row for linkification.
55 * @param {number} rowIndex The index of the row to linkify.
56 */
57 public linkifyRow(rowIndex: number): void {
58 const timeoutId = this._rowTimeoutIds[rowIndex];
59 if (timeoutId) {
60 clearTimeout(timeoutId);
61 }
62 this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
63 }
64
7167b06b 65 /**
3bf31aa4
DI
66 * Attaches a handler for hypertext links, overriding default <a> behavior
67 * for standard http(s) links.
7167b06b
DI
68 * @param {LinkHandler} handler The handler to use, this can be cleared with
69 * null.
70 */
0f3ee21d 71 public attachHypertextLinkHandler(handler: LinkHandler): void {
7167b06b
DI
72 this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
73 }
74
75 /**
76 * Registers a link matcher, allowing custom link patterns to be matched and
77 * handled.
1ee774d0
DI
78 * @param {RegExp} regex The regular expression the search for, specifically
79 * this searches the textContent of the rows. You will want to use \s to match
80 * a space ' ' character for example.
7167b06b 81 * @param {LinkHandler} handler The callback when the link is called.
c8bb3216
DI
82 * @param {number} matchIndex The index of the link from the regex.match(html)
83 * call. This defaults to 0 (for regular expressions without capture groups).
7167b06b
DI
84 * @return {number} The ID of the new matcher, this can be used to deregister.
85 */
c8bb3216 86 public registerLinkMatcher(regex: RegExp, handler: LinkHandler, matchIndex?: number): number {
7167b06b
DI
87 if (Linkifier._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
88 throw new Error('handler cannot be falsy');
89 }
90 const matcher: LinkMatcher = {
91 id: Linkifier._nextLinkMatcherId++,
92 regex,
c8bb3216
DI
93 handler,
94 matchIndex
7167b06b
DI
95 };
96 this._linkMatchers.push(matcher);
97 return matcher.id;
98 }
99
100 /**
101 * Deregisters a link matcher if it has been registered.
102 * @param {number} matcherId The link matcher's ID (returned after register)
1c030f57 103 * @return {boolean} Whether a link matcher was found and deregistered.
7167b06b 104 */
1c030f57 105 public deregisterLinkMatcher(matcherId: number): boolean {
7167b06b
DI
106 // ID 0 is the hypertext link matcher which cannot be deregistered
107 for (let i = 1; i < this._linkMatchers.length; i++) {
108 if (this._linkMatchers[i].id === matcherId) {
109 this._linkMatchers.splice(i, 1);
1c030f57 110 return true;
7167b06b
DI
111 }
112 }
1c030f57 113 return false;
2207d356
DI
114 }
115
116 /**
117 * Linkifies a row.
118 * @param {number} rowIndex The index of the row to linkify.
119 */
120 private _linkifyRow(rowIndex: number): void {
1ee774d0 121 const text = this._rows[rowIndex].textContent;
7167b06b
DI
122 for (let i = 0; i < this._linkMatchers.length; i++) {
123 const matcher = this._linkMatchers[i];
1ee774d0 124 const uri = this._findLinkMatch(text, matcher.regex, matcher.matchIndex);
7167b06b
DI
125 if (uri) {
126 this._doLinkifyRow(rowIndex, uri, matcher.handler);
127 // Only allow a single LinkMatcher to trigger on any given row.
128 return;
129 }
a489037e 130 }
7167b06b 131 }
a489037e 132
7167b06b
DI
133 /**
134 * Linkifies a row given a specific handler.
135 * @param {number} rowIndex The index of the row to linkify.
136 * @param {string} uri The uri that has been found.
137 * @param {handler} handler The handler to trigger when the link is triggered.
138 */
139 private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void {
a489037e
DI
140 // Iterate over nodes as we want to consider text nodes
141 const nodes = this._rows[rowIndex].childNodes;
142 for (let i = 0; i < nodes.length; i++) {
143 const node = nodes[i];
144 const searchIndex = node.textContent.indexOf(uri);
145 if (searchIndex >= 0) {
7167b06b 146 const linkElement = this._createAnchorElement(uri, handler);
a489037e
DI
147 if (node.textContent.trim().length === uri.length) {
148 // Matches entire string
a489037e 149 if (node.nodeType === Node.TEXT_NODE) {
a489037e
DI
150 this._replaceNode(node, linkElement);
151 } else {
a489037e 152 const element = (<HTMLElement>node);
c8bb3216
DI
153 if (element.nodeName === 'A') {
154 // This row has already been linkified
155 return;
156 }
a489037e
DI
157 element.innerHTML = '';
158 element.appendChild(linkElement);
159 }
160 } else {
161 // Matches part of string
a489037e
DI
162 this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
163 }
164 }
2207d356
DI
165 }
166 }
167
168 /**
1ee774d0
DI
169 * Finds a link match in a piece of text.
170 * @param {string} text The text to search.
c8bb3216 171 * @param {number} matchIndex The regex match index of the link.
a489037e 172 * @return {string} The matching URI or null if not found.
2207d356 173 */
1ee774d0
DI
174 private _findLinkMatch(text: string, regex: RegExp, matchIndex?: number): string {
175 const match = text.match(regex);
2207d356
DI
176 if (!match || match.length === 0) {
177 return null;
178 }
c8bb3216 179 return match[typeof matchIndex !== 'number' ? 0 : matchIndex];
2207d356 180 }
a489037e
DI
181
182 /**
183 * Creates a link anchor element.
184 * @param {string} uri The uri of the link.
185 * @return {HTMLAnchorElement} The link.
186 */
7167b06b 187 private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement {
a489037e
DI
188 const element = document.createElement('a');
189 element.textContent = uri;
7167b06b
DI
190 if (handler) {
191 element.addEventListener('click', () => handler(uri));
a489037e
DI
192 } else {
193 element.href = uri;
0f3ee21d
DI
194 // Force link on another tab so work is not lost
195 element.target = '_blank';
a489037e
DI
196 }
197 return element;
198 }
199
200 /**
201 * Replace a node with 1 or more other nodes.
202 * @param {Node} oldNode The node to replace.
203 * @param {Node[]} newNodes The new nodes to insert in order.
204 */
205 private _replaceNode(oldNode: Node, ...newNodes: Node[]): void {
206 const parent = oldNode.parentNode;
207 for (let i = 0; i < newNodes.length; i++) {
208 parent.insertBefore(newNodes[i], oldNode);
209 }
210 parent.removeChild(oldNode);
211 }
212
213 /**
214 * Replace a substring within a node with a new node.
0f3ee21d
DI
215 * @param {Node} targetNode The target node; either a text node or a <span>
216 * containing a single text node.
a489037e
DI
217 * @param {Node} newNode The new node to insert.
218 * @param {string} substring The substring to replace.
219 * @param {number} substringIndex The index of the substring within the string.
220 */
221 private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void {
222 let node = targetNode;
223 if (node.nodeType !== Node.TEXT_NODE) {
224 node = node.childNodes[0];
225 }
0f3ee21d
DI
226
227 // The targetNode will be either a text node or a <span>. The text node
228 // (targetNode or its only-child) needs to be replaced with newNode plus new
229 // text nodes potentially on either side.
a489037e
DI
230 if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) {
231 throw new Error('targetNode must be a text node or only contain a single text node');
232 }
233
234 const fullText = node.textContent;
235
236 if (substringIndex === 0) {
237 // Replace with <newNode><textnode>
a489037e
DI
238 const rightText = fullText.substring(substring.length);
239 const rightTextNode = document.createTextNode(rightText);
240 this._replaceNode(node, newNode, rightTextNode);
241 } else if (substringIndex === targetNode.textContent.length - substring.length) {
242 // Replace with <textnode><newNode>
a489037e
DI
243 const leftText = fullText.substring(0, substringIndex);
244 const leftTextNode = document.createTextNode(leftText);
245 this._replaceNode(node, leftTextNode, newNode);
246 } else {
247 // Replace with <textnode><newNode><textnode>
a489037e
DI
248 const leftText = fullText.substring(0, substringIndex);
249 const leftTextNode = document.createTextNode(leftText);
250 const rightText = fullText.substring(substringIndex + substring.length);
251 const rightTextNode = document.createTextNode(rightText);
252 this._replaceNode(node, leftTextNode, newNode, rightTextNode);
253 }
254 }
2207d356 255}