diff --git a/CHANGELOG.md b/CHANGELOG.md index a502f069d7b32073ae1655928943a35cec98bbce..4fc525ae14c3bf6ed48eb5e011e99156b7aa6172 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/src/mol-canvas3d/canvas3d.ts b/src/mol-canvas3d/canvas3d.ts index a993df511c3791ddb09278f15d6c1a08f9568912..de4b02bca65778e72d75243a1a345abccdf38ae6 100644 --- a/src/mol-canvas3d/canvas3d.ts +++ b/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); diff --git a/src/mol-canvas3d/controls/trackball.ts b/src/mol-canvas3d/controls/trackball.ts index ac33bba3eede1e97a6623dd0dbd831b085c53234..cb122268df5473392f58ad84ab0f45f9efa6fab0 100644 --- a/src/mol-canvas3d/controls/trackball.ts +++ b/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); diff --git a/src/mol-canvas3d/helper/interaction-events.ts b/src/mol-canvas3d/helper/interaction-events.ts index 30145c9518d493eee6a7498a9fb96c33c3e70872..424232659892f8f288d7b334dbeec0d12278148e 100644 --- a/src/mol-canvas3d/helper/interaction-events.ts +++ b/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)) { diff --git a/src/mol-plugin/behavior/dynamic/camera.ts b/src/mol-plugin/behavior/dynamic/camera.ts index 8d47d2bfc55395569c81f43fc0c79adf13e2c421..b4c43d7a1c2b8718051822af12b670a83c6d497e 100644 --- a/src/mol-plugin/behavior/dynamic/camera.ts +++ b/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, diff --git a/src/mol-plugin/behavior/dynamic/representation.ts b/src/mol-plugin/behavior/dynamic/representation.ts index 2c3900ab45c4f34bb2f768898c13e87538996b3d..c5dfab832bdced30d2626bc650f0850995a99d33 100644 --- a/src/mol-plugin/behavior/dynamic/representation.ts +++ b/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}'), }; diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 03346bbc348cd5f0371464b0f77cda9412a1271c..4288cc4ab640812f856e25eb46183a76ce730a37 100644 --- a/src/mol-plugin/context.ts +++ b/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(); diff --git a/src/mol-util/binding.ts b/src/mol-util/binding.ts index 252de33a3bdd563565fe216d7e704486c9740d51..ac71d492cb45ba85eb8583d87abb15c819cefe81 100644 --- a/src/mol-util/binding.ts +++ b/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(); } \ No newline at end of file diff --git a/src/mol-util/input/input-observer.ts b/src/mol-util/input/input-observer.ts index f83b7e11a68c8ecfc9c766b19fa76a4bacf51478..51fe5bcb02a20a5c3d15eaedcfc45b5ca53e9368 100644 --- a/src/mol-util/input/input-observer.ts +++ b/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 + }; } }