@accelerated-agency/visual-editor 0.2.4 → 0.2.5

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/dist/vite.cjs CHANGED
@@ -850,6 +850,7 @@ select.pr-inp{cursor:pointer;background:#fff}
850
850
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/inputs.js"></script>
851
851
  <!-- components.js defines shared colour-class arrays used by bootstrap5/widgets components -->
852
852
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components.js"></script>
853
+ <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-html.js"></script>
853
854
  <script>
854
855
  /* Safety stub: if components.js didn't define these, create empty arrays so
855
856
  components-bootstrap5.js doesn't throw ReferenceError on load. */
@@ -863,6 +864,18 @@ if (typeof colorSelectOptions === 'undefined') window.colorSelectOptions =
863
864
  if (typeof textColorSelectOptions=== 'undefined') window.textColorSelectOptions= [];
864
865
  if (typeof borderSelectOptions === 'undefined') window.borderSelectOptions = [];
865
866
  if (typeof sizeSelectOptions === 'undefined') window.sizeSelectOptions = [];
867
+ if (window.Vvveb && window.Vvveb.Components) {
868
+ if (!window.Vvveb.ComponentsGroup) window.Vvveb.ComponentsGroup = {};
869
+ if (!window.Vvveb.ComponentsGroup['Bootstrap 5']) window.Vvveb.ComponentsGroup['Bootstrap 5'] = [];
870
+ try {
871
+ var baseExists =
872
+ window.Vvveb.Components._components &&
873
+ Object.prototype.hasOwnProperty.call(window.Vvveb.Components._components, '_base');
874
+ if (!baseExists && typeof window.Vvveb.Components.add === 'function') {
875
+ window.Vvveb.Components.add('_base', { name: 'Base', properties: [] });
876
+ }
877
+ } catch(_) {}
878
+ }
866
879
  </script>
867
880
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-bootstrap5.js"></script>
868
881
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-widgets.js"></script>
@@ -914,6 +927,8 @@ var experimentData = null;
914
927
  var variations = [];
915
928
  var activeVarId = null;
916
929
  var varHtmlCache = {};
930
+ /** Per-variation chain rows from structural actions (insert/duplicate/delete/reorder/hide), merged on Finalize. */
931
+ var sessionStructuralChainRowsByVarId = {};
917
932
  /** Last iframe proxy URL we navigated to \u2014 used to skip redundant reloads when parent re-sends load-experiment */
918
933
  var lastLoadedProxyUrl = '';
919
934
  /** API changeset rows (excluding __vvveb_body__) reapplied until selectors match late-hydrated DOM */
@@ -927,6 +942,8 @@ var iframeContentNavGen = 0;
927
942
  var iframeContentApplyTimer = null;
928
943
  var iframeEarlyGranularPrimedForGen = null;
929
944
  var iframeEarlySyncPrimedForGen = null;
945
+ /** insert/reorder entries are applied from early granular + full apply \u2014 skip exact duplicates per iframe nav */
946
+ var appliedStructuralChangesetKeys = {};
930
947
  var isDirty = false;
931
948
  var vvvebReady = false;
932
949
  var currentMode = 'editor';
@@ -946,6 +963,8 @@ var selectionResizeBound = false;
946
963
  var clickAttachDoc = null;
947
964
  var changeObserver = null;
948
965
  var changeObserverDoc = null;
966
+ /** Incremented while applying changesets / selection chrome so MutationObserver does not mark dirty */
967
+ var suppressIframeMutationDirty = 0;
949
968
  /** { doc, onRS } \u2014 iframe document readystate until complete */
950
969
  var iframeDocLoadingListeners = null;
951
970
  // Each entry: {selector, label, cssProp, value, targetEl}
@@ -953,12 +972,89 @@ var iframeDocLoadingListeners = null;
953
972
  var stateChanges = [];
954
973
  /** Pre-apply DOM snapshots for granular + body changesets (used when removing History rows) */
955
974
  var appliedChangesetSnapshots = {};
975
+ /** Canonical JSON fingerprints of persisted changesets per variation (last load / finalize) */
976
+ var baselineChangesetsByVarId = {};
977
+
978
+ // \u2500\u2500 Dirty tracking (compare DB baseline + session stateChanges vs current export) \u2500\u2500
979
+ function beginSuppressIframeMutationDirty() {
980
+ suppressIframeMutationDirty += 1;
981
+ }
982
+
983
+ function endSuppressIframeMutationDirty() {
984
+ suppressIframeMutationDirty = Math.max(0, suppressIframeMutationDirty - 1);
985
+ }
986
+
987
+ /** Stable stringify of a variation's changesets field (string or array from API). */
988
+ function fingerprintChangesetsField(raw) {
989
+ if (raw == null) return '[]';
990
+ if (Array.isArray(raw)) {
991
+ try {
992
+ return JSON.stringify(raw);
993
+ } catch(_) {
994
+ return '[]';
995
+ }
996
+ }
997
+ if (typeof raw !== 'string') return '[]';
998
+ var s = raw.trim();
999
+ if (!s) return '[]';
1000
+ try {
1001
+ var p = JSON.parse(s);
1002
+ return JSON.stringify(Array.isArray(p) ? p : []);
1003
+ } catch(_) {
1004
+ return '[]';
1005
+ }
1006
+ }
1007
+
1008
+ function captureBaselineFromVariations(list) {
1009
+ baselineChangesetsByVarId = {};
1010
+ if (!list || !list.length) return;
1011
+ for (var i = 0; i < list.length; i++) {
1012
+ var v = list[i];
1013
+ if (!v || !v._id) continue;
1014
+ baselineChangesetsByVarId[v._id] = fingerprintChangesetsField(v.changesets);
1015
+ }
1016
+ }
1017
+
1018
+ /** Fingerprint of what Finalize would send for this variation (matches buildPersistedChainSetsForVariation). */
1019
+ function persistedExportFingerprintForVariation(v) {
1020
+ if (!v || !v._id) return '[]';
1021
+ try {
1022
+ var rows = buildPersistedChainSetsForVariation(v);
1023
+ return JSON.stringify(rows || []);
1024
+ } catch(_) {
1025
+ return '[]';
1026
+ }
1027
+ }
1028
+
1029
+ function setEditorDirty(dirty) {
1030
+ var was = isDirty;
1031
+ isDirty = !!dirty;
1032
+ var dot = document.getElementById('dirty-dot');
1033
+ if (dot) dot.classList.toggle('on', isDirty);
1034
+ if (isDirty && !was) send('mutations-changed', {});
1035
+ if (!isDirty && was) send('editor-dirty', { dirty: false });
1036
+ if (!isDirty) {
1037
+ savedAt = Date.now();
1038
+ updateSaveTime();
1039
+ }
1040
+ }
956
1041
 
957
- // \u2500\u2500 Dirty tracking \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
958
- function markDirty() {
959
- isDirty = true;
960
- document.getElementById('dirty-dot').classList.add('on');
961
- send('mutations-changed', {});
1042
+ function recomputeEditorDirty() {
1043
+ var d = stateChanges.length > 0;
1044
+ if (!d && variations && variations.length) {
1045
+ for (var i = 0; i < variations.length; i++) {
1046
+ var v = variations[i];
1047
+ var vid = v._id;
1048
+ var cur = persistedExportFingerprintForVariation(v);
1049
+ var base = baselineChangesetsByVarId[vid];
1050
+ if (base == null) base = '[]';
1051
+ if (cur !== base) {
1052
+ d = true;
1053
+ break;
1054
+ }
1055
+ }
1056
+ }
1057
+ setEditorDirty(d);
962
1058
  }
963
1059
  var savedAt = null;
964
1060
  function updateSaveTime() {
@@ -968,12 +1064,6 @@ function updateSaveTime() {
968
1064
  el.textContent = s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago';
969
1065
  }
970
1066
  setInterval(updateSaveTime, 10000);
971
- function markClean() {
972
- isDirty = false;
973
- savedAt = Date.now();
974
- document.getElementById('dirty-dot').classList.remove('on');
975
- updateSaveTime();
976
- }
977
1067
 
978
1068
  // \u2500\u2500 Mode toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
979
1069
  function setMode(mode) {
@@ -1150,6 +1240,7 @@ function logChange(selector, inputId, value, targetEl, originalValue) {
1150
1240
  if (idx >= 0) { stateChanges[idx] = entry; } else { stateChanges.push(entry); }
1151
1241
  }
1152
1242
  if (currentMainTab === 'states') renderStatesTab();
1243
+ recomputeEditorDirty();
1153
1244
  }
1154
1245
 
1155
1246
  function renderStatesTab() {
@@ -1183,7 +1274,7 @@ function renderStatesTab() {
1183
1274
 
1184
1275
  // Resolve a live DOM element for a state-change entry.
1185
1276
  // Tries the stored direct reference first; if it's detached or missing,
1186
- // falls back to querySelector(selector) inside the iframe document.
1277
+ // falls back to querySelector (with .vve-* class stripped) inside the iframe document.
1187
1278
  function resolveChangeEl(change) {
1188
1279
  try {
1189
1280
  var iframeDoc = document.getElementById('iframeId').contentDocument;
@@ -1193,7 +1284,7 @@ function resolveChangeEl(change) {
1193
1284
  return change.targetEl;
1194
1285
  }
1195
1286
  // Fallback: re-query by the stored CSS selector
1196
- return iframeDoc.querySelector(change.selector) || null;
1287
+ return querySelectorResolved(iframeDoc, change.selector);
1197
1288
  } catch (e) {
1198
1289
  console.warn('[V2] resolveChangeEl:', e);
1199
1290
  return null;
@@ -1256,7 +1347,7 @@ function removeStateChange(idx) {
1256
1347
  syncDesignInput(change);
1257
1348
  stateChanges.splice(idx, 1);
1258
1349
  renderStatesTab();
1259
- markDirty();
1350
+ recomputeEditorDirty();
1260
1351
  }
1261
1352
 
1262
1353
  function clearAllStates() {
@@ -1266,7 +1357,7 @@ function clearAllStates() {
1266
1357
  });
1267
1358
  stateChanges = [];
1268
1359
  renderStatesTab();
1269
- markDirty();
1360
+ recomputeEditorDirty();
1270
1361
  }
1271
1362
 
1272
1363
  // \u2500\u2500 History tab (saved changesets from DB for active variation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -1291,10 +1382,11 @@ function persistActiveVariationChangesets(arr) {
1291
1382
 
1292
1383
  function entrySnapshotKey(entry) {
1293
1384
  if (!entry || !entry.selector) return '';
1385
+ var selKey = sanitizeSelectorForMatch(entry.selector) || entry.selector;
1294
1386
  return (
1295
- entry.selector +
1387
+ selKey +
1296
1388
  '\0' +
1297
- (entry.type || '') +
1389
+ normalizeChangesetType(entry) +
1298
1390
  '\0' +
1299
1391
  String(entry.property || '') +
1300
1392
  '\0' +
@@ -1312,7 +1404,7 @@ function captureChangesetSnapshotBeforeApply(entry, el, iframeDoc) {
1312
1404
  if (!entry || !el || entry.selector === '__vvveb_body__') return;
1313
1405
  var k = entrySnapshotKey(entry);
1314
1406
  if (appliedChangesetSnapshots[k]) return;
1315
- switch (entry.type) {
1407
+ switch (normalizeChangesetType(entry)) {
1316
1408
  case 'content':
1317
1409
  if (entry.html != null) {
1318
1410
  appliedChangesetSnapshots[k] = { kind: 'innerHTML', v: el.innerHTML };
@@ -1380,10 +1472,7 @@ function revertChangesetEntryOnDom(entry) {
1380
1472
  if (!iframeDoc) return false;
1381
1473
  var k = entrySnapshotKey(entry);
1382
1474
  var snap = appliedChangesetSnapshots[k];
1383
- var el = null;
1384
- try {
1385
- el = iframeDoc.querySelector(entry.selector);
1386
- } catch(_) {}
1475
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1387
1476
  if (!snap || !el) {
1388
1477
  softReloadEditorIframe();
1389
1478
  delete appliedChangesetSnapshots[k];
@@ -1410,7 +1499,7 @@ function revertChangesetEntryOnDom(entry) {
1410
1499
  function historyEntryTypeLabel(entry) {
1411
1500
  if (!entry) return 'Change';
1412
1501
  if (entry.selector === '__vvveb_body__') return 'Full page HTML';
1413
- var t = (entry.type || '').toLowerCase();
1502
+ var t = normalizeChangesetType(entry);
1414
1503
  if (t === 'content') return entry.html != null ? 'Inner HTML' : 'Text / content';
1415
1504
  if (t === 'style') return 'Style: ' + (entry.property || '');
1416
1505
  if (t === 'attribute') return 'Attribute: ' + (entry.attribute || '');
@@ -1424,7 +1513,8 @@ function historyEntryValuePreview(entry) {
1424
1513
  if (entry.selector === '__vvveb_body__') return '(body snapshot)';
1425
1514
  if (entry.html != null) return String(entry.html).slice(0, 120);
1426
1515
  if (entry.value != null) return String(entry.value).slice(0, 120);
1427
- if (entry.type === 'style' || entry.type === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1516
+ var nt = normalizeChangesetType(entry);
1517
+ if (nt === 'style' || nt === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1428
1518
  return '';
1429
1519
  }
1430
1520
 
@@ -1458,6 +1548,9 @@ function renderHistoryTab() {
1458
1548
  var val = historyEntryValuePreview(item.entry);
1459
1549
  html +=
1460
1550
  '<div class="state-item">' +
1551
+ '<span class="state-item-idx" style="opacity:0.55;font-size:10px;min-width:2.2em;display:inline-block" title="Order in saved changesets">#' +
1552
+ item.idx +
1553
+ '</span>' +
1461
1554
  '<span class="state-item-label">' +
1462
1555
  esc(lab) +
1463
1556
  '</span>' +
@@ -1466,7 +1559,9 @@ function renderHistoryTab() {
1466
1559
  '">' +
1467
1560
  esc(val) +
1468
1561
  '</span>' +
1469
- '<button type="button" class="state-remove" title="Remove from saved changesets" onclick="removeHistoryChangeset(' +
1562
+ '<button type="button" class="state-remove" title="Remove this saved row (#' +
1563
+ item.idx +
1564
+ ')" onclick="removeHistoryChangeset(' +
1470
1565
  item.idx +
1471
1566
  ')">&#x2715;</button>' +
1472
1567
  '</div>';
@@ -1476,6 +1571,16 @@ function renderHistoryTab() {
1476
1571
  container.innerHTML = html;
1477
1572
  }
1478
1573
 
1574
+ function changesetListHasStructural(arr) {
1575
+ if (!arr || !arr.length) return false;
1576
+ for (var i = 0; i < arr.length; i++) {
1577
+ var e = arr[i];
1578
+ var t = normalizeChangesetType(e);
1579
+ if (e && (t === 'insert' || t === 'reorder')) return true;
1580
+ }
1581
+ return false;
1582
+ }
1583
+
1479
1584
  function removeHistoryChangeset(idx) {
1480
1585
  var v = getActiveVariationForHistory();
1481
1586
  if (!v) return;
@@ -1488,18 +1593,31 @@ function removeHistoryChangeset(idx) {
1488
1593
  try {
1489
1594
  delete varHtmlCache[activeVarId];
1490
1595
  } catch(_) {}
1491
- if (!didReload) {
1596
+ // Re-applying remaining rows on top of current DOM duplicates insert/reorder nodes; reload when any
1597
+ // structural row remains or was removed (revert may already have started a reload for insert/body).
1598
+ var removedType = normalizeChangesetType(removed);
1599
+ var needsStructuralReload =
1600
+ !didReload &&
1601
+ (removedType === 'insert' ||
1602
+ removedType === 'reorder' ||
1603
+ changesetListHasStructural(arr));
1604
+ if (didReload) {
1605
+ /* revertChangesetEntryOnDom already kicked off iframe reload */
1606
+ } else if (needsStructuralReload) {
1607
+ softReloadEditorIframe();
1608
+ } else {
1492
1609
  try {
1610
+ appliedStructuralChangesetKeys = {};
1493
1611
  applyActiveVariationHtml();
1494
1612
  registerPendingGranularChangesets(
1495
1613
  arr,
1496
1614
  document.getElementById('iframeId').contentDocument,
1497
1615
  );
1616
+ saveCurrentVariationHtml();
1498
1617
  } catch(_) {}
1499
- saveCurrentVariationHtml();
1500
1618
  }
1501
1619
  if (currentMainTab === 'history') renderHistoryTab();
1502
- markDirty();
1620
+ recomputeEditorDirty();
1503
1621
  scheduleDomTreeRefresh();
1504
1622
  }
1505
1623
 
@@ -1509,15 +1627,82 @@ function clearAllHistoryChangesets() {
1509
1627
  if (!parseVariationChangesets(v).length) return;
1510
1628
  persistActiveVariationChangesets([]);
1511
1629
  appliedChangesetSnapshots = {};
1630
+ appliedStructuralChangesetKeys = {};
1512
1631
  try {
1513
1632
  delete varHtmlCache[activeVarId];
1514
1633
  } catch(_) {}
1515
1634
  if (currentMainTab === 'history') renderHistoryTab();
1516
- markDirty();
1635
+ recomputeEditorDirty();
1517
1636
  scheduleDomTreeRefresh();
1518
1637
  softReloadEditorIframe();
1519
1638
  }
1520
1639
 
1640
+ // \u2500\u2500 Persisted active variation (survives iframe / full page reload) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
1641
+ /** All Visual Editor iframe keys in localStorage use this prefix \u2014 cleared on close. */
1642
+ var VVE_LOCAL_STORAGE_PREFIX = 'vve:';
1643
+
1644
+ function clearVisualEditorLocalStorage() {
1645
+ try {
1646
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1647
+ var k = localStorage.key(i);
1648
+ if (k && k.indexOf(VVE_LOCAL_STORAGE_PREFIX) === 0) {
1649
+ localStorage.removeItem(k);
1650
+ }
1651
+ }
1652
+ } catch(_) {}
1653
+ }
1654
+
1655
+ function activeVariationStorageKeyFromPayload(data) {
1656
+ return (
1657
+ VVE_LOCAL_STORAGE_PREFIX +
1658
+ 'activeVar:' +
1659
+ String((data && data.experimentId) || '') +
1660
+ ':' +
1661
+ String((data && data.pageUrl) || '')
1662
+ );
1663
+ }
1664
+
1665
+ function readPersistedActiveVariationId(data) {
1666
+ try {
1667
+ var sk = activeVariationStorageKeyFromPayload(data);
1668
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return null;
1669
+ return localStorage.getItem(sk);
1670
+ } catch(_) {
1671
+ return null;
1672
+ }
1673
+ }
1674
+
1675
+ function writePersistedActiveVariationId(varId) {
1676
+ try {
1677
+ if (!experimentData || !experimentData.experimentId) return;
1678
+ var sk =
1679
+ VVE_LOCAL_STORAGE_PREFIX +
1680
+ 'activeVar:' +
1681
+ String(experimentData.experimentId || '') +
1682
+ ':' +
1683
+ String(experimentData.pageUrl || '');
1684
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return;
1685
+ if (varId) localStorage.setItem(sk, String(varId));
1686
+ } catch(_) {}
1687
+ }
1688
+
1689
+ /**
1690
+ * @param allowPrevMemory when true, keep in-session activeVarId if still valid (skip-reload path).
1691
+ */
1692
+ function pickActiveVariationIdForLoad(data, variationsArr, prevMemoryId, allowPrevMemory) {
1693
+ var baseline = variationsArr.find(function(v) { return v.baseline; });
1694
+ var fallback = (baseline || variationsArr[0] || {})._id || null;
1695
+ if (!variationsArr.length) return null;
1696
+ if (allowPrevMemory && prevMemoryId && variationsArr.some(function(v) { return v._id === prevMemoryId; })) {
1697
+ return prevMemoryId;
1698
+ }
1699
+ var stored = readPersistedActiveVariationId(data);
1700
+ if (stored && variationsArr.some(function(v) { return v._id === stored; })) {
1701
+ return stored;
1702
+ }
1703
+ return fallback;
1704
+ }
1705
+
1521
1706
  // \u2500\u2500 Experiment loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1522
1707
  function handleLoadExperiment(data) {
1523
1708
  clearPendingGranularChangesets();
@@ -1545,10 +1730,8 @@ function handleLoadExperiment(data) {
1545
1730
  experimentData = data;
1546
1731
  variations = Array.isArray(data.variations) ? data.variations : [];
1547
1732
  var prevActive = activeVarId;
1548
- var baseline = variations.find(function(v) { return v.baseline; });
1549
- var fallback = (baseline || variations[0] || {})._id || null;
1550
- activeVarId =
1551
- prevActive && variations.some(function(v) { return v._id === prevActive; }) ? prevActive : fallback;
1733
+ activeVarId = pickActiveVariationIdForLoad(data, variations, prevActive, true);
1734
+ writePersistedActiveVariationId(activeVarId);
1552
1735
  renderVariationTabs();
1553
1736
  var urlBarSkip = document.getElementById('url-bar');
1554
1737
  urlBarSkip.textContent = pageUrl;
@@ -1562,24 +1745,30 @@ function handleLoadExperiment(data) {
1562
1745
  if (ifrSkip && ifrSkip.contentDocument) attachIframeLoadingUntilComplete(ifrSkip);
1563
1746
  } catch(_) {}
1564
1747
  if (currentMainTab === 'history') renderHistoryTab();
1748
+ captureBaselineFromVariations(variations);
1749
+ recomputeEditorDirty();
1565
1750
  return;
1566
1751
  }
1567
1752
 
1568
1753
  if (!experimentData || prevKey !== nextKey) {
1569
1754
  varHtmlCache = {};
1755
+ sessionStructuralChainRowsByVarId = {};
1570
1756
  appliedChangesetSnapshots = {};
1757
+ appliedStructuralChangesetKeys = {};
1571
1758
  }
1572
1759
  experimentData = data;
1573
1760
  variations = Array.isArray(data.variations) ? data.variations : [];
1574
- // New document load: start on baseline so the first paint matches the live page.
1575
- var baseline = variations.find(function(v) { return v.baseline; });
1576
- activeVarId = (baseline || variations[0] || {})._id || null;
1761
+ var sameExpPage = prevKey === nextKey;
1762
+ activeVarId = pickActiveVariationIdForLoad(data, variations, activeVarId, sameExpPage);
1763
+ writePersistedActiveVariationId(activeVarId);
1577
1764
  renderVariationTabs();
1578
1765
 
1579
1766
  var urlBar = document.getElementById('url-bar');
1580
1767
  urlBar.textContent = pageUrl;
1581
1768
  urlBar.title = pageUrl;
1582
1769
  if (currentMainTab === 'history') renderHistoryTab();
1770
+ captureBaselineFromVariations(variations);
1771
+ recomputeEditorDirty();
1583
1772
  loadPage(proxyUrl);
1584
1773
  }
1585
1774
 
@@ -1679,9 +1868,7 @@ function granularAnySelectorMatches(doc, cs) {
1679
1868
  if (!doc || !cs || !cs.length) return false;
1680
1869
  var g = filterGranularChangesetEntries(cs);
1681
1870
  for (var i = 0; i < g.length; i++) {
1682
- try {
1683
- if (doc.querySelector(g[i].selector)) return true;
1684
- } catch(_) {}
1871
+ if (querySelectorResolved(doc, g[i].selector)) return true;
1685
1872
  }
1686
1873
  return false;
1687
1874
  }
@@ -1702,7 +1889,7 @@ function appendIframeReloadBust(url) {
1702
1889
  // applying variation changesets there is then wiped when the real navigation commits.
1703
1890
  function iframeDocMatchesNavigatedSrc(iframe, doc) {
1704
1891
  if (!iframe || !doc) return false;
1705
- var src = iframe.src || iframe.getAttribute('src') || '';
1892
+ var src = iframe.getAttribute('src') || iframe.src || '';
1706
1893
  if (!src || src === 'about:blank') return false;
1707
1894
  var loc = '';
1708
1895
  try {
@@ -1711,12 +1898,28 @@ function iframeDocMatchesNavigatedSrc(iframe, doc) {
1711
1898
  return false;
1712
1899
  }
1713
1900
  if (!loc || loc === 'about:blank') return false;
1714
- var rmSrc = src.match(/__ve_reload=([0-9]+)/);
1715
- if (rmSrc) return loc.indexOf('__ve_reload=' + rmSrc[1]) !== -1;
1716
1901
  try {
1717
1902
  var base = window.location.href;
1718
1903
  var su = new URL(src, base);
1904
+ if (su.searchParams && su.searchParams.has('__ve_reload')) {
1905
+ su.searchParams.delete('__ve_reload');
1906
+ }
1719
1907
  var du = new URL(loc, base);
1908
+ if (du.searchParams && du.searchParams.has('__ve_reload')) {
1909
+ du.searchParams.delete('__ve_reload');
1910
+ }
1911
+ // Same-origin proxy that keeps document address aligned with iframe src
1912
+ if (su.origin === du.origin && su.pathname + su.search === du.pathname + du.search) {
1913
+ return true;
1914
+ }
1915
+ // conversion-proxy: iframe src stays on our app, but doc.URL is usually the target site after redirects
1916
+ var p = su.pathname || '';
1917
+ var isRootProxyPath = p === '/api/conversion-proxy' || p.indexOf('/api/conversion-proxy/') === 0;
1918
+ var isNestedMalformedProxy = !isRootProxyPath && p.indexOf('api/conversion-proxy') !== -1;
1919
+ if (isNestedMalformedProxy) return false;
1920
+ if (isRootProxyPath || String(su.href).indexOf('conversion-proxy') !== -1) {
1921
+ return doc === iframe.contentDocument;
1922
+ }
1720
1923
  return su.pathname + su.search === du.pathname + du.search;
1721
1924
  } catch(_) {
1722
1925
  return false;
@@ -1749,6 +1952,7 @@ function resetIframeBindings() {
1749
1952
  detachIframeLoadingListeners();
1750
1953
  stopIframeContentApplyWatcher();
1751
1954
  appliedChangesetSnapshots = {};
1955
+ appliedStructuralChangesetKeys = {};
1752
1956
  clickAttachDoc = null;
1753
1957
  dragAttachDoc = null;
1754
1958
  changeObserverDoc = null;
@@ -1819,13 +2023,19 @@ function switchVariation(varId) {
1819
2023
  saveCurrentVariationHtml();
1820
2024
  clearPendingGranularChangesets();
1821
2025
  activeVarId = varId;
2026
+ writePersistedActiveVariationId(varId);
1822
2027
  renderVariationTabs();
1823
2028
  deselectElement();
1824
2029
  try {
1825
2030
  var iframe = document.getElementById('iframeId');
1826
2031
  var saved = varHtmlCache[varId];
1827
2032
  if (saved) {
1828
- iframe.contentDocument.body.innerHTML = saved;
2033
+ beginSuppressIframeMutationDirty();
2034
+ try {
2035
+ iframe.contentDocument.body.innerHTML = saved;
2036
+ } finally {
2037
+ endSuppressIframeMutationDirty();
2038
+ }
1829
2039
  detachIframeLoadingListeners();
1830
2040
  setIframePageLoadingUi(false);
1831
2041
  syncIframeInteractions('switch-variation-cache');
@@ -1848,6 +2058,7 @@ function switchVariation(varId) {
1848
2058
  }
1849
2059
  } catch(_) {}
1850
2060
  if (currentMainTab === 'history') renderHistoryTab();
2061
+ recomputeEditorDirty();
1851
2062
  }
1852
2063
 
1853
2064
  function saveCurrentVariationHtml() {
@@ -1886,31 +2097,123 @@ function parseVariationChangesets(variation) {
1886
2097
  }
1887
2098
  }
1888
2099
 
2100
+ /** Lowercase entry.type so persisted / API rows match switches (e.g. Insert vs insert). */
2101
+ function normalizeChangesetType(entry) {
2102
+ return String(entry && entry.type != null ? entry.type : '').toLowerCase();
2103
+ }
2104
+
1889
2105
  function filterGranularChangesetEntries(cs) {
1890
2106
  if (!cs || !cs.length) return [];
1891
2107
  var out = [];
1892
2108
  for (var i = 0; i < cs.length; i++) {
1893
2109
  var e = cs[i];
1894
- if (e && e.selector && e.selector !== '__vvveb_body__') out.push(e);
2110
+ if (!e || !e.selector || e.selector === '__vvveb_body__') continue;
2111
+ out.push(e);
1895
2112
  }
1896
2113
  return out;
1897
2114
  }
1898
2115
 
2116
+ /** Dedup key for merging chain-set rows (overlay wins over base). */
2117
+ function chainSetDedupKey(entry) {
2118
+ if (!entry || !entry.selector) return '';
2119
+ var t = normalizeChangesetType(entry);
2120
+ if (t === 'style') return entry.selector + '|s|' + String(entry.property || '');
2121
+ if (t === 'content') return entry.selector + '|c|' + (entry.html != null ? 'h' : 't');
2122
+ if (t === 'attribute') return entry.selector + '|a|' + String(entry.attribute || '');
2123
+ if (t === 'insert') return entry.selector + '|i|' + String(entry.action || '') + '|' + String(entry.html || '').slice(0, 120);
2124
+ if (t === 'remove') return entry.selector + '|r|';
2125
+ if (t === 'reorder') {
2126
+ return entry.selector + '|ro|' + String(entry.targetSelector || '') + '|' + String(entry.action || '');
2127
+ }
2128
+ try {
2129
+ return entry.selector + '|' + t + '|' + JSON.stringify(entry);
2130
+ } catch(_) {
2131
+ return entry.selector + '|' + t;
2132
+ }
2133
+ }
2134
+
2135
+ function mergeGranularChainSets(baseList, overlayList) {
2136
+ var map = {};
2137
+ var order = [];
2138
+ function ingest(arr) {
2139
+ if (!arr || !arr.length) return;
2140
+ for (var i = 0; i < arr.length; i++) {
2141
+ var e = arr[i];
2142
+ if (!e || !e.selector) continue;
2143
+ var k = chainSetDedupKey(e);
2144
+ if (!map[k]) order.push(k);
2145
+ map[k] = e;
2146
+ }
2147
+ }
2148
+ ingest(baseList);
2149
+ ingest(overlayList);
2150
+ var out = [];
2151
+ for (var j = 0; j < order.length; j++) {
2152
+ out.push(map[order[j]]);
2153
+ }
2154
+ return out;
2155
+ }
2156
+
2157
+ function appendSessionStructuralChainRow(varId, row) {
2158
+ if (!varId || !row) return;
2159
+ if (!sessionStructuralChainRowsByVarId[varId]) sessionStructuralChainRowsByVarId[varId] = [];
2160
+ sessionStructuralChainRowsByVarId[varId].push(row);
2161
+ }
2162
+
2163
+ /** One States-tab row -> Conversion.io chain-set shape (matches applyChangesetEntry). */
2164
+ function stateChangeToChainSet(c) {
2165
+ if (!c || !c.selector) return null;
2166
+ if (c.cssProp) {
2167
+ return { selector: c.selector, type: 'style', property: c.cssProp, value: c.value };
2168
+ }
2169
+ switch (c.inputId) {
2170
+ case 'pp-text':
2171
+ return { selector: c.selector, type: 'content', value: c.value };
2172
+ case 'pp-html':
2173
+ return { selector: c.selector, type: 'content', html: c.value };
2174
+ case 'pp-cls':
2175
+ return { selector: c.selector, type: 'attribute', attribute: 'class', value: c.value };
2176
+ case 'pp-id':
2177
+ return { selector: c.selector, type: 'attribute', attribute: 'id', value: c.value };
2178
+ case 'pp-href':
2179
+ return { selector: c.selector, type: 'attribute', attribute: 'href', value: c.value };
2180
+ case 'pp-target':
2181
+ return { selector: c.selector, type: 'attribute', attribute: 'target', value: c.value };
2182
+ case 'pp-src':
2183
+ return { selector: c.selector, type: 'attribute', attribute: 'src', value: c.value };
2184
+ case 'pp-alt':
2185
+ return { selector: c.selector, type: 'attribute', attribute: 'alt', value: c.value };
2186
+ case 'pp-ph':
2187
+ return { selector: c.selector, type: 'attribute', attribute: 'placeholder', value: c.value };
2188
+ case 'pp-css':
2189
+ return { selector: c.selector, type: 'attribute', attribute: 'style', value: c.value };
2190
+ case 'pp-mob-css':
2191
+ return { selector: c.selector, type: 'attribute', attribute: 'data-mobile-css', value: c.value };
2192
+ case 'pp-tab-css':
2193
+ return { selector: c.selector, type: 'attribute', attribute: 'data-tablet-css', value: c.value };
2194
+ default:
2195
+ return null;
2196
+ }
2197
+ }
2198
+
1899
2199
  function countUnresolvedGranularSelectors(iframeDoc, entries) {
1900
2200
  if (!iframeDoc || !entries || !entries.length) return 0;
1901
2201
  var n = 0;
1902
2202
  for (var i = 0; i < entries.length; i++) {
1903
- var el = null;
1904
- try { el = iframeDoc.querySelector(entries[i].selector); } catch(_) {}
1905
- if (!el) n++;
2203
+ if (!querySelectorResolved(iframeDoc, entries[i].selector)) n++;
1906
2204
  }
1907
2205
  return n;
1908
2206
  }
1909
2207
 
1910
2208
  function applyGranularChangesetEntries(iframeDoc, entries) {
1911
2209
  if (!iframeDoc || !entries || !entries.length) return;
1912
- for (var i = 0; i < entries.length; i++) {
1913
- applyChangesetEntry(entries[i], iframeDoc);
2210
+ beginSuppressIframeMutationDirty();
2211
+ try {
2212
+ for (var i = 0; i < entries.length; i++) {
2213
+ applyChangesetEntry(entries[i], iframeDoc);
2214
+ }
2215
+ } finally {
2216
+ endSuppressIframeMutationDirty();
1914
2217
  }
1915
2218
  }
1916
2219
 
@@ -1965,6 +2268,30 @@ function flushPendingGranularChangesets() {
1965
2268
  scheduleGranularChangesetReapply();
1966
2269
  }
1967
2270
 
2271
+ /** Stable key for structural changesets (insert/reorder) to avoid double-apply across early + full paint. */
2272
+ function structuralChangesetDedupKey(entry) {
2273
+ var nt = normalizeChangesetType(entry);
2274
+ if (!entry || (nt !== 'insert' && nt !== 'reorder')) return '';
2275
+ var vid = activeVarId || '';
2276
+ try {
2277
+ return (
2278
+ vid +
2279
+ '\0' +
2280
+ nt +
2281
+ '\0' +
2282
+ entry.selector +
2283
+ '\0' +
2284
+ String(entry.action || '') +
2285
+ '\0' +
2286
+ String(entry.html != null ? entry.html : '').slice(0, 240) +
2287
+ '\0' +
2288
+ String(entry.targetSelector || '')
2289
+ );
2290
+ } catch(_) {
2291
+ return vid + '\0' + nt + '\0' + entry.selector;
2292
+ }
2293
+ }
2294
+
1968
2295
  /**
1969
2296
  * Apply a single changeset entry inside the editor iframe.
1970
2297
  * Backend format: { selector, type: 'content'|'style'|'attribute'|'insert'|'remove', ... }
@@ -1984,21 +2311,34 @@ function applyChangesetEntry(entry, iframeDoc) {
1984
2311
  return;
1985
2312
  }
1986
2313
 
2314
+ var structuralDedupeKey = structuralChangesetDedupKey(entry);
2315
+ if (structuralDedupeKey && appliedStructuralChangesetKeys[structuralDedupeKey]) return;
2316
+
1987
2317
  // \u2500\u2500 Standard granular changeset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1988
- var el = null;
1989
- try { el = iframeDoc.querySelector(entry.selector); } catch(_) {}
2318
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1990
2319
  if (!el) return;
1991
2320
 
1992
2321
  captureChangesetSnapshotBeforeApply(entry, el, iframeDoc);
1993
2322
 
1994
- switch (entry.type) {
2323
+ switch (normalizeChangesetType(entry)) {
1995
2324
  case 'content':
1996
2325
  if (entry.html != null) el.innerHTML = entry.html;
1997
2326
  else if (entry.value != null) el.textContent = entry.value;
1998
2327
  break;
1999
2328
  case 'style':
2000
- if (entry.property && entry.value != null) {
2001
- el.style[camelize(entry.property)] = entry.value;
2329
+ if (entry.property) {
2330
+ var propKebab = entry.property;
2331
+ var cam = camelize(propKebab);
2332
+ if (entry.value == null || entry.value === '') {
2333
+ try { el.style.removeProperty(propKebab); } catch(_) {}
2334
+ try { if (cam in el.style) el.style[cam] = ''; } catch(__) {}
2335
+ } else {
2336
+ try {
2337
+ el.style.setProperty(propKebab, entry.value, 'important');
2338
+ } catch(_) {
2339
+ el.style[cam] = entry.value;
2340
+ }
2341
+ }
2002
2342
  }
2003
2343
  break;
2004
2344
  case 'attribute':
@@ -2009,11 +2349,23 @@ function applyChangesetEntry(entry, iframeDoc) {
2009
2349
  case 'insert': {
2010
2350
  var pos = entry.action === 'before' ? 'beforebegin' : 'afterend';
2011
2351
  if (entry.html) el.insertAdjacentHTML(pos, entry.html);
2352
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2012
2353
  break;
2013
2354
  }
2014
2355
  case 'remove':
2015
2356
  el.style.display = 'none';
2016
2357
  break;
2358
+ case 'reorder': {
2359
+ var target = entry.targetSelector ? querySelectorResolved(iframeDoc, entry.targetSelector) : null;
2360
+ if (!target || !el.parentNode || !target.parentNode) break;
2361
+ if (entry.action === 'before') {
2362
+ target.parentNode.insertBefore(el, target);
2363
+ } else {
2364
+ target.parentNode.insertBefore(el, target.nextSibling);
2365
+ }
2366
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2367
+ break;
2368
+ }
2017
2369
  default: break;
2018
2370
  }
2019
2371
  }
@@ -2028,19 +2380,24 @@ function applyActiveVariationHtml() {
2028
2380
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2029
2381
  var cs = parseVariationChangesets(variation);
2030
2382
 
2031
- // If we have an in-session HTML snapshot, use it (user edited in this session)
2032
- var saved = varHtmlCache[activeVarId];
2033
- if (saved) {
2034
- try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2035
- return;
2036
- }
2383
+ beginSuppressIframeMutationDirty();
2384
+ try {
2385
+ // If we have an in-session HTML snapshot, use it (user edited in this session)
2386
+ var saved = varHtmlCache[activeVarId];
2387
+ if (saved) {
2388
+ try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2389
+ return;
2390
+ }
2037
2391
 
2038
- if (!cs.length) return;
2039
- for (var i = 0; i < cs.length; i++) {
2040
- applyChangesetEntry(cs[i], iframeDoc);
2392
+ if (!cs.length) return;
2393
+ for (var i = 0; i < cs.length; i++) {
2394
+ applyChangesetEntry(cs[i], iframeDoc);
2395
+ }
2396
+ // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2397
+ registerPendingGranularChangesets(cs, iframeDoc);
2398
+ } finally {
2399
+ endSuppressIframeMutationDirty();
2041
2400
  }
2042
- // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2043
- registerPendingGranularChangesets(cs, iframeDoc);
2044
2401
  }
2045
2402
 
2046
2403
  function changesetsHaveBodySnapshot(cs) {
@@ -2051,6 +2408,23 @@ function changesetsHaveBodySnapshot(cs) {
2051
2408
  return false;
2052
2409
  }
2053
2410
 
2411
+ /** Rows to persist for this variation on Finalize (same chain-set model as EditorShell \u2014 never __vvveb_body__). */
2412
+ function buildPersistedChainSetsForVariation(v) {
2413
+ if (!v || !v._id) return [];
2414
+ var parsed = parseVariationChangesets(v);
2415
+ var base = filterGranularChangesetEntries(parsed);
2416
+ var sessionExtra = sessionStructuralChainRowsByVarId[v._id] || [];
2417
+ if (v._id !== activeVarId) {
2418
+ return mergeGranularChainSets(base, sessionExtra);
2419
+ }
2420
+ var overlay = [];
2421
+ for (var si = 0; si < stateChanges.length; si++) {
2422
+ var row = stateChangeToChainSet(stateChanges[si]);
2423
+ if (row) overlay.push(row);
2424
+ }
2425
+ return mergeGranularChainSets(mergeGranularChainSets(base, sessionExtra), overlay);
2426
+ }
2427
+
2054
2428
  /**
2055
2429
  * While document.readyState === 'loading', apply only granular changesets (no __vvveb_body__
2056
2430
  * replacement) so the first painted nodes can receive edits before iframe/window load completes.
@@ -2061,10 +2435,15 @@ function applyVariationGranularOnly(iframeDoc) {
2061
2435
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2062
2436
  var cs = parseVariationChangesets(variation);
2063
2437
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2064
- for (var i = 0; i < cs.length; i++) {
2065
- applyChangesetEntry(cs[i], iframeDoc);
2438
+ beginSuppressIframeMutationDirty();
2439
+ try {
2440
+ for (var i = 0; i < cs.length; i++) {
2441
+ applyChangesetEntry(cs[i], iframeDoc);
2442
+ }
2443
+ registerPendingGranularChangesets(cs, iframeDoc);
2444
+ } finally {
2445
+ endSuppressIframeMutationDirty();
2066
2446
  }
2067
- registerPendingGranularChangesets(cs, iframeDoc);
2068
2447
  }
2069
2448
 
2070
2449
  /** Re-try granular entries without resetting pending registration (use between poll ticks). */
@@ -2074,8 +2453,13 @@ function reapplyActiveVariationGranular(iframeDoc) {
2074
2453
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2075
2454
  var cs = parseVariationChangesets(variation);
2076
2455
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2077
- for (var i = 0; i < cs.length; i++) {
2078
- applyChangesetEntry(cs[i], iframeDoc);
2456
+ beginSuppressIframeMutationDirty();
2457
+ try {
2458
+ for (var i = 0; i < cs.length; i++) {
2459
+ applyChangesetEntry(cs[i], iframeDoc);
2460
+ }
2461
+ } finally {
2462
+ endSuppressIframeMutationDirty();
2079
2463
  }
2080
2464
  }
2081
2465
 
@@ -2137,9 +2521,14 @@ function startIframeContentApplyWatcher(navGen) {
2137
2521
 
2138
2522
  // \u2500\u2500 Element selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2139
2523
  function selectElement(el) {
2140
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2141
- selectedEl = el;
2142
- try { el.classList.add('vve-selected'); } catch(_) {}
2524
+ beginSuppressIframeMutationDirty();
2525
+ try {
2526
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2527
+ selectedEl = el;
2528
+ try { el.classList.add('vve-selected'); } catch(_) {}
2529
+ } finally {
2530
+ endSuppressIframeMutationDirty();
2531
+ }
2143
2532
  document.getElementById('bc-path').textContent = buildSelector(el);
2144
2533
  document.getElementById('bc-path').style.color = 'var(--accent-txt)';
2145
2534
  document.getElementById('no-sel').style.display = 'none';
@@ -2154,7 +2543,12 @@ function selectElement(el) {
2154
2543
 
2155
2544
  function deselectElement() {
2156
2545
  setDragHandleActive(false);
2157
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2546
+ beginSuppressIframeMutationDirty();
2547
+ try {
2548
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2549
+ } finally {
2550
+ endSuppressIframeMutationDirty();
2551
+ }
2158
2552
  document.getElementById('no-sel').style.display = '';
2159
2553
  document.getElementById('el-info').style.display = 'none';
2160
2554
  document.getElementById('rp-accordion').style.display = 'none';
@@ -2178,18 +2572,27 @@ function injectIframeSelectionStyles(doc) {
2178
2572
  'html.vve-drag-armed .vve-selected{cursor:grab!important;}' +
2179
2573
  '.vve-dragging{opacity:0.92!important;outline:2px dashed #f59e0b!important;' +
2180
2574
  'outline-offset:2px!important;cursor:grabbing!important;box-shadow:none!important;}';
2181
- doc.head.appendChild(st);
2575
+ beginSuppressIframeMutationDirty();
2576
+ try {
2577
+ doc.head.appendChild(st);
2578
+ } finally {
2579
+ endSuppressIframeMutationDirty();
2580
+ }
2182
2581
  }
2183
2582
 
2184
2583
  function setDragHandleActive(on) {
2185
2584
  dragHandleActive = !!on;
2186
2585
  var b = document.getElementById('sf-drag');
2187
2586
  if (b) b.classList.toggle('active', dragHandleActive);
2587
+ beginSuppressIframeMutationDirty();
2188
2588
  try {
2189
2589
  var iframe = document.getElementById('iframeId');
2190
2590
  var d = iframe && iframe.contentDocument && iframe.contentDocument.documentElement;
2191
2591
  if (d) d.classList.toggle('vve-drag-armed', dragHandleActive);
2192
- } catch(_) {}
2592
+ } catch(_) {
2593
+ } finally {
2594
+ endSuppressIframeMutationDirty();
2595
+ }
2193
2596
  }
2194
2597
 
2195
2598
  function positionSelectionToolbar() {
@@ -2259,34 +2662,72 @@ function selectElementFromTree(el) {
2259
2662
 
2260
2663
  function duplicateSelectedEl() {
2261
2664
  if (!selectedEl || !selectedEl.parentNode) return;
2665
+ var anchorSel = buildSelector(selectedEl);
2262
2666
  var clone = selectedEl.cloneNode(true);
2263
2667
  clone.classList.remove('vve-selected');
2264
2668
  var all = clone.querySelectorAll ? clone.querySelectorAll('.vve-selected') : [];
2265
2669
  for (var i = 0; i < all.length; i++) all[i].classList.remove('vve-selected');
2266
2670
  clone.style.visibility = '';
2267
2671
  if (clone.removeAttribute) clone.removeAttribute('data-vve-hidden');
2672
+ stripDataVveInstanceSubtree(clone);
2673
+ try {
2674
+ if (clone.id) clone.removeAttribute('id');
2675
+ } catch(_) {}
2676
+ try {
2677
+ clone.setAttribute('data-vve-instance', generateVveInstanceId());
2678
+ } catch(_) {}
2679
+ if (activeVarId) {
2680
+ appendSessionStructuralChainRow(activeVarId, {
2681
+ selector: anchorSel,
2682
+ type: 'insert',
2683
+ action: 'after',
2684
+ html: clone.outerHTML,
2685
+ });
2686
+ }
2268
2687
  selectedEl.parentNode.insertBefore(clone, selectedEl.nextSibling);
2269
- markDirty();
2688
+ saveCurrentVariationHtml();
2689
+ recomputeEditorDirty();
2270
2690
  scheduleDomTreeRefresh();
2271
2691
  selectElement(clone);
2272
2692
  }
2273
2693
 
2274
2694
  function toggleHideSelectedEl() {
2275
2695
  if (!selectedEl) return;
2696
+ var hidSel = buildSelector(selectedEl);
2276
2697
  if (selectedEl.getAttribute('data-vve-hidden') === '1') {
2277
2698
  selectedEl.style.visibility = '';
2278
2699
  selectedEl.removeAttribute('data-vve-hidden');
2700
+ if (activeVarId) {
2701
+ appendSessionStructuralChainRow(activeVarId, {
2702
+ selector: hidSel,
2703
+ type: 'style',
2704
+ property: 'visibility',
2705
+ value: '',
2706
+ });
2707
+ }
2279
2708
  } else {
2280
2709
  selectedEl.style.visibility = 'hidden';
2281
2710
  selectedEl.setAttribute('data-vve-hidden', '1');
2711
+ if (activeVarId) {
2712
+ appendSessionStructuralChainRow(activeVarId, {
2713
+ selector: hidSel,
2714
+ type: 'style',
2715
+ property: 'visibility',
2716
+ value: 'hidden',
2717
+ });
2718
+ }
2282
2719
  }
2283
- markDirty();
2720
+ saveCurrentVariationHtml();
2721
+ recomputeEditorDirty();
2284
2722
  }
2285
2723
 
2286
2724
  function deleteSelectedEl() {
2287
2725
  if (!selectedEl || !selectedEl.parentNode) return;
2726
+ var delSel = buildSelector(selectedEl);
2288
2727
  selectedEl.remove();
2289
- markDirty();
2728
+ if (activeVarId) appendSessionStructuralChainRow(activeVarId, { selector: delSel, type: 'remove' });
2729
+ saveCurrentVariationHtml();
2730
+ recomputeEditorDirty();
2290
2731
  deselectElement();
2291
2732
  scheduleDomTreeRefresh();
2292
2733
  }
@@ -2578,14 +3019,12 @@ function renderImageSection(el) {
2578
3019
  var prev = document.querySelector('.img-preview');
2579
3020
  if (prev) prev.src = srcInp.value;
2580
3021
  logChange(sel, 'pp-src', srcInp.value, el, orig);
2581
- markDirty();
2582
3022
  });
2583
3023
  var altInp = document.getElementById('pp-img-alt');
2584
3024
  if (altInp) altInp.addEventListener('input', function() {
2585
3025
  var orig = getOriginalValue('pp-alt', el);
2586
3026
  el.setAttribute('alt', altInp.value);
2587
3027
  logChange(sel, 'pp-alt', altInp.value, el, orig);
2588
- markDirty();
2589
3028
  });
2590
3029
 
2591
3030
  // Wire srcset entry inputs
@@ -2594,7 +3033,8 @@ function renderImageSection(el) {
2594
3033
  var dInp = document.getElementById('pp-se-desc-'+i);
2595
3034
  function flushSrcset() {
2596
3035
  el.setAttribute('srcset', buildSrcset(_srcsetEntries));
2597
- markDirty();
3036
+ saveCurrentVariationHtml();
3037
+ recomputeEditorDirty();
2598
3038
  }
2599
3039
  if (uInp) uInp.addEventListener('input', function(){ _srcsetEntries[i].url = uInp.value; flushSrcset(); });
2600
3040
  if (dInp) dInp.addEventListener('input', function(){ _srcsetEntries[i].descriptor = dInp.value; flushSrcset(); });
@@ -2612,7 +3052,8 @@ function renderImageSection(el) {
2612
3052
  }
2613
3053
  function flushSizes() {
2614
3054
  el.setAttribute('sizes', buildSizes(_sizesEntries));
2615
- markDirty();
3055
+ saveCurrentVariationHtml();
3056
+ recomputeEditorDirty();
2616
3057
  }
2617
3058
  if (opInp) opInp.addEventListener('change', function(){ buildCondition(); flushSizes(); });
2618
3059
  if (valInp) valInp.addEventListener('input', function(){ buildCondition(); flushSizes(); });
@@ -2626,7 +3067,12 @@ function addSrcsetEntry() {
2626
3067
  }
2627
3068
  function removeSrcsetEntry(i) {
2628
3069
  _srcsetEntries.splice(i, 1);
2629
- if (_imageEl) { _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries)); renderImageSection(_imageEl); markDirty(); }
3070
+ if (_imageEl) {
3071
+ _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries));
3072
+ renderImageSection(_imageEl);
3073
+ saveCurrentVariationHtml();
3074
+ recomputeEditorDirty();
3075
+ }
2630
3076
  }
2631
3077
  function addSizesEntry() {
2632
3078
  _sizesEntries.push({condition:'max-width: 760px', value:'760px'});
@@ -2634,7 +3080,12 @@ function addSizesEntry() {
2634
3080
  }
2635
3081
  function removeSizesEntry(i) {
2636
3082
  _sizesEntries.splice(i, 1);
2637
- if (_imageEl) { _imageEl.setAttribute('sizes', buildSizes(_sizesEntries)); renderImageSection(_imageEl); markDirty(); }
3083
+ if (_imageEl) {
3084
+ _imageEl.setAttribute('sizes', buildSizes(_sizesEntries));
3085
+ renderImageSection(_imageEl);
3086
+ saveCurrentVariationHtml();
3087
+ recomputeEditorDirty();
3088
+ }
2638
3089
  }
2639
3090
 
2640
3091
  // \u2500\u2500 Right panel rendering (accordion) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -2839,20 +3290,51 @@ function renderRightPanel(el) {
2839
3290
  var orig = getOriginalValue(b[0], el);
2840
3291
  b[1](inp.value);
2841
3292
  logChange(sel, b[0], inp.value, el, orig);
2842
- markDirty();
2843
3293
  });
2844
3294
  });
2845
3295
  }
2846
3296
 
2847
3297
  // \u2500\u2500 Selector helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3298
+ function generateVveInstanceId() {
3299
+ return 'v' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
3300
+ }
3301
+
3302
+ /** Editor-assigned clone marker so duplicated subtrees do not share the same CSS path as the original. */
3303
+ function stripDataVveInstanceSubtree(root) {
3304
+ if (!root || root.nodeType !== 1) return;
3305
+ try {
3306
+ root.removeAttribute('data-vve-instance');
3307
+ } catch(_) {}
3308
+ var sub = root.querySelectorAll ? root.querySelectorAll('[data-vve-instance]') : [];
3309
+ for (var i = 0; i < sub.length; i++) {
3310
+ try {
3311
+ sub[i].removeAttribute('data-vve-instance');
3312
+ } catch(_) {}
3313
+ }
3314
+ }
3315
+
2848
3316
  function buildSelector(el) {
2849
3317
  if (!el) return '';
3318
+ var doc = el.ownerDocument || document;
3319
+ var inst = el.getAttribute && el.getAttribute('data-vve-instance');
3320
+ if (inst && String(inst).trim()) {
3321
+ var safe = String(inst).split('"').join('\\"');
3322
+ var attrSel = '[data-vve-instance="' + safe + '"]';
3323
+ try {
3324
+ if (doc.querySelectorAll(attrSel).length === 1) return attrSel;
3325
+ } catch(_) {}
3326
+ }
2850
3327
  if (el.id) return '#' + el.id;
2851
3328
  var parts = [], node = el, depth = 0;
2852
3329
  while (node && node.nodeType === 1 && depth < 5) {
2853
3330
  if (node.id) { parts.unshift('#' + node.id); break; }
2854
3331
  var p = node.tagName.toLowerCase();
2855
- if (node.classList && node.classList.length) p += '.' + Array.from(node.classList).slice(0,2).join('.');
3332
+ if (node.classList && node.classList.length) {
3333
+ var clsArr = Array.from(node.classList).filter(function(c) {
3334
+ return c.indexOf('vve-') !== 0;
3335
+ });
3336
+ if (clsArr.length) p += '.' + clsArr.slice(0, 2).join('.');
3337
+ }
2856
3338
  var idx = 1, sib = node.previousElementSibling;
2857
3339
  while (sib) { if (sib.tagName === node.tagName) idx++; sib = sib.previousElementSibling; }
2858
3340
  if (idx > 1) p += ':nth-of-type(' + idx + ')';
@@ -2863,6 +3345,71 @@ function buildSelector(el) {
2863
3345
  return parts.join(' > ');
2864
3346
  }
2865
3347
 
3348
+ /**
3349
+ * Strip editor-only .vve-* class tokens from a selector string (fixes DB rows saved while an element was selected).
3350
+ */
3351
+ function sanitizeSelectorForMatch(sel) {
3352
+ if (!sel || typeof sel !== 'string') return '';
3353
+ var s0 = sel.replace(/.vve-[a-zA-Z0-9_-]+/gi, '');
3354
+ var parts = s0.split(/s*>s*/).map(function(seg) {
3355
+ var t = seg.replace(/.+/g, '.').replace(/.$/, '');
3356
+ return t.trim();
3357
+ });
3358
+ return parts.filter(Boolean).join(' > ');
3359
+ }
3360
+
3361
+ /** Drop the rightmost :nth-of-type(n) (hydration / layout often shifts sibling indices). */
3362
+ function stripRightmostNthOfType(sel) {
3363
+ if (!sel || typeof sel !== 'string') return null;
3364
+ var idx = sel.lastIndexOf(':nth-of-type(');
3365
+ if (idx === -1) return null;
3366
+ var j = idx + ':nth-of-type('.length;
3367
+ while (j < sel.length && sel.charCodeAt(j) >= 48 && sel.charCodeAt(j) <= 57) j++;
3368
+ if (j >= sel.length || sel.charAt(j) !== ')') return null;
3369
+ return (sel.slice(0, idx) + sel.slice(j + 1))
3370
+ .replace(/s{2,}/g, ' ')
3371
+ .replace(/s*>s*>/g, ' >')
3372
+ .trim();
3373
+ }
3374
+
3375
+ function querySelectorResolved(iframeDoc, selector) {
3376
+ if (!iframeDoc || !selector) return null;
3377
+ var seen = {};
3378
+ function tryOne(s) {
3379
+ if (!s || seen[s]) return null;
3380
+ seen[s] = true;
3381
+ try {
3382
+ return iframeDoc.querySelector(s) || null;
3383
+ } catch(_) {
3384
+ return null;
3385
+ }
3386
+ }
3387
+ function walkRelax(base) {
3388
+ if (!base) return null;
3389
+ var el = tryOne(base);
3390
+ if (el) return el;
3391
+ var cur = base;
3392
+ for (var g = 0; g < 28; g++) {
3393
+ var nxt = stripRightmostNthOfType(cur);
3394
+ if (!nxt || nxt === cur) break;
3395
+ cur = nxt;
3396
+ el = tryOne(cur);
3397
+ if (el) return el;
3398
+ }
3399
+ return null;
3400
+ }
3401
+ var alt = sanitizeSelectorForMatch(selector);
3402
+ // Prefer sanitized + nth relax FIRST: raw selectors often still contain .vve-* from
3403
+ // save-time selection; those only match after clicking (we re-add vve-selected).
3404
+ var el = walkRelax(alt || selector);
3405
+ if (el) return el;
3406
+ if (alt !== selector) {
3407
+ el = walkRelax(selector);
3408
+ if (el) return el;
3409
+ }
3410
+ return null;
3411
+ }
3412
+
2866
3413
  // \u2500\u2500 Iframe interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2867
3414
  function repositionDragSibling(dragEl, clientY) {
2868
3415
  var p = dragEl.parentElement;
@@ -2888,6 +3435,27 @@ function repositionDragSibling(dragEl, clientY) {
2888
3435
  }
2889
3436
  }
2890
3437
 
3438
+ function recordReorderAfterDrag(movedEl) {
3439
+ if (!activeVarId || !movedEl || !movedEl.parentElement) return;
3440
+ var prev = movedEl.previousElementSibling;
3441
+ var next = movedEl.nextElementSibling;
3442
+ if (prev) {
3443
+ appendSessionStructuralChainRow(activeVarId, {
3444
+ selector: buildSelector(movedEl),
3445
+ type: 'reorder',
3446
+ targetSelector: buildSelector(prev),
3447
+ action: 'after',
3448
+ });
3449
+ } else if (next) {
3450
+ appendSessionStructuralChainRow(activeVarId, {
3451
+ selector: buildSelector(movedEl),
3452
+ type: 'reorder',
3453
+ targetSelector: buildSelector(next),
3454
+ action: 'before',
3455
+ });
3456
+ }
3457
+ }
3458
+
2891
3459
  function attachDragReposition() {
2892
3460
  try {
2893
3461
  var iframe = document.getElementById('iframeId');
@@ -2941,7 +3509,9 @@ function attachDragReposition() {
2941
3509
  } catch(_) {}
2942
3510
  suppressClickUntil = Date.now() + 200;
2943
3511
  setDragHandleActive(false);
2944
- markDirty();
3512
+ if (activeVarId) recordReorderAfterDrag(selectedEl);
3513
+ saveCurrentVariationHtml();
3514
+ recomputeEditorDirty();
2945
3515
  updateSelectionToolbar();
2946
3516
  scheduleDomTreeRefresh();
2947
3517
  }
@@ -2995,8 +3565,7 @@ function attachChangeObserver() {
2995
3565
  changeObserverDoc = null;
2996
3566
  }
2997
3567
  changeObserver = new MutationObserver(function() {
2998
- markDirty();
2999
- // Debounced full rebuild of Elements panel as the live DOM grows / changes
3568
+ // Dirty state is derived from changesets baseline + stateChanges (not raw DOM mutations).
3000
3569
  scheduleDomTreeRefresh();
3001
3570
  scheduleGranularChangesetReapply();
3002
3571
  });
@@ -3032,6 +3601,7 @@ function syncIframeInteractions(reason) {
3032
3601
  var inp = document.getElementById('comp-search');
3033
3602
  renderDomTree(inp ? inp.value : '');
3034
3603
  updateSelectionToolbar();
3604
+ recomputeEditorDirty();
3035
3605
  } catch(_) {}
3036
3606
  }
3037
3607
 
@@ -3083,8 +3653,11 @@ function insertHtml(html) {
3083
3653
  console.warn('[V2] insertHtml: iframe document not ready');
3084
3654
  return;
3085
3655
  }
3656
+ var htmlStr = String(html).trim();
3657
+ var anchorSel =
3658
+ selectedEl && selectedEl !== doc.body && selectedEl.parentNode ? buildSelector(selectedEl) : 'body';
3086
3659
  var t = doc.createElement('template');
3087
- t.innerHTML = String(html).trim();
3660
+ t.innerHTML = htmlStr;
3088
3661
  var frag = doc.createDocumentFragment();
3089
3662
  var firstEl = null;
3090
3663
  while (t.content.firstChild) {
@@ -3100,7 +3673,16 @@ function insertHtml(html) {
3100
3673
  doc.body.appendChild(frag);
3101
3674
  }
3102
3675
  if (firstEl) selectElement(firstEl);
3103
- markDirty();
3676
+ if (activeVarId) {
3677
+ appendSessionStructuralChainRow(activeVarId, {
3678
+ selector: anchorSel,
3679
+ type: 'insert',
3680
+ action: 'after',
3681
+ html: htmlStr,
3682
+ });
3683
+ }
3684
+ saveCurrentVariationHtml();
3685
+ recomputeEditorDirty();
3104
3686
  scheduleDomTreeRefresh();
3105
3687
  } catch(err) { console.warn('[V2] insertHtml:', err); }
3106
3688
  }
@@ -3192,15 +3774,37 @@ document.getElementById('btn-close').addEventListener('click', handleClose);
3192
3774
  function handleSave() {
3193
3775
  saveCurrentVariationHtml();
3194
3776
  var updatedVariations = variations.map(function(v) {
3195
- var saved = varHtmlCache[v._id];
3196
- if (!saved) return Object.assign({}, v);
3197
- return Object.assign({}, v, { changesets: JSON.stringify([{ selector: '__vvveb_body__', html: saved, mutations: [] }]) });
3777
+ var prevParsed = parseVariationChangesets(v);
3778
+ var granularPrev = filterGranularChangesetEntries(prevParsed);
3779
+ var bodyOnlyLegacy = changesetsHaveBodySnapshot(prevParsed) && granularPrev.length === 0;
3780
+
3781
+ var rows = buildPersistedChainSetsForVariation(v);
3782
+ if (bodyOnlyLegacy && rows.length === 0) {
3783
+ return Object.assign({}, v);
3784
+ }
3785
+ var json = '[]';
3786
+ try {
3787
+ json = JSON.stringify(rows || []);
3788
+ } catch(_) {
3789
+ json = '[]';
3790
+ }
3791
+ return Object.assign({}, v, { changesets: json });
3198
3792
  });
3793
+ variations = updatedVariations;
3794
+ varHtmlCache = {};
3795
+ sessionStructuralChainRowsByVarId = {};
3796
+ stateChanges = [];
3797
+ if (currentMainTab === 'states') renderStatesTab();
3798
+ captureBaselineFromVariations(variations);
3799
+ if (experimentData && Array.isArray(experimentData.variations)) {
3800
+ experimentData.variations = updatedVariations;
3801
+ }
3199
3802
  send('save-experiment', { experimentId: experimentData ? experimentData.experimentId : null, variations: updatedVariations });
3200
- markClean();
3803
+ setEditorDirty(false);
3201
3804
  }
3202
3805
 
3203
3806
  function handleClose() {
3807
+ clearVisualEditorLocalStorage();
3204
3808
  // Unsaved-changes UX lives in the parent (PlatformVisualEditorV2); avoid double confirm here.
3205
3809
  send('close-editor', {});
3206
3810
  }
@@ -3208,8 +3812,22 @@ function handleClose() {
3208
3812
  // \u2500\u2500 Keyboard shortcuts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3209
3813
  document.addEventListener('keydown', function(e) {
3210
3814
  var meta = e.metaKey || e.ctrlKey;
3211
- if (meta && !e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.undo(); }
3212
- if (meta && e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.redo(); }
3815
+ if (meta && !e.shiftKey && e.key === 'z') {
3816
+ e.preventDefault();
3817
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3818
+ Vvveb.Undo.undo();
3819
+ saveCurrentVariationHtml();
3820
+ recomputeEditorDirty();
3821
+ }
3822
+ }
3823
+ if (meta && e.shiftKey && e.key === 'z') {
3824
+ e.preventDefault();
3825
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3826
+ Vvveb.Undo.redo();
3827
+ saveCurrentVariationHtml();
3828
+ recomputeEditorDirty();
3829
+ }
3830
+ }
3213
3831
  if (meta && e.key === 's') { e.preventDefault(); handleSave(); }
3214
3832
  if (e.key === 'Escape') {
3215
3833
  var openTips = document.querySelectorAll('.ve-pl-tip.is-tip-open');