Skip to content

Commit de662ad

Browse files
authored
fix(ar): prevent WebXR select events on DOM overlay inputs and adapt visualViewport layout for virtual keyboards (#5153)
1 parent 98c3817 commit de662ad

4 files changed

Lines changed: 92 additions & 1 deletion

File tree

packages/model-viewer/src/test/three-components/ARRenderer-spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,44 @@ suite('ARRenderer', () => {
241241
expect(scale.z).to.be.equal(1);
242242
});
243243

244+
test('prevents default WebXR select when beforexrselect is triggered on interactive elements', () => {
245+
const button = document.createElement('button');
246+
const event = new CustomEvent('beforexrselect', {
247+
bubbles: true,
248+
cancelable: true
249+
});
250+
Object.defineProperty(event, 'composedPath', {
251+
value: () => [button, (arRenderer as any).overlay]
252+
});
253+
254+
let defaultPrevented = false;
255+
event.preventDefault = () => {
256+
defaultPrevented = true;
257+
};
258+
259+
(arRenderer as any).overlay.dispatchEvent(event);
260+
expect(defaultPrevented).to.be.equal(true);
261+
});
262+
263+
test('does not prevent default WebXR select on non-interactive elements', () => {
264+
const div = document.createElement('div');
265+
const event = new CustomEvent('beforexrselect', {
266+
bubbles: true,
267+
cancelable: true
268+
});
269+
Object.defineProperty(event, 'composedPath', {
270+
value: () => [div, (arRenderer as any).overlay]
271+
});
272+
273+
let defaultPrevented = false;
274+
event.preventDefault = () => {
275+
defaultPrevented = true;
276+
};
277+
278+
(arRenderer as any).overlay.dispatchEvent(event);
279+
expect(defaultPrevented).to.be.equal(false);
280+
});
281+
244282
suite('presentation ends', () => {
245283
setup(async () => {
246284
await arRenderer.stopPresenting();

packages/model-viewer/src/three-components/ARRenderer.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ export class ARRenderer extends EventDispatcher<
261261
// This sets isPresenting to true
262262
this._presentedScene = scene;
263263
this.overlay = scene.element.shadowRoot!.querySelector('div.default');
264+
if (this.overlay != null) {
265+
this.overlay.addEventListener('beforexrselect', this.onBeforeXRSelect);
266+
}
264267

265268
if (environmentEstimation === true) {
266269
this.xrLight = new XREstimatedLight(this.threeRenderer);
@@ -712,7 +715,10 @@ export class ARRenderer extends EventDispatcher<
712715
this.oldShadowIntensity = null;
713716
this.frame = null;
714717
this.inputSource = null;
715-
this.overlay = null;
718+
if (this.overlay != null) {
719+
this.overlay.removeEventListener('beforexrselect', this.onBeforeXRSelect);
720+
this.overlay = null;
721+
}
716722
this.worldSpaceInitialPlacementDone = false;
717723

718724
if (this.resolveCleanup != null) {
@@ -945,6 +951,22 @@ export class ARRenderer extends EventDispatcher<
945951
this.placementBox!.show = false
946952
};
947953

954+
private onBeforeXRSelect = (event: Event) => {
955+
const path = event.composedPath();
956+
for (const element of path) {
957+
if (element instanceof HTMLElement) {
958+
const tagName = element.tagName.toLowerCase();
959+
if (tagName === 'input' || tagName === 'button' ||
960+
tagName === 'select' || tagName === 'textarea' ||
961+
tagName === 'a' || element.hasAttribute('data-pointer-coalesce') ||
962+
element.classList.contains('interactive')) {
963+
event.preventDefault();
964+
break;
965+
}
966+
}
967+
}
968+
};
969+
948970
private fingerPolar(fingers: XRTransientInputHitTestResult[]):
949971
{separation: number, deltaYaw: number, angle: number} {
950972
const fingerOne = fingers[0].inputSource.gamepad!.axes;

packages/modelviewer.dev/data/faq.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@
124124
"<a href='../docs/#entrydocs-loading-staticproperties-dracoDecoderLocation'>Decoder location static properties</a>",
125125
"<a href='../examples/loading/#dracoSupport'>Details on setting static properties</a>"
126126
]
127+
},
128+
{
129+
"name": "How do I make custom inputs or overlays work reliably in AR DOM Overlay mode?",
130+
"htmlName": "ar-dom-overlay-inputs",
131+
"description": "Inside a WebXR full-screen immersive AR session, mobile browsers do not shrink the layout viewport when a soft keyboard is shown, which can cover your custom input overlays. Additionally, tap events on your overlays can propagate as WebXR screen taps and trigger unexpected 3D gestures (like model movements). To prevent these issues, follow two best practices:<br/><br/>1. <b>Event Suppression:</b> The core &lt;model-viewer&gt; now automatically intercepts the <code>beforexrselect</code> event on your interactive DOM elements and calls <code>event.preventDefault()</code> to stop WebXR from turning DOM taps into 3D session interactions.<br/>2. <b>Viewport Resizing:</b> Use the <code>visualViewport</code> API to listen for keyboard height overlays (when <code>ar-status='session-started'</code>) and translate your elements upward (e.g., via <code>transform: translateY(-offset px);</code>) to keep them visible above the keyboard, as demonstrated in our scenegraph example.",
132+
"links": [
133+
"<a href='../examples/scenegraph/#transforms'>Model Transformations example</a>"
134+
]
127135
}
128136
]
129137
},

packages/modelviewer.dev/examples/scenegraph/index.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ <h2 class="demo-title">Model Transformations</h2>
157157
will be generated on the fly from the current state.
158158
</p>
159159
<p><b>Tip:</b> When building custom interactive overlay controls with text or numeric input fields on mobile, ensure your inputs are styled with a CSS rule setting <code>font-size: 16px;</code> (or larger). If a focused text field's computed font size is below 16px, mobile WebKit browsers (like iOS Safari and Chrome) will automatically zoom in the entire viewport, which does not automatically reset when the input is blurred and can break the page layout.</p>
160+
<p><b>AR Keyboard Overlays:</b> Inside a WebXR full-screen immersive AR session, mobile browsers do not shrink the layout viewport when a software virtual keyboard is open. To prevent inputs from being covered by the keyboard, you can register a <code>visualViewport</code> resize listener (only active when <code>ar-status="session-started"</code>) to calculate the soft keyboard's overlay height and dynamically translate your overlay container upward (e.g., via <code>transform: translateY(-offset px);</code>), as demonstrated in the script below.</p>
160161
</div>
161162
<example-snippet stamp-to="transforms" highlight-as="html">
162163
<template>
@@ -215,6 +216,28 @@ <h2 class="demo-title">Model Transformations</h2>
215216
z.addEventListener('input', () => {
216217
updateScale();
217218
});
219+
220+
const controls = modelViewerTransform.querySelector(".controls");
221+
if (window.visualViewport) {
222+
const updateControls = () => {
223+
if (modelViewerTransform.getAttribute('ar-status') !== 'session-started') {
224+
controls.style.transform = 'none';
225+
return;
226+
}
227+
const layoutHeight = window.innerHeight;
228+
const visualHeight = window.visualViewport.height;
229+
const offsetTop = window.visualViewport.offsetTop;
230+
const offset = layoutHeight - (visualHeight + offsetTop);
231+
if (offset > 0) {
232+
controls.style.transform = `translateY(-${offset}px)`;
233+
} else {
234+
controls.style.transform = 'none';
235+
}
236+
};
237+
window.visualViewport.addEventListener('resize', updateControls);
238+
window.visualViewport.addEventListener('scroll', updateControls);
239+
modelViewerTransform.addEventListener('ar-status', updateControls);
240+
}
218241
</script>
219242
</template>
220243
</example-snippet>

0 commit comments

Comments
 (0)