]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/SelectionManager.ts
Implement triple click to select line
[mirror_xterm.js.git] / src / SelectionManager.ts
CommitLineData
70fda994
DI
1/**
2 * @license MIT
3 */
4
5import { CharMeasure } from './utils/CharMeasure';
6import { CircularList } from './utils/CircularList';
b594407c 7import { EventEmitter } from './EventEmitter';
70fda994 8import * as Mouse from './utils/Mouse';
ad3ae67e 9import { 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 */
15const DRAG_SCROLL_MAX_THRESHOLD = 100;
16
17/**
18 * The maximum scrolling speed
19 */
20const DRAG_SCROLL_MAX_SPEED = 5;
21
22/**
23 * The number of milliseconds between drag scroll updates.
24 */
25const 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 */
31const CLEAR_MOUSE_DOWN_TIME = 400;
32
b594407c
DI
33export 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}