From 0c173ce21cf20e855b23a3e3b36c3e69928492ce Mon Sep 17 00:00:00 2001 From: Leon van Kammen Date: Wed, 3 Jan 2024 12:40:05 +0000 Subject: [PATCH] work in progress [might break] --- app/apptiler.js | 289 ++++++++++++++++++++++++++++++ app/helloworld.js | 38 ++-- app/windowmanager.js | 416 ------------------------------------------- com/app.js | 197 +++++++++++++++++++- 4 files changed, 503 insertions(+), 437 deletions(-) create mode 100644 app/apptiler.js delete mode 100644 app/windowmanager.js diff --git a/app/apptiler.js b/app/apptiler.js new file mode 100644 index 0000000..73bf23f --- /dev/null +++ b/app/apptiler.js @@ -0,0 +1,289 @@ +// +// this is just an AFRAME wrapper for golden-layout v2 (docs: https://golden-layout.github.io/golden-layout/) +// +// +AFRAME.registerComponent('apptiler', { + schema: { + }, + + requires:{ + "goldenlayout_css1": "https://unpkg.com/golden-layout@2.6.0/dist/css/goldenlayout-base.css", + "goldenlayout_css2": "https://unpkg.com/golden-layout@2.6.0/dist/css/themes/goldenlayout-dark-theme.css" + }, + + dom: { + events: [], + + // for stylesheet see bottom of file + html: (me) => ` +
+
+
`, + + }, + + events:{ + DOMready: async function(){ + await this.initLayout(this) + AFRAME.app.foreach( (opts) => { + this.add( opts.component, opts.app.el.dom) + if( opts.component != 'apptiler' ) opts.app.el.dom.querySelector('.modal').classList.add(['tile']) + }) + setTimeout( () => document.querySelector('#overlay').classList.add(['apptiler']), 100 ) + }, + + }, + + init: function () { + this.require( this.requires ) + }, + + initLayout: async function(){ + if( this.goldenLayout !== undefined || !this.el.dom.querySelector(".modals")) return console.warn("TODO: fix duplicate ready-events") + + let { GoldenLayout } = await import("https://cdn.skypack.dev/pin/golden-layout@v2.5.0-dAz3xMzxIRpbnbfEAik0/mode=imports/optimized/golden-layout.js"); + + class Modal { + constructor(container) { + this.container = container; + this.rootElement = container.element; + this.rootElement.innerHTML = '' + this.resizeWithContainerAutomatically = true; + } + } + const myLayout = { + root: { + type: 'row', + content: [] + } + }; + + this.goldenLayout = new GoldenLayout( this.el.dom.querySelector('.modals')); + this.goldenLayout.registerComponent('Modal', Modal); + this.goldenLayout.loadLayout(myLayout); + }, + + add: function(title,el){ + if( title == 'apptiler' ) return // dont add yourself to yourself please + let item = this.goldenLayout.addComponent('Modal', undefined, title ) + try{ + item.parentItem.contentItems[ item.parentItem.contentItems.length-1 ].element.querySelector('.lm_content').appendChild(el) + }catch(e){} // ignore elements which are already appended + }, + + manifest: { // HTML5 manifest to identify app to xrsh + "short_name": "windowmanager", + "name": "Window Manager", + "icons": [ + { + "src": "/images/icons-vector.svg", + "type": "image/svg+xml", + "sizes": "512x512" + } + ], + "id": "/?source=pwa", + "start_url": "/?source=pwa", + "background_color": "#3367D6", + "display": "standalone", + "scope": "/", + "theme_color": "#3367D6", + "shortcuts": [], + "description": "2D/3D management of windows", + "screenshots": [ + { + "src": "/images/screenshot1.png", + "type": "image/png", + "sizes": "540x720", + "form_factor": "narrow" + } + ], + "help":` +Window Manager + +The window manager manages all the windows in 2D/XR. +This is a core XRSH system application + ` + } + +}); + +document.head.innerHTML += ` + +` diff --git a/app/helloworld.js b/app/helloworld.js index d9fed91..56da0db 100644 --- a/app/helloworld.js +++ b/app/helloworld.js @@ -8,18 +8,13 @@ AFRAME.registerComponent('helloworld', { "stylis": "https://unpkg.com/stylis@4.3.1/dist/umd/stylis.js" // modern CSS (https://stylis.js.org) }, - dependencies: ['windowmanager'], - dom: { - scale: 3.5, - modal: true, + scale: 3, events: ['click','input'], html: (me) => ` `, css: `.modal.hello { - position:relative;top:0;width:200px - foo { /* modern css supported via stylis */ } + position:relative; + top:0; + width:200px; + .title { font-weight:bold; } /* modern nested buildless css thanks to stylis */ }` }, events:{ - html: function( ){ - console.log("html-mesh mounted") - this.el.setAttribute("html",`html:#${this.el.uid}; cursor:#cursor`) - }, // html-component was added to this AFRAME entity + + html: function( ){ console.log("html-mesh requirement mounted") }, + stylis: function( ){ console.log("stylis requirement mounted") }, + + DOMready: function(e){ + // our reactive dom element has been added to the dom (DOMElement = this.el.dom) + }, + click: function(e){ // a click was detected on this.el.dom or AFRAME entity let el = e.detail.target || e.detail.srcElement if( !el ) return if( el.className.match("fold") ) this.el.toggleFold() if( el.className.match("close") ) this.el.close() }, + input: function(e){ if( !e.detail.target ) return if( e.detail.target.id == 'myRange' ) this.data.value = e.detail.target.value // reactive demonstration }, - value: function(e){ this.el.dom.querySelector("#value").innerHTML = e.detail.v }, // this.data.title was changed - ready: function(){ - } + + value: function(e){ this.el.dom.querySelector("#value").innerHTML = e.detail.v }, // auto-emitted when this.data.value gets updated + }, init: function () { this.require( this.requires ) + + this.scene.addEventListener('apps:2D', () => this.el.setAttribute('visible', false) ) + this.scene.addEventListener('apps:XR', () => { + this.el.setAttribute('visible', true) + this.el.setAttribute("html",`html:#${this.el.uid}; cursor:#cursor`) + }) }, manifest: { // HTML5 manifest to identify app to xrsh diff --git a/app/windowmanager.js b/app/windowmanager.js deleted file mode 100644 index 606db5d..0000000 --- a/app/windowmanager.js +++ /dev/null @@ -1,416 +0,0 @@ -// -// this is just an AFRAME wrapper for golden-layout v2 (docs: https://golden-layout.github.io/golden-layout/) -// -// - -AFRAME.registerComponent('windowmanager', { - schema: { - }, - - requires:{ - "goldenlayout_css1": "https://unpkg.com/golden-layout@2.6.0/dist/css/goldenlayout-base.css", - "goldenlayout_css2": "https://unpkg.com/golden-layout@2.6.0/dist/css/themes/goldenlayout-dark-theme.css" - }, - - dom: { - events: [], - - // for stylesheet see bottom of file - html: (me) => ` -
-
-
`, - - }, - - events:{ - ready: function(){ - this.initLayout(this) - } - }, - - init: function () { - this.require( this.requires ) - }, - - initLayout: async function(){ - if( this.goldenLayout !== undefined || !this.el.dom.querySelector(".modals")) return console.warn("TODO: fix duplicate ready-events") - - let { GoldenLayout } = await import("https://cdn.skypack.dev/pin/golden-layout@v2.5.0-dAz3xMzxIRpbnbfEAik0/mode=imports/optimized/golden-layout.js"); - - class Modal { - constructor(container) { - this.container = container; - this.rootElement = container.element; - this.rootElement.innerHTML = '' - this.resizeWithContainerAutomatically = true; - } - } - const myLayout = { - root: { - type: 'row', - content: [ - { - title: 'Terminal', - type: 'component', - componentType: 'Modal', - width: 50, - }, - { - title: 'Welcome to XR shell', - type: 'component', - componentType: 'Modal', - // componentState: { text: 'Component 2' } - } - ] - } - }; - - this.goldenLayout = new GoldenLayout( this.el.dom.querySelector('.modals')); - this.goldenLayout.registerComponent('Modal', Modal); - this.goldenLayout.loadLayout(myLayout); - - }, - - add: function(title,el){ - setTimeout( () => { - let item = this.goldenLayout.addComponent('Modal', undefined, title ) - console.dir(item) - item.parentItem.contentItems[ item.parentItem.contentItems.length-1 ].element.querySelector('.lm_content').appendChild(el) - },1000) - }, - - manifest: { // HTML5 manifest to identify app to xrsh - "short_name": "windowmanager", - "name": "Window Manager", - "icons": [ - { - "src": "/images/icons-vector.svg", - "type": "image/svg+xml", - "sizes": "512x512" - } - ], - "id": "/?source=pwa", - "start_url": "/?source=pwa", - "background_color": "#3367D6", - "display": "standalone", - "scope": "/", - "theme_color": "#3367D6", - "shortcuts": [], - "description": "2D/3D management of windows", - "screenshots": [ - { - "src": "/images/screenshot1.png", - "type": "image/png", - "sizes": "540x720", - "form_factor": "narrow" - } - ], - "help":` -Window Manager - -The window manager manages all the windows in 2D/XR. -This is a core XRSH system application - ` - } - -}); - -// monkeypatching updateProperties will detect a component config like: -// dom: { -// html: `

Welcome to XR shell

`, -// css: `#hello {color:red}`, -// events: ['click'] -// } -// and use it to create a reactive DOM-component (using native javascript Proxy) -// which delegates all related DOM-events AND data-changes back to the AFRAME component -AFRAME.AComponent.prototype.updateProperties = function(updateProperties){ - return function(){ - updateProperties.apply(this,arguments) - if( this.dom && this.data && this.data.uri ){ - - tasks = { - - generateUniqueId: () => { - this.el.uid = String(Math.random()).substr(2) - return tasks - }, - - ensureOverlay: () => { - let overlay = document.querySelector('#overlay') - if( !overlay ){ - overlay = document.createElement('div') - overlay.id = "overlay" - document.body.appendChild(overlay) - document.querySelector("a-scene").setAttribute("webxr","overlayElement:#overlay") - } - tasks.overlay = overlay - return tasks - }, - - createReactiveDOMElement: () => { - const reactify = (el,aframe) => new Proxy(this.data,{ - get(me,k,v) { return me[k] }, - set(me,k,v){ - me[k] = v - aframe.emit(k,{el,k,v}) - } - }) - this.el.dom = document.createElement('div') - this.el.dom.className = this.parseAppURI(this.data.uri).component - this.el.dom.innerHTML = this.dom.html(this) - this.data = reactify( this.dom.el, this.el ) - this.dom.events.map( (e) => this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) ) ) - return tasks - }, - - addCSS: () => { - if( this.dom.css && !document.head.querySelector(`style#${this.attrName}`) ){ - document.head.innerHTML += `` - } - return tasks - }, - - scaleDOMvsXR: () => { - if( this.dom.scale ) this.el.setAttribute('scale',`${this.dom.scale} ${this.dom.scale} ${this.dom.scale}`) - return tasks - }, - - addModalFunctions: () => { - this.el.close = () => { - this.el.dom.remove() - this.el.removeAttribute("html") - } - this.el.toggleFold = () => { - this.el.dom.querySelector(".modal").classList.toggle('fold') - this.el.dom.querySelector('.top .fold').innerText = this.el.dom.querySelector('.modal').className.match(/fold/) ? '▢' : '_' - } - return tasks - }, - } - - tasks - .generateUniqueId() - .ensureOverlay() - .addCSS() - .createReactiveDOMElement() - .scaleDOMvsXR() - .addModalFunctions() - // finally lets add the bad boy to the DOM - if( this.dom && this.dom.modal ){ - document.querySelector('[windowmanager]').components['windowmanager'].add( this.parseAppURI(this.data.uri).component, this.el.dom ) - }else tasks.overlay.appendChild(this.el.dom) - } - } -}( AFRAME.AComponent.prototype.updateProperties) - - -// -// base CSS for XRSH apps -// -// limitations / some guidelines for html-mesh compatibility: -// * no icon libraries (favicon e.g.) -// * 'border-radius: 2px 3px 4px 5px' (applies 2px to all corners) -// - -document.head.innerHTML += ` - -` diff --git a/com/app.js b/com/app.js index 43a04b0..2b71f55 100644 --- a/com/app.js +++ b/com/app.js @@ -1,5 +1,22 @@ // this is a highlevel way of loading buildless 'apps' (a collection of js components) +AFRAME.required = {} +AFRAME.app = new Proxy({ + + add(component, entity){ + this[component] = this[component] || [] + this[component].push(entity) + }, + foreach(cb){ + for( let i in this ){ + if( typeof this[i] != 'function' ) this[i].map( (app) => cb({app,component:i}) ) + } + } +},{ + get(me,k) { return me[k] }, + set(me,k,v){ me[k] = v } +}) + AFRAME.registerComponent('app', { schema:{ "uri":{ type:"string"} @@ -8,22 +25,25 @@ AFRAME.registerComponent('app', { events:{ "app:ready": function(){ let {id,component,type} = this.parseAppURI(this.data.uri) - let entity = document.createElement("a-entity") - this.el.setAttribute(component,this.data) + AFRAME.app[component].map( (app) => { + if( !app.el.getAttribute(component) ) app.el.setAttribute(component,app.data) + }) } }, init: function() { let {id,component,type} = this.parseAppURI(this.data.uri) - if( AFRAME.components[component] || document.head.querySelector(`script#${id}`) ) this.el.emit('app:ready',{}) - else this.require([ this.data.uri ], 'app:ready') + let sel = `script#${component}` + if( AFRAME.app[component] || AFRAME.components[component] || document.head.querySelector(sel) ) return AFRAME.app.add(component,this) + AFRAME.app.add(component,this) + this.require([ this.data.uri ], 'app:ready') }, parseAppURI: AFRAME.AComponent.prototype.parseAppURI = function(uri){ return { - id: String(uri).split("/").pop(), // 'a/b/c/mycom.js' => 'mycom.js' + id: String(uri).split("/").pop(), // 'a/b/c/mycom.js' => 'mycom.js' component: String(uri).split("/").pop().split(".js").shift(), // 'mycom.js' => 'mycom' - type: String(uri).split(".").pop() // 'mycom.js' => 'js' + type: String(uri).split(".").pop() // 'mycom.js' => 'js' } }, @@ -34,6 +54,10 @@ AFRAME.registerComponent('app', { if( !packages.map ) packages = Object.values(packages) packages.map( (package) => { let id = package.split("/").pop() + // prevent duplicate requests + if( AFRAME.required[id] ) return + AFRAME.required[id] = true + if( !document.head.querySelector(`script#${id}`) ){ let {id,component,type} = this.parseAppURI(package) let p = new Promise( (resolve,reject) => { @@ -73,8 +97,169 @@ AFRAME.registerComponent('app', { AFRAME.AComponent.prototype.initComponent = function(initComponent){ return function(){ this.el.emit( this.attrName, this) + this.scene = AFRAME.scenes[0] // mount scene for convenience return initComponent.apply(this,arguments) } }( AFRAME.AComponent.prototype.initComponent) +AFRAME.AComponent.prototype.updateProperties = function(updateProperties){ + return function(){ + updateProperties.apply(this,arguments) + if( this.dom && this.data && this.data.uri ){ + + tasks = { + + generateUniqueId: () => { + this.el.uid = String(Math.random()).substr(10) + return tasks + }, + + ensureOverlay: () => { + let overlay = document.querySelector('#overlay') + if( !overlay ){ + overlay = document.createElement('div') + overlay.id = "overlay" + document.body.appendChild(overlay) + document.querySelector("a-scene").setAttribute("webxr","overlayElement:#overlay") + } + tasks.overlay = overlay + return tasks + }, + + createReactiveDOMElement: () => { + const reactify = (el,aframe) => new Proxy(this.data,{ + get(me,k,v) { return me[k] }, + set(me,k,v){ + me[k] = v + aframe.emit(k,{el,k,v}) + } + }) + this.el.dom = document.createElement('div') + this.el.dom.className = this.parseAppURI(this.data.uri).component + this.el.dom.innerHTML = this.dom.html(this) + this.data = reactify( this.dom.el, this.el ) + this.dom.events.map( (e) => this.el.dom.addEventListener(e, (ev) => this.el.emit(e,ev) ) ) + return tasks + }, + + addCSS: () => { + if( this.dom.css && !document.head.querySelector(`style#${this.attrName}`) ){ + document.head.innerHTML += `` + } + return tasks + }, + + scaleDOMvsXR: () => { + if( this.dom.scale ) this.el.setAttribute('scale',`${this.dom.scale} ${this.dom.scale} ${this.dom.scale}`) + return tasks + }, + + addModalFunctions: () => { + this.el.close = () => { + this.el.dom.remove() + this.el.removeAttribute("html") + } + return tasks + } + } + + tasks + .generateUniqueId() + .ensureOverlay() + .addCSS() + .createReactiveDOMElement() + .scaleDOMvsXR() + .addModalFunctions() + + tasks.overlay.appendChild(this.el.dom) + this.el.emit('DOMready',{el: this.el.dom}) + } + } +}( AFRAME.AComponent.prototype.updateProperties) +// +// base CSS for XRSH apps +// +// limitations / some guidelines for html-mesh compatibility: +// * no icon libraries (favicon e.g.) +// * 'border-radius: 2px 3px 4px 5px' (applies 2px to all corners) +// + +document.head.innerHTML += ` + +` + +// draw a button so we can toggle apps between 2D / XR +let toggle = (state) => { + state = state || document.body.className.match(/XR/) + document.body.classList[ state ? 'remove' : 'add'](['XR']) + AFRAME.scenes[0].emit( state ? 'apps:2D' : 'apps:XR') +} +document.addEventListener("DOMContentLoaded", (event) => { + let btn = document.createElement("button") + btn.id = "toggle_overlay" + btn.innerText = 'XRSH' + btn.addEventListener('click', (e) => toggle() ) + document.body.appendChild(btn) + + document.querySelector('a-scene').addEventListener('enter-vr', () => toggle(true) ) + document.querySelector('a-scene').addEventListener('exit-vr', () => toggle(false) ) + document.querySelector('a-scene').addEventListener('loaded', () => { + let VRbtn = document.querySelector('a-scene .a-enter-vr') + let ARbtn = document.querySelector('a-scene .a-enter-ar') + if( VRbtn ) document.body.appendChild(VRbtn) // move to body + if( ARbtn ) document.body.appendChild(ARbtn) // so they will always be visible + }) +})