]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/Linkifier.ts
Add custom link handlers, use generic way for http handler
[mirror_xterm.js.git] / src / Linkifier.ts
CommitLineData
2207d356
DI
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 */
6const TIME_BEFORE_LINKIFY = 200;
7
2207d356
DI
8const protocolClause = '(https?:\\/\\/)';
9const domainCharacterSet = '[\\da-z\\.-]+';
10const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
11const domainBodyClause = '(' + domainCharacterSet + ')';
12const tldClause = '([a-z\\.]{2,6})';
13const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
14const portClause = '(:\\d{1,5})';
15const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?';
16const pathClause = '(\\/[\\/\\w\\.-]*)*';
17const negatedPathCharacterSet = '[^\\/\\w\\.-]+';
18const bodyClause = hostClause + pathClause;
19const start = '(?:^|' + negatedDomainCharacterSet + ')(';
20const end = ')($|' + negatedPathCharacterSet + ')';
f7bc0fba 21const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
2207d356
DI
22
23export type LinkHandler = (uri: string) => void;
24
7167b06b
DI
25type LinkMatcher = {id: number, regex: RegExp, handler: LinkHandler};
26
27const HYPERTEXT_LINK_MATCHER_ID = 0;
28
f7bc0fba
DI
29/**
30 * The Linkifier applies links to rows shortly after they have been refreshed.
31 */
2207d356 32export class Linkifier {
7167b06b
DI
33 private static _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
34
2207d356
DI
35 private _rows: HTMLElement[];
36 private _rowTimeoutIds: number[];
7167b06b 37 private _linkMatchers: LinkMatcher[];
2207d356
DI
38
39 constructor(rows: HTMLElement[]) {
40 this._rows = rows;
41 this._rowTimeoutIds = [];
7167b06b
DI
42 this._linkMatchers = [];
43 this.registerLinkMatcher(strictUrlRegex, null);
2207d356
DI
44 }
45
46 /**
47 * Queues a row for linkification.
48 * @param {number} rowIndex The index of the row to linkify.
49 */
50 public linkifyRow(rowIndex: number): void {
51 const timeoutId = this._rowTimeoutIds[rowIndex];
52 if (timeoutId) {
53 clearTimeout(timeoutId);
54 }
55 this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), TIME_BEFORE_LINKIFY);
56 }
57
7167b06b
DI
58 /**
59 * Attaches a handler for hypertext links, overriding default <a> behavior.
60 * @param {LinkHandler} handler The handler to use, this can be cleared with
61 * null.
62 */
0f3ee21d 63 public attachHypertextLinkHandler(handler: LinkHandler): void {
7167b06b
DI
64 this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
65 }
66
67 /**
68 * Registers a link matcher, allowing custom link patterns to be matched and
69 * handled.
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.
73 */
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');
77 }
78 const matcher: LinkMatcher = {
79 id: Linkifier._nextLinkMatcherId++,
80 regex,
81 handler
82 };
83 this._linkMatchers.push(matcher);
84 return matcher.id;
85 }
86
87 /**
88 * Deregisters a link matcher if it has been registered.
89 * @param {number} matcherId The link matcher's ID (returned after register)
90 */
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);
96 }
97 }
2207d356
DI
98 }
99
100 /**
101 * Linkifies a row.
102 * @param {number} rowIndex The index of the row to linkify.
103 */
104 private _linkifyRow(rowIndex: number): void {
105 const rowHtml = this._rows[rowIndex].innerHTML;
7167b06b
DI
106 for (let i = 0; i < this._linkMatchers.length; i++) {
107 const matcher = this._linkMatchers[i];
108 const uri = this._findLinkMatch(rowHtml, matcher.regex);
109 if (uri) {
110 this._doLinkifyRow(rowIndex, uri, matcher.handler);
111 // Only allow a single LinkMatcher to trigger on any given row.
112 return;
113 }
a489037e 114 }
7167b06b 115 }
a489037e 116
7167b06b
DI
117 /**
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.
122 */
123 private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void {
a489037e
DI
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
132 return;
133 }
134
7167b06b 135 const linkElement = this._createAnchorElement(uri, handler);
a489037e
DI
136 // TODO: Check if childNodes check is needed
137 if (node.textContent.trim().length === uri.length) {
138 // Matches entire string
a489037e 139 if (node.nodeType === Node.TEXT_NODE) {
a489037e
DI
140 this._replaceNode(node, linkElement);
141 } else {
a489037e
DI
142 const element = (<HTMLElement>node);
143 element.innerHTML = '';
144 element.appendChild(linkElement);
145 }
146 } else {
147 // Matches part of string
a489037e
DI
148 this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex);
149 }
150 }
2207d356
DI
151 }
152 }
153
154 /**
155 * Finds a link match in a piece of HTML.
156 * @param {string} html The HTML to search.
a489037e 157 * @return {string} The matching URI or null if not found.
2207d356 158 */
7167b06b
DI
159 private _findLinkMatch(html: string, regex: RegExp): string {
160 const match = html.match(regex);
2207d356
DI
161 if (!match || match.length === 0) {
162 return null;
163 }
164 return match[1];
165 }
a489037e
DI
166
167 /**
168 * Creates a link anchor element.
169 * @param {string} uri The uri of the link.
170 * @return {HTMLAnchorElement} The link.
171 */
7167b06b 172 private _createAnchorElement(uri: string, handler: LinkHandler): HTMLAnchorElement {
a489037e
DI
173 const element = document.createElement('a');
174 element.textContent = uri;
7167b06b
DI
175 if (handler) {
176 element.addEventListener('click', () => handler(uri));
a489037e
DI
177 } else {
178 element.href = uri;
0f3ee21d
DI
179 // Force link on another tab so work is not lost
180 element.target = '_blank';
a489037e
DI
181 }
182 return element;
183 }
184
185 /**
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.
189 */
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);
194 }
195 parent.removeChild(oldNode);
196 }
197
198 /**
199 * Replace a substring within a node with a new node.
0f3ee21d
DI
200 * @param {Node} targetNode The target node; either a text node or a <span>
201 * containing a single text node.
a489037e
DI
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.
205 */
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];
210 }
0f3ee21d
DI
211
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.
a489037e
DI
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');
217 }
218
219 const fullText = node.textContent;
220
221 if (substringIndex === 0) {
222 // Replace with <newNode><textnode>
a489037e
DI
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>
a489037e
DI
228 const leftText = fullText.substring(0, substringIndex);
229 const leftTextNode = document.createTextNode(leftText);
230 this._replaceNode(node, leftTextNode, newNode);
231 } else {
232 // Replace with <textnode><newNode><textnode>
a489037e
DI
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);
238 }
239 }
2207d356 240}