This article covers the desktop adaptation of the cube-gizmo prototype. The touch interaction has been documented separately, so the focus here is on mouse and keyboard input.
The same Gizmo geometry and manipulation model are shared across devices. On desktop, hover, multiple mouse buttons, the wheel, and the keyboard provide additional input channels, so several touch-specific mechanisms can be simplified.
The desktop version uses the following controls:
| Input | Behavior |
|---|---|
| Left press + drag | Manipulate the active Gizmo handle |
| Mouse hover | Preview the handle currently under the pointer |
| Right-button drag | Orbit the camera |
| Middle-button drag | Pan the camera |
| Wheel | Zoom |
| Escape | Cancel the active Gizmo operation and restore the previous state |
The touch version uses a 260ms long press to confirm drag intent. Desktop interaction starts directly on pointerdown. A precise pointer and stable hover state already provide enough target confirmation before the drag begins.
The object model stays the same across devices: users still manipulate the visible axis, plane, or rotation handle directly. Only the input mechanics change.
Hover adds a useful pre-action state on desktop.
The current visual states are:
#FFD060 warm yellow;#FF8A00 orange.Warm yellow indicates the handle currently available under the pointer. Orange is reserved for an operation that has already started. The cursor changes to grab when a draggable handle is detected.
Hover detection runs on pointermove. A stable hitKey prevents repeated state updates while the pointer remains over the same target:
const hit = intersectHandle(event)
const hitKey = hit ? `${hit.type}-${hit.axis ?? hit.plane}` : null
const prevKey = state.hover ? `${state.hover.type}-${state.hover.axis ?? state.hover.plane}` : null
if (hitKey !== prevKey) {
state.hover = hit
renderer.domElement.style.cursor = hit ? 'grab' : 'default'
}This matters for a Gizmo because pointer-move events are frequent while the actual target changes much less often.
Dimension labels can overlap Gizmo handles in screen space. Labels are clickable for numeric editing, while Gizmo handles are draggable.
The desktop rule gives the Gizmo hit priority.
On pointerdown, the scene first checks whether a handle is hit:
if (state.variant === 4) {
if (intersectHandle(event)) {
pcDidRoute = true
onPointerDown(event)
} else {
pcDidRoute = false
}
}The later click event uses pcDidRoute before opening the dimension editor:
if (state.variant === 4 && !pcDidRoute) {
openDimensionEditor()
}pcDidRoute is kept separately because click fires after pointerup. Temporary drag-routing state may already be cleared by then.
The useful rule here is to decide ownership at the beginning of the input sequence. Overlapping interactions become much easier to reason about when one target owns the pointer from pointerdown onward.
Desktop input can separate object manipulation and camera navigation across different buttons:
The browser context menu is disabled on the renderer so the right button can remain dedicated to camera rotation:
renderer.domElement.addEventListener('contextmenu', event => {
event.preventDefault()
})This keeps left-button interaction consistently tied to scene objects and Gizmo manipulation.
Escape provides a natural desktop cancel action.
At the beginning of a drag, the scene stores an operationStart snapshot containing the cube position and rotation. Each frame calculates the current result from the initial state plus the latest delta.
This keeps manipulation stable across frame-rate changes and also makes cancellation straightforward:
function onKeyDown(event) {
if (event.key === 'Escape' && state.variant === 4) {
if (state.operationStart) {
cubeGroup.position.copy(state.operationStart.cubePosition)
cubeGroup.quaternion.copy(state.operationStart.cubeQuaternion)
}
state.active = null
state.operationStart = null
state.draggingCamera = false
state.panningCamera = false
}
}Because the listener is attached to window, it is also removed when the scene is disposed to avoid duplicate listeners and memory leaks.
Desktop / touch behavior is selected through pointer capability rather than user-agent strings:
window.matchMedia('(pointer: fine)').matchespointer: fine usually represents a mouse or trackpad, while coarse is closer to finger input.
React.useEffect(() => {
if (window.matchMedia('(pointer: fine)').matches) {
setVariant(4)
}
}, [])This avoids several UA-related edge cases, including tablets that report desktop-like user agents and browser device emulation.
Hybrid devices still need attention. An iPad with an external mouse may enter desktop mode, and convertible devices can move between touch and precise-pointer input. A production version could listen for media-query changes instead of checking only once at startup.
The demo does not expose a manual desktop / touch mode switch. It enters the matching interaction mode automatically so viewers can use the prototype immediately.
Desktop adaptation continues to use the same Raycaster and handle data as the touch version.
The basic picking path remains:
Raycaster.intersectObjects();The visible Gizmo geometry stays thin. Larger invisible hit geometries are used for picking, which keeps visual size separate from interactive target size.
Axis-end arrows receive a higher priority than axis bodies, while planes and rotation arcs use their own priorities. This keeps target selection stable when hit areas overlap.
Input events. Desktop interaction mainly uses: pointerdown / pointermove / pointerup / wheel / contextmenu / keydown. Pointer Events continue to unify mouse and touch instead of maintaining two unrelated event systems.
Core state. createScene() keeps a small set of explicit interaction states: active (handle currently being manipulated), selected (selected object / handle), hover (current mouse hover target), operationStart (snapshot taken at the start of an operation), pointers (active pointers), draggingCamera (camera orbit state), panningCamera (camera pan state).
React / Three.js boundary. React manages declarative UI state such as variants, panels, and editors. Three.js manages the renderer, camera, Gizmo, Raycaster, and continuous manipulation. createScene() receives the DOM container and callbacks, then returns imperative methods such as setVariant, reset, and setDimension. React stores this API through useRef and receives scene changes through callbacks.
The desktop version reuses the same manipulation semantics and picking layer as touch. Hover, mouse-button roles, and keyboard cancellation are added on top of that shared scene logic, so each input type can use the controls that fit it best without maintaining a separate Gizmo system.