forked from xtermjs/xterm.js
/
Linkifier.ts
302 lines (273 loc) · 10.4 KB
/
Linkifier.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IMouseZoneManager } from './ui/Types';
import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal, IBufferStringIteratorResult } from './Types';
import { MouseZone } from './ui/MouseZoneManager';
import { EventEmitter } from './common/EventEmitter';
import { CHAR_DATA_ATTR_INDEX } from './Buffer';
import { getStringCellWidth } from './CharWidth';
/**
* The Linkifier applies links to rows shortly after they have been refreshed.
*/
export class Linkifier extends EventEmitter implements ILinkifier {
/**
* The time to wait after a row is changed before it is linkified. This prevents
* the costly operation of searching every row multiple times, potentially a
* huge amount of times.
*/
protected static readonly TIME_BEFORE_LINKIFY = 200;
protected _linkMatchers: ILinkMatcher[] = [];
private _mouseZoneManager: IMouseZoneManager;
private _rowsTimeoutId: number;
private _nextLinkMatcherId = 0;
private _rowsToLinkify: { start: number, end: number };
constructor(
protected _terminal: ITerminal
) {
super();
this._rowsToLinkify = {
start: null,
end: null
};
}
/**
* Attaches the linkifier to the DOM, enabling linkification.
* @param mouseZoneManager The mouse zone manager to register link zones with.
*/
public attachToDom(mouseZoneManager: IMouseZoneManager): void {
this._mouseZoneManager = mouseZoneManager;
}
/**
* Queue linkification on a set of rows.
* @param start The row to linkify from (inclusive).
* @param end The row to linkify to (inclusive).
*/
public linkifyRows(start: number, end: number): void {
// Don't attempt linkify if not yet attached to DOM
if (!this._mouseZoneManager) {
return;
}
// Increase range to linkify
if (this._rowsToLinkify.start === null) {
this._rowsToLinkify.start = start;
this._rowsToLinkify.end = end;
} else {
this._rowsToLinkify.start = Math.min(this._rowsToLinkify.start, start);
this._rowsToLinkify.end = Math.max(this._rowsToLinkify.end, end);
}
// Clear out any existing links on this row range
this._mouseZoneManager.clearAll(start, end);
// Restart timer
if (this._rowsTimeoutId) {
clearTimeout(this._rowsTimeoutId);
}
this._rowsTimeoutId = <number><any>setTimeout(() => this._linkifyRows(), Linkifier.TIME_BEFORE_LINKIFY);
}
/**
* Linkifies the rows requested.
*/
private _linkifyRows(): void {
this._rowsTimeoutId = null;
const buffer = this._terminal.buffer;
// Ensure the start row exists
const absoluteRowIndexStart = buffer.ydisp + this._rowsToLinkify.start;
if (absoluteRowIndexStart >= buffer.lines.length) {
return;
}
// Invalidate bad end row values (if a resize happened)
const absoluteRowIndexEnd = buffer.ydisp + Math.min(this._rowsToLinkify.end, this._terminal.rows) + 1;
// iterate over the range of unwrapped content strings within start..end (excluding)
// _doLinkifyRow gets full unwrapped lines with the start row as buffer offset for every matcher
// for wrapped content over several rows the iterator might return rows outside the viewport
// we skip those later in _doLinkifyRow
const iterator = buffer.iterator(false, absoluteRowIndexStart, absoluteRowIndexEnd);
while (iterator.hasNext()) {
const lineData: IBufferStringIteratorResult = iterator.next();
for (let i = 0; i < this._linkMatchers.length; i++) {
this._doLinkifyRow(lineData.range.first, lineData.content, this._linkMatchers[i]);
}
}
this._rowsToLinkify.start = null;
this._rowsToLinkify.end = null;
}
/**
* Registers a link matcher, allowing custom link patterns to be matched and
* handled.
* @param regex The regular expression to search for. Specifically, this
* searches the textContent of the rows. You will want to use \s to match a
* space ' ' character for example.
* @param handler The callback when the link is called.
* @param options Options for the link matcher.
* @return The ID of the new matcher, this can be used to deregister.
*/
public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: ILinkMatcherOptions = {}): number {
if (!handler) {
throw new Error('handler must be defined');
}
const matcher: ILinkMatcher = {
id: this._nextLinkMatcherId++,
regex,
handler,
matchIndex: options.matchIndex,
validationCallback: options.validationCallback,
hoverTooltipCallback: options.tooltipCallback,
hoverLeaveCallback: options.leaveCallback,
willLinkActivate: options.willLinkActivate,
priority: options.priority || 0
};
this._addLinkMatcherToList(matcher);
return matcher.id;
}
/**
* Inserts a link matcher to the list in the correct position based on the
* priority of each link matcher. New link matchers of equal priority are
* considered after older link matchers.
* @param matcher The link matcher to be added.
*/
private _addLinkMatcherToList(matcher: ILinkMatcher): void {
if (this._linkMatchers.length === 0) {
this._linkMatchers.push(matcher);
return;
}
for (let i = this._linkMatchers.length - 1; i >= 0; i--) {
if (matcher.priority <= this._linkMatchers[i].priority) {
this._linkMatchers.splice(i + 1, 0, matcher);
return;
}
}
this._linkMatchers.splice(0, 0, matcher);
}
/**
* Deregisters a link matcher if it has been registered.
* @param matcherId The link matcher's ID (returned after register)
* @return Whether a link matcher was found and deregistered.
*/
public deregisterLinkMatcher(matcherId: number): boolean {
for (let i = 0; i < this._linkMatchers.length; i++) {
if (this._linkMatchers[i].id === matcherId) {
this._linkMatchers.splice(i, 1);
return true;
}
}
return false;
}
/**
* Linkifies a row given a specific handler.
* @param rowIndex The row index to linkify (absolute index).
* @param text string content of the unwrapped row.
* @param matcher The link matcher for this line.
*/
private _doLinkifyRow(rowIndex: number, text: string, matcher: ILinkMatcher): void {
// clone regex to do a global search on text
const rex = new RegExp(matcher.regex.source, matcher.regex.flags + 'g');
let match;
let stringIndex = -1;
while ((match = rex.exec(text)) !== null) {
const uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex];
if (!uri) {
// something matched but does not comply with the given matchIndex
// since this is most likely a bug the regex itself we simply do nothing here
// DEBUG: print match and throw
if ((<any>this._terminal).debug) {
console.log({match, matcher});
throw new Error('match found without corresponding matchIndex');
}
break;
}
// Get index, match.index is for the outer match which includes negated chars
// therefore we cannot use match.index directly, instead we search the position
// of the match group in text again
// also correct regex and string search offsets for the next loop run
stringIndex = text.indexOf(uri, stringIndex + 1);
rex.lastIndex = stringIndex + uri.length;
// get the buffer index as [absolute row, col] for the match
const bufferIndex = this._terminal.buffer.stringIndexToBufferIndex(rowIndex, stringIndex);
// skip rows outside of the viewport
if (bufferIndex[0] - this._terminal.buffer.ydisp < 0) {
continue;
}
if (bufferIndex[0] - this._terminal.buffer.ydisp > this._terminal.rows) {
break;
}
const line = this._terminal.buffer.lines.get(bufferIndex[0]);
const char = line.get(bufferIndex[1]);
let fg: number | undefined;
if (char) {
const attr: number = char[CHAR_DATA_ATTR_INDEX];
fg = (attr >> 9) & 0x1ff;
}
if (matcher.validationCallback) {
matcher.validationCallback(uri, isValid => {
// Discard link if the line has already changed
if (this._rowsTimeoutId) {
return;
}
if (isValid) {
this._addLink(bufferIndex[1], bufferIndex[0] - this._terminal.buffer.ydisp, uri, matcher, fg);
}
});
} else {
this._addLink(bufferIndex[1], bufferIndex[0] - this._terminal.buffer.ydisp, uri, matcher, fg);
}
}
}
/**
* Registers a link to the mouse zone manager.
* @param x The column the link starts.
* @param y The row the link is on.
* @param uri The URI of the link.
* @param matcher The link matcher for the link.
* @param fg The link color for hover event.
*/
private _addLink(x: number, y: number, uri: string, matcher: ILinkMatcher, fg: number): void {
const width = getStringCellWidth(uri);
const x1 = x % this._terminal.cols;
const y1 = y + Math.floor(x / this._terminal.cols);
let x2 = (x1 + width) % this._terminal.cols;
let y2 = y1 + Math.floor((x1 + width) / this._terminal.cols);
if (x2 === 0) {
x2 = this._terminal.cols;
y2--;
}
this._mouseZoneManager.add(new MouseZone(
x1 + 1,
y1 + 1,
x2 + 1,
y2 + 1,
e => {
if (matcher.handler) {
return matcher.handler(e, uri);
}
window.open(uri, '_blank');
},
e => {
this.emit(LinkHoverEventTypes.HOVER, this._createLinkHoverEvent(x1, y1, x2, y2, fg));
this._terminal.element.classList.add('xterm-cursor-pointer');
},
e => {
this.emit(LinkHoverEventTypes.TOOLTIP, this._createLinkHoverEvent(x1, y1, x2, y2, fg));
if (matcher.hoverTooltipCallback) {
matcher.hoverTooltipCallback(e, uri);
}
},
() => {
this.emit(LinkHoverEventTypes.LEAVE, this._createLinkHoverEvent(x1, y1, x2, y2, fg));
this._terminal.element.classList.remove('xterm-cursor-pointer');
if (matcher.hoverLeaveCallback) {
matcher.hoverLeaveCallback();
}
},
e => {
if (matcher.willLinkActivate) {
return matcher.willLinkActivate(e, uri);
}
return true;
}
));
}
private _createLinkHoverEvent(x1: number, y1: number, x2: number, y2: number, fg: number): ILinkHoverEvent {
return { x1, y1, x2, y2, cols: this._terminal.cols, fg };
}
}