Files
xterm.js/src/browser/scrollable/touch.ts
T
Daniel ImmsandCursor 17ad33dc3d Remove TypeScript path mappings in favor of relative imports
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>
2026-05-31 09:16:10 -07:00

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;
}
}
}