iobroker.mywebui 1.37.74 → 1.37.76

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.37.74",
4
+ "version": "1.37.76",
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.37.74",
3
+ "version": "1.37.76",
4
4
  "description": "ioBroker mywebui - Custom edited mywebui by gokturk413",
5
5
  "type": "module",
6
6
  "main": "dist/backend/main.js",
@@ -74,20 +74,20 @@
74
74
  "@alcalzone/release-script-plugin-iobroker": "^4.0.0",
75
75
  "@alcalzone/release-script-plugin-license": "^4.0.0",
76
76
  "@blockly/zoom-to-fit": "^7.0.3",
77
- "@iobroker/socket-client": "^5.0.2",
78
- "@iobroker/testing": "^5.1.1",
79
- "@iobroker/webcomponent-selectid-dialog": "^1.0.12",
80
77
  "@gokturk413/base-custom-webcomponent": "*",
81
- "@node-projects/lean-he-esm": "^3.4.1",
82
- "@node-projects/node-html-parser-esm": "^6.4.1",
83
78
  "@gokturk413/propertygrid.webcomponent": "*",
84
79
  "@gokturk413/splitview.webcomponent": "^1.0.1",
85
80
  "@gokturk413/web-component-designer": "*",
86
81
  "@gokturk413/web-component-designer-codeview-monaco": "*",
87
82
  "@gokturk413/web-component-designer-htmlparserservice-nodehtmlparser": "*",
88
- "@node-projects/web-component-designer-stylesheetservice-css-tools": "^0.1.11",
89
83
  "@gokturk413/web-component-designer-visualization-addons": "*",
90
84
  "@gokturk413/web-component-designer-widgets-wunderbaum": "*",
85
+ "@iobroker/socket-client": "^5.0.2",
86
+ "@iobroker/testing": "^5.1.1",
87
+ "@iobroker/webcomponent-selectid-dialog": "^1.0.12",
88
+ "@node-projects/lean-he-esm": "^3.4.1",
89
+ "@node-projects/node-html-parser-esm": "^6.4.1",
90
+ "@node-projects/web-component-designer-stylesheetservice-css-tools": "^0.1.11",
91
91
  "@types/json-schema": "^7.0.15",
92
92
  "@web/dev-server": "^0.4.6",
93
93
  "blockly": "^12.3.1",
@@ -107,10 +107,12 @@
107
107
  "gulp-replace": "^1.1.4",
108
108
  "javascript-obfuscator": "^5.3.0",
109
109
  "long": "^5.3.2",
110
+ "marked": "^18.0.3",
110
111
  "mobile-drag-drop": "^3.0.0-rc.0",
111
112
  "mocha": "^11.7.4",
112
113
  "monaco-editor": "^0.50.0",
113
114
  "nyc": "^17.1.0",
115
+ "puppeteer": "^24.42.0",
114
116
  "sinon-chai": "^4.0.1",
115
117
  "toastify-js": "^1.12.0",
116
118
  "ts-node": "^10.9.2",
@@ -1115,154 +1115,192 @@ export class IobrokerWebuiAppShell extends BaseCustomWebComponentConstructorAppe
1115
1115
  const designItem = items[0];
1116
1116
  const element = designItem.element;
1117
1117
 
1118
- let cfg = {};
1119
- try { cfg = JSON.parse(element.getAttribute('data-effects') || '{}'); } catch (e) {}
1120
-
1121
- const collectEffectsCfg = () => {
1122
- const out = {};
1123
- if (typeSel.value) out.type = typeSel.value;
1124
- out.trigger = triggerSel.value || 'load';
1125
- out.duration = parseFloat(durationInp.value) || 0.5;
1126
- if (parseFloat(delayInp.value)) out.delay = parseFloat(delayInp.value);
1127
- if (parseInt(repeatInp.value)) out.repeat = parseInt(repeatInp.value);
1128
- out.ease = easeSel.value || 'power2.out';
1129
- if (triggerSel.value === 'oid') {
1130
- out.condition = condSel.value;
1131
- out.conditionValue = condValInp.value;
1132
- }
1133
- if (typeSel.value === 'glow') {
1134
- out.glowColor = glowColorInp.value;
1135
- out.glowSize = parseInt(glowSizeInp.value) || 10;
1136
- }
1137
- if (typeSel.value === 'blur') out.blurAmount = parseInt(blurInp.value) || 5;
1138
- // Preserve all _bind properties
1139
- for (const [k, v] of Object.entries(cfg)) {
1140
- if (k.endsWith('_bind') && v) out[k] = v;
1141
- }
1142
- return out;
1118
+ let raw = null;
1119
+ try { raw = JSON.parse(element.getAttribute('data-effects') || 'null'); } catch (e) {}
1120
+ let cfgList = Array.isArray(raw) ? raw : (raw ? [raw] : []);
1121
+
1122
+ content.innerHTML = '';
1123
+
1124
+ const saveAll = () => {
1125
+ const data = cfgList.filter(c => c._collect).map(c => c._collect()).filter(c => c.type);
1126
+ if (data.length === 0) designItem.removeAttribute('data-effects');
1127
+ else if (data.length === 1) designItem.setAttribute('data-effects', JSON.stringify(data[0]));
1128
+ else designItem.setAttribute('data-effects', JSON.stringify(data));
1143
1129
  };
1130
+ const saveAndRefresh = () => { saveAll(); this._updateEffectsPanel(); };
1144
1131
 
1145
- const save = () => {
1146
- const c = collectEffectsCfg();
1147
- if (!c.type) designItem.removeAttribute('data-effects');
1148
- else designItem.setAttribute('data-effects', JSON.stringify(c));
1132
+ // ── Add Effect button ─────────────────────────────────────────────────
1133
+ const addBtn = document.createElement('button');
1134
+ addBtn.textContent = '+ Add Effect';
1135
+ addBtn.style.cssText = 'width:100%;font-size:11px;padding:4px;cursor:pointer;border:1px solid #4caf50;border-radius:3px;background:#e8f5e9;color:#2e7d32;margin-bottom:8px;';
1136
+ addBtn.onclick = () => {
1137
+ const existing = cfgList.filter(c => c._collect).map(c => c._collect()).filter(c => c.type);
1138
+ existing.push({ type: 'fadeIn', trigger: 'load', duration: 0.5, ease: 'power2.out' });
1139
+ const val = existing.length === 1 ? existing[0] : existing;
1140
+ designItem.setAttribute('data-effects', JSON.stringify(val));
1141
+ this._updateEffectsPanel();
1149
1142
  };
1143
+ content.appendChild(addBtn);
1150
1144
 
1151
- const saveAndRefresh = () => { save(); this._updateEffectsPanel(); };
1145
+ if (cfgList.length === 0) {
1146
+ const ph = document.createElement('p');
1147
+ ph.style.cssText = 'color:#999;font-style:italic;font-size:11px;text-align:center;margin-top:12px;';
1148
+ ph.textContent = 'No effects. Click "+ Add Effect".';
1149
+ content.appendChild(ph);
1150
+ return;
1151
+ }
1152
1152
 
1153
- const field = (label, propKey, inputEl) => {
1154
- const row = document.createElement('div');
1155
- row.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:6px;';
1156
- if (propKey) {
1157
- row.appendChild(this._makeBindSquare(propKey, cfg, designItem, 'data-effects', saveAndRefresh));
1158
- } else {
1159
- const sp = document.createElement('div');
1160
- sp.style.cssText = 'width:11px;min-width:11px;flex-shrink:0;';
1161
- row.appendChild(sp);
1162
- }
1163
- const lbl = document.createElement('span');
1164
- lbl.textContent = label;
1165
- lbl.style.cssText = 'min-width:78px;font-size:11px;color:#555;';
1166
- row.appendChild(lbl); row.appendChild(inputEl);
1167
- return row;
1168
- };
1153
+ // ── Build one collapsible block per effect ────────────────────────────
1154
+ const buildEffectBlock = (cfg, index) => {
1155
+ const wrapper = document.createElement('div');
1156
+ wrapper.style.cssText = 'border:1px solid #ddd;border-radius:4px;margin-bottom:6px;overflow:hidden;';
1157
+
1158
+ // Header
1159
+ const header = document.createElement('div');
1160
+ header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;background:#f0f4ff;padding:4px 6px;cursor:pointer;user-select:none;';
1161
+ const headerLeft = document.createElement('span');
1162
+ headerLeft.style.cssText = 'font-size:11px;font-weight:600;color:#1a237e;';
1163
+ headerLeft.textContent = `▶ #${index + 1} — ${cfg.type || 'none'} (${cfg.trigger || 'load'})`;
1164
+ const delBtn = document.createElement('button');
1165
+ delBtn.textContent = '';
1166
+ delBtn.title = 'Remove this effect';
1167
+ delBtn.style.cssText = 'font-size:10px;padding:1px 5px;cursor:pointer;border:1px solid #e57373;border-radius:3px;background:#ffebee;color:#c62828;';
1168
+ delBtn.onclick = (e) => {
1169
+ e.stopPropagation();
1170
+ const data = cfgList.filter(c => c._collect).map(c => c._collect()).filter(c => c.type);
1171
+ data.splice(index, 1);
1172
+ if (data.length === 0) designItem.removeAttribute('data-effects');
1173
+ else if (data.length === 1) designItem.setAttribute('data-effects', JSON.stringify(data[0]));
1174
+ else designItem.setAttribute('data-effects', JSON.stringify(data));
1175
+ this._updateEffectsPanel();
1176
+ };
1177
+ header.appendChild(headerLeft);
1178
+ header.appendChild(delBtn);
1169
1179
 
1170
- const inp = (val, type = 'text') => {
1171
- const i = document.createElement('input');
1172
- i.type = type; i.value = val ?? '';
1173
- i.style.cssText = 'flex:1;padding:3px 5px;font-size:11px;border:1px solid #ccc;border-radius:3px;';
1174
- i.onchange = save; return i;
1175
- };
1180
+ // Body (collapsed by default)
1181
+ const body = document.createElement('div');
1182
+ body.style.cssText = 'padding:8px;display:none;';
1183
+ header.onclick = () => {
1184
+ body.style.display = body.style.display === 'none' ? '' : 'none';
1185
+ headerLeft.textContent = (body.style.display === 'none' ? '▶' : '▼') + ` #${index + 1} — ${cfg.type || 'none'} (${cfg.trigger || 'load'})`;
1186
+ };
1176
1187
 
1177
- const sel = (options, val) => {
1178
- const s = document.createElement('select');
1179
- s.style.cssText = 'flex:1;padding:3px 5px;font-size:11px;border:1px solid #ccc;border-radius:3px;';
1180
- options.forEach(([v, t]) => {
1181
- const o = document.createElement('option');
1182
- o.value = v; o.textContent = t; o.selected = v === val;
1183
- s.appendChild(o);
1184
- });
1185
- s.onchange = save; return s;
1186
- };
1188
+ // ── helpers scoped to this block ──────────────────────────────────
1189
+ const makeBindSq = (propKey) => this._makeBindSquare(propKey, cfg, designItem, 'data-effects', saveAndRefresh);
1190
+
1191
+ const field = (label, propKey, inputEl) => {
1192
+ const row = document.createElement('div');
1193
+ row.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:6px;';
1194
+ row.appendChild(propKey ? makeBindSq(propKey) : (() => { const sp = document.createElement('div'); sp.style.cssText = 'width:11px;min-width:11px;flex-shrink:0;'; return sp; })());
1195
+ const lbl = document.createElement('span');
1196
+ lbl.textContent = label;
1197
+ lbl.style.cssText = 'min-width:78px;font-size:11px;color:#555;';
1198
+ row.appendChild(lbl); row.appendChild(inputEl);
1199
+ return row;
1200
+ };
1187
1201
 
1188
- const typeSel = sel([
1189
- ['','— none —'],['fadeIn','Fade In'],['fadeOut','Fade Out'],
1190
- ['slideInLeft','Slide In Left'],['slideInRight','Slide In Right'],
1191
- ['slideInTop','Slide In Top'],['slideInBottom','Slide In Bottom'],
1192
- ['bounce','Bounce'],['pulse','Pulse'],['shake','Shake'],
1193
- ['glow','Glow'],['blur','Blur'],['spin','Spin'],['flip','Flip 3D']
1194
- ], cfg.type || '');
1195
-
1196
- const triggerSel = sel([
1197
- ['load','On Load'],['hover','On Hover'],['click','On Click'],['oid','OID State']
1198
- ], cfg.trigger || 'load');
1199
-
1200
- const durationInp = inp(cfg.duration ?? 0.5, 'number');
1201
- const delayInp = inp(cfg.delay ?? 0, 'number');
1202
- const repeatInp = inp(cfg.repeat ?? 0, 'number');
1203
-
1204
- const easeSel = sel([
1205
- ['power2.out','power2.out'],['power2.in','power2.in'],['power2.inOut','power2.inOut'],
1206
- ['power1.inOut','power1.inOut'],['bounce.out','bounce.out'],
1207
- ['elastic.out(1,0.3)','elastic.out'],['back.inOut(1.7)','back.inOut'],['none','none']
1208
- ], cfg.ease || 'power2.out');
1209
-
1210
- const condSel = sel([['equal','='],['not_equal','≠'],['less_than','<'],['greater_than','>'],['exists','exists']], cfg.condition || 'equal');
1211
- const condValInp = inp(cfg.conditionValue ?? 'true');
1212
- const glowColorInp = inp(cfg.glowColor || 'yellow', 'color');
1213
- const glowSizeInp = inp(cfg.glowSize || 10, 'number');
1214
- const blurInp = inp(cfg.blurAmount || 5, 'number');
1215
-
1216
- // ── Build UI ──────────────────────────────────────────────────────────
1217
- content.innerHTML = '';
1202
+ const inp = (val, type = 'text') => {
1203
+ const i = document.createElement('input');
1204
+ i.type = type; i.value = val ?? '';
1205
+ i.style.cssText = 'flex:1;padding:3px 5px;font-size:11px;border:1px solid #ccc;border-radius:3px;';
1206
+ i.onchange = saveAll; return i;
1207
+ };
1208
+
1209
+ const sel = (options, val) => {
1210
+ const s = document.createElement('select');
1211
+ s.style.cssText = 'flex:1;padding:3px 5px;font-size:11px;border:1px solid #ccc;border-radius:3px;';
1212
+ options.forEach(([v, t]) => { const o = document.createElement('option'); o.value = v; o.textContent = t; o.selected = v === val; s.appendChild(o); });
1213
+ s.onchange = saveAll; return s;
1214
+ };
1215
+
1216
+ const typeSel = sel([
1217
+ ['','— none —'],['fadeIn','Fade In'],['fadeOut','Fade Out'],
1218
+ ['slideInLeft','Slide In Left'],['slideInRight','Slide In Right'],
1219
+ ['slideInTop','Slide In Top'],['slideInBottom','Slide In Bottom'],
1220
+ ['bounce','Bounce'],['pulse','Pulse'],['shake','Shake'],
1221
+ ['glow','Glow'],['blur','Blur'],['spin','Spin'],['flip','Flip 3D']
1222
+ ], cfg.type || '');
1223
+
1224
+ const triggerSel = sel([
1225
+ ['load','On Load'],['hover','On Hover'],['click','On Click'],['oid','OID State']
1226
+ ], cfg.trigger || 'load');
1227
+
1228
+ const durationInp = inp(cfg.duration ?? 0.5, 'number');
1229
+ const delayInp = inp(cfg.delay ?? 0, 'number');
1230
+ const repeatInp = inp(cfg.repeat ?? 0, 'number');
1231
+ const easeSel = sel([
1232
+ ['power2.out','power2.out'],['power2.in','power2.in'],['power2.inOut','power2.inOut'],
1233
+ ['power1.inOut','power1.inOut'],['bounce.out','bounce.out'],
1234
+ ['elastic.out(1,0.3)','elastic.out'],['back.inOut(1.7)','back.inOut'],['none','none']
1235
+ ], cfg.ease || 'power2.out');
1236
+
1237
+ const condSel = sel([['equal','='],['not_equal','≠'],['less_than','<'],['greater_than','>'],['exists','exists']], cfg.condition || 'equal');
1238
+ const condValInp = inp(cfg.conditionValue ?? 'true');
1239
+ const glowColorInp = inp(cfg.glowColor || 'yellow', 'color');
1240
+ const glowSizeInp = inp(cfg.glowSize || 10, 'number');
1241
+ const blurInp = inp(cfg.blurAmount || 5, 'number');
1242
+
1243
+ body.appendChild(field('Type', 'type', typeSel));
1244
+ body.appendChild(field('Trigger', null, triggerSel));
1245
+ body.appendChild(field('Duration (s)', 'duration', durationInp));
1246
+ body.appendChild(field('Delay (s)', 'delay', delayInp));
1247
+ body.appendChild(field('Repeat', 'repeat', repeatInp));
1248
+ body.appendChild(field('Ease', 'ease', easeSel));
1249
+
1250
+ // OID section
1251
+ const oidSection = document.createElement('div');
1252
+ oidSection.style.display = cfg.trigger === 'oid' ? '' : 'none';
1253
+ const oidLabel = document.createElement('div');
1254
+ oidLabel.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:#555;margin-bottom:3px;padding-left:4px;';
1255
+ oidLabel.appendChild(makeBindSq('oid'));
1256
+ const oidLabelText = document.createElement('span'); oidLabelText.textContent = 'OID'; oidLabel.appendChild(oidLabelText);
1257
+ const condRowDiv = document.createElement('div');
1258
+ condRowDiv.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:4px;padding-left:4px;';
1259
+ condRowDiv.appendChild(makeBindSq('condition')); condRowDiv.appendChild(condSel);
1260
+ const condValRowDiv = document.createElement('div');
1261
+ condValRowDiv.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:6px;padding-left:4px;';
1262
+ condValRowDiv.appendChild(makeBindSq('conditionValue')); condValRowDiv.appendChild(condValInp);
1263
+ oidSection.appendChild(oidLabel); oidSection.appendChild(condRowDiv); oidSection.appendChild(condValRowDiv);
1264
+ body.appendChild(oidSection);
1265
+ triggerSel.addEventListener('change', () => { oidSection.style.display = triggerSel.value === 'oid' ? '' : 'none'; });
1266
+
1267
+ // Glow section
1268
+ const glowSection = document.createElement('div');
1269
+ glowSection.style.display = cfg.type === 'glow' ? '' : 'none';
1270
+ glowSection.appendChild(field('Glow Color', 'glowColor', glowColorInp));
1271
+ glowSection.appendChild(field('Glow Size', 'glowSize', glowSizeInp));
1272
+ body.appendChild(glowSection);
1273
+ typeSel.addEventListener('change', () => { glowSection.style.display = typeSel.value === 'glow' ? '' : 'none'; });
1274
+
1275
+ // Blur section
1276
+ const blurSection = document.createElement('div');
1277
+ blurSection.style.display = cfg.type === 'blur' ? '' : 'none';
1278
+ blurSection.appendChild(field('Blur (px)', 'blurAmount', blurInp));
1279
+ body.appendChild(blurSection);
1280
+ typeSel.addEventListener('change', () => { blurSection.style.display = typeSel.value === 'blur' ? '' : 'none'; });
1281
+
1282
+ // _collect: read current UI values back into a plain object
1283
+ cfg._collect = () => {
1284
+ const out = {};
1285
+ if (typeSel.value) out.type = typeSel.value;
1286
+ out.trigger = triggerSel.value || 'load';
1287
+ out.duration = parseFloat(durationInp.value) || 0.5;
1288
+ if (parseFloat(delayInp.value)) out.delay = parseFloat(delayInp.value);
1289
+ if (parseInt(repeatInp.value)) out.repeat = parseInt(repeatInp.value);
1290
+ out.ease = easeSel.value || 'power2.out';
1291
+ if (triggerSel.value === 'oid') { out.condition = condSel.value; out.conditionValue = condValInp.value; }
1292
+ if (typeSel.value === 'glow') { out.glowColor = glowColorInp.value; out.glowSize = parseInt(glowSizeInp.value) || 10; }
1293
+ if (typeSel.value === 'blur') out.blurAmount = parseInt(blurInp.value) || 5;
1294
+ for (const [k, v] of Object.entries(cfg)) { if (k.endsWith('_bind') && v) out[k] = v; }
1295
+ return out;
1296
+ };
1297
+
1298
+ wrapper.appendChild(header);
1299
+ wrapper.appendChild(body);
1300
+ return wrapper;
1301
+ };
1218
1302
 
1219
- const clearBtn = document.createElement('button');
1220
- clearBtn.textContent = 'Clear';
1221
- clearBtn.style.cssText = 'float:right;font-size:10px;padding:2px 6px;cursor:pointer;border:1px solid #ccc;border-radius:3px;background:#fff;margin-bottom:8px;';
1222
- clearBtn.onclick = () => { designItem.removeAttribute('data-effects'); this._updateEffectsPanel(); };
1223
- content.appendChild(clearBtn);
1224
-
1225
- content.appendChild(field('Type', 'type', typeSel));
1226
- content.appendChild(field('Trigger', null, triggerSel));
1227
- content.appendChild(field('Duration (s)', 'duration', durationInp));
1228
- content.appendChild(field('Delay (s)', 'delay', delayInp));
1229
- content.appendChild(field('Repeat', 'repeat', repeatInp));
1230
- content.appendChild(field('Ease', 'ease', easeSel));
1231
-
1232
- const oidSection = document.createElement('div');
1233
- oidSection.style.display = cfg.trigger === 'oid' ? '' : 'none';
1234
- const oidLabel = document.createElement('div');
1235
- oidLabel.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:#555;margin-bottom:3px;padding-left:4px;';
1236
- oidLabel.appendChild(this._makeBindSquare('oid', cfg, designItem, 'data-effects', saveAndRefresh));
1237
- const oidLabelText = document.createElement('span');
1238
- oidLabelText.textContent = 'OID';
1239
- oidLabel.appendChild(oidLabelText);
1240
- const condRowDiv = document.createElement('div');
1241
- condRowDiv.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:4px;padding-left:4px;';
1242
- condRowDiv.appendChild(this._makeBindSquare('condition', cfg, designItem, 'data-effects', saveAndRefresh));
1243
- condRowDiv.appendChild(condSel);
1244
- const condValRowDiv = document.createElement('div');
1245
- condValRowDiv.style.cssText = 'display:flex;align-items:center;gap:4px;margin-bottom:6px;padding-left:4px;';
1246
- condValRowDiv.appendChild(this._makeBindSquare('conditionValue', cfg, designItem, 'data-effects', saveAndRefresh));
1247
- condValRowDiv.appendChild(condValInp);
1248
- oidSection.appendChild(oidLabel);
1249
- oidSection.appendChild(condRowDiv);
1250
- oidSection.appendChild(condValRowDiv);
1251
- content.appendChild(oidSection);
1252
- triggerSel.addEventListener('change', () => { oidSection.style.display = triggerSel.value === 'oid' ? '' : 'none'; });
1253
-
1254
- const glowSection = document.createElement('div');
1255
- glowSection.style.display = cfg.type === 'glow' ? '' : 'none';
1256
- glowSection.appendChild(field('Glow Color', 'glowColor', glowColorInp));
1257
- glowSection.appendChild(field('Glow Size', 'glowSize', glowSizeInp));
1258
- content.appendChild(glowSection);
1259
- typeSel.addEventListener('change', () => { glowSection.style.display = typeSel.value === 'glow' ? '' : 'none'; });
1260
-
1261
- const blurSection = document.createElement('div');
1262
- blurSection.style.display = cfg.type === 'blur' ? '' : 'none';
1263
- blurSection.appendChild(field('Blur (px)', 'blurAmount', blurInp));
1264
- content.appendChild(blurSection);
1265
- typeSel.addEventListener('change', () => { blurSection.style.display = typeSel.value === 'blur' ? '' : 'none'; });
1303
+ cfgList.forEach((cfg, i) => content.appendChild(buildEffectBlock(cfg, i)));
1266
1304
  }
1267
1305
 
1268
1306
  /* Move to a Dock Spawn Helper */
@@ -2,7 +2,6 @@ import { iobrokerHandler } from '../common/IobrokerHandler.js';
2
2
 
3
3
  let _gsapLoadPromise = null;
4
4
 
5
- // Resolve paths relative to this module's location (works regardless of adapter URL prefix)
6
5
  // AnimationService.js is at dist/frontend/runtime/ → ../../vendor/gsap/ = dist/vendor/gsap/
7
6
  const _gsapBase = new URL('../../vendor/gsap/', import.meta.url).href;
8
7
 
@@ -105,7 +104,6 @@ function buildTweenConfig(cfg, value) {
105
104
  if (cfg.valueFrom != null) config.startAt = { y: parseFloat(cfg.valueFrom) };
106
105
  break;
107
106
  case 'translate': {
108
- // valueTo / valueFrom: "x,y" or single number (used for x, y=0)
109
107
  const parseXY = (v, defVal) => {
110
108
  if (v == null) return { x: defVal, y: 0 };
111
109
  const s = String(v);
@@ -174,6 +172,157 @@ function buildTweenConfig(cfg, value) {
174
172
  return config;
175
173
  }
176
174
 
175
+ // ─── Binding resolver ──────────────────────────────────────────────────────────
176
+ // Handles all webui binding prefix types so animations/effects work in custom
177
+ // controls just like they do in screens.
178
+
179
+ function _cleanupSubs(subs) {
180
+ for (const sub of subs) {
181
+ try {
182
+ if (sub.type === 'event') {
183
+ sub.target.removeEventListener(sub.event, sub.handler);
184
+ } else if (sub.type === 'state') {
185
+ iobrokerHandler.connection.unsubscribeState(sub.oid, sub.handler);
186
+ } else if (sub.type === 'delegate') {
187
+ _cleanupSubs(sub.innerSubs);
188
+ }
189
+ } catch (e) {}
190
+ }
191
+ subs.length = 0;
192
+ }
193
+
194
+ /**
195
+ * Resolve a template OID like "webui.0.test.{webui.0.test.select}" by subscribing
196
+ * to each {stateId} placeholder and re-resolving when any placeholder changes.
197
+ */
198
+ async function _resolveTemplateBinding(template, element, onValue, subs) {
199
+ const placeholders = [];
200
+ const regex = /\{([^}]+)\}/g;
201
+ let m;
202
+ while ((m = regex.exec(template)) !== null) placeholders.push(m[1]);
203
+ if (placeholders.length === 0) return;
204
+
205
+ const values = {};
206
+ for (const p of placeholders) values[p] = null;
207
+
208
+ const innerSubs = [];
209
+
210
+ const rebuildAndSub = async () => {
211
+ if (Object.values(values).some(v => v == null)) return;
212
+ let resolved = template;
213
+ for (const [p, v] of Object.entries(values)) resolved = resolved.replace('{' + p + '}', v);
214
+ _cleanupSubs(innerSubs);
215
+ await resolveAnimBinding(resolved, element, onValue, innerSubs);
216
+ };
217
+
218
+ for (const placeholder of placeholders) {
219
+ const h = async (id, state) => {
220
+ if (state?.val != null) { values[placeholder] = String(state.val); await rebuildAndSub(); }
221
+ };
222
+ try { iobrokerHandler.connection.subscribeState(placeholder, h); subs.push({ type: 'state', oid: placeholder, handler: h }); } catch (e) {}
223
+ try { const s = await iobrokerHandler.connection.getState(placeholder); if (s?.val != null) values[placeholder] = String(s.val); } catch (e) {}
224
+ }
225
+
226
+ await rebuildAndSub();
227
+ subs.push({ type: 'delegate', innerSubs });
228
+ }
229
+
230
+ /**
231
+ * Subscribe to a signal/binding reference, calling onValue(value) for current and future values.
232
+ * Supports: {template} ??prop ?prop state:id object:id local_* .relative plain OID
233
+ *
234
+ * @param {string} signal - binding signal string from *_bind.signal
235
+ * @param {Element} element - DOM element carrying the animation (for host lookup)
236
+ * @param {Function} onValue - called with the resolved scalar value
237
+ * @param {Array} subs - array for cleanup descriptors (push here, pass to _cleanupSubs later)
238
+ */
239
+ async function resolveAnimBinding(signal, element, onValue, subs) {
240
+ if (!signal) return;
241
+
242
+ // Template OID: "prefix.{stateId}.suffix" — resolve placeholders dynamically
243
+ if (signal.includes('{') && signal.includes('}')) {
244
+ await _resolveTemplateBinding(signal, element, onValue, subs);
245
+ return;
246
+ }
247
+
248
+ // Find nearest custom-control host element (for ? and ?? bindings)
249
+ const getRoot = () => {
250
+ const rn = element.getRootNode();
251
+ return rn && rn !== document ? (rn.host ?? null) : null;
252
+ };
253
+
254
+ if (signal.startsWith('??')) {
255
+ // Direct property read: root[propName], re-fires on propName-changed event
256
+ const propName = signal.slice(2);
257
+ const root = getRoot();
258
+ if (!root) return;
259
+ const read = () => {
260
+ const v = root[propName] ?? root.getAttribute?.(propName);
261
+ if (v != null) onValue(v);
262
+ };
263
+ read();
264
+ const h = () => read();
265
+ root.addEventListener(propName + '-changed', h);
266
+ subs.push({ type: 'event', target: root, event: propName + '-changed', handler: h });
267
+
268
+ } else if (signal.startsWith('?')) {
269
+ // Indirect: root[propName] holds the actual OID; re-resolve when property changes
270
+ const propName = signal.slice(1);
271
+ const root = getRoot();
272
+ if (!root) return;
273
+ const innerSubs = [];
274
+ const resolveAndSub = async () => {
275
+ _cleanupSubs(innerSubs);
276
+ const actualOid = root[propName] ?? root.getAttribute?.(propName);
277
+ if (actualOid) await resolveAnimBinding(actualOid, element, onValue, innerSubs);
278
+ };
279
+ await resolveAndSub();
280
+ const h = () => resolveAndSub();
281
+ root.addEventListener(propName + '-changed', h);
282
+ subs.push({ type: 'event', target: root, event: propName + '-changed', handler: h });
283
+ subs.push({ type: 'delegate', innerSubs });
284
+
285
+ } else if (signal.startsWith('state:')) {
286
+ const oid = signal.slice(6);
287
+ const h = (id, state) => { if (state?.val != null) onValue(state.val); };
288
+ try { iobrokerHandler.connection.subscribeState(oid, h); subs.push({ type: 'state', oid, handler: h }); } catch (e) {}
289
+ try { const s = await iobrokerHandler.connection.getState(oid); if (s?.val != null) onValue(s.val); } catch (e) {}
290
+
291
+ } else if (signal.startsWith('object:')) {
292
+ // object: bindings rarely used for animation scalar params — skip
293
+
294
+ } else {
295
+ // Relative path (starts with '.'): prepend host's relativeSignalsPath
296
+ let oid = signal;
297
+ if (oid.startsWith('.')) {
298
+ const root = getRoot();
299
+ const relPath = root?._getRelativeSignalsPath?.() ?? '';
300
+ oid = relPath + oid.slice(1);
301
+ }
302
+ // local_* handled by iobrokerHandler.subscribeState; plain OID falls through to connection
303
+ const h = (id, state) => { if (state?.val != null) onValue(state.val); };
304
+ try {
305
+ if (oid.startsWith('local_')) {
306
+ iobrokerHandler.subscribeState(oid, h);
307
+ } else {
308
+ iobrokerHandler.connection.subscribeState(oid, h);
309
+ }
310
+ subs.push({ type: 'state', oid, handler: h });
311
+ } catch (e) {}
312
+ try {
313
+ let s;
314
+ if (oid.startsWith('local_')) {
315
+ s = await iobrokerHandler.getState(oid);
316
+ } else {
317
+ s = await iobrokerHandler.connection.getState(oid);
318
+ }
319
+ if (s?.val != null) onValue(s.val);
320
+ } catch (e) {}
321
+ }
322
+ }
323
+
324
+ // ─── AnimationInstance ─────────────────────────────────────────────────────────
325
+
177
326
  class AnimationInstance {
178
327
  constructor(element, cfg) {
179
328
  this.element = element;
@@ -191,39 +340,25 @@ class AnimationInstance {
191
340
  const ctrl = controls[key];
192
341
  if (!ctrl) return;
193
342
 
194
- // condition_bind: dynamically update which condition operator to use
343
+ // condition_bind: dynamically update the condition operator
195
344
  if (ctrl.condition_bind?.signal) {
196
- try {
197
- const s = await iobrokerHandler.connection.getState(ctrl.condition_bind.signal);
198
- if (s?.val != null) ctrl.condition = String(s.val);
199
- } catch (e) {}
200
- const h = (id, state) => { if (state?.val != null) ctrl.condition = String(state.val); };
201
- try { iobrokerHandler.connection.subscribeState(ctrl.condition_bind.signal, h); this._subs.push({ oid: ctrl.condition_bind.signal, handler: h }); } catch (e) {}
345
+ await resolveAnimBinding(ctrl.condition_bind.signal, this.element,
346
+ (val) => { ctrl.condition = String(val); }, this._subs);
202
347
  }
203
348
 
204
349
  // value_bind: dynamically update the trigger value
205
350
  if (ctrl.value_bind?.signal) {
206
- try {
207
- const s = await iobrokerHandler.connection.getState(ctrl.value_bind.signal);
208
- if (s?.val != null) ctrl.value = String(s.val);
209
- } catch (e) {}
210
- const h = (id, state) => { if (state?.val != null) ctrl.value = String(state.val); };
211
- try { iobrokerHandler.connection.subscribeState(ctrl.value_bind.signal, h); this._subs.push({ oid: ctrl.value_bind.signal, handler: h }); } catch (e) {}
351
+ await resolveAnimBinding(ctrl.value_bind.signal, this.element,
352
+ (val) => { ctrl.value = String(val); }, this._subs);
212
353
  }
213
354
 
214
355
  // oid_bind.signal IS the OID to watch (binding square directly holds the target OID)
215
- const oid = ctrl.oid || ctrl.oid_bind?.signal;
216
- if (!oid) return;
217
- const handler = (id, state) => {
218
- if (checkCond(state?.val, ctrl.condition || 'equal', ctrl.value ?? 'true')) action();
219
- };
220
- try {
221
- iobrokerHandler.connection.subscribeState(oid, handler);
222
- this._subs.push({ oid, handler });
223
- } catch (e) {}
224
- iobrokerHandler.connection.getState(oid).then(state => {
225
- if (state && checkCond(state.val, ctrl.condition || 'equal', ctrl.value ?? 'true')) action();
226
- }).catch(() => {});
356
+ const oidSignal = ctrl.oid_bind?.signal || ctrl.oid;
357
+ if (!oidSignal) return;
358
+
359
+ await resolveAnimBinding(oidSignal, this.element, (val) => {
360
+ if (checkCond(val, ctrl.condition || 'equal', ctrl.value ?? 'true')) action();
361
+ }, this._subs);
227
362
  };
228
363
 
229
364
  await bindControl('play', () => this._play());
@@ -243,14 +378,13 @@ class AnimationInstance {
243
378
  const bindCfg = this.cfg[prop + '_bind'];
244
379
  if (!bindCfg?.signal) continue;
245
380
  const propCapture = prop;
246
- const handler = (id, state) => {
247
- if (state?.val == null) return;
248
- this.cfg[propCapture] = state.val;
381
+ await resolveAnimBinding(bindCfg.signal, this.element, (val) => {
382
+ this.cfg[propCapture] = val;
249
383
  if (!this.tween) return;
250
384
  // Duration: adjust timeScale without restarting (smooth for repeat:-1)
251
385
  if (propCapture === 'duration' && this.tween.vars) {
252
386
  const origDur = parseFloat(this.tween.vars.duration) || 1;
253
- const newDur = parseFloat(state.val) || 1;
387
+ const newDur = parseFloat(val) || 1;
254
388
  this.tween.timeScale(origDur / newDur);
255
389
  return;
256
390
  }
@@ -259,15 +393,7 @@ class AnimationInstance {
259
393
  _restartTimer = setTimeout(() => {
260
394
  if (this.tween && !this.tween.paused()) this._play();
261
395
  }, 60);
262
- };
263
- try {
264
- iobrokerHandler.connection.subscribeState(bindCfg.signal, handler);
265
- this._subs.push({ oid: bindCfg.signal, handler });
266
- } catch (e) {}
267
- try {
268
- const state = await iobrokerHandler.connection.getState(bindCfg.signal);
269
- if (state?.val != null) this.cfg[prop] = state.val;
270
- } catch (e) {}
396
+ }, this._subs);
271
397
  }
272
398
  }
273
399
 
@@ -316,13 +442,12 @@ class AnimationInstance {
316
442
 
317
443
  destroy() {
318
444
  if (this.tween) { this.tween.kill(); this.tween = null; }
319
- for (const sub of this._subs) {
320
- try { iobrokerHandler.connection.unsubscribeState(sub.oid, sub.handler); } catch (e) {}
321
- }
322
- this._subs = [];
445
+ _cleanupSubs(this._subs);
323
446
  }
324
447
  }
325
448
 
449
+ // ─── Public API ────────────────────────────────────────────────────────────────
450
+
326
451
  const _activeAnimations = new WeakMap();
327
452
  const _activeEffects = new WeakMap();
328
453
 
@@ -364,11 +489,17 @@ export async function scanAndApplyEffects(root) {
364
489
  const elements = (root || document).querySelectorAll('[data-effects]');
365
490
  for (const el of elements) {
366
491
  try {
367
- const cfg = JSON.parse(el.getAttribute('data-effects'));
368
- const existing = _activeEffects.get(el);
369
- if (existing?.cleanup) existing.cleanup();
370
- const cleanup = await _applyEffect(el, cfg);
371
- _activeEffects.set(el, { cleanup });
492
+ const raw = JSON.parse(el.getAttribute('data-effects'));
493
+ const cfgList = Array.isArray(raw) ? raw : [raw];
494
+ const existing = _activeEffects.get(el) || [];
495
+ for (const inst of existing) if (inst?.cleanup) inst.cleanup();
496
+ const instances = [];
497
+ for (const cfg of cfgList) {
498
+ if (!cfg || typeof cfg !== 'object') continue;
499
+ const cleanup = await _applyEffect(el, cfg);
500
+ instances.push({ cleanup });
501
+ }
502
+ _activeEffects.set(el, instances);
372
503
  } catch (e) {
373
504
  console.warn('[AnimationService] data-effects parse error on element:', el, e);
374
505
  }
@@ -378,8 +509,9 @@ export async function scanAndApplyEffects(root) {
378
509
  export function cleanupEffects(root) {
379
510
  const elements = (root || document).querySelectorAll('[data-effects]');
380
511
  for (const el of elements) {
381
- const inst = _activeEffects.get(el);
382
- if (inst?.cleanup) { inst.cleanup(); _activeEffects.delete(el); }
512
+ const instances = _activeEffects.get(el) || [];
513
+ for (const inst of instances) if (inst?.cleanup) inst.cleanup();
514
+ _activeEffects.delete(el);
383
515
  }
384
516
  }
385
517
 
@@ -426,7 +558,8 @@ async function _applyEffect(el, cfg) {
426
558
  }
427
559
  };
428
560
 
429
- let _hoverFn = null, _clickFn = null, _oidHandler = null, _oidId = null;
561
+ let _hoverFn = null, _clickFn = null;
562
+ const effectSubs = [];
430
563
 
431
564
  if (cfg.trigger === 'load') {
432
565
  applyTween();
@@ -437,36 +570,28 @@ async function _applyEffect(el, cfg) {
437
570
  _clickFn = applyTween;
438
571
  el.addEventListener('click', _clickFn);
439
572
  } else if (cfg.trigger === 'oid') {
440
- // condition_bind and conditionValue_bind
573
+ // condition_bind: resolve condition operator dynamically
441
574
  if (cfg.condition_bind?.signal) {
442
- try { const s = await iobrokerHandler.connection.getState(cfg.condition_bind.signal); if (s?.val != null) cfg.condition = String(s.val); } catch (e) {}
443
- const h = (id, state) => { if (state?.val != null) cfg.condition = String(state.val); };
444
- try { iobrokerHandler.connection.subscribeState(cfg.condition_bind.signal, h); } catch (e) {}
575
+ await resolveAnimBinding(cfg.condition_bind.signal, el,
576
+ (val) => { cfg.condition = String(val); }, effectSubs);
445
577
  }
578
+ // conditionValue_bind: resolve comparison value dynamically
446
579
  if (cfg.conditionValue_bind?.signal) {
447
- try { const s = await iobrokerHandler.connection.getState(cfg.conditionValue_bind.signal); if (s?.val != null) cfg.conditionValue = String(s.val); } catch (e) {}
448
- const h = (id, state) => { if (state?.val != null) cfg.conditionValue = String(state.val); };
449
- try { iobrokerHandler.connection.subscribeState(cfg.conditionValue_bind.signal, h); } catch (e) {}
580
+ await resolveAnimBinding(cfg.conditionValue_bind.signal, el,
581
+ (val) => { cfg.conditionValue = String(val); }, effectSubs);
450
582
  }
451
- // oid_bind.signal IS the OID to watch (same as for animation controls)
452
- const oid = cfg.oid || cfg.oid_bind?.signal;
453
- if (oid) {
454
- _oidId = oid;
455
- _oidHandler = (id, state) => {
456
- if (checkCond(state?.val, cfg.condition || 'equal', cfg.conditionValue ?? 'true')) applyTween();
457
- };
458
- try { iobrokerHandler.connection.subscribeState(oid, _oidHandler); } catch (e) {}
459
- iobrokerHandler.connection.getState(oid).then(state => {
460
- if (state && checkCond(state.val, cfg.condition || 'equal', cfg.conditionValue ?? 'true')) applyTween();
461
- }).catch(() => {});
583
+ // oid_bind.signal IS the OID to watch
584
+ const oidSignal = cfg.oid_bind?.signal || cfg.oid;
585
+ if (oidSignal) {
586
+ await resolveAnimBinding(oidSignal, el, (val) => {
587
+ if (checkCond(val, cfg.condition || 'equal', cfg.conditionValue ?? 'true')) applyTween();
588
+ }, effectSubs);
462
589
  }
463
590
  }
464
591
 
465
592
  return () => {
466
593
  if (_hoverFn) el.removeEventListener('mouseenter', _hoverFn);
467
594
  if (_clickFn) el.removeEventListener('click', _clickFn);
468
- if (_oidHandler && _oidId) {
469
- try { iobrokerHandler.connection.unsubscribeState(_oidId, _oidHandler); } catch (e) {}
470
- }
595
+ _cleanupSubs(effectSubs);
471
596
  };
472
597
  }
@@ -698,6 +698,7 @@ export class BindingsHelper {
698
698
  let valuesObject = new Array(signals.length);
699
699
  for (let i = 0; i < signals.length; i++) {
700
700
  const s = signals[i];
701
+ if (s == null) continue; // ?prop resolved to undefined — skip, re-apply fires when property changes
701
702
  if (s[0] === '?') {
702
703
  if (root) {
703
704
  const nm = s.substring(1);