]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/Linkifier.ts
Remove linkify addon
[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 58 /**
3bf31aa4
DI
59 * Attaches a handler for hypertext links, overriding default <a> behavior
60 * for standard http(s) links.
7167b06b
DI
61 * @param {LinkHandler} handler The handler to use, this can be cleared with
62 * null.
63 */
0f3ee21d 64 public attachHypertextLinkHandler(handler: LinkHandler): void {
7167b06b
DI
65 this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
66 }
67
68 /**
69 * Registers a link matcher, allowing custom link patterns to be matched and
70 * handled.
71 * @param {RegExp} regex The regular expression the search for.
72 * @param {LinkHandler} handler The callback when the link is called.
73 * @return {number} The ID of the new matcher, this can be used to deregister.
74 */
75 public registerLinkMatcher(regex: RegExp, handler: LinkHandler): number {
76 if (Linkifier._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
77 throw new Error('handler cannot be falsy');
78 }
79 const matcher: LinkMatcher = {
80 id: Linkifier._nextLinkMatcherId++,
81 regex,
82 handler
83 };
84 this._linkMatchers.push(matcher);
85 return matcher.id;
86 }
87
88 /**
89 * Deregisters a link matcher if it has been registered.
90 * @param {number} matcherId The link matcher's ID (returned after register)
91 */
92 public deregisterLinkMatcher(matcherId: number): void {
93 // ID 0 is the hypertext link matcher which cannot be deregistered
94 for (let i = 1; i < this._linkMatchers.length; i++) {
95 if (this._linkMatchers[i].id === matcherId) {
96 this._linkMatchers.splice(i, 1);
97 }
98 }
2207d356
DI
99 }
100
101 /**
102 * Linkifies a row.
103 * @param {number} rowIndex The index of the row to linkify.
104 */
105 private _linkifyRow(rowIndex: number): void {
106 const rowHtml = this._rows[rowIndex].innerHTML;
7167b06b
DI
107 for (let i = 0; i < this._linkMatchers.length; i++) {
108 const matcher = this._linkMatchers[i];
109 const uri = this._findLinkMatch(rowHtml, matcher.regex);
110 if (uri) {
111 this._doLinkifyRow(rowIndex, uri, matcher.handler);
112 // Only allow a single LinkMatcher to trigger on any given row.
113 return;
114 }
a489037e 115 }
7167b06b 116 }
a489037e 117
7167b06b
DI
118 /**
119 * Linkifies a row given a specific handler.
120 * @param {number} rowIndex The index of the row to linkify.
121 * @param {string} uri The uri that has been found.
122 * @param {handler} handler The handler to trigger when the link is triggered.
123 */
124 private _doLinkifyRow(rowIndex: number, uri: string, handler?: LinkHandler): void {
a489037e
DI
125 // Iterate over nodes as we want to consider text nodes
126 const nodes = this._rows[rowIndex].childNodes;
127 for (let i = 0; i < nodes.length; i++) {
128 const node = nodes[i];
129 const searchIndex = node.textContent.indexOf(uri);
130 if (searchIndex >= 0) {
131 if (node.childNodes.length > 0) {
132 // This row has already been linkified
133 return;
134 }
135
7167b06b 136 const linkElement = this._createAnchorElement(uri, handler);
a489037e
DI
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}