Skip to content

Instantly share code, notes, and snippets.

@Martini024
Created February 22, 2025 14:52
Show Gist options
  • Save Martini024/4954e0b2205bc20197019775af6113c3 to your computer and use it in GitHub Desktop.
Save Martini024/4954e0b2205bc20197019775af6113c3 to your computer and use it in GitHub Desktop.

Fix for Lost pointerup Events in OrbitControls (iOS Safari Issue)

This fix addresses an issue in OrbitControls where pointerup events are sometimes lost on iOS Safari, leading to stale or invalid pointer tracking.

Observed Behavior on iOS Safari

  • When using touch gestures, pointerup events may fail to trigger, causing OrbitControls to think the pointer is still active.
  • This can result in stuck interactions, where dragging or zooming no longer behaves correctly.
  • The issue is most noticeable in multi-touch gestures or when interrupting interactions (e.g., switching tabs or performing quick touches).

Fix Implemented

  • Detects inconsistencies by comparing active touches (touch.identifier) with recorded pointers (pointerId).
  • Resets stale pointers when a mismatch is found, preventing ghost touches.
  • Immediately reevaluates the next operation, ensuring that the action continues as intended.

With this fix, OrbitControls properly resets when an inconsistency is detected and seamlessly continues the expected operation, preventing stuck interactions.

// Modified based on three-stdlib 2.34.0
/* eslint-disable */
import { EventDispatcher, Matrix4, MOUSE, OrthographicCamera, PerspectiveCamera, Plane, Quaternion, Ray, Spherical, TOUCH, Vector2, Vector3 } from "three"
const _ray = new Ray()
const _plane = new Plane()
const TILT_LIMIT = Math.cos(70 * (Math.PI / 180))
// This set of controls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
//
// Orbit - left mouse / touch: one-finger move
// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
const moduloWrapAround = (offset: number, capacity: number) => ((offset % capacity) + capacity) % capacity
class OrbitControls extends EventDispatcher {
object: PerspectiveCamera | OrthographicCamera
domElement: HTMLElement | undefined
// Set to false to disable this control
enabled = true
// "target" sets the location of focus, where the object orbits around
target = new Vector3()
// How far you can dolly in and out ( PerspectiveCamera only )
minDistance = 0
maxDistance = Infinity
// How far you can zoom in and out ( OrthographicCamera only )
minZoom = 0
maxZoom = Infinity
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to Math.PI radians.
minPolarAngle = 0 // radians
maxPolarAngle = Math.PI // radians
// How far you can orbit horizontally, upper and lower limits.
// If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
minAzimuthAngle = -Infinity // radians
maxAzimuthAngle = Infinity // radians
// Set to true to enable damping (inertia)
// If damping is enabled, you must call controls.update() in your animation loop
enableDamping = false
dampingFactor = 0.05
// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
// Set to false to disable zooming
enableZoom = true
zoomSpeed = 1.0
// Set to false to disable rotating
enableRotate = true
rotateSpeed = 1.0
// Set to false to disable panning
enablePan = true
panSpeed = 1.0
screenSpacePanning = true // if false, pan orthogonal to world-space direction camera.up
keyPanSpeed = 7.0 // pixels moved per arrow key push
zoomToCursor = false
// Set to true to automatically rotate around the target
// If auto-rotate is enabled, you must call controls.update() in your animation loop
autoRotate = false
autoRotateSpeed = 2.0 // 30 seconds per orbit when fps is 60
reverseOrbit = false // true if you want to reverse the orbit to mouse drag from left to right = orbits left
reverseHorizontalOrbit = false // true if you want to reverse the horizontal orbit direction
reverseVerticalOrbit = false // true if you want to reverse the vertical orbit direction
// The four arrow keys
keys = { LEFT: "ArrowLeft", UP: "ArrowUp", RIGHT: "ArrowRight", BOTTOM: "ArrowDown" }
// Mouse buttons
mouseButtons: Partial<{
LEFT: MOUSE
MIDDLE: MOUSE
RIGHT: MOUSE
}> = {
LEFT: MOUSE.ROTATE,
MIDDLE: MOUSE.DOLLY,
RIGHT: MOUSE.PAN
}
// Touch fingers
touches: Partial<{
ONE: TOUCH
TWO: TOUCH
}> = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }
target0: Vector3
position0: Vector3
zoom0: number
// the target DOM element for key events
_domElementKeyEvents: any = null
getPolarAngle: () => number
getAzimuthalAngle: () => number
setPolarAngle: (x: number) => void
setAzimuthalAngle: (x: number) => void
getDistance: () => number
// Not used in most scenarios, however they can be useful for specific use cases
getZoomScale: () => number
listenToKeyEvents: (domElement: HTMLElement) => void
stopListenToKeyEvents: () => void
saveState: () => void
reset: () => void
update: () => void
connect: (domElement: HTMLElement) => void
dispose: () => void
// Dolly in programmatically
dollyIn: (dollyScale?: number) => void
// Dolly out programmatically
dollyOut: (dollyScale?: number) => void
// Get the current scale
getScale: () => number
// Set the current scale (these are not used in most scenarios, however they can be useful for specific use cases)
setScale: (newScale: number) => void
constructor(object: PerspectiveCamera | OrthographicCamera, domElement?: HTMLElement) {
super()
this.object = object
this.domElement = domElement
// for reset
this.target0 = this.target.clone()
this.position0 = this.object.position.clone()
this.zoom0 = this.object.zoom
//
// public methods
//
this.getPolarAngle = (): number => spherical.phi
this.getAzimuthalAngle = (): number => spherical.theta
this.setPolarAngle = (value: number): void => {
// use modulo wrapping to safeguard value
let phi = moduloWrapAround(value, 2 * Math.PI)
let currentPhi = spherical.phi
// convert to the equivalent shortest angle
if (currentPhi < 0) currentPhi += 2 * Math.PI
if (phi < 0) phi += 2 * Math.PI
const phiDist = Math.abs(phi - currentPhi)
if (2 * Math.PI - phiDist < phiDist) {
if (phi < currentPhi) {
phi += 2 * Math.PI
} else {
currentPhi += 2 * Math.PI
}
}
sphericalDelta.phi = phi - currentPhi
scope.update()
}
this.setAzimuthalAngle = (value: number): void => {
// use modulo wrapping to safeguard value
let theta = moduloWrapAround(value, 2 * Math.PI)
let currentTheta = spherical.theta
// convert to the equivalent shortest angle
if (currentTheta < 0) currentTheta += 2 * Math.PI
if (theta < 0) theta += 2 * Math.PI
const thetaDist = Math.abs(theta - currentTheta)
if (2 * Math.PI - thetaDist < thetaDist) {
if (theta < currentTheta) {
theta += 2 * Math.PI
} else {
currentTheta += 2 * Math.PI
}
}
sphericalDelta.theta = theta - currentTheta
scope.update()
}
this.getDistance = (): number => scope.object.position.distanceTo(scope.target)
this.listenToKeyEvents = (domElement: HTMLElement): void => {
domElement.addEventListener("keydown", onKeyDown)
this._domElementKeyEvents = domElement
}
this.stopListenToKeyEvents = (): void => {
this._domElementKeyEvents.removeEventListener("keydown", onKeyDown)
this._domElementKeyEvents = null
}
this.saveState = (): void => {
scope.target0.copy(scope.target)
scope.position0.copy(scope.object.position)
scope.zoom0 = scope.object.zoom
}
this.reset = (): void => {
scope.target.copy(scope.target0)
scope.object.position.copy(scope.position0)
scope.object.zoom = scope.zoom0
scope.object.updateProjectionMatrix()
// @ts-ignore
scope.dispatchEvent(changeEvent)
scope.update()
state = STATE.NONE
}
// this method is exposed, but perhaps it would be better if we can make it private...
this.update = ((): (() => void) => {
const offset = new Vector3()
const up = new Vector3(0, 1, 0)
// so camera.up is the orbit axis
const quat = new Quaternion().setFromUnitVectors(object.up, up)
const quatInverse = quat.clone().invert()
const lastPosition = new Vector3()
const lastQuaternion = new Quaternion()
const twoPI = 2 * Math.PI
return function update(): boolean {
const position = scope.object.position
// update new up direction
quat.setFromUnitVectors(object.up, up)
quatInverse.copy(quat).invert()
offset.copy(position).sub(scope.target)
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat)
// angle from z-axis around y-axis
spherical.setFromVector3(offset)
if (scope.autoRotate && state === STATE.NONE) {
rotateLeft(getAutoRotationAngle())
}
if (scope.enableDamping) {
spherical.theta += sphericalDelta.theta * scope.dampingFactor
spherical.phi += sphericalDelta.phi * scope.dampingFactor
} else {
spherical.theta += sphericalDelta.theta
spherical.phi += sphericalDelta.phi
}
// restrict theta to be between desired limits
let min = scope.minAzimuthAngle
let max = scope.maxAzimuthAngle
if (isFinite(min) && isFinite(max)) {
if (min < -Math.PI) min += twoPI
else if (min > Math.PI) min -= twoPI
if (max < -Math.PI) max += twoPI
else if (max > Math.PI) max -= twoPI
if (min <= max) {
spherical.theta = Math.max(min, Math.min(max, spherical.theta))
} else {
spherical.theta = spherical.theta > (min + max) / 2 ? Math.max(min, spherical.theta) : Math.min(max, spherical.theta)
}
}
// restrict phi to be between desired limits
spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi))
spherical.makeSafe()
// move target to panned location
if (scope.enableDamping === true) {
scope.target.addScaledVector(panOffset, scope.dampingFactor)
} else {
scope.target.add(panOffset)
}
// adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
// we adjust zoom later in these cases
if ((scope.zoomToCursor && performCursorZoom) || (scope.object as OrthographicCamera).isOrthographicCamera) {
spherical.radius = clampDistance(spherical.radius)
} else {
spherical.radius = clampDistance(spherical.radius * scale)
}
offset.setFromSpherical(spherical)
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse)
position.copy(scope.target).add(offset)
if (!scope.object.matrixAutoUpdate) scope.object.updateMatrix()
scope.object.lookAt(scope.target)
if (scope.enableDamping === true) {
sphericalDelta.theta *= 1 - scope.dampingFactor
sphericalDelta.phi *= 1 - scope.dampingFactor
panOffset.multiplyScalar(1 - scope.dampingFactor)
} else {
sphericalDelta.set(0, 0, 0)
panOffset.set(0, 0, 0)
}
// adjust camera position
let zoomChanged = false
if (scope.zoomToCursor && performCursorZoom) {
let newRadius = null
if (scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) {
// move the camera down the pointer ray
// this method avoids floating point error
const prevRadius = offset.length()
newRadius = clampDistance(prevRadius * scale)
const radiusDelta = prevRadius - newRadius
scope.object.position.addScaledVector(dollyDirection, radiusDelta)
scope.object.updateMatrixWorld()
} else if ((scope.object as OrthographicCamera).isOrthographicCamera) {
// adjust the ortho camera position based on zoom changes
const mouseBefore = new Vector3(mouse.x, mouse.y, 0)
mouseBefore.unproject(scope.object)
scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / scale))
scope.object.updateProjectionMatrix()
zoomChanged = true
const mouseAfter = new Vector3(mouse.x, mouse.y, 0)
mouseAfter.unproject(scope.object)
scope.object.position.sub(mouseAfter).add(mouseBefore)
scope.object.updateMatrixWorld()
newRadius = offset.length()
} else {
console.warn("WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.")
scope.zoomToCursor = false
}
// handle the placement of the target
if (newRadius !== null) {
if (scope.screenSpacePanning) {
// position the orbit target in front of the new camera position
scope.target.set(0, 0, -1).transformDirection(scope.object.matrix).multiplyScalar(newRadius).add(scope.object.position)
} else {
// get the ray and translation plane to compute target
_ray.origin.copy(scope.object.position)
_ray.direction.set(0, 0, -1).transformDirection(scope.object.matrix)
// if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
// extremely large values
if (Math.abs(scope.object.up.dot(_ray.direction)) < TILT_LIMIT) {
object.lookAt(scope.target)
} else {
_plane.setFromNormalAndCoplanarPoint(scope.object.up, scope.target)
_ray.intersectPlane(_plane, scope.target)
}
}
}
} else if (scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera) {
zoomChanged = scale !== 1
if (zoomChanged) {
scope.object.zoom = Math.max(scope.minZoom, Math.min(scope.maxZoom, scope.object.zoom / scale))
scope.object.updateProjectionMatrix()
}
}
scale = 1
performCursorZoom = false
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (zoomChanged || lastPosition.distanceToSquared(scope.object.position) > EPS || 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) {
// @ts-ignore
scope.dispatchEvent(changeEvent)
lastPosition.copy(scope.object.position)
lastQuaternion.copy(scope.object.quaternion)
zoomChanged = false
return true
}
return false
}
})()
// https://github.com/mrdoob/three.js/issues/20575
this.connect = (domElement: HTMLElement): void => {
scope.domElement = domElement
// disables touch scroll
// touch-action needs to be defined for pointer events to work on mobile
// https://stackoverflow.com/a/48254578
scope.domElement.style.touchAction = "none"
scope.domElement.addEventListener("contextmenu", onContextMenu)
scope.domElement.addEventListener("pointerdown", onPointerDown)
scope.domElement.addEventListener("pointercancel", onPointerUp)
scope.domElement.addEventListener("touchstart", handleTouchStart)
scope.domElement.addEventListener("wheel", onMouseWheel)
}
this.dispose = (): void => {
// Enabling touch scroll
if (scope.domElement) {
scope.domElement.style.touchAction = "auto"
}
scope.domElement?.removeEventListener("contextmenu", onContextMenu)
scope.domElement?.removeEventListener("pointerdown", onPointerDown)
scope.domElement?.removeEventListener("pointercancel", onPointerUp)
scope.domElement?.removeEventListener("touchstart", handleTouchStart)
scope.domElement?.removeEventListener("wheel", onMouseWheel)
scope.domElement?.ownerDocument.removeEventListener("pointermove", onPointerMove)
scope.domElement?.ownerDocument.removeEventListener("pointerup", onPointerUp)
if (scope._domElementKeyEvents !== null) {
scope._domElementKeyEvents.removeEventListener("keydown", onKeyDown)
}
//scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
}
//
// internals
//
const scope = this
const changeEvent = { type: "change" }
const startEvent = { type: "start" }
const endEvent = { type: "end" }
const STATE = {
NONE: -1,
ROTATE: 0,
DOLLY: 1,
PAN: 2,
TOUCH_ROTATE: 3,
TOUCH_PAN: 4,
TOUCH_DOLLY_PAN: 5,
TOUCH_DOLLY_ROTATE: 6
}
let state = STATE.NONE
const EPS = 0.000001
// current position in spherical coordinates
const spherical = new Spherical()
const sphericalDelta = new Spherical()
let scale = 1
const panOffset = new Vector3()
const rotateStart = new Vector2()
const rotateEnd = new Vector2()
const rotateDelta = new Vector2()
const panStart = new Vector2()
const panEnd = new Vector2()
const panDelta = new Vector2()
const dollyStart = new Vector2()
const dollyEnd = new Vector2()
const dollyDelta = new Vector2()
const dollyDirection = new Vector3()
const mouse = new Vector2()
let performCursorZoom = false
const pointers: PointerEvent[] = []
const pointerPositions: { [key: string]: Vector2 } = {}
function getAutoRotationAngle(): number {
return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed
}
function getZoomScale(): number {
return Math.pow(0.95, scope.zoomSpeed)
}
function rotateLeft(angle: number): void {
if (scope.reverseOrbit || scope.reverseHorizontalOrbit) {
sphericalDelta.theta += angle
} else {
sphericalDelta.theta -= angle
}
}
function rotateUp(angle: number): void {
if (scope.reverseOrbit || scope.reverseVerticalOrbit) {
sphericalDelta.phi += angle
} else {
sphericalDelta.phi -= angle
}
}
const panLeft = (() => {
const v = new Vector3()
return function panLeft(distance: number, objectMatrix: Matrix4) {
v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix
v.multiplyScalar(-distance)
panOffset.add(v)
}
})()
const panUp = (() => {
const v = new Vector3()
return function panUp(distance: number, objectMatrix: Matrix4) {
if (scope.screenSpacePanning === true) {
v.setFromMatrixColumn(objectMatrix, 1)
} else {
v.setFromMatrixColumn(objectMatrix, 0)
v.crossVectors(scope.object.up, v)
}
v.multiplyScalar(distance)
panOffset.add(v)
}
})()
// deltaX and deltaY are in pixels; right and down are positive
const pan = (() => {
const offset = new Vector3()
return function pan(deltaX: number, deltaY: number) {
const element = scope.domElement
if (element && scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) {
// perspective
const position = scope.object.position
offset.copy(position).sub(scope.target)
let targetDistance = offset.length()
// half of the fov is center to top of screen
targetDistance *= Math.tan(((scope.object.fov / 2) * Math.PI) / 180.0)
// we use only clientHeight here so aspect ratio does not distort speed
panLeft((2 * deltaX * targetDistance) / element.clientHeight, scope.object.matrix)
panUp((2 * deltaY * targetDistance) / element.clientHeight, scope.object.matrix)
} else if (element && scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera) {
// orthographic
panLeft((deltaX * (scope.object.right - scope.object.left)) / scope.object.zoom / element.clientWidth, scope.object.matrix)
panUp((deltaY * (scope.object.top - scope.object.bottom)) / scope.object.zoom / element.clientHeight, scope.object.matrix)
} else {
// camera neither orthographic nor perspective
console.warn("WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.")
scope.enablePan = false
}
}
})()
function setScale(newScale: number) {
if (
(scope.object instanceof PerspectiveCamera && scope.object.isPerspectiveCamera) ||
(scope.object instanceof OrthographicCamera && scope.object.isOrthographicCamera)
) {
scale = newScale
} else {
console.warn("WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.")
scope.enableZoom = false
}
}
function dollyOut(dollyScale: number) {
setScale(scale / dollyScale)
}
function dollyIn(dollyScale: number) {
setScale(scale * dollyScale)
}
function updateMouseParameters(event: MouseEvent): void {
if (!scope.zoomToCursor || !scope.domElement) {
return
}
performCursorZoom = true
const rect = scope.domElement.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const w = rect.width
const h = rect.height
mouse.x = (x / w) * 2 - 1
mouse.y = -(y / h) * 2 + 1
dollyDirection.set(mouse.x, mouse.y, 1).unproject(scope.object).sub(scope.object.position).normalize()
}
function clampDistance(dist: number): number {
return Math.max(scope.minDistance, Math.min(scope.maxDistance, dist))
}
//
// event callbacks - update the object state
//
function handleMouseDownRotate(event: MouseEvent) {
rotateStart.set(event.clientX, event.clientY)
}
function handleMouseDownDolly(event: MouseEvent) {
updateMouseParameters(event)
dollyStart.set(event.clientX, event.clientY)
}
function handleMouseDownPan(event: MouseEvent) {
panStart.set(event.clientX, event.clientY)
}
function handleMouseMoveRotate(event: MouseEvent) {
rotateEnd.set(event.clientX, event.clientY)
rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)
const element = scope.domElement
if (element) {
rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height
rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight)
}
rotateStart.copy(rotateEnd)
scope.update()
}
function handleMouseMoveDolly(event: MouseEvent) {
dollyEnd.set(event.clientX, event.clientY)
dollyDelta.subVectors(dollyEnd, dollyStart)
if (dollyDelta.y > 0) {
dollyOut(getZoomScale())
} else if (dollyDelta.y < 0) {
dollyIn(getZoomScale())
}
dollyStart.copy(dollyEnd)
scope.update()
}
function handleMouseMovePan(event: MouseEvent) {
panEnd.set(event.clientX, event.clientY)
panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)
pan(panDelta.x, panDelta.y)
panStart.copy(panEnd)
scope.update()
}
function handleMouseWheel(event: WheelEvent) {
updateMouseParameters(event)
if (event.deltaY < 0) {
dollyIn(getZoomScale())
} else if (event.deltaY > 0) {
dollyOut(getZoomScale())
}
scope.update()
}
function handleKeyDown(event: KeyboardEvent) {
let needsUpdate = false
switch (event.code) {
case scope.keys.UP:
pan(0, scope.keyPanSpeed)
needsUpdate = true
break
case scope.keys.BOTTOM:
pan(0, -scope.keyPanSpeed)
needsUpdate = true
break
case scope.keys.LEFT:
pan(scope.keyPanSpeed, 0)
needsUpdate = true
break
case scope.keys.RIGHT:
pan(-scope.keyPanSpeed, 0)
needsUpdate = true
break
}
if (needsUpdate) {
// prevent the browser from scrolling on cursor keys
event.preventDefault()
scope.update()
}
}
function handleTouchStartRotate() {
if (pointers.length == 1) {
rotateStart.set(pointers[0].pageX, pointers[0].pageY)
} else {
const x = 0.5 * (pointers[0].pageX + pointers[1].pageX)
const y = 0.5 * (pointers[0].pageY + pointers[1].pageY)
rotateStart.set(x, y)
}
}
function handleTouchStartPan() {
if (pointers.length == 1) {
panStart.set(pointers[0].pageX, pointers[0].pageY)
} else {
const x = 0.5 * (pointers[0].pageX + pointers[1].pageX)
const y = 0.5 * (pointers[0].pageY + pointers[1].pageY)
panStart.set(x, y)
}
}
function handleTouchStartDolly() {
const dx = pointers[0].pageX - pointers[1].pageX
const dy = pointers[0].pageY - pointers[1].pageY
const distance = Math.sqrt(dx * dx + dy * dy)
dollyStart.set(0, distance)
}
function handleTouchStartDollyPan() {
if (scope.enableZoom) handleTouchStartDolly()
if (scope.enablePan) handleTouchStartPan()
}
function handleTouchStartDollyRotate() {
if (scope.enableZoom) handleTouchStartDolly()
if (scope.enableRotate) handleTouchStartRotate()
}
function handleTouchMoveRotate(event: PointerEvent) {
if (pointers.length == 1) {
rotateEnd.set(event.pageX, event.pageY)
} else {
const position = getSecondPointerPosition(event)
const x = 0.5 * (event.pageX + position.x)
const y = 0.5 * (event.pageY + position.y)
rotateEnd.set(x, y)
}
rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)
const element = scope.domElement
if (element) {
rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height
rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight)
}
rotateStart.copy(rotateEnd)
}
function handleTouchMovePan(event: PointerEvent) {
if (pointers.length == 1) {
panEnd.set(event.pageX, event.pageY)
} else {
const position = getSecondPointerPosition(event)
const x = 0.5 * (event.pageX + position.x)
const y = 0.5 * (event.pageY + position.y)
panEnd.set(x, y)
}
panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)
pan(panDelta.x, panDelta.y)
panStart.copy(panEnd)
}
function handleTouchMoveDolly(event: PointerEvent) {
const position = getSecondPointerPosition(event)
const dx = event.pageX - position.x
const dy = event.pageY - position.y
const distance = Math.sqrt(dx * dx + dy * dy)
dollyEnd.set(0, distance)
dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed))
dollyOut(dollyDelta.y)
dollyStart.copy(dollyEnd)
}
function handleTouchMoveDollyPan(event: PointerEvent) {
if (scope.enableZoom) handleTouchMoveDolly(event)
if (scope.enablePan) handleTouchMovePan(event)
}
function handleTouchMoveDollyRotate(event: PointerEvent) {
if (scope.enableZoom) handleTouchMoveDolly(event)
if (scope.enableRotate) handleTouchMoveRotate(event)
}
//
// event handlers - FSM: listen for events and reset state
//
function onPointerDown(event: PointerEvent) {
if (scope.enabled === false) return
if (pointers.length === 0) {
scope.domElement?.ownerDocument.addEventListener("pointermove", onPointerMove)
scope.domElement?.ownerDocument.addEventListener("pointerup", onPointerUp)
}
addPointer(event)
if (event.pointerType === "touch") {
onTouchStart(event)
} else {
onMouseDown(event)
}
}
function onPointerMove(event: PointerEvent) {
if (scope.enabled === false) return
if (event.pointerType === "touch") {
onTouchMove(event)
} else {
onMouseMove(event)
}
}
function onPointerUp(event: PointerEvent) {
removePointer(event)
if (pointers.length === 0) {
scope.domElement?.releasePointerCapture(event.pointerId)
scope.domElement?.ownerDocument.removeEventListener("pointermove", onPointerMove)
scope.domElement?.ownerDocument.removeEventListener("pointerup", onPointerUp)
}
// @ts-ignore
scope.dispatchEvent(endEvent)
state = STATE.NONE
}
function onMouseDown(event: MouseEvent) {
let mouseAction
switch (event.button) {
case 0:
mouseAction = scope.mouseButtons.LEFT
break
case 1:
mouseAction = scope.mouseButtons.MIDDLE
break
case 2:
mouseAction = scope.mouseButtons.RIGHT
break
default:
mouseAction = -1
}
switch (mouseAction) {
case MOUSE.DOLLY:
if (scope.enableZoom === false) return
handleMouseDownDolly(event)
state = STATE.DOLLY
break
case MOUSE.ROTATE:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (scope.enablePan === false) return
handleMouseDownPan(event)
state = STATE.PAN
} else {
if (scope.enableRotate === false) return
handleMouseDownRotate(event)
state = STATE.ROTATE
}
break
case MOUSE.PAN:
if (event.ctrlKey || event.metaKey || event.shiftKey) {
if (scope.enableRotate === false) return
handleMouseDownRotate(event)
state = STATE.ROTATE
} else {
if (scope.enablePan === false) return
handleMouseDownPan(event)
state = STATE.PAN
}
break
default:
state = STATE.NONE
}
if (state !== STATE.NONE) {
// @ts-ignore
scope.dispatchEvent(startEvent)
}
}
function onMouseMove(event: MouseEvent) {
if (scope.enabled === false) return
switch (state) {
case STATE.ROTATE:
if (scope.enableRotate === false) return
handleMouseMoveRotate(event)
break
case STATE.DOLLY:
if (scope.enableZoom === false) return
handleMouseMoveDolly(event)
break
case STATE.PAN:
if (scope.enablePan === false) return
handleMouseMovePan(event)
break
}
}
function onMouseWheel(event: WheelEvent) {
if (scope.enabled === false || scope.enableZoom === false || (state !== STATE.NONE && state !== STATE.ROTATE)) {
return
}
event.preventDefault()
// @ts-ignore
scope.dispatchEvent(startEvent)
handleMouseWheel(event)
// @ts-ignore
scope.dispatchEvent(endEvent)
}
function onKeyDown(event: KeyboardEvent) {
if (scope.enabled === false || scope.enablePan === false) return
handleKeyDown(event)
}
function onTouchStart(event: Pick<PointerEvent, "pointerId" | "pageX" | "pageY">) {
trackPointer(event)
processTouchStart()
if (state !== STATE.NONE) {
// @ts-ignore
scope.dispatchEvent(startEvent)
}
}
function processTouchStart() {
switch (pointers.length) {
case 1:
switch (scope.touches.ONE) {
case TOUCH.ROTATE:
if (scope.enableRotate === false) return
handleTouchStartRotate()
state = STATE.TOUCH_ROTATE
break
case TOUCH.PAN:
if (scope.enablePan === false) return
handleTouchStartPan()
state = STATE.TOUCH_PAN
break
default:
state = STATE.NONE
}
break
case 2:
switch (scope.touches.TWO) {
case TOUCH.DOLLY_PAN:
if (scope.enableZoom === false && scope.enablePan === false) return
handleTouchStartDollyPan()
state = STATE.TOUCH_DOLLY_PAN
break
case TOUCH.DOLLY_ROTATE:
if (scope.enableZoom === false && scope.enableRotate === false) return
handleTouchStartDollyRotate()
state = STATE.TOUCH_DOLLY_ROTATE
break
default:
state = STATE.NONE
}
break
default:
state = STATE.NONE
}
}
function onTouchMove(event: PointerEvent) {
trackPointer(event)
switch (state) {
case STATE.TOUCH_ROTATE:
if (scope.enableRotate === false) return
handleTouchMoveRotate(event)
scope.update()
break
case STATE.TOUCH_PAN:
if (scope.enablePan === false) return
handleTouchMovePan(event)
scope.update()
break
case STATE.TOUCH_DOLLY_PAN:
if (scope.enableZoom === false && scope.enablePan === false) return
handleTouchMoveDollyPan(event)
scope.update()
break
case STATE.TOUCH_DOLLY_ROTATE:
if (scope.enableZoom === false && scope.enableRotate === false) return
handleTouchMoveDollyRotate(event)
scope.update()
break
default:
state = STATE.NONE
}
}
function onContextMenu(event: Event) {
if (scope.enabled === false) return
event.preventDefault()
}
function handleTouchStart(event: TouchEvent) {
const touchIdentifiers = new Set(Array.from(event.touches).map((touch) => touch.identifier))
const initialLength = pointers.length
// Modify `pointers` in place by filtering out elements not in touchIdentifiers
for (let i = pointers.length - 1; i >= 0; i--) {
if (!touchIdentifiers.has(pointers[i].pointerId)) {
pointers.splice(i, 1) // Remove invalid pointer
}
}
if (pointers.length !== initialLength) {
console.log("handleTouchStart: invalid pointers detected, corrected")
processTouchStart()
}
}
function addPointer(event: PointerEvent) {
pointers.push(event)
}
function removePointer(event: PointerEvent) {
delete pointerPositions[event.pointerId]
for (let i = 0; i < pointers.length; i++) {
if (pointers[i].pointerId == event.pointerId) {
pointers.splice(i, 1)
return
}
}
}
function trackPointer(event: Pick<PointerEvent, "pointerId" | "pageX" | "pageY">) {
let position = pointerPositions[event.pointerId]
if (position === undefined) {
position = new Vector2()
pointerPositions[event.pointerId] = position
}
position.set(event.pageX, event.pageY)
}
function getSecondPointerPosition(event: PointerEvent) {
const pointer = event.pointerId === pointers[0].pointerId ? pointers[1] : pointers[0]
return pointerPositions[pointer.pointerId]
}
// Add dolly in/out methods for public API
this.dollyIn = (dollyScale = getZoomScale()) => {
dollyIn(dollyScale)
scope.update()
}
this.dollyOut = (dollyScale = getZoomScale()) => {
dollyOut(dollyScale)
scope.update()
}
this.getScale = () => {
return scale
}
this.setScale = (newScale) => {
setScale(newScale)
scope.update()
}
this.getZoomScale = () => {
return getZoomScale()
}
// connect events
if (domElement !== undefined) this.connect(domElement)
// force an update at start
this.update()
}
}
// This set of controls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
// This is very similar to OrbitControls, another set of touch behavior
//
// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate
// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
// Pan - left mouse, or arrow keys / touch: one-finger move
class MapControls extends OrbitControls {
constructor(object: PerspectiveCamera | OrthographicCamera, domElement?: HTMLElement) {
super(object, domElement)
this.screenSpacePanning = false // pan orthogonal to world-space direction camera.up
this.mouseButtons.LEFT = MOUSE.PAN
this.mouseButtons.RIGHT = MOUSE.ROTATE
this.touches.ONE = TOUCH.PAN
this.touches.TWO = TOUCH.DOLLY_ROTATE
}
}
export { MapControls, OrbitControls }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment