iobroker.mywebui 1.42.16 → 1.42.18

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/io-package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "mywebui",
4
- "version": "1.42.16",
4
+ "version": "1.42.18",
5
5
  "titleLang": {
6
6
  "en": "mywebui",
7
7
  "de": "mywebui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.mywebui",
3
- "version": "1.42.16",
3
+ "version": "1.42.18",
4
4
  "description": "ioBroker mywebui - Custom edited mywebui by gokturk413 with 3D Editor",
5
5
  "type": "module",
6
6
  "main": "dist/backend/main.js",
@@ -6,8 +6,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
6
6
  static template = html`
7
7
  <div id="root" style="width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;background:#1e1e1e;font-family:'Segoe UI',sans-serif;font-size:12px;">
8
8
 
9
- <!-- Toolbar - realvirtual style dark bar -->
10
- <div style="height:40px;background:#252526;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;padding:0 8px;gap:4px;">
9
+ <!-- Toolbar -->
10
+ <div style="height:40px;background:#252526;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0;">
11
11
  <span style="color:#ccc;font-size:11px;font-weight:bold;margin-right:8px;letter-spacing:0.5px;">3D EDITOR</span>
12
12
  <button id="saveBtn" class="tb-btn primary">&#128190; Save</button>
13
13
  <div class="tb-sep"></div>
@@ -22,44 +22,62 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
22
22
  </div>
23
23
 
24
24
  <!-- 3-column body -->
25
- <div style="flex:1;display:flex;overflow:hidden;">
25
+ <div style="flex:1;display:flex;overflow:hidden;min-height:0;">
26
26
 
27
- <!-- LEFT: Scene tree (realvirtual-style) -->
28
- <div id="leftPanel" style="width:220px;min-width:160px;background:#252526;border-right:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
29
- <!-- Tab row: All / Assets / Lights / Signals -->
30
- <div style="display:flex;background:#2d2d2d;border-bottom:1px solid #3c3c3c;">
27
+ <!-- LEFT: Scene tree + Library -->
28
+ <div id="leftPanel" style="width:240px;min-width:160px;background:#252526;border-right:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
29
+ <!-- Tab row: All / Assets / Lights / Signals / Library -->
30
+ <div style="display:flex;background:#2d2d2d;border-bottom:1px solid #3c3c3c;flex-shrink:0;">
31
31
  <button id="tabAll" class="lt-tab active" data-tab="all">All</button>
32
32
  <button id="tabAssets" class="lt-tab" data-tab="assets">Assets</button>
33
33
  <button id="tabLights" class="lt-tab" data-tab="lights">Lights</button>
34
34
  <button id="tabSignals" class="lt-tab" data-tab="signals">Signals</button>
35
+ <button id="tabLibrary" class="lt-tab" data-tab="library" style="color:#9cdcfe;" title="3D Components Library">Lib</button>
35
36
  </div>
36
37
  <!-- Search -->
37
- <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;">
38
- <input id="treeSearch" type="text" placeholder="Search nodes..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
38
+ <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;flex-shrink:0;">
39
+ <input id="treeSearch" type="text" placeholder="Search..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
39
40
  </div>
40
- <!-- Tree content -->
41
+ <!-- Tree / Library content -->
41
42
  <div id="treeContent" style="flex:1;overflow:auto;padding:4px 0;"></div>
43
+ <!-- Library quick-add button (shown in library tab) -->
44
+ <div id="libAddRow" style="display:none;padding:4px 6px;border-top:1px solid #3c3c3c;flex-shrink:0;">
45
+ <button id="addLibraryBtn" class="tb-btn" style="width:100%;font-size:10px;">&#43; Import GLB as Component</button>
46
+ </div>
42
47
  </div>
43
48
 
44
49
  <!-- CENTER: Viewport -->
45
- <div id="viewport" style="flex:1;position:relative;overflow:hidden;background:#333;"></div>
46
-
47
- <!-- RIGHT: Component Library (realvirtual-style) -->
48
- <div id="rightPanel" style="width:220px;min-width:160px;background:#252526;border-left:1px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;">
49
- <!-- Library header -->
50
- <div style="padding:6px 8px;background:#2d2d2d;border-bottom:1px solid #3c3c3c;display:flex;align-items:center;justify-content:space-between;">
51
- <span style="color:#9cdcfe;font-size:11px;font-weight:bold;letter-spacing:0.5px;">&#128218; LIBRARY</span>
52
- <button id="addLibraryBtn" class="tb-btn" style="padding:2px 8px;font-size:10px;">&#43; Add</button>
53
- </div>
54
- <!-- Search library -->
55
- <div style="padding:4px 6px;border-bottom:1px solid #3c3c3c;">
56
- <input id="libSearch" type="text" placeholder="Search..." style="width:100%;box-sizing:border-box;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:3px 6px;border-radius:3px;font-size:11px;">
50
+ <div id="viewport" style="flex:1;position:relative;overflow:hidden;background:#333;">
51
+ <!-- Loading overlay -->
52
+ <div id="loadOverlay" style="position:absolute;inset:0;background:#1e1e1e;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:100;transition:opacity 0.3s;">
53
+ <div style="color:#9cdcfe;font-size:16px;font-weight:bold;margin-bottom:16px;letter-spacing:1px;">3D EDITOR</div>
54
+ <div style="width:220px;height:4px;background:#3c3c3c;border-radius:2px;overflow:hidden;margin-bottom:10px;">
55
+ <div id="loadBarFill" style="height:100%;background:linear-gradient(90deg,#0e639c,#4ec9b0);width:0%;transition:width 0.4s ease;"></div>
56
+ </div>
57
+ <div id="loadText" style="color:#666;font-size:11px;">Initializing...</div>
57
58
  </div>
58
- <!-- Thumbnail grid -->
59
- <div id="libraryGrid" style="flex:1;overflow:auto;padding:6px;"></div>
60
59
  </div>
61
60
 
62
61
  </div>
62
+
63
+ <!-- Bottom: Script / Style editor panel -->
64
+ <div id="bottomPanel" style="height:0;flex-shrink:0;background:#1e1e1e;border-top:2px solid #3c3c3c;display:flex;flex-direction:column;overflow:hidden;transition:height 0.15s;">
65
+ <div style="display:flex;background:#252526;border-bottom:1px solid #3c3c3c;align-items:center;flex-shrink:0;">
66
+ <button id="btTabScript" class="bt-tab active" data-btab="script">JS Script</button>
67
+ <button id="btTabStyle" class="bt-tab" data-btab="style">CSS Style</button>
68
+ <div style="flex:1;"></div>
69
+ <span style="color:#555;font-size:10px;margin-right:6px;">Ctrl+Enter = Run</span>
70
+ <button id="runScriptBtn" class="tb-btn" style="padding:2px 10px;font-size:10px;margin-right:4px;">&#9654; Run</button>
71
+ <button id="scriptErrBtn" id="scriptErrBtn" style="display:none;padding:2px 8px;font-size:10px;background:#5a1a1a;color:#f44747;border:1px solid #8a2a2a;border-radius:3px;cursor:pointer;margin-right:4px;" title="Script error"></button>
72
+ </div>
73
+ <textarea id="scriptEditor" spellcheck="false" placeholder="// Three.js API available:&#10;// scene, camera, THREE, renderer, controls&#10;// assets → Map(name → Object3D)&#10;// mixers → Map(assetId → {mixer, clips, actions})&#10;// driveEngine, subscribe(signal, cb), setState(signal, val)&#10;// onFrame(fn) → called every animation frame(dt, timestamp)&#10;// onSelect(fn) → called when node selected(node, assetData)&#10;&#10;// Example:&#10;// const robot = assets.get('Robot');&#10;// onFrame(dt => { robot.rotation.y += dt; });"></textarea>
74
+ <textarea id="styleEditor" spellcheck="false" placeholder="/* CSS for overlay elements */&#10;/* .label-3d { color: white; background: rgba(0,0,0,0.6); } */"></textarea>
75
+ </div>
76
+ <!-- Bottom panel toggle (draggable handle) -->
77
+ <div id="bottomHandle" style="height:6px;background:#3c3c3c;cursor:ns-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;">
78
+ <div style="width:40px;height:2px;background:#555;border-radius:1px;"></div>
79
+ </div>
80
+
63
81
  </div>
64
82
  `;
65
83
 
@@ -149,9 +167,57 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
149
167
  text-align: center;
150
168
  }
151
169
  .lib-card:hover { border-color:#9cdcfe; background:#094771; }
152
- .lib-card .lib-thumb { font-size:32px; margin-bottom:4px; line-height:1; }
170
+ .lib-card .lib-thumb { font-size:28px; margin-bottom:4px; line-height:1; }
153
171
  .lib-card .lib-name { color:#ccc; font-size:10px; word-break:break-word; line-height:1.2; }
154
172
  .lib-card .lib-type { color:#666; font-size:9px; margin-top:2px; }
173
+
174
+ .bt-tab {
175
+ padding: 5px 12px;
176
+ background: transparent;
177
+ color: #888;
178
+ border: none;
179
+ border-bottom: 2px solid transparent;
180
+ cursor: pointer;
181
+ font-size: 11px;
182
+ font-weight: bold;
183
+ }
184
+ .bt-tab:hover { color:#ccc; }
185
+ .bt-tab.active { color:#9cdcfe; border-bottom-color:#9cdcfe; }
186
+
187
+ #scriptEditor, #styleEditor {
188
+ flex: 1;
189
+ width: 100%;
190
+ background: #1e1e1e;
191
+ color: #d4d4d4;
192
+ border: none;
193
+ resize: none;
194
+ font-family: 'Consolas','Courier New',monospace;
195
+ font-size: 12px;
196
+ line-height: 1.5;
197
+ padding: 8px 12px;
198
+ box-sizing: border-box;
199
+ outline: none;
200
+ tab-size: 4;
201
+ display: none;
202
+ }
203
+ #scriptEditor.active, #styleEditor.active { display: block; }
204
+
205
+ .lib-item {
206
+ display: flex;
207
+ align-items: center;
208
+ padding: 5px 8px;
209
+ color: #ccc;
210
+ cursor: pointer;
211
+ border-left: 2px solid transparent;
212
+ gap: 6px;
213
+ }
214
+ .lib-item:hover { background:#2d2d2d; border-left-color:#9cdcfe; }
215
+ .lib-item .lib-icon { font-size:16px; flex-shrink:0; }
216
+ .lib-item .lib-info { flex:1; overflow:hidden; }
217
+ .lib-item .lib-name { font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
218
+ .lib-item .lib-meta { font-size:9px; color:#555; }
219
+ .lib-item .lib-add { opacity:0; font-size:11px; color:#4ec9b0; padding:0 4px; }
220
+ .lib-item:hover .lib-add { opacity:1; }
155
221
  `;
156
222
 
157
223
  constructor() {
@@ -175,6 +241,9 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
175
241
  this._threeObjects = [];
176
242
  this._mixers = new Map(); // assetId → { mixer, clips, actions }
177
243
  this._lastAnimTs = null;
244
+ this._scriptFrameCallbacks = [];
245
+ this._scriptSelectCallbacks = [];
246
+ this._scriptUnsubs = [];
178
247
  }
179
248
 
180
249
  async connectedCallback() {
@@ -183,25 +252,53 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
183
252
 
184
253
  async init() {
185
254
  try {
186
- this._setStatus('Loading Three.js...');
187
- const THREE = await import('/mywebui.0.widgets/node_modules/three/build/three.module.js');
188
- const { OrbitControls } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js');
189
- const { GLTFLoader } = await import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js');
255
+ this._setLoadProgress(5, 'Loading Three.js...');
256
+ // Parallel import - 2-3x faster than sequential
257
+ const [THREE, { OrbitControls }, { GLTFLoader }] = await Promise.all([
258
+ import('/mywebui.0.widgets/node_modules/three/build/three.module.js'),
259
+ import('/mywebui.0.widgets/node_modules/three/examples/jsm/controls/OrbitControls.js'),
260
+ import('/mywebui.0.widgets/node_modules/three/examples/jsm/loaders/GLTFLoader.js'),
261
+ ]);
190
262
  this.THREE = THREE;
191
263
  this.gltfLoader = new GLTFLoader();
192
- this._setStatus('Initializing...');
264
+
265
+ this._setLoadProgress(40, 'Setting up renderer...');
193
266
  this.initThreeJS(THREE, OrbitControls);
267
+
268
+ this._setLoadProgress(65, 'Loading scene...');
194
269
  const sceneName = this.getAttribute('scene-name') || 'new-scene';
195
270
  await this.loadScene(sceneName);
196
- this._setStatus('Ready');
197
- setTimeout(() => this._setStatus(''), 2000);
271
+
272
+ this._setLoadProgress(90, 'Starting...');
198
273
  this.setupEventListeners();
274
+ this._setupBottomPanel();
275
+ this._runUserScript();
276
+
277
+ this._setLoadProgress(100, 'Ready');
278
+ this._setStatus('Ready');
279
+ setTimeout(() => { this._setStatus(''); this._hideLoadOverlay(); }, 400);
199
280
  } catch (err) {
200
281
  console.error('3D Editor init failed:', err);
282
+ this._setLoadProgress(0, 'Error: ' + err.message);
201
283
  this._setStatus('Error: ' + err.message);
202
284
  }
203
285
  }
204
286
 
287
+ _setLoadProgress(pct, msg) {
288
+ const fill = this._getDomElement('loadBarFill');
289
+ const text = this._getDomElement('loadText');
290
+ if (fill) fill.style.width = pct + '%';
291
+ if (text) text.textContent = msg;
292
+ }
293
+
294
+ _hideLoadOverlay() {
295
+ const overlay = this._getDomElement('loadOverlay');
296
+ if (overlay) {
297
+ overlay.style.opacity = '0';
298
+ setTimeout(() => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }, 350);
299
+ }
300
+ }
301
+
205
302
  _setStatus(msg) {
206
303
  const el = this._getDomElement('statusLabel');
207
304
  if (el) el.textContent = msg;
@@ -256,6 +353,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
256
353
  this._lastAnimTs = ts;
257
354
  if (this.driveEngine) this.driveEngine.tick(dt);
258
355
  for (const { mixer } of this._mixers.values()) mixer.update(dt);
356
+ // User script frame callbacks
357
+ for (const fn of this._scriptFrameCallbacks) {
358
+ try { fn(dt, ts); } catch (_) {}
359
+ }
259
360
  this.controls.update();
260
361
  this.renderer.render(this.scene, this.camera);
261
362
  };
@@ -263,6 +364,7 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
263
364
  this._stopAnimation = () => { animating = false; };
264
365
 
265
366
  this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
367
+ this._setupHoverDetection();
266
368
  const resizeObs = new ResizeObserver(() => this.onViewportResize());
267
369
  resizeObs.observe(viewport);
268
370
  }
@@ -280,6 +382,9 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
280
382
  try {
281
383
  this.sceneData = await iobrokerHandler.get3DScreen(sceneName);
282
384
  if (!this.sceneData) { console.warn('Scene not found:', sceneName); return; }
385
+ // Ensure script/style fields exist
386
+ if (!this.sceneData.script) this.sceneData.script = '';
387
+ if (!this.sceneData.style) this.sceneData.style = '';
283
388
  console.log('Scene loaded:', sceneName);
284
389
  this._buildSceneView();
285
390
  } catch (err) {
@@ -343,6 +448,11 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
343
448
  this._threeObjects.push(model);
344
449
  if (this.driveEngine && Array.isArray(this.sceneData?.assets)) {
345
450
  this.driveEngine.createFromAssets(this.sceneData.assets, this._threeObjects);
451
+ // Wrap drives for AtMax/AtMin events
452
+ if (asset.drives?.length) {
453
+ const drives = asset.drives.map((_, i) => this.driveEngine.getDrive(asset.id, i));
454
+ this._wrapDriveEvents(asset, drives);
455
+ }
346
456
  }
347
457
  // AnimationMixer for built-in GLB animations
348
458
  if (gltf.animations?.length > 0) {
@@ -353,6 +463,12 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
353
463
  }
354
464
  this._mixers.set(asset.id, { mixer, clips: gltf.animations, actions });
355
465
  asset.availableClips = gltf.animations.map(c => c.name);
466
+ // animationEnd event
467
+ mixer.addEventListener('finished', (e) => {
468
+ const clipName = e.action.getClip().name;
469
+ this._executeNodeEvent(model, asset, `animationEnd:${clipName}`);
470
+ this._executeNodeEvent(model, asset, 'animationEnd');
471
+ });
356
472
  }
357
473
  // Extract named node list for scene tree
358
474
  asset._nodeList = this._extractNamedNodes(model);
@@ -408,6 +524,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
408
524
 
409
525
  if (hitNode && hitAssetRoot) {
410
526
  if (hitNode.userData.interaction) this._handleInteraction(hitNode);
527
+ // Fire node event: check by name AND by uuid
528
+ this._executeNodeEvent(hitNode, hitAssetRoot.userData.assetData, 'click');
411
529
  this.selectNode(hitNode, hitAssetRoot);
412
530
  } else {
413
531
  this.deselectAll();
@@ -430,6 +548,10 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
430
548
  this._expandedAssets.add(assetRoot.userData.assetData?.id);
431
549
  this.refreshTree();
432
550
  this._showNodeProperties(node, assetRoot);
551
+ // User script select callbacks
552
+ for (const fn of this._scriptSelectCallbacks) {
553
+ try { fn(node, assetRoot.userData.assetData); } catch (_) {}
554
+ }
433
555
  }
434
556
 
435
557
  selectObject(obj) {
@@ -529,6 +651,131 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
529
651
  }
530
652
  }
531
653
 
654
+ // ── Event execution ───────────────────────────────────
655
+
656
+ /**
657
+ * Execute a node event handler stored in asset.events[nodeName][eventKey].
658
+ * eventKey: 'click' | 'mouseenter' | 'mouseleave' | 'animationEnd' | 'animationEnd:ClipName' |
659
+ * 'driveAtMax:N' | 'driveAtMin:N' | 'signalChange:path'
660
+ * Node name '__root__' = the asset root object.
661
+ */
662
+ _executeNodeEvent(node, asset, eventKey) {
663
+ if (!asset?.events) return;
664
+ const nodeKey = (node?.userData?.assetData === asset || !node?.name) ? '__root__' : node.name;
665
+ const script = asset.events[nodeKey]?.[eventKey];
666
+ if (!script?.trim()) return;
667
+
668
+ const ctx = {
669
+ node,
670
+ assetData: asset,
671
+ THREE: this.THREE,
672
+ scene: this.scene,
673
+ camera: this.camera,
674
+ renderer: this.renderer,
675
+ controls: this.controls,
676
+ assets: this._buildAssetsMap(),
677
+ mixers: this._mixers,
678
+ driveEngine: this.driveEngine,
679
+ setState(signal, val) { iobrokerHandler.setState(signal, val); },
680
+ getState: (s) => this.driveEngine.bus.get(s),
681
+ subscribe: (s, cb) => { const u = this.driveEngine.bus.subscribe(s, cb); this._scriptUnsubs.push(u); return u; },
682
+ log: (...a) => console.log('[3D Event]', nodeKey, eventKey, ...a),
683
+ };
684
+ try {
685
+ new Function(...Object.keys(ctx), script)(...Object.values(ctx));
686
+ } catch (err) {
687
+ console.error(`[3D Event ${nodeKey}:${eventKey}]`, err.message);
688
+ }
689
+ }
690
+
691
+ _buildAssetsMap() {
692
+ const m = new Map();
693
+ for (const obj of this.scene.children) {
694
+ if (obj.userData.assetData) {
695
+ m.set(obj.userData.assetData.name, obj);
696
+ m.set(obj.userData.assetData.id, obj);
697
+ }
698
+ }
699
+ return m;
700
+ }
701
+
702
+ // ── Hover detection ───────────────────────────────────
703
+
704
+ _setupHoverDetection() {
705
+ let hoverNode = null;
706
+ let hoverAsset = null;
707
+ let throttle = null;
708
+
709
+ this.renderer.domElement.addEventListener('mousemove', (e) => {
710
+ if (throttle) return;
711
+ throttle = setTimeout(() => { throttle = null; }, 50); // 20 fps check
712
+
713
+ const rect = this.renderer.domElement.getBoundingClientRect();
714
+ this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
715
+ this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
716
+ this.raycaster.setFromCamera(this.mouse, this.camera);
717
+ const hits = this.raycaster.intersectObjects(this.scene.children, true);
718
+
719
+ let newNode = null, newRoot = null;
720
+ for (const hit of hits) {
721
+ const o = hit.object;
722
+ if (o.userData.isGrid || o.userData.isAxes || o.userData.isSelectionHelper) continue;
723
+ let p = o;
724
+ while (p && p !== this.scene) {
725
+ if (p.userData.assetData) { newRoot = p; break; }
726
+ p = p.parent;
727
+ }
728
+ if (newRoot) { newNode = o; break; }
729
+ }
730
+
731
+ if (newNode !== hoverNode) {
732
+ if (hoverNode && hoverAsset) {
733
+ this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
734
+ this.renderer.domElement.style.cursor = 'default';
735
+ }
736
+ if (newNode && newRoot) {
737
+ const asset = newRoot.userData.assetData;
738
+ this._executeNodeEvent(newNode, asset, 'mouseenter');
739
+ // Change cursor if node has any event or interaction
740
+ const hasEvent = asset?.events || newNode.userData.interaction;
741
+ if (hasEvent) this.renderer.domElement.style.cursor = 'pointer';
742
+ }
743
+ hoverNode = newNode;
744
+ hoverAsset = newRoot?.userData.assetData ?? null;
745
+ }
746
+ });
747
+
748
+ this.renderer.domElement.addEventListener('mouseleave', () => {
749
+ if (hoverNode && hoverAsset) {
750
+ this._executeNodeEvent(hoverNode, hoverAsset, 'mouseleave');
751
+ }
752
+ hoverNode = null; hoverAsset = null;
753
+ this.renderer.domElement.style.cursor = 'default';
754
+ });
755
+ }
756
+
757
+ // ── Drive events (AtMax / AtMin) ──────────────────────
758
+ // Called from DriveEngine after createFromAssets - wraps onAfterUpdate
759
+ _wrapDriveEvents(asset, drives) {
760
+ for (let i = 0; i < drives.length; i++) {
761
+ const drive = drives[i];
762
+ if (!drive) continue;
763
+ const cfg = asset.drives?.[i];
764
+ const origCb = drive.onAfterUpdate;
765
+ drive.onAfterUpdate = (d) => {
766
+ if (origCb) origCb(d);
767
+ const root = this._threeObjects.find(o => o.userData.assetId === asset.id);
768
+ if (!root) return;
769
+ const atMax = cfg?.maxPos !== undefined ? Math.abs(d.currentPosition - cfg.maxPos) < 0.1 : false;
770
+ const atMin = cfg?.minPos !== undefined ? Math.abs(d.currentPosition - cfg.minPos) < 0.1 : false;
771
+ if (atMax && !d._evtWasAtMax) this._executeNodeEvent(root, asset, `driveAtMax:${i}`);
772
+ if (atMin && !d._evtWasAtMin) this._executeNodeEvent(root, asset, `driveAtMin:${i}`);
773
+ d._evtWasAtMax = atMax;
774
+ d._evtWasAtMin = atMin;
775
+ };
776
+ }
777
+ }
778
+
532
779
  _extractNamedNodes(obj, depth = 0, result = []) {
533
780
  if (depth > 7) return result;
534
781
  if (depth > 0 && obj.name) {
@@ -546,11 +793,152 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
546
793
  return result;
547
794
  }
548
795
 
796
+ // ── Bottom Script/Style panel ─────────────────────────
797
+
798
+ _setupBottomPanel() {
799
+ const root = this._getDomElement('root');
800
+ const panel = this._getDomElement('bottomPanel');
801
+ const handle = this._getDomElement('bottomHandle');
802
+ const scriptTA = this._getDomElement('scriptEditor');
803
+ const styleTA = this._getDomElement('styleEditor');
804
+
805
+ // Populate from sceneData
806
+ if (scriptTA && this.sceneData?.script) scriptTA.value = this.sceneData.script;
807
+ if (styleTA && this.sceneData?.style) styleTA.value = this.sceneData.style;
808
+
809
+ // Set initial active editor
810
+ if (scriptTA) scriptTA.classList.add('active');
811
+
812
+ // Tab switching
813
+ ['btTabScript','btTabStyle'].forEach(id => {
814
+ const btn = this._getDomElement(id);
815
+ if (!btn) return;
816
+ btn.addEventListener('click', () => {
817
+ this.shadowRoot.querySelectorAll('.bt-tab').forEach(b => b.classList.remove('active'));
818
+ btn.classList.add('active');
819
+ const tab = btn.dataset.btab;
820
+ if (scriptTA) scriptTA.classList.toggle('active', tab === 'script');
821
+ if (styleTA) styleTA.classList.toggle('active', tab === 'style');
822
+ });
823
+ });
824
+
825
+ // Run button
826
+ this._getDomElement('runScriptBtn')?.addEventListener('click', () => this._runUserScript());
827
+
828
+ // Ctrl+Enter to run while editing
829
+ scriptTA?.addEventListener('keydown', (e) => {
830
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); this._runUserScript(); }
831
+ });
832
+
833
+ // Auto-save script/style to sceneData on change
834
+ scriptTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.script = scriptTA.value; });
835
+ styleTA?.addEventListener('input', () => { if (this.sceneData) this.sceneData.style = styleTA.value; this._applyUserStyle(); });
836
+
837
+ // Draggable resize handle
838
+ let dragStart = null;
839
+ let startH = 0;
840
+ handle?.addEventListener('mousedown', (e) => {
841
+ dragStart = e.clientY;
842
+ startH = panel ? panel.offsetHeight : 0;
843
+ // If panel collapsed, open it
844
+ if (startH < 20) startH = 0;
845
+ document.addEventListener('mousemove', onDrag);
846
+ document.addEventListener('mouseup', onUp);
847
+ });
848
+ const onDrag = (e) => {
849
+ if (dragStart === null) return;
850
+ const delta = dragStart - e.clientY;
851
+ const h = Math.max(0, Math.min(startH + delta, 500));
852
+ if (panel) panel.style.height = h + 'px';
853
+ };
854
+ const onUp = () => { dragStart = null; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', onUp); };
855
+
856
+ // Click handle to toggle
857
+ handle?.addEventListener('click', (e) => {
858
+ if (!panel) return;
859
+ const h = panel.offsetHeight;
860
+ panel.style.height = h < 20 ? '200px' : '0';
861
+ });
862
+ }
863
+
864
+ _applyUserStyle() {
865
+ const styleTA = this._getDomElement('styleEditor');
866
+ const css = styleTA?.value || this.sceneData?.style || '';
867
+ let styleEl = this.shadowRoot.getElementById('userStyle3d');
868
+ if (!styleEl) {
869
+ styleEl = document.createElement('style');
870
+ styleEl.id = 'userStyle3d';
871
+ this.shadowRoot.appendChild(styleEl);
872
+ }
873
+ styleEl.textContent = css;
874
+ }
875
+
876
+ _runUserScript() {
877
+ // Clear previous script state
878
+ for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
879
+ this._scriptUnsubs = [];
880
+ this._scriptFrameCallbacks = [];
881
+ this._scriptSelectCallbacks = [];
882
+
883
+ const scriptTA = this._getDomElement('scriptEditor');
884
+ const errBtn = this._getDomElement('scriptErrBtn');
885
+ const script = scriptTA?.value || this.sceneData?.script || '';
886
+ if (!script.trim()) return;
887
+
888
+ // Build assets map: name/id → Three.js Object3D
889
+ const assets = new Map();
890
+ for (const obj of this.scene.children) {
891
+ if (obj.userData.assetData) {
892
+ assets.set(obj.userData.assetData.name, obj);
893
+ assets.set(obj.userData.assetData.id, obj);
894
+ }
895
+ }
896
+
897
+ const self = this;
898
+ const ctx = {
899
+ THREE: this.THREE,
900
+ scene: this.scene,
901
+ camera: this.camera,
902
+ renderer: this.renderer,
903
+ controls: this.controls,
904
+ assets,
905
+ mixers: this._mixers,
906
+ driveEngine: this.driveEngine,
907
+ subscribe(signal, cb) {
908
+ const unsub = self.driveEngine.bus.subscribe(signal, cb);
909
+ self._scriptUnsubs.push(unsub);
910
+ return unsub;
911
+ },
912
+ setState(signal, val) { iobrokerHandler.setState(signal, val); },
913
+ getState(signal) { return self.driveEngine.bus.get(signal); },
914
+ onFrame(fn) { self._scriptFrameCallbacks.push(fn); },
915
+ onSelect(fn) { self._scriptSelectCallbacks.push(fn); },
916
+ log(...args) { console.log('[3D Script]', ...args); },
917
+ };
918
+
919
+ if (errBtn) errBtn.style.display = 'none';
920
+ try {
921
+ const fn = new Function(...Object.keys(ctx), script);
922
+ fn(...Object.values(ctx));
923
+ } catch (err) {
924
+ console.error('[3D Script] Error:', err);
925
+ if (errBtn) { errBtn.style.display = ''; errBtn.textContent = '⚠ ' + err.message; }
926
+ }
927
+ }
928
+
549
929
  // ── Scene tree (left panel) ───────────────────────────
550
930
 
551
931
  refreshTree() {
552
932
  const el = this._getDomElement('treeContent');
553
- if (!el || !this.sceneData) return;
933
+ if (!el) return;
934
+
935
+ // Library tab - show reusable 3D components
936
+ if (this._activeTab === 'library') {
937
+ this._renderLibraryTab(el);
938
+ return;
939
+ }
940
+
941
+ if (!this.sceneData) return;
554
942
  const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
555
943
  el.innerHTML = '';
556
944
 
@@ -714,42 +1102,80 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
714
1102
  }
715
1103
  }
716
1104
 
717
- // ── Library (right panel) ─────────────────────────────
1105
+ // ── Library (right panel - kept for backward compat) ───
718
1106
 
719
1107
  refreshLibrary() {
720
1108
  const el = this._getDomElement('libraryGrid');
721
1109
  if (!el || !this.sceneData) return;
722
- const search = (this._getDomElement('libSearch')?.value ?? '').toLowerCase();
723
- const assets = (Array.isArray(this.sceneData.assets) ? this.sceneData.assets : [])
724
- .filter(a => !search || a.name.toLowerCase().includes(search));
1110
+ // Library now lives primarily in left panel tab
1111
+ // Right panel libraryGrid kept but hidden
1112
+ }
725
1113
 
726
- if (assets.length === 0) {
727
- el.innerHTML = `<div style="color:#555;text-align:center;padding:20px 8px;font-size:11px;line-height:1.6;">
728
- No models<br><br>Click <b style="color:#9cdcfe">+ Add</b> to load<br>a GLB/GLTF file
1114
+ // ── Library tab in left panel ─────────────────────────
1115
+
1116
+ _renderLibraryTab(el) {
1117
+ el.innerHTML = '';
1118
+ const search = (this._getDomElement('treeSearch')?.value ?? '').toLowerCase();
1119
+ const assets = Array.isArray(this.sceneData?.assets) ? this.sceneData.assets : [];
1120
+ const filtered = assets.filter(a => !search || a.name.toLowerCase().includes(search));
1121
+
1122
+ // Show/hide add button
1123
+ const addRow = this._getDomElement('libAddRow');
1124
+ if (addRow) addRow.style.display = '';
1125
+
1126
+ if (filtered.length === 0) {
1127
+ el.innerHTML = `<div style="color:#555;padding:16px 8px;font-size:11px;text-align:center;line-height:1.8;">
1128
+ No 3D components yet.<br>
1129
+ <span style="color:#9cdcfe;">Import GLB</span> to create<br>a reusable component.
729
1130
  </div>`;
730
1131
  return;
731
1132
  }
732
1133
 
733
- el.style.display = 'grid';
734
- el.style.gridTemplateColumns = '1fr 1fr';
735
- el.style.gap = '6px';
736
- el.innerHTML = '';
737
-
738
- for (const asset of assets) {
739
- const card = document.createElement('div');
740
- card.className = 'lib-card';
1134
+ for (const asset of filtered) {
741
1135
  const shortName = asset.name.replace(/\.(glb|gltf)$/i, '');
742
- card.innerHTML = `
743
- <div class="lib-thumb">📦</div>
744
- <div class="lib-name">${shortName.substring(0, 20)}</div>
745
- <div class="lib-type">GLB</div>
1136
+ const driveCnt = asset.drives?.length ?? 0;
1137
+ const clipCnt = asset.availableClips?.length ?? 0;
1138
+ const interCnt = Object.keys(asset.interactions ?? {}).length;
1139
+ const meta = [
1140
+ driveCnt ? `${driveCnt}D` : '',
1141
+ clipCnt ? `${clipCnt}A` : '',
1142
+ interCnt ? `${interCnt}I` : '',
1143
+ ].filter(Boolean).join(' · ') || 'GLB';
1144
+
1145
+ const item = document.createElement('div');
1146
+ item.className = 'lib-item';
1147
+ item.innerHTML = `
1148
+ <span class="lib-icon">📦</span>
1149
+ <div class="lib-info">
1150
+ <div class="lib-name" title="${asset.name}">${shortName}</div>
1151
+ <div class="lib-meta">${meta}</div>
1152
+ </div>
1153
+ <span class="lib-add" title="Add instance to scene">+</span>
746
1154
  `;
747
- card.title = asset.name;
748
- card.addEventListener('click', () => this._selectAsset(asset));
749
- el.appendChild(card);
1155
+ item.querySelector('.lib-add')?.addEventListener('click', (e) => {
1156
+ e.stopPropagation();
1157
+ this._addAssetInstance(asset);
1158
+ });
1159
+ item.addEventListener('click', () => this._selectAsset(asset));
1160
+ el.appendChild(item);
750
1161
  }
751
1162
  }
752
1163
 
1164
+ _addAssetInstance(templateAsset) {
1165
+ // Duplicate the asset as a new instance in the scene
1166
+ const newId = 'asset_' + Date.now().toString(36);
1167
+ const instance = JSON.parse(JSON.stringify(templateAsset));
1168
+ instance.id = newId;
1169
+ instance.name = templateAsset.name + '_' + newId.slice(-4);
1170
+ instance.position = { x: Math.random() * 2 - 1, y: 0, z: Math.random() * 2 - 1 };
1171
+ if (!this.sceneData.assets) this.sceneData.assets = [];
1172
+ this.sceneData.assets.push(instance);
1173
+ this.loadAsset(instance);
1174
+ this.refreshTree();
1175
+ this._setStatus('Instance added: ' + instance.name);
1176
+ setTimeout(() => this._setStatus(''), 2000);
1177
+ }
1178
+
753
1179
  // ── File input ────────────────────────────────────────
754
1180
 
755
1181
  onFileSelected(event) {
@@ -839,19 +1265,25 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
839
1265
  this._getDomElement('gridToggle').addEventListener('click', () => this.toggleGrid());
840
1266
  this._getDomElement('axesToggle').addEventListener('click', () => this.toggleAxes());
841
1267
 
842
- // Tree tabs
843
- ['tabAll','tabAssets','tabLights','tabSignals'].forEach(id => {
844
- this._getDomElement(id).addEventListener('click', (e) => {
1268
+ // Tree tabs (including Library)
1269
+ ['tabAll','tabAssets','tabLights','tabSignals','tabLibrary'].forEach(id => {
1270
+ const el = this._getDomElement(id);
1271
+ if (!el) return;
1272
+ el.addEventListener('click', (e) => {
845
1273
  this._activeTab = e.target.dataset.tab;
846
1274
  this.shadowRoot.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
847
1275
  e.target.classList.add('active');
1276
+ const addRow = this._getDomElement('libAddRow');
1277
+ if (addRow) addRow.style.display = this._activeTab === 'library' ? '' : 'none';
848
1278
  this.refreshTree();
849
1279
  });
850
1280
  });
851
1281
 
1282
+ // Library add button
1283
+ this._getDomElement('addLibraryBtn')?.addEventListener('click', () => this._getDomElement('fileInput').click());
1284
+
852
1285
  // Search
853
- this._getDomElement('treeSearch').addEventListener('input', () => this.refreshTree());
854
- this._getDomElement('libSearch').addEventListener('input', () => this.refreshLibrary());
1286
+ this._getDomElement('treeSearch')?.addEventListener('input', () => this.refreshTree());
855
1287
  }
856
1288
 
857
1289
  disconnectedCallback() {
@@ -859,6 +1291,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
859
1291
  if (this.driveEngine) this.driveEngine.destroy();
860
1292
  for (const { mixer } of this._mixers.values()) mixer.stopAllAction();
861
1293
  this._mixers.clear();
1294
+ for (const unsub of this._scriptUnsubs) try { unsub(); } catch (_) {}
1295
+ this._scriptUnsubs = [];
862
1296
  if (this.renderer) {
863
1297
  this.renderer.dispose();
864
1298
  this.renderer.domElement?.remove();
@@ -1710,7 +1710,7 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1710
1710
  : `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No drives configured</div>`;
1711
1711
 
1712
1712
  // ── Interaction binding ──
1713
- const nodeKey = nodeName;
1713
+ const nodeKey = (node?.userData?.assetData === asset || !node?.name) ? '__root__' : (node?.name || nodeName);
1714
1714
  const inter = asset?.interactions?.[nodeKey];
1715
1715
  const interHtml = inter
1716
1716
  ? `<div style="background:#1a2a1a;border:1px solid #2a4a2a;border-radius:3px;padding:6px;font-size:10px;">
@@ -1721,6 +1721,50 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1721
1721
  </div>`
1722
1722
  : `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No interaction</div>`;
1723
1723
 
1724
+ // ── Events ──
1725
+ const nodeEvents = asset?.events?.[nodeKey] ?? {};
1726
+ const nodeEventEntries = Object.entries(nodeEvents);
1727
+ // Build list of available preset events for "Add" dropdown
1728
+ const presetEvents = [
1729
+ { key: 'click', label: '🖱️ click' },
1730
+ { key: 'mouseenter', label: '↗️ mouseenter' },
1731
+ { key: 'mouseleave', label: '↙️ mouseleave' },
1732
+ { key: 'animationEnd', label: '🎬 animationEnd (any clip)' },
1733
+ ...( (asset?.availableClips ?? []).map(c => ({ key: `animationEnd:${c}`, label: `🎬 animationEnd:${c}` })) ),
1734
+ ...( (asset?.drives ?? []).map((_, i) => ([
1735
+ { key: `driveAtMax:${i}`, label: `⬆️ driveAtMax:${i}` },
1736
+ { key: `driveAtMin:${i}`, label: `⬇️ driveAtMin:${i}` },
1737
+ ])).flat() ),
1738
+ ].filter(p => !nodeEvents[p.key]); // hide already-configured ones
1739
+
1740
+ const presetOptsHtml = presetEvents.map(p => `<option value="${p.key}">${p.label}</option>`).join('');
1741
+
1742
+ const eventsHtml = nodeEventEntries.length > 0
1743
+ ? nodeEventEntries.map(([evtKey, script]) => `
1744
+ <div class="evt-row" style="background:#1a1a2e;border:1px solid #2a2a4a;border-radius:3px;padding:5px;margin-bottom:4px;">
1745
+ <div style="display:flex;align-items:center;gap:4px;margin-bottom:3px;">
1746
+ <span style="color:#dcdcaa;font-size:10px;flex:1;font-family:monospace;">${evtKey}</span>
1747
+ <button class="evt-del-btn tb-btn" data-evtkey="${evtKey}" style="padding:0px 5px;font-size:9px;background:#5a1a1a;color:#f44747;border-color:#8a2a2a;">✕</button>
1748
+ </div>
1749
+ <textarea class="evt-script" data-evtkey="${evtKey}" rows="3"
1750
+ style="width:100%;box-sizing:border-box;background:#0d0d1a;border:1px solid #3c3c5c;color:#d4d4d4;font-family:'Consolas',monospace;font-size:10px;padding:4px;resize:vertical;line-height:1.4;"
1751
+ spellcheck="false"
1752
+ placeholder="// context: node, assetData, THREE, scene, camera&#10;// setState(signal, val), getState(signal), subscribe(signal, cb)&#10;// assets Map, mixers Map, driveEngine"
1753
+ >${this._escHtml(script)}</textarea>
1754
+ </div>`).join('')
1755
+ : `<div style="color:#555;font-size:10px;font-style:italic;padding:4px 0;">No events configured</div>`;
1756
+
1757
+ const eventsAddHtml = presetEvents.length > 0
1758
+ ? `<div style="display:flex;gap:4px;margin-top:4px;">
1759
+ <select class="evt-preset-sel" style="flex:1;background:#3c3c3c;border:1px solid #555;color:#ccc;padding:2px 4px;border-radius:3px;font-size:10px;">
1760
+ <option value="">— select event type —</option>
1761
+ ${presetOptsHtml}
1762
+ <option value="__custom__">Custom event name...</option>
1763
+ </select>
1764
+ <button class="evt-add-btn tb-btn" style="padding:1px 10px;font-size:10px;">+ Add</button>
1765
+ </div>`
1766
+ : `<div style="color:#555;font-size:9px;margin-top:4px;">All event types configured</div>`;
1767
+
1724
1768
  panel.innerHTML = `
1725
1769
  <style>
1726
1770
  .pg-group{margin-bottom:8px;}
@@ -1771,6 +1815,19 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1771
1815
  <div class="pg-header">🖱️ Interaction (onClick) <button class="inter-add-btn tb-btn" style="padding:1px 8px;font-size:9px;">+ Set</button></div>
1772
1816
  ${interHtml}
1773
1817
  </div>
1818
+ <div class="pg-group">
1819
+ <div class="pg-header">⚡ Events
1820
+ <span style="color:#555;font-size:9px;font-weight:normal;">node: ${nodeKey}</span>
1821
+ </div>
1822
+ ${eventsHtml}
1823
+ ${eventsAddHtml}
1824
+ <div style="color:#555;font-size:9px;margin-top:6px;line-height:1.5;">
1825
+ Script context:<br>
1826
+ <code style="color:#888;">node, assetData, THREE, scene</code><br>
1827
+ <code style="color:#888;">setState(sig,val) · getState(sig)</code><br>
1828
+ <code style="color:#888;">assets(Map) · mixers · driveEngine</code>
1829
+ </div>
1830
+ </div>
1774
1831
  </div>
1775
1832
  `;
1776
1833
 
@@ -1855,6 +1912,53 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1855
1912
  this.show3DNodeProperties(node, asset, mixerInfo, onChange);
1856
1913
  }
1857
1914
  });
1915
+
1916
+ // ── Events wiring ──
1917
+ // Script textarea auto-save on blur
1918
+ p.querySelectorAll('.evt-script').forEach(ta => {
1919
+ ta.addEventListener('blur', () => {
1920
+ const evtKey = ta.dataset.evtkey;
1921
+ if (!asset.events) asset.events = {};
1922
+ if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
1923
+ asset.events[nodeKey][evtKey] = ta.value;
1924
+ if (onChange) onChange(asset);
1925
+ });
1926
+ });
1927
+
1928
+ // Delete event
1929
+ p.querySelectorAll('.evt-del-btn').forEach(btn => {
1930
+ btn.addEventListener('click', () => {
1931
+ const evtKey = btn.dataset.evtkey;
1932
+ if (asset?.events?.[nodeKey]) {
1933
+ delete asset.events[nodeKey][evtKey];
1934
+ if (Object.keys(asset.events[nodeKey]).length === 0) delete asset.events[nodeKey];
1935
+ if (onChange) onChange(asset);
1936
+ this.show3DNodeProperties(node, asset, mixerInfo, onChange);
1937
+ }
1938
+ });
1939
+ });
1940
+
1941
+ // Add event from preset dropdown
1942
+ p.querySelector('.evt-add-btn')?.addEventListener('click', () => {
1943
+ const sel = p.querySelector('.evt-preset-sel');
1944
+ let evtKey = sel?.value;
1945
+ if (!evtKey) return;
1946
+ if (evtKey === '__custom__') {
1947
+ evtKey = prompt('Custom event name:', 'signalChange:mywebui.0.signal.path');
1948
+ if (!evtKey) return;
1949
+ }
1950
+ if (!asset.events) asset.events = {};
1951
+ if (!asset.events[nodeKey]) asset.events[nodeKey] = {};
1952
+ if (!asset.events[nodeKey][evtKey]) {
1953
+ asset.events[nodeKey][evtKey] = '// event: ' + evtKey + '\n';
1954
+ }
1955
+ if (onChange) onChange(asset);
1956
+ this.show3DNodeProperties(node, asset, mixerInfo, onChange);
1957
+ });
1958
+ }
1959
+
1960
+ _escHtml(str) {
1961
+ return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1858
1962
  }
1859
1963
 
1860
1964
  _showAddDriveDialog(obj, onChange) {