janusweb 1.7.2 → 1.7.3

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.
@@ -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.panel', class extends elation.elements.base {
50
+ elation.elements.define('janus.ui.editor.controller', class extends elation.elements.base {
50
51
  create() {
51
- //this.innerHTML = elation.template.get('janus.ui.editor.panel');
52
- let elements = elation.elements.fromTemplate('janus.ui.editor.panel', this);
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.pickable = false;
408
- //console.log('set object unpickable', object);
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
- manipulator.enabled = true;
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.updateObject(object);
445
-
446
- if (this.objectinfo.hidden) {
447
- this.objectinfo.show();
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
- //this.roomedit.object.pickable = true;
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
- if (janus.engine.systems.admin.hidden) {
1206
- janus.engine.systems.controls.requestPointerLock();
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
- console.log('[editor] scene added a thing', ev);
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.refreshList();
1229
+ if (this.scenetree) this.scenetree.addNode(ev.data.thing);
1214
1230
  }, 0);
1215
1231
  }
1216
1232
  handleThingRemove(ev) {
1217
- setTimeout(() => {
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
- let roomedit = elation.elements.create('janus-ui-editor-source-file', { append: this.tabs, label: 'Room Markup', source: room.getRoomSource(), hints: this.jmlhints });
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 = room.getRoomSource();
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
- elation.events.add(this.parentNode, 'tabselect', ev => { this.codemirror.refresh(); setTimeout(() => { this.codemirror.focus(); }, 0); });
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
- value: this.source,
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
- if (this.source != this.codemirror.getValue()) {
2011
- this.codemirror.setValue(this.source);
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
  });
@@ -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; }