327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
|
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import { addDisposableListener } from 'browser/Dom';
|
|
import { IBufferService, ICoreMouseService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
|
|
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, IDisposable } from 'common/Types';
|
|
import { C0 } from 'common/data/EscapeSequences';
|
|
import { toDisposable } from 'common/Lifecycle';
|
|
import { CustomWheelEventHandler } from 'browser/Types';
|
|
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
|
|
|
|
type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>;
|
|
|
|
interface IMouseBindContext {
|
|
readonly target: IMouseServiceTarget;
|
|
readonly focus: () => void;
|
|
readonly requestedEvents: RequestedMouseEvents;
|
|
}
|
|
|
|
export class MouseService implements IMouseService {
|
|
public serviceBrand: undefined;
|
|
|
|
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
|
|
|
|
constructor(
|
|
@IRenderService private readonly _renderService: IRenderService,
|
|
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
|
|
@ICoreMouseService private readonly _coreMouseService: ICoreMouseService,
|
|
@ICoreService private readonly _coreService: ICoreService,
|
|
@IBufferService private readonly _bufferService: IBufferService,
|
|
@IOptionsService private readonly _optionsService: IOptionsService,
|
|
@ISelectionService private readonly _selectionService: ISelectionService,
|
|
@ILogService private readonly _logService: ILogService,
|
|
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
|
|
) {
|
|
}
|
|
|
|
public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void {
|
|
const { element, document } = target;
|
|
|
|
/**
|
|
* Event listener state handling.
|
|
* We listen to the onProtocolChange event of CoreMouseService and put
|
|
* requested listeners in `requestedEvents`. With this the listeners
|
|
* have all bits to do the event listener juggling.
|
|
* Note: 'mousedown' currently is "always on" and not managed
|
|
* by onProtocolChange.
|
|
*/
|
|
const requestedEvents: RequestedMouseEvents = {
|
|
mouseup: null,
|
|
wheel: null,
|
|
mousedrag: null,
|
|
mousemove: null
|
|
};
|
|
const ctx: IMouseBindContext = { target, focus, requestedEvents };
|
|
const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
|
|
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
|
|
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
|
|
mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent),
|
|
mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent)
|
|
};
|
|
register(this._coreMouseService.onProtocolChange(events => {
|
|
this._handleProtocolChange(ctx, eventListeners, events);
|
|
}));
|
|
// force initial onProtocolChange so we dont miss early mouse requests
|
|
this._coreMouseService.activeProtocol = this._coreMouseService.activeProtocol;
|
|
|
|
// Ensure document-level listeners are removed on dispose
|
|
register(toDisposable(() => {
|
|
if (requestedEvents.mouseup) {
|
|
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
|
}
|
|
if (requestedEvents.mousedrag) {
|
|
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
|
}
|
|
}));
|
|
|
|
/**
|
|
* "Always on" event listeners.
|
|
*/
|
|
register(addDisposableListener(element, 'mousedown', (ev: MouseEvent) => this._handleMouseDown(ctx, ev)));
|
|
register(addDisposableListener(element, 'wheel', (ev: WheelEvent) => this._handlePassiveWheel(ctx, ev), { passive: false }));
|
|
}
|
|
|
|
private _sendEvent(ctx: IMouseBindContext, ev: MouseEvent | WheelEvent): boolean {
|
|
// Get mouse coordinates
|
|
const pos = this._mouseCoordsService.getMouseReportCoords(ev as MouseEvent, ctx.target.screenElement);
|
|
if (!pos) {
|
|
return false;
|
|
}
|
|
|
|
let but: CoreMouseButton;
|
|
let action: CoreMouseAction | undefined;
|
|
switch ((ev as MouseEvent & { overrideType?: string }).overrideType || ev.type) {
|
|
case 'mousemove':
|
|
action = CoreMouseAction.MOVE;
|
|
if (ev.buttons === undefined) {
|
|
// buttons is not supported on macOS, try to get a value from button instead
|
|
but = CoreMouseButton.NONE;
|
|
if (ev.button !== undefined) {
|
|
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
}
|
|
} else {
|
|
// according to MDN buttons only reports up to button 5 (AUX2)
|
|
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
|
|
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
|
|
ev.buttons & 2 ? CoreMouseButton.RIGHT :
|
|
CoreMouseButton.NONE; // fallback to NONE
|
|
}
|
|
break;
|
|
case 'mouseup':
|
|
action = CoreMouseAction.UP;
|
|
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
break;
|
|
case 'mousedown':
|
|
action = CoreMouseAction.DOWN;
|
|
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
break;
|
|
case 'wheel':
|
|
if (this._customWheelEventHandler && this._customWheelEventHandler(ev as WheelEvent) === false) {
|
|
return false;
|
|
}
|
|
const deltaY = (ev as WheelEvent).deltaY;
|
|
if (deltaY === 0) {
|
|
return false;
|
|
}
|
|
const lines = this._coreMouseService.consumeWheelEvent(
|
|
ev as WheelEvent,
|
|
this._renderService?.dimensions?.device?.cell?.height,
|
|
this._coreBrowserService?.dpr
|
|
);
|
|
if (lines === 0) {
|
|
return false;
|
|
}
|
|
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
|
|
but = CoreMouseButton.WHEEL;
|
|
break;
|
|
default:
|
|
// dont handle other event types by accident
|
|
return false;
|
|
}
|
|
|
|
// exit if we cannot determine valid button/action values
|
|
// do nothing for higher buttons than wheel
|
|
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
|
|
return false;
|
|
}
|
|
|
|
return this._coreMouseService.triggerMouseEvent({
|
|
col: pos.col,
|
|
row: pos.row,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
button: but,
|
|
action,
|
|
ctrl: ev.ctrlKey,
|
|
alt: ev.altKey,
|
|
shift: ev.shiftKey
|
|
});
|
|
}
|
|
|
|
private _handleMouseUp(ctx: IMouseBindContext, ev: MouseEvent): void {
|
|
this._sendEvent(ctx, ev);
|
|
if (!ev.buttons) {
|
|
// if no other button is held remove global handlers
|
|
if (ctx.requestedEvents.mouseup) {
|
|
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
|
|
}
|
|
if (ctx.requestedEvents.mousedrag) {
|
|
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
|
}
|
|
}
|
|
}
|
|
|
|
private _handleWheel(ctx: IMouseBindContext, ev: WheelEvent): false {
|
|
this._sendEvent(ctx, ev);
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
return false;
|
|
}
|
|
|
|
private _handleMouseDrag(ctx: IMouseBindContext, ev: MouseEvent): void {
|
|
// deal only with move while a button is held
|
|
if (ev.buttons) {
|
|
this._sendEvent(ctx, ev);
|
|
}
|
|
}
|
|
|
|
private _handleMouseMove(ctx: IMouseBindContext, ev: MouseEvent): void {
|
|
// deal only with move without any button
|
|
if (!ev.buttons) {
|
|
this._sendEvent(ctx, ev);
|
|
}
|
|
}
|
|
|
|
private _handleMouseDown(ctx: IMouseBindContext, ev: MouseEvent): void {
|
|
ev.preventDefault();
|
|
ctx.focus();
|
|
|
|
// Don't send the mouse button to the pty if mouse events are disabled or
|
|
// if the selection manager is having selection forced (ie. a modifier is
|
|
// held).
|
|
if (!this._coreMouseService.areMouseEventsActive || this._selectionService.shouldForceSelection(ev)) {
|
|
return;
|
|
}
|
|
|
|
this._sendEvent(ctx, ev);
|
|
|
|
// Register additional global handlers which should keep reporting outside
|
|
// of the terminal element.
|
|
// Note: Other emulators also do this for 'mousedown' while a button
|
|
// is held, we currently limit 'mousedown' to the terminal only.
|
|
if (ctx.requestedEvents.mouseup) {
|
|
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
|
|
}
|
|
if (ctx.requestedEvents.mousedrag) {
|
|
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
|
}
|
|
}
|
|
|
|
private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void {
|
|
// do nothing, if app side handles wheel itself
|
|
if (ctx.requestedEvents.wheel) {
|
|
return;
|
|
}
|
|
|
|
if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) {
|
|
return false;
|
|
}
|
|
|
|
if (!this._bufferService.buffer.hasScrollback) {
|
|
// Convert wheel events into up/down events when the buffer does not have scrollback, this
|
|
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
|
|
// events are not enabled.
|
|
// This used implementation used get the actual lines/partial lines scrolled from the
|
|
// viewport but since moving to the new viewport implementation has been simplified to
|
|
// simply send a single up or down sequence.
|
|
|
|
// Do nothing if there's no vertical scroll
|
|
const deltaY = ev.deltaY;
|
|
if (deltaY === 0) {
|
|
return false;
|
|
}
|
|
|
|
const lines = this._coreMouseService.consumeWheelEvent(
|
|
ev,
|
|
this._renderService?.dimensions?.device?.cell?.height,
|
|
this._coreBrowserService?.dpr
|
|
);
|
|
if (lines === 0) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
return false;
|
|
}
|
|
|
|
// Construct and send sequences
|
|
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
|
|
this._coreService.triggerDataEvent(sequence, true);
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
|
|
const { element, document } = ctx.target;
|
|
const { requestedEvents } = ctx;
|
|
// apply global changes on events
|
|
if (events) {
|
|
if (this._optionsService.rawOptions.logLevel === 'debug') {
|
|
this._logService.debug('Binding to mouse events:', this._coreMouseService.explainEvents(events));
|
|
}
|
|
element.classList.add('enable-mouse-events');
|
|
this._selectionService.disable();
|
|
} else {
|
|
this._logService.debug('Unbinding from mouse events.');
|
|
element.classList.remove('enable-mouse-events');
|
|
this._selectionService.enable();
|
|
}
|
|
|
|
// add/remove handlers from requestedEvents
|
|
if (!(events & CoreMouseEventType.MOVE)) {
|
|
if (requestedEvents.mousemove) {
|
|
element.removeEventListener('mousemove', requestedEvents.mousemove);
|
|
}
|
|
requestedEvents.mousemove = null;
|
|
} else if (!requestedEvents.mousemove) {
|
|
element.addEventListener('mousemove', eventListeners.mousemove);
|
|
requestedEvents.mousemove = eventListeners.mousemove;
|
|
}
|
|
|
|
if (!(events & CoreMouseEventType.WHEEL)) {
|
|
if (requestedEvents.wheel) {
|
|
element.removeEventListener('wheel', requestedEvents.wheel);
|
|
}
|
|
requestedEvents.wheel = null;
|
|
} else if (!requestedEvents.wheel) {
|
|
element.addEventListener('wheel', eventListeners.wheel, { passive: false });
|
|
requestedEvents.wheel = eventListeners.wheel;
|
|
}
|
|
|
|
if (!(events & CoreMouseEventType.UP)) {
|
|
if (requestedEvents.mouseup) {
|
|
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
|
}
|
|
requestedEvents.mouseup = null;
|
|
} else {
|
|
requestedEvents.mouseup ??= eventListeners.mouseup;
|
|
}
|
|
|
|
if (!(events & CoreMouseEventType.DRAG)) {
|
|
if (requestedEvents.mousedrag) {
|
|
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
|
}
|
|
requestedEvents.mousedrag = null;
|
|
} else {
|
|
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
|
}
|
|
}
|
|
|
|
public setCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler | undefined): void {
|
|
this._customWheelEventHandler = customWheelEventHandler;
|
|
}
|
|
}
|