diff --git a/src/browser/touch.ts b/src/browser/touch.ts new file mode 100644 index 00000000..6d940c2f --- /dev/null +++ b/src/browser/touch.ts @@ -0,0 +1,385 @@ +import { Disposable } from "../helpers/disposable"; +import { tail } from "../helpers/tail"; + +export namespace EventType { + export const Tap = "-allotment-gesturetap"; + export const Change = "-allotment-gesturechange"; + export const Start = "-allotment-gesturestart"; + export const End = "-allotment-gesturesend"; + export const Contextmenu = "-allotment-gesturecontextmenu"; +} + +interface TouchData { + id: number; + initialTarget: EventTarget; + initialTimeStamp: number; + initialPageX: number; + initialPageY: number; + rollingTimestamps: number[]; + rollingPageX: number[]; + rollingPageY: number[]; +} + +export interface GestureEvent extends MouseEvent { + initialTarget: EventTarget | undefined; + translationX: number; + translationY: number; + pageX: number; + pageY: number; + tapCount: number; +} + +export class Gesture implements Disposable { + private static readonly SCROLL_FRICTION = -0.005; + private static INSTANCE: Gesture; + private static readonly HOLD_DELAY = 700; + + private dispatched = false; + private targets: HTMLElement[]; + private ignoreTargets: HTMLElement[]; + private handle: number | null; + + private activeTouches: { [id: number]: TouchData }; + + private _lastSetTapCountTime: number; + + private static readonly CLEAR_TAP_COUNT_TIME = 400; // ms + + private constructor() { + this.activeTouches = {}; + this.handle = null; + this.targets = []; + this.ignoreTargets = []; + this._lastSetTapCountTime = 0; + + document.addEventListener( + "touchstart", + (e: TouchEvent) => this.onTouchStart(e), + { passive: false } + ); + + document.addEventListener("touchend", (e: TouchEvent) => + this.onTouchEnd(e) + ); + + document.addEventListener( + "touchmove", + (e: TouchEvent) => this.onTouchMove(e), + { passive: false } + ); + } + + public static addTarget(element: HTMLElement): Disposable { + if (!Gesture.isTouchDevice()) { + return Object.freeze({ dispose() {} }); + } + + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = new Gesture(); + } + + Gesture.INSTANCE.targets.push(element); + + return { + dispose: () => { + Gesture.INSTANCE.targets = Gesture.INSTANCE.targets.filter( + (t) => t !== element + ); + }, + }; + } + + public static ignoreTarget(element: HTMLElement): Disposable { + if (!Gesture.isTouchDevice()) { + return Object.freeze({ dispose() {} }); + } + + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = new Gesture(); + } + + Gesture.INSTANCE.ignoreTargets.push(element); + + return { + dispose: () => { + Gesture.INSTANCE.ignoreTargets = Gesture.INSTANCE.ignoreTargets.filter( + (t) => t !== element + ); + }, + }; + } + + static isTouchDevice(): boolean { + // `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be + // `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast + return "ontouchstart" in window || navigator.maxTouchPoints > 0; + } + + public dispose(): void { + if (this.handle) { + cancelAnimationFrame(this.handle); + this.handle = null; + } + + document.removeEventListener("touchstart", this.onTouchStart); + document.removeEventListener("touchend", this.onTouchEnd); + document.removeEventListener("touchmove", () => this.onTouchMove); + } + + private onTouchStart(e: TouchEvent): void { + let timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + if (this.handle) { + cancelAnimationFrame(this.handle); + this.handle = null; + } + + for (let i = 0, len = e.targetTouches.length; i < len; i++) { + let 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], + }; + + let 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 onTouchEnd(e: TouchEvent): void { + let timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + let activeTouchCount = Object.keys(this.activeTouches).length; + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + let touch = e.changedTouches.item(i)!; + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn("move of an UNKNOWN touch", touch); + continue; + } + + let data = this.activeTouches[touch.identifier], + holdTime = Date.now() - data.initialTimeStamp; + + if ( + holdTime < Gesture.HOLD_DELAY && + Math.abs(data.initialPageX - tail(data.rollingPageX)) < 30 && + Math.abs(data.initialPageY - tail(data.rollingPageY)) < 30 + ) { + let evt = this.newGestureEvent(EventType.Tap, data.initialTarget); + evt.pageX = tail(data.rollingPageX); + evt.pageY = tail(data.rollingPageY); + this.dispatchEvent(evt); + } else if ( + holdTime >= Gesture.HOLD_DELAY && + Math.abs(data.initialPageX - tail(data.rollingPageX)) < 30 && + Math.abs(data.initialPageY - tail(data.rollingPageY)) < 30 + ) { + let evt = this.newGestureEvent( + EventType.Contextmenu, + data.initialTarget + ); + evt.pageX = tail(data.rollingPageX); + evt.pageY = tail(data.rollingPageY); + this.dispatchEvent(evt); + } else if (activeTouchCount === 1) { + let finalX = tail(data.rollingPageX); + let finalY = tail(data.rollingPageY); + + let deltaT = tail(data.rollingTimestamps) - data.rollingTimestamps[0]; + let deltaX = finalX - data.rollingPageX[0]; + let deltaY = finalY - data.rollingPageY[0]; + + // We need to get all the dispatch targets on the start of the inertia event + const dispatchTo = this.targets.filter( + (t) => + data.initialTarget instanceof Node && t.contains(data.initialTarget) + ); + this.inertia( + dispatchTo, + timestamp, // time now + Math.abs(deltaX) / deltaT, // speed + deltaX > 0 ? 1 : -1, // x direction + finalX, // x now + Math.abs(deltaY) / deltaT, // y speed + deltaY > 0 ? 1 : -1, // y direction + finalY // y now + ); + } + + this.dispatchEvent( + this.newGestureEvent(EventType.End, data.initialTarget) + ); + // forget about this touch + delete this.activeTouches[touch.identifier]; + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } + + private newGestureEvent( + type: string, + initialTarget?: EventTarget + ): GestureEvent { + let event = document.createEvent("CustomEvent") as unknown as GestureEvent; + event.initEvent(type, false, true); + event.initialTarget = initialTarget; + event.tapCount = 0; + return event; + } + + private dispatchEvent(event: GestureEvent): void { + if (event.type === EventType.Tap) { + const currentTime = new Date().getTime(); + let setTapCount = 0; + if ( + currentTime - this._lastSetTapCountTime > + Gesture.CLEAR_TAP_COUNT_TIME + ) { + setTapCount = 1; + } else { + setTapCount = 2; + } + + this._lastSetTapCountTime = currentTime; + event.tapCount = setTapCount; + } else if ( + event.type === EventType.Change || + event.type === EventType.Contextmenu + ) { + // tap is canceled by scrolling or context menu + this._lastSetTapCountTime = 0; + } + + for (let i = 0; i < this.ignoreTargets.length; i++) { + if ( + event.initialTarget instanceof Node && + this.ignoreTargets[i].contains(event.initialTarget) + ) { + return; + } + } + + this.targets.forEach((target) => { + if ( + event.initialTarget instanceof Node && + target.contains(event.initialTarget) + ) { + target.dispatchEvent(event); + this.dispatched = true; + } + }); + } + + private inertia( + dispatchTo: EventTarget[], + t1: number, + vX: number, + dirX: number, + x: number, + vY: number, + dirY: number, + y: number + ): void { + this.handle = requestAnimationFrame(() => { + let now = Date.now(); + + // velocity: old speed + accel_over_time + let deltaT = now - t1, + delta_pos_x = 0, + delta_pos_y = 0, + stopped = true; + + vX += Gesture.SCROLL_FRICTION * deltaT; + vY += Gesture.SCROLL_FRICTION * deltaT; + + if (vX > 0) { + stopped = false; + delta_pos_x = dirX * vX * deltaT; + } + + if (vY > 0) { + stopped = false; + delta_pos_y = dirY * vY * deltaT; + } + + // dispatch translation event + let evt = this.newGestureEvent(EventType.Change); + evt.translationX = delta_pos_x; + evt.translationY = delta_pos_y; + dispatchTo.forEach((d) => d.dispatchEvent(evt)); + + if (!stopped) { + this.inertia( + dispatchTo, + now, + vX, + dirX, + x + delta_pos_x, + vY, + dirY, + y + delta_pos_y + ); + } + }); + } + + private onTouchMove(e: TouchEvent): void { + let timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based. + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + let touch = e.changedTouches.item(i)!; + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn("end of an UNKNOWN touch", touch); + continue; + } + + let data = this.activeTouches[touch.identifier]; + + let 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; + this.dispatchEvent(evt); + + // only keep a few data points, to average the final speed + 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; + } + } +} diff --git a/src/helpers/tail.ts b/src/helpers/tail.ts new file mode 100644 index 00000000..0f3a367b --- /dev/null +++ b/src/helpers/tail.ts @@ -0,0 +1,8 @@ +/** + * Returns the last element of an array. + * @param array The array. + * @param n Which element from the end (default is zero). + */ +export function tail(array: ArrayLike, n: number = 0): T { + return array[array.length - (1 + n)]; +} diff --git a/src/sash/sash.ts b/src/sash/sash.ts index 4b6599cf..bfea81d1 100644 --- a/src/sash/sash.ts +++ b/src/sash/sash.ts @@ -1,5 +1,6 @@ import EventEmitter from "eventemitter3"; import debounce from "lodash.debounce"; +import { EventType, Gesture, GestureEvent } from "../browser/touch"; import { Disposable } from "../helpers/disposable"; import { isIOS, isMacintosh } from "../helpers/platform"; @@ -58,6 +59,61 @@ export function setGlobalSashSize(size: number): void { onDidChangeGlobalSize.emit("onDidChangeGlobalSize", size); } +export interface EventLike { + preventDefault(): void; + stopPropagation(): void; +} + +interface PointerEvent extends EventLike { + readonly pageX: number; + readonly pageY: number; + readonly altKey: boolean; + readonly target: EventTarget | null; +} +/* +interface PointerEventFactory { + readonly onPointerMove: Event; + readonly onPointerUp: Event; + dispose(): void; +} + +this.el.addEventListener(EventType.Start, onPointerMove); + +window.addEventListener("pointermove", onPointerMove); + +class MouseEventFactory implements PointerEventFactory { + get onPointerMove(): Event { + return this.disposables.add(new DomEmitter(window, "mousemove")).event; + } + + get onPointerUp(): Event { + return this.disposables.add(new DomEmitter(window, "mouseup")).event; + } + + dispose(): void { + window.removeEventListener("pointermove", onPointerMove); + } +} + +class GestureEventFactory implements PointerEventFactory { + private disposables = new DisposableStore(); + + get onPointerMove(): Event { + return this.disposables.add(new DomEmitter(this.el, EventType.Change)) + .event; + } + + get onPointerUp(): Event { + return this.disposables.add(new DomEmitter(this.el, EventType.End)).event; + } + + constructor(private el: HTMLElement) {} + + dispose(): void { + this.el.removeEventListener(EventType.Change); + } +} */ + export interface SashLayoutProvider {} /** A vertical sash layout provider provides position and height for a sash. */ @@ -155,11 +211,38 @@ export class Sash extends EventEmitter implements Disposable { this.el.classList.add(styles.mac); } - this.el.addEventListener("pointerdown", this.onPointerStart); + this.el.addEventListener("mousedown", (e) => this.onPointerStart(e)); this.el.addEventListener("dblclick", this.onPointerDoublePress); this.el.addEventListener("mouseenter", this.onMouseEnter); this.el.addEventListener("mouseleave", this.onMouseLeave); + this.el.addEventListener("touchstart", (e) => this.onPointerStart(e)); + + Gesture.addTarget(this.el); + + /* this.el.addEventListener(EventType.Start, ((e: GestureEvent) => + this.onPointerStart({ + ...e, + target: e.initialTarget ?? null, + })) as EventListener); */ + + /* const onMouseLeave = this._register( + new DomEmitter(this.el, "mouseleave") + ).event; + this._register(onMouseLeave(() => Sash.onMouseLeave(this))); + + const onTouchStart = Event.map( + this._register(new DomEmitter(this.el, EventType.Start)).event, + (e) => ({ ...e, target: e.initialTarget ?? null }) + ); + + this._register( + onTouchStart( + (e) => this.onPointerStart(e, new GestureEventFactory(this.el)), + this + ) + ); */ + if (typeof options.size === "number") { this.size = options.size; @@ -192,10 +275,15 @@ export class Sash extends EventEmitter implements Disposable { this.layout(); } - private onPointerStart = (event: PointerEvent) => { + private onPointerStart = ( + event: PointerEvent + //pointerEventFactory: PointerEventFactory + ) => { const startX = event.pageX; const startY = event.pageY; + console.log(event); + const startEvent: SashEvent = { startX, currentX: startX, @@ -207,7 +295,7 @@ export class Sash extends EventEmitter implements Disposable { this.emit("start", startEvent); - this.el.setPointerCapture(event.pointerId); + //this.el.setPointerCapture(event.pointerId); const onPointerMove = (event: PointerEvent) => { event.preventDefault(); @@ -229,12 +317,16 @@ export class Sash extends EventEmitter implements Disposable { this.emit("end"); - this.el.releasePointerCapture(event.pointerId); + //this.el.releasePointerCapture(event.pointerId); + + //this.el.removeEventListener(EventType.Change, onPointerMove); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); }; + //this.el.addEventListener(EventType.Start, onPointerMove); + window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); }; @@ -297,7 +389,7 @@ export class Sash extends EventEmitter implements Disposable { } public dispose(): void { - this.el.removeEventListener("pointerdown", this.onPointerStart); + this.el.removeEventListener("mousedown", this.onPointerStart); this.el.removeEventListener("dblclick", this.onPointerDoublePress); this.el.removeEventListener("mouseenter", this.onMouseEnter); this.el.removeEventListener("mouseleave", () => this.onMouseLeave);