Skip to content

Commit

Permalink
initial touch support
Browse files Browse the repository at this point in the history
  • Loading branch information
nk-coding committed Dec 10, 2024
1 parent f82baae commit 8093575
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 31 deletions.
3 changes: 3 additions & 0 deletions packages/sprotty/src/base/di.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { CssClassPostprocessor } from "./views/css-class-postprocessor";
import { SetModelCommand } from "./features/set-model";
import { UIExtensionRegistry, SetUIExtensionVisibilityCommand } from "./ui-extensions/ui-extension-registry";
import { DefaultDiagramLocker } from "./actions/diagram-locker";
import { TouchTool } from "./views/touch-tool";

const defaultContainerModule = new ContainerModule((bind, _unbind, isBound) => {
// Logging ---------------------------------------------
Expand Down Expand Up @@ -134,6 +135,8 @@ const defaultContainerModule = new ContainerModule((bind, _unbind, isBound) => {
bind(TYPES.HiddenVNodePostprocessor).toService(CssClassPostprocessor);
bind(MouseTool).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(MouseTool);
bind(TouchTool).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(TouchTool);
bind(KeyTool).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(KeyTool);
bind(FocusFixPostprocessor).toSelf().inSingletonScope();
Expand Down
1 change: 1 addition & 0 deletions packages/sprotty/src/base/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const TYPES = {
ISnapper: Symbol('ISnapper'),
SvgExporter: Symbol('SvgExporter'),
ISvgExportPostprocessor: Symbol('ISvgExportPostprocessor'),
ITouchListener: Symbol('ITouchListener'),
IUIExtension: Symbol('IUIExtension'),
UIExtensionRegistry: Symbol('UIExtensionRegistry'),
IVNodePostprocessor: Symbol('IVNodePostprocessor'),
Expand Down
131 changes: 131 additions & 0 deletions packages/sprotty/src/base/views/touch-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/********************************************************************************
* Copyright (c) 2024 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable, multiInject, optional } from "inversify";
import { VNode } from "snabbdom";
import { Action, isAction } from "sprotty-protocol/lib/actions";
import { IActionDispatcher } from "../actions/action-dispatcher";
import { SModelElementImpl, SModelRootImpl } from "../model/smodel";
import { TYPES } from "../types";
import { DOMHelper } from "./dom-helper";
import { IVNodePostprocessor } from "./vnode-postprocessor";
import { on } from "./vnode-utils";

@injectable()
export class TouchTool implements IVNodePostprocessor {
@inject(TYPES.IActionDispatcher) protected actionDispatcher: IActionDispatcher;
@inject(TYPES.DOMHelper) protected domHelper: DOMHelper;

constructor(@multiInject(TYPES.ITouchListener) @optional() protected touchListeners: ITouchListener[] = []) { }

register(mouseListener: ITouchListener) {
this.touchListeners.push(mouseListener);
}

deregister(mouseListener: ITouchListener) {
const index = this.touchListeners.indexOf(mouseListener);
if (index >= 0)
this.touchListeners.splice(index, 1);
}

protected getTargetElement(model: SModelRootImpl, event: TouchEvent): SModelElementImpl | undefined {
let target = event.target as Element;
const index = model.index;
while (target) {
if (target.id) {
const element = index.getById(this.domHelper.findSModelIdByDOMElement(target));
if (element !== undefined)
return element;
}
target = target.parentNode as Element;
}
return undefined;
}

protected handleEvent(methodName: TouchEventKind, model: SModelRootImpl, event: TouchEvent) {
const element = this.getTargetElement(model, event);
if (!element)
return;
const actions = this.touchListeners
.map(listener => listener[methodName](element, event))
.reduce((a, b) => a.concat(b));
if (actions.length > 0) {
event.preventDefault();
for (const actionOrPromise of actions) {
if (isAction(actionOrPromise)) {
this.actionDispatcher.dispatch(actionOrPromise);
} else {
actionOrPromise.then((action: Action) => {
this.actionDispatcher.dispatch(action);
});
}
}
}
}

touchStart(model: SModelRootImpl, event: TouchEvent) {
this.handleEvent('touchStart', model, event)
}

touchMove(model: SModelRootImpl, event: TouchEvent) {
this.handleEvent('touchMove', model, event)
}

touchEnd(model: SModelRootImpl, event: TouchEvent) {
this.handleEvent('touchEnd', model, event)
}

decorate(vnode: VNode, element: SModelElementImpl): VNode {
if (element instanceof SModelRootImpl) {
on(vnode, 'touchstart', this.touchStart.bind(this, element));
on(vnode, 'touchmove', this.touchMove.bind(this, element));
on(vnode, 'touchend', this.touchEnd.bind(this, element));
}
return vnode;
}

postUpdate() {
}
}

export type TouchEventKind = 'touchStart' | 'touchMove' | 'touchEnd';

export interface ITouchListener {

touchStart(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[]

touchMove(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[]

touchEnd(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[]

}

@injectable()
export class TouchListener implements TouchListener {

touchStart(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[] {
return [];
}

touchMove(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[] {
return [];
}

touchEnd(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[] {
return [];
}

}
1 change: 1 addition & 0 deletions packages/sprotty/src/features/viewport/di.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const viewportModule = new ContainerModule((bind , _unbind, isBound) => {
bind(ScrollMouseListener).toSelf().inSingletonScope();
bind(ZoomMouseListener).toSelf().inSingletonScope();
bind(TYPES.MouseListener).toService(ScrollMouseListener);
bind(TYPES.ITouchListener).toService(ScrollMouseListener);
bind(TYPES.MouseListener).toService(ZoomMouseListener);
});

Expand Down
160 changes: 129 additions & 31 deletions packages/sprotty/src/features/viewport/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,27 @@
import { inject } from 'inversify';
import { Viewport } from 'sprotty-protocol/lib/model';
import { Action, CenterAction, SetViewportAction } from 'sprotty-protocol/lib/actions';
import { almostEquals, Point } from 'sprotty-protocol/lib/utils/geometry';
import { almostEquals, Bounds, Point } from 'sprotty-protocol/lib/utils/geometry';
import { SModelElementImpl, SModelRootImpl } from '../../base/model/smodel';
import { MouseListener } from '../../base/views/mouse-tool';
import { findParentByFeature } from '../../base/model/smodel-utils';
import { isViewport } from './model';
import { isMoveable } from '../move/model';
import { SRoutingHandleImpl } from '../routing/model';
import { getModelBounds } from '../projection/model';
import { hitsMouseEvent } from '../../utils/browser';
import { getWindowScroll, hitsMouseEvent } from '../../utils/browser';
import { TYPES } from '../../base/types';
import { ViewerOptions } from '../../base/views/viewer-options';
import { ITouchListener } from '../../base/views/touch-tool';
import { limit } from '../../utils/geometry';

export class ScrollMouseListener extends MouseListener {
export class ScrollMouseListener extends MouseListener implements ITouchListener {

@inject(TYPES.ViewerOptions) protected viewerOptions: ViewerOptions;

protected lastScrollPosition: Point |undefined;
protected lastScrollPosition: Point | undefined;
protected lastTouchDistance: number | undefined;
protected lastTouchMidpoint: Point | undefined;
protected scrollbar: HTMLElement | undefined;
protected scrollbarMouseDownTimeout: number | undefined;
protected scrollbarMouseDownDelay = 200;
Expand All @@ -43,15 +47,7 @@ export class ScrollMouseListener extends MouseListener {
if (moveable === undefined && !(target instanceof SRoutingHandleImpl)) {
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
this.lastScrollPosition = { x: event.pageX, y: event.pageY };
this.scrollbar = this.getScrollbar(event);
if (this.scrollbar) {
window.clearTimeout(this.scrollbarMouseDownTimeout);
return this.moveScrollBar(viewport, event, this.scrollbar, true)
.map(action => new Promise(resolve => {
this.scrollbarMouseDownTimeout = window.setTimeout(() => resolve(action), this.scrollbarMouseDownDelay);
}));
}
return this.mouseDownOrSingleTouchStart(event, viewport);
} else {
this.lastScrollPosition = undefined;
this.scrollbar = undefined;
Expand All @@ -64,20 +60,7 @@ export class ScrollMouseListener extends MouseListener {
if (event.buttons === 0) {
return this.mouseUp(target, event);
}
if (this.scrollbar) {
window.clearTimeout(this.scrollbarMouseDownTimeout);
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
return this.moveScrollBar(viewport, event, this.scrollbar);
}
}
if (this.lastScrollPosition) {
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
return this.dragCanvas(viewport, event, this.lastScrollPosition);
}
}
return [];
return this.mouseOrSingleTouchMove(target, event);
}

override mouseEnter(target: SModelElementImpl, event: MouseEvent): Action[] {
Expand Down Expand Up @@ -114,7 +97,108 @@ export class ScrollMouseListener extends MouseListener {
return [];
}

protected dragCanvas(model: SModelRootImpl & Viewport, event: MouseEvent, lastScrollPosition: Point): Action[] {
touchStart(target: SModelElementImpl, event: TouchEvent): (Action | Promise<Action>)[] {
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
const touches = event.touches;
if (touches.length === 1) {
return this.mouseDownOrSingleTouchStart(touches[0], viewport);
} else if (touches.length === 2) {
this.lastTouchDistance = this.calculateDistance(touches);
this.lastTouchMidpoint = this.calculateMidpoint(touches, viewport.canvasBounds);
}
} else {
this.lastScrollPosition = undefined;
this.scrollbar = undefined;
}
return [];
}

touchMove(target: SModelElementImpl, event: TouchEvent): Action[] {
const touches = event.touches;
if (touches.length == 1) {
return this.mouseOrSingleTouchMove(target, touches[0]);
} else if (touches.length == 2) {
return this.twoTouchMove(target, touches);
} else {
return [];
}
}

protected mouseDownOrSingleTouchStart(event: MouseEvent | Touch, viewport: SModelRootImpl & Viewport): (Action | Promise<Action>)[] {
this.lastScrollPosition = { x: event.pageX, y: event.pageY };
this.scrollbar = this.getScrollbar(event);
if (this.scrollbar) {
window.clearTimeout(this.scrollbarMouseDownTimeout);
return this.moveScrollBar(viewport, event, this.scrollbar, true)
.map(action => new Promise(resolve => {
this.scrollbarMouseDownTimeout = window.setTimeout(() => resolve(action), this.scrollbarMouseDownDelay);
}));
}
return [];
}

protected mouseOrSingleTouchMove(target: SModelElementImpl, event: MouseEvent | Touch): Action[] {
if (this.scrollbar) {
window.clearTimeout(this.scrollbarMouseDownTimeout);
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
return this.moveScrollBar(viewport, event, this.scrollbar);
}
}
if (this.lastScrollPosition) {
const viewport = findParentByFeature(target, isViewport);
if (viewport) {
return this.dragCanvas(viewport, event, this.lastScrollPosition);
}
}
return [];
}

protected twoTouchMove(target: SModelElementImpl, touches: TouchList): Action[] {
const viewport = findParentByFeature(target, isViewport);
if (!viewport) {
return [];
}
const newDistance = this.calculateDistance(touches);
const newMidpoint = this.calculateMidpoint(touches, viewport.canvasBounds);

const scaleChange = newDistance / this.lastTouchDistance!;
const newZoom = limit(viewport.zoom * scaleChange, this.viewerOptions.zoomLimits);

const dx = (newMidpoint.x - this.lastTouchMidpoint!.x) / viewport.zoom;
const dy = (newMidpoint.y - this.lastTouchMidpoint!.y) / viewport.zoom;
const offsetFactor = 1.0 / newZoom - 1.0 / viewport.zoom;
const newViewport = {
scroll: {
x: viewport.scroll.x - dx - offsetFactor * newMidpoint.x,
y: viewport.scroll.y - dy - offsetFactor * newMidpoint.y
},
zoom: newZoom
};

this.lastTouchDistance = newDistance;
this.lastTouchMidpoint = newMidpoint;
return [SetViewportAction.create(viewport.id, newViewport, { animate: false })];
}

touchEnd(target: SModelElementImpl, event: TouchEvent): Action[] {
if (event.touches.length === 0) {
this.lastScrollPosition = undefined;
this.lastTouchDistance = undefined;
this.lastTouchMidpoint = undefined;
this.scrollbar = undefined;
return [];
} else if (event.touches.length === 1) {
this.lastScrollPosition = {
x: event.touches[0].pageX,
y: event.touches[0].pageY
};
}
return [];
}

protected dragCanvas(model: SModelRootImpl & Viewport, event: MouseEvent | Touch, lastScrollPosition: Point): Action[] {
let dx = (event.pageX - lastScrollPosition.x) / model.zoom;
if (dx > 0 && almostEquals(model.scroll.x, this.viewerOptions.horizontalScrollLimits.min)
|| dx < 0 && almostEquals(model.scroll.x, this.viewerOptions.horizontalScrollLimits.max - model.canvasBounds.width / model.zoom)) {
Expand All @@ -139,7 +223,7 @@ export class ScrollMouseListener extends MouseListener {
return [SetViewportAction.create(model.id, newViewport, { animate: false })];
}

protected moveScrollBar(model: SModelRootImpl & Viewport, event: MouseEvent, scrollbar: HTMLElement, animate: boolean = false): Action[] {
protected moveScrollBar(model: SModelRootImpl & Viewport, event: MouseEvent | Touch, scrollbar: HTMLElement, animate: boolean = false): Action[] {
const modelBounds = getModelBounds(model);
if (!modelBounds || model.zoom <= 0) {
return [];
Expand Down Expand Up @@ -196,7 +280,7 @@ export class ScrollMouseListener extends MouseListener {
return [SetViewportAction.create(model.id, { scroll: newScroll, zoom: model.zoom }, { animate })];
}

protected getScrollbar(event: MouseEvent): HTMLElement | undefined {
protected getScrollbar(event: MouseEvent | Touch): HTMLElement | undefined {
return findViewportScrollbar(event);
}

Expand All @@ -218,9 +302,23 @@ export class ScrollMouseListener extends MouseListener {
return undefined;
}

protected calculateDistance(touches: TouchList): number {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}

protected calculateMidpoint(touches: TouchList, canvasBounds: Bounds): Point {
const windowScroll = getWindowScroll();
return {
x: (touches[0].clientX + touches[1].clientX) / 2 + windowScroll.x - canvasBounds.x,
y: (touches[0].clientY + touches[1].clientY) / 2 + windowScroll.y - canvasBounds.y
};
}

}

export function findViewportScrollbar(event: MouseEvent): HTMLElement | undefined {
export function findViewportScrollbar(event: MouseEvent | Touch): HTMLElement | undefined {
let element = event.target as HTMLElement | null;
while (element) {
if (element.classList && element.classList.contains('sprotty-projection-bar')) {
Expand Down

0 comments on commit 8093575

Please sign in to comment.