diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/src/InputHandler.ts b/src/InputHandler.ts index a7023083..e1223e7e 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -75,8 +75,9 @@ export class InputHandler implements IInputHandler { const removed = this._terminal.lines.get(this._terminal.y + this._terminal.ybase).pop(); if (removed[2] === 0 && this._terminal.lines.get(row)[this._terminal.cols - 2] - && this._terminal.lines.get(row)[this._terminal.cols - 2][2] === 2) + && this._terminal.lines.get(row)[this._terminal.cols - 2][2] === 2) { this._terminal.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1]; + } // insert empty cell at cursor this._terminal.lines.get(row).splice(this._terminal.x, 0, [this._terminal.curAttr, ' ', 1]); @@ -903,7 +904,8 @@ export class InputHandler implements IInputHandler { this._terminal.vt200Mouse = params[0] === 1000; this._terminal.normalMouse = params[0] > 1000; this._terminal.mouseEvents = true; - this._terminal.element.style.cursor = 'default'; + this._terminal.element.classList.add('enable-mouse-events'); + this._terminal.selectionManager.disable(); this._terminal.log('Binding to mouse events.'); break; case 1004: // send focusin/focusout events @@ -1096,7 +1098,8 @@ export class InputHandler implements IInputHandler { this._terminal.vt200Mouse = false; this._terminal.normalMouse = false; this._terminal.mouseEvents = false; - this._terminal.element.style.cursor = ''; + this._terminal.element.classList.remove('enable-mouse-events'); + this._terminal.selectionManager.enable(); break; case 1004: // send focusin/focusout events this._terminal.sendFocus = false; @@ -1127,6 +1130,8 @@ export class InputHandler implements IInputHandler { this._terminal.scrollBottom = this._terminal.normal.scrollBottom; this._terminal.tabs = this._terminal.normal.tabs; this._terminal.normal = null; + // Ensure the selection manager has the correct buffer + this._terminal.selectionManager.setBuffer(this._terminal.lines); // if (params === 1049) { // this.x = this.savedX; // this.y = this.savedY; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index ca228ce0..4b857674 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -20,6 +20,8 @@ export interface IBrowser { export interface ITerminal { element: HTMLElement; rowContainer: HTMLElement; + selectionContainer: HTMLElement; + charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; ybase: number; ydisp: number; @@ -47,6 +49,10 @@ export interface ITerminal { emit(event: string, data: any); } +export interface ISelectionManager { + selectionText: string; +} + export interface ICharMeasure { width: number; height: number; diff --git a/src/Renderer.ts b/src/Renderer.ts index af95d51e..f0f50f61 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -318,6 +318,67 @@ export class Renderer { this._terminal.emit('refresh', {element: this._terminal.element, start: start, end: end}); }; + + /** + * Refreshes the selection in the DOM. + * @param start The selection start. + * @param end The selection end. + */ + public refreshSelection(start: [number, number], end: [number, number]) { + // Remove all selections + while (this._terminal.selectionContainer.children.length) { + this._terminal.selectionContainer.removeChild(this._terminal.selectionContainer.children[0]); + } + + // Selection does not exist + if (!start || !end) { + return; + } + + // Translate from buffer position to viewport position + const viewportStartRow = start[1] - this._terminal.ydisp; + const viewportEndRow = end[1] - this._terminal.ydisp; + const viewportCappedStartRow = Math.max(viewportStartRow, 0); + const viewportCappedEndRow = Math.min(viewportEndRow, this._terminal.rows - 1); + + // No need to draw the selection + if (viewportCappedStartRow >= this._terminal.rows || viewportCappedEndRow < 0) { + return; + } + + // Create the selections + const documentFragment = document.createDocumentFragment(); + // Draw first row + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol)); + // Draw middle rows + for (let i = viewportCappedStartRow + 1; i < viewportCappedEndRow; i++) { + documentFragment.appendChild(this._createSelectionElement(i, 0, this._terminal.cols)); + } + // Draw final row + if (viewportCappedStartRow !== viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewporttartRow + const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol)); + } + this._terminal.selectionContainer.appendChild(documentFragment); + } + + /** + * Creates a selection element at the specified position. + * @param row The row of the selection. + * @param colStart The start column. + * @param colEnd The end columns. + */ + private _createSelectionElement(row: number, colStart: number, colEnd: number): HTMLElement { + const element = document.createElement('div'); + element.style.height = `${this._terminal.charMeasure.height}px`; + element.style.top = `${row * this._terminal.charMeasure.height}px`; + element.style.left = `${colStart * this._terminal.charMeasure.width}px`; + element.style.width = `${this._terminal.charMeasure.width * (colEnd - colStart)}px`; + return element; + } } diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts new file mode 100644 index 00000000..e0ff6789 --- /dev/null +++ b/src/SelectionManager.test.ts @@ -0,0 +1,169 @@ +/** + * @license MIT + */ +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { ITerminal } from './Interfaces'; +import { CharMeasure } from './utils/CharMeasure'; +import { CircularList } from './utils/CircularList'; +import { SelectionManager } from './SelectionManager'; +import { SelectionModel } from './SelectionModel'; + +class TestSelectionManager extends SelectionManager { + constructor( + terminal: ITerminal, + buffer: CircularList, + rowContainer: HTMLElement, + charMeasure: CharMeasure + ) { + super(terminal, buffer, rowContainer, charMeasure); + } + + public get model(): SelectionModel { return this._model; } + + public selectLineAt(line: number): void { this._selectLineAt(line); } + public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords); } + + // Disable DOM interaction + public enable(): void {} + public disable(): void {} + public refresh(): void {} +} + +describe('SelectionManager', () => { + let window: Window; + let document: Document; + + let terminal: ITerminal; + let buffer: CircularList; + let rowContainer: HTMLElement; + let selectionManager: TestSelectionManager; + + beforeEach(done => { + jsdom.env('', (err, w) => { + window = w; + document = window.document; + buffer = new CircularList(100); + terminal = { cols: 80, rows: 2 }; + selectionManager = new TestSelectionManager(terminal, buffer, rowContainer, null); + done(); + }); + }); + + function stringToRow(text: string): [number, string, number][] { + let result: [number, string, number][] = []; + for (let i = 0; i < text.length; i++) { + result.push([0, text.charAt(i), 1]); + } + return result; + } + + describe('_selectWordAt', () => { + it('should expand selection for normal width chars', () => { + buffer.push(stringToRow('foo bar')); + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, 'bar'); + selectionManager.selectWordAt([5, 0]); + assert.equal(selectionManager.selectionText, 'bar'); + selectionManager.selectWordAt([6, 0]); + assert.equal(selectionManager.selectionText, 'bar'); + }); + it('should expand selection for whitespace', () => { + buffer.push(stringToRow('a b')); + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, 'a'); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, 'b'); + }); + it('should expand selection for wide characters', () => { + // Wide characters use a special format + buffer.push([ + [null, '中', 2], + [null, '', 0], + [null, '文', 2], + [null, '', 0], + [null, ' ', 1], + [null, 'a', 1], + [null, '中', 2], + [null, '', 0], + [null, '文', 2], + [null, '', 0], + [null, 'b', 1], + [null, ' ', 1], + [null, 'f', 1], + [null, 'o', 1], + [null, 'o', 1] + ]); + // Ensure wide characters take up 2 columns + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, '中文'); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '中文'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, '中文'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, '中文'); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, ' '); + // Ensure wide characters work when wrapped in normal width characters + selectionManager.selectWordAt([5, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([6, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([7, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([8, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([9, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([10, 0]); + assert.equal(selectionManager.selectionText, 'a中文b'); + selectionManager.selectWordAt([11, 0]); + assert.equal(selectionManager.selectionText, ' '); + // Ensure normal width characters work fine in a line containing wide characters + selectionManager.selectWordAt([12, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + selectionManager.selectWordAt([13, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + selectionManager.selectWordAt([14, 0]); + assert.equal(selectionManager.selectionText, 'foo'); + }); + }); + + describe('_selectLineAt', () => { + it('should select the entire line', () => { + buffer.push(stringToRow('foo bar')); + selectionManager.selectLineAt(0); + assert.equal(selectionManager.selectionText, 'foo bar', 'The selected text is correct'); + assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionManager.model.finalSelectionEnd, [terminal.cols, 0], 'The actual selection spans the entire column'); + }); + }); + + describe('selectAll', () => { + it('should select the entire buffer, beyond the viewport', () => { + buffer.push(stringToRow('1')); + buffer.push(stringToRow('2')); + buffer.push(stringToRow('3')); + buffer.push(stringToRow('4')); + buffer.push(stringToRow('5')); + selectionManager.selectAll(); + terminal.ybase = buffer.length - terminal.rows; + assert.equal(selectionManager.selectionText, '1\n2\n3\n4\n5'); + }); + }); +}); diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts new file mode 100644 index 00000000..906d5b01 --- /dev/null +++ b/src/SelectionManager.ts @@ -0,0 +1,631 @@ +/** + * @license MIT + */ + +import { CharMeasure } from './utils/CharMeasure'; +import { CircularList } from './utils/CircularList'; +import { EventEmitter } from './EventEmitter'; +import * as Mouse from './utils/Mouse'; +import { ITerminal } from './Interfaces'; +import { SelectionModel } from './SelectionModel'; + +/** + * The number of pixels the mouse needs to be above or below the viewport in + * order to scroll at the maximum speed. + */ +const DRAG_SCROLL_MAX_THRESHOLD = 50; + +/** + * The maximum scrolling speed + */ +const DRAG_SCROLL_MAX_SPEED = 15; + +/** + * The number of milliseconds between drag scroll updates. + */ +const DRAG_SCROLL_INTERVAL = 50; + +/** + * The amount of time before mousedown events are no longer stacked to create + * double/triple click events. + */ +const CLEAR_MOUSE_DOWN_TIME = 400; + +/** + * The number of pixels in each direction that the mouse must move before + * mousedown events are no longer stacked to create double/triple click events. + */ +const CLEAR_MOUSE_DISTANCE = 10; + +// TODO: Move these constants elsewhere, they belong in a buffer or buffer +// data/line class. +const LINE_DATA_CHAR_INDEX = 1; +const LINE_DATA_WIDTH_INDEX = 2; + +const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160); +const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g'); + +/** + * A class that manages the selection of the terminal. With help from + * SelectionModel, SelectionManager handles with all logic associated with + * dealing with the selection, including handling mouse interaction, wide + * characters and fetching the actual text within the selection. Rendering is + * not handled by the SelectionManager but a 'refresh' event is fired when the + * selection is ready to be redrawn. + */ +export class SelectionManager extends EventEmitter { + protected _model: SelectionModel; + + /** + * The amount to scroll every drag scroll update (depends on how far the mouse + * drag is above or below the terminal). + */ + private _dragScrollAmount: number; + + /** + * The last time the mousedown event fired, this is used to track double and + * triple clicks. + */ + private _lastMouseDownTime: number; + + /** + * The last position the mouse was clicked [x, y]. + */ + private _lastMousePosition: [number, number]; + + /** + * The number of clicks of the mousedown event. This is used to keep track of + * double and triple clicks. + */ + private _clickCount: number; + + /** + * Whether line select mode is active, this occurs after a triple click. + */ + private _isLineSelectModeActive: boolean; + + /** + * A setInterval timer that is active while the mouse is down whose callback + * scrolls the viewport when necessary. + */ + private _dragScrollIntervalTimer: NodeJS.Timer; + + /** + * The animation frame ID used for refreshing the selection. + */ + private _refreshAnimationFrame: number; + + private _bufferTrimListener: any; + private _mouseMoveListener: EventListener; + private _mouseDownListener: EventListener; + private _mouseUpListener: EventListener; + + constructor( + private _terminal: ITerminal, + private _buffer: CircularList, + private _rowContainer: HTMLElement, + private _charMeasure: CharMeasure + ) { + super(); + this._initListeners(); + this.enable(); + + this._model = new SelectionModel(_terminal); + this._lastMouseDownTime = 0; + this._isLineSelectModeActive = false; + } + + /** + * Initializes listener variables. + */ + private _initListeners() { + this._bufferTrimListener = (amount: number) => this._onTrim(amount); + this._mouseMoveListener = event => this._onMouseMove(event); + this._mouseDownListener = event => this._onMouseDown(event); + this._mouseUpListener = event => this._onMouseUp(event); + } + + /** + * Disables the selection manager. This is useful for when terminal mouse + * are enabled. + */ + public disable() { + this.clearSelection(); + this._buffer.off('trim', this._bufferTrimListener); + this._rowContainer.removeEventListener('mousedown', this._mouseDownListener); + } + + /** + * Enable the selection manager. + */ + public enable() { + // Only adjust the selection on trim, shiftElements is rarely used (only in + // reverseIndex) and delete in a splice is only ever used when the same + // number of elements was just added. Given this is could actually be + // beneficial to leave the selection as is for these cases. + this._buffer.on('trim', this._bufferTrimListener); + this._rowContainer.addEventListener('mousedown', this._mouseDownListener); + } + + /** + * Sets the active buffer, this should be called when the alt buffer is + * switched in or out. + * @param buffer The active buffer. + */ + public setBuffer(buffer: CircularList): void { + this._buffer = buffer; + } + + /** + * Gets whether there is an active text selection. + */ + public get hasSelection(): boolean { + return !!this._model.finalSelectionStart && !!this._model.finalSelectionEnd; + } + + /** + * Gets the text currently selected. + */ + public get selectionText(): string { + const start = this._model.finalSelectionStart; + const end = this._model.finalSelectionEnd; + if (!start || !end) { + return ''; + } + + // Get first row + const startRowEndCol = start[1] === end[1] ? end[0] : null; + let result: string[] = []; + result.push(this._translateBufferLineToString(this._buffer.get(start[1]), true, start[0], startRowEndCol)); + + // Get middle rows + for (let i = start[1] + 1; i <= end[1] - 1; i++) { + result.push(this._translateBufferLineToString(this._buffer.get(i), true)); + } + + // Get final row + if (start[1] !== end[1]) { + result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0])); + } + + // Format string by replacing non-breaking space chars with regular spaces + // and joining the array into a multi-line string. + const formattedResult = result.map(line => { + return line.replace(ALL_NON_BREAKING_SPACE_REGEX, ' '); + }).join('\n'); + + return formattedResult; + } + + /** + * Clears the current terminal selection. + */ + public clearSelection(): void { + this._model.clearSelection(); + this._removeMouseDownListeners(); + this.refresh(); + } + + /** + * Translates a buffer line to a string, with optional start and end columns. + * Wide characters will count as two columns in the resulting string. This + * function is useful for getting the actual text underneath the raw selection + * position. + * @param line The line being translated. + * @param trimRight Whether to trim whitespace to the right. + * @param startCol The column to start at. + * @param endCol The column to end at. + */ + private _translateBufferLineToString(line: any, trimRight: boolean, startCol: number = 0, endCol: number = null): string { + // TODO: This function should live in a buffer or buffer line class + + // Get full line + let lineString = ''; + let widthAdjustedStartCol = startCol; + let widthAdjustedEndCol = endCol; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + lineString += char[LINE_DATA_CHAR_INDEX]; + // Adjust start and end cols for wide characters if they affect their + // column indexes + if (char[LINE_DATA_WIDTH_INDEX] === 0) { + if (startCol >= i) { + widthAdjustedStartCol--; + } + if (endCol >= i) { + widthAdjustedEndCol--; + } + } + } + + // Calculate the final end col by trimming whitespace on the right of the + // line if needed. + let finalEndCol = widthAdjustedEndCol || line.length; + if (trimRight) { + const rightWhitespaceIndex = lineString.search(/\s+$/); + if (rightWhitespaceIndex !== -1) { + finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); + } + // Return the empty string if only trimmed whitespace is selected + if (finalEndCol <= widthAdjustedStartCol) { + return ''; + } + } + + return lineString.substring(widthAdjustedStartCol, finalEndCol); + } + + /** + * Queues a refresh, redrawing the selection on the next opportunity. + */ + public refresh(): void { + if (!this._refreshAnimationFrame) { + this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh()); + } + } + + /** + * Fires the refresh event, causing consumers to pick it up and redraw the + * selection state. + */ + private _refresh(): void { + this._refreshAnimationFrame = null; + this.emit('refresh', { start: this._model.finalSelectionStart, end: this._model.finalSelectionEnd }); + } + + /** + * Selects all text within the terminal. + */ + public selectAll(): void { + this._model.isSelectAllActive = true; + this.refresh(); + } + + /** + * Handle the buffer being trimmed, adjust the selection position. + * @param amount The amount the buffer is being trimmed. + */ + private _onTrim(amount: number) { + const needsRefresh = this._model.onTrim(amount); + if (needsRefresh) { + this.refresh(); + } + } + + /** + * Gets the 0-based [x, y] buffer coordinates of the current mouse event. + * @param event The mouse event. + */ + private _getMouseBufferCoords(event: MouseEvent): [number, number] { + const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows); + // Convert to 0-based + coords[0]--; + coords[1]--; + // Convert viewport coords to buffer coords + coords[1] += this._terminal.ydisp; + return coords; + } + + /** + * Gets the amount the viewport should be scrolled based on how far out of the + * terminal the mouse is. + * @param event The mouse event. + */ + private _getMouseEventScrollAmount(event: MouseEvent): number { + let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1]; + const terminalHeight = this._terminal.rows * this._charMeasure.height; + if (offset >= 0 && offset <= terminalHeight) { + return 0; + } + if (offset > terminalHeight) { + offset -= terminalHeight; + } + + offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD); + offset /= DRAG_SCROLL_MAX_THRESHOLD; + return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1)); + } + + /** + * Handles te mousedown event, setting up for a new selection. + * @param event The mousedown event. + */ + private _onMouseDown(event: MouseEvent) { + // Only action the primary button + if (event.button !== 0) { + return; + } + + // Reset drag scroll state + this._dragScrollAmount = 0; + + this._setMouseClickCount(event); + + if (event.shiftKey) { + this._onShiftClick(event); + } else { + if (this._clickCount === 1) { + this._onSingleClick(event); + } else if (this._clickCount === 2) { + this._onDoubleClick(event); + } else if (this._clickCount === 3) { + this._onTripleClick(event); + } + } + + this._addMouseDownListeners(); + this.refresh(); + } + + /** + * Adds listeners when mousedown is triggered. + */ + private _addMouseDownListeners(): void { + // Listen on the document so that dragging outside of viewport works + this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); + this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener); + this._dragScrollIntervalTimer = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); + } + + /** + * Removes the listeners that are registered when mousedown is triggered. + */ + private _removeMouseDownListeners(): void { + this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); + this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); + clearInterval(this._dragScrollIntervalTimer); + this._dragScrollIntervalTimer = null; + } + + /** + * Performs a shift click, setting the selection end position to the mouse + * position. + * @param event The mouse event. + */ + private _onShiftClick(event: MouseEvent): void { + if (this._model.selectionStart) { + this._model.selectionEnd = this._getMouseBufferCoords(event); + } + } + + /** + * Performs a single click, resetting relevant state and setting the selection + * start position. + * @param event The mouse event. + */ + private _onSingleClick(event: MouseEvent): void { + this._model.selectionStartLength = 0; + this._model.isSelectAllActive = false; + this._isLineSelectModeActive = false; + this._model.selectionStart = this._getMouseBufferCoords(event); + if (this._model.selectionStart) { + this._model.selectionEnd = null; + // If the mouse is over the second half of a wide character, adjust the + // selection to cover the whole character + const char = this._buffer.get(this._model.selectionStart[1])[this._model.selectionStart[0]]; + if (char[LINE_DATA_WIDTH_INDEX] === 0) { + this._model.selectionStart[0]++; + } + } + } + + /** + * Performs a double click, selecting the current work. + * @param event The mouse event. + */ + private _onDoubleClick(event: MouseEvent): void { + const coords = this._getMouseBufferCoords(event); + if (coords) { + this._selectWordAt(coords); + } + } + + /** + * Performs a triple click, selecting the current line and activating line + * select mode. + * @param event The mouse event. + */ + private _onTripleClick(event: MouseEvent): void { + const coords = this._getMouseBufferCoords(event); + if (coords) { + this._isLineSelectModeActive = true; + this._selectLineAt(coords[1]); + } + } + + /** + * Sets the number of clicks for the current mousedown event based on the time + * and position of the last mousedown event. + * @param event The mouse event. + */ + private _setMouseClickCount(event: MouseEvent): void { + let currentTime = (new Date()).getTime(); + if (currentTime - this._lastMouseDownTime > CLEAR_MOUSE_DOWN_TIME || this._distanceFromLastMousePosition(event) > CLEAR_MOUSE_DISTANCE) { + this._clickCount = 0; + } + this._lastMouseDownTime = currentTime; + this._lastMousePosition = [event.pageX, event.pageY]; + this._clickCount++; + } + + /** + * Gets the maximum number of pixels in each direction the mouse has moved. + * @param event The mouse event. + */ + private _distanceFromLastMousePosition(event: MouseEvent): number { + const result = Math.max( + Math.abs(this._lastMousePosition[0] - event.pageX), + Math.abs(this._lastMousePosition[1] - event.pageY)); + return result; + } + + /** + * Handles the mousemove event when the mouse button is down, recording the + * end of the selection and refreshing the selection. + * @param event The mousemove event. + */ + private _onMouseMove(event: MouseEvent) { + // Record the previous position so we know whether to redraw the selection + // at the end. + const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null; + + // Set the initial selection end based on the mouse coordinates + this._model.selectionEnd = this._getMouseBufferCoords(event); + + // Select the entire line if line select mode is active. + if (this._isLineSelectModeActive) { + if (this._model.selectionEnd[1] < this._model.selectionStart[1]) { + this._model.selectionEnd[0] = 0; + } else { + this._model.selectionEnd[0] = this._terminal.cols; + } + } + + // Determine the amount of scrolling that will happen. + this._dragScrollAmount = this._getMouseEventScrollAmount(event); + + // If the cursor was above or below the viewport, make sure it's at the + // start or end of the viewport respectively. + if (this._dragScrollAmount > 0) { + this._model.selectionEnd[0] = this._terminal.cols - 1; + } else if (this._dragScrollAmount < 0) { + this._model.selectionEnd[0] = 0; + } + + // If the character is a wide character include the cell to the right in the + // selection. Note that selections at the very end of the line will never + // have a character. + if (this._model.selectionEnd[1] < this._buffer.length) { + const char = this._buffer.get(this._model.selectionEnd[1])[this._model.selectionEnd[0]]; + if (char && char[2] === 0) { + this._model.selectionEnd[0]++; + } + } + + // Only draw here if the selection changes. + if (!previousSelectionEnd || + previousSelectionEnd[0] !== this._model.selectionEnd[0] || + previousSelectionEnd[1] !== this._model.selectionEnd[1]) { + this.refresh(); + } + } + + /** + * The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the + * scrolling of the viewport. + */ + private _dragScroll() { + if (this._dragScrollAmount) { + this._terminal.scrollDisp(this._dragScrollAmount, false); + // Re-evaluate selection + if (this._dragScrollAmount > 0) { + this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.ydisp + this._terminal.rows]; + } else { + this._model.selectionEnd = [0, this._terminal.ydisp]; + } + this.refresh(); + } + } + + /** + * Handles the mouseup event, removing the mousedown listeners. + * @param event The mouseup event. + */ + private _onMouseUp(event: MouseEvent) { + this._removeMouseDownListeners(); + } + + /** + * Converts a viewport column to the character index on the buffer line, the + * latter takes into account wide characters. + * @param coords The coordinates to find the 2 index for. + */ + private _convertViewportColToCharacterIndex(bufferLine: any, coords: [number, number]): number { + let charIndex = coords[0]; + for (let i = 0; coords[0] >= i; i++) { + const char = bufferLine[i]; + if (char[LINE_DATA_WIDTH_INDEX] === 0) { + charIndex--; + } + } + return charIndex; + } + + /** + * Selects the word at the coordinates specified. Words are defined as all + * non-whitespace characters. + * @param coords The coordinates to get the word at. + */ + protected _selectWordAt(coords: [number, number]): void { + const bufferLine = this._buffer.get(coords[1]); + const line = this._translateBufferLineToString(bufferLine, false); + + // Get actual index, taking into consideration wide characters + let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); + let startIndex = endIndex; + + // Record offset to be used later + const charOffset = coords[0] - startIndex; + let leftWideCharCount = 0; + let rightWideCharCount = 0; + + if (line.charAt(startIndex) === ' ') { + // Expand until non-whitespace is hit + while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') { + startIndex--; + } + while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') { + endIndex++; + } + } else { + // Expand until whitespace is hit. This algorithm works by scanning left + // and right from the starting position, keeping both the index format + // (line) and the column format (bufferLine) in sync. When a wide + // character is hit, it is recorded and the column index is adjusted. + let startCol = coords[0]; + let endCol = coords[0]; + // Consider the initial position, skip it and increment the wide char + // variable + if (bufferLine[startCol][LINE_DATA_WIDTH_INDEX] === 0) { + leftWideCharCount++; + startCol--; + } + if (bufferLine[endCol][LINE_DATA_WIDTH_INDEX] === 2) { + rightWideCharCount++; + endCol++; + } + // Expand the string in both directions until a space is hit + while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') { + if (bufferLine[startCol - 1][LINE_DATA_WIDTH_INDEX] === 0) { + // If the next character is a wide char, record it and skip the column + leftWideCharCount++; + startCol--; + } + startIndex--; + startCol--; + } + while (endIndex + 1 < line.length && line.charAt(endIndex + 1) !== ' ') { + if (bufferLine[endCol + 1][LINE_DATA_WIDTH_INDEX] === 2) { + // If the next character is a wide char, record it and skip the column + rightWideCharCount++; + endCol++; + } + endIndex++; + endCol++; + } + } + + // Record the resulting selection + this._model.selectionStart = [startIndex + charOffset - leftWideCharCount, coords[1]]; + this._model.selectionStartLength = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols); + } + + /** + * Selects the line specified. + * @param line The line index. + */ + protected _selectLineAt(line: number): void { + this._model.selectionStart = [0, line]; + this._model.selectionStartLength = this._terminal.cols; + } +} diff --git a/src/SelectionModel.test.ts b/src/SelectionModel.test.ts new file mode 100644 index 00000000..e8629596 --- /dev/null +++ b/src/SelectionModel.test.ts @@ -0,0 +1,133 @@ +/** + * @license MIT + */ +import { assert } from 'chai'; +import { ITerminal } from './Interfaces'; +import { SelectionModel } from './SelectionModel'; + +class TestSelectionModel extends SelectionModel { + constructor( + terminal: ITerminal + ) { + super(terminal); + } + + public areSelectionValuesReversed(): boolean { return this._areSelectionValuesReversed(); } +} + +describe('SelectionManager', () => { + let window: Window; + let document: Document; + + let terminal: ITerminal; + let model: TestSelectionModel; + + beforeEach(() => { + terminal = { cols: 80, rows: 2, ybase: 0 }; + model = new TestSelectionModel(terminal); + }); + + describe('clearSelection', () => { + it('should clear the final selection', () => { + model.selectionStart = [0, 0]; + model.selectionEnd = [10, 2]; + assert.deepEqual(model.finalSelectionStart, [0, 0]); + assert.deepEqual(model.finalSelectionEnd, [10, 2]); + model.clearSelection(); + assert.deepEqual(model.finalSelectionStart, null); + assert.deepEqual(model.finalSelectionEnd, null); + }); + }); + + describe('_areSelectionValuesReversed', () => { + it('should return true when the selection end is before selection start', () => { + model.selectionStart = [1, 0]; + model.selectionEnd = [0, 0]; + assert.equal(model.areSelectionValuesReversed(), true); + model.selectionStart = [10, 2]; + model.selectionEnd = [0, 0]; + assert.equal(model.areSelectionValuesReversed(), true); + }); + it('should return false when the selection end is after selection start', () => { + model.selectionStart = [0, 0]; + model.selectionEnd = [1, 0]; + assert.equal(model.areSelectionValuesReversed(), false); + model.selectionStart = [0, 0]; + model.selectionEnd = [10, 2]; + assert.equal(model.areSelectionValuesReversed(), false); + }); + }); + + describe('onTrim', () => { + it('should trim a portion of the selection when a part of it is trimmed', () => { + model.selectionStart = [0, 0]; + model.selectionEnd = [10, 2]; + model.onTrim(1); + assert.deepEqual(model.finalSelectionStart, [0, 0]); + assert.deepEqual(model.finalSelectionEnd, [10, 1]); + model.onTrim(1); + assert.deepEqual(model.finalSelectionStart, [0, 0]); + assert.deepEqual(model.finalSelectionEnd, [10, 0]); + }); + it('should clear selection when it is trimmed in its entirety', () => { + model.selectionStart = [0, 0]; + model.selectionEnd = [10, 0]; + model.onTrim(1); + assert.deepEqual(model.finalSelectionStart, null); + assert.deepEqual(model.finalSelectionEnd, null); + }); + }); + + describe('finalSelectionStart', () => { + it('should return the start of the buffer if select all is active', () => { + model.isSelectAllActive = true; + assert.deepEqual(model.finalSelectionStart, [0, 0]); + }); + it('should return selection start if there is no selection end', () => { + model.selectionStart = [2, 2]; + assert.deepEqual(model.finalSelectionStart, [2, 2]); + }); + it('should return selection end if values are reversed', () => { + model.selectionStart = [2, 2]; + model.selectionEnd = [3, 2]; + assert.deepEqual(model.finalSelectionStart, [2, 2]); + model.selectionEnd = [1, 2]; + assert.deepEqual(model.finalSelectionStart, [1, 2]); + }); + }); + + describe('finalSelectionEnd', () => { + it('should return the end of the buffer if select all is active', () => { + model.isSelectAllActive = true; + assert.deepEqual(model.finalSelectionEnd, [80, 1]); + }); + it('should return null if there is no selection start', () => { + assert.equal(model.finalSelectionEnd, null); + model.selectionEnd = [1, 2]; + assert.equal(model.finalSelectionEnd, null); + }); + it('should return selection start + length if there is no selection end', () => { + model.selectionStart = [2, 2]; + model.selectionStartLength = 2; + assert.deepEqual(model.finalSelectionEnd, [4, 2]); + }); + it('should return selection start + length if values are reversed', () => { + model.selectionStart = [2, 2]; + model.selectionStartLength = 2; + model.selectionEnd = [2, 1]; + assert.deepEqual(model.finalSelectionEnd, [4, 2]); + }); + it('should return selection start + length if selection end is inside the start selection', () => { + model.selectionStart = [2, 2]; + model.selectionStartLength = 2; + model.selectionEnd = [3, 2]; + assert.deepEqual(model.finalSelectionEnd, [4, 2]); + }); + it('should return selection end if selection end is after selection start + length', () => { + model.selectionStart = [2, 2]; + model.selectionStartLength = 2; + model.selectionEnd = [5, 2]; + assert.deepEqual(model.finalSelectionEnd, [5, 2]); + }); + }); +}); diff --git a/src/SelectionModel.ts b/src/SelectionModel.ts new file mode 100644 index 00000000..403f42e0 --- /dev/null +++ b/src/SelectionModel.ts @@ -0,0 +1,128 @@ +/** + * @license MIT + */ + +import { ITerminal } from './Interfaces'; + +/** + * Represents a selection within the buffer. This model only cares about column + * and row coordinates, not wide characters. + */ +export class SelectionModel { + /** + * Whether select all is currently active. + */ + public isSelectAllActive: boolean; + + /** + * The [x, y] position the selection starts at. + */ + public selectionStart: [number, number]; + + /** + * The minimal length of the selection from the start position. When double + * clicking on a word, the word will be selected which makes the selection + * start at the start of the word and makes this variable the length. + */ + public selectionStartLength: number; + + /** + * The [x, y] position the selection ends at. + */ + public selectionEnd: [number, number]; + + constructor( + private _terminal: ITerminal + ) { + this.clearSelection(); + } + + /** + * Clears the current selection. + */ + public clearSelection(): void { + this.selectionStart = null; + this.selectionEnd = null; + this.isSelectAllActive = false; + this.selectionStartLength = 0; + } + + /** + * The final selection start, taking into consideration select all. + */ + public get finalSelectionStart(): [number, number] { + if (this.isSelectAllActive) { + return [0, 0]; + } + + if (!this.selectionEnd || !this.selectionStart) { + return this.selectionStart; + } + + return this._areSelectionValuesReversed() ? this.selectionEnd : this.selectionStart; + } + + /** + * The final selection end, taking into consideration select all, double click + * word selection and triple click line selection. + */ + public get finalSelectionEnd(): [number, number] { + if (this.isSelectAllActive) { + return [this._terminal.cols, this._terminal.ybase + this._terminal.rows - 1]; + } + + if (!this.selectionStart) { + return null; + } + + // Use the selection start if the end doesn't exist or they're reversed + if (!this.selectionEnd || this._areSelectionValuesReversed()) { + return [this.selectionStart[0] + this.selectionStartLength, this.selectionStart[1]]; + } + + // Ensure the the word/line is selected after a double/triple click + if (this.selectionStartLength) { + // Select the larger of the two when start and end are on the same line + if (this.selectionEnd[1] === this.selectionStart[1]) { + return [Math.max(this.selectionStart[0] + this.selectionStartLength, this.selectionEnd[0]), this.selectionEnd[1]]; + } + } + return this.selectionEnd; + } + + /** + * Returns whether the selection start and end are reversed. + */ + protected _areSelectionValuesReversed(): boolean { + const start = this.selectionStart; + const end = this.selectionEnd; + return start[1] > end[1] || (start[1] === end[1] && start[0] > end[0]); + } + + /** + * Handle the buffer being trimmed, adjust the selection position. + * @param amount The amount the buffer is being trimmed. + * @return Whether a refresh is necessary. + */ + public onTrim(amount: number): boolean { + // Adjust the selection position based on the trimmed amount. + if (this.selectionStart) { + this.selectionStart[1] -= amount; + } + if (this.selectionEnd) { + this.selectionEnd[1] -= amount; + } + + // The selection has moved off the buffer, clear it. + if (this.selectionEnd && this.selectionEnd[1] < 0) { + this.clearSelection(); + return true; + } + + // If the selection start is trimmed, ensure the start column is 0. + if (this.selectionStart && this.selectionStart[1] < 0) { + this.selectionStart[1] = 0; + } + return false; + } +} diff --git a/src/Viewport.test.ts b/src/Viewport.test.ts index 70ee97eb..193e7969 100644 --- a/src/Viewport.test.ts +++ b/src/Viewport.test.ts @@ -4,6 +4,7 @@ import { Viewport } from './Viewport'; describe('Viewport', () => { let terminal; let viewportElement; + let selectionContainer; let charMeasure; let viewport; let scrollAreaElement; @@ -20,6 +21,11 @@ describe('Viewport', () => { style: { lineHeight: 0 } + }, + selectionContainer: { + style: { + height: 0 + } } }; viewportElement = { diff --git a/src/Viewport.ts b/src/Viewport.ts index dc7ff7c3..82f748ea 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -57,6 +57,7 @@ export class Viewport { if (rowHeightChanged || viewportHeightChanged) { this.lastRecordedViewportHeight = this.terminal.rows; this.viewportElement.style.height = this.charMeasure.height * this.terminal.rows + 'px'; + this.terminal.selectionContainer.style.height = this.viewportElement.style.height; } this.scrollArea.style.height = (this.charMeasure.height * this.lastRecordedBufferLength) + 'px'; } diff --git a/src/handlers/Clipboard.test.ts b/src/handlers/Clipboard.test.ts index 471389c4..187d3a89 100644 --- a/src/handlers/Clipboard.test.ts +++ b/src/handlers/Clipboard.test.ts @@ -2,21 +2,6 @@ import { assert } from 'chai'; import * as Terminal from '../xterm'; import * as Clipboard from './Clipboard'; - -describe('evaluateCopiedTextProcessing', function () { - it('should strip trailing whitespaces and replace nbsps with spaces', function () { - let nonBreakingSpace = String.fromCharCode(160), - copiedText = 'echo' + nonBreakingSpace + 'hello' + nonBreakingSpace, - processedText = Clipboard.prepareTextForClipboard(copiedText); - - // No trailing spaces - assert.equal(processedText.match(/\s+$/), null); - - // No non-breaking space - assert.equal(processedText.indexOf(nonBreakingSpace), -1); - }); -}); - describe('evaluatePastedTextProcessing', function () { it('should replace carriage return + line feed with line feed on windows', function () { const pastedText = 'foo\r\nbar\r\n', diff --git a/src/handlers/Clipboard.ts b/src/handlers/Clipboard.ts index 0f3b9d04..493f11cd 100644 --- a/src/handlers/Clipboard.ts +++ b/src/handlers/Clipboard.ts @@ -5,7 +5,7 @@ * @license MIT */ -import { ITerminal } from '../Interfaces'; +import { ITerminal, ISelectionManager } from '../Interfaces'; interface IWindow extends Window { clipboardData?: { @@ -16,28 +16,6 @@ interface IWindow extends Window { declare var window: IWindow; -/** - * Prepares text copied from terminal selection, to be saved in the clipboard by: - * 1. stripping all trailing white spaces - * 2. converting all non-breaking spaces to regular spaces - * @param {string} text The copied text that needs processing for storing in clipboard - * @returns {string} - */ -export function prepareTextForClipboard(text: string): string { - let space = String.fromCharCode(32), - nonBreakingSpace = String.fromCharCode(160), - allNonBreakingSpaces = new RegExp(nonBreakingSpace, 'g'), - processedText = text.split('\n').map(function (line) { - // Strip all trailing white spaces and convert all non-breaking spaces - // to regular spaces. - let processedLine = line.replace(/\s+$/g, '').replace(allNonBreakingSpaces, space); - - return processedLine; - }).join('\n'); - - return processedText; -} - /** * Prepares text to be pasted into the terminal by normalizing the line endings * @param text The pasted text that needs processing before inserting into the terminal @@ -53,19 +31,15 @@ export function prepareTextForTerminal(text: string, isMSWindows: boolean): stri * Binds copy functionality to the given terminal. * @param {ClipboardEvent} ev The original copy event to be handled */ -export function copyHandler(ev: ClipboardEvent, term: ITerminal) { - // We cast `window` to `any` type, because TypeScript has not declared the `clipboardData` - // property that we use below for Internet Explorer. - let copiedText = window.getSelection().toString(), - text = prepareTextForClipboard(copiedText); - +export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManager: ISelectionManager) { if (term.browser.isMSIE) { - window.clipboardData.setData('Text', text); + window.clipboardData.setData('Text', selectionManager.selectionText); } else { - ev.clipboardData.setData('text/plain', text); + ev.clipboardData.setData('text/plain', selectionManager.selectionText); } - ev.preventDefault(); // Prevent or the original text will be copied. + // Prevent or the original text will be copied. + ev.preventDefault(); } /** @@ -102,67 +76,31 @@ export function pasteHandler(ev: ClipboardEvent, term: ITerminal) { /** * Bind to right-click event and allow right-click copy and paste. - * - * **Logic** - * If text is selected and right-click happens on selected text, then - * do nothing to allow seamless copying. - * If no text is selected or right-click is outside of the selection - * area, then bring the terminal's input below the cursor, in order to - * trigger the event on the textarea and allow-right click paste, without - * caring about disappearing selection. - * @param {MouseEvent} ev The original right click event to be handled - * @param {Terminal} term The terminal on which to apply the handled paste event + * @param ev The original right click event to be handled + * @param term The terminal on which to apply the handled paste event + * @param selectionManager The terminal's selection manager. */ -export function rightClickHandler(ev: MouseEvent, term: ITerminal) { - let s = document.getSelection(), - selectedText = prepareTextForClipboard(s.toString()), - clickIsOnSelection = false, - x = ev.clientX, - y = ev.clientY; - - if (s.rangeCount) { - let r = s.getRangeAt(0), - cr = r.getClientRects(); - - for (let i = 0; i < cr.length; i++) { - let rect = cr[i]; - - clickIsOnSelection = ( - (x > rect.left) && (x < rect.right) && - (y > rect.top) && (y < rect.bottom) - ); - - if (clickIsOnSelection) { - break; - } - } - // If we clicked on selection and selection is not a single space, - // then mark the right click as copy-only. We check for the single - // space selection, as this can happen when clicking on an   - // and there is not much pointing in copying a single space. - if (selectedText.match(/^\s$/) || !selectedText.length) { - clickIsOnSelection = false; - } - } - +export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, selectionManager: ISelectionManager) { // Bring textarea at the cursor position - if (!clickIsOnSelection) { - term.textarea.style.position = 'fixed'; - term.textarea.style.width = '20px'; - term.textarea.style.height = '20px'; - term.textarea.style.left = (x - 10) + 'px'; - term.textarea.style.top = (y - 10) + 'px'; - term.textarea.style.zIndex = '1000'; - term.textarea.focus(); + textarea.style.position = 'fixed'; + textarea.style.width = '20px'; + textarea.style.height = '20px'; + textarea.style.left = (ev.clientX - 10) + 'px'; + textarea.style.top = (ev.clientY - 10) + 'px'; + textarea.style.zIndex = '1000'; - // Reset the terminal textarea's styling - setTimeout(function () { - term.textarea.style.position = null; - term.textarea.style.width = null; - term.textarea.style.height = null; - term.textarea.style.left = null; - term.textarea.style.top = null; - term.textarea.style.zIndex = null; - }, 4); - } + // Get textarea ready to copy from the context menu + textarea.value = selectionManager.selectionText; + textarea.focus(); + textarea.select(); + + // Reset the terminal textarea's styling + setTimeout(function () { + textarea.style.position = null; + textarea.style.width = null; + textarea.style.height = null; + textarea.style.left = null; + textarea.style.top = null; + textarea.style.zIndex = null; + }, 4); } diff --git a/src/utils/CircularList.ts b/src/utils/CircularList.ts index b72c667f..d0b2f685 100644 --- a/src/utils/CircularList.ts +++ b/src/utils/CircularList.ts @@ -4,12 +4,15 @@ * @module xterm/utils/CircularList * @license MIT */ -export class CircularList { +import { EventEmitter } from '../EventEmitter'; + +export class CircularList extends EventEmitter { private _array: T[]; private _startIndex: number; private _length: number; constructor(maxLength: number) { + super(); this._array = new Array(maxLength); this._startIndex = 0; this._length = 0; @@ -43,8 +46,14 @@ export class CircularList { this._length = newLength; } - public get forEach(): (callbackfn: (value: T, index: number, array: T[]) => void) => void { - return this._array.forEach; + public get forEach(): (callbackfn: (value: T, index: number) => void) => void { + return (callbackfn: (value: T, index: number) => void) => { + let i = 0; + let length = this.length; + for (let i = 0; i < length; i++) { + callbackfn(this.get(i), i); + } + }; } /** @@ -83,6 +92,7 @@ export class CircularList { if (this._startIndex === this.maxLength) { this._startIndex = 0; } + this.emit('trim', 1); } else { this._length++; } @@ -106,13 +116,16 @@ export class CircularList { * @param items The items to insert. */ public splice(start: number, deleteCount: number, ...items: T[]): void { + // Delete items if (deleteCount) { for (let i = start; i < this._length - deleteCount; i++) { this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)]; } this._length -= deleteCount; } + if (items && items.length) { + // Add items for (let i = this._length - 1; i >= start; i--) { this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)]; } @@ -120,9 +133,12 @@ export class CircularList { this._array[this._getCyclicIndex(start + i)] = items[i]; } + // Adjust length as needed if (this._length + items.length > this.maxLength) { - this._startIndex += (this._length + items.length) - this.maxLength; + const countToTrim = (this._length + items.length) - this.maxLength; + this._startIndex += countToTrim; this._length = this.maxLength; + this.emit('trim', countToTrim); } else { this._length += items.length; } @@ -139,6 +155,7 @@ export class CircularList { } this._startIndex += count; this._length -= count; + this.emit('trim', count); } public shiftElements(start: number, count: number, offset: number): void { @@ -162,6 +179,7 @@ export class CircularList { while (this._length > this.maxLength) { this._length--; this._startIndex++; + this.emit('trim', 1); } } } else { diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts index a9efdf93..79c5d5c1 100644 --- a/src/utils/Mouse.ts +++ b/src/utils/Mouse.ts @@ -4,6 +4,25 @@ import { CharMeasure } from './CharMeasure'; +export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLElement): [number, number] { + // Ignore browsers that don't support MouseEvent.pageX + if (event.pageX == null) { + return null; + } + + let x = event.pageX; + let y = event.pageY; + + // Converts the coordinates from being relative to the document to being + // relative to the terminal. + while (element && element !== self.document.documentElement) { + x -= element.offsetLeft; + y -= element.offsetTop; + element = 'offsetParent' in element ? element.offsetParent : element.parentElement; + } + return [x, y]; +} + /** * Gets coordinates within the terminal for a particular mouse event. The result * is returned as an array in the form [x, y] instead of an object as it's a @@ -12,29 +31,18 @@ import { CharMeasure } from './CharMeasure'; * @param rowContainer The terminal's row container. * @param charMeasure The char measure object used to determine character sizes. */ -export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure): [number, number] { - // Ignore browsers that don't support MouseEvent.pageX - if (event.pageX == null) { - return null; - } - - let x = event.pageX; - let y = event.pageY; - let el = rowContainer; - - // Converts the coordinates from being relative to the document to being - // relative to the terminal. - while (el && el !== self.document.documentElement) { - x -= el.offsetLeft; - y -= el.offsetTop; - el = 'offsetParent' in el ? el.offsetParent : el.parentElement; - } +export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): [number, number] { + const coords = getCoordsRelativeToElement(event, rowContainer); // Convert to cols/rows - x = Math.ceil(x / charMeasure.width); - y = Math.ceil(y / charMeasure.height); + coords[0] = Math.ceil(coords[0] / charMeasure.width); + coords[1] = Math.ceil(coords[1] / charMeasure.height); - return [x, y]; + // Ensure coordinates are within the terminal viewport. + coords[0] = Math.min(Math.max(coords[0], 1), colCount + 1); + coords[1] = Math.min(Math.max(coords[1], 1), rowCount + 1); + + return coords; } /** @@ -48,14 +56,10 @@ export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeas * @param rowCount The number of rows in the terminal. */ export function getRawByteCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): { x: number, y: number } { - const coords = getCoords(event, rowContainer, charMeasure); + const coords = getCoords(event, rowContainer, charMeasure, colCount, rowCount); let x = coords[0]; let y = coords[1]; - // Ensure coordinates are within the terminal viewport. - x = Math.min(Math.max(x, 0), colCount); - y = Math.min(Math.max(y, 0), rowCount); - // xterm sends raw bytes and starts at 32 (SP) for each. x += 32; y += 32; diff --git a/src/xterm.css b/src/xterm.css index efdc0169..37f661a7 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -41,6 +41,9 @@ font-family: courier-new, courier, monospace; font-feature-settings: "liga" 0; position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; } .terminal.focus, @@ -180,6 +183,22 @@ left: -9999em; } +.terminal.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.terminal .xterm-selection { + position: absolute; + top: 0; + left: 0; +} + +.terminal .xterm-selection div { + position: absolute; + background-color: #555; +} + /* * Determine default colors for xterm.js */ diff --git a/src/xterm.js b/src/xterm.js index 3ace764c..ea4b3535 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -20,9 +20,10 @@ import { InputHandler } from './InputHandler'; import { Parser } from './Parser'; import { Renderer } from './Renderer'; import { Linkifier } from './Linkifier'; +import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; import * as Browser from './utils/Browser'; -import * as Keyboard from './utils/Keyboard'; +import * as Mouse from './utils/Mouse'; import { CHARSETS } from './Charsets'; import { getRawByteCoords } from './utils/Mouse'; @@ -220,6 +221,7 @@ function Terminal(options) { this.parser = new Parser(this.inputHandler, this); // Reuse renderer if the Terminal is being recreated via a Terminal.reset call. this.renderer = this.renderer || null; + this.selectionManager = this.selectionManager || null; this.linkifier = this.linkifier || new Linkifier(); // user input states @@ -249,6 +251,10 @@ function Terminal(options) { while (i--) { this.lines.push(this.blankLine()); } + // Ensure the selection manager has the correct buffer + if (this.selectionManager) { + this.selectionManager.setBuffer(this.lines); + } this.tabs; this.setupStops(); @@ -519,28 +525,28 @@ Terminal.prototype.initGlobal = function() { Terminal.bindBlur(this); // Bind clipboard functionality - on(this.element, 'copy', function (ev) { - copyHandler.call(this, ev, term); + on(this.element, 'copy', event => { + // If mouse events are active it means the selection manager is disabled and + // copy should be handled by the host program. + if (this.mouseEvents) { + return; + } + copyHandler(event, term, this.selectionManager); }); - on(this.textarea, 'paste', function (ev) { - pasteHandler.call(this, ev, term); - }); - on(this.element, 'paste', function (ev) { - pasteHandler.call(this, ev, term); - }); - - function rightClickHandlerWrapper (ev) { - rightClickHandler.call(this, ev, term); - } + const pasteHandlerWrapper = event => pasteHandler(event, term); + on(this.textarea, 'paste', pasteHandlerWrapper); + on(this.element, 'paste', pasteHandlerWrapper); if (term.browser.isFirefox) { - on(this.element, 'mousedown', function (ev) { + on(this.element, 'mousedown', event => { if (ev.button == 2) { - rightClickHandlerWrapper(ev); + rightClickHandler(event, this.textarea, this.selectionManager); } }); } else { - on(this.element, 'contextmenu', rightClickHandlerWrapper); + on(this.element, 'contextmenu', event => { + rightClickHandler(event, this.textarea, this.selectionManager); + }); } }; @@ -641,6 +647,12 @@ Terminal.prototype.open = function(parent, focus) { this.viewportScrollArea.classList.add('xterm-scroll-area'); this.viewportElement.appendChild(this.viewportScrollArea); + // Create the selection container. This needs to be added before the + // rowContainer as the selection must be below the text. + this.selectionContainer = document.createElement('div'); + this.selectionContainer.classList.add('xterm-selection'); + this.element.appendChild(this.selectionContainer); + // Create the container that will hold the lines of the terminal and then // produce the lines the lines. this.rowContainer = document.createElement('div'); @@ -684,12 +696,16 @@ Terminal.prototype.open = function(parent, focus) { this.charMeasure = new CharMeasure(document, this.helperContainer); this.charMeasure.on('charsizechanged', function () { - self.updateCharSizeCSS(); + self.updateCharSizeStyles(); }); this.charMeasure.measure(); this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); this.renderer = new Renderer(this); + this.selectionManager = new SelectionManager(this, this.lines, this.rowContainer, this.charMeasure); + this.selectionManager.on('refresh', data => this.renderer.refreshSelection(data.start, data.end)); + this.on('scroll', () => this.selectionManager.refresh()); + this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); // Setup loop that draws to screen this.refresh(0, this.rows - 1); @@ -760,7 +776,7 @@ Terminal.loadAddon = function(addon, callback) { * Updates the helper CSS class with any changes necessary after the terminal's * character width has been changed. */ -Terminal.prototype.updateCharSizeCSS = function() { +Terminal.prototype.updateCharSizeStyles = function() { this.charSizeStyleElement.textContent = `.xterm-wide-char{width:${this.charMeasure.width * 2}px;}` + `.xterm-normal-char{width:${this.charMeasure.width}px;}` + @@ -1167,6 +1183,9 @@ Terminal.prototype.scroll = function() { */ Terminal.prototype.scrollDisp = function(disp, suppressScrollEvent) { if (disp < 0) { + if (this.ydisp === 0) { + return; + } this.userScrolling = true; } else if (disp + this.ydisp >= this.ybase) { this.userScrolling = false; @@ -1355,6 +1374,35 @@ Terminal.prototype.deregisterLinkMatcher = function(matcherId) { } } +/** + * Gets whether the terminal has an active selection. + */ +Terminal.prototype.hasSelection = function() { + return this.selectionManager.hasSelection; +} + +/** + * Gets the terminal's current selection, this is useful for implementing copy + * behavior outside of xterm.js. + */ +Terminal.prototype.getSelection = function() { + return this.selectionManager.selectionText; +} + +/** + * Clears the current terminal selection. + */ +Terminal.prototype.clearSelection = function() { + this.selectionManager.clearSelection(); +} + +/** + * Selects all text within the terminal. + */ +Terminal.prototype.selectAll = function() { + this.selectionManager.selectAll(); +} + /** * Handle a keydown event * Key Resources: @@ -1686,6 +1734,10 @@ Terminal.prototype.evaluateKeyEscapeSequence = function(ev) { } else if (ev.keyCode >= 48 && ev.keyCode <= 57) { result.key = C0.ESC + (ev.keyCode - 48); } + } else if (this.browser.isMac && !ev.altKey && !ev.ctrlKey && ev.metaKey) { + if (ev.keyCode === 65) { // cmd + a + this.selectAll(); + } } break; }