]>
Commit | Line | Data |
---|---|---|
70fda994 DI |
1 | /** |
2 | * @license MIT | |
3 | */ | |
4 | ||
5 | import { CharMeasure } from './utils/CharMeasure'; | |
6 | import { CircularList } from './utils/CircularList'; | |
b594407c | 7 | import { EventEmitter } from './EventEmitter'; |
70fda994 | 8 | import * as Mouse from './utils/Mouse'; |
ad3ae67e | 9 | import { ITerminal } from './Interfaces'; |
70fda994 | 10 | |
0dc3dd03 DI |
11 | /** |
12 | * The number of pixels the mouse needs to be above or below the viewport in | |
13 | * order to scroll at the maximum speed. | |
14 | */ | |
15 | const DRAG_SCROLL_MAX_THRESHOLD = 100; | |
16 | ||
17 | /** | |
18 | * The maximum scrolling speed | |
19 | */ | |
20 | const DRAG_SCROLL_MAX_SPEED = 5; | |
21 | ||
22 | /** | |
23 | * The number of milliseconds between drag scroll updates. | |
24 | */ | |
25 | const DRAG_SCROLL_INTERVAL = 100; | |
26 | ||
9f271de8 DI |
27 | /** |
28 | * The amount of time before mousedown events are no stacked to create double | |
29 | * click events. | |
30 | */ | |
31 | const CLEAR_MOUSE_DOWN_TIME = 400; | |
32 | ||
b594407c DI |
33 | export class SelectionManager extends EventEmitter { |
34 | // TODO: Create a SelectionModel | |
9f271de8 DI |
35 | |
36 | /** | |
37 | * Whether select all is currently active. | |
38 | */ | |
39 | private _isSelectAllActive: boolean; | |
40 | ||
41 | /** | |
42 | * The [x, y] position the selection starts at. | |
43 | */ | |
70fda994 | 44 | private _selectionStart: [number, number]; |
9f271de8 DI |
45 | |
46 | /** | |
47 | * The minimal length of the selection from the start position. When double | |
48 | * clicking on a word, the word will be selected which makes the selection | |
49 | * start at the start of the word and makes this variable the length. | |
50 | */ | |
51 | private _selectionStartLength: number; | |
52 | ||
53 | /** | |
54 | * The [x, y] position the selection ends at. | |
55 | */ | |
70fda994 | 56 | private _selectionEnd: [number, number]; |
9f271de8 DI |
57 | |
58 | /** | |
59 | * The amount to scroll every drag scroll update (depends on how far the mouse | |
60 | * drag is above or below the terminal). | |
61 | */ | |
0dc3dd03 | 62 | private _dragScrollAmount: number; |
70fda994 | 63 | |
9f271de8 DI |
64 | /** |
65 | * The last time the mousedown event fired, this is used to track double and | |
66 | * triple clicks. | |
67 | */ | |
68 | private _lastMouseDownTime: number; | |
69 | ||
70 | private _clickCount: number; | |
71 | ||
ab40908f | 72 | private _bufferTrimListener: any; |
70fda994 | 73 | private _mouseMoveListener: EventListener; |
ab40908f DI |
74 | private _mouseDownListener: EventListener; |
75 | private _mouseUpListener: EventListener; | |
70fda994 | 76 | |
0dc3dd03 DI |
77 | private _dragScrollTimeout: NodeJS.Timer; |
78 | ||
ad3ae67e DI |
79 | constructor( |
80 | private _terminal: ITerminal, | |
81 | private _buffer: CircularList<any>, | |
82 | private _rowContainer: HTMLElement, | |
83 | private _selectionContainer: HTMLElement, | |
84 | private _charMeasure: CharMeasure | |
85 | ) { | |
b594407c | 86 | super(); |
ab40908f DI |
87 | this._initListeners(); |
88 | this.enable(); | |
9f271de8 DI |
89 | |
90 | this._lastMouseDownTime = 0; | |
70fda994 DI |
91 | } |
92 | ||
ab40908f DI |
93 | private _initListeners() { |
94 | this._bufferTrimListener = (amount: number) => this._onTrim(amount); | |
70fda994 | 95 | this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event); |
ab40908f DI |
96 | this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event); |
97 | this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event); | |
ab40908f | 98 | } |
70fda994 | 99 | |
ab40908f DI |
100 | /** |
101 | * Disables the selection manager. This is useful for when terminal mouse | |
102 | * are enabled. | |
103 | */ | |
104 | public disable() { | |
105 | this._selectionStart = null; | |
106 | this._selectionEnd = null; | |
107 | this.refresh(); | |
108 | this._buffer.off('trim', this._bufferTrimListener); | |
109 | this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); | |
0dc3dd03 DI |
110 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
111 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
112 | clearInterval(this._dragScrollTimeout); | |
ab40908f DI |
113 | } |
114 | ||
115 | /** | |
116 | * Enable the selection manager. | |
117 | */ | |
118 | public enable() { | |
119 | this._buffer.on('trim', this._bufferTrimListener); | |
120 | this._rowContainer.addEventListener('mousedown', this._mouseDownListener); | |
70fda994 DI |
121 | } |
122 | ||
9f271de8 DI |
123 | /** |
124 | * Gets the text currently selected. | |
125 | */ | |
70fda994 | 126 | public get selectionText(): string { |
9f271de8 DI |
127 | const originalStart = this.finalSelectionStart; |
128 | const originalEnd = this.finalSelectionEnd; | |
43c796a7 | 129 | if (!originalStart || !originalEnd) { |
293ae18a | 130 | return ''; |
70fda994 | 131 | } |
43c796a7 DI |
132 | |
133 | // Flip values if start is after end | |
134 | const flipValues = originalStart[1] > originalEnd[1] || | |
135 | (originalStart[1] === originalEnd[1] && originalStart[0] > originalEnd[0]); | |
136 | const start = flipValues ? originalEnd : originalStart; | |
137 | const end = flipValues ? originalStart : originalEnd; | |
138 | ||
139 | // Get first row | |
293ae18a | 140 | const startRowEndCol = start[1] === end[1] ? end[0] : null; |
32b34cbe | 141 | let result: string[] = []; |
293ae18a | 142 | result.push(this._translateBufferLineToString(this._buffer.get(start[1]), start[0], startRowEndCol)); |
43c796a7 DI |
143 | |
144 | // Get middle rows | |
293ae18a | 145 | for (let i = start[1] + 1; i <= end[1] - 1; i++) { |
32b34cbe DI |
146 | result.push(this._translateBufferLineToString(this._buffer.get(i))); |
147 | } | |
43c796a7 DI |
148 | |
149 | // Get final row | |
293ae18a DI |
150 | if (start[1] !== end[1]) { |
151 | result.push(this._translateBufferLineToString(this._buffer.get(end[1]), 0, end[1])); | |
32b34cbe | 152 | } |
597c6939 | 153 | console.log('selectionText result: ' + result); |
32b34cbe DI |
154 | return result.join('\n'); |
155 | } | |
156 | ||
157 | private _translateBufferLineToString(line: any, startCol: number = 0, endCol: number = null): string { | |
158 | // TODO: This function should live in a buffer or buffer line class | |
0dc3dd03 | 159 | endCol = endCol || line.length; |
32b34cbe DI |
160 | let result = ''; |
161 | for (let i = startCol; i < endCol; i++) { | |
162 | result += line[i][1]; | |
163 | } | |
597c6939 | 164 | // TODO: Trim line here instead of in handlers/Clipboard? |
e63fdf58 | 165 | // TODO: Only trim off the whitespace at the end of a line |
32b34cbe | 166 | // TODO: Handle the double-width character case |
e63fdf58 | 167 | return result; |
70fda994 DI |
168 | } |
169 | ||
9f271de8 DI |
170 | /** |
171 | * The final selection start, taking into consideration things like select all | |
172 | * and double click word selection. | |
173 | */ | |
174 | private get finalSelectionStart(): [number, number] { | |
175 | if (this._isSelectAllActive) { | |
25152e44 DI |
176 | return [0, 0]; |
177 | } | |
178 | return this._selectionStart; | |
179 | } | |
180 | ||
9f271de8 DI |
181 | /** |
182 | * The final selection end, taking into consideration things like select all | |
183 | * and double click word selection. | |
184 | */ | |
185 | private get finalSelectionEnd(): [number, number] { | |
186 | if (this._isSelectAllActive) { | |
25152e44 DI |
187 | return [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows - 1]; |
188 | } | |
189 | return this._selectionEnd; | |
190 | } | |
191 | ||
207c4cf9 DI |
192 | /** |
193 | * Redraws the selection. | |
194 | */ | |
195 | public refresh(): void { | |
b594407c | 196 | // TODO: Figure out when to refresh the selection vs when to refresh the viewport |
9f271de8 | 197 | this.emit('refresh', { start: this.finalSelectionStart, end: this.finalSelectionEnd }); |
25152e44 DI |
198 | } |
199 | ||
200 | /** | |
201 | * Selects all text within the terminal. | |
202 | */ | |
203 | public selectAll(): void { | |
9f271de8 | 204 | this._isSelectAllActive = true; |
25152e44 | 205 | this.refresh(); |
207c4cf9 DI |
206 | } |
207 | ||
208 | /** | |
209 | * Handle the buffer being trimmed, adjust the selection position. | |
210 | * @param amount The amount the buffer is being trimmed. | |
211 | */ | |
70fda994 | 212 | private _onTrim(amount: number) { |
207c4cf9 | 213 | // Adjust the selection position based on the trimmed amount. |
3846fe0a DI |
214 | if (this._selectionStart) { |
215 | this._selectionStart[0] -= amount; | |
216 | } | |
217 | if (this._selectionEnd) { | |
218 | this._selectionEnd[0] -= amount; | |
219 | } | |
207c4cf9 DI |
220 | |
221 | // The selection has moved off the buffer, clear it. | |
3846fe0a | 222 | if (this._selectionEnd && this._selectionEnd[0] < 0) { |
207c4cf9 DI |
223 | this._selectionStart = null; |
224 | this._selectionEnd = null; | |
225 | this.refresh(); | |
226 | return; | |
227 | } | |
228 | ||
229 | // If the selection start is trimmed, ensure the start column is 0. | |
3846fe0a | 230 | if (this._selectionStart && this._selectionStart[0] < 0) { |
207c4cf9 DI |
231 | this._selectionStart[1] = 0; |
232 | } | |
70fda994 DI |
233 | } |
234 | ||
32b34cbe DI |
235 | // TODO: Handle splice/shiftElements in the buffer (just clear the selection?) |
236 | ||
0dc3dd03 DI |
237 | private _getMouseBufferCoords(event: MouseEvent): [number, number] { |
238 | const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows); | |
239 | console.log(coords); | |
ad3ae67e DI |
240 | // Convert to 0-based |
241 | coords[0]--; | |
242 | coords[1]--; | |
243 | // Convert viewport coords to buffer coords | |
244 | coords[1] += this._terminal.ydisp; | |
245 | return coords; | |
b36d8780 DI |
246 | } |
247 | ||
0dc3dd03 DI |
248 | private _getMouseEventScrollAmount(event: MouseEvent): number { |
249 | let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1]; | |
250 | const terminalHeight = this._terminal.rows * this._charMeasure.height; | |
251 | if (offset >= 0 && offset <= terminalHeight) { | |
252 | return 0; | |
253 | } | |
254 | if (offset > terminalHeight) { | |
255 | offset -= terminalHeight; | |
256 | } | |
257 | ||
258 | offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD); | |
259 | offset /= DRAG_SCROLL_MAX_THRESHOLD; | |
260 | return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1)); | |
261 | } | |
262 | ||
e63fdf58 DI |
263 | /** |
264 | * Handles te mousedown event, setting up for a new selection. | |
265 | * @param event The mousedown event. | |
266 | */ | |
70fda994 | 267 | private _onMouseDown(event: MouseEvent) { |
0dc3dd03 DI |
268 | // TODO: On right click move the text into the textbox so it can be copied via the context menu |
269 | ||
270 | // Only action the primary button | |
271 | if (event.button !== 0) { | |
272 | return; | |
273 | } | |
274 | ||
9f271de8 DI |
275 | this._setMouseClickCount(); |
276 | console.log(this._clickCount); | |
277 | ||
278 | if (this._clickCount === 1) { | |
279 | this._onSingleClick(event); | |
280 | } else if (this._clickCount === 2) { | |
281 | this._onDoubleClick(event); | |
282 | } else if (this._clickCount === 3) { | |
283 | this._onTripleClick(event); | |
284 | } | |
285 | } | |
286 | ||
287 | private _onSingleClick(event: MouseEvent): void { | |
288 | this._selectionStartLength = 0; | |
289 | this._isSelectAllActive = false; | |
b36d8780 | 290 | this._selectionStart = this._getMouseBufferCoords(event); |
70fda994 | 291 | if (this._selectionStart) { |
ad3ae67e | 292 | this._selectionEnd = null; |
0dc3dd03 DI |
293 | // Listen on the document so that dragging outside of viewport works |
294 | this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); | |
295 | this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener); | |
296 | this._dragScrollTimeout = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); | |
ad3ae67e | 297 | this.refresh(); |
70fda994 DI |
298 | } |
299 | } | |
300 | ||
9f271de8 DI |
301 | private _onDoubleClick(event: MouseEvent): void { |
302 | const coords = this._getMouseBufferCoords(event); | |
303 | if (coords) { | |
304 | this._selectWordAt(coords); | |
305 | } | |
306 | } | |
307 | ||
308 | private _onTripleClick(event: MouseEvent): void { | |
309 | const coords = this._getMouseBufferCoords(event); | |
310 | if (coords) { | |
311 | this._selectLineAt(coords[1]); | |
312 | } | |
313 | } | |
314 | ||
315 | private _setMouseClickCount(): void { | |
316 | let currentTime = (new Date()).getTime(); | |
317 | if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME) { | |
318 | this._clickCount = 0; | |
319 | } | |
320 | this._lastMouseDownTime = currentTime; | |
321 | this._clickCount++; | |
322 | ||
323 | // TODO: Invalidate click count if the position is different | |
324 | } | |
325 | ||
e63fdf58 DI |
326 | /** |
327 | * Handles the mousemove event when the mouse button is down, recording the | |
328 | * end of the selection and refreshing the selection. | |
329 | * @param event The mousemove event. | |
330 | */ | |
70fda994 | 331 | private _onMouseMove(event: MouseEvent) { |
b36d8780 | 332 | this._selectionEnd = this._getMouseBufferCoords(event); |
0dc3dd03 DI |
333 | // TODO: Perhaps the actual selection setting could be merged into _dragScroll? |
334 | this._dragScrollAmount = this._getMouseEventScrollAmount(event); | |
335 | // If the cursor was above or below the viewport, make sure it's at the | |
336 | // start or end of the viewport respectively | |
337 | if (this._dragScrollAmount > 0) { | |
338 | this._selectionEnd[0] = this._terminal.cols - 1; | |
339 | } else if (this._dragScrollAmount < 0) { | |
340 | this._selectionEnd[0] = 0; | |
341 | } | |
207c4cf9 DI |
342 | // TODO: Only draw here if the selection changes |
343 | this.refresh(); | |
70fda994 DI |
344 | } |
345 | ||
0dc3dd03 DI |
346 | private _dragScroll() { |
347 | if (this._dragScrollAmount) { | |
348 | this._terminal.scrollDisp(this._dragScrollAmount, false); | |
349 | // Re-evaluate selection | |
350 | if (this._dragScrollAmount > 0) { | |
351 | this._selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; | |
352 | } else { | |
353 | this._selectionEnd = [0, this._terminal.ydisp]; | |
354 | } | |
355 | this.refresh(); | |
356 | } | |
357 | } | |
358 | ||
e63fdf58 DI |
359 | /** |
360 | * Handles the mouseup event, removing the mousemove listener when | |
361 | * appropriate. | |
362 | * @param event The mouseup event. | |
363 | */ | |
70fda994 | 364 | private _onMouseUp(event: MouseEvent) { |
b3b2bd1f | 365 | this._dragScrollAmount = 0; |
70fda994 DI |
366 | if (!this._selectionStart) { |
367 | return; | |
368 | } | |
0dc3dd03 DI |
369 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
370 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
70fda994 | 371 | } |
597c6939 | 372 | |
597c6939 DI |
373 | /** |
374 | * Selects the word at the coordinates specified. Words are defined as all | |
375 | * non-whitespace characters. | |
376 | * @param coords The coordinates to get the word at. | |
377 | */ | |
378 | private _selectWordAt(coords: [number, number]): void { | |
379 | // TODO: Handle double click and drag in both directions! | |
380 | ||
381 | const line = this._translateBufferLineToString(this._buffer.get(coords[1])); | |
382 | // Expand the string in both directions until a space is hit | |
383 | let startCol = coords[0]; | |
384 | let endCol = coords[0]; | |
385 | while (startCol > 0 && line.charAt(startCol - 1) !== ' ') { | |
386 | startCol--; | |
387 | } | |
388 | while (endCol < line.length && line.charAt(endCol) !== ' ') { | |
389 | endCol++; | |
390 | } | |
391 | this._selectionStart = [startCol, coords[1]]; | |
392 | this._selectionEnd = [endCol, coords[1]]; | |
393 | this.refresh(); | |
394 | } | |
9f271de8 DI |
395 | |
396 | private _selectLineAt(line: number): void { | |
397 | this._selectionStart = [0, line]; | |
398 | this._selectionEnd = [this._terminal.cols - 1, line]; | |
399 | this.refresh(); | |
400 | } | |
70fda994 | 401 | } |