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