iobroker.mywebui 1.42.17 → 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
package/package.json
CHANGED
|
@@ -364,6 +364,7 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
364
364
|
this._stopAnimation = () => { animating = false; };
|
|
365
365
|
|
|
366
366
|
this.renderer.domElement.addEventListener('click', (e) => this.onMouseClick(e));
|
|
367
|
+
this._setupHoverDetection();
|
|
367
368
|
const resizeObs = new ResizeObserver(() => this.onViewportResize());
|
|
368
369
|
resizeObs.observe(viewport);
|
|
369
370
|
}
|
|
@@ -447,6 +448,11 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
447
448
|
this._threeObjects.push(model);
|
|
448
449
|
if (this.driveEngine && Array.isArray(this.sceneData?.assets)) {
|
|
449
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
|
+
}
|
|
450
456
|
}
|
|
451
457
|
// AnimationMixer for built-in GLB animations
|
|
452
458
|
if (gltf.animations?.length > 0) {
|
|
@@ -457,6 +463,12 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
457
463
|
}
|
|
458
464
|
this._mixers.set(asset.id, { mixer, clips: gltf.animations, actions });
|
|
459
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
|
+
});
|
|
460
472
|
}
|
|
461
473
|
// Extract named node list for scene tree
|
|
462
474
|
asset._nodeList = this._extractNamedNodes(model);
|
|
@@ -512,6 +524,8 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
512
524
|
|
|
513
525
|
if (hitNode && hitAssetRoot) {
|
|
514
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');
|
|
515
529
|
this.selectNode(hitNode, hitAssetRoot);
|
|
516
530
|
} else {
|
|
517
531
|
this.deselectAll();
|
|
@@ -637,6 +651,131 @@ export class IobrokerWebui3DScreenEditor extends BaseCustomWebComponentConstruct
|
|
|
637
651
|
}
|
|
638
652
|
}
|
|
639
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
|
+
|
|
640
779
|
_extractNamedNodes(obj, depth = 0, result = []) {
|
|
641
780
|
if (depth > 7) return result;
|
|
642
781
|
if (depth > 0 && obj.name) {
|
|
@@ -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 // setState(signal, val), getState(signal), subscribe(signal, cb) // 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
1858
1962
|
}
|
|
1859
1963
|
|
|
1860
1964
|
_showAddDriveDialog(obj, onChange) {
|