X-Git-Url: https://git.proxmox.com/?a=blobdiff_plain;f=src%2FSelectionManager.ts;h=8f1d0e2084a2426dddd56b14e07f9c4c610fcf76;hb=e56f710cd1ce2e4aa15ea8af8bd5b0cc8cfd736e;hp=7cb99ccd36721367ff348595b09dea1b19d86604;hpb=5f9ad5c19e76253357609c790874d5bbe038f087;p=mirror_xterm.js.git diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 7cb99cc..8f1d0e2 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -2,12 +2,14 @@ * @license MIT */ +import * as Mouse from './utils/Mouse'; +import * as Browser from './utils/Browser'; 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 { ITerminal, ICircularList } from './Interfaces'; import { SelectionModel } from './SelectionModel'; +import { translateBufferLineToString } from './utils/BufferLine'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -26,16 +28,10 @@ const DRAG_SCROLL_MAX_SPEED = 15; const DRAG_SCROLL_INTERVAL = 50; /** - * The amount of time before mousedown events are no longer stacked to create - * double/triple click events. + * A string containing all characters that are considered word separated by the + * double click to select work logic. */ -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; +const WORD_SEPARATORS = ' ()[]{}\'"'; // TODO: Move these constants elsewhere, they belong in a buffer or buffer // data/line class. @@ -45,6 +41,23 @@ 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'); +/** + * Represents a position of a word on a line. + */ +interface IWordPosition { + start: number; + length: number; +} + +/** + * A selection mode, this drives how the selection behaves on mouse move. + */ +enum SelectionMode { + NORMAL, + WORD, + LINE +} + /** * A class that manages the selection of the terminal. With help from * SelectionModel, SelectionManager handles with all logic associated with @@ -63,26 +76,9 @@ export class SelectionManager extends EventEmitter { private _dragScrollAmount: number; /** - * The last time the mousedown event fired, this is used to track double and - * triple clicks. + * The current selection mode. */ - 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; + private _activeSelectionMode: SelectionMode; /** * A setInterval timer that is active while the mouse is down whose callback @@ -102,7 +98,7 @@ export class SelectionManager extends EventEmitter { constructor( private _terminal: ITerminal, - private _buffer: CircularList, + private _buffer: ICircularList<[number, string, number][]>, private _rowContainer: HTMLElement, private _charMeasure: CharMeasure ) { @@ -111,8 +107,7 @@ export class SelectionManager extends EventEmitter { this.enable(); this._model = new SelectionModel(_terminal); - this._lastMouseDownTime = 0; - this._isLineSelectModeActive = false; + this._activeSelectionMode = SelectionMode.NORMAL; } /** @@ -152,15 +147,24 @@ export class SelectionManager extends EventEmitter { * switched in or out. * @param buffer The active buffer. */ - public setBuffer(buffer: CircularList): void { + public setBuffer(buffer: ICircularList<[number, string, number][]>): void { this._buffer = buffer; + this.clearSelection(); } + public get selectionStart(): [number, number] { return this._model.finalSelectionStart; } + public get selectionEnd(): [number, number] { return this._model.finalSelectionEnd; } + /** * Gets whether there is an active text selection. */ public get hasSelection(): boolean { - return !!this._model.finalSelectionStart && !!this._model.finalSelectionEnd; + const start = this._model.finalSelectionStart; + const end = this._model.finalSelectionEnd; + if (!start || !end) { + return false; + } + return start[0] !== end[0] || start[1] !== end[1]; } /** @@ -176,23 +180,35 @@ export class SelectionManager extends EventEmitter { // 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)); + result.push(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)); + const bufferLine = this._buffer.get(i); + const lineText = translateBufferLineToString(bufferLine, true); + if ((bufferLine).isWrapped) { + result[result.length - 1] += lineText; + } else { + result.push(lineText); + } } // Get final row if (start[1] !== end[1]) { - result.push(this._translateBufferLineToString(this._buffer.get(end[1]), true, 0, end[0])); + const bufferLine = this._buffer.get(end[1]); + const lineText = translateBufferLineToString(bufferLine, true, 0, end[0]); + if ((bufferLine).isWrapped) { + result[result.length - 1] += lineText; + } else { + result.push(lineText); + } } // 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'); + }).join(Browser.isMSWindows ? '\r\n' : '\n'); return formattedResult; } @@ -206,62 +222,25 @@ export class SelectionManager extends EventEmitter { 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. + * @param isNewSelection Whether the selection should be registered as a new + * selection on Linux. */ - public refresh(): void { + public refresh(isNewSelection?: boolean): void { + // Queue the refresh for the renderer if (!this._refreshAnimationFrame) { this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh()); } + + // If the platform is Linux and the refresh call comes from a mouse event, + // we need to update the selection for middle click to paste selection. + if (Browser.isLinux && isNewSelection) { + const selectionText = this.selectionText; + if (selectionText.length) { + this.emit('newselection', this.selectionText); + } + } } /** @@ -297,12 +276,16 @@ export class SelectionManager extends EventEmitter { * @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); + const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows, true); + if (!coords) { + return null; + } + // Convert to 0-based coords[0]--; coords[1]--; // Convert viewport coords to buffer coords - coords[1] += this._terminal.ydisp; + coords[1] += this._terminal.buffer.ydisp; return coords; } @@ -342,22 +325,20 @@ export class SelectionManager extends EventEmitter { // Reset drag scroll state this._dragScrollAmount = 0; - this._setMouseClickCount(event); - if (event.shiftKey) { this._onShiftClick(event); } else { - if (this._clickCount === 1) { + if (event.detail === 1) { this._onSingleClick(event); - } else if (this._clickCount === 2) { + } else if (event.detail === 2) { this._onDoubleClick(event); - } else if (this._clickCount === 3) { + } else if (event.detail === 3) { this._onTripleClick(event); } } this._addMouseDownListeners(); - this.refresh(); + this.refresh(true); } /** @@ -399,16 +380,26 @@ export class SelectionManager extends EventEmitter { private _onSingleClick(event: MouseEvent): void { this._model.selectionStartLength = 0; this._model.isSelectAllActive = false; - this._isLineSelectModeActive = false; + this._activeSelectionMode = SelectionMode.NORMAL; + + // Initialize the new selection 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]++; - } + if (!this._model.selectionStart) { + return; + } + this._model.selectionEnd = null; + + // Ensure the line exists + const line = this._buffer.get(this._model.selectionStart[1]); + if (!line) { + return; + } + + // If the mouse is over the second half of a wide character, adjust the + // selection to cover the whole character + const char = line[this._model.selectionStart[0]]; + if (char[LINE_DATA_WIDTH_INDEX] === 0) { + this._model.selectionStart[0]++; } } @@ -419,6 +410,7 @@ export class SelectionManager extends EventEmitter { private _onDoubleClick(event: MouseEvent): void { const coords = this._getMouseBufferCoords(event); if (coords) { + this._activeSelectionMode = SelectionMode.WORD; this._selectWordAt(coords); } } @@ -431,37 +423,11 @@ export class SelectionManager extends EventEmitter { private _onTripleClick(event: MouseEvent): void { const coords = this._getMouseBufferCoords(event); if (coords) { - this._isLineSelectModeActive = true; + this._activeSelectionMode = SelectionMode.LINE; 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. @@ -474,14 +440,20 @@ export class SelectionManager extends EventEmitter { // Set the initial selection end based on the mouse coordinates this._model.selectionEnd = this._getMouseBufferCoords(event); + if (!this._model.selectionEnd) { + this.refresh(true); + return; + } // Select the entire line if line select mode is active. - if (this._isLineSelectModeActive) { + if (this._activeSelectionMode === SelectionMode.LINE) { if (this._model.selectionEnd[1] < this._model.selectionStart[1]) { this._model.selectionEnd[0] = 0; } else { this._model.selectionEnd[0] = this._terminal.cols; } + } else if (this._activeSelectionMode === SelectionMode.WORD) { + this._selectToWordAt(this._model.selectionEnd); } // Determine the amount of scrolling that will happen. @@ -509,7 +481,7 @@ export class SelectionManager extends EventEmitter { if (!previousSelectionEnd || previousSelectionEnd[0] !== this._model.selectionEnd[0] || previousSelectionEnd[1] !== this._model.selectionEnd[1]) { - this.refresh(); + this.refresh(true); } } @@ -522,9 +494,9 @@ export class SelectionManager extends EventEmitter { 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]; + this._model.selectionEnd = [this._terminal.cols - 1, this._terminal.buffer.ydisp + this._terminal.rows]; } else { - this._model.selectionEnd = [0, this._terminal.ydisp]; + this._model.selectionEnd = [0, this._terminal.buffer.ydisp]; } this.refresh(); } @@ -554,14 +526,25 @@ export class SelectionManager extends EventEmitter { return charIndex; } + public setSelection(col: number, row: number, length: number): void { + this._model.clearSelection(); + this._removeMouseDownListeners(); + this._model.selectionStart = [col, row]; + this._model.selectionStartLength = length; + this.refresh(); + } + /** - * Selects the word at the coordinates specified. Words are defined as all - * non-whitespace characters. + * Gets positional information for the word at the coordinated specified. * @param coords The coordinates to get the word at. */ - protected _selectWordAt(coords: [number, number]): void { + private _getWordAt(coords: [number, number]): IWordPosition { const bufferLine = this._buffer.get(coords[1]); - const line = this._translateBufferLineToString(bufferLine, false); + if (!bufferLine) { + return null; + } + + const line = translateBufferLineToString(bufferLine, false); // Get actual index, taking into consideration wide characters let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); @@ -598,7 +581,7 @@ export class SelectionManager extends EventEmitter { endCol++; } // Expand the string in both directions until a space is hit - while (startIndex > 0 && line.charAt(startIndex - 1) !== ' ') { + while (startIndex > 0 && !this._isCharWordSeparator(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++; @@ -607,7 +590,7 @@ export class SelectionManager extends EventEmitter { startIndex--; startCol--; } - while (endIndex + 1 < line.length && line.charAt(endIndex + 1) !== ' ') { + while (endIndex + 1 < line.length && !this._isCharWordSeparator(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++; @@ -618,9 +601,41 @@ export class SelectionManager extends EventEmitter { } } - // 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); + const start = startIndex + charOffset - leftWideCharCount; + const length = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols); + return {start, length}; + } + + /** + * Selects the word at the coordinates specified. + * @param coords The coordinates to get the word at. + */ + protected _selectWordAt(coords: [number, number]): void { + const wordPosition = this._getWordAt(coords); + if (wordPosition) { + this._model.selectionStart = [wordPosition.start, coords[1]]; + this._model.selectionStartLength = wordPosition.length; + } + } + + /** + * Sets the selection end to the word at the coordinated specified. + * @param coords The coordinates to get the word at. + */ + private _selectToWordAt(coords: [number, number]): void { + const wordPosition = this._getWordAt(coords); + if (wordPosition) { + this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : (wordPosition.start + wordPosition.length), coords[1]]; + } + } + + /** + * Gets whether the character is considered a word separator by the select + * word logic. + * @param char The character to check. + */ + private _isCharWordSeparator(char: string): boolean { + return WORD_SEPARATORS.indexOf(char) >= 0; } /**