-
Notifications
You must be signed in to change notification settings - Fork 23
Standalone client mobile support #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
15b6869
13ae887
bb0a95a
ca1a86f
7412d6c
8102987
2780022
3a3b4b1
5762197
81c5717
0a872c8
4e53731
9a270bd
3dd45d5
9bf924b
c54c5ed
1cfabcb
f87dcbb
05820f6
0ac6660
7329c6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
| export class MMLClickTrigger { | ||
| private eventHandlerCollection: EventHandlerCollection = new EventHandlerCollection(); | ||
|
|
@@ -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() { | ||
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| // Avoid infinite loop of handling click events that originated from this trigger | ||
| return; | ||
| } | ||
| let x = 0; | ||
| let y = 0; | ||
| if (!document.pointerLockElement) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From this point in this method this is duplicated from |
||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -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)); | ||
|
|
@@ -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() { | ||
|
|
@@ -190,6 +216,7 @@ export class DragFlyCameraControls { | |
|
|
||
| this.camera.quaternion.setFromEuler(this.tempEuler); | ||
| } | ||
|
|
||
| private onMouseUp() { | ||
| this.mouseDown = false; | ||
| } | ||
|
|
@@ -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", { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems extraneous now as the |
||
| 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; | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.