Browse Source

input/controls improvements

Alexander Rose 2 years ago
parent
commit
f3fa54addf

+ 4 - 0
CHANGELOG.md

@@ -12,6 +12,10 @@ Note that since we don't clearly distinguish between a public and private interf
 - Fix occlusion artefact with non-canvas viewport and pixel-ratio > 1
 - Update nodejs-shims conditionals to handle polyfilled document object in NodeJS environment.
 - Ensure marking edges are at least one pixel wide
+- Input/controls improvements
+    - Move or fly around the scene using keys
+    - Pointer lock to look around scene
+    - Toggle spin/rock animation using keys
 
 ## [v3.31.4] - 2023-02-24
 

+ 1 - 1
src/mol-canvas3d/canvas3d.ts

@@ -332,7 +332,7 @@ namespace Canvas3D {
         }, { x, y, width, height }, { pixelScale: attribs.pixelScale });
         const stereoCamera = new StereoCamera(camera, p.camera.stereo.params);
 
-        const controls = TrackballControls.create(input, camera, p.trackball);
+        const controls = TrackballControls.create(input, camera, scene, p.trackball);
         const renderer = Renderer.create(webgl, p.renderer);
         const helper = new Helper(webgl, scene, p);
 

+ 318 - 36
src/mol-canvas3d/controls/trackball.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
@@ -10,19 +10,21 @@
 
 import { Quat, Vec2, Vec3, EPSILON } from '../../mol-math/linear-algebra';
 import { Viewport } from '../camera/util';
-import { InputObserver, DragInput, WheelInput, PinchInput, ButtonsType, ModifiersKeys, GestureInput } from '../../mol-util/input/input-observer';
+import { InputObserver, DragInput, WheelInput, PinchInput, ButtonsType, ModifiersKeys, GestureInput, KeyInput, MoveInput } from '../../mol-util/input/input-observer';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { Camera } from '../camera';
 import { absMax, degToRad } from '../../mol-math/misc';
 import { Binding } from '../../mol-util/binding';
+import { Scene } from '../../mol-gl/scene';
 
 const B = ButtonsType;
 const M = ModifiersKeys;
 const Trigger = Binding.Trigger;
+const Key = Binding.TriggerKey;
 
 export const DefaultTrackballBindings = {
     dragRotate: Binding([Trigger(B.Flag.Primary, M.create())], 'Rotate', 'Drag using ${triggers}'),
-    dragRotateZ: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Rotate around z-axis', 'Drag using ${triggers}'),
+    dragRotateZ: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Rotate around z-axis (roll)', 'Drag using ${triggers}'),
     dragPan: Binding([Trigger(B.Flag.Secondary, M.create()), Trigger(B.Flag.Primary, M.create({ control: true }))], 'Pan', 'Drag using ${triggers}'),
     dragZoom: Binding.Empty,
     dragFocus: Binding([Trigger(B.Flag.Forth, M.create())], 'Focus', 'Drag using ${triggers}'),
@@ -31,6 +33,22 @@ export const DefaultTrackballBindings = {
     scrollZoom: Binding([Trigger(B.Flag.Auxilary, M.create())], 'Zoom', 'Scroll using ${triggers}'),
     scrollFocus: Binding([Trigger(B.Flag.Auxilary, M.create({ shift: true }))], 'Clip', 'Scroll using ${triggers}'),
     scrollFocusZoom: Binding.Empty,
+
+    keyMoveForward: Binding([Key('KeyW')], 'Move forward', 'Press ${triggers}'),
+    keyMoveBack: Binding([Key('KeyS')], 'Move back', 'Press ${triggers}'),
+    keyMoveLeft: Binding([Key('KeyA')], 'Move left', 'Press ${triggers}'),
+    keyMoveRight: Binding([Key('KeyD')], 'Move right', 'Press ${triggers}'),
+    keyMoveUp: Binding([Key('KeyR')], 'Move up', 'Press ${triggers}'),
+    keyMoveDown: Binding([Key('KeyF')], 'Move down', 'Press ${triggers}'),
+    keyRollLeft: Binding([Key('KeyQ')], 'Roll left', 'Press ${triggers}'),
+    keyRollRight: Binding([Key('KeyE')], 'Roll right', 'Press ${triggers}'),
+    keyPitchUp: Binding([Key('ArrowUp')], 'Pitch up', 'Press ${triggers}'),
+    keyPitchDown: Binding([Key('ArrowDown')], 'Pitch down', 'Press ${triggers}'),
+    keyYawLeft: Binding([Key('ArrowLeft')], 'Yaw left', 'Press ${triggers}'),
+    keyYawRight: Binding([Key('ArrowRight')], 'Yaw right', 'Press ${triggers}'),
+
+    boostMove: Binding([Key('ShiftLeft')], 'Boost move', 'Press ${triggers}'),
+    enablePointerLock: Binding([Key('Space', M.create({ control: true }))], 'Enable pointer lock', 'Press ${triggers}'),
 };
 
 export const TrackballControlsParams = {
@@ -39,6 +57,9 @@ export const TrackballControlsParams = {
     rotateSpeed: PD.Numeric(5.0, { min: 1, max: 10, step: 1 }),
     zoomSpeed: PD.Numeric(7.0, { min: 1, max: 15, step: 1 }),
     panSpeed: PD.Numeric(1.0, { min: 0.1, max: 5, step: 0.1 }),
+    moveSpeed: PD.Numeric(0.75, { min: 0.1, max: 3, step: 0.1 }),
+    boostMoveFactor: PD.Numeric(5.0, { min: 0.1, max: 10, step: 0.1 }),
+    flyMode: PD.Boolean(false),
 
     animate: PD.MappedStatic('off', {
         off: PD.EmptyGroup(),
@@ -92,8 +113,10 @@ interface TrackballControls {
     dispose: () => void
 }
 namespace TrackballControls {
-    export function create(input: InputObserver, camera: Camera, props: Partial<TrackballControlsProps> = {}): TrackballControls {
+    export function create(input: InputObserver, camera: Camera, scene: Scene, props: Partial<TrackballControlsProps> = {}): TrackballControls {
         const p = { ...PD.getDefaultValues(TrackballControlsParams), ...props };
+        // include defaults for backwards state compatibility
+        const b = { ...DefaultTrackballBindings, ...p.bindings };
 
         const viewport = Viewport.clone(camera.viewport);
 
@@ -104,6 +127,11 @@ namespace TrackballControls {
         const wheelSub = input.wheel.subscribe(onWheel);
         const pinchSub = input.pinch.subscribe(onPinch);
         const gestureSub = input.gesture.subscribe(onGesture);
+        const keyDownSub = input.keyDown.subscribe(onKeyDown);
+        const keyUpSub = input.keyUp.subscribe(onKeyUp);
+        const moveSub = input.move.subscribe(onMove);
+        const lockSub = input.lock.subscribe(onLock);
+        const leaveSub = input.leave.subscribe(onLeave);
 
         let _isInteracting = false;
 
@@ -117,9 +145,12 @@ namespace TrackballControls {
         const _rotLastAxis = Vec3();
         let _rotLastAngle = 0;
 
-        const _zRotPrev = Vec2();
-        const _zRotCurr = Vec2();
-        let _zRotLastAngle = 0;
+        const _rollPrev = Vec2();
+        const _rollCurr = Vec2();
+        let _rollLastAngle = 0;
+
+        let _pitchLastAngle = 0;
+        let _yawLastAngle = 0;
 
         const _zoomStart = Vec2();
         const _zoomEnd = Vec2();
@@ -149,7 +180,7 @@ namespace TrackballControls {
             return Vec2.set(
                 mouseOnCircleVec2,
                 (pageX - viewport.width * 0.5 - viewport.x) / (viewport.width * 0.5),
-                (viewport.height + 2 * (viewport.y - pageY)) / viewport.width // screen.width intentional
+                (viewport.height + 2 * (viewport.y - pageY)) / viewport.width // viewport.width intentional
             );
         }
 
@@ -203,26 +234,74 @@ namespace TrackballControls {
             Vec2.copy(_rotPrev, _rotCurr);
         }
 
-        const zRotQuat = Quat();
+        const rollQuat = Quat();
+        const rollDir = Vec3();
 
-        function zRotateCamera() {
-            const dx = _zRotCurr[0] - _zRotPrev[0];
-            const dy = _zRotCurr[1] - _zRotPrev[1];
-            const angle = p.rotateSpeed * (-dx + dy) * -0.05;
+        function rollCamera() {
+            const k = (keyState.rollRight - keyState.rollLeft) / 45;
+            const dx = (_rollCurr[0] - _rollPrev[0]) * -Math.sign(_rollCurr[1]);
+            const dy = (_rollCurr[1] - _rollPrev[1]) * -Math.sign(_rollCurr[0]);
+            const angle = -p.rotateSpeed * (-dx + dy) + k;
 
             if (angle) {
-                Vec3.sub(_eye, camera.position, camera.target);
-                Quat.setAxisAngle(zRotQuat, _eye, angle);
-                Vec3.transformQuat(camera.up, camera.up, zRotQuat);
-                _zRotLastAngle = angle;
-            } else if (!p.staticMoving && _zRotLastAngle) {
-                _zRotLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
-                Vec3.sub(_eye, camera.position, camera.target);
-                Quat.setAxisAngle(zRotQuat, _eye, _zRotLastAngle);
-                Vec3.transformQuat(camera.up, camera.up, zRotQuat);
+                Vec3.normalize(rollDir, _eye);
+                Quat.setAxisAngle(rollQuat, rollDir, angle);
+                Vec3.transformQuat(camera.up, camera.up, rollQuat);
+                _rollLastAngle = angle;
+            } else if (!p.staticMoving && _rollLastAngle) {
+                _rollLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.normalize(rollDir, _eye);
+                Quat.setAxisAngle(rollQuat, rollDir, _rollLastAngle);
+                Vec3.transformQuat(camera.up, camera.up, rollQuat);
+            }
+
+            Vec2.copy(_rollPrev, _rollCurr);
+        }
+
+        const pitchQuat = Quat();
+        const pitchDir = Vec3();
+
+        function pitchCamera() {
+            const m = (keyState.pitchUp - keyState.pitchDown) / (p.flyMode ? 360 : 90);
+            const angle = -p.rotateSpeed * m;
+
+            if (angle) {
+                Vec3.cross(pitchDir, _eye, camera.up);
+                Vec3.normalize(pitchDir, pitchDir);
+                Quat.setAxisAngle(pitchQuat, pitchDir, angle);
+                Vec3.transformQuat(_eye, _eye, pitchQuat);
+                Vec3.transformQuat(camera.up, camera.up, pitchQuat);
+                _pitchLastAngle = angle;
+            } else if (!p.staticMoving && _pitchLastAngle) {
+                _pitchLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.cross(pitchDir, _eye, camera.up);
+                Vec3.normalize(pitchDir, pitchDir);
+                Quat.setAxisAngle(pitchQuat, pitchDir, _pitchLastAngle);
+                Vec3.transformQuat(_eye, _eye, pitchQuat);
+                Vec3.transformQuat(camera.up, camera.up, pitchQuat);
             }
+        }
+
+        const yawQuat = Quat();
+        const yawDir = Vec3();
+
+        function yawCamera() {
+            const m = (keyState.yawRight - keyState.yawLeft) / (p.flyMode ? 360 : 90);
+            const angle = -p.rotateSpeed * m;
 
-            Vec2.copy(_zRotPrev, _zRotCurr);
+            if (angle) {
+                Vec3.normalize(yawDir, camera.up);
+                Quat.setAxisAngle(yawQuat, yawDir, angle);
+                Vec3.transformQuat(_eye, _eye, yawQuat);
+                Vec3.transformQuat(camera.up, camera.up, yawQuat);
+                _yawLastAngle = angle;
+            } else if (!p.staticMoving && _yawLastAngle) {
+                _yawLastAngle *= Math.sqrt(1.0 - p.dynamicDampingFactor);
+                Vec3.normalize(yawDir, camera.up);
+                Quat.setAxisAngle(yawQuat, yawDir, _yawLastAngle);
+                Vec3.transformQuat(_eye, _eye, yawQuat);
+                Vec3.transformQuat(camera.up, camera.up, yawQuat);
+            }
         }
 
         function zoomCamera() {
@@ -283,6 +362,91 @@ namespace TrackballControls {
             }
         }
 
+        const keyState = {
+            moveUp: 0, moveDown: 0, moveLeft: 0, moveRight: 0, moveForward: 0, moveBack: 0,
+            pitchUp: 0, pitchDown: 0, yawLeft: 0, yawRight: 0, rollLeft: 0, rollRight: 0,
+            boostMove: 0,
+        };
+
+        const moveDir = Vec3();
+        const moveEye = Vec3();
+
+        function moveCamera() {
+            Vec3.sub(moveEye, camera.position, camera.target);
+            Vec3.setMagnitude(moveEye, moveEye, camera.state.minNear);
+
+            const moveSpeed = p.moveSpeed * (keyState.boostMove === 1 ? p.boostMoveFactor : 1);
+
+            if (keyState.moveForward === 1) {
+                Vec3.normalize(moveDir, moveEye);
+                Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                const dt = Vec3.distance(camera.target, camera.position);
+                const ds = Vec3.distance(scene.boundingSphereVisible.center, camera.position);
+                if (p.flyMode || input.pointerLock || (dt < camera.state.minNear && ds < camera.state.radiusMax)) {
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                }
+            }
+
+            if (keyState.moveBack === 1) {
+                Vec3.normalize(moveDir, moveEye);
+                Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                }
+            }
+
+            if (keyState.moveLeft === 1) {
+                Vec3.cross(moveDir, moveEye, camera.up);
+                Vec3.normalize(moveDir, moveDir);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveRight === 1) {
+                Vec3.cross(moveDir, moveEye, camera.up);
+                Vec3.normalize(moveDir, moveDir);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveUp === 1) {
+                Vec3.normalize(moveDir, camera.up);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (keyState.moveDown === 1) {
+                Vec3.normalize(moveDir, camera.up);
+                if (p.flyMode || input.pointerLock) {
+                    Vec3.scaleAndSub(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, moveEye);
+                } else {
+                    Vec3.scaleAndAdd(camera.position, camera.position, moveDir, moveSpeed);
+                    Vec3.sub(camera.target, camera.position, _eye);
+                }
+            }
+
+            if (p.flyMode || input.pointerLock) {
+                const ds = Vec3.distance(scene.boundingSphereVisible.center, camera.position);
+                camera.setState({ radius: Math.max(ds, camera.state.radius) });
+            }
+        }
+
         /**
          * Ensure the distance between object and target is within the min/max distance
          * and not too large compared to `camera.state.radiusMax`
@@ -327,7 +491,9 @@ namespace TrackballControls {
             Vec3.sub(_eye, camera.position, camera.target);
 
             rotateCamera();
-            zRotateCamera();
+            rollCamera();
+            pitchCamera();
+            yawCamera();
             zoomCamera();
             focusCamera();
             panCamera();
@@ -335,6 +501,11 @@ namespace TrackballControls {
             Vec3.add(camera.position, camera.target, _eye);
             checkDistances();
 
+            moveCamera();
+
+            Vec3.sub(_eye, camera.position, camera.target);
+            checkDistances();
+
             if (Vec3.squaredDistance(lastPosition, camera.position) > EPSILON) {
                 Vec3.copy(lastPosition, camera.position);
             }
@@ -363,24 +534,28 @@ namespace TrackballControls {
             _isInteracting = true;
             resetRock(); // start rocking from the center after interactions
 
-            const dragRotate = Binding.match(p.bindings.dragRotate, buttons, modifiers);
-            const dragRotateZ = Binding.match(p.bindings.dragRotateZ, buttons, modifiers);
-            const dragPan = Binding.match(p.bindings.dragPan, buttons, modifiers);
-            const dragZoom = Binding.match(p.bindings.dragZoom, buttons, modifiers);
-            const dragFocus = Binding.match(p.bindings.dragFocus, buttons, modifiers);
-            const dragFocusZoom = Binding.match(p.bindings.dragFocusZoom, buttons, modifiers);
+            const dragRotate = Binding.match(b.dragRotate, buttons, modifiers);
+            const dragRotateZ = Binding.match(b.dragRotateZ, buttons, modifiers);
+            const dragPan = Binding.match(b.dragPan, buttons, modifiers);
+            const dragZoom = Binding.match(b.dragZoom, buttons, modifiers);
+            const dragFocus = Binding.match(b.dragFocus, buttons, modifiers);
+            const dragFocusZoom = Binding.match(b.dragFocusZoom, buttons, modifiers);
 
             getMouseOnCircle(pageX, pageY);
             getMouseOnScreen(pageX, pageY);
 
+            const pr = input.pixelRatio;
+            const vx = (x * pr - viewport.width / 2 - viewport.x) / viewport.width;
+            const vy = -(input.height - y * pr - viewport.height / 2 - viewport.y) / viewport.height;
+
             if (isStart) {
                 if (dragRotate) {
                     Vec2.copy(_rotCurr, mouseOnCircleVec2);
                     Vec2.copy(_rotPrev, _rotCurr);
                 }
                 if (dragRotateZ) {
-                    Vec2.copy(_zRotCurr, mouseOnCircleVec2);
-                    Vec2.copy(_zRotPrev, _zRotCurr);
+                    Vec2.set(_rollCurr, vx, vy);
+                    Vec2.copy(_rollPrev, _rollCurr);
                 }
                 if (dragZoom || dragFocusZoom) {
                     Vec2.copy(_zoomStart, mouseOnScreenVec2);
@@ -397,7 +572,7 @@ namespace TrackballControls {
             }
 
             if (dragRotate) Vec2.copy(_rotCurr, mouseOnCircleVec2);
-            if (dragRotateZ) Vec2.copy(_zRotCurr, mouseOnCircleVec2);
+            if (dragRotateZ) Vec2.set(_rollCurr, vx, vy);
             if (dragZoom || dragFocusZoom) Vec2.copy(_zoomEnd, mouseOnScreenVec2);
             if (dragFocus) Vec2.copy(_focusEnd, mouseOnScreenVec2);
             if (dragFocusZoom) {
@@ -418,16 +593,16 @@ namespace TrackballControls {
             if (delta < -p.maxWheelDelta) delta = -p.maxWheelDelta;
             else if (delta > p.maxWheelDelta) delta = p.maxWheelDelta;
 
-            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
+            if (Binding.match(b.scrollZoom, buttons, modifiers)) {
                 _zoomEnd[1] += delta;
             }
-            if (Binding.match(p.bindings.scrollFocus, buttons, modifiers)) {
+            if (Binding.match(b.scrollFocus, buttons, modifiers)) {
                 _focusEnd[1] += delta;
             }
         }
 
         function onPinch({ fractionDelta, buttons, modifiers }: PinchInput) {
-            if (Binding.match(p.bindings.scrollZoom, buttons, modifiers)) {
+            if (Binding.match(b.scrollZoom, buttons, modifiers)) {
                 _isInteracting = true;
                 _zoomEnd[1] += p.gestureScaleFactor * fractionDelta;
             }
@@ -438,6 +613,108 @@ namespace TrackballControls {
             _zoomEnd[1] += p.gestureScaleFactor * deltaScale;
         }
 
+        function onMove({ movementX, movementY }: MoveInput) {
+            if (!input.pointerLock || movementX === undefined || movementY === undefined) return;
+
+            const cx = viewport.width * 0.5 - viewport.x;
+            const cy = viewport.height * 0.5 - viewport.y;
+
+            Vec2.copy(_rotPrev, getMouseOnCircle(cx, cy));
+            Vec2.copy(_rotCurr, getMouseOnCircle(movementX + cx, movementY + cy));
+        }
+
+        function onKeyDown({ modifiers, code }: KeyInput) {
+            if (Binding.matchKey(b.keyMoveForward, code, modifiers)) {
+                keyState.moveForward = 1;
+            } else if (Binding.matchKey(b.keyMoveBack, code, modifiers)) {
+                keyState.moveBack = 1;
+            } else if (Binding.matchKey(b.keyMoveLeft, code, modifiers)) {
+                keyState.moveLeft = 1;
+            } else if (Binding.matchKey(b.keyMoveRight, code, modifiers)) {
+                keyState.moveRight = 1;
+            } else if (Binding.matchKey(b.keyMoveUp, code, modifiers)) {
+                keyState.moveUp = 1;
+            } else if (Binding.matchKey(b.keyMoveDown, code, modifiers)) {
+                keyState.moveDown = 1;
+            } else if (Binding.matchKey(b.keyRollLeft, code, modifiers)) {
+                keyState.rollLeft = 1;
+            } else if (Binding.matchKey(b.keyRollRight, code, modifiers)) {
+                keyState.rollRight = 1;
+            } else if (Binding.matchKey(b.keyPitchUp, code, modifiers)) {
+                keyState.pitchUp = 1;
+            } else if (Binding.matchKey(b.keyPitchDown, code, modifiers)) {
+                keyState.pitchDown = 1;
+            } else if (Binding.matchKey(b.keyYawLeft, code, modifiers)) {
+                keyState.yawLeft = 1;
+            } else if (Binding.matchKey(b.keyYawRight, code, modifiers)) {
+                keyState.yawRight = 1;
+            }
+
+            if (Binding.matchKey(b.boostMove, code, modifiers)) {
+                keyState.boostMove = 1;
+            }
+
+            if (Binding.matchKey(b.enablePointerLock, code, modifiers)) {
+                input.requestPointerLock(viewport);
+            }
+        }
+
+        function onKeyUp({ modifiers, code }: KeyInput) {
+            if (Binding.matchKey(b.keyMoveForward, code, modifiers)) {
+                keyState.moveForward = 0;
+            } else if (Binding.matchKey(b.keyMoveBack, code, modifiers)) {
+                keyState.moveBack = 0;
+            } else if (Binding.matchKey(b.keyMoveLeft, code, modifiers)) {
+                keyState.moveLeft = 0;
+            } else if (Binding.matchKey(b.keyMoveRight, code, modifiers)) {
+                keyState.moveRight = 0;
+            } else if (Binding.matchKey(b.keyMoveUp, code, modifiers)) {
+                keyState.moveUp = 0;
+            } else if (Binding.matchKey(b.keyMoveDown, code, modifiers)) {
+                keyState.moveDown = 0;
+            } else if (Binding.matchKey(b.keyRollLeft, code, modifiers)) {
+                keyState.rollLeft = 0;
+            } else if (Binding.matchKey(b.keyRollRight, code, modifiers)) {
+                keyState.rollRight = 0;
+            } else if (Binding.matchKey(b.keyPitchUp, code, modifiers)) {
+                keyState.pitchUp = 0;
+            } else if (Binding.matchKey(b.keyPitchDown, code, modifiers)) {
+                keyState.pitchDown = 0;
+            } else if (Binding.matchKey(b.keyYawLeft, code, modifiers)) {
+                keyState.yawLeft = 0;
+            } else if (Binding.matchKey(b.keyYawRight, code, modifiers)) {
+                keyState.yawRight = 0;
+            }
+
+            if (Binding.matchKey(b.boostMove, code, modifiers)) {
+                keyState.boostMove = 0;
+            }
+        }
+
+        function onLock(isLocked: boolean) {
+            if (isLocked) {
+                Vec3.sub(moveEye, camera.position, camera.target);
+                Vec3.setMagnitude(moveEye, moveEye, camera.state.minNear);
+                Vec3.sub(camera.target, camera.position, moveEye);
+            }
+        }
+
+        function onLeave() {
+            keyState.moveForward = 0;
+            keyState.moveBack = 0;
+            keyState.moveLeft = 0;
+            keyState.moveRight = 0;
+            keyState.moveUp = 0;
+            keyState.moveDown = 0;
+            keyState.rollLeft = 0;
+            keyState.rollRight = 0;
+            keyState.pitchUp = 0;
+            keyState.pitchDown = 0;
+            keyState.yawLeft = 0;
+            keyState.yawRight = 0;
+            keyState.boostMove = 0;
+        }
+
         function dispose() {
             if (disposed) return;
             disposed = true;
@@ -447,6 +724,11 @@ namespace TrackballControls {
             pinchSub.unsubscribe();
             gestureSub.unsubscribe();
             interactionEndSub.unsubscribe();
+            keyDownSub.unsubscribe();
+            keyUpSub.unsubscribe();
+            moveSub.unsubscribe();
+            lockSub.unsubscribe();
+            leaveSub.unsubscribe();
         }
 
         const _spinSpeed = Vec2.create(0.005, 0);

+ 2 - 2
src/mol-canvas3d/helper/interaction-events.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -68,7 +68,7 @@ export class Canvas3dInteractionHelper {
     }
 
     private identify(e: InputEvent, t: number) {
-        const xyChanged = this.startX !== this.endX || this.startY !== this.endY;
+        const xyChanged = this.startX !== this.endX || this.startY !== this.endY || this.input.pointerLock;
 
         if (e === InputEvent.Drag) {
             if (xyChanged && !this.outsideViewport(this.startX, this.startY)) {

+ 40 - 1
src/mol-plugin/behavior/dynamic/camera.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -17,6 +17,7 @@ import { Vec3 } from '../../../mol-math/linear-algebra';
 const B = ButtonsType;
 const M = ModifiersKeys;
 const Trigger = Binding.Trigger;
+const Key = Binding.TriggerKey;
 
 const DefaultFocusLociBindings = {
     clickCenterFocus: Binding([
@@ -28,6 +29,8 @@ const DefaultFocusLociBindings = {
         Trigger(B.Flag.Secondary, M.create()),
         Trigger(B.Flag.Primary, M.create({ control: true }))
     ], 'Camera center and focus', 'Click element using ${triggers}'),
+    keySpinAnimation: Binding([Key('KeyI')], 'Spin Animation', 'Press ${triggers}'),
+    keyRockAnimation: Binding([Key('KeyO')], 'Rock Animation', 'Press ${triggers}'),
 };
 const FocusLociParams = {
     minRadius: PD.Numeric(8, { min: 1, max: 50, step: 1 }),
@@ -60,6 +63,42 @@ export const FocusLoci = PluginBehavior.create<FocusLociProps>({
                     this.ctx.managers.camera.focusLoci(loci, this.params);
                 }
             });
+
+            this.subscribeObservable(this.ctx.behaviors.interaction.key, ({ code, modifiers }) => {
+                if (!this.ctx.canvas3d) return;
+
+                // include defaults for backwards state compatibility
+                const b = { ...DefaultFocusLociBindings, ...this.params.bindings };
+                const p = this.ctx.canvas3d.props.trackball;
+
+                if (Binding.matchKey(b.keySpinAnimation, code, modifiers)) {
+                    const name = p.animate.name !== 'spin' ? 'spin' : 'off';
+                    if (name === 'off') {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: { name, params: {} } }
+                        });
+                    } else {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: {
+                                name, params: { speed: 1 } }
+                            }
+                        });
+                    }
+                } else if (Binding.matchKey(b.keyRockAnimation, code, modifiers)) {
+                    const name = p.animate.name !== 'rock' ? 'rock' : 'off';
+                    if (name === 'off') {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: { name, params: {} } }
+                        });
+                    } else {
+                        this.ctx.canvas3d.setProps({
+                            trackball: { animate: {
+                                name, params: { speed: 0.3, angle: 10 } }
+                            }
+                        });
+                    }
+                }
+            });
         }
     },
     params: () => FocusLociParams,

+ 3 - 3
src/mol-plugin/behavior/dynamic/representation.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -94,9 +94,9 @@ export const HighlightLoci = PluginBehavior.create({
 
 const DefaultSelectLociBindings = {
     clickSelect: Binding.Empty,
-    clickToggleExtend: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Toggle extended selection', '${triggers} to extend selection along polymer'),
+    clickToggleExtend: Binding([Trigger(B.Flag.Primary, M.create({ shift: true }))], 'Toggle extended selection', 'Click on element using ${triggers} to extend selection along polymer'),
     clickSelectOnly: Binding.Empty,
-    clickToggle: Binding([Trigger(B.Flag.Primary, M.create())], 'Toggle selection', '${triggers} on element'),
+    clickToggle: Binding([Trigger(B.Flag.Primary, M.create())], 'Toggle selection', 'Click on element using ${triggers}'),
     clickDeselect: Binding.Empty,
     clickDeselectAllOnEmpty: Binding([Trigger(B.Flag.Primary, M.create())], 'Deselect all', 'Click on nothing using ${triggers}'),
 };

+ 5 - 3
src/mol-plugin/context.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -43,7 +43,7 @@ import { AssetManager } from '../mol-util/assets';
 import { Color } from '../mol-util/color';
 import { ajaxGet } from '../mol-util/data-source';
 import { isDebugMode, isProductionMode } from '../mol-util/debug';
-import { ModifiersKeys } from '../mol-util/input/input-observer';
+import { EmptyKeyInput, KeyInput, ModifiersKeys } from '../mol-util/input/input-observer';
 import { LogEntry } from '../mol-util/log-entry';
 import { objectForEach } from '../mol-util/object';
 import { RxEventHelper } from '../mol-util/rx-event-helper';
@@ -95,7 +95,8 @@ export class PluginContext {
             hover: this.ev.behavior<InteractivityManager.HoverEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0 }),
             click: this.ev.behavior<InteractivityManager.ClickEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0 }),
             drag: this.ev.behavior<InteractivityManager.DragEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0, button: 0, pageStart: Vec2(), pageEnd: Vec2() }),
-            selectionMode: this.ev.behavior<boolean>(false)
+            key: this.ev.behavior<KeyInput>(EmptyKeyInput),
+            selectionMode: this.ev.behavior<boolean>(false),
         },
         labels: {
             highlight: this.ev.behavior<{ labels: ReadonlyArray<LociLabel> }>({ labels: [] })
@@ -293,6 +294,7 @@ export class PluginContext {
             this.subs.push(this.canvas3d!.interaction.drag.subscribe(e => this.behaviors.interaction.drag.next(e)));
             this.subs.push(this.canvas3d!.interaction.hover.subscribe(e => this.behaviors.interaction.hover.next(e)));
             this.subs.push(this.canvas3d!.input.resize.subscribe(() => this.handleResize()));
+            this.subs.push(this.canvas3d!.input.keyDown.subscribe(e => this.behaviors.interaction.key.next(e)));
             this.subs.push(this.layout.events.updated.subscribe(() => requestAnimationFrame(() => this.handleResize())));
 
             this.handleResize();

+ 33 - 10
src/mol-util/binding.ts

@@ -1,11 +1,11 @@
 /**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2019-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { ButtonsType, ModifiersKeys } from './input/input-observer';
-import { interpolate, stringToWords } from './string';
+import { ButtonsType, KeyCode, ModifiersKeys } from './input/input-observer';
+import { camelCaseToWords, interpolate, stringToWords } from './string';
 
 export { Binding };
 
@@ -31,13 +31,17 @@ namespace Binding {
     export const Empty: Binding = { triggers: [], action: '', description: '' };
     export function isEmpty(binding: Binding) {
         return binding.triggers.length === 0 ||
-            binding.triggers.every(t => t.buttons === undefined && t.modifiers === undefined);
+            binding.triggers.every(t => t.buttons === undefined && t.modifiers === undefined && !t.code);
     }
 
     export function match(binding: Binding, buttons: ButtonsType, modifiers: ModifiersKeys) {
         return binding.triggers.some(t => Trigger.match(t, buttons, modifiers));
     }
 
+    export function matchKey(binding: Binding, code: KeyCode, modifiers: ModifiersKeys) {
+        return binding.triggers.some(t => Trigger.matchKey(t, code, modifiers));
+    }
+
     export function formatTriggers(binding: Binding) {
         return binding.triggers.map(Trigger.format).join(' or ');
     }
@@ -50,15 +54,20 @@ namespace Binding {
     export interface Trigger {
         buttons?: ButtonsType,
         modifiers?: ModifiersKeys
+        code?: KeyCode
     }
 
     export function Trigger(buttons?: ButtonsType, modifiers?: ModifiersKeys) {
         return Trigger.create(buttons, modifiers);
     }
 
+    export function TriggerKey(code?: KeyCode, modifiers?: ModifiersKeys) {
+        return Trigger.create(undefined, modifiers, code);
+    }
+
     export namespace Trigger {
-        export function create(buttons?: ButtonsType, modifiers?: ModifiersKeys): Trigger {
-            return { buttons, modifiers };
+        export function create(buttons?: ButtonsType, modifiers?: ModifiersKeys, code?: KeyCode): Trigger {
+            return { buttons, modifiers, code };
         }
         export const Empty: Trigger = {};
 
@@ -69,10 +78,19 @@ namespace Binding {
                 (!m || ModifiersKeys.areEqual(m, modifiers));
         }
 
+        export function matchKey(trigger: Trigger, code: KeyCode, modifiers: ModifiersKeys): boolean {
+            const { modifiers: m, code: c } = trigger;
+            return c !== undefined &&
+                (c === code) &&
+                (!m || ModifiersKeys.areEqual(m, modifiers));
+        }
+
         export function format(trigger: Trigger) {
             const s: string[] = [];
-            const b = formatButtons(trigger.buttons);
+            const b = formatButtons(trigger.buttons, trigger.code);
             if (b) s.push(b);
+            const c = formatCode(trigger.code);
+            if (c) s.push(c);
             const m = formatModifiers(trigger.modifiers);
             if (m) s.push(m);
             return s.join(' + ');
@@ -82,13 +100,13 @@ namespace Binding {
 
 const B = ButtonsType;
 
-function formatButtons(buttons?: ButtonsType) {
+function formatButtons(buttons?: ButtonsType, code?: KeyCode) {
     const s: string[] = [];
-    if (buttons === undefined) {
+    if (buttons === undefined && !code) {
         s.push('any mouse button');
     } else if (buttons === 0) {
         s.push('mouse hover');
-    } else {
+    } else if (buttons !== undefined) {
         if (B.has(buttons, B.Flag.Primary)) s.push('left mouse button');
         if (B.has(buttons, B.Flag.Secondary)) s.push('right mouse button');
         if (B.has(buttons, B.Flag.Auxilary)) s.push('wheel/middle mouse button');
@@ -110,4 +128,9 @@ function formatModifiers(modifiers?: ModifiersKeys, verbose?: boolean) {
         if (verbose) s.push('any key');
     }
     return s.join(' + ');
+}
+
+function formatCode(code?: KeyCode) {
+    if (code?.startsWith('Key')) code = code.substring(3);
+    return code && camelCaseToWords(code).toLowerCase();
 }

+ 181 - 44
src/mol-util/input/input-observer.ts

@@ -1,11 +1,12 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2023 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
 import { Subject, Observable } from 'rxjs';
+import { Viewport } from '../../mol-canvas3d/camera/util';
 
 import { Vec2, EPSILON } from '../../mol-math/linear-algebra';
 
@@ -122,6 +123,8 @@ export namespace ButtonsType {
     }
 }
 
+export type KeyCode = string
+
 type BaseInput = {
     buttons: ButtonsType
     button: ButtonsType.Flag
@@ -162,6 +165,8 @@ export type MoveInput = {
     y: number,
     pageX: number,
     pageY: number,
+    movementX?: number,
+    movementY?: number,
     inside: boolean,
 } & BaseInput
 
@@ -184,9 +189,20 @@ export type GestureInput = {
 
 export type KeyInput = {
     key: string,
+    code: string,
     modifiers: ModifiersKeys
+
+    /** for overwriting browser shortcuts like `ctrl+s` as needed */
+    preventDefault: () => void
 }
 
+export const EmptyKeyInput: KeyInput = {
+    key: '',
+    code: '',
+    modifiers: ModifiersKeys.None,
+    preventDefault: noop,
+};
+
 export type ResizeInput = {
 
 }
@@ -202,6 +218,8 @@ type PointerEvent = {
     clientY: number
     pageX: number
     pageY: number
+    movementX?: number
+    movementY?: number
 
     preventDefault?: () => void
 }
@@ -218,6 +236,7 @@ interface InputObserver {
     readonly width: number
     readonly height: number
     readonly pixelRatio: number
+    readonly pointerLock: boolean
 
     readonly drag: Observable<DragInput>,
     // Equivalent to mouseUp and touchEnd
@@ -232,7 +251,12 @@ interface InputObserver {
     readonly resize: Observable<ResizeInput>,
     readonly modifiers: Observable<ModifiersKeys>
     readonly key: Observable<KeyInput>
+    readonly keyUp: Observable<KeyInput>
+    readonly keyDown: Observable<KeyInput>
+    readonly lock: Observable<boolean>
 
+    requestPointerLock: (viewport: Viewport) => void
+    exitPointerLock: () => void
     dispose: () => void
 }
 
@@ -250,6 +274,9 @@ function createEvents() {
         enter: new Subject<undefined>(),
         modifiers: new Subject<ModifiersKeys>(),
         key: new Subject<KeyInput>(),
+        keyUp: new Subject<KeyInput>(),
+        keyDown: new Subject<KeyInput>(),
+        lock: new Subject<boolean>(),
     };
 }
 
@@ -261,6 +288,7 @@ namespace InputObserver {
         return {
             noScroll,
             noContextMenu,
+            pointerLock: false,
 
             width: 0,
             height: 0,
@@ -268,6 +296,8 @@ namespace InputObserver {
 
             ...createEvents(),
 
+            requestPointerLock: noop,
+            exitPointerLock: noop,
             dispose: noop
         };
     }
@@ -278,6 +308,9 @@ namespace InputObserver {
         let width = element.clientWidth * pixelRatio();
         let height = element.clientHeight * pixelRatio();
 
+        let isLocked = false;
+        let lockedViewport = Viewport();
+
         let lastTouchDistance = 0, lastTouchFraction = 0;
         const pointerDown = Vec2();
         const pointerStart = Vec2();
@@ -307,25 +340,10 @@ namespace InputObserver {
         let hasMoved = false;
 
         const events = createEvents();
-        const { drag, interactionEnd, wheel, pinch, gesture, click, move, leave, enter, resize, modifiers, key } = events;
+        const { drag, interactionEnd, wheel, pinch, gesture, click, move, leave, enter, resize, modifiers, key, keyUp, keyDown, lock } = events;
 
         attach();
 
-        return {
-            get noScroll() { return noScroll; },
-            set noScroll(value: boolean) { noScroll = value; },
-            get noContextMenu() { return noContextMenu; },
-            set noContextMenu(value: boolean) { noContextMenu = value; },
-
-            get width() { return width; },
-            get height() { return height; },
-            get pixelRatio() { return pixelRatio(); },
-
-            ...events,
-
-            dispose
-        };
-
         function attach() {
             element.addEventListener('contextmenu', onContextMenu as any, false);
 
@@ -337,9 +355,6 @@ namespace InputObserver {
             window.addEventListener('mousemove', onMouseMove as any, false);
             window.addEventListener('mouseup', onMouseUp as any, false);
 
-            element.addEventListener('mouseenter', onMouseEnter as any, false);
-            element.addEventListener('mouseleave', onMouseLeave as any, false);
-
             element.addEventListener('touchstart', onTouchStart as any, false);
             element.addEventListener('touchmove', onTouchMove as any, false);
             element.addEventListener('touchend', onTouchEnd as any, false);
@@ -354,6 +369,9 @@ namespace InputObserver {
             window.addEventListener('keydown', handleKeyDown as EventListener, false);
             window.addEventListener('keypress', handleKeyPress as EventListener, false);
 
+            document.addEventListener('pointerlockchange', onPointerLockChange, false);
+            document.addEventListener('pointerlockerror', onPointerLockError, false);
+
             window.addEventListener('resize', onResize, false);
         }
 
@@ -368,9 +386,6 @@ namespace InputObserver {
             window.removeEventListener('mousemove', onMouseMove as any, false);
             window.removeEventListener('mouseup', onMouseUp as any, false);
 
-            element.removeEventListener('mouseenter', onMouseEnter as any, false);
-            element.removeEventListener('mouseleave', onMouseLeave as any, false);
-
             element.removeEventListener('touchstart', onTouchStart as any, false);
             element.removeEventListener('touchmove', onTouchMove as any, false);
             element.removeEventListener('touchend', onTouchEnd as any, false);
@@ -384,9 +399,29 @@ namespace InputObserver {
             window.removeEventListener('keydown', handleKeyDown as EventListener, false);
             window.removeEventListener('keypress', handleKeyPress as EventListener, false);
 
+            document.removeEventListener('pointerlockchange', onPointerLockChange, false);
+            document.removeEventListener('pointerlockerror', onPointerLockError, false);
+
             window.removeEventListener('resize', onResize, false);
         }
 
+        function onPointerLockChange() {
+            if (element.ownerDocument.pointerLockElement === element) {
+                isLocked = true;
+            } else {
+                isLocked = false;
+            }
+            toggleCross(isLocked);
+            lock.next(isLocked);
+        }
+
+        function onPointerLockError() {
+            console.error('Unable to use Pointer Lock API');
+            isLocked = false;
+            toggleCross(isLocked);
+            lock.next(isLocked);
+        }
+
         function onContextMenu(event: MouseEvent) {
             if (!mask(event.clientX, event.clientY)) return;
 
@@ -417,6 +452,15 @@ namespace InputObserver {
             if (!modifierKeys.meta && event.metaKey) { changed = true; modifierKeys.meta = true; }
 
             if (changed && isInside) modifiers.next(getModifierKeys());
+
+            if (event.target === document.body && isInside) {
+                keyDown.next({
+                    key: event.key,
+                    code: event.code,
+                    modifiers: getModifierKeys(),
+                    preventDefault: () => event.preventDefault(),
+                });
+            }
         }
 
         function handleKeyUp(event: KeyboardEvent) {
@@ -430,12 +474,25 @@ namespace InputObserver {
             if (changed && isInside) modifiers.next(getModifierKeys());
 
             if (AllowedNonPrintableKeys.includes(event.key)) handleKeyPress(event);
+
+            if (event.target === document.body && isInside) {
+                keyUp.next({
+                    key: event.key,
+                    code: event.code,
+                    modifiers: getModifierKeys(),
+                    preventDefault: () => event.preventDefault(),
+                });
+            }
         }
 
         function handleKeyPress(event: KeyboardEvent) {
+            if (event.target !== document.body || !isInside) return;
+
             key.next({
                 key: event.key,
-                modifiers: getModifierKeys()
+                code: event.code,
+                modifiers: getModifierKeys(),
+                preventDefault: () => event.preventDefault(),
             });
         }
 
@@ -579,7 +636,7 @@ namespace InputObserver {
 
             eventOffset(pointerEnd, ev);
             if (!hasMoved && Vec2.distance(pointerEnd, pointerDown) < 4) {
-                const { pageX, pageY } = ev;
+                const { pageX, pageY } = getPagePosition(ev);
                 const [x, y] = pointerEnd;
 
                 click.next({ x, y, pageX, pageY, buttons, button, modifiers: getModifierKeys() });
@@ -589,10 +646,19 @@ namespace InputObserver {
 
         function onPointerMove(ev: PointerEvent) {
             eventOffset(pointerEnd, ev);
-            const { pageX, pageY } = ev;
+            const { pageX, pageY } = getPagePosition(ev);
             const [x, y] = pointerEnd;
-            const inside = insideBounds(pointerEnd);
-            move.next({ x, y, pageX, pageY, buttons, button, modifiers: getModifierKeys(), inside });
+            const { movementX, movementY } = ev;
+
+            const inside = insideBounds(pointerEnd) && mask(ev.clientX, ev.clientY);
+            if (isInside && !inside) {
+                leave.next(void 0);
+            } else if (!isInside && inside) {
+                enter.next(void 0);
+            }
+            isInside = inside;
+
+            move.next({ x, y, pageX, pageY, movementX, movementY, buttons, button, modifiers: getModifierKeys(), inside });
 
             if (dragging === DraggingState.Stopped) return;
 
@@ -621,7 +687,7 @@ namespace InputObserver {
             if (!mask(ev.clientX, ev.clientY)) return;
 
             eventOffset(pointerEnd, ev);
-            const { pageX, pageY } = ev;
+            const { pageX, pageY } = getPagePosition(ev);
             const [x, y] = pointerEnd;
 
             if (noScroll) {
@@ -675,16 +741,6 @@ namespace InputObserver {
             gestureDelta(ev, true);
         }
 
-        function onMouseEnter(ev: Event) {
-            isInside = true;
-            enter.next(void 0);
-        }
-
-        function onMouseLeave(ev: Event) {
-            isInside = false;
-            leave.next(void 0);
-        }
-
         function onResize(ev: Event) {
             resize.next({});
         }
@@ -708,13 +764,94 @@ namespace InputObserver {
             width = element.clientWidth * pixelRatio();
             height = element.clientHeight * pixelRatio();
 
-            const cx = ev.clientX || 0;
-            const cy = ev.clientY || 0;
-            const rect = element.getBoundingClientRect();
-            out[0] = cx - rect.left;
-            out[1] = cy - rect.top;
+            if (isLocked) {
+                const pr = pixelRatio();
+                out[0] = (lockedViewport.x + lockedViewport.width / 2) / pr;
+                out[1] = (height - (lockedViewport.y + lockedViewport.height / 2)) / pr;
+            } else {
+                const rect = element.getBoundingClientRect();
+                out[0] = (ev.clientX || 0) - rect.left;
+                out[1] = (ev.clientY || 0) - rect.top;
+            }
             return out;
         }
+
+        function getPagePosition(ev: PointerEvent) {
+            if (isLocked) {
+                return {
+                    pageX: Math.round(window.innerWidth / 2) + lockedViewport.x,
+                    pageY: Math.round(window.innerHeight / 2) + lockedViewport.y
+                };
+            } else {
+                return {
+                    pageX: ev.pageX,
+                    pageY: ev.pageY
+                };
+            }
+        }
+
+        const cross = addCross();
+        const crossWidth = 30;
+
+        function addCross() {
+            const cross = document.createElement('div');
+
+            const b = '30%';
+            const t = '10%';
+            const c = `#000 ${b}, #0000 0 calc(100% - ${b}), #000 0`;
+            Object.assign(cross.style, {
+
+                width: `${crossWidth}px`,
+                aspectRatio: 1,
+                background: `linear-gradient(0deg, ${c}) 50%/${t} 100% no-repeat, linear-gradient(90deg, ${c}) 50%/100% ${t} no-repeat`,
+                display: 'none',
+                zIndex: 1000,
+                position: 'absolute',
+            });
+
+            element.parentElement?.appendChild(cross);
+
+            return cross;
+        }
+
+        function toggleCross(value: boolean) {
+            cross.style.display = value ? 'block' : 'none';
+            if (value) {
+                const pr = pixelRatio();
+                const offsetX = (lockedViewport.x + lockedViewport.width / 2) / pr;
+                const offsetY = (lockedViewport.y + lockedViewport.height / 2) / pr;
+                cross.style.width = `${crossWidth}px`;
+                cross.style.left = `calc(${offsetX}px - ${crossWidth / 2}px)`;
+                cross.style.bottom = `calc(${offsetY}px - ${crossWidth / 2}px)`;
+            }
+        }
+
+        return {
+            get noScroll() { return noScroll; },
+            set noScroll(value: boolean) { noScroll = value; },
+            get noContextMenu() { return noContextMenu; },
+            set noContextMenu(value: boolean) { noContextMenu = value; },
+
+            get width() { return width; },
+            get height() { return height; },
+            get pixelRatio() { return pixelRatio(); },
+            get pointerLock() { return isLocked; },
+
+            ...events,
+
+            requestPointerLock: (viewport: Viewport) => {
+                lockedViewport = viewport;
+                if (!isLocked) {
+                    element.requestPointerLock();
+                }
+            },
+            exitPointerLock: () => {
+                if (isLocked) {
+                    element.ownerDocument.exitPointerLock();
+                }
+            },
+            dispose
+        };
     }
 }