2 * Copyright (c) 2017 The xterm.js authors. All rights reserved.
6 import { Disposable } from 'common/Lifecycle';
7 import { addDisposableDomListener } from 'browser/Lifecycle';
8 import { IMouseService, ISelectionService } from 'browser/services/Services';
9 import { IMouseZoneManager, IMouseZone } from 'browser/Types';
10 import { IBufferService } from 'common/services/Services';
12 const HOVER_DURATION = 500;
15 * The MouseZoneManager allows components to register zones within the terminal
16 * that trigger hover and click callbacks.
18 * This class was intentionally made not so robust initially as the only case it
19 * needed to support was single-line links which never overlap. Improvements can
20 * be made in the future.
22 export class MouseZoneManager extends Disposable implements IMouseZoneManager {
23 private _zones: IMouseZone[] = [];
25 private _areZonesActive: boolean = false;
26 private _mouseMoveListener: (e: MouseEvent) => any;
27 private _mouseLeaveListener: (e: MouseEvent) => any;
28 private _clickListener: (e: MouseEvent) => any;
30 private _tooltipTimeout: number | undefined;
31 private _currentZone: IMouseZone | undefined;
32 private _lastHoverCoords: [number | undefined, number | undefined] = [undefined, undefined];
33 private _initialSelectionLength: number = 0;
36 private readonly _element: HTMLElement,
37 private readonly _screenElement: HTMLElement,
38 @IBufferService private readonly _bufferService: IBufferService,
39 @IMouseService private readonly _mouseService: IMouseService,
40 @ISelectionService private readonly _selectionService: ISelectionService
44 this.register(addDisposableDomListener(this._element, 'mousedown', e => this._onMouseDown(e)));
46 // These events are expensive, only listen to it when mouse zones are active
47 this._mouseMoveListener = e => this._onMouseMove(e);
48 this._mouseLeaveListener = e => this._onMouseLeave(e);
49 this._clickListener = e => this._onClick(e);
52 public dispose(): void {
57 public add(zone: IMouseZone): void {
58 this._zones.push(zone);
59 if (this._zones.length === 1) {
64 public clearAll(start?: number, end?: number): void {
65 // Exit if there's nothing to clear
66 if (this._zones.length === 0) {
70 // Clear all if start/end weren't set
73 end = this._bufferService.rows - 1;
76 // Iterate through zones and clear them out if they're within the range
77 for (let i = 0; i < this._zones.length; i++) {
78 const zone = this._zones[i];
79 if ((zone.y1 > start && zone.y1 <= end + 1) ||
80 (zone.y2 > start && zone.y2 <= end + 1) ||
81 (zone.y1 < start && zone.y2 > end + 1)) {
82 if (this._currentZone && this._currentZone === zone) {
83 this._currentZone.leaveCallback();
84 this._currentZone = undefined;
86 this._zones.splice(i--, 1);
90 // Deactivate the mouse zone manager if all the zones have been removed
91 if (this._zones.length === 0) {
96 private _activate(): void {
97 if (!this._areZonesActive) {
98 this._areZonesActive = true;
99 this._element.addEventListener('mousemove', this._mouseMoveListener);
100 this._element.addEventListener('mouseleave', this._mouseLeaveListener);
101 this._element.addEventListener('click', this._clickListener);
105 private _deactivate(): void {
106 if (this._areZonesActive) {
107 this._areZonesActive = false;
108 this._element.removeEventListener('mousemove', this._mouseMoveListener);
109 this._element.removeEventListener('mouseleave', this._mouseLeaveListener);
110 this._element.removeEventListener('click', this._clickListener);
114 private _onMouseMove(e: MouseEvent): void {
115 // TODO: Ideally this would only clear the hover state when the mouse moves
116 // outside of the mouse zone
117 if (this._lastHoverCoords[0] !== e.pageX || this._lastHoverCoords[1] !== e.pageY) {
119 // Record the current coordinates
120 this._lastHoverCoords = [e.pageX, e.pageY];
124 private _onHover(e: MouseEvent): void {
125 const zone = this._findZoneEventAt(e);
127 // Do nothing if the zone is the same
128 if (zone === this._currentZone) {
132 // Fire the hover end callback and cancel any existing timer if a new zone
134 if (this._currentZone) {
135 this._currentZone.leaveCallback();
136 this._currentZone = undefined;
137 if (this._tooltipTimeout) {
138 clearTimeout(this._tooltipTimeout);
142 // Exit if there is not zone
146 this._currentZone = zone;
148 // Trigger the hover callback
149 if (zone.hoverCallback) {
150 zone.hoverCallback(e);
153 // Restart the tooltip timeout
154 this._tooltipTimeout = <number><any>setTimeout(() => this._onTooltip(e), HOVER_DURATION);
157 private _onTooltip(e: MouseEvent): void {
158 this._tooltipTimeout = undefined;
159 const zone = this._findZoneEventAt(e);
160 if (zone && zone.tooltipCallback) {
161 zone.tooltipCallback(e);
165 private _onMouseDown(e: MouseEvent): void {
166 // Store current terminal selection length, to check if we're performing
167 // a selection operation
168 this._initialSelectionLength = this._getSelectionLength();
170 // Ignore the event if there are no zones active
171 if (!this._areZonesActive) {
175 // Find the active zone, prevent event propagation if found to prevent other
176 // components from handling the mouse event.
177 const zone = this._findZoneEventAt(e);
179 if (zone.willLinkActivate(e)) {
181 e.stopImmediatePropagation();
186 private _onMouseLeave(e: MouseEvent): void {
187 // Fire the hover end callback and cancel any existing timer if the mouse
188 // leaves the terminal element
189 if (this._currentZone) {
190 this._currentZone.leaveCallback();
191 this._currentZone = undefined;
192 if (this._tooltipTimeout) {
193 clearTimeout(this._tooltipTimeout);
198 private _onClick(e: MouseEvent): void {
199 // Find the active zone and click it if found and no selection was
201 const zone = this._findZoneEventAt(e);
202 const currentSelectionLength = this._getSelectionLength();
204 if (zone && currentSelectionLength === this._initialSelectionLength) {
205 zone.clickCallback(e);
207 e.stopImmediatePropagation();
211 private _getSelectionLength(): number {
212 const selectionText = this._selectionService.selectionText;
213 return selectionText ? selectionText.length : 0;
216 private _findZoneEventAt(e: MouseEvent): IMouseZone | undefined {
217 const coords = this._mouseService.getCoords(e, this._screenElement, this._bufferService.cols, this._bufferService.rows);
223 for (let i = 0; i < this._zones.length; i++) {
224 const zone = this._zones[i];
225 if (zone.y1 === zone.y2) {
227 if (y === zone.y1 && x >= zone.x1 && x < zone.x2) {
232 if ((y === zone.y1 && x >= zone.x1) ||
233 (y === zone.y2 && x < zone.x2) ||
234 (y > zone.y1 && y < zone.y2)) {