]> git.proxmox.com Git - mirror_xterm.js.git/blame - src/SelectionManager.ts
Support copying of select all text
[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
b594407c
DI
27export 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}