launcher/handmenu demo

This commit is contained in:
Leon van Kammen 2025-02-28 17:05:43 +01:00
parent 46bb9afe6d
commit 987828b233
26 changed files with 1227 additions and 835 deletions

View File

@ -19,7 +19,7 @@ BEGIN{
print "" print ""
} }
/\$\(/ { cmd=$0; /\$(?![\(|"])/ { cmd=$0;
gsub(/^.*\$\(/,"",cmd); gsub(/^.*\$\(/,"",cmd);
gsub(/\).*/,"",cmd); gsub(/\).*/,"",cmd);
cmd | getline stdout; close(cmd); cmd | getline stdout; close(cmd);

View File

@ -101,7 +101,7 @@ a Linux ISO image (via WASM).
| `height` | `number` | 600 || | `height` | `number` | 600 ||
| `depth` | `number` | 0.03 || | `depth` | `number` | 0.03 ||
| `lineHeight` | `number` | 18 || | `lineHeight` | `number` | 18 ||
| `prompt` | `boolean` | true | boot straight into ISO or give user choice | | `bootmenu` | `boolean` | true | give user choice [or boot straight into ISO ] |
| `padding` | `number`` | 18 | | | `padding` | `number`` | 18 | |
| `maximized` | `boolean` | false | | | `maximized` | `boolean` | false | |
| `minimized` | `boolean` | false | | | `minimized` | `boolean` | false | |
@ -147,6 +147,54 @@ NOTE: For convenience reasons, events are forwarded between com/isoterminal.js,
``` ```
## [launcher](com/launcher.js)
displays app (icons) for enduser to launch
```javascript
<a-scene launcher/>
```
| property | type | example |
|--------------|--------------------|----------------------------------------------------------------------------------------|
| `registries` | `array` of strings | `<a-entity launcher="registers: https://foo.com/index.json, ./index.json"/>` |
| event | target | info |
|--------------|-------------------------------------------------------------------------------------------------------------|
| `launcher` | an app | when pressing an app icon, `launcher` event will be send to the respective app |
There a multiple ways of letting the launcher know that an app can be launched:
1. any AFRAME component with an `launcher`-event + manifest is automatically added:
```javascript
AFRAME.registerComponent('foo',{
events:{
launcher: function(){ ...launch something... }
},
manifest:{ // HTML5 manifesto JSON object
// https://www.w3.org/TR/appmanifest/
}
}
```
2. dynamically in javascript
```javascript
window.launcher.register({
name:"foo",
icon: "https://.../optional_icon.png"
description: "lorem ipsum",
cb: () => alert("foo")
})
//window.launcher.unregister('foo')
```
Restore the pose.
## [pastedrop](com/pastedrop.js) ## [pastedrop](com/pastedrop.js)
detects user copy/paste and file dragdrop action detects user copy/paste and file dragdrop action
@ -161,9 +209,12 @@ and clipboard functions
| `pasteFile` | self | always translates input to a File object | | `pasteFile` | self | always translates input to a File object |
Restore the pose.
## [require](com/require('').js) ## [require](com/require('').js)
automatically requires dependencies automatically requires dependencies or missing components
```javascript ```javascript
await AFRAME.utils.require( this.dependencies ) (*) autoload missing components await AFRAME.utils.require( this.dependencies ) (*) autoload missing components
@ -173,3 +224,28 @@ await AFRAME.utils.require(["./app/foo.js","foo.css"],this)
``` ```
> (*) = prefixes baseURL AFRAME.utils.require.baseURL ('./com/' e.g.) > (*) = prefixes baseURL AFRAME.utils.require.baseURL ('./com/' e.g.)
## [window](com/window.js)
wraps a draggable window around a dom id or [dom](com/dom.js) component.
```html
<a-entity window="dom: #mydiv"/>
```
> depends on [AFRAME.utils.require](com/require.js)
| property | type | default | info |
|------------------|-----------|------------------------|------|
| `title` |`string` | "" | |
| `width` |`string` | | |
| `height` |`string` | 260px | |
| `uid` |`string` | | |
| `attach` |`selector` | | |
| `dom` |`selector` | | |
| `max` |`boolean` | false | |
| `min` |`boolean` | false | |
| `x` |`string` | "center" | |
| `y` |`string` | "center" | |
| `class` |`array` | [] | |

View File

@ -1,26 +0,0 @@
window.AFRAME.registerComponent('xrf-wear', {
schema:{
el: {type:"selector"},
position: {type:"vec3"},
rotation: {type:"vec3"}
},
init: function(){
$('a-scene').addEventListener('enter-vr', (e) => this.wear(e) )
$('a-scene').addEventListener('exit-vr', (e) => this.unwear(e) )
},
wear: function(){
if( !this.wearable ){
let d = this.data
this.wearable = new THREE.Group()
this.el.object3D.children.map( (c) => this.wearable.add(c) )
this.wearable.position.set( d.position.x, d.position.y, d.position.z)
this.wearable.rotation.set( d.rotation.x, d.rotation.y, d.rotation.z)
}
this.data.el.object3D.add(this.wearable)
},
unwear: function(){
this.data.el.remove(this.wearable)
this.wearable.children.map( (c) => this.el.object3D.add(c) )
delete this.wearable
}
})

85
com/controlattach.js Normal file
View File

@ -0,0 +1,85 @@
/*
*Usage:
* <a-entity controlattach="el: #leftHand; class: foo"></a-entity>
* <a-entity id="leftHand">
* <a-entity class="foo" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity>
*
* NOTE: you can hint different position/rotation-offsets by adding controller-specific entities to the target:
*
* <a-entity id="leftHand">
* <a-entity class="foo" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity class="foo hand-tracking-controls" position="0 0 0" rotation="0 0 0" scale="0 0 0"></a-entity>
* <a-entity>
*
* The controllernames are hinted by the 'controllermodelready' event:
*
* 'hand-tracking-controls',
* 'meta-touch-controls',
* 'valve-index-controls',
* 'logitech-mx-ink-controls',
* 'windows-motion-controls',
* 'hp-mixed-reality-controls',
* 'generic-tracked-controller-controls',
* 'pico-controls',
* (and more in the future)
*/
AFRAME.registerComponent('controlattach', {
schema:{
el: {type:"selector"},
class: {type:"string"}
},
init: function(){
if( !this.data.el ) return console.warn(`controlattach.js: cannot find ${this.data.el}`)
this.controllers = {}
this.remember()
this.data.el.addEventListener('controllermodelready', this.bindPlaceHolders.bind(this,["controllermodelready"]) )
this.data.el.addEventListener('controllerconnected', this.bindPlaceHolders.bind(this,["controllerconnected"] ) )
},
bindPlaceHolders: function(type,e){
let controllerName = e.detail.name
let selector = `.${this.data.class}.${controllerName}`
let placeholder = this.data.el.querySelector(selector)
if( !placeholder ){
placeholder = this.data.el.querySelector(`.${this.data.class}`)
console.warn(`controlattach.js: '${selector}' not found, fallback to default`)
}
if( !placeholder ) return console.warn("controlattach.js: could not find placeholder to attach to")
this.attachPlaceHolder(type,e,placeholder, controllerName)
},
attachPlaceHolder: function(type, el, placeholder, controllerName){
this.el.object3DMap = {} // unsync THREE <-> AFRAME entity
// these are handled by the placeholder entity
this.obj.position.set(0,0,0);
this.obj.rotation.set(0,0,0);
this.obj.scale.set(1,1,1);
if( controllerName != 'hand-tracking-controls' ){
// re-add for controller-models which don't re-add children ('meta-touch-controls' e.g.)
if( this.data.el.getObject3D("mesh") ){
this.data.el.getObject3D("mesh").add(placeholder.object3D)
}
}
placeholder.object3D.add( this.obj )
},
detach: function(){
this.el.setObject3D( this.obj.uuid, this.obj )
this.el.object3D.position.copy( this.position )
this.el.object3D.rotation.copy( this.rotation )
this.el.object3D.scale.copy( this.scale )
},
remember: function(){
this.obj = this.el.object3D
this.position = this.el.object3D.position.clone()
this.rotation = this.el.object3D.rotation.clone()
this.scale = this.el.object3D.scale.clone()
this.parent = this.el.object3D.parent
}
})

View File

@ -94,8 +94,12 @@ if( !AFRAME.components.dom ){
this.el.dom.innerHTML = this.dom.html(this) this.el.dom.innerHTML = this.dom.html(this)
this.el.dom.className = this.dom.attrName this.el.dom.className = this.dom.attrName
this.com.data = this.reactify( this.el, this.com.data ) this.com.data = this.reactify( this.el, this.com.data )
if( this.dom.events ) this.dom.events.map( (e) => this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) ) )
this.el.dom = this.el.dom.children[0] this.el.dom = this.el.dom.children[0]
if( this.dom.events ){
this.dom.events.map( (e) => {
this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) )
})
}
return this return this
}, },

View File

@ -1,107 +0,0 @@
AFRAME.registerComponent('helloworld-html', {
schema: {
foo: { type:"string"}
},
init: function () {
this.el.addEventListener('ready', () => this.el.dom.style.display = 'none' )
},
requires:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
},
dom: {
scale: 1,
events: ['click'],
html: (me) => `<div>
<div class="pad"> helloworld-html: ${me.data.foo} <b>${me.data.myvalue}</b></span>
</div>`,
css: (me) => `.helloworld-html {
color: var(--xrsh-light-gray); /* see index.css */
}`,
},
events:{
// combined AFRAME+DOM reactive events
keydown: function(e){ }, //
click: function(e){
console.dir(e)
}, //
// reactive events for this.data updates
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
ready: function( ){
this.el.dom.style.display = 'none'
console.log("this.el.dom has been added to DOM")
this.el.dom.children[0].id = this.el.uid // important hint for html-mesh
this.data.myvalue = 1
setInterval( () => this.data.myvalue++, 100 )
},
launcher: function(){
this.el.dom.style.display = ''
console.log("launcher")
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Hello world",
"name": "Hello world",
"icons": [
{
"src": "https://css.gg/browser.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View File

@ -1,156 +0,0 @@
AFRAME.registerComponent('helloworld-iframe', {
schema: {
url: { type:"string"}
},
init: function(){},
requires:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", //
},
dom: {
scale: 3,
events: ['click','keydown'],
html: (me) => `<div class="relative">
<span id="warning">
<b>Unfortunately,</b><br><br>
The browser does not allow IFRAME rendering<br>
in immersive mode (for security reasons).<br><br>
</span>
<iframe src=""></iframe>
</div>`,
css: (me) => `
.XR #overlay .winbox.iframe{
visibility: visible;
position:relative;
} /* don't hide in XR mode */
.winbox.iframe > .wb-body { background:#FFF !important;
overflow-y: hidden;
overflow-x: hidden;
}
.winbox.iframe iframe { z-index:10; }
.winbox.iframe #warning { position:absolute; z-index:9; top:100px; left:20px; width:100%; height:50px; color:black; display:none; }
`
},
events:{
// combined AFRAME+DOM reactive events
click: function(e){ }, //
keydown: function(e){ }, //
// reactive updates (data2event.js)
url: function(e){
this.el.dom.querySelector('iframe').src = this.data.url
console.dir(this.el.dom.querySelector('iframe'))
},
launcher: async function(){
let URL = this.data.url || prompt('enter URL to display','https://fabien.benetou.fr/Wiki/Wiki')
if( !URL ) return
this.createWindow()
},
createWindow: function(){
let s = await AFRAME.utils.require(this.requires)
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("data2event","")
instance.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' )
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(1.39) )
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
this.el.sceneEl.addEventListener('3D', () => {
instance.dom.querySelector('#warning').style.display = 'block' // show warning
})
const setupWindow = () => {
const com = instance.components['helloworld-iframe']
instance.dom.style.display = 'none'
new WinBox( this.manifest.short_name+" "+URL,{
width: Math.round(window.innerWidth*0.6),
height: Math.round(window.innerHeight*0.6),
class:["iframe"],
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
com.data.url = URL
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
instance.setAttribute("html",`html:#${instance.uid}; cursor:#cursor`)
instance.setAttribute("show-texture-in-xr","") // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = ''
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Iframe",
"name": "Hello world IFRAME window",
"icons": [
{
"src": "https://css.gg/browse.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View File

@ -1,135 +0,0 @@
AFRAME.registerComponent('helloworld-window', {
schema: {
foo: { type:"string", "default":"foo"}
},
requires: {
dom: "./com/dom.js", // interpret .dom object
xrtexture: "./com/html-as-texture-in-xr.js", // allow switching between 3D/3D
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", //
},
init: function(){ },
dom: {
scale: 0.8,
events: ['click','keydown'],
html: (me) => `<div>
<div class="pad"> <span>${me.data.foo}</span> <b>${me.data.myvalue}</b></span>
</div>`,
css: (me) => `.helloworld-window div.pad { padding:11px; }`
},
events:{
// combined AFRAME+DOM reactive events
click: function(e){ }, //
keydown: function(e){ }, //
// reactive events for this.data updates (data2event.js)
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
foo: function(e){ this.el.dom.querySelector('span').innerText = this.data.foo },
launcher: async function(){
let s = await AFRAME.utils.require(this.requires)
// instance this component
const instance = this.el.cloneNode(false)
this.el.sceneEl.appendChild( instance )
instance.setAttribute("dom", "")
instance.setAttribute("grabbable","")
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
const setupWindow = () => {
const com = instance.components['helloworld-window']
instance.dom.style.display = 'none'
new WinBox("Hello World",{
width: 250,
height: 150,
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
instance.setAttribute("show-texture-in-xr",`domid: #${instance.uid}`) // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = '' // show
// hint grabbable's obb-collider to track the window-object
instance.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
instance.components['obb-collider'].update()
// data2event demo
instance.setAttribute("data2event","")
com.data.myvalue = 1
com.data.foo = `instance ${instance.uid}: `
setInterval( () => com.data.myvalue++, 500 )
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
},
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "window",
"name": "Hello world window",
"icons": [
{
"src": "https://css.gg/browser.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBkPSJNNCA4QzQuNTUyMjggOCA1IDcuNTUyMjggNSA3QzUgNi40NDc3MiA0LjU1MjI4IDYgNCA2QzMuNDQ3NzIgNiAzIDYuNDQ3NzIgMyA3QzMgNy41NTIyOCAzLjQ0NzcyIDggNCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNOCA3QzggNy41NTIyOCA3LjU1MjI4IDggNyA4QzYuNDQ3NzIgOCA2IDcuNTUyMjggNiA3QzYgNi40NDc3MiA2LjQ0NzcyIDYgNyA2QzcuNTUyMjggNiA4IDYuNDQ3NzIgOCA3WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBkPSJNMTAgOEMxMC41NTIzIDggMTEgNy41NTIyOCAxMSA3QzExIDYuNDQ3NzIgMTAuNTUyMyA2IDEwIDZDOS40NDc3MSA2IDkgNi40NDc3MiA5IDdDOSA3LjU1MjI4IDkuNDQ3NzEgOCAxMCA4WiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMyAzQzEuMzQzMTUgMyAwIDQuMzQzMTUgMCA2VjE4QzAgMTkuNjU2OSAxLjM0MzE1IDIxIDMgMjFIMjFDMjIuNjU2OSAyMSAyNCAxOS42NTY5IDI0IDE4VjZDMjQgNC4zNDMxNSAyMi42NTY5IDMgMjEgM0gzWk0yMSA1SDNDMi40NDc3MiA1IDIgNS40NDc3MiAyIDZWOUgyMlY2QzIyIDUuNDQ3NzIgMjEuNTUyMyA1IDIxIDVaTTIgMThWMTFIMjJWMThDMjIgMTguNTUyMyAyMS41NTIzIDE5IDIxIDE5SDNDMi40NDc3MiAxOSAyIDE4LjU1MjMgMiAxOFoiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View File

@ -1,94 +0,0 @@
AFRAME.registerComponent('helloworld', {
schema: {
wireframe: { type:"boolean", "default":false},
text: {type:"string","default":"hello world"}
},
dependencies: ['data2event'],
init: async function() {
this.el.object3D.visible = false
await AFRAME.utils.require(this.dependencies)
this.el.setAttribute("data2event","")
this.el.setAttribute("grabbable","")
this.el.innerHTML = `
<a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9"></a-box>
<a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
<a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
<a-entity position="0 1.8 -3" scale="10 10 10" text="value: ${this.data.text}; align:center; color:#888"></a-entity>
`
},
events:{
launcher: function(e){
this.el.object3D.visible = !this.el.object3D.visible
clearInterval(this.interval)
this.interval = setInterval( () => {
this.data.wireframe = !this.data.wireframe
}, 500 )
},
// reactive this.data value demo
wireframe:function( ){
this.el.object3D.traverse( (obj) => obj.material && (obj.material.wireframe = this.data.wireframe) )
}
},
manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "Hello world",
"name": "Hello world",
"icons": [
{
"src": "https://css.gg/shape-hexagon.svg",
"src": "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNNiAxNS4yMzQ4TDEyIDE4LjU2ODFMMTggMTUuMjM0OFY4Ljc2NTIxTDEyIDUuNDMxODhMNiA4Ljc2NTIxVjE1LjIzNDhaTTEyIDJMMyA3VjE3TDEyIDIyTDIxIDE3VjdMMTIgMloiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgLz4KPC9zdmc+",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "Hello world information",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
This is a help file which describes the application.
It will be rendered thru troika text, and will contain
headers based on non-punctualized lines separated by linebreaks,
in above's case "\nHelloworld application\n" will qualify as header.
`
}
});

View File

@ -1,34 +1,44 @@
AFRAME.registerComponent('helloworld-htmlform', { AFRAME.registerComponent('helloworld-window', {
schema: { schema: {
foo: { type:"string"} foo: { type:"string"}
}, },
init: function () {}, init: function () {
},
requires:{ requires:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME window: "com/window.js",
winboxjs: "https://unpkg.com/winbox@0.2.82/dist/winbox.bundle.min.js", // deadsimple windows: https://nextapps-de.github.io/winbox reactive: "com/data2event.js"
winboxcss: "https://unpkg.com/winbox@0.2.82/dist/css/winbox.min.css", // deadsimple windows: https://nextapps-de.github.io/winbox
}, },
dom: { dom: {
scale: 1, scale: 0.66,
events: ['click','input'], events: ['click','input'],
html: (me) => `<div class="htmlform"> html: (me) => `<div class="htmlform">
<fieldset> <fieldset>
<legend>Colour</legend> <legend>Theme</legend>
<input type="radio" id="color-red" name="color" value="red" checked><label for="color-red"> Red</label><br> <input type="radio" id="theme" name="theme" value="0" checked style=""><label for="theme" style="margin-right:15px;">Normal</label>
<input type="radio" id="color-blue" name="color" value="blue"><label for="color-blue"> Blue</label><br> <input type="radio" id="themei" name="theme" value="1"><label for="themei">Invert</label><br>
</fieldset> </fieldset>
<br>
<fieldset> <fieldset>
<legend>Material:</legend> <legend>Welcome to XR Shell</legend>
<input id="material-wireframe" type="checkbox" name="wireframe"><label for="material-wireframe"> Wireframe</label><br> A free offline-first morphable<br>
environment which provides <br>
<span id="myvalue"></span>&nbsp; XR-friendly shells.<br>
<ol>
<li>check the <a href="/" target="_blank">website</a></li>
<li>check the <a href="https://forgejo.isvery.ninja/xrsh/xrsh-buildroot/src/branch/main/buildroot-v86/board/v86/rootfs_overlay/root/manual.md" target="_blank">manual</a></li>
</ol>
</fieldset> </fieldset>
<!--
<fieldset> <fieldset>
<legend>Size</legend> <legend>Size</legend>
<input type="range" min="0.1" max="2" value="1" step="0.01" id="myRange" style="background-color: transparent;"> <input type="range" min="0.1" max="2" value="1" step="0.01" id="myRange" style="background-color: transparent;">
</fieldset> </fieldset>
<button>hello <span id="myvalue"></span></button> <button>hello <span id="myvalue"></span></button>
-->
</div>`, </div>`,
css: (me) => `.htmlform { padding:11px; }` css: (me) => `.htmlform { padding:11px; }`
@ -38,13 +48,16 @@ AFRAME.registerComponent('helloworld-htmlform', {
events:{ events:{
// component events // component events
html: function( ){ console.log("html-mesh requirement mounted") }, window: function( ){ console.log("window component mounted") },
// combined AFRAME+DOM reactive events // combined AFRAME+DOM reactive events
click: function(e){ }, // click: function(e){ console.dir(e) }, //
input: function(e){ input: function(e){
if( !e.detail.target ) return if( !e.detail.target ) return
if( e.detail.target.id == 'myRange' ) this.data.myvalue = e.detail.target.value // reactive demonstration if( e.detail.target.id == 'myRange' ) this.data.myvalue = e.detail.target.value // reactive demonstration
if( e.detail.target.name == 'theme' ) document.body.style.filter = `invert(${e.detail.target.value})`
if( e.detail.target.name == 'cmenu' ) document.querySelector(".iconmenu").style.display = e.detail.target.value == 'on' ? '' : 'none';
console.dir(e.detail)
}, },
// reactive events for this.data updates // reactive events for this.data updates
@ -54,51 +67,23 @@ AFRAME.registerComponent('helloworld-htmlform', {
let s = await AFRAME.utils.require(this.requires) let s = await AFRAME.utils.require(this.requires)
// instance this component // instance this component
const instance = this.el.cloneNode(false) this.el.setAttribute("dom", "")
this.el.sceneEl.appendChild( instance ) this.el.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
instance.setAttribute("dom", "")
instance.setAttribute("show-texture-in-xr", "") // only show aframe-html in xr
instance.setAttribute("grabbable","")
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
const setupWindow = () => {
const com = instance.components['helloworld-htmlform']
instance.dom.style.display = 'none'
new WinBox("Hello World",{
width: 250,
height: 340,
x:"center",
y:"center",
id: instance.uid, // important hint for html-mesh
root: document.querySelector("#overlay"),
mount: instance.dom,
onclose: () => { instance.dom.style.display = 'none'; return false; },
oncreate: () => {
instance.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.5) )
instance.setAttribute("html",`html:#${instance.uid}; cursor:#cursor`)
instance.setAttribute("show-texture-in-xr","") // only show aframe-html texture in xr mode
}
});
instance.dom.style.display = AFRAME.utils.XD() == '3D' ? 'none' : '' // show/hide
// hint grabbable's obb-collider to track the window-object
instance.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
instance.components['obb-collider'].update()
// data2event demo
instance.setAttribute("data2event","")
com.data.myvalue = 1
com.data.foo = `instance ${instance.uid}: `
setInterval( () => com.data.myvalue++, 500 )
}
setTimeout( () => setupWindow(), 10 ) // give new components time to init
}, },
ready: function( ){ DOMready: function(){
this.el.dom.style.display = 'none' this.el.setAttribute("window", `title: Welcome; uid: ${this.el.uid}; attach: #overlay; dom: #${this.el.dom.id}; width:250; height: 360`)
console.log("this.el.dom has been added to DOM")
this.data.myvalue = 1 // data2event demo
this.el.setAttribute("data2event","")
this.data.myvalue = 1001
this.data.foo = `this.el ${this.el.uid}: `
setInterval( () => this.data.myvalue++, 500 )
},
"window.oncreate": function(){
this.el.setAttribute("html-as-texture-in-xr", `domid: .winbox#${this.el.uid}; faceuser: true`)
} }
}, },

View File

@ -34,69 +34,62 @@ if( !AFRAME.components['html-as-texture-in-xr'] ){
AFRAME.registerComponent('html-as-texture-in-xr', { AFRAME.registerComponent('html-as-texture-in-xr', {
schema: { schema: {
domid: { type:"string"}, domid: { type:"string"},
faceuser: { type: "boolean", default: false} doublesided: {type: "boolean", default: true},
faceuser: { type: "boolean", default: false}
}, },
dependencies:{ dependencies:{
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME html: "com/lib/aframe-html.js"
//html: "https://coderofsalvation.github.io/aframe-htmlmesh/build/aframe-html.js"
//html: "com/aframe-html.js"
}, },
init: async function () { init: async function () {
let el = document.querySelector(this.data.domid) let el = document.querySelector(this.data.domid)
if( ! el ){ if( ! el ){
return console.error("html-as-texture-in-xr: cannot get dom element "+this.data.dom.id) return console.error("html-as-texture-in-xr: cannot get dom element "+this.data.domid)
} }
let s = await AFRAME.utils.require(this.dependencies) let s = await AFRAME.utils.require(this.dependencies)
this.el.setAttribute("html",`html: ${this.data.domid}; cursor:#cursor; xrlayer: true`)
this.forwardClickToMesh();
this.el.sceneEl.addEventListener('enter-vr', () => this.enableDoubleSided() )
this.el.setAttribute("html",`html: ${this.data.domid}; cursor:#cursor; `)
this.el.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' ) this.el.setAttribute("visible", AFRAME.utils.XD() == '3D' ? 'true' : 'false' )
if( this.data.faceuser ){ if( this.data.faceuser ){
this.el.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.4) ) this.el.setAttribute("position", AFRAME.utils.XD.getPositionInFrontOfCamera(0.4) )
} }
}, },
manifest: { // HTML5 manifest to identify app to xrsh forwardClickToMesh: function(){
"short_name": "show-texture-in-xr", // monkeypatch: forward click to mesh
"name": "2D/3D switcher", const handle = AFRAME.components['html'].Component.prototype.handle
"icons": [], AFRAME.components['html'].Component.prototype.handle = function(type,evt){
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"background_color": "#3367D6",
"display": "standalone",
"scope": "/",
"theme_color": "#3367D6",
"category":"system",
"shortcuts": [
{
"name": "What is the latest news?",
"cli":{
"usage": "helloworld <type> [options]",
"example": "helloworld news",
"args":{
"--latest": {type:"string"}
}
},
"short_name": "Today",
"description": "View weather information for today",
"url": "/today?source=pwa",
"icons": [{ "src": "/images/today.png", "sizes": "192x192" }]
}
],
"description": "use ESC-key to toggle between 2D / 3D",
"screenshots": [
{
"src": "/images/screenshot1.png",
"type": "image/png",
"sizes": "540x720",
"form_factor": "narrow"
}
],
"help":`
Helloworld application
` if( !this.el.sceneEl.renderer.xr.isPresenting ) return // ignore events in desktop mode
if( this.el.sceneEl.renderer.xr.isPresenting && type.match(/^mouse/) ) return // ignore mouse-events in XR
if( type == 'click' && evt.detail.length && evt.detail[0].uv ){
const mesh = this.el.object3D.children[0]
const uv = evt.detail[0].uv;
const _pointer = new THREE.Vector2();
const _event = { type: '', data: _pointer };
_event.type = type;
_event.data.set( uv.x, 1 - uv.y );
mesh.dispatchEvent( _event );
}
return handle.apply(this,[type,evt])
}
},
enableDoubleSided: function(){
// enable doubleside
this.el.object3D.traverse( (o) => {
if( o.constructor && String(o.constructor).match(/HTMLMesh/) ){
o.material.side = THREE.DoubleSide
}
})
} }
}); });

View File

@ -140,7 +140,7 @@ if( typeof AFRAME != 'undefined '){
css: (me) => ` css: (me) => `
.isoterminal{ .isoterminal{
padding: ${me.com.data.padding}px; padding: ${me.com.data.padding}px 0px 0px ${me.com.data.padding}px;
width:100%; width:100%;
height:99%; height:99%;
resize: both; resize: both;
@ -252,6 +252,8 @@ if( typeof AFRAME != 'undefined '){
background: transparent; background: transparent;
} }
.wb-control { margin-right:10px }
.XR .isoterminal{ .XR .isoterminal{
background: #000; background: #000;
} }
@ -336,7 +338,7 @@ if( typeof AFRAME != 'undefined '){
this.term.emit('term_init', {instance, aEntity:this}) this.term.emit('term_init', {instance, aEntity:this})
//instance.winbox.resize(720,380) //instance.winbox.resize(720,380)
let size = `width: ${this.data.width}; height: ${this.data.height}` let size = `width: ${this.data.width}; height: ${this.data.height}`
instance.setAttribute("window", `title: xrsh.iso; uid: ${instance.uid}; attach: #overlay; dom: #${instance.dom.id}; ${size}; min: ${this.data.minimized}; max: ${this.data.maximized}; class: no-full, no-max, no-resize`) instance.setAttribute("window", `title: xrsh; uid: ${instance.uid}; attach: #overlay; dom: #${instance.dom.id}; ${size}; min: ${this.data.minimized}; max: ${this.data.maximized}; class: no-full, no-max, no-resize, no-close; `)
}) })
instance.addEventListener('window.oncreate', (e) => { instance.addEventListener('window.oncreate', (e) => {
@ -372,7 +374,7 @@ if( typeof AFRAME != 'undefined '){
const w = instance.winbox const w = instance.winbox
if(!w) return if(!w) return
w.titleBak = w.titleBak || w.title w.titleBak = w.titleBak || w.title
w.setTitle( `${w.titleBak} [${msg}]` ) w.setTitle( `${w.titleBak} ${msg ? "["+msg+"]" : ""}` )
}) })
instance.addEventListener('window.onclose', (e) => { instance.addEventListener('window.onclose', (e) => {
@ -383,18 +385,13 @@ if( typeof AFRAME != 'undefined '){
instance.addEventListener('window.onresize', resize ) instance.addEventListener('window.onresize', resize )
instance.addEventListener('window.onmaximize', resize ) instance.addEventListener('window.onmaximize', resize )
const focus = (showdom) => (e) => { const focus = (e) => {
this.el.emit('focus',e.detail) this.el.emit('focus',e.detail)
if( this.el.components.window && this.data.renderer == 'canvas'){
this.el.components.window.show( showdom )
}
} }
this.el.addEventListener('obbcollisionstarted', focus(false) ) this.el.addEventListener('obbcollisionstarted', focus )
this.el.sceneEl.addEventListener('enter-vr', focus(false) ) this.el.sceneEl.addEventListener('exit-vr', focus )
this.el.sceneEl.addEventListener('enter-ar', focus(false) ) this.el.sceneEl.addEventListener('exit-ar', focus )
this.el.sceneEl.addEventListener('exit-vr', focus(true) )
this.el.sceneEl.addEventListener('exit-ar', focus(true) )
instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera instance.object3D.quaternion.copy( AFRAME.scenes[0].camera.quaternion ) // face towards camera
}, },
@ -444,7 +441,12 @@ if( typeof AFRAME != 'undefined '){
myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue }, myvalue: function(e){ this.el.dom.querySelector('b').innerText = this.data.myvalue },
launcher: async function(){ launcher: async function(){
this.initTerminal() if( !this.term.instance ){
this.initTerminal()
}else{
// toggle visibility
this.el.winbox[ this.el.winbox.min ? 'restore' : 'minimize' ]()
}
} }
}, },

View File

@ -266,13 +266,13 @@ ISOTerminal.prototype.startVM = function(opts){
let msglib = this.getLoaderMsg() let msglib = this.getLoaderMsg()
let msg = msglib.motd let msg = msglib.motd
this.emit('status',msglib.loadmsg)
this.emit('serial-output-string', msg) this.emit('serial-output-string', msg)
this.emit('bootMenu',{bootMenu: this.opts.bootMenu, bootMenuURL: this.opts.bootMenuURL }) this.emit('bootMenu',{bootMenu: this.opts.bootMenu, bootMenuURL: this.opts.bootMenuURL })
} }
ISOTerminal.prototype.bootISO = function(){ ISOTerminal.prototype.bootISO = function(){
let msglib = this.getLoaderMsg() let msglib = this.getLoaderMsg()
this.emit('status',msglib.loadmsg)
let msg = "\n\r" + msglib.empowermsg + msglib.text_color + msglib.loadmsg + msglib.text_reset let msg = "\n\r" + msglib.empowermsg + msglib.text_color + msglib.loadmsg + msglib.text_reset
this.emit('serial-output-string', msg) this.emit('serial-output-string', msg)
this.emit('runISO',{...this.v86opts, bufferLatency: this.opts.bufferLatency }) this.emit('runISO',{...this.v86opts, bufferLatency: this.opts.bufferLatency })

View File

@ -4,8 +4,8 @@ ISOTerminal.addEventListener('ready', function(e){
ISOTerminal.prototype.bootMenu = function(e){ ISOTerminal.prototype.bootMenu = function(e){
this.boot.menu.selected = false // reset this.boot.menu.selected = false // reset
const autobootURL = e.detail.bootMenuURL && document.location.hash.length > 1 const autobootURL = e && e.detail.bootMenuURL && document.location.hash.length > 1
const autoboot = e.detail.bootMenu || autobootURL const autoboot = e && e.detail.bootMenu || autobootURL
if( !autoboot ){ if( !autoboot ){
let msg = '\n\r' let msg = '\n\r'

View File

@ -96,10 +96,10 @@ ISOTerminal.addEventListener('init', function(){
}, },
keyHandler: function(ch){ keyHandler: function(ch){
let erase = false let erase = false
if( ch == '\x7F' ){ // if( ch == '\x7F' ){
ch = "\b \b" // why does write() not just support \x7F ? // ch = "\b \b" // why does write() not just support \x7F ?
erase = true // erase = true
} // }
this.send(ch) this.send(ch)
const reset = () => { const reset = () => {
this.console = "" this.console = ""

View File

@ -92,7 +92,11 @@ ISOTerminal.prototype.TermInit = function(){
} }
if( !erase ) this.lastChar = ch if( !erase ) this.lastChar = ch
}) })
aEntity.el.addEventListener('focus', () => el.querySelector("textarea").focus() ) aEntity.el.addEventListener('focus', () => {
let textarea = el.querySelector("textarea")
textarea.focus()
if( document.activeElement != textarea ) textarea.focus()
})
aEntity.el.addEventListener('serial-output-string', (e) => { aEntity.el.addEventListener('serial-output-string', (e) => {
let msg = e.detail let msg = e.detail
this.term.write(msg) this.term.write(msg)

View File

@ -1,20 +1,52 @@
/* /**
* ## launcher * ## [launcher](com/launcher.js)
* *
* displays app (icons) for enduser to launch * displays app (icons) in 2D and 3D handmenu (enduser can launch desktop-like 'apps')
* *
* ```javascript * ```html
* <a-entity app="app/launcher.js"/> * <a-entity launcher>
* <a-entity launch="component: helloworld; foo: bar"><a-entity>
* </a-entity>
*
* ``` * ```
* *
* | property | type | example | * | property | type | example |
* |--------------|--------------------|----------------------------------------------------------------------------------------| * |--------------|--------------------|----------------------------------------------------------------------------------------|
* | `registries` | `array` of strings | <a-entity app="app/launcher.js; registers: https://foo.com/index.json, ./index.json"/> | * | `attach` | `selector` | hand or object to attach menu to |
* | `registries` | `array` of strings | `<a-entity launcher="registers: https://foo.com/index.json, ./index.json"/>` |
* *
* | event | target | info | * | event | target | info |
* |--------------|-------------------------------------------------------------------------------------------------------------| * |--------------|-------------------------------------------------------------------------------------------------------------|
* | `launcher` | an app | when pressing an app icon, `launcher` event will be send to the respective app | * | `launcher` | an app | when pressing an app icon, `launcher` event will be send to the respective app |
*/ *
* There a multiple ways of letting the launcher know that an app can be launched:
*
* 1. any AFRAME component with an `launcher`-event + manifest is automatically added:
*
* ```javascript
* AFRAME.registerComponent('foo',{
* events:{
* launcher: function(){ ...launch something... }
* },
* manifest:{ // HTML5 manifesto JSON object
* // https://www.w3.org/TR/appmanifest/
* }
* }
* ```
*
* 2. dynamically in javascript
*
* ```javascript
* window.launcher.register({
* name:"foo",
* icon: "https://.../optional_icon.png"
* description: "lorem ipsum",
* cb: () => alert("foo")
* })
* //window.launcher.unregister('foo')
* ```
*
*/
AFRAME.registerComponent('launch', { // use this component to auto-launch component AFRAME.registerComponent('launch', { // use this component to auto-launch component
init: function(){ init: function(){
@ -26,12 +58,6 @@ AFRAME.registerComponent('launch', { // use this component to auto-launch compon
AFRAME.registerComponent('launcher', { AFRAME.registerComponent('launcher', {
schema: { schema: {
attach: { type:"selector"},
padding: { type:"number","default":0.15},
fingerTip: {type:"selector"},
fingerDistance: {type:"number", "default":0.25},
rescale: {type:"number","default":0.4},
open: { type:"boolean", "default":true},
colors: { type:"array", "default": [ colors: { type:"array", "default": [
'#4C73FE', '#4C73FE',
'#554CFE', '#554CFE',
@ -42,52 +68,32 @@ AFRAME.registerComponent('launcher', {
'#333333', '#333333',
]}, ]},
paused: { type:"boolean","default":false}, paused: { type:"boolean","default":false},
cols: { type:"number", "default": 5 }
}, },
dependencies:{ requires:{
dom: "com/dom.js" dom: "com/dom.js",
htmlinxr: "com/html-as-texture-in-xr.js",
data2events: "com/data2event.js"
}, },
init: async function () { init: async function () {
await AFRAME.utils.require(this.dependencies) this.el.object3D.visible = false;
await AFRAME.utils.require(this.requires)
this.worldPosition = new THREE.Vector3() this.worldPosition = new THREE.Vector3()
await AFRAME.utils.require({
html: "https://unpkg.com/aframe-htmlmesh@2.1.0/build/aframe-html.js", // html to AFRAME
dom: "./com/dom.js",
data2events: "./com/data2event.js"
})
this.el.setAttribute("dom","") this.el.setAttribute("dom","")
this.el.setAttribute("noxd","ignore") // hint to XD.js that we manage ourselve concerning 2D/3D switching this.el.setAttribute("pressable","")
this.el.sceneEl.addEventListener('enter-vr', () => this.centerMenu() )
this.el.sceneEl.addEventListener('enter-ar', () => this.centerMenu() )
this.render() this.render()
if( this.data.attach ){
this.el.object3D.visible = false
if( this.isHand(this.data.attach) ){
this.data.attach.addEventListener('model-loaded', () => {
this.ready = true
this.attachMenu()
})
// add button
this.menubutton = this.createMenuButton()
this.menubutton.object3D.visible = false
this.data.attach.appendChild( this.menubutton )
}else this.data.attach.appendChild(this.el)
}
},
isHand: (el) => {
return el.getAttributeNames().filter( (n) => n.match(/^hand-tracking/) ? n : null ).length ? true : false
}, },
dom: { dom: {
scale: 3, scale: 0.8,
events: ['click'], events: ['click'],
html: (me) => `<div class="iconmenu">loading components..</div>`, html: (me) => `<div class="iconmenu">loading components..</div>`,
css: (me) => `.iconmenu { css: (me) => `
.iconmenu {
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -95,7 +101,6 @@ AFRAME.registerComponent('launcher', {
height: 50px; height: 50px;
overflow:hidden; overflow:hidden;
position: fixed; position: fixed;
right: 162px;
bottom: 10px; bottom: 10px;
left:20px; left:20px;
background: transparent; background: transparent;
@ -116,6 +121,8 @@ AFRAME.registerComponent('launcher', {
border-top: 2px solid #BBB; border-top: 2px solid #BBB;
border-bottom: 2px solid #BBB; border-bottom: 2px solid #BBB;
font-size:18px; font-size:18px;
color: #777;
line-height: 7px;
} }
.iconmenu > button:first-child { .iconmenu > button:first-child {
@ -132,6 +139,10 @@ AFRAME.registerComponent('launcher', {
padding-top:13px; padding-top:13px;
} }
.iconmenu > button:only-child{
border-radius:5px 5px 5px 5px;
}
.iconmenu > button > img { .iconmenu > button > img {
transform: translate(0px,-14px); transform: translate(0px,-14px);
opacity:0.5; opacity:0.5;
@ -146,15 +157,11 @@ AFRAME.registerComponent('launcher', {
}, },
events:{ events:{
open: function(){
this.preventAccidentalButtonPresses() click: function(e){ },
if( this.data.open ){
this.el.setAttribute("animation",`dur: 200; property: scale; from: 0 0 1; to: ${this.data.rescale} ${this.data.rescale} ${this.data.rescale}`) DOMready: function(){
this.menubutton.object3D.visible = false this.el.setAttribute("html-as-texture-in-xr", `domid: #${this.el.dom.id}; faceuser: false`)
}else{
this.el.setAttribute("animation",`dur: 200; property: scale; from: ${this.data.rescale} ${this.data.rescale} ${this.data.rescale}; to: 0 0 1`)
this.menubutton.object3D.visible = true
}
} }
}, },
@ -163,136 +170,70 @@ AFRAME.registerComponent('launcher', {
setTimeout( () => this.data.paused = false, 500 ) // prevent menubutton press collide with animated buttons setTimeout( () => this.data.paused = false, 500 ) // prevent menubutton press collide with animated buttons
}, },
createMenuButton: function(colo){
let aentity = document.createElement('a-entity')
aentity.setAttribute("mixin","menubutton")
aentity.addEventListener('obbcollisionstarted', this.onpress )
aentity.addEventListener('obbcollisionended', this.onreleased )
return aentity
},
render: async function(els){ render: async function(els){
if( !this.el.dom ) return // too early (dom.js component not ready) if( !this.el.dom ) return // too early (dom.js component not ready)
let requires = []
let i = 0
let j = 0
let colors = this.data.colors let colors = this.data.colors
const add2D = (launchCom,el,manifest) => { const add2D = (launchCom,manifest) => {
let btn = document.createElement('button') let btn = document.createElement('button')
btn.innerHTML = `${ manifest?.icons?.length > 0 let iconDefault = "data:image/svg+xml;base64,PHN2ZwogIHdpZHRoPSIyNCIKICBoZWlnaHQ9IjI0IgogIHZpZXdCb3g9IjAgMCAyNCAyNCIKICBmaWxsPSJub25lIgogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKPgogIDxwYXRoCiAgICBmaWxsLXJ1bGU9ImV2ZW5vZGQiCiAgICBjbGlwLXJ1bGU9ImV2ZW5vZGQiCiAgICBkPSJNMjAuMTcwMiAzTDIwLjE2NjMgMy4wMDQ1M0MyMS43NDU4IDMuMDkwODQgMjMgNC4zOTg5NiAyMyA2VjE4QzIzIDE5LjY1NjkgMjEuNjU2OSAyMSAyMCAyMUg0QzIuMzQzMTUgMjEgMSAxOS42NTY5IDEgMThWNkMxIDQuMzQzMTUgMi4zNDMxNSAzIDQgM0gyMC4xNzAyWk0xMC40NzY0IDVIMTYuNDc2NEwxMy4wODkgOUg3LjA4ODk5TDEwLjQ3NjQgNVpNNS4wODg5OSA5TDguNDc2NDQgNUg0QzMuNDQ3NzIgNSAzIDUuNDQ3NzIgMyA2VjlINS4wODg5OVpNMyAxMVYxOEMzIDE4LjU1MjMgMy40NDc3MiAxOSA0IDE5SDIwQzIwLjU1MjMgMTkgMjEgMTguNTUyMyAyMSAxOFYxMUgzWk0yMSA5VjZDMjEgNS40NDc3MSAyMC41NTIzIDUgMjAgNUgxOC40NzY0TDE1LjA4OSA5SDIxWiIKICAgIGZpbGw9ImN1cnJlbnRDb2xvciIKICAvPgo8L3N2Zz4="
? `<img src='${manifest.icons[0].src}' title='${manifest.name}: ${manifest.description}'/>` let html = manifest?.icons?.length > 0 || !manifest.name ? `<img src='${manifest.icons[0].src || iconDefault}' title='${manifest.name}: ${manifest.description}'/>` : ""
: `${manifest.short_name}` if( manifest.name && !html ) html = `${manifest.short_name || manifest.name}`
}` btn.innerHTML = html
btn.addEventListener('click', () => el.emit('launcher',{}) )
this.el.dom.appendChild(btn)
}
const add3D = (launchCom,el,manifest) => { btn.addEventListener('click', (e) => {
let aentity = document.createElement('a-entity') launchCom.launcher()
let atext = document.createElement('a-entity') e.stopPropagation()
let padding = this.data.padding // visual feedback to user
if( (i % this.data.cols) == 0 ) j++ btn.style.filter = "brightness(0.5)"
aentity.setAttribute("mixin","menuitem") this.el.components.html.rerender()
aentity.setAttribute("position",`${padding+(i++ % this.data.cols) * padding} ${j*padding} 0`) setTimeout( () => {
if( !aentity.getAttribute("material")){ btn.style.filter = "brightness(1)"
aentity.setAttribute('material',`side: double; color: ${colors[ i % colors.length]}`) this.el.components.html.rerender()
} }, 500 )
aentity.addEventListener('obbcollisionstarted', this.onpress ) return false
aentity.addEventListener('obbcollisionended', this.onreleased ) })
atext.setAttribute("text",`value: ${manifest.short_name}; align: baseline; anchor: align; align:center; wrapCount:7`) this.el.dom.appendChild(btn)
atext.setAttribute("scale","0.1 0.1 0.1")
aentity.appendChild(atext)
this.el.appendChild(aentity)
aentity.launchCom = launchCom
return aentity
} }
// finally render them! // finally render them!
this.el.dom.innerHTML = '' // clear this.el.dom.innerHTML = '' // clear
els = els || this.system.components els = els || this.system.launchables
els.map( (c) => { els.map( (c) => {
const launchComponentKey = c.getAttributeNames().shift() const manifest = c.manifest
const launchCom = c.components[ launchComponentKey ]
if( !launchCom ) return console.warn(`could not find component '${launchComponentKey}' (forgot to include script-tag?)`)
const manifest = launchCom.manifest
if( manifest ){ if( manifest ){
add2D(launchCom,c,manifest) add2D(c,manifest)
add3D(launchCom,c,manifest)
} }
}) })
this.centerMenu();
}, },
onpress: function(e){ centerMenu: function(){
const launcher = document.querySelector('[launcher]').components.launcher // center along x-axis
if( launcher.data.paused ) return // prevent accidental pressed due to animation this.el.object3D.traverse( (o) => {
if( e.detail.withEl.computedMixinStr == 'menuitem' ) return // dont react to menuitems touching eachother if( o.constructor && String(o.constructor).match(/HTMLMesh/) ){
this.setOriginToMiddle(o, this.el.object3D, {x:true})
// if user press menu button toggle menu o.position.z = 0.012 // position a bit before the grab-line
if( launcher && !launcher.data.open && e.srcElement.computedMixinStr == 'menubutton' ){ o.position.y = -0.01 // position a bit before the grab-line
return (launcher.data.open = true)
}
if( launcher && !launcher.data.open ) return // dont process menuitems when menu is closed
let el = e.srcElement
if(!el) return
el.object3D.traverse( (o) => {
if( o.material && o.material.color ){
if( !o.material.colorOriginal ) o.material.colorOriginal = o.material.color.clone()
o.material.color.r *= 0.3
o.material.color.g *= 0.3
o.material.color.b *= 0.3
} }
}) })
if( el.launchCom ){ this.el.object3D.visible = true // ensure visibility
console.log("launcher.js: launching "+el.launchCom.el.getAttributeNames().shift())
launcher.preventAccidentalButtonPresses()
el.launchCom.el.emit('launcher') // launch component!
this.data.open = false // close to prevent infinite loop of clicks when leaving immersive mode
}
}, },
onreleased: function(e){ setOriginToMiddle: function(fromObject, toObject, axis) {
if( e.detail.withEl.computedMixinStr == 'menuitem' ) return // dont react to menuitems touching eachother var boxFrom = new THREE.Box3().setFromObject(fromObject);
let el = e.srcElement var boxTo = new THREE.Box3().setFromObject(toObject);
el.object3D.traverse( (o) => { var center = new THREE.Vector3();
if( o.material && o.material.color ){ if( !toObject.positionOriginal ) toObject.positionOriginal = toObject.position.clone()
if( o.material.colorOriginal ) o.material.color = o.material.colorOriginal.clone() center.x = axis.x ? - (boxFrom.max.x/2) : 0
} center.y = axis.y ? - (boxFrom.max.y/2) : 0
}) center.z = axis.z ? - (boxFrom.max.z/2) : 0
toObject.position.copy( toObject.positionOriginal )
toObject.position.sub(center);
}, },
attachMenu: function(){
if( this.el.parentNode != this.data.attach ){
this.el.object3D.visible = true
let armature = this.data.attach.object3D.getObjectByName('Armature')
if( !armature ) return console.warn('cannot find armature')
this.data.attach.object3D.children[0].add(this.el.object3D)
this.el.object3D.scale.x = this.data.rescale
this.el.object3D.scale.y = this.data.rescale
this.el.object3D.scale.z = this.data.rescale
// add obb-collider to index finger-tip
let aentity = document.createElement('a-entity')
trackedObject3DVariable = 'parentNode.components.hand-tracking-controls.bones.9';
this.data.fingerTip.appendChild(aentity)
aentity.setAttribute('obb-collider', {trackedObject3D: trackedObject3DVariable, size: 0.015});
if( this.isHand(this.data.attach) ){
// shortly show and hide menu into palm (hint user)
setTimeout( () => { this.data.open = false }, 1500 )
}
}
},
tick: function(){
if( this.ready && this.data.open ){
let indexTipPosition = document.querySelector('#right-hand[hand-tracking-controls]').components['hand-tracking-controls'].indexTipPosition
this.el.object3D.getWorldPosition(this.worldPosition)
const lookingAtPalm = this.data.attach.components['hand-tracking-controls'].wristObject3D.rotation.z > 2.0
if( !lookingAtPalm ){ this.data.open = false }
}
},
manifest: { // HTML5 manifest to identify app to xrsh manifest: { // HTML5 manifest to identify app to xrsh
"short_name": "launcher", "short_name": "launcher",
@ -352,35 +293,73 @@ in above's case "\nHelloworld application\n" will qualify as header.
AFRAME.registerSystem('launcher',{ AFRAME.registerSystem('launcher',{
init: function(){ init: function(){
this.components = [] this.launchables = []
this.dom = []
this.registered = []
// observe HTML changes in <a-scene> // observe HTML changes in <a-scene>
observer = new MutationObserver( (a,b) => this.getLaunchables(a,b) ) observer = new MutationObserver( (a,b) => this.getLaunchables(a,b) )
observer.observe( this.sceneEl, {characterData: false, childList: true, attributes: false}); observer.observe( this.sceneEl, {characterData: false, childList: true, attributes: false});
window.launcher = this
this.getLaunchables()
},
register: function(launchable){
try{
let {name, description, cb} = launchable
this.registered.push({
manifest: {name, description, icons: launchable.icon ? [{src:launchable.icon}] : [] },
launcher: cb
})
}catch(e){
console.error('AFRAME.systems.launcher.register({ name, description, icon, cb }) got invalid obj')
console.error(e)
}
this.getLaunchables()
},
unregister: function(launchableName){
this.registered = this.registered.filter( (l) => l.name != launchableName )
}, },
getLaunchables: function(mutationsList,observer){ getLaunchables: function(mutationsList,observer){
let searchEvent = 'launcher' let searchEvent = 'launcher'
let els = [...this.sceneEl.getElementsByTagName("*")] let els = [...this.sceneEl.getElementsByTagName("*")]
let seen = {} let seen = {}
this.launchables = [
/*
* {
* manifest: {...}
* launcher: () => ....
* }
*/
];
this.components = els.filter( (el) => { // collect manually registered launchables
this.registered.map( (launchable) => this.launchables.push(launchable) )
// collect launchables in aframe dom elements
this.dom = els.filter( (el) => {
let hasEvent = false let hasEvent = false
if( el.components ){ if( el.components ){
for( let i in el.components ){ for( let i in el.components ){
if( el.components[i].events && el.components[i].events[searchEvent] && !seen[i] ){ if( el.components[i].events && el.components[i].events[searchEvent] && !seen[i] ){
hasEvent = seen[i] = true let com = hasEvent = seen[i] = el.components[i]
com.launcher = () => com.el.emit('launcher',null,false) // important: no bubble
this.launchables.push(com)
} }
} }
} }
return hasEvent ? el : null return hasEvent ? el : null
}) })
this.updateLauncher() this.updateLauncher()
return seen return this.launchables
}, },
updateLauncher: function(){ updateLauncher: function(){
let launcher = document.querySelector('[launcher]') let launcher = document.querySelector('[launcher]')
if( launcher ) launcher.components.launcher.render() if( launcher && launcher.components.launcher) launcher.components.launcher.render()
} }
}) })

679
com/lib/aframe-html.js Normal file
View File

@ -0,0 +1,679 @@
(function (three) {
'use strict';
// This is a copy of https://github.com/mrdoob/three.js/blob/0403020848c26a9605eb91c99a949111ad4a532e/examples/jsm/interactive/HTMLMesh.js
class HTMLMesh extends three.Mesh {
constructor( dom ) {
const texture = new HTMLTexture( dom );
const geometry = new three.PlaneGeometry( texture.image.width * 0.001, texture.image.height * 0.001 );
const material = new three.MeshBasicMaterial( { map: texture, toneMapped: false, transparent: true } );
super( geometry, material );
function onEvent( event ) {
material.map.dispatchDOMEvent( event );
}
this.addEventListener( 'mousedown', onEvent );
this.addEventListener( 'mousemove', onEvent );
this.addEventListener( 'mouseup', onEvent );
this.addEventListener( 'click', onEvent );
this.dispose = function () {
geometry.dispose();
material.dispose();
material.map.dispose();
canvases.delete( dom );
this.removeEventListener( 'mousedown', onEvent );
this.removeEventListener( 'mousemove', onEvent );
this.removeEventListener( 'mouseup', onEvent );
this.removeEventListener( 'click', onEvent );
};
}
}
class HTMLTexture extends three.CanvasTexture {
constructor( dom ) {
super( html2canvas( dom ) );
this.dom = dom;
this.anisotropy = 16;
this.encoding = three.sRGBEncoding;
this.minFilter = three.LinearFilter;
this.magFilter = three.LinearFilter;
// Create an observer on the DOM, and run html2canvas update in the next loop
const observer = new MutationObserver( () => {
if ( ! this.scheduleUpdate ) {
// ideally should use xr.requestAnimationFrame, here setTimeout to avoid passing the renderer
this.scheduleUpdate = setTimeout( () => this.update(), 16 );
}
} );
const config = { attributes: true, childList: true, subtree: true, characterData: true };
observer.observe( dom, config );
this.observer = observer;
}
dispatchDOMEvent( event ) {
if ( event.data ) {
htmlevent( this.dom, event.type, event.data.x, event.data.y );
}
}
update() {
this.image = html2canvas( this.dom );
this.needsUpdate = true;
this.scheduleUpdate = null;
}
dispose() {
if ( this.observer ) {
this.observer.disconnect();
}
this.scheduleUpdate = clearTimeout( this.scheduleUpdate );
super.dispose();
}
}
//
const canvases = new WeakMap();
function html2canvas( element ) {
const range = document.createRange();
const color = new three.Color();
function Clipper( context ) {
const clips = [];
let isClipping = false;
function doClip() {
if ( isClipping ) {
isClipping = false;
context.restore();
}
if ( clips.length === 0 ) return;
let minX = - Infinity, minY = - Infinity;
let maxX = Infinity, maxY = Infinity;
for ( let i = 0; i < clips.length; i ++ ) {
const clip = clips[ i ];
minX = Math.max( minX, clip.x );
minY = Math.max( minY, clip.y );
maxX = Math.min( maxX, clip.x + clip.width );
maxY = Math.min( maxY, clip.y + clip.height );
}
context.save();
context.beginPath();
context.rect( minX, minY, maxX - minX, maxY - minY );
context.clip();
isClipping = true;
}
return {
add: function ( clip ) {
clips.push( clip );
doClip();
},
remove: function () {
clips.pop();
doClip();
}
};
}
function drawText( style, x, y, string ) {
if ( string !== '' ) {
if ( style.textTransform === 'uppercase' ) {
string = string.toUpperCase();
}
context.font = style.fontWeight + ' ' + style.fontSize + ' ' + style.fontFamily;
context.textBaseline = 'top';
context.fillStyle = style.color;
context.fillText( string, x, y + parseFloat( style.fontSize ) * 0.1 );
}
}
function buildRectPath( x, y, w, h, r ) {
if ( w < 2 * r ) r = w / 2;
if ( h < 2 * r ) r = h / 2;
context.beginPath();
context.moveTo( x + r, y );
context.arcTo( x + w, y, x + w, y + h, r );
context.arcTo( x + w, y + h, x, y + h, r );
context.arcTo( x, y + h, x, y, r );
context.arcTo( x, y, x + w, y, r );
context.closePath();
}
function drawBorder( style, which, x, y, width, height ) {
const borderWidth = style[ which + 'Width' ];
const borderStyle = style[ which + 'Style' ];
const borderColor = style[ which + 'Color' ];
if ( borderWidth !== '0px' && borderStyle !== 'none' && borderColor !== 'transparent' && borderColor !== 'rgba(0, 0, 0, 0)' ) {
context.strokeStyle = borderColor;
context.lineWidth = parseFloat( borderWidth );
context.beginPath();
context.moveTo( x, y );
context.lineTo( x + width, y + height );
context.stroke();
}
}
function drawElement( element, style ) {
let x = 0, y = 0, width = 0, height = 0;
if ( element.nodeType === Node.TEXT_NODE ) {
// text
range.selectNode( element );
const rect = range.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
drawText( style, x, y, element.nodeValue.trim() );
} else if ( element.nodeType === Node.COMMENT_NODE ) {
return;
} else if ( element instanceof HTMLCanvasElement ) {
// Canvas element
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
context.save();
const dpr = window.devicePixelRatio;
context.scale( 1 / dpr, 1 / dpr );
context.drawImage( element, x, y );
context.restore();
} else if ( element instanceof HTMLImageElement ) {
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
context.drawImage( element, x, y, width, height );
} else {
if ( element.style.display === 'none' ) return;
const rect = element.getBoundingClientRect();
x = rect.left - offset.left - 0.5;
y = rect.top - offset.top - 0.5;
width = rect.width;
height = rect.height;
style = window.getComputedStyle( element );
// Get the border of the element used for fill and border
buildRectPath( x, y, width, height, parseFloat( style.borderRadius ) );
const backgroundColor = style.backgroundColor;
if ( backgroundColor !== 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' ) {
context.fillStyle = backgroundColor;
context.fill();
}
// If all the borders match then stroke the round rectangle
const borders = [ 'borderTop', 'borderLeft', 'borderBottom', 'borderRight' ];
let match = true;
let prevBorder = null;
for ( const border of borders ) {
if ( prevBorder !== null ) {
match = ( style[ border + 'Width' ] === style[ prevBorder + 'Width' ] ) &&
( style[ border + 'Color' ] === style[ prevBorder + 'Color' ] ) &&
( style[ border + 'Style' ] === style[ prevBorder + 'Style' ] );
}
if ( match === false ) break;
prevBorder = border;
}
if ( match === true ) {
// They all match so stroke the rectangle from before allows for border-radius
const width = parseFloat( style.borderTopWidth );
if ( style.borderTopWidth !== '0px' && style.borderTopStyle !== 'none' && style.borderTopColor !== 'transparent' && style.borderTopColor !== 'rgba(0, 0, 0, 0)' ) {
context.strokeStyle = style.borderTopColor;
context.lineWidth = width;
context.stroke();
}
} else {
// Otherwise draw individual borders
drawBorder( style, 'borderTop', x, y, width, 0 );
drawBorder( style, 'borderLeft', x, y, 0, height );
drawBorder( style, 'borderBottom', x, y + height, width, 0 );
drawBorder( style, 'borderRight', x + width, y, 0, height );
}
if ( element instanceof HTMLInputElement ) {
let accentColor = style.accentColor;
if ( accentColor === undefined || accentColor === 'auto' ) accentColor = style.color;
color.set( accentColor );
const luminance = Math.sqrt( 0.299 * ( color.r ** 2 ) + 0.587 * ( color.g ** 2 ) + 0.114 * ( color.b ** 2 ) );
const accentTextColor = luminance < 0.5 ? 'white' : '#111111';
if ( element.type === 'radio' ) {
buildRectPath( x, y, width, height, height );
context.fillStyle = 'white';
context.strokeStyle = accentColor;
context.lineWidth = 1;
context.fill();
context.stroke();
if ( element.checked ) {
buildRectPath( x + 2, y + 2, width - 4, height - 4, height );
context.fillStyle = accentColor;
context.strokeStyle = accentTextColor;
context.lineWidth = 2;
context.fill();
context.stroke();
}
}
if ( element.type === 'checkbox' ) {
buildRectPath( x, y, width, height, 2 );
context.fillStyle = element.checked ? accentColor : 'white';
context.strokeStyle = element.checked ? accentTextColor : accentColor;
context.lineWidth = 1;
context.stroke();
context.fill();
if ( element.checked ) {
const currentTextAlign = context.textAlign;
context.textAlign = 'center';
const properties = {
color: accentTextColor,
fontFamily: style.fontFamily,
fontSize: height + 'px',
fontWeight: 'bold'
};
drawText( properties, x + ( width / 2 ), y, '✔' );
context.textAlign = currentTextAlign;
}
}
if ( element.type === 'range' ) {
const [ min, max, value ] = [ 'min', 'max', 'value' ].map( property => parseFloat( element[ property ] ) );
const position = ( ( value - min ) / ( max - min ) ) * ( width - height );
buildRectPath( x, y + ( height / 4 ), width, height / 2, height / 4 );
context.fillStyle = accentTextColor;
context.strokeStyle = accentColor;
context.lineWidth = 1;
context.fill();
context.stroke();
buildRectPath( x, y + ( height / 4 ), position + ( height / 2 ), height / 2, height / 4 );
context.fillStyle = accentColor;
context.fill();
buildRectPath( x + position, y, height, height, height / 2 );
context.fillStyle = accentColor;
context.fill();
}
if ( element.type === 'color' || element.type === 'text' || element.type === 'number' ) {
clipper.add( { x: x, y: y, width: width, height: height } );
drawText( style, x + parseInt( style.paddingLeft ), y + parseInt( style.paddingTop ), element.value );
clipper.remove();
}
}
}
/*
// debug
context.strokeStyle = '#' + Math.random().toString( 16 ).slice( - 3 );
context.strokeRect( x - 0.5, y - 0.5, width + 1, height + 1 );
*/
const isClipping = style.overflow === 'auto' || style.overflow === 'hidden';
if ( isClipping ) clipper.add( { x: x, y: y, width: width, height: height } );
for ( let i = 0; i < element.childNodes.length; i ++ ) {
drawElement( element.childNodes[ i ], style );
}
if ( isClipping ) clipper.remove();
}
const offset = element.getBoundingClientRect();
let canvas = canvases.get( element );
if ( canvas === undefined ) {
canvas = document.createElement( 'canvas' );
canvas.width = offset.width;
canvas.height = offset.height;
canvases.set( element, canvas );
}
const context = canvas.getContext( '2d'/*, { alpha: false }*/ );
const clipper = new Clipper( context );
// console.time( 'drawElement' );
context.clearRect(0, 0, canvas.width, canvas.height);
drawElement( element );
// console.timeEnd( 'drawElement' );
return canvas;
}
function htmlevent( element, event, x, y ) {
const mouseEventInit = {
clientX: ( x * element.offsetWidth ) + element.offsetLeft,
clientY: ( y * element.offsetHeight ) + element.offsetTop,
view: element.ownerDocument.defaultView
};
// TODO: Find out why this is added. Keep commented out when this file is updated
// window.dispatchEvent( new MouseEvent( event, mouseEventInit ) );
const rect = element.getBoundingClientRect();
x = x * rect.width + rect.left;
y = y * rect.height + rect.top;
function traverse( element ) {
if ( element.nodeType !== Node.TEXT_NODE && element.nodeType !== Node.COMMENT_NODE ) {
const rect = element.getBoundingClientRect();
if ( x > rect.left && x < rect.right && y > rect.top && y < rect.bottom ) {
element.dispatchEvent( new MouseEvent( event, mouseEventInit ) );
if ( element instanceof HTMLInputElement && element.type === 'range' && ( event === 'mousedown' || event === 'click' ) ) {
const [ min, max ] = [ 'min', 'max' ].map( property => parseFloat( element[ property ] ) );
const width = rect.width;
const offsetX = x - rect.x;
const proportion = offsetX / width;
element.value = min + ( max - min ) * proportion;
element.dispatchEvent( new InputEvent( 'input', { bubbles: true } ) );
}
}
for ( let i = 0; i < element.childNodes.length; i ++ ) {
traverse( element.childNodes[ i ] );
}
}
}
traverse( element );
}
/* jshint esversion: 9, -W097 */
const schemaHTML = {
html: {
type: 'selector',
},
cursor: {
type: 'selector',
}
};
{
schemaHTML.html.description = `HTML element to use.`;
schemaHTML.cursor.description = `Visual indicator for where the user is currently pointing`;
}
const _pointer = new THREE.Vector2();
const _event = { type: '', data: _pointer };
AFRAME.registerComponent('html', {
schema: schemaHTML,
init() {
this.rerender = this.rerender.bind(this);
this.handle = this.handle.bind(this);
this.onClick = e => this.handle('click', e);
this.onMouseLeave = e => this.handle('mouseleave', e);
this.onMouseEnter = e => this.handle('mouseenter', e);
this.onMouseUp = e => this.handle('mouseup', e);
this.onMouseDown = e => this.handle('mousedown', e);
this.mouseMoveDetail = {
detail: {
cursorEl: null,
intersection: null
}
};
},
play() {
this.el.addEventListener('click', this.onClick);
this.el.addEventListener('mouseleave', this.onMouseLeave);
this.el.addEventListener('mouseenter', this.onMouseEnter);
this.el.addEventListener('mouseup', this.onMouseUp);
this.el.addEventListener('mousedown', this.onMouseDown);
},
pause() {
this.el.removeEventListener('click', this.onClick);
this.el.removeEventListener('mouseleave', this.onMouseLeave);
this.el.removeEventListener('mouseenter', this.onMouseEnter);
this.el.removeEventListener('mouseup', this.onMouseUp);
this.el.removeEventListener('mousedown', this.onMouseDown);
},
update() {
this.remove();
if (!this.data.html) return;
const mesh = new HTMLMesh(this.data.html);
this.el.setObject3D('html', mesh);
this.data.html.addEventListener('input', this.rerender);
this.data.html.addEventListener('change', this.rerender);
this.cursor = this.data.cursor ? this.data.cursor.object3D : null;
},
tick() {
if (this.activeRaycaster) {
const intersection = this.activeRaycaster.components.raycaster.getIntersection(this.el);
this.mouseMoveDetail.detail.cursorEl = this.activeRaycaster;
this.mouseMoveDetail.detail.intersection = intersection;
this.handle('mousemove', this.mouseMoveDetail);
}
},
handle(type, evt) {
const intersection = evt.detail.intersection;
const raycaster = evt.detail.cursorEl;
if (type === 'mouseenter') {
this.activeRaycaster = raycaster;
}
if (type === 'mouseleave' && this.activeRaycaster === raycaster) {
this.activeRaycaster = null;
}
if (this.cursor) this.cursor.visible = false;
if (intersection) {
const mesh = this.el.getObject3D('html');
const uv = intersection.uv;
_event.type = type;
_event.data.set( uv.x, 1 - uv.y );
mesh.dispatchEvent( _event );
if (this.cursor) {
this.cursor.visible = true;
this.cursor.parent.worldToLocal(this.cursor.position.copy(intersection.point));
}
}
},
rerender() {
const mesh = this.el.getObject3D('html');
if (mesh && !mesh.material.map.scheduleUpdate) {
mesh.material.map.scheduleUpdate = setTimeout( () => mesh.material.map.update(), 16 );
}
},
remove() {
const mesh = this.el.getObject3D('html');
if (mesh) {
this.el.removeObject3D('html');
this.data.html.removeEventListener('input', this.rerender);
this.data.html.removeEventListener('change', this.rerender);
mesh.dispose();
}
this.activeRaycaster = null;
this.mouseMoveDetail.detail.cursorEl = null;
this.mouseMoveDetail.detail.intersection = null;
this.cursor = null;
},
});
})(THREE);
//# sourceMappingURL=aframe-html.js.map

View File

@ -17,7 +17,9 @@ window.AFRAME.registerComponent('pinch-to-teleport', {
pos.x += direction.x pos.x += direction.x
pos.z += direction.z pos.z += direction.z
// set the new position // set the new position
this.data.rig.setAttribute("position", pos); if( !this.data.rig ){
this.data.rig.setAttribute("position", pos);
}
// !!! NOTE - it would be more efficient to do the // !!! NOTE - it would be more efficient to do the
// position change on the players THREE.Object: // position change on the players THREE.Object:
// `player.object3D.position.add(direction)` // `player.object3D.position.add(direction)`

View File

@ -2,19 +2,23 @@
AFRAME.registerComponent('pressable', { AFRAME.registerComponent('pressable', {
schema: { schema: {
pressDistance: { pressDistance: { default: 0.005 },
default: 0.01 pressDuration: { default: 300 },
} immersiveOnly: { default: true }
}, },
init: function() { init: function() {
this.worldPosition = new THREE.Vector3(); this.worldPosition = new THREE.Vector3();
this.fingerWorldPosition = new THREE.Vector3();
this.raycaster = new THREE.Raycaster() this.raycaster = new THREE.Raycaster()
this.handEls = document.querySelectorAll('[hand-tracking-controls]'); this.handEls = document.querySelectorAll('[hand-tracking-controls]');
this.pressed = false; this.pressed = false;
this.distance = -1 this.distance = -1
// we throttle by distance, to support scenes with loads of clickable objects (far away) // we throttle by distance, to support scenes with loads of clickable objects (far away)
this.tick = this.throttleByDistance( () => this.detectPress() ) this.tick = this.throttleByDistance( () => this.detectPress() )
this.el.addEventListener("raycaster-intersected", (e) => {
if( !this.data || (this.data.immersiveOnly && !this.el.sceneEl.renderer.xr.isPresenting) ) return
this.el.emit('click', e.detail )
})
}, },
throttleByDistance: function(f){ throttleByDistance: function(f){
return function(){ return function(){
@ -30,43 +34,48 @@ AFRAME.registerComponent('pressable', {
} }
}, },
detectPress: function(){ detectPress: function(){
if( this.handEls.length == 0 ){
this.handEls = document.querySelectorAll('[hand-tracking-controls]');
}
var handEls = this.handEls; var handEls = this.handEls;
var handEl; var handEl;
let minDistance = 5 let minDistance = 5
// compensate for xrf-get AFRAME component (which references non-reparented buffergeometries from the 3D model) // compensate for an object inside a group
let object3D = this.el.object3D.child || this.el.object3D let object3D = this.el.object3D.type == "Group" ? this.el.object3D.children[0] : this.el.object3D
if( !object3D ) return
for (var i = 0; i < handEls.length; i++) { for (var i = 0; i < handEls.length; i++) {
handEl = handEls[i]; handEl = handEls[i];
let indexTipPosition = handEl.components['hand-tracking-controls'].indexTipPosition let indexTip = handEl.object3D.getObjectByName('index-finger-tip')
// Apply the relative position to the parent's world position if( ! indexTip ) return // nothing to do here
handEl.object3D.updateMatrixWorld();
handEl.object3D.getWorldPosition( this.fingerWorldPosition )
this.fingerWorldPosition.add( indexTipPosition )
this.raycaster.far = this.data.pressDistance this.raycaster.far = this.data.pressDistance
// Create a direction vector (doesnt matter because it is supershort for 'touch' purposes)
const direction = new THREE.Vector3(1.0,0,0); // Create a direction vector to negative Z
this.raycaster.set(this.fingerWorldPosition, direction) const direction = new THREE.Vector3(0,0,-1.0);
direction.normalize()
this.raycaster.set(indexTip.position, direction)
intersects = this.raycaster.intersectObjects([object3D],true) intersects = this.raycaster.intersectObjects([object3D],true)
object3D.getWorldPosition(this.worldPosition) object3D.getWorldPosition(this.worldPosition)
distance = this.fingerWorldPosition.distanceTo(this.worldPosition) distance = indexTip.position.distanceTo(this.worldPosition)
minDistance = distance < minDistance ? distance : minDistance minDistance = distance < minDistance ? distance : minDistance
if (intersects.length ){ if (intersects.length ){
this.i = this.i || 0;
if( !this.pressed ){ if( !this.pressed ){
this.el.emit('pressedstarted'); this.el.emit('pressedstarted', intersects);
this.el.emit('click'); this.el.emit('click', intersects);
this.pressed = setTimeout( () => { this.pressed = setTimeout( () => {
this.el.emit('pressedended'); this.el.emit('pressedended', intersects);
this.pressed = null this.pressed = null
},300) }, this.data.pressDuration )
} }
} }
} }
this.distance = minDistance this.distance = minDistance
} },
}); });

View File

@ -1,7 +1,7 @@
/** /**
* ## [require](com/require('').js) * ## [require](com/require('').js)
* *
* automatically requires dependencies * automatically requires dependencies or missing components
* *
* ```javascript * ```javascript
* await AFRAME.utils.require( this.dependencies ) (*) autoload missing components * await AFRAME.utils.require( this.dependencies ) (*) autoload missing components
@ -74,3 +74,38 @@ AFRAME.utils.require = function(arr_or_obj,opts){
AFRAME.utils.require.required = {} AFRAME.utils.require.required = {}
AFRAME.utils.require.baseURL = './com/' AFRAME.utils.require.baseURL = './com/'
//// this component will scan the DOM for missing components and lazy load them
//AFRAME.registerSystem('require',{
//
// init: function(){
// this.components = []
// // observe HTML changes in <a-scene>
// observer = new MutationObserver( (a,b) => this.getMissingComponents(a,b) )
// observer.observe( this.sceneEl, {characterData: false, childList: true, attributes: false});
// },
//
// getMissingComponents: function(mutationsList,observer){
// let els = [...this.sceneEl.getElementsByTagName("*")]
// let seen = []
//
// els.map( async (el) => {
// let attrs = el.getAttributeNames()
// .filter( (a) => a.match(/(^aframe-injected|^data-aframe|^id$|^class$|^on)/) ? null : a )
// for( let attr in attrs ){
// let component = attrs[attr]
// if( el.components && !el.components[component] ){
// console.info(`require.js: lazy-loading missing <${el.tagName.toLowerCase()} ${component} ... > (TODO: fix selectors in schema)`)
// // require && remount
// try{
// await AFRAME.utils.require([component])
// el.removeAttribute(component)
// el.setAttribute(component, el.getAttribute(component) )
// }catch(e){ } // give up, normal AFRAME behaviour follows
// }
// }
// })
// }
//
//})

56
com/wearable.js Normal file
View File

@ -0,0 +1,56 @@
AFRAME.registerComponent('wearable', {
schema:{
el: {type:"selector"},
controlPos: {type:"vec3"},
controlRot: {type:"vec3"},
handPos: {type:"vec3"},
handRot: {type:"vec3"}
},
init: function(){
this.remember()
if( !this.data.el ) return console.warn(`wear.js: cannot find ${this.data.el}`)
this.data.el.object3D.name = 'wearable'
// hand vs controller multi attach-heuristics (intended to survived AFRAME updates)
this.data.el.addEventListener('controllermodelready', this.attachWhatEverController.bind(this) ) // downside: no model yet
this.data.el.addEventListener('model-loaded', this.attachWhatEverController.bind(this) ) // downside: only called once [model not added yet]
this.el.sceneEl.addEventListener('controllersupdated', this.attachWhatEverController.bind(this) ) // downside: only called when switching [no model yet]
},
attachWhatEverController: function(e){
setTimeout( () => { // needed because the events are called before the model was added via add())
let wrist = false
let hand = this.data.el.components['hand-tracking-controls']
if( hand && hand.controllerPresent ) wrist = hand.wristObject3D
this.attach( wrist || this.data.el.object3D)
this.update( wrist ? 'hand' : 'control')
},100)
},
attach: function(target){
if( this.target && target.uuid == this.target.uuid ) return// already attached
target.add(this.el.object3D )
this.target = target
},
detach: function(){
this.parent.add(this.el.object3D)
this.el.object3D.position.copy( this.position )
this.el.object3D.rotation.copy( this.rotation )
},
remember: function(){
this.position = this.el.object3D.position.clone()
this.rotation = this.el.object3D.rotation.clone()
this.parent = this.el.object3D.parent
},
update: function(type){
let position = type == 'hand' ? this.data.handPos : this.data.controlPos
let rotation = type == 'hand' ? this.data.handRot : this.data.controlRot
this.el.object3D.position.copy( position )
this.el.object3D.rotation.set( rotation.x, rotation.y, rotation.z )
}
})

View File

@ -37,6 +37,7 @@ AFRAME.registerComponent('window', {
x: {type:'string',"default":"center"}, x: {type:'string',"default":"center"},
y: {type:'string',"default":"center"}, y: {type:'string',"default":"center"},
"class": {type:'array',"default":[]}, "class": {type:'array',"default":[]},
autoresize:{type:'bool', "default": false}
}, },
dependencies:{ dependencies:{
@ -84,7 +85,7 @@ AFRAME.registerComponent('window', {
this.el.emit('window.oncreate',{}) this.el.emit('window.oncreate',{})
// resize after the dom content has been rendered & updated // resize after the dom content has been rendered & updated
setTimeout( () => { setTimeout( () => {
if( !this.data.max ) winbox.resize( this.el.dom.offsetWidth+'px', this.el.dom.offsetHeight+'px' ) if( !this.data.max && this.data.autoresize ) winbox.resize( this.el.dom.offsetWidth+'px', this.el.dom.offsetHeight+'px' )
// hint grabbable's obb-collider to track the window-object // hint grabbable's obb-collider to track the window-object
this.el.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0' this.el.components['obb-collider'].data.trackedObject3D = 'components.html.el.object3D.children.0'
this.el.components['obb-collider'].update() this.el.components['obb-collider'].update()