]>
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 | ||
b594407c DI |
27 | export class SelectionManager extends EventEmitter { |
28 | // TODO: Create a SelectionModel | |
25152e44 | 29 | private _isSelectAllEnabled: boolean; |
70fda994 DI |
30 | private _selectionStart: [number, number]; |
31 | private _selectionEnd: [number, number]; | |
0dc3dd03 | 32 | private _dragScrollAmount: number; |
70fda994 | 33 | |
ab40908f | 34 | private _bufferTrimListener: any; |
70fda994 | 35 | private _mouseMoveListener: EventListener; |
ab40908f DI |
36 | private _mouseDownListener: EventListener; |
37 | private _mouseUpListener: EventListener; | |
38 | private _dblClickListener: EventListener; | |
70fda994 | 39 | |
0dc3dd03 DI |
40 | private _dragScrollTimeout: NodeJS.Timer; |
41 | ||
ad3ae67e DI |
42 | constructor( |
43 | private _terminal: ITerminal, | |
44 | private _buffer: CircularList<any>, | |
45 | private _rowContainer: HTMLElement, | |
46 | private _selectionContainer: HTMLElement, | |
47 | private _charMeasure: CharMeasure | |
48 | ) { | |
b594407c | 49 | super(); |
ab40908f DI |
50 | this._initListeners(); |
51 | this.enable(); | |
70fda994 DI |
52 | } |
53 | ||
ab40908f DI |
54 | private _initListeners() { |
55 | this._bufferTrimListener = (amount: number) => this._onTrim(amount); | |
70fda994 | 56 | this._mouseMoveListener = event => this._onMouseMove(<MouseEvent>event); |
ab40908f DI |
57 | this._mouseDownListener = event => this._onMouseDown(<MouseEvent>event); |
58 | this._mouseUpListener = event => this._onMouseUp(<MouseEvent>event); | |
59 | this._dblClickListener = event => this._onDblClick(<MouseEvent>event); | |
60 | } | |
70fda994 | 61 | |
ab40908f DI |
62 | /** |
63 | * Disables the selection manager. This is useful for when terminal mouse | |
64 | * are enabled. | |
65 | */ | |
66 | public disable() { | |
67 | this._selectionStart = null; | |
68 | this._selectionEnd = null; | |
69 | this.refresh(); | |
70 | this._buffer.off('trim', this._bufferTrimListener); | |
71 | this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); | |
ab40908f | 72 | this._rowContainer.removeEventListener('dblclick', this._dblClickListener); |
0dc3dd03 DI |
73 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
74 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
75 | clearInterval(this._dragScrollTimeout); | |
ab40908f DI |
76 | } |
77 | ||
78 | /** | |
79 | * Enable the selection manager. | |
80 | */ | |
81 | public enable() { | |
82 | this._buffer.on('trim', this._bufferTrimListener); | |
83 | this._rowContainer.addEventListener('mousedown', this._mouseDownListener); | |
ab40908f | 84 | this._rowContainer.addEventListener('dblclick', this._dblClickListener); |
70fda994 DI |
85 | } |
86 | ||
87 | public get selectionText(): string { | |
43c796a7 DI |
88 | const originalStart = this.selectAllAwareSelectionStart; |
89 | const originalEnd = this.selectAllAwareSelectionEnd; | |
90 | if (!originalStart || !originalEnd) { | |
293ae18a | 91 | return ''; |
70fda994 | 92 | } |
43c796a7 DI |
93 | |
94 | // Flip values if start is after end | |
95 | const flipValues = originalStart[1] > originalEnd[1] || | |
96 | (originalStart[1] === originalEnd[1] && originalStart[0] > originalEnd[0]); | |
97 | const start = flipValues ? originalEnd : originalStart; | |
98 | const end = flipValues ? originalStart : originalEnd; | |
99 | ||
100 | // Get first row | |
293ae18a | 101 | const startRowEndCol = start[1] === end[1] ? end[0] : null; |
32b34cbe | 102 | let result: string[] = []; |
293ae18a | 103 | result.push(this._translateBufferLineToString(this._buffer.get(start[1]), start[0], startRowEndCol)); |
43c796a7 DI |
104 | |
105 | // Get middle rows | |
293ae18a | 106 | for (let i = start[1] + 1; i <= end[1] - 1; i++) { |
32b34cbe DI |
107 | result.push(this._translateBufferLineToString(this._buffer.get(i))); |
108 | } | |
43c796a7 DI |
109 | |
110 | // Get final row | |
293ae18a DI |
111 | if (start[1] !== end[1]) { |
112 | result.push(this._translateBufferLineToString(this._buffer.get(end[1]), 0, end[1])); | |
32b34cbe | 113 | } |
597c6939 | 114 | console.log('selectionText result: ' + result); |
32b34cbe DI |
115 | return result.join('\n'); |
116 | } | |
117 | ||
118 | private _translateBufferLineToString(line: any, startCol: number = 0, endCol: number = null): string { | |
119 | // TODO: This function should live in a buffer or buffer line class | |
0dc3dd03 | 120 | endCol = endCol || line.length; |
32b34cbe DI |
121 | let result = ''; |
122 | for (let i = startCol; i < endCol; i++) { | |
123 | result += line[i][1]; | |
124 | } | |
597c6939 | 125 | // TODO: Trim line here instead of in handlers/Clipboard? |
e63fdf58 | 126 | // TODO: Only trim off the whitespace at the end of a line |
32b34cbe | 127 | // TODO: Handle the double-width character case |
e63fdf58 | 128 | return result; |
70fda994 DI |
129 | } |
130 | ||
25152e44 DI |
131 | private get selectAllAwareSelectionStart(): [number, number] { |
132 | if (this._isSelectAllEnabled) { | |
133 | return [0, 0]; | |
134 | } | |
135 | return this._selectionStart; | |
136 | } | |
137 | ||
138 | private get selectAllAwareSelectionEnd(): [number, number] { | |
139 | if (this._isSelectAllEnabled) { | |
140 | return [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows - 1]; | |
141 | } | |
142 | return this._selectionEnd; | |
143 | } | |
144 | ||
207c4cf9 DI |
145 | /** |
146 | * Redraws the selection. | |
147 | */ | |
148 | public refresh(): void { | |
b594407c | 149 | // TODO: Figure out when to refresh the selection vs when to refresh the viewport |
25152e44 DI |
150 | this.emit('refresh', { start: this.selectAllAwareSelectionStart, end: this.selectAllAwareSelectionEnd }); |
151 | } | |
152 | ||
153 | /** | |
154 | * Selects all text within the terminal. | |
155 | */ | |
156 | public selectAll(): void { | |
157 | this._isSelectAllEnabled = true; | |
158 | this.refresh(); | |
207c4cf9 DI |
159 | } |
160 | ||
161 | /** | |
162 | * Handle the buffer being trimmed, adjust the selection position. | |
163 | * @param amount The amount the buffer is being trimmed. | |
164 | */ | |
70fda994 | 165 | private _onTrim(amount: number) { |
207c4cf9 | 166 | // Adjust the selection position based on the trimmed amount. |
3846fe0a DI |
167 | if (this._selectionStart) { |
168 | this._selectionStart[0] -= amount; | |
169 | } | |
170 | if (this._selectionEnd) { | |
171 | this._selectionEnd[0] -= amount; | |
172 | } | |
207c4cf9 DI |
173 | |
174 | // The selection has moved off the buffer, clear it. | |
3846fe0a | 175 | if (this._selectionEnd && this._selectionEnd[0] < 0) { |
207c4cf9 DI |
176 | this._selectionStart = null; |
177 | this._selectionEnd = null; | |
178 | this.refresh(); | |
179 | return; | |
180 | } | |
181 | ||
182 | // If the selection start is trimmed, ensure the start column is 0. | |
3846fe0a | 183 | if (this._selectionStart && this._selectionStart[0] < 0) { |
207c4cf9 DI |
184 | this._selectionStart[1] = 0; |
185 | } | |
70fda994 DI |
186 | } |
187 | ||
32b34cbe DI |
188 | // TODO: Handle splice/shiftElements in the buffer (just clear the selection?) |
189 | ||
0dc3dd03 DI |
190 | private _getMouseBufferCoords(event: MouseEvent): [number, number] { |
191 | const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows); | |
192 | console.log(coords); | |
ad3ae67e DI |
193 | // Convert to 0-based |
194 | coords[0]--; | |
195 | coords[1]--; | |
196 | // Convert viewport coords to buffer coords | |
197 | coords[1] += this._terminal.ydisp; | |
198 | return coords; | |
b36d8780 DI |
199 | } |
200 | ||
0dc3dd03 DI |
201 | private _getMouseEventScrollAmount(event: MouseEvent): number { |
202 | let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1]; | |
203 | const terminalHeight = this._terminal.rows * this._charMeasure.height; | |
204 | if (offset >= 0 && offset <= terminalHeight) { | |
205 | return 0; | |
206 | } | |
207 | if (offset > terminalHeight) { | |
208 | offset -= terminalHeight; | |
209 | } | |
210 | ||
211 | offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD); | |
212 | offset /= DRAG_SCROLL_MAX_THRESHOLD; | |
213 | return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1)); | |
214 | } | |
215 | ||
e63fdf58 DI |
216 | /** |
217 | * Handles te mousedown event, setting up for a new selection. | |
218 | * @param event The mousedown event. | |
219 | */ | |
70fda994 | 220 | private _onMouseDown(event: MouseEvent) { |
0dc3dd03 DI |
221 | // TODO: On right click move the text into the textbox so it can be copied via the context menu |
222 | ||
223 | // Only action the primary button | |
224 | if (event.button !== 0) { | |
225 | return; | |
226 | } | |
227 | ||
25152e44 | 228 | this._isSelectAllEnabled = false; |
b36d8780 | 229 | this._selectionStart = this._getMouseBufferCoords(event); |
70fda994 | 230 | if (this._selectionStart) { |
ad3ae67e | 231 | this._selectionEnd = null; |
0dc3dd03 DI |
232 | // Listen on the document so that dragging outside of viewport works |
233 | this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); | |
234 | this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener); | |
235 | this._dragScrollTimeout = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); | |
ad3ae67e | 236 | this.refresh(); |
70fda994 DI |
237 | } |
238 | } | |
239 | ||
e63fdf58 DI |
240 | /** |
241 | * Handles the mousemove event when the mouse button is down, recording the | |
242 | * end of the selection and refreshing the selection. | |
243 | * @param event The mousemove event. | |
244 | */ | |
70fda994 | 245 | private _onMouseMove(event: MouseEvent) { |
b36d8780 | 246 | this._selectionEnd = this._getMouseBufferCoords(event); |
0dc3dd03 DI |
247 | // TODO: Perhaps the actual selection setting could be merged into _dragScroll? |
248 | this._dragScrollAmount = this._getMouseEventScrollAmount(event); | |
249 | // If the cursor was above or below the viewport, make sure it's at the | |
250 | // start or end of the viewport respectively | |
251 | if (this._dragScrollAmount > 0) { | |
252 | this._selectionEnd[0] = this._terminal.cols - 1; | |
253 | } else if (this._dragScrollAmount < 0) { | |
254 | this._selectionEnd[0] = 0; | |
255 | } | |
207c4cf9 DI |
256 | // TODO: Only draw here if the selection changes |
257 | this.refresh(); | |
70fda994 DI |
258 | } |
259 | ||
0dc3dd03 DI |
260 | private _dragScroll() { |
261 | if (this._dragScrollAmount) { | |
262 | this._terminal.scrollDisp(this._dragScrollAmount, false); | |
263 | // Re-evaluate selection | |
264 | if (this._dragScrollAmount > 0) { | |
265 | this._selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; | |
266 | } else { | |
267 | this._selectionEnd = [0, this._terminal.ydisp]; | |
268 | } | |
269 | this.refresh(); | |
270 | } | |
271 | } | |
272 | ||
e63fdf58 DI |
273 | /** |
274 | * Handles the mouseup event, removing the mousemove listener when | |
275 | * appropriate. | |
276 | * @param event The mouseup event. | |
277 | */ | |
70fda994 | 278 | private _onMouseUp(event: MouseEvent) { |
b3b2bd1f | 279 | this._dragScrollAmount = 0; |
70fda994 DI |
280 | if (!this._selectionStart) { |
281 | return; | |
282 | } | |
0dc3dd03 DI |
283 | this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); |
284 | this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); | |
70fda994 | 285 | } |
597c6939 | 286 | |
ab40908f | 287 | private _onDblClick(event: MouseEvent) { |
597c6939 DI |
288 | const coords = this._getMouseBufferCoords(event); |
289 | if (coords) { | |
290 | this._selectWordAt(coords); | |
291 | } | |
292 | } | |
293 | ||
294 | /** | |
295 | * Selects the word at the coordinates specified. Words are defined as all | |
296 | * non-whitespace characters. | |
297 | * @param coords The coordinates to get the word at. | |
298 | */ | |
299 | private _selectWordAt(coords: [number, number]): void { | |
300 | // TODO: Handle double click and drag in both directions! | |
301 | ||
302 | const line = this._translateBufferLineToString(this._buffer.get(coords[1])); | |
303 | // Expand the string in both directions until a space is hit | |
304 | let startCol = coords[0]; | |
305 | let endCol = coords[0]; | |
306 | while (startCol > 0 && line.charAt(startCol - 1) !== ' ') { | |
307 | startCol--; | |
308 | } | |
309 | while (endCol < line.length && line.charAt(endCol) !== ' ') { | |
310 | endCol++; | |
311 | } | |
312 | this._selectionStart = [startCol, coords[1]]; | |
313 | this._selectionEnd = [endCol, coords[1]]; | |
314 | this.refresh(); | |
315 | } | |
70fda994 | 316 | } |