Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/mml-web/src/MMLClickTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { getRelativePositionAndRotationRelativeToObject } from "./utils/position

const mouseMovePixelsThreshold = 10;
const mouseMoveTimeThresholdMilliseconds = 500;
const touchThresholdMilliseconds = 200;

let touchX: number;
let touchY: number;
let touchTimestamp: number;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be class properties rather than global as they are now.


export class MMLClickTrigger {
private eventHandlerCollection: EventHandlerCollection = new EventHandlerCollection();
Expand All @@ -28,6 +33,8 @@ export class MMLClickTrigger {
this.eventHandlerCollection.add(clickTarget, "mousedown", this.handleMouseDown.bind(this));
this.eventHandlerCollection.add(clickTarget, "mouseup", this.handleMouseUp.bind(this));
this.eventHandlerCollection.add(clickTarget, "mousemove", this.handleMouseMove.bind(this));
this.eventHandlerCollection.add(clickTarget, "touchstart", this.handleTouchStart.bind(this));
this.eventHandlerCollection.add(clickTarget, "touchend", this.handleTouchEnd.bind(this));
}

private handleMouseDown() {
Expand Down Expand Up @@ -55,6 +62,71 @@ export class MMLClickTrigger {
}
}

private handleTouchEnd(event: TouchEvent) {
if (Date.now() - touchTimestamp < touchThresholdMilliseconds) {
/* a short touch, i.e., a click */
if ((event.detail as any).element) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is redundant here and also in the source it was copied from (handleClick). It can be removed from there too. It was originally a catch for when this class was an element itself, which is no longer the case.

// Avoid infinite loop of handling click events that originated from this trigger
return;
}
let x = 0;
let y = 0;
if (!document.pointerLockElement) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a touch handler then it seems to follow that we should always be using the position of the touch rather than check if there was a pointerLockElement.

It's plausible that a device with both mouse and touchscreen could be using pointer lock controls and then have the user touch the screen to "click" something, but in that instance I'd expect the touch location to still be used.

let offsetX = touchX;
let offsetY = touchY;
let width = window.innerWidth;
let height = window.innerHeight;
if (this.clickTarget instanceof HTMLElement) {
width = this.clickTarget.offsetWidth;
height = this.clickTarget.offsetHeight;
}
if (event.target) {
/* get the equivalent of event.offset in a mouse event */
const bcr = (event.target as HTMLElement).getBoundingClientRect();
offsetX = offsetX - bcr.x;
offsetY = offsetY - bcr.y;
}
x = (offsetX / width) * 2 - 1;
y = -((offsetY / height) * 2 - 1);
}
this.raycaster.setFromCamera(new THREE.Vector2(x, y), this.scene.getCamera());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From this point in this method this is duplicated from handleClick. It's likely best to extract this into a private method to avoid them diverging unintentionally.

const intersections = this.raycaster.intersectObject(this.scene.getRootContainer(), true);
if (intersections.length > 0) {
for (const intersection of intersections) {
let obj: THREE.Object3D | null = intersection.object;
while (obj) {
/*
Ignore scene objects that have a transparent or wireframe material
*/
if (this.isMaterialIgnored(obj)) {
break;
}

const mElement = MElement.getMElementFromObject(obj);
if (mElement && mElement.isClickable()) {
mElement.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
}),
);
return;
}
obj = obj.parent;
}
}
}
}
}

private handleTouchStart(event: TouchEvent) {
/* remember the x and y position of the touch, so that it can be used in touchEnd */
touchX = event.touches[0].clientX;
touchY = event.touches[0].clientY;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the first-available touch to track position here (rather than using the position of the touch that has ended in touchEnd) has an unintentional side-effect.

If you hold a finger (A) on the screen, and then tap with a second finger (B), you will be "clicking" at point A rather than B which is where the actual tapping is happening.


/* remember the start time of the touch to calculate the touch duration in touchEnd */
touchTimestamp = Date.now();
}

private handleClick(event: MouseEvent) {
if ((event.detail as any).element) {
// Avoid infinite loop of handling click events that originated from this trigger
Expand Down
179 changes: 179 additions & 0 deletions packages/mml-web/src/camera/DragFlyCameraControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { EventHandlerCollection } from "../utils/events/EventHandlerCollection";

const WorldUp = new Vector3(0, 1, 0);

type TouchState = {
touch: Touch;
startX: number;
startY: number;
currentX: number;
currentY: number;
};

// Creates a set of 5DOF flight controls that requires dragging the mouse to move the rotation and position of the camera
export class DragFlyCameraControls {
private enabled = false;
Expand Down Expand Up @@ -38,6 +46,16 @@ export class DragFlyCameraControls {
private eventHandlerCollection: EventHandlerCollection = new EventHandlerCollection();
private mouseDown = false;

// Touch zooming and panning
private touchesMap = new Map<number, TouchState>();
private isMoving = false;
private panStartX: number;
private panStartY: number;
private zoomTimestamp: number;
private zoomThresholdMilliseconds = 200;
private clickTimestamp: number;
private clickThresholdMilliseconds = 200;

constructor(camera: Camera, domElement: HTMLElement, speed = 15.0) {
this.camera = camera;
this.domElement = domElement;
Expand All @@ -48,6 +66,7 @@ export class DragFlyCameraControls {
if (this.enabled) {
return;
}

this.enabled = true;
this.eventHandlerCollection.add(document, "keydown", this.onKeyDown.bind(this));
this.eventHandlerCollection.add(document, "keyup", this.onKeyUp.bind(this));
Expand All @@ -56,6 +75,13 @@ export class DragFlyCameraControls {
this.eventHandlerCollection.add(this.domElement, "mousedown", this.onMouseDown.bind(this));
this.eventHandlerCollection.add(document, "mouseup", this.onMouseUp.bind(this));
this.eventHandlerCollection.add(document, "wheel", this.onMouseWheel.bind(this));
this.eventHandlerCollection.add(
this.domElement,
"touchstart",
this.handleTouchStart.bind(this),
);
this.eventHandlerCollection.add(document, "touchend", this.handleTouchEnd.bind(this));
this.eventHandlerCollection.add(this.domElement, "touchmove", this.handleTouchMove.bind(this));
}

public disable() {
Expand Down Expand Up @@ -190,6 +216,7 @@ export class DragFlyCameraControls {

this.camera.quaternion.setFromEuler(this.tempEuler);
}

private onMouseUp() {
this.mouseDown = false;
}
Expand All @@ -203,4 +230,156 @@ export class DragFlyCameraControls {
// restrict to a reasonable min and max
this.speed = Math.max(5, Math.min(this.speed, 1000));
}

private handleTouchStart(event: TouchEvent) {
event.preventDefault();
let startX: number;
let startY: number;

for (const touch of Array.from(event.touches)) {
if (!this.touchesMap.has(touch.identifier)) {
startX = touch.clientX;
startY = touch.clientY;

this.touchesMap.set(touch.identifier, {
touch,
startX,
startY,
currentX: startX,
currentY: startY,
});
}
}

if (event.touches.length === 1) {
this.panStartX = event.touches[0].clientX;
this.panStartY = event.touches[0].clientY;
this.clickTimestamp = Date.now();
}
}

private handleTouchEnd(event: TouchEvent) {
if (this.isMoving) {
this.zoomTimestamp = Date.now();
}
this.isMoving = false;

const remainingTouches = new Set(Array.from(event.touches).map((touch) => touch.identifier));

for (const [touchId] of this.touchesMap) {
if (!remainingTouches.has(touchId)) {
this.touchesMap.delete(touchId);
}
}

if (Date.now() - this.clickTimestamp < this.clickThresholdMilliseconds) {
/* this is a click */
// Create and dispatch a new mouse event with specific x and y coordinates
const clickEvent = new MouseEvent("click", {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems extraneous now as the MMLClickTrigger handles clicks without this logic.

bubbles: true,
cancelable: true,
view: window,
clientX: this.panStartX,
clientY: this.panStartY,
});
window.dispatchEvent(clickEvent);
}
}

private handleTouchMove(event: TouchEvent) {
for (const touch of Array.from(event.touches)) {
const touchState = this.touchesMap.get(touch.identifier);
if (!touchState) {
throw new Error("Touch identifier not found.");
}
touchState.touch = touch;
}

if (event.touches.length > 1) {
let currentAverageX = 0;
let latestAverageX = 0;
let currentAverageY = 0;
let latestAverageY = 0;
for (const [, touch] of this.touchesMap) {
currentAverageX += touch.currentX;
currentAverageY += touch.currentY;
latestAverageX += touch.touch.clientX;
latestAverageY += touch.touch.clientY;
}

currentAverageX = currentAverageX / this.touchesMap.size;
currentAverageY = currentAverageY / this.touchesMap.size;
latestAverageX = latestAverageX / this.touchesMap.size;
latestAverageY = latestAverageY / this.touchesMap.size;
let currentAverageDX = 0;
let currentAverageDY = 0;
let latestAverageDX = 0;
let latestAverageDY = 0;
for (const [, touch] of this.touchesMap) {
currentAverageDX += Math.abs(touch.currentX - currentAverageX);
currentAverageDY += Math.abs(touch.currentY - currentAverageY);
latestAverageDX += Math.abs(touch.touch.clientX - latestAverageX);
latestAverageDY += Math.abs(touch.touch.clientY - latestAverageY);
}

const currentDistance = Math.hypot(currentAverageDX, currentAverageDY);
const latestDistance = Math.hypot(latestAverageDX, latestAverageDY);
const deltaDistance = latestDistance - currentDistance;

this.camera.getWorldDirection(this.vForward);
this.vRight.crossVectors(this.vForward, WorldUp);
this.vRight.normalize();
this.vUp.crossVectors(this.vRight, this.vForward);
this.vUp.normalize();

this.vMovement.set(0, 0, 0);
this.vMovement.addScaledVector(this.vForward, deltaDistance);
this.vMovement.multiplyScalar(0.01);

this.camera.position.add(this.vMovement);
this.isMoving = true;
} else if (event.touches.length === 1) {
// Pan
if (!this.zoomTimestamp || Date.now() > this.zoomTimestamp + this.zoomThresholdMilliseconds) {
this.isMoving = false;

const movementX = event.touches[0].clientX - this.panStartX;
let movementY = event.touches[0].clientY - this.panStartY;

// Update the start coordinates for the next move event
this.panStartX = event.touches[0].clientX;
this.panStartY = event.touches[0].clientY;

// This is an addition to the original PointerLockControls class
if (this.invertedMouseY) {
movementY *= -1;
}

this.tempEuler.setFromQuaternion(this.camera.quaternion);

this.tempEuler.y += movementX * 0.002;
this.tempEuler.x += movementY * 0.002;

this.tempEuler.x = Math.max(
Math.PI / 2 - this.maxPolarAngle,
Math.min(Math.PI / 2 - this.minPolarAngle, this.tempEuler.x),
);

this.camera.quaternion.setFromEuler(this.tempEuler);
} else {
// Update the start coordinates for the next move event so that transitioning from zoom to pan does not flick the view
this.panStartX = event.touches[0].clientX;
this.panStartY = event.touches[0].clientY;
}
}

for (const touch of Array.from(event.touches)) {
const touchState = this.touchesMap.get(touch.identifier);
if (!touchState) {
throw new Error("Touch identifier not found.");
}
touchState.currentX = touch.clientX;
touchState.currentY = touch.clientY;
}
}
}