From cb77892682e3651672cf165253b4c5bcc167ead8 Mon Sep 17 00:00:00 2001 From: Dwane Hemmings Date: Sat, 3 Jan 2026 20:45:53 +0000 Subject: [PATCH 1/2] Add WebView component and integrate into UI framework --- src/ui/UI.ts | 3 + src/ui/components/WebView.ts | 166 +++++++++++++++++++++++++++++++++++ src/ui/layouts/Grid.ts | 7 ++ src/xrblocks.ts | 2 + 4 files changed, 178 insertions(+) create mode 100644 src/ui/components/WebView.ts diff --git a/src/ui/UI.ts b/src/ui/UI.ts index 36e8e3e..2c9282c 100644 --- a/src/ui/UI.ts +++ b/src/ui/UI.ts @@ -15,6 +15,7 @@ import {LabelView, LabelViewOptions} from './components/LabelView'; import {TextButton, TextButtonOptions} from './components/TextButton'; import {TextView, TextViewOptions} from './components/TextView'; import {VideoView, VideoViewOptions} from './components/VideoView'; +import {WebView, WebViewOptions} from './components/WebView'; import {Panel} from './core/Panel'; import type {PanelOptions} from './core/PanelOptions'; import {View} from './core/View'; @@ -35,6 +36,7 @@ export type UIJsonNodeOptions = | LabelViewOptions | TextButtonOptions | VideoViewOptions + | WebViewOptions | ColOptions | GridOptions | RowOptions @@ -153,6 +155,7 @@ UI.registerComponent('TextView', TextView); UI.registerComponent('Label', LabelView); UI.registerComponent('LabelView', LabelView); UI.registerComponent('VideoView', VideoView); +UI.registerComponent('WebView', WebView); UI.registerComponent('TextButton', TextButton); UI.registerComponent('IconButton', IconButton); UI.registerComponent('IconView', IconView); diff --git a/src/ui/components/WebView.ts b/src/ui/components/WebView.ts new file mode 100644 index 0000000..624c9e6 --- /dev/null +++ b/src/ui/components/WebView.ts @@ -0,0 +1,166 @@ +import * as THREE from 'three'; +import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js'; +import { View } from '../core/View'; +import { ViewOptions } from '../core/ViewOptions'; + +export type WebViewOptions = ViewOptions & { + url: string; +}; + +export class WebView extends View { + /** Default description of this view in Three.js DevTools. */ + name: string = 'WebView'; + + private static cssRenderer: CSS3DRenderer | null = null; + private static cssScene: THREE.Scene = new THREE.Scene(); + private static instances: WebView[] = []; + private static cameraRef: THREE.Camera | null = null; + + public url: string; + public pixelWidth: number; + public pixelHeight: number; + /** WebView resides in a panel by default. */ + public isRoot = false; + + private cssObject: CSS3DObject; + public occlusionMesh: THREE.Mesh; + + constructor(options: WebViewOptions) { + + // --- Units Logic (pixels vs. meters) --- + const inputWidth = options.width ?? 1920; + const inputHeight = options.height ?? 1080; + const isPixels = inputWidth > 10; + const physicalWidth = isPixels ? inputWidth * 0.001 : inputWidth; + const physicalHeight = isPixels ? inputHeight * 0.001 : inputHeight; + + super({ ...options, width: physicalWidth, height: physicalHeight }); + + this.url = options.url; + this.pixelWidth = isPixels ? inputWidth : physicalWidth / 0.001; + this.pixelHeight = isPixels ? inputHeight : physicalHeight / 0.001; + + WebView.instances.push(this); + WebView.ensureSystem(); + + // --- Occlusion Mesh --- + const material = new THREE.MeshBasicMaterial({ + opacity: 0, + color: new THREE.Color(0x000000), + side: THREE.DoubleSide, + blending: THREE.NoBlending, + }); + const geometry = new THREE.PlaneGeometry(this.pixelWidth, this.pixelHeight); + this.occlusionMesh = new THREE.Mesh(geometry, material); + this.occlusionMesh.scale.set(0.001, 0.001, 0.001); + this.add(this.occlusionMesh); + + // --- CSS Object --- + const div = document.createElement('div'); + div.style.width = `${this.pixelWidth}px`; + div.style.height = `${this.pixelHeight}px`; + div.style.backgroundColor = '#000000'; + + const iframe = document.createElement('iframe'); + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = '0px'; + iframe.src = this.url; + iframe.onerror = (e) => console.error(`[WebView] ❌ Iframe Error:`, e); + div.appendChild(iframe); + + this.cssObject = new CSS3DObject(div); + + // Add to private overlay scene + WebView.cssScene.add(this.cssObject); + } + + public static initialize(camera: THREE.Camera) { + WebView.cameraRef = camera; + WebView.ensureSystem(); + } + + public updateLayout(): void { + this.pixelWidth = this.width / 0.001; + this.pixelHeight = this.height / 0.001; + + const div = this.cssObject.element; + div.style.width = `${this.pixelWidth}px`; + div.style.height = `${this.pixelHeight}px`; + + if (this.occlusionMesh) { + this.occlusionMesh.geometry.dispose(); + this.occlusionMesh.geometry = new THREE.PlaneGeometry(this.pixelWidth, this.pixelHeight); + } + super.updateLayout(); + } + +private static ensureSystem() { + if (WebView.cssRenderer || !WebView.cameraRef) return; + + WebView.cssRenderer = new CSS3DRenderer(); + WebView.cssRenderer.setSize(window.innerWidth, window.innerHeight); + + const style = WebView.cssRenderer.domElement.style; + style.position = 'absolute'; + style.top = '0'; + style.left = '0'; + style.width = '100%'; + style.height = '100%'; + style.zIndex = '9999'; + style.pointerEvents = 'none'; + + document.body.appendChild(WebView.cssRenderer.domElement); + + window.addEventListener('resize', () => { + WebView.cssRenderer?.setSize(window.innerWidth, window.innerHeight); + }); + + const tick = () => { + if (WebView.cssRenderer && WebView.cameraRef) { + + WebView.instances.forEach(view => { + if (view.occlusionMesh && view.cssObject) { + view.occlusionMesh.updateMatrixWorld(); + + // POSITION + view.cssObject.position.setFromMatrixPosition(view.occlusionMesh.matrixWorld); + view.cssObject.scale.setFromMatrixScale(view.occlusionMesh.matrixWorld); + + // ROTATION: WebView rotates to match SpatialPanel + const targetRotation = new THREE.Quaternion(); + let foundPanel = false; + + // Traverse up: WebView -> Row -> Grid -> SpatialPanel + let parent = view.parent; + while (parent) { + // Check if this parent looks like a SpatialPanel + if (parent.constructor.name === 'SpatialPanel') { + parent.updateMatrixWorld(); + targetRotation.setFromRotationMatrix(parent.matrixWorld); + foundPanel = true; + break; + } + parent = parent.parent; + } + + if (foundPanel) { + // Use the Panel's flat rotation + view.cssObject.quaternion.copy(targetRotation); + } else { + // Fallback to the old way if we can't find a panel + view.cssObject.quaternion.setFromRotationMatrix(view.occlusionMesh.matrixWorld); + } + + // Push WebView forward to clear the curved panel + view.cssObject.translateZ(0.08); + } + }); + + WebView.cssRenderer.render(WebView.cssScene, WebView.cameraRef); + } + requestAnimationFrame(tick); + }; + tick(); + } +} \ No newline at end of file diff --git a/src/ui/layouts/Grid.ts b/src/ui/layouts/Grid.ts index 11adf63..f680c3b 100644 --- a/src/ui/layouts/Grid.ts +++ b/src/ui/layouts/Grid.ts @@ -6,6 +6,7 @@ import {LabelView} from '../components/LabelView'; import {TextButton, TextButtonOptions} from '../components/TextButton'; import {TextView, TextViewOptions} from '../components/TextView'; import {VideoView, VideoViewOptions} from '../components/VideoView'; +import {WebView, WebViewOptions} from '../components/WebView'; import type {Panel} from '../core/Panel'; import type {PanelOptions} from '../core/PanelOptions'; import {View} from '../core/View'; @@ -133,6 +134,12 @@ export class Grid extends View { return ui; } + addURL(options: WebViewOptions) { + const webView = new WebView(options); + this.add(webView); + return webView; + } + /** * Adds a panel to the grid. * @param options - The options for the panel. diff --git a/src/xrblocks.ts b/src/xrblocks.ts index 047bf36..3067340 100644 --- a/src/xrblocks.ts +++ b/src/xrblocks.ts @@ -80,6 +80,7 @@ export * from './ui/components/ScrollingTroikaTextView'; export * from './ui/components/TextButton'; export * from './ui/components/TextView'; export * from './ui/components/VideoView'; +export * from './ui/components/WebView'; export * from './ui/core/Panel'; export * from './ui/core/PanelMesh'; export * from './ui/core/Reticle'; @@ -130,6 +131,7 @@ export type {ScrollingTroikaTextViewOptions} from './ui/components/ScrollingTroi export type {TextButtonOptions} from './ui/components/TextButton'; export type {TextViewOptions} from './ui/components/TextView'; export type {VideoViewOptions} from './ui/components/VideoView'; +export type {WebViewOptions} from './ui/components/WebView'; export type {PanelOptions} from './ui/core/PanelOptions'; export type {ViewOptions} from './ui/core/ViewOptions'; export type {ColOptions} from './ui/layouts/Col'; From 1d0304543e5ec05880fbd93bd04f36e96e4a6322 Mon Sep 17 00:00:00 2001 From: Dwane Hemmings Date: Sat, 3 Jan 2026 20:50:09 +0000 Subject: [PATCH 2/2] Refactor WebView.ts for improved code formatting and readability --- src/ui/components/WebView.ts | 88 ++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/src/ui/components/WebView.ts b/src/ui/components/WebView.ts index 624c9e6..bce5a4a 100644 --- a/src/ui/components/WebView.ts +++ b/src/ui/components/WebView.ts @@ -1,7 +1,10 @@ import * as THREE from 'three'; -import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js'; -import { View } from '../core/View'; -import { ViewOptions } from '../core/ViewOptions'; +import { + CSS3DRenderer, + CSS3DObject, +} from 'three/addons/renderers/CSS3DRenderer.js'; +import {View} from '../core/View'; +import {ViewOptions} from '../core/ViewOptions'; export type WebViewOptions = ViewOptions & { url: string; @@ -12,7 +15,7 @@ export class WebView extends View { name: string = 'WebView'; private static cssRenderer: CSS3DRenderer | null = null; - private static cssScene: THREE.Scene = new THREE.Scene(); + private static cssScene: THREE.Scene = new THREE.Scene(); private static instances: WebView[] = []; private static cameraRef: THREE.Camera | null = null; @@ -23,10 +26,9 @@ export class WebView extends View { public isRoot = false; private cssObject: CSS3DObject; - public occlusionMesh: THREE.Mesh; + public occlusionMesh: THREE.Mesh; constructor(options: WebViewOptions) { - // --- Units Logic (pixels vs. meters) --- const inputWidth = options.width ?? 1920; const inputHeight = options.height ?? 1080; @@ -34,7 +36,7 @@ export class WebView extends View { const physicalWidth = isPixels ? inputWidth * 0.001 : inputWidth; const physicalHeight = isPixels ? inputHeight * 0.001 : inputHeight; - super({ ...options, width: physicalWidth, height: physicalHeight }); + super({...options, width: physicalWidth, height: physicalHeight}); this.url = options.url; this.pixelWidth = isPixels ? inputWidth : physicalWidth / 0.001; @@ -60,7 +62,7 @@ export class WebView extends View { div.style.width = `${this.pixelWidth}px`; div.style.height = `${this.pixelHeight}px`; div.style.backgroundColor = '#000000'; - + const iframe = document.createElement('iframe'); iframe.style.width = '100%'; iframe.style.height = '100%'; @@ -70,14 +72,14 @@ export class WebView extends View { div.appendChild(iframe); this.cssObject = new CSS3DObject(div); - + // Add to private overlay scene WebView.cssScene.add(this.cssObject); } public static initialize(camera: THREE.Camera) { - WebView.cameraRef = camera; - WebView.ensureSystem(); + WebView.cameraRef = camera; + WebView.ensureSystem(); } public updateLayout(): void { @@ -89,27 +91,30 @@ export class WebView extends View { div.style.height = `${this.pixelHeight}px`; if (this.occlusionMesh) { - this.occlusionMesh.geometry.dispose(); - this.occlusionMesh.geometry = new THREE.PlaneGeometry(this.pixelWidth, this.pixelHeight); + this.occlusionMesh.geometry.dispose(); + this.occlusionMesh.geometry = new THREE.PlaneGeometry( + this.pixelWidth, + this.pixelHeight + ); } super.updateLayout(); } -private static ensureSystem() { - if (WebView.cssRenderer || !WebView.cameraRef) return; + private static ensureSystem() { + if (WebView.cssRenderer || !WebView.cameraRef) return; WebView.cssRenderer = new CSS3DRenderer(); WebView.cssRenderer.setSize(window.innerWidth, window.innerHeight); - + const style = WebView.cssRenderer.domElement.style; style.position = 'absolute'; style.top = '0'; style.left = '0'; style.width = '100%'; style.height = '100%'; - style.zIndex = '9999'; - style.pointerEvents = 'none'; - + style.zIndex = '9999'; + style.pointerEvents = 'none'; + document.body.appendChild(WebView.cssRenderer.domElement); window.addEventListener('resize', () => { @@ -118,42 +123,47 @@ private static ensureSystem() { const tick = () => { if (WebView.cssRenderer && WebView.cameraRef) { - - WebView.instances.forEach(view => { + WebView.instances.forEach((view) => { if (view.occlusionMesh && view.cssObject) { view.occlusionMesh.updateMatrixWorld(); - + // POSITION - view.cssObject.position.setFromMatrixPosition(view.occlusionMesh.matrixWorld); - view.cssObject.scale.setFromMatrixScale(view.occlusionMesh.matrixWorld); + view.cssObject.position.setFromMatrixPosition( + view.occlusionMesh.matrixWorld + ); + view.cssObject.scale.setFromMatrixScale( + view.occlusionMesh.matrixWorld + ); // ROTATION: WebView rotates to match SpatialPanel const targetRotation = new THREE.Quaternion(); let foundPanel = false; - + // Traverse up: WebView -> Row -> Grid -> SpatialPanel let parent = view.parent; while (parent) { - // Check if this parent looks like a SpatialPanel - if (parent.constructor.name === 'SpatialPanel') { - parent.updateMatrixWorld(); - targetRotation.setFromRotationMatrix(parent.matrixWorld); - foundPanel = true; - break; - } - parent = parent.parent; + // Check if this parent looks like a SpatialPanel + if (parent.constructor.name === 'SpatialPanel') { + parent.updateMatrixWorld(); + targetRotation.setFromRotationMatrix(parent.matrixWorld); + foundPanel = true; + break; + } + parent = parent.parent; } if (foundPanel) { - // Use the Panel's flat rotation - view.cssObject.quaternion.copy(targetRotation); + // Use the Panel's flat rotation + view.cssObject.quaternion.copy(targetRotation); } else { - // Fallback to the old way if we can't find a panel - view.cssObject.quaternion.setFromRotationMatrix(view.occlusionMesh.matrixWorld); + // Fallback to the old way if we can't find a panel + view.cssObject.quaternion.setFromRotationMatrix( + view.occlusionMesh.matrixWorld + ); } // Push WebView forward to clear the curved panel - view.cssObject.translateZ(0.08); + view.cssObject.translateZ(0.08); } }); @@ -163,4 +173,4 @@ private static ensureSystem() { }; tick(); } -} \ No newline at end of file +}