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 +1 -1
- package/package.json +9 -7
- package/www/dist/frontend/config/IobrokerWebuiAppShell.js +177 -139
- package/www/dist/frontend/runtime/AnimationService.js +198 -73
- package/www/node_modules/@gokturk413/web-component-designer-visualization-addons/dist/helpers/BindingsHelper.js +1 -0
package/io-package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.mywebui",
|
|
3
|
-
"version": "1.37.
|
|
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
|
|
1119
|
-
try {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
if (
|
|
1127
|
-
if (
|
|
1128
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
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
|
|
343
|
+
// condition_bind: dynamically update the condition operator
|
|
195
344
|
if (ctrl.condition_bind?.signal) {
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
216
|
-
if (!
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
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
|
|
382
|
-
if (inst?.cleanup)
|
|
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
|
|
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
|
|
573
|
+
// condition_bind: resolve condition operator dynamically
|
|
441
574
|
if (cfg.condition_bind?.signal) {
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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);
|