janusweb 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy.yml +2 -1
- package/README.md +1 -1
- package/media/assets/webui/apps/comms/chat.html +6 -7
- package/media/assets/webui/apps/comms/comms.css +282 -84
- package/media/assets/webui/apps/comms/comms.html +6 -5
- package/media/assets/webui/apps/comms/comms.js +30 -14
- package/media/assets/webui/apps/comms/userlist.html +10 -17
- package/media/assets/webui/apps/comms/voip.js +111 -1
- package/media/assets/webui/apps/editor/editor-properties.js +34 -2
- package/media/assets/webui/apps/editor/editor.css +27 -0
- package/media/assets/webui/apps/editor/editor.js +326 -49
- package/media/assets/webui/apps/editor/objecticons-small.png +0 -0
- package/media/assets/webui/apps/inventory/inventory.css +71 -0
- package/media/assets/webui/apps/inventory/inventory.html +16 -61
- package/media/assets/webui/apps/inventory/inventory.js +342 -72
- package/media/assets/webui/apps/inventory/inventory.json +1 -4
- package/media/assets/webui/apps/inventory/objecticons-small.png +0 -0
- package/media/assets/webui/apps/xrfragment/level2-hyperlink.js +7 -5
- package/media/assets/webui/apps/xrfragment/xrfragment.json +1 -2
- package/package.json +1 -1
- package/scripts/client.js +4 -27
- package/scripts/config.js +7 -2
- package/scripts/janusbase.js +21 -3
- package/scripts/janusweb.js +24 -3
- package/scripts/object.js +67 -7
- package/scripts/room.js +113 -7
- package/media/assets/webui/apps/xrfragment/patch/metaquest-fix-flickering.js +0 -15
|
@@ -19,8 +19,9 @@ elation.elements.define('janus.ui.editor.button', class extends elation.elements
|
|
|
19
19
|
}
|
|
20
20
|
let inventory = inventorypanel.querySelector('janus-ui-inventory');
|
|
21
21
|
if (!inventory) {
|
|
22
|
+
// The inventory wires its own selection to the editor controller (see
|
|
23
|
+
// inventory.js), so it works both here and when placed standalone.
|
|
22
24
|
inventory = elation.elements.create('janus-ui-inventory', { append: inventorypanel });
|
|
23
|
-
elation.events.add(inventory, 'assetselect', (ev) => editorpanel.handleInventorySelect(ev));
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
let editpanel = document.querySelector('ui-panel[name="topright"]');
|
|
@@ -46,28 +47,14 @@ elation.elements.define('janus.ui.editor.button', class extends elation.elements
|
|
|
46
47
|
janus.engine.systems.render.setdirty();
|
|
47
48
|
}
|
|
48
49
|
});
|
|
49
|
-
elation.elements.define('janus.ui.editor.
|
|
50
|
+
elation.elements.define('janus.ui.editor.controller', class extends elation.elements.base {
|
|
50
51
|
create() {
|
|
51
|
-
//this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
elation.events.add(elements.transformtype, 'change', (ev) => this.handleTransformTypeChange(ev));
|
|
55
|
-
elation.events.add(elements.transformspace, 'change', (ev) => this.handleTransformSpaceChange(ev));
|
|
56
|
-
//elements.transformglobal.addEventListener('deactivate', (ev) => this.handleTransformGlobalDeactivate(ev));
|
|
57
|
-
|
|
58
|
-
elation.events.add(elements.translatesnap, 'change', (ev) => this.handleTranslateSnapChange(ev));
|
|
59
|
-
elation.events.add(elements.rotatesnap, 'change', (ev) => this.handleRotateSnapChange(ev));
|
|
60
|
-
*/
|
|
61
|
-
|
|
62
|
-
elation.events.add(elements.debugview, 'click', (ev) => this.handleDebugviewClick(ev));
|
|
63
|
-
elation.events.add(elements.viewsource, 'click', (ev) => this.handleViewSourceClick(ev));
|
|
64
|
-
elation.events.add(elements.export, 'click', (ev) => this.handleExportClick(ev));
|
|
65
|
-
|
|
52
|
+
// this.objectinfo / this.scenetree are populated by the view elements when they
|
|
53
|
+
// register (getEditorController().objectinfo = this). Don't initialize them here —
|
|
54
|
+
// this create() runs AFTER those registrations, so assigning would wipe them.
|
|
66
55
|
// If we've made any changes and the user tries to leave, prompt them to verify that they want to throw the changes away
|
|
67
56
|
window.addEventListener('beforeunload', (ev) => { if (this.history.length > 0) { ev.returnValue = false; ev.preventDefault(); return "You've made changes to this room, do you really want to leave?"; } });
|
|
68
57
|
|
|
69
|
-
this.elements = elements;
|
|
70
|
-
|
|
71
58
|
this.roomedit = {
|
|
72
59
|
snap: .01,
|
|
73
60
|
rotationsnap: 5,
|
|
@@ -122,11 +109,6 @@ elation.elements.define('janus.ui.editor.panel', class extends elation.elements.
|
|
|
122
109
|
|
|
123
110
|
this.initialized = new Set();
|
|
124
111
|
|
|
125
|
-
let inventorypanel = document.querySelector('ui-collapsiblepanel[name="right"]');
|
|
126
|
-
this.scenetree = elation.elements.create('janus-ui-editor-scenetree', { append: inventorypanel });
|
|
127
|
-
elation.events.add(this.scenetree, 'select', (ev) => { this.editObject(ev.data); });
|
|
128
|
-
this.objectinfo = elation.elements.create('janus-ui-editor-objectinfo', { append: inventorypanel });
|
|
129
|
-
|
|
130
112
|
if (typeof room != 'undefined') {
|
|
131
113
|
this.initRoomEvents(room);
|
|
132
114
|
}
|
|
@@ -364,6 +346,12 @@ console.log('set translation snap', ev.data, ev);
|
|
|
364
346
|
}
|
|
365
347
|
} else if (ev.button == 0 && !this.roomedit.transforming) {
|
|
366
348
|
if (this.roomedit.object) {
|
|
349
|
+
// Lost pointer lock mid-placement (Esc)? This click re-acquires it — don't
|
|
350
|
+
// also treat it as the placement-confirmation click.
|
|
351
|
+
if (this.roomedit.raycast && !janus.engine.systems.controls.pointerLockActive) {
|
|
352
|
+
janus.engine.systems.controls.requestPointerLock();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
367
355
|
if (!this.roomedit.raycast) {
|
|
368
356
|
// raycasting means this is an initial object placement, we don't need to log a change
|
|
369
357
|
// FIXME - this is no longer the case, you can activate raycast mode by holding ctrl while clicking an object
|
|
@@ -404,8 +392,16 @@ console.log('set translation snap', ev.data, ev);
|
|
|
404
392
|
console.log('update bbox', this.roomedit.objectBoundingBox);
|
|
405
393
|
});
|
|
406
394
|
|
|
407
|
-
//object
|
|
408
|
-
//
|
|
395
|
+
// While raycast-placing (a fresh inventory object, or a ctrl+right-click move),
|
|
396
|
+
// make this object non-pickable so the placement ray lands on the scene behind it
|
|
397
|
+
// instead of snapping onto the object itself. Collision stays on so the object
|
|
398
|
+
// rests against other objects rather than interpenetrating them; holding Ctrl
|
|
399
|
+
// momentarily disables it (see editObjectKeydown/Keyup). Restored on stop.
|
|
400
|
+
if (this.roomedit.raycast) {
|
|
401
|
+
this.roomedit.objectPickableWas = object.pickable;
|
|
402
|
+
this.roomedit.objectCollidableWas = object.collidable;
|
|
403
|
+
object.pickable = false;
|
|
404
|
+
}
|
|
409
405
|
|
|
410
406
|
room.addEventListener('mousemove', (ev) => this.editObjectMousemove(ev));
|
|
411
407
|
elation.events.add(this, 'mousedown', this.editObjectClick);
|
|
@@ -434,19 +430,24 @@ console.log('set translation snap', ev.data, ev);
|
|
|
434
430
|
this.editObjectShowInfo(object);
|
|
435
431
|
let manipulator = this.getManipulator();
|
|
436
432
|
manipulator.attach(object.objects['3d']);
|
|
437
|
-
|
|
433
|
+
// During raycast placement the gizmo stays visible but inert (no mouse
|
|
434
|
+
// interaction); a normal edit makes it interactive.
|
|
435
|
+
manipulator.enabled = !this.roomedit.raycast;
|
|
438
436
|
this.setMode('pos');
|
|
439
437
|
}
|
|
440
438
|
|
|
441
439
|
editObjectShowInfo(object) {
|
|
442
440
|
//let content = elation.template.get('janus.ui.editor.object.info', {object: object, editmode: this.roomedit.modes[this.roomedit.modeid]});
|
|
443
441
|
let inventorypanel = document.querySelector('ui-collapsiblepanel[name="right"]');
|
|
444
|
-
this.objectinfo
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
442
|
+
if (this.objectinfo) {
|
|
443
|
+
this.objectinfo.updateObject(object);
|
|
444
|
+
if (this.objectinfo.hidden) {
|
|
445
|
+
this.objectinfo.show();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (inventorypanel) {
|
|
449
|
+
inventorypanel.refresh();
|
|
448
450
|
}
|
|
449
|
-
inventorypanel.refresh();
|
|
450
451
|
}
|
|
451
452
|
|
|
452
453
|
editObjectShowOutline() {
|
|
@@ -602,10 +603,18 @@ console.log('set translation snap', ev.data, ev);
|
|
|
602
603
|
this.roomedit.object.dispatchEvent({type: 'edit', bubbles: true});
|
|
603
604
|
}
|
|
604
605
|
}
|
|
605
|
-
//
|
|
606
|
+
// Restore pickability/collision suppressed for raycast placement.
|
|
607
|
+
if (this.roomedit.objectPickableWas !== undefined) {
|
|
608
|
+
this.roomedit.object.pickable = this.roomedit.objectPickableWas;
|
|
609
|
+
this.roomedit.object.collidable = this.roomedit.objectCollidableWas;
|
|
610
|
+
this.roomedit.objectPickableWas = undefined;
|
|
611
|
+
this.roomedit.objectCollidableWas = undefined;
|
|
612
|
+
}
|
|
606
613
|
this.roomedit.object.sync = true;
|
|
607
614
|
this.roomedit.object = false;
|
|
608
615
|
this.roomedit.raycast = false;
|
|
616
|
+
// Done placing — hide the engine "click to focus" prompt.
|
|
617
|
+
try { janus.engine.systems.controls.pointerLockWanted = false; } catch (e) {}
|
|
609
618
|
this.roomedit.parentObject = null;
|
|
610
619
|
//this.editObjectRemoveWireframe();
|
|
611
620
|
this.editObjectRemoveOutline();
|
|
@@ -810,9 +819,17 @@ obj.opacity = obj.opacity;
|
|
|
810
819
|
}
|
|
811
820
|
editObjectKeydown(ev) {
|
|
812
821
|
this.updateEditConstraints(ev);
|
|
822
|
+
// While raycast-placing, holding Ctrl momentarily disables collision so the
|
|
823
|
+
// object can be placed overlapping others; reverts on keyup or placement.
|
|
824
|
+
if (this.roomedit.object && this.roomedit.raycast && ev.ctrlKey) {
|
|
825
|
+
this.roomedit.object.collidable = false;
|
|
826
|
+
}
|
|
813
827
|
}
|
|
814
828
|
editObjectKeyup(ev) {
|
|
815
829
|
this.updateEditConstraints(ev);
|
|
830
|
+
if (this.roomedit.object && this.roomedit.raycast && !ev.ctrlKey) {
|
|
831
|
+
this.roomedit.object.collidable = this.roomedit.objectCollidableWas;
|
|
832
|
+
}
|
|
816
833
|
}
|
|
817
834
|
editObjectClick(ev) {
|
|
818
835
|
if (this.roomedit.object) {
|
|
@@ -1201,22 +1218,19 @@ console.log('change color', obj.col, vec);
|
|
|
1201
1218
|
}
|
|
1202
1219
|
}
|
|
1203
1220
|
this.history.push({type: 'addobjects', objects: objects});
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
}
|
|
1208
|
-
*/
|
|
1221
|
+
// Enter pointer-lock placement so the dropped object follows the cursor until
|
|
1222
|
+
// it's clicked into position — same as click-to-place from the inventory.
|
|
1223
|
+
janus.engine.systems.controls.requestPointerLock();
|
|
1209
1224
|
}
|
|
1210
1225
|
handleThingAdd(ev) {
|
|
1211
|
-
|
|
1226
|
+
// defer one tick so the object is registered in room.objects and its
|
|
1227
|
+
// parent/children proxies resolve before we map it into the tree
|
|
1212
1228
|
setTimeout(() => {
|
|
1213
|
-
this.scenetree.
|
|
1229
|
+
if (this.scenetree) this.scenetree.addNode(ev.data.thing);
|
|
1214
1230
|
}, 0);
|
|
1215
1231
|
}
|
|
1216
1232
|
handleThingRemove(ev) {
|
|
1217
|
-
|
|
1218
|
-
this.scenetree.refreshList();
|
|
1219
|
-
}, 0);
|
|
1233
|
+
if (this.scenetree) this.scenetree.removeNode(ev.data.thing);
|
|
1220
1234
|
}
|
|
1221
1235
|
loadObjectFromURIList(list) {
|
|
1222
1236
|
var urls = list.split('\n');
|
|
@@ -1457,6 +1471,37 @@ console.log('pastey!', ev.clipboardData.items[0], ev);
|
|
|
1457
1471
|
}
|
|
1458
1472
|
});
|
|
1459
1473
|
|
|
1474
|
+
let editorController = null;
|
|
1475
|
+
function getEditorController() {
|
|
1476
|
+
if (!editorController) {
|
|
1477
|
+
editorController = elation.elements.create('janus-ui-editor-controller', { append: document.body });
|
|
1478
|
+
editorController.style.display = 'none';
|
|
1479
|
+
}
|
|
1480
|
+
return editorController;
|
|
1481
|
+
}
|
|
1482
|
+
// Expose for editor components defined in other files (e.g. the inventory app),
|
|
1483
|
+
// so they can reach the shared controller singleton without re-creating it.
|
|
1484
|
+
if (typeof window !== 'undefined') window.getEditorController = getEditorController;
|
|
1485
|
+
|
|
1486
|
+
elation.elements.define('janus.ui.editor.panel', class extends elation.elements.base {
|
|
1487
|
+
create() {
|
|
1488
|
+
this.controller = getEditorController();
|
|
1489
|
+
|
|
1490
|
+
let elements = elation.elements.fromTemplate('janus.ui.editor.panel', this);
|
|
1491
|
+
this.elements = elements;
|
|
1492
|
+
elation.events.add(elements.debugview, 'click', (ev) => this.controller.handleDebugviewClick(ev));
|
|
1493
|
+
elation.events.add(elements.viewsource, 'click', (ev) => this.controller.handleViewSourceClick(ev));
|
|
1494
|
+
elation.events.add(elements.export, 'click', (ev) => this.controller.handleExportClick(ev));
|
|
1495
|
+
|
|
1496
|
+
let inventorypanel = document.querySelector('ui-collapsiblepanel[name="right"]');
|
|
1497
|
+
elation.elements.create('janus-ui-editor-scenetree', { append: inventorypanel });
|
|
1498
|
+
elation.elements.create('janus-ui-editor-objectinfo', { append: inventorypanel });
|
|
1499
|
+
}
|
|
1500
|
+
handleInventorySelect(ev) {
|
|
1501
|
+
this.controller.handleInventorySelect(ev);
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1460
1505
|
elation.elements.define('janus.ui.editor.objectinfo', class extends elation.elements.base {
|
|
1461
1506
|
create() {
|
|
1462
1507
|
this.handleThingChange = elation.bind(this, this.handleThingChange);
|
|
@@ -1489,6 +1534,7 @@ elation.elements.define('janus.ui.editor.objectinfo', class extends elation.elem
|
|
|
1489
1534
|
//itemtemplate: 'janus.ui.editor.property',
|
|
1490
1535
|
});
|
|
1491
1536
|
this.setMode('pos');
|
|
1537
|
+
getEditorController().objectinfo = this;
|
|
1492
1538
|
}
|
|
1493
1539
|
setMode(mode) {
|
|
1494
1540
|
if (this.list) {
|
|
@@ -1588,10 +1634,13 @@ elation.elements.define('janus.ui.editor.objectinfo', class extends elation.elem
|
|
|
1588
1634
|
handleEditorChange(ev, attrname, attrdef) {
|
|
1589
1635
|
//console.log('the editor changed', ev.target.value, ev, attrname, attrdef);
|
|
1590
1636
|
this.object[attrname] = ev.target.value;
|
|
1637
|
+
// Flag the object dirty so the change is broadcast to other clients.
|
|
1638
|
+
this.object.sync = true;
|
|
1591
1639
|
this.object.refresh();
|
|
1592
1640
|
}
|
|
1593
1641
|
handleEditorSelect(ev, attrname, attrdef) {
|
|
1594
1642
|
//console.log('selected it', attrname);
|
|
1643
|
+
getEditorController().setMode(attrname);
|
|
1595
1644
|
this.setMode(attrname);
|
|
1596
1645
|
}
|
|
1597
1646
|
});
|
|
@@ -1607,11 +1656,17 @@ elation.elements.define('janus.ui.editor.scenetree', class extends elation.eleme
|
|
|
1607
1656
|
label: 'js_id',
|
|
1608
1657
|
children: 'children',
|
|
1609
1658
|
visible: 'persist',
|
|
1659
|
+
// Leading type icon per row (rendered natively by ui.treeviewitem), from the
|
|
1660
|
+
// object's janus tag. All primitives share the 'object' icon — not per-shape.
|
|
1661
|
+
icon: (value) => { let t = this.sceneTreeType(value); return t ? 'st-typeicon st-type-' + t : null; },
|
|
1610
1662
|
};
|
|
1611
1663
|
elation.events.add(this.tree, 'ui_treeview_select', (ev) => this.handleTreeviewSelect(ev));
|
|
1612
1664
|
setTimeout(() => {
|
|
1613
1665
|
this.refreshList();
|
|
1614
1666
|
}, 0);
|
|
1667
|
+
let c = getEditorController();
|
|
1668
|
+
c.scenetree = this;
|
|
1669
|
+
elation.events.add(this, 'select', (ev) => c.editObject(ev.data));
|
|
1615
1670
|
}
|
|
1616
1671
|
refreshList() {
|
|
1617
1672
|
this.tree.setItems({
|
|
@@ -1622,13 +1677,183 @@ elation.elements.define('janus.ui.editor.scenetree', class extends elation.eleme
|
|
|
1622
1677
|
getProxyObject: room.getProxyObject.bind(room),
|
|
1623
1678
|
}
|
|
1624
1679
|
});
|
|
1680
|
+
// Start expanded so the room's top-level objects are visible without a click.
|
|
1681
|
+
let root = this.getRootTvitem();
|
|
1682
|
+
if (root) root.collapsed = false;
|
|
1683
|
+
}
|
|
1684
|
+
// Map a scene object to its type-icon class (used by the tree's attrs.icon).
|
|
1685
|
+
// All primitives share the 'object' tag, so they get one icon (not per-shape);
|
|
1686
|
+
// custom elements fall back to the generic icon via st-typeicon's default.
|
|
1687
|
+
sceneTreeType(value) {
|
|
1688
|
+
let tag = value && (value.tag || value.tagName || value.type);
|
|
1689
|
+
// The tree's item may be a proxy/representation without a tag; resolve the
|
|
1690
|
+
// real object by js_id, which always carries one.
|
|
1691
|
+
if (!tag && value && value.js_id && room.getObjectById) {
|
|
1692
|
+
let real = room.getObjectById(value.js_id);
|
|
1693
|
+
if (real) tag = real.tag || real.tagName || real.type;
|
|
1694
|
+
}
|
|
1695
|
+
return tag ? String(tag).toLowerCase().replace(/[^a-z0-9_-]/g, '') : null;
|
|
1696
|
+
}
|
|
1697
|
+
getRootTvitem() {
|
|
1698
|
+
return this.tree._itemsByKey && this.tree._itemsByKey[room.url];
|
|
1699
|
+
}
|
|
1700
|
+
addNode(thing) {
|
|
1701
|
+
if (!thing || !thing.js_id) return;
|
|
1702
|
+
let data = room.getObjectById(thing.js_id) || (thing.getProxyObject ? thing.getProxyObject() : thing);
|
|
1703
|
+
if (!data || !data.persist) return;
|
|
1704
|
+
// Resolve the parent tvitem incrementally. A nested object's parent is keyed
|
|
1705
|
+
// by js_id; a top-level object's parent is the room, which isn't reliably
|
|
1706
|
+
// keyed (the root tvitem is keyed by room.url, not the room's js_id), so fall
|
|
1707
|
+
// back to the root rather than refreshList() — a full rebuild collapses the
|
|
1708
|
+
// whole tree. Only rebuild when the tree hasn't been built at all yet.
|
|
1709
|
+
let parentProxy = data.parent;
|
|
1710
|
+
let parentEl = (parentProxy && parentProxy.js_id && this.tree._itemsByKey)
|
|
1711
|
+
? this.tree._itemsByKey[parentProxy.js_id]
|
|
1712
|
+
: null;
|
|
1713
|
+
if (!parentEl) parentEl = this.getRootTvitem();
|
|
1714
|
+
if (!parentEl) { this.refreshList(); return; }
|
|
1715
|
+
this.tree.addItem(data, parentEl);
|
|
1716
|
+
}
|
|
1717
|
+
removeNode(thing) {
|
|
1718
|
+
if (!thing || !thing.js_id) return;
|
|
1719
|
+
this.tree.removeItem(thing.js_id);
|
|
1625
1720
|
}
|
|
1626
1721
|
handleTreeviewSelect(ev) {
|
|
1627
1722
|
elation.events.fire({type: 'select', element: this, data: ev.data.value.getProxyObject()});
|
|
1628
1723
|
}
|
|
1629
1724
|
});
|
|
1725
|
+
// Reconcile an authored JanusML source string against the live scene, changing
|
|
1726
|
+
// only what differs: patch attributes, insert created objects, remove deleted
|
|
1727
|
+
// ones. Comments, formatting, and unmodelled attributes are left untouched.
|
|
1728
|
+
function jmlScanElements(src) {
|
|
1729
|
+
var els = [], stack = [];
|
|
1730
|
+
var re = /<!--[\s\S]*?-->|<\/([A-Za-z][\w.-]*)\s*>|<([A-Za-z][\w.-]*)((?:\s+[\w:.-]+\s*=\s*"[^"]*")*)\s*(\/?)>/g;
|
|
1731
|
+
var m;
|
|
1732
|
+
while ((m = re.exec(src)) !== null) {
|
|
1733
|
+
if (m[0].indexOf('<!--') === 0) continue;
|
|
1734
|
+
if (m[1]) {
|
|
1735
|
+
for (var i = stack.length - 1; i >= 0; i--) { if (stack[i].name === m[1]) { stack[i].fullEnd = m.index + m[0].length; stack[i].contentStart = stack[i].openEnd; stack[i].contentEnd = m.index; stack.length = i; break; } }
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
var attrs = {}, ar = /([\w:.-]+)\s*=\s*"([^"]*)"/g, a;
|
|
1739
|
+
while ((a = ar.exec(m[3])) !== null) attrs[a[1]] = a[2];
|
|
1740
|
+
var el = { name: m[2], attrs: attrs, start: m.index, openEnd: m.index + m[0].length,
|
|
1741
|
+
parent: stack.length ? stack[stack.length - 1].name : null, selfclose: m[4] === '/' };
|
|
1742
|
+
el.fullEnd = el.openEnd;
|
|
1743
|
+
els.push(el);
|
|
1744
|
+
if (!el.selfclose) stack.push(el);
|
|
1745
|
+
}
|
|
1746
|
+
return els;
|
|
1747
|
+
}
|
|
1748
|
+
function jmlParseAttrs(tag) {
|
|
1749
|
+
var attrs = {}, ar = /([\w:.-]+)\s*=\s*"([^"]*)"/g, a;
|
|
1750
|
+
while ((a = ar.exec(tag)) !== null) attrs[a[1]] = a[2];
|
|
1751
|
+
return attrs;
|
|
1752
|
+
}
|
|
1753
|
+
// For an element serialized in content form (<tag ...>value</tag>) return the
|
|
1754
|
+
// inner text; for self-closing/attribute-only markup return null.
|
|
1755
|
+
function jmlExtractContent(xml) {
|
|
1756
|
+
var open = xml.indexOf('>'), close = xml.lastIndexOf('</');
|
|
1757
|
+
if (open === -1 || close === -1 || close <= open || xml.charAt(open - 1) === '/') return null;
|
|
1758
|
+
return xml.slice(open + 1, close).trim();
|
|
1759
|
+
}
|
|
1760
|
+
function jmlSetAttr(tag, attr, value) {
|
|
1761
|
+
var re = new RegExp('(\\s' + attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*")[^"]*(")');
|
|
1762
|
+
if (re.test(tag)) return tag.replace(re, '$1' + value + '$2');
|
|
1763
|
+
return tag.replace(/\s*\/?>$/, function (mm) { return ' ' + attr + '="' + value + '"' + mm; });
|
|
1764
|
+
}
|
|
1765
|
+
function jmlRemoveAttr(tag, attr) {
|
|
1766
|
+
var re = new RegExp('\\s+' + attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*"[^"]*"');
|
|
1767
|
+
return tag.replace(re, '');
|
|
1768
|
+
}
|
|
1769
|
+
// The attribute names an object's serializer manages, regardless of current
|
|
1770
|
+
// value — used so the reconciler can drop a managed attribute that dropped out
|
|
1771
|
+
// of the live summary (reverted to default, e.g. a boolean toggled off) without
|
|
1772
|
+
// touching custom/unknown attributes the engine never emits.
|
|
1773
|
+
function jmlObjManagedKeys(object) {
|
|
1774
|
+
try {
|
|
1775
|
+
var pd = object.getProxyObject()._proxydefs, names = [],
|
|
1776
|
+
skip = { room: 1, tagName: 1, classList: 1, jsid: 1, classname: 1 };
|
|
1777
|
+
for (var k in pd) { if (!skip[k] && pd[k] && pd[k][0] === 'property') names.push(k); }
|
|
1778
|
+
return names;
|
|
1779
|
+
} catch (e) { return null; }
|
|
1780
|
+
}
|
|
1781
|
+
function jmlReconcileSource(src, summaries) {
|
|
1782
|
+
var els = jmlScanElements(src);
|
|
1783
|
+
var objEls = els.filter(function (e) { return e.parent && e.parent.toLowerCase() === 'room'; });
|
|
1784
|
+
var usedEl = [], usedLive = [], matched = [];
|
|
1785
|
+
objEls.forEach(function (el) { if (el.attrs.js_id) {
|
|
1786
|
+
for (var i = 0; i < summaries.length; i++) { if (usedLive.indexOf(i) === -1 && summaries[i].js_id === el.attrs.js_id) { matched.push([el, summaries[i]]); usedEl.push(el); usedLive.push(i); break; } }
|
|
1787
|
+
}});
|
|
1788
|
+
objEls.forEach(function (el) { if (usedEl.indexOf(el) !== -1) return;
|
|
1789
|
+
for (var i = 0; i < summaries.length; i++) { if (usedLive.indexOf(i) === -1 && summaries[i].id === el.attrs.id && summaries[i].name === el.name) { matched.push([el, summaries[i]]); usedEl.push(el); usedLive.push(i); break; } }
|
|
1790
|
+
});
|
|
1791
|
+
var deleted = objEls.filter(function (e) { return usedEl.indexOf(e) === -1; });
|
|
1792
|
+
var created = summaries.filter(function (o, i) { return usedLive.indexOf(i) === -1; });
|
|
1793
|
+
var edits = [];
|
|
1794
|
+
matched.forEach(function (pair) {
|
|
1795
|
+
var el = pair[0], live = jmlParseAttrs(pair[1].xml);
|
|
1796
|
+
var tag = src.slice(el.start, el.openEnd), changed = false;
|
|
1797
|
+
for (var k in live) { if (k === 'js_id' || k === 'jsid') continue; if (el.attrs[k] !== live[k]) { tag = jmlSetAttr(tag, k, live[k]); changed = true; } }
|
|
1798
|
+
// Drop managed attributes the live object no longer emits (reverted to
|
|
1799
|
+
// default — e.g. a boolean toggled back off). Identity attrs and custom /
|
|
1800
|
+
// unknown attributes (absent from the managed set) are left untouched.
|
|
1801
|
+
var keys = pair[1].keys;
|
|
1802
|
+
if (keys) {
|
|
1803
|
+
for (var ki = 0; ki < keys.length; ki++) {
|
|
1804
|
+
var mk = keys[ki];
|
|
1805
|
+
if (mk === 'js_id' || mk === 'jsid' || mk === 'id' || mk === 'class' || mk === 'classname') continue;
|
|
1806
|
+
if ((mk in el.attrs) && !(mk in live)) { tag = jmlRemoveAttr(tag, mk); changed = true; }
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (changed) edits.push({ start: el.start, end: el.openEnd, text: tag });
|
|
1810
|
+
// Reconcile tag content (<text>foo</text>). Only when both sides are plain
|
|
1811
|
+
// text (no nested elements) so child markup is never clobbered.
|
|
1812
|
+
if (el.contentStart != null) {
|
|
1813
|
+
var liveContent = jmlExtractContent(pair[1].xml);
|
|
1814
|
+
var srcContent = src.slice(el.contentStart, el.contentEnd);
|
|
1815
|
+
if (liveContent != null && liveContent.indexOf('<') === -1 &&
|
|
1816
|
+
srcContent.indexOf('<') === -1 && srcContent.trim() !== liveContent) {
|
|
1817
|
+
edits.push({ start: el.contentStart, end: el.contentEnd, text: liveContent });
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
deleted.forEach(function (el) {
|
|
1822
|
+
var end = el.fullEnd, nl = src.indexOf('\n', end); if (nl !== -1 && src.slice(end, nl).trim() === '') end = nl + 1;
|
|
1823
|
+
var start = el.start, ls = src.lastIndexOf('\n', start - 1); if (ls !== -1 && src.slice(ls + 1, start).trim() === '') start = ls + 1;
|
|
1824
|
+
edits.push({ start: start, end: end, text: '' });
|
|
1825
|
+
});
|
|
1826
|
+
if (created.length) {
|
|
1827
|
+
var close = src.search(/<\/room\s*>/i);
|
|
1828
|
+
if (close !== -1) {
|
|
1829
|
+
var lineStart = src.lastIndexOf('\n', close - 1) + 1;
|
|
1830
|
+
// Indent new elements to match existing room-level objects rather than the
|
|
1831
|
+
// </room> line + 2, which over-indents when the file's convention differs.
|
|
1832
|
+
var indent;
|
|
1833
|
+
if (objEls.length) {
|
|
1834
|
+
var refStart = src.lastIndexOf('\n', objEls[0].start - 1) + 1;
|
|
1835
|
+
indent = src.slice(refStart, objEls[0].start).match(/^\s*/)[0] || '';
|
|
1836
|
+
} else {
|
|
1837
|
+
indent = (src.slice(lineStart, close).match(/^\s*/)[0] || '') + ' ';
|
|
1838
|
+
}
|
|
1839
|
+
var text = created.map(function (o) { return indent + o.xml + '\n'; }).join('');
|
|
1840
|
+
edits.push({ start: lineStart, end: lineStart, text: text });
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
edits.sort(function (a, b) { return b.start - a.start; });
|
|
1844
|
+
var out = src;
|
|
1845
|
+
edits.forEach(function (e) { out = out.slice(0, e.start) + e.text + out.slice(e.end); });
|
|
1846
|
+
return out;
|
|
1847
|
+
}
|
|
1630
1848
|
elation.elements.define('janus.ui.editor.source', class extends elation.elements.base {
|
|
1631
1849
|
async create() {
|
|
1850
|
+
// CodeMirror core is loaded by the host page as a plain <script>; the hint
|
|
1851
|
+
// addons below reference the global CodeMirror, so on a cold/slow load they
|
|
1852
|
+
// can run before it's defined ("CodeMirror is not defined"). Wait for the
|
|
1853
|
+
// core to finish loading before pulling the addons in.
|
|
1854
|
+
for (let i = 0; typeof CodeMirror === 'undefined' && i < 200; i++) {
|
|
1855
|
+
await new Promise(r => setTimeout(r, 25));
|
|
1856
|
+
}
|
|
1632
1857
|
await janus.ui.apps.default.apps.editor.loadScriptsAndCSS([
|
|
1633
1858
|
'./codemirror/addon/hint/show-hint.js',
|
|
1634
1859
|
'./codemirror/addon/hint/xml-hint.js',
|
|
@@ -1642,13 +1867,45 @@ elation.elements.define('janus.ui.editor.source', class extends elation.elements
|
|
|
1642
1867
|
|
|
1643
1868
|
this.tabs = elation.elements.create('ui-tabs', { append: this });
|
|
1644
1869
|
//let roomtab = elation.elements.create('ui-tab', { append: this.tabs, label: 'Room Markup' });
|
|
1645
|
-
|
|
1870
|
+
// Edit the authored markup (room.roomsrc) rather than a regenerated copy,
|
|
1871
|
+
// so comments and formatting are preserved.
|
|
1872
|
+
let roomedit = elation.elements.create('janus-ui-editor-source-file', { append: this.tabs, label: 'Room Markup', source: (room.roomsrc || room.getRoomSource()), hints: this.jmlhints });
|
|
1873
|
+
|
|
1874
|
+
// Bring the markup view in line with the live scene: when the room exposes
|
|
1875
|
+
// per-object summaries, edit the authored text in place (preserving comments
|
|
1876
|
+
// and formatting); otherwise regenerate the whole document.
|
|
1877
|
+
let syncFromScene = () => {
|
|
1878
|
+
let src = roomedit.codemirror ? roomedit.codemirror.getValue() : roomedit.source;
|
|
1879
|
+
let next = null;
|
|
1880
|
+
try {
|
|
1881
|
+
if (typeof room.getObjectSummaries === 'function') {
|
|
1882
|
+
let summaries = room.getObjectSummaries();
|
|
1883
|
+
// Tag each summary with its object's managed attribute names so the
|
|
1884
|
+
// reconciler can drop attributes that reverted to default.
|
|
1885
|
+
summaries.forEach(s => { let o = room.getObjectById(s.js_id); if (o) s.keys = jmlObjManagedKeys(o); });
|
|
1886
|
+
next = jmlReconcileSource(src, summaries);
|
|
1887
|
+
}
|
|
1888
|
+
} catch (e) { console.error('[editor] source reconcile failed', e); }
|
|
1889
|
+
return (next != null) ? next : room.getRoomSource();
|
|
1890
|
+
};
|
|
1646
1891
|
|
|
1647
1892
|
let refreshbutton = elation.elements.create('ui-button', { append: this, label: '↻', title: "Refresh room source", name: "refresh" });
|
|
1648
1893
|
refreshbutton.addEventListener('click', ev => {
|
|
1649
|
-
roomedit.source =
|
|
1894
|
+
roomedit.source = syncFromScene();
|
|
1650
1895
|
});
|
|
1651
1896
|
|
|
1897
|
+
// Keep the markup view current with edits made elsewhere (gizmo, inspector,
|
|
1898
|
+
// remote peers). The reconciler merges scene changes into the current buffer
|
|
1899
|
+
// and preserves the user's hand edits (comments/formatting/custom attrs), so
|
|
1900
|
+
// we only hold back while the editor is focused — i.e. actively being typed
|
|
1901
|
+
// in — to avoid disturbing the caret. The ↻ button forces a full refresh.
|
|
1902
|
+
let refreshFromScene = () => {
|
|
1903
|
+
let cm = roomedit.codemirror;
|
|
1904
|
+
if (cm && cm.hasFocus()) return;
|
|
1905
|
+
roomedit.source = syncFromScene();
|
|
1906
|
+
};
|
|
1907
|
+
elation.events.add(room, 'scene_changed', refreshFromScene);
|
|
1908
|
+
|
|
1652
1909
|
if (room.roomassets.script) {
|
|
1653
1910
|
for (let k in room.roomassets.script) {
|
|
1654
1911
|
let scriptasset = room.roomassets.script[k];
|
|
@@ -1912,7 +2169,9 @@ elation.elements.define('janus.ui.editor.source.file', class extends elation.ele
|
|
|
1912
2169
|
this.filename = this.label;
|
|
1913
2170
|
|
|
1914
2171
|
this.initCodemirror();
|
|
1915
|
-
|
|
2172
|
+
// codemirror is created asynchronously (mode scripts load on demand); guard so a
|
|
2173
|
+
// tab switch before it's ready doesn't throw.
|
|
2174
|
+
elation.events.add(this.parentNode, 'tabselect', ev => { if (this.codemirror) { this.codemirror.refresh(); setTimeout(() => { this.codemirror.focus(); }, 0); } });
|
|
1916
2175
|
}
|
|
1917
2176
|
async initCodemirror() {
|
|
1918
2177
|
if (!CodeMirror.modes[this.mode]) {
|
|
@@ -1920,7 +2179,10 @@ elation.elements.define('janus.ui.editor.source.file', class extends elation.ele
|
|
|
1920
2179
|
}
|
|
1921
2180
|
|
|
1922
2181
|
this.codemirror = CodeMirror(this, {
|
|
1923
|
-
|
|
2182
|
+
// CodeMirror needs a string; a script tab whose asset code hasn't loaded
|
|
2183
|
+
// yet has an undefined source, which throws inside CodeMirror (it tries to
|
|
2184
|
+
// set modeOption on a missing doc). The source setter fills it in later.
|
|
2185
|
+
value: this.source || '',
|
|
1924
2186
|
mode: this.mode,
|
|
1925
2187
|
lineNumbers: true,
|
|
1926
2188
|
extraKeys: {
|
|
@@ -1990,6 +2252,9 @@ console.log('editor hints', this.hints);
|
|
|
1990
2252
|
this.label = this.filename;
|
|
1991
2253
|
}
|
|
1992
2254
|
handleEditContentChange(ev) {
|
|
2255
|
+
// setValue() from a programmatic refresh (scene -> source) also fires
|
|
2256
|
+
// 'change'; ignore it so it isn't treated as a user edit and re-applied.
|
|
2257
|
+
if (this.applyingExternal) return;
|
|
1993
2258
|
//console.log('it changed', ev);
|
|
1994
2259
|
let newsource = this.codemirror.getValue();
|
|
1995
2260
|
this.dirty = (newsource != this.source);
|
|
@@ -2007,8 +2272,20 @@ console.log('editor hints', this.hints);
|
|
|
2007
2272
|
}, 500);
|
|
2008
2273
|
}
|
|
2009
2274
|
updateSource() {
|
|
2010
|
-
|
|
2011
|
-
|
|
2275
|
+
let cm = this.codemirror;
|
|
2276
|
+
// The source attribute can be set before codemirror exists (initial value) or
|
|
2277
|
+
// while it's still loading its mode; initCodemirror picks up this.source then.
|
|
2278
|
+
if (!cm) return;
|
|
2279
|
+
if (this.source != cm.getValue()) {
|
|
2280
|
+
// Preserve caret/selection and scroll across the programmatic refresh so a
|
|
2281
|
+
// scene-driven source update doesn't jump the view back to the top.
|
|
2282
|
+
let sels = cm.listSelections();
|
|
2283
|
+
let scroll = cm.getScrollInfo();
|
|
2284
|
+
this.applyingExternal = true;
|
|
2285
|
+
cm.setValue(this.source);
|
|
2286
|
+
try { cm.setSelections(sels); } catch (e) {}
|
|
2287
|
+
cm.scrollTo(scroll.left, scroll.top);
|
|
2288
|
+
this.applyingExternal = false;
|
|
2012
2289
|
}
|
|
2013
2290
|
}
|
|
2014
2291
|
});
|
|
Binary file
|
|
@@ -112,3 +112,74 @@ janus-ui-inventory-roomassets ui-grid.models {
|
|
|
112
112
|
overflow-x: visible;
|
|
113
113
|
overflow-y: visible;
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
/* ---- unified asset inventory (controls + provenance badges) ---- */
|
|
117
|
+
janus-ui-inventory {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
min-height: 0;
|
|
121
|
+
height: 100%;
|
|
122
|
+
}
|
|
123
|
+
janus-ui-inventory > h3 { margin: 0 0 .3em; }
|
|
124
|
+
.inv-controls { display: flex; gap: .4em; margin-bottom: .4em; }
|
|
125
|
+
.inv-search {
|
|
126
|
+
flex: 1 1 auto; min-width: 0; padding: .3em .5em;
|
|
127
|
+
background: #111; color: #eee; border: 1px solid #333; border-radius: 5px;
|
|
128
|
+
}
|
|
129
|
+
.inv-sort {
|
|
130
|
+
flex: 0 0 auto; padding: .3em; background: #111; color: #eee;
|
|
131
|
+
border: 1px solid #333; border-radius: 5px;
|
|
132
|
+
}
|
|
133
|
+
.inv-chips { display: flex; flex-wrap: wrap; gap: .3em; margin-bottom: .4em; }
|
|
134
|
+
.inv-chip {
|
|
135
|
+
font-size: .75em; padding: .15em .55em; border-radius: 999px; cursor: pointer;
|
|
136
|
+
background: #1a1a1a; color: #bbb; border: 1px solid #333; text-transform: capitalize;
|
|
137
|
+
opacity: .6;
|
|
138
|
+
}
|
|
139
|
+
.inv-chip:hover { color: #fff; border-color: #555; opacity: .85; }
|
|
140
|
+
/* Two independent dimensions. A chip's background is its identity (source chips
|
|
141
|
+
carry their source color via .inv-badge-*; type chips stay neutral), and
|
|
142
|
+
selection is layered on top as a bright ring + full opacity — so a selected
|
|
143
|
+
chip keeps its own color instead of all turning teal and clashing with Room. */
|
|
144
|
+
.inv-chip.active {
|
|
145
|
+
opacity: 1; color: #fff; border-color: #fff;
|
|
146
|
+
box-shadow: 0 0 0 1px rgba(255,255,255,.2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* grid is now the main, growable element rather than a fixed-height tab body */
|
|
150
|
+
ui-grid.inv-grid { flex: 1 1 auto; height: auto; min-height: 0; }
|
|
151
|
+
|
|
152
|
+
.inv-type-icon {
|
|
153
|
+
font-size: 1.8em; line-height: 1; opacity: .85;
|
|
154
|
+
display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;
|
|
155
|
+
}
|
|
156
|
+
/* Shape-primitive icons drawn from the engine object-icon sprite sheet
|
|
157
|
+
(objecticons-small.png, 20px cells), scaled up so the small source icon
|
|
158
|
+
fills the card. */
|
|
159
|
+
.inv-prim-icon {
|
|
160
|
+
width: 20px; height: 20px;
|
|
161
|
+
background-image: url('./objecticons-small.png');
|
|
162
|
+
background-repeat: no-repeat;
|
|
163
|
+
image-rendering: auto;
|
|
164
|
+
transform: scale(2);
|
|
165
|
+
transform-origin: center;
|
|
166
|
+
}
|
|
167
|
+
.inv-prim-plane { background-position: 0 -20px; }
|
|
168
|
+
.inv-prim-cube { background-position: -21px -20px; }
|
|
169
|
+
.inv-prim-sphere { background-position: -63px -20px; }
|
|
170
|
+
.inv-prim-cylinder,
|
|
171
|
+
.inv-prim-pipe,
|
|
172
|
+
.inv-prim-capsule { background-position: -147px -20px; }
|
|
173
|
+
.inv-prim-torus { background-position: -168px -20px; }
|
|
174
|
+
.inv-prim-cone,
|
|
175
|
+
.inv-prim-pyramid { background-position: -189px -20px; }
|
|
176
|
+
.inv-badges { position: absolute; top: 2px; left: 2px; display: flex; gap: 2px; z-index: 2; pointer-events: none; }
|
|
177
|
+
.inv-badge {
|
|
178
|
+
font-size: .6em; line-height: 1; padding: .15em .35em; border-radius: 4px;
|
|
179
|
+
background: rgba(0,0,0,.6); color: #fff; text-transform: capitalize; max-width: 6em;
|
|
180
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
181
|
+
}
|
|
182
|
+
.inv-badge-room { background: rgba(29,233,182,.85); color: #06241d; }
|
|
183
|
+
.inv-badge-primitive { background: rgba(120,120,120,.85); }
|
|
184
|
+
.inv-badge-fse-assets { background: rgba(124,77,255,.9); }
|
|
185
|
+
.inv-empty { padding: 1em .5em; color: #666; font-size: .85em; line-height: 1.4; }
|