]>
Commit | Line | Data |
---|---|---|
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 | */ | |
6 | const TIME_BEFORE_LINKIFY = 200; | |
7 | ||
2207d356 DI |
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 + ')'; | |
f7bc0fba | 21 | const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end); |
2207d356 DI |
22 | |
23 | export type LinkHandler = (uri: string) => void; | |
24 | ||
f7bc0fba DI |
25 | /** |
26 | * The Linkifier applies links to rows shortly after they have been refreshed. | |
27 | */ | |
2207d356 DI |
28 | export class Linkifier { |
29 | private _rows: HTMLElement[]; | |
30 | private _rowTimeoutIds: number[]; | |
0f3ee21d | 31 | private _hypertextLinkHandler: LinkHandler; |
2207d356 DI |
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 | ||
a489037e | 50 | // TODO: Support local links |
0f3ee21d DI |
51 | public attachHypertextLinkHandler(handler: LinkHandler): void { |
52 | this._hypertextLinkHandler = handler; | |
2207d356 DI |
53 | } |
54 | ||
55 | /** | |
56 | * Linkifies a row. | |
57 | * @param {number} rowIndex The index of the row to linkify. | |
58 | */ | |
59 | private _linkifyRow(rowIndex: number): void { | |
60 | const rowHtml = this._rows[rowIndex].innerHTML; | |
61 | const uri = this._findLinkMatch(rowHtml); | |
a489037e DI |
62 | if (!uri) { |
63 | return; | |
64 | } | |
65 | ||
66 | // Iterate over nodes as we want to consider text nodes | |
67 | const nodes = this._rows[rowIndex].childNodes; | |
68 | for (let i = 0; i < nodes.length; i++) { | |
69 | const node = nodes[i]; | |
70 | const searchIndex = node.textContent.indexOf(uri); | |
71 | if (searchIndex >= 0) { | |
72 | if (node.childNodes.length > 0) { | |
73 | // This row has already been linkified | |
74 | return; | |
75 | } | |
76 | ||
77 | console.log('found uri: ' + uri); | |
78 | const linkElement = this._createAnchorElement(uri); | |
79 | // TODO: Check if childNodes check is needed | |
80 | if (node.textContent.trim().length === uri.length) { | |
81 | // Matches entire string | |
82 | console.log('match entire string'); | |
83 | if (node.nodeType === Node.TEXT_NODE) { | |
84 | console.log('text node'); | |
85 | this._replaceNode(node, linkElement); | |
86 | } else { | |
87 | console.log('element'); | |
88 | const element = (<HTMLElement>node); | |
89 | element.innerHTML = ''; | |
90 | element.appendChild(linkElement); | |
91 | } | |
92 | } else { | |
93 | // Matches part of string | |
94 | console.log('part of string'); | |
95 | this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex); | |
96 | } | |
97 | } | |
2207d356 DI |
98 | } |
99 | } | |
100 | ||
101 | /** | |
102 | * Finds a link match in a piece of HTML. | |
103 | * @param {string} html The HTML to search. | |
a489037e | 104 | * @return {string} The matching URI or null if not found. |
2207d356 | 105 | */ |
a489037e | 106 | private _findLinkMatch(html: string): string { |
2207d356 DI |
107 | const match = html.match(strictUrlRegex); |
108 | if (!match || match.length === 0) { | |
109 | return null; | |
110 | } | |
111 | return match[1]; | |
112 | } | |
a489037e DI |
113 | |
114 | /** | |
115 | * Creates a link anchor element. | |
116 | * @param {string} uri The uri of the link. | |
117 | * @return {HTMLAnchorElement} The link. | |
118 | */ | |
119 | private _createAnchorElement(uri: string): HTMLAnchorElement { | |
120 | const element = document.createElement('a'); | |
121 | element.textContent = uri; | |
0f3ee21d DI |
122 | if (this._hypertextLinkHandler) { |
123 | element.addEventListener('click', () => this._hypertextLinkHandler(uri)); | |
a489037e DI |
124 | } else { |
125 | element.href = uri; | |
0f3ee21d DI |
126 | // Force link on another tab so work is not lost |
127 | element.target = '_blank'; | |
a489037e DI |
128 | } |
129 | return element; | |
130 | } | |
131 | ||
132 | /** | |
133 | * Replace a node with 1 or more other nodes. | |
134 | * @param {Node} oldNode The node to replace. | |
135 | * @param {Node[]} newNodes The new nodes to insert in order. | |
136 | */ | |
137 | private _replaceNode(oldNode: Node, ...newNodes: Node[]): void { | |
138 | const parent = oldNode.parentNode; | |
139 | for (let i = 0; i < newNodes.length; i++) { | |
140 | parent.insertBefore(newNodes[i], oldNode); | |
141 | } | |
142 | parent.removeChild(oldNode); | |
143 | } | |
144 | ||
145 | /** | |
146 | * Replace a substring within a node with a new node. | |
0f3ee21d DI |
147 | * @param {Node} targetNode The target node; either a text node or a <span> |
148 | * containing a single text node. | |
a489037e DI |
149 | * @param {Node} newNode The new node to insert. |
150 | * @param {string} substring The substring to replace. | |
151 | * @param {number} substringIndex The index of the substring within the string. | |
152 | */ | |
153 | private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): void { | |
154 | let node = targetNode; | |
155 | if (node.nodeType !== Node.TEXT_NODE) { | |
156 | node = node.childNodes[0]; | |
157 | } | |
0f3ee21d DI |
158 | |
159 | // The targetNode will be either a text node or a <span>. The text node | |
160 | // (targetNode or its only-child) needs to be replaced with newNode plus new | |
161 | // text nodes potentially on either side. | |
a489037e DI |
162 | if (node.childNodes.length === 0 && node.nodeType !== Node.TEXT_NODE) { |
163 | throw new Error('targetNode must be a text node or only contain a single text node'); | |
164 | } | |
165 | ||
166 | const fullText = node.textContent; | |
167 | ||
168 | if (substringIndex === 0) { | |
169 | // Replace with <newNode><textnode> | |
170 | console.log('Replace with <newNode><textnode>'); | |
171 | const rightText = fullText.substring(substring.length); | |
172 | const rightTextNode = document.createTextNode(rightText); | |
173 | this._replaceNode(node, newNode, rightTextNode); | |
174 | } else if (substringIndex === targetNode.textContent.length - substring.length) { | |
175 | // Replace with <textnode><newNode> | |
176 | console.log('Replace with <textnode><newNode>'); | |
177 | const leftText = fullText.substring(0, substringIndex); | |
178 | const leftTextNode = document.createTextNode(leftText); | |
179 | this._replaceNode(node, leftTextNode, newNode); | |
180 | } else { | |
181 | // Replace with <textnode><newNode><textnode> | |
182 | console.log('Replace with <textnode><newNode><textnode>'); | |
183 | const leftText = fullText.substring(0, substringIndex); | |
184 | const leftTextNode = document.createTextNode(leftText); | |
185 | const rightText = fullText.substring(substringIndex + substring.length); | |
186 | const rightTextNode = document.createTextNode(rightText); | |
187 | this._replaceNode(node, leftTextNode, newNode, rightTextNode); | |
188 | } | |
189 | } | |
2207d356 | 190 | } |