Fixes https://github.com/xtermjs/xterm.js/issues/5896 TypeScript path mappings for common/*, browser/*, and headless/* were not rewritten in emitted JavaScript, which forced webpack resolve aliases and NODE_PATH workarounds so runtime could resolve modules. That added build complexity and broke tooling such as ESLint import/no-cycle. Changes: - Convert ~750 imports across src/, addons/, and test/ to relative paths - Remove common/browser/headless paths and src catch-all mappings from tsconfigs (keep @xterm/addon-* demo and addon typings paths) - Remove webpack resolve.alias for common, browser, headless, and vs - Drop NODE_PATH from unit tests, benchmarks, and VS Code launch config - Fix addon-image IIPImageStorage to import ./Types after removing catch-all * Verified: npm run build, esbuild, package, package-headless Co-authored-by: Cursor <cursoragent@cursor.com>
486 lines
14 KiB
TypeScript
486 lines
14 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as DomUtils from '../Dom';
|
|
import { Disposable, IDisposable, toDisposable } from '../../common/Lifecycle';
|
|
|
|
const mainWindow = (typeof window === 'object' ? window : globalThis) as Window & typeof globalThis;
|
|
|
|
function tail<T>(array: ArrayLike<T>, n: number = 0): T | undefined {
|
|
return array[array.length - (1 + n)];
|
|
}
|
|
|
|
function memoize(_target: any, key: string, descriptor: PropertyDescriptor): void {
|
|
let fnKey: string | null = null;
|
|
let fn: Function | null = null;
|
|
|
|
if (typeof descriptor.value === 'function') {
|
|
fnKey = 'value';
|
|
fn = descriptor.value;
|
|
|
|
if (fn!.length !== 0) {
|
|
console.warn('Memoize should only be used in functions with zero parameters');
|
|
}
|
|
} else if (typeof descriptor.get === 'function') {
|
|
fnKey = 'get';
|
|
fn = descriptor.get;
|
|
}
|
|
|
|
if (!fn || !fnKey) {
|
|
throw new Error('not supported');
|
|
}
|
|
|
|
const memoizeKey = `$memoize$${key}`;
|
|
const descriptorAny = descriptor as { [key: string]: any };
|
|
descriptorAny[fnKey] = function (...args: any[]) {
|
|
if (!this.hasOwnProperty(memoizeKey)) {
|
|
Object.defineProperty(this, memoizeKey, {
|
|
configurable: false,
|
|
enumerable: false,
|
|
writable: false,
|
|
value: fn.apply(this, args)
|
|
});
|
|
}
|
|
|
|
return (this as { [key: string]: any })[memoizeKey];
|
|
};
|
|
}
|
|
|
|
class LinkedListNode<E> {
|
|
|
|
public static readonly Undefined = new LinkedListNode<any>(undefined);
|
|
|
|
public element: E;
|
|
public next: LinkedListNode<E>;
|
|
public prev: LinkedListNode<E>;
|
|
|
|
public constructor(element: E) {
|
|
this.element = element;
|
|
this.next = LinkedListNode.Undefined;
|
|
this.prev = LinkedListNode.Undefined;
|
|
}
|
|
}
|
|
|
|
class LinkedList<E> {
|
|
|
|
private _first: LinkedListNode<E> = LinkedListNode.Undefined;
|
|
private _last: LinkedListNode<E> = LinkedListNode.Undefined;
|
|
|
|
public push(element: E): () => void {
|
|
return this._insert(element, true);
|
|
}
|
|
|
|
private _insert(element: E, atTheEnd: boolean): () => void {
|
|
const newNode = new LinkedListNode(element);
|
|
if (this._first === LinkedListNode.Undefined) {
|
|
this._first = newNode;
|
|
this._last = newNode;
|
|
|
|
} else if (atTheEnd) {
|
|
const oldLast = this._last;
|
|
this._last = newNode;
|
|
newNode.prev = oldLast;
|
|
oldLast.next = newNode;
|
|
|
|
} else {
|
|
const oldFirst = this._first;
|
|
this._first = newNode;
|
|
newNode.next = oldFirst;
|
|
oldFirst.prev = newNode;
|
|
}
|
|
let didRemove = false;
|
|
return () => {
|
|
if (!didRemove) {
|
|
didRemove = true;
|
|
this._remove(newNode);
|
|
}
|
|
};
|
|
}
|
|
|
|
private _remove(node: LinkedListNode<E>): void {
|
|
if (node.prev !== LinkedListNode.Undefined && node.next !== LinkedListNode.Undefined) {
|
|
const anchor = node.prev;
|
|
anchor.next = node.next;
|
|
node.next.prev = anchor;
|
|
|
|
} else if (node.prev === LinkedListNode.Undefined && node.next === LinkedListNode.Undefined) {
|
|
this._first = LinkedListNode.Undefined;
|
|
this._last = LinkedListNode.Undefined;
|
|
|
|
} else if (node.next === LinkedListNode.Undefined) {
|
|
this._last = this._last.prev!;
|
|
this._last.next = LinkedListNode.Undefined;
|
|
|
|
} else if (node.prev === LinkedListNode.Undefined) {
|
|
this._first = this._first.next!;
|
|
this._first.prev = LinkedListNode.Undefined;
|
|
}
|
|
}
|
|
|
|
public *[Symbol.iterator](): Iterator<E> {
|
|
let node = this._first;
|
|
while (node !== LinkedListNode.Undefined) {
|
|
yield node.element;
|
|
node = node.next;
|
|
}
|
|
}
|
|
}
|
|
|
|
export namespace EventType {
|
|
export const TAP = '-xterm-gesturetap';
|
|
export const CHANGE = '-xterm-gesturechange';
|
|
export const START = '-xterm-gesturestart';
|
|
export const END = '-xterm-gesturesend';
|
|
export const CONTEXT_MENU = '-xterm-gesturecontextmenu';
|
|
}
|
|
|
|
interface ITouchData {
|
|
id: number;
|
|
initialTarget: EventTarget;
|
|
initialTimeStamp: number;
|
|
initialPageX: number;
|
|
initialPageY: number;
|
|
rollingTimestamps: number[];
|
|
rollingPageX: number[];
|
|
rollingPageY: number[];
|
|
}
|
|
|
|
export interface IGestureEvent extends MouseEvent {
|
|
initialTarget: EventTarget | undefined;
|
|
translationX: number;
|
|
translationY: number;
|
|
pageX: number;
|
|
pageY: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
tapCount: number;
|
|
}
|
|
|
|
interface ITouch {
|
|
identifier: number;
|
|
screenX: number;
|
|
screenY: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
pageX: number;
|
|
pageY: number;
|
|
radiusX: number;
|
|
radiusY: number;
|
|
rotationAngle: number;
|
|
force: number;
|
|
target: Element;
|
|
}
|
|
|
|
interface ITouchList {
|
|
[i: number]: ITouch;
|
|
length: number;
|
|
item(index: number): ITouch;
|
|
identifiedTouch(id: number): ITouch;
|
|
}
|
|
|
|
interface ITouchEvent extends Event {
|
|
touches: ITouchList;
|
|
targetTouches: ITouchList;
|
|
changedTouches: ITouchList;
|
|
}
|
|
|
|
export class Gesture extends Disposable {
|
|
|
|
private static readonly _scrollFriction = -0.005;
|
|
private static _instance: Gesture;
|
|
private static readonly _holdDelay = 700;
|
|
|
|
private _dispatched = false;
|
|
private readonly _targets = new LinkedList<HTMLElement>();
|
|
private readonly _ignoreTargets = new LinkedList<HTMLElement>();
|
|
private _handle: IDisposable | null;
|
|
|
|
private readonly _activeTouches: { [id: number]: ITouchData };
|
|
|
|
private _lastSetTapCountTime: number;
|
|
|
|
private static readonly _clearTapCountTime = 400; // ms
|
|
|
|
|
|
private constructor() {
|
|
super();
|
|
|
|
this._activeTouches = {};
|
|
this._handle = null;
|
|
this._lastSetTapCountTime = 0;
|
|
|
|
const targetWindow = mainWindow;
|
|
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchstart', (e: ITouchEvent) => this._handleTouchStart(e), { passive: false }));
|
|
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchend', (e: ITouchEvent) => this._handleTouchEnd(targetWindow, e)));
|
|
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchmove', (e: ITouchEvent) => this._handleTouchMove(e), { passive: false }));
|
|
}
|
|
|
|
public static addTarget(element: HTMLElement): IDisposable {
|
|
if (!Gesture.isTouchDevice()) {
|
|
return Disposable.None;
|
|
}
|
|
if (!Gesture._instance) {
|
|
Gesture._instance = new Gesture();
|
|
}
|
|
|
|
const remove = Gesture._instance._targets.push(element);
|
|
return toDisposable(remove);
|
|
}
|
|
|
|
public static ignoreTarget(element: HTMLElement): IDisposable {
|
|
if (!Gesture.isTouchDevice()) {
|
|
return Disposable.None;
|
|
}
|
|
if (!Gesture._instance) {
|
|
Gesture._instance = new Gesture();
|
|
}
|
|
|
|
const remove = Gesture._instance._ignoreTargets.push(element);
|
|
return toDisposable(remove);
|
|
}
|
|
|
|
@memoize
|
|
public static isTouchDevice(): boolean {
|
|
return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0;
|
|
}
|
|
|
|
public override dispose(): void {
|
|
if (this._handle) {
|
|
this._handle.dispose();
|
|
this._handle = null;
|
|
}
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
private _handleTouchStart(e: ITouchEvent): void {
|
|
const timestamp = Date.now();
|
|
|
|
if (this._handle) {
|
|
this._handle.dispose();
|
|
this._handle = null;
|
|
}
|
|
|
|
for (let i = 0, len = e.targetTouches.length; i < len; i++) {
|
|
const touch = e.targetTouches.item(i);
|
|
|
|
this._activeTouches[touch.identifier] = {
|
|
id: touch.identifier,
|
|
initialTarget: touch.target,
|
|
initialTimeStamp: timestamp,
|
|
initialPageX: touch.pageX,
|
|
initialPageY: touch.pageY,
|
|
rollingTimestamps: [timestamp],
|
|
rollingPageX: [touch.pageX],
|
|
rollingPageY: [touch.pageY]
|
|
};
|
|
|
|
const evt = this._newGestureEvent(EventType.START, touch.target);
|
|
evt.pageX = touch.pageX;
|
|
evt.pageY = touch.pageY;
|
|
this._dispatchEvent(evt);
|
|
}
|
|
|
|
if (this._dispatched) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._dispatched = false;
|
|
}
|
|
}
|
|
|
|
private _handleTouchEnd(targetWindow: Window, e: ITouchEvent): void {
|
|
const timestamp = Date.now();
|
|
|
|
const activeTouchCount = Object.keys(this._activeTouches).length;
|
|
|
|
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
|
|
|
|
const touch = e.changedTouches.item(i);
|
|
|
|
if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) {
|
|
console.warn('move of an UNKNOWN touch', touch);
|
|
continue;
|
|
}
|
|
|
|
const data = this._activeTouches[touch.identifier];
|
|
const holdTime = Date.now() - data.initialTimeStamp;
|
|
|
|
if (holdTime < Gesture._holdDelay
|
|
&& Math.abs(data.initialPageX - tail(data.rollingPageX)!) < 30
|
|
&& Math.abs(data.initialPageY - tail(data.rollingPageY)!) < 30) {
|
|
|
|
const evt = this._newGestureEvent(EventType.TAP, data.initialTarget);
|
|
evt.pageX = tail(data.rollingPageX)!;
|
|
evt.pageY = tail(data.rollingPageY)!;
|
|
this._dispatchEvent(evt);
|
|
|
|
} else if (holdTime >= Gesture._holdDelay
|
|
&& Math.abs(data.initialPageX - tail(data.rollingPageX)!) < 30
|
|
&& Math.abs(data.initialPageY - tail(data.rollingPageY)!) < 30) {
|
|
|
|
const evt = this._newGestureEvent(EventType.CONTEXT_MENU, data.initialTarget);
|
|
evt.pageX = tail(data.rollingPageX)!;
|
|
evt.pageY = tail(data.rollingPageY)!;
|
|
this._dispatchEvent(evt);
|
|
|
|
} else if (activeTouchCount === 1) {
|
|
const finalX = tail(data.rollingPageX)!;
|
|
const finalY = tail(data.rollingPageY)!;
|
|
|
|
const deltaT = tail(data.rollingTimestamps)! - data.rollingTimestamps[0];
|
|
const deltaX = finalX - data.rollingPageX[0];
|
|
const deltaY = finalY - data.rollingPageY[0];
|
|
|
|
const dispatchTo = [...this._targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget));
|
|
this._inertia(targetWindow, dispatchTo, timestamp,
|
|
Math.abs(deltaX) / deltaT,
|
|
deltaX > 0 ? 1 : -1,
|
|
finalX,
|
|
Math.abs(deltaY) / deltaT,
|
|
deltaY > 0 ? 1 : -1,
|
|
finalY
|
|
);
|
|
}
|
|
|
|
|
|
this._dispatchEvent(this._newGestureEvent(EventType.END, data.initialTarget));
|
|
delete this._activeTouches[touch.identifier];
|
|
}
|
|
|
|
if (this._dispatched) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._dispatched = false;
|
|
}
|
|
}
|
|
|
|
private _newGestureEvent(type: string, initialTarget?: EventTarget): IGestureEvent {
|
|
const event = document.createEvent('CustomEvent') as unknown as IGestureEvent;
|
|
event.initEvent(type, false, true);
|
|
event.initialTarget = initialTarget;
|
|
event.tapCount = 0;
|
|
return event;
|
|
}
|
|
|
|
private _dispatchEvent(event: IGestureEvent): void {
|
|
if (event.type === EventType.TAP) {
|
|
const currentTime = (new Date()).getTime();
|
|
let setTapCount;
|
|
if (currentTime - this._lastSetTapCountTime > Gesture._clearTapCountTime) {
|
|
setTapCount = 1;
|
|
} else {
|
|
setTapCount = 2;
|
|
}
|
|
|
|
this._lastSetTapCountTime = currentTime;
|
|
event.tapCount = setTapCount;
|
|
} else if (event.type === EventType.CHANGE || event.type === EventType.CONTEXT_MENU) {
|
|
this._lastSetTapCountTime = 0;
|
|
}
|
|
|
|
if (event.initialTarget instanceof Node) {
|
|
for (const ignoreTarget of this._ignoreTargets) {
|
|
if (ignoreTarget.contains(event.initialTarget)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const targets: [number, HTMLElement][] = [];
|
|
for (const target of this._targets) {
|
|
if (target.contains(event.initialTarget)) {
|
|
let depth = 0;
|
|
let now: Node | null = event.initialTarget;
|
|
while (now && now !== target) {
|
|
depth++;
|
|
now = now.parentElement;
|
|
}
|
|
targets.push([depth, target]);
|
|
}
|
|
}
|
|
|
|
targets.sort((a, b) => a[0] - b[0]);
|
|
|
|
for (const [, target] of targets) {
|
|
target.dispatchEvent(event);
|
|
this._dispatched = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private _inertia(targetWindow: Window, dispatchTo: ReadonlyArray<EventTarget>, t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void {
|
|
this._handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => {
|
|
const now = Date.now();
|
|
|
|
const deltaT = now - t1;
|
|
let deltaPosX = 0;
|
|
let deltaPosY = 0;
|
|
let stopped = true;
|
|
|
|
vX += Gesture._scrollFriction * deltaT;
|
|
vY += Gesture._scrollFriction * deltaT;
|
|
|
|
if (vX > 0) {
|
|
stopped = false;
|
|
deltaPosX = dirX * vX * deltaT;
|
|
}
|
|
|
|
if (vY > 0) {
|
|
stopped = false;
|
|
deltaPosY = dirY * vY * deltaT;
|
|
}
|
|
|
|
const evt = this._newGestureEvent(EventType.CHANGE);
|
|
evt.translationX = deltaPosX;
|
|
evt.translationY = deltaPosY;
|
|
dispatchTo.forEach(d => d.dispatchEvent(evt));
|
|
|
|
if (!stopped) {
|
|
this._inertia(targetWindow, dispatchTo, now, vX, dirX, x + deltaPosX, vY, dirY, y + deltaPosY);
|
|
}
|
|
});
|
|
}
|
|
|
|
private _handleTouchMove(e: ITouchEvent): void {
|
|
const timestamp = Date.now();
|
|
|
|
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
|
|
|
|
const touch = e.changedTouches.item(i);
|
|
|
|
if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) {
|
|
console.warn('end of an UNKNOWN touch', touch);
|
|
continue;
|
|
}
|
|
|
|
const data = this._activeTouches[touch.identifier];
|
|
|
|
const evt = this._newGestureEvent(EventType.CHANGE, data.initialTarget);
|
|
evt.translationX = touch.pageX - tail(data.rollingPageX)!;
|
|
evt.translationY = touch.pageY - tail(data.rollingPageY)!;
|
|
evt.pageX = touch.pageX;
|
|
evt.pageY = touch.pageY;
|
|
evt.clientX = touch.clientX;
|
|
evt.clientY = touch.clientY;
|
|
this._dispatchEvent(evt);
|
|
|
|
if (data.rollingPageX.length > 3) {
|
|
data.rollingPageX.shift();
|
|
data.rollingPageY.shift();
|
|
data.rollingTimestamps.shift();
|
|
}
|
|
|
|
data.rollingPageX.push(touch.pageX);
|
|
data.rollingPageY.push(touch.pageY);
|
|
data.rollingTimestamps.push(timestamp);
|
|
}
|
|
|
|
if (this._dispatched) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._dispatched = false;
|
|
}
|
|
}
|
|
}
|