feat(viewer): scope keyboard events to focused canvas (#5740)

This commit is contained in:
Alexandru Popovici
2026-01-27 10:15:46 +02:00
committed by GitHub
parent d96d1c5446
commit ac383bca1f
9 changed files with 71 additions and 32 deletions
+1
View File
@@ -39,6 +39,7 @@ const createViewer = async (containerName: string, _stream: string) => {
const params = DefaultViewerParams
params.showStats = true
params.verbose = true
// params.restrictInputToCanvas = true
const multiSelectList: SelectionEvent[] = []
const viewer: Viewer = new Viewer(container, params)
+5 -1
View File
@@ -39,7 +39,9 @@ export interface ViewerParams {
showStats: boolean
environmentSrc: Asset
verbose: boolean
restrictInputToCanvas: boolean
}
export enum AssetType {
TEXTURE_8BPP = 'png', // For now
TEXTURE_HDR = 'hdr',
@@ -71,7 +73,8 @@ export const DefaultViewerParams: ViewerParams = {
id: 'defaultHDRI',
src: defaultHdri,
type: AssetType.TEXTURE_EXR
}
},
restrictInputToCanvas: false
}
export enum ViewerEvent {
@@ -205,6 +208,7 @@ export interface IViewer {
getRenderer(): SpeckleRenderer
getContainer(): HTMLElement
getCanvas(): HTMLCanvasElement
createExtension<T extends Extension>(type: Constructor<T>): T
getExtension<T extends Extension>(type: Constructor<T>): T
@@ -436,7 +436,10 @@ export default class SpeckleRenderer {
this._pipeline = new DefaultPipeline(this)
this.input = new Input(this._renderer.domElement)
this.input = new Input(
this._renderer.domElement,
this.viewer.params.restrictInputToCanvas
)
this.input.on(InputEvent.Click, this.onClick.bind(this))
this.input.on(InputEvent.DoubleClick, this.onDoubleClick.bind(this))
+8
View File
@@ -75,6 +75,10 @@ export class Viewer extends EventEmitter implements IViewer {
return this.speckleRenderer.input
}
public get params(): ViewerParams {
return this.startupParams
}
private getConstructorChain(obj: object) {
const cs = []
let pt = obj
@@ -166,6 +170,10 @@ export class Viewer extends EventEmitter implements IViewer {
return this.container
}
public getCanvas() {
return this.speckleRenderer.renderer.domElement
}
public getRenderer() {
return this.speckleRenderer
}
@@ -182,7 +182,7 @@ export class CameraController extends Extension implements SpeckleCamera {
this._flyControls = new FlyControls(
this._renderingCamera,
this.viewer.getContainer(),
this.viewer.getRenderer().input,
this.viewer.World,
this._options
)
@@ -1,6 +1,7 @@
import { PerspectiveCamera } from 'three'
import { IViewer } from '../../IViewer.js'
import { CameraController } from './CameraController.js'
import { InputEvent } from '../input/Input.js'
type MoveType = 'forward' | 'back' | 'left' | 'right' | 'up' | 'down'
export class HybridCameraController extends CameraController {
@@ -17,9 +18,9 @@ export class HybridCameraController extends CameraController {
public constructor(viewer: IViewer) {
super(viewer)
document.addEventListener('keydown', this.onKeyDown.bind(this))
document.addEventListener('keyup', this.onKeyUp.bind(this))
document.addEventListener('contextmenu', this.onContextMenu.bind(this))
viewer.getRenderer().input.on(InputEvent.KeyUp, this.onKeyUp.bind(this))
viewer.getRenderer().input.on(InputEvent.KeyDown, this.onKeyDown.bind(this))
viewer.getRenderer().input.on(InputEvent.ContextMenu, this.onContextMenu.bind(this))
}
public onEarlyUpdate(_delta?: number): void {
@@ -13,6 +13,7 @@ import { SpeckleControls } from './SpeckleControls.js'
import { World } from '../../World.js'
import { AngleDamper } from '../../utils/AngleDamper.js'
import { TIME_MS } from '@speckle/shared'
import Input, { InputEvent } from '../../input/Input.js'
const _vectorBuff0 = new Vector3()
@@ -33,9 +34,9 @@ export interface FlyControlsOptions {
}
class FlyControls extends SpeckleControls {
protected input: Input
protected _options: Required<FlyControlsOptions>
protected _targetCamera: PerspectiveCamera | OrthographicCamera
protected container: HTMLElement
protected velocity = new Vector3()
protected euler = new Euler(0, 0, 0, 'YXZ')
protected position = new Vector3()
@@ -109,14 +110,14 @@ class FlyControls extends SpeckleControls {
constructor(
camera: PerspectiveCamera | OrthographicCamera,
container: HTMLElement,
input: Input,
world: World,
options: Required<FlyControlsOptions>
) {
super()
this._targetCamera = camera
this.container = container
this.input = input
this.world = world
this._options = Object.assign({}, options)
@@ -324,20 +325,20 @@ class FlyControls extends SpeckleControls {
protected connect() {
if (this._enabled) return
this.container.addEventListener('pointermove', this.onMouseMove)
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('keyup', this.onKeyUp)
document.addEventListener('contextmenu', this.onContextMenu)
this.input.on(InputEvent.KeyUp, this.onKeyUp)
this.input.on(InputEvent.KeyDown, this.onKeyDown)
this.input.on(InputEvent.PointerMove, this.onMouseMove)
this.input.on(InputEvent.ContextMenu, this.onContextMenu)
}
protected disconnect() {
if (!this._enabled) return
this.container.removeEventListener('pointermove', this.onMouseMove)
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('keyup', this.onKeyUp)
document.removeEventListener('contextmenu', this.onContextMenu)
this.input.removeListener(InputEvent.KeyUp, this.onKeyUp)
this.input.removeListener(InputEvent.KeyDown, this.onKeyDown)
this.input.removeListener(InputEvent.PointerMove, this.onMouseMove)
this.input.removeListener(InputEvent.ContextMenu, this.onContextMenu)
for (const k in this.keyMap) this.keyMap[k as MoveType] = false
}
@@ -355,11 +356,11 @@ class FlyControls extends SpeckleControls {
}
// event listeners
protected onMouseMove = (event: PointerEvent) => {
if (event.buttons !== 1 || !this._enabled) return
protected onMouseMove = (arg: Vector2 & { event: PointerEvent }) => {
if (arg.event.buttons !== 1 || !this._enabled) return
const movementX = event.movementX || 0
const movementY = event.movementY || 0
const movementX = arg.event.movementX || 0
const movementY = arg.event.movementY || 0
const amount = new Vector2()
amount.y = movementX * 0.005 * this._options.lookSpeed
amount.x = movementY * 0.005 * this._options.lookSpeed
@@ -618,9 +618,8 @@ export class SectionTool extends Extension {
}
}
/** Event listeners */
document.addEventListener('keydown', this.keydownHandler)
document.addEventListener('keyup', this.keyupHandler)
this.viewer.getRenderer().input.on(InputEvent.KeyDown, this.keydownHandler)
this.viewer.getRenderer().input.on(InputEvent.KeyUp, this.keyupHandler)
}
/**
@@ -1135,10 +1134,14 @@ export class SectionTool extends Extension {
*/
public dispose() {
if (this.keydownHandler) {
document.removeEventListener('keydown', this.keydownHandler)
this.viewer
.getRenderer()
.input.removeListener(InputEvent.KeyDown, this.keydownHandler)
}
if (this.keyupHandler) {
document.removeEventListener('keyup', this.keyupHandler)
this.viewer
.getRenderer()
.input.removeListener(InputEvent.KeyUp, this.keyupHandler)
}
}
}
+23 -5
View File
@@ -9,7 +9,9 @@ export enum InputEvent {
Wheel = 'wheel',
Click = 'click',
DoubleClick = 'double-click',
KeyUp = 'key-up'
KeyUp = 'key-up',
KeyDown = 'key-down',
ContextMenu = 'context-menu'
}
export interface InputEventPayload {
@@ -21,6 +23,8 @@ export interface InputEventPayload {
[InputEvent.Click]: Vector2 & { event: PointerEvent; multiSelect: boolean }
[InputEvent.DoubleClick]: Vector2 & { event: PointerEvent; multiSelect: boolean }
[InputEvent.KeyUp]: KeyboardEvent
[InputEvent.KeyDown]: KeyboardEvent
[InputEvent.ContextMenu]: PointerEvent
}
//TO DO: Define proper interface for InputEvent data
@@ -33,14 +37,20 @@ export default class Input extends EventEmitter {
private touchLocation: Touch | undefined
private container
constructor(container: HTMLElement) {
constructor(container: HTMLElement, restrictKeyInput: boolean = false) {
super()
this.container = container
if (restrictKeyInput) {
// Make canvas focusable for scoped keyboard events
this.container.tabIndex = -1
// Remove default focus outline
this.container.style.outline = 'none'
}
// Handle mouseclicks
let mdTime: number
this.container.addEventListener('pointerdown', (e) => {
e.preventDefault()
this.container.focus() // preventDefault blocks default focus
const loc = this._getNormalisedClickPosition(e)
;(loc as unknown as Record<string, unknown>).event = e
mdTime = new Date().getTime()
@@ -106,11 +116,15 @@ export default class Input extends EventEmitter {
this.emit(InputEvent.PointerMove, data)
})
document.addEventListener('keyup', (e) => {
const keySource = restrictKeyInput ? this.container : document
keySource.addEventListener('keyup', (e) => {
this.emit(InputEvent.KeyUp, e)
})
keySource.addEventListener('keydown', (e) => {
this.emit(InputEvent.KeyDown, e)
})
document.addEventListener('wheel', (e) => {
this.container.addEventListener('wheel', (e) => {
this.emit(InputEvent.Wheel, e)
})
@@ -121,6 +135,10 @@ export default class Input extends EventEmitter {
this.emit(InputEvent.PointerUp, loc)
this.emit(InputEvent.PointerCancel, loc)
})
this.container.addEventListener('contextmenu', (e) => {
this.emit(InputEvent.ContextMenu, e)
})
}
public on<T extends InputEvent>(