@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.js CHANGED
@@ -842,6 +842,7 @@ select.pr-inp{cursor:pointer;background:#fff}
842
842
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/inputs.js"></script>
843
843
  <!-- components.js defines shared colour-class arrays used by bootstrap5/widgets components -->
844
844
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components.js"></script>
845
+ <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-html.js"></script>
845
846
  <script>
846
847
  /* Safety stub: if components.js didn't define these, create empty arrays so
847
848
  components-bootstrap5.js doesn't throw ReferenceError on load. */
@@ -855,6 +856,18 @@ if (typeof colorSelectOptions === 'undefined') window.colorSelectOptions =
855
856
  if (typeof textColorSelectOptions=== 'undefined') window.textColorSelectOptions= [];
856
857
  if (typeof borderSelectOptions === 'undefined') window.borderSelectOptions = [];
857
858
  if (typeof sizeSelectOptions === 'undefined') window.sizeSelectOptions = [];
859
+ if (window.Vvveb && window.Vvveb.Components) {
860
+ if (!window.Vvveb.ComponentsGroup) window.Vvveb.ComponentsGroup = {};
861
+ if (!window.Vvveb.ComponentsGroup['Bootstrap 5']) window.Vvveb.ComponentsGroup['Bootstrap 5'] = [];
862
+ try {
863
+ var baseExists =
864
+ window.Vvveb.Components._components &&
865
+ Object.prototype.hasOwnProperty.call(window.Vvveb.Components._components, '_base');
866
+ if (!baseExists && typeof window.Vvveb.Components.add === 'function') {
867
+ window.Vvveb.Components.add('_base', { name: 'Base', properties: [] });
868
+ }
869
+ } catch(_) {}
870
+ }
858
871
  </script>
859
872
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-bootstrap5.js"></script>
860
873
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-widgets.js"></script>
@@ -906,6 +919,8 @@ var experimentData = null;
906
919
  var variations = [];
907
920
  var activeVarId = null;
908
921
  var varHtmlCache = {};
922
+ /** Per-variation chain rows from structural actions (insert/duplicate/delete/reorder/hide), merged on Finalize. */
923
+ var sessionStructuralChainRowsByVarId = {};
909
924
  /** Last iframe proxy URL we navigated to \u2014 used to skip redundant reloads when parent re-sends load-experiment */
910
925
  var lastLoadedProxyUrl = '';
911
926
  /** API changeset rows (excluding __vvveb_body__) reapplied until selectors match late-hydrated DOM */
@@ -919,6 +934,8 @@ var iframeContentNavGen = 0;
919
934
  var iframeContentApplyTimer = null;
920
935
  var iframeEarlyGranularPrimedForGen = null;
921
936
  var iframeEarlySyncPrimedForGen = null;
937
+ /** insert/reorder entries are applied from early granular + full apply \u2014 skip exact duplicates per iframe nav */
938
+ var appliedStructuralChangesetKeys = {};
922
939
  var isDirty = false;
923
940
  var vvvebReady = false;
924
941
  var currentMode = 'editor';
@@ -938,6 +955,8 @@ var selectionResizeBound = false;
938
955
  var clickAttachDoc = null;
939
956
  var changeObserver = null;
940
957
  var changeObserverDoc = null;
958
+ /** Incremented while applying changesets / selection chrome so MutationObserver does not mark dirty */
959
+ var suppressIframeMutationDirty = 0;
941
960
  /** { doc, onRS } \u2014 iframe document readystate until complete */
942
961
  var iframeDocLoadingListeners = null;
943
962
  // Each entry: {selector, label, cssProp, value, targetEl}
@@ -945,12 +964,89 @@ var iframeDocLoadingListeners = null;
945
964
  var stateChanges = [];
946
965
  /** Pre-apply DOM snapshots for granular + body changesets (used when removing History rows) */
947
966
  var appliedChangesetSnapshots = {};
967
+ /** Canonical JSON fingerprints of persisted changesets per variation (last load / finalize) */
968
+ var baselineChangesetsByVarId = {};
969
+
970
+ // \u2500\u2500 Dirty tracking (compare DB baseline + session stateChanges vs current export) \u2500\u2500
971
+ function beginSuppressIframeMutationDirty() {
972
+ suppressIframeMutationDirty += 1;
973
+ }
974
+
975
+ function endSuppressIframeMutationDirty() {
976
+ suppressIframeMutationDirty = Math.max(0, suppressIframeMutationDirty - 1);
977
+ }
978
+
979
+ /** Stable stringify of a variation's changesets field (string or array from API). */
980
+ function fingerprintChangesetsField(raw) {
981
+ if (raw == null) return '[]';
982
+ if (Array.isArray(raw)) {
983
+ try {
984
+ return JSON.stringify(raw);
985
+ } catch(_) {
986
+ return '[]';
987
+ }
988
+ }
989
+ if (typeof raw !== 'string') return '[]';
990
+ var s = raw.trim();
991
+ if (!s) return '[]';
992
+ try {
993
+ var p = JSON.parse(s);
994
+ return JSON.stringify(Array.isArray(p) ? p : []);
995
+ } catch(_) {
996
+ return '[]';
997
+ }
998
+ }
999
+
1000
+ function captureBaselineFromVariations(list) {
1001
+ baselineChangesetsByVarId = {};
1002
+ if (!list || !list.length) return;
1003
+ for (var i = 0; i < list.length; i++) {
1004
+ var v = list[i];
1005
+ if (!v || !v._id) continue;
1006
+ baselineChangesetsByVarId[v._id] = fingerprintChangesetsField(v.changesets);
1007
+ }
1008
+ }
1009
+
1010
+ /** Fingerprint of what Finalize would send for this variation (matches buildPersistedChainSetsForVariation). */
1011
+ function persistedExportFingerprintForVariation(v) {
1012
+ if (!v || !v._id) return '[]';
1013
+ try {
1014
+ var rows = buildPersistedChainSetsForVariation(v);
1015
+ return JSON.stringify(rows || []);
1016
+ } catch(_) {
1017
+ return '[]';
1018
+ }
1019
+ }
1020
+
1021
+ function setEditorDirty(dirty) {
1022
+ var was = isDirty;
1023
+ isDirty = !!dirty;
1024
+ var dot = document.getElementById('dirty-dot');
1025
+ if (dot) dot.classList.toggle('on', isDirty);
1026
+ if (isDirty && !was) send('mutations-changed', {});
1027
+ if (!isDirty && was) send('editor-dirty', { dirty: false });
1028
+ if (!isDirty) {
1029
+ savedAt = Date.now();
1030
+ updateSaveTime();
1031
+ }
1032
+ }
948
1033
 
949
- // \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
950
- function markDirty() {
951
- isDirty = true;
952
- document.getElementById('dirty-dot').classList.add('on');
953
- send('mutations-changed', {});
1034
+ function recomputeEditorDirty() {
1035
+ var d = stateChanges.length > 0;
1036
+ if (!d && variations && variations.length) {
1037
+ for (var i = 0; i < variations.length; i++) {
1038
+ var v = variations[i];
1039
+ var vid = v._id;
1040
+ var cur = persistedExportFingerprintForVariation(v);
1041
+ var base = baselineChangesetsByVarId[vid];
1042
+ if (base == null) base = '[]';
1043
+ if (cur !== base) {
1044
+ d = true;
1045
+ break;
1046
+ }
1047
+ }
1048
+ }
1049
+ setEditorDirty(d);
954
1050
  }
955
1051
  var savedAt = null;
956
1052
  function updateSaveTime() {
@@ -960,12 +1056,6 @@ function updateSaveTime() {
960
1056
  el.textContent = s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago';
961
1057
  }
962
1058
  setInterval(updateSaveTime, 10000);
963
- function markClean() {
964
- isDirty = false;
965
- savedAt = Date.now();
966
- document.getElementById('dirty-dot').classList.remove('on');
967
- updateSaveTime();
968
- }
969
1059
 
970
1060
  // \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
971
1061
  function setMode(mode) {
@@ -1142,6 +1232,7 @@ function logChange(selector, inputId, value, targetEl, originalValue) {
1142
1232
  if (idx >= 0) { stateChanges[idx] = entry; } else { stateChanges.push(entry); }
1143
1233
  }
1144
1234
  if (currentMainTab === 'states') renderStatesTab();
1235
+ recomputeEditorDirty();
1145
1236
  }
1146
1237
 
1147
1238
  function renderStatesTab() {
@@ -1175,7 +1266,7 @@ function renderStatesTab() {
1175
1266
 
1176
1267
  // Resolve a live DOM element for a state-change entry.
1177
1268
  // Tries the stored direct reference first; if it's detached or missing,
1178
- // falls back to querySelector(selector) inside the iframe document.
1269
+ // falls back to querySelector (with .vve-* class stripped) inside the iframe document.
1179
1270
  function resolveChangeEl(change) {
1180
1271
  try {
1181
1272
  var iframeDoc = document.getElementById('iframeId').contentDocument;
@@ -1185,7 +1276,7 @@ function resolveChangeEl(change) {
1185
1276
  return change.targetEl;
1186
1277
  }
1187
1278
  // Fallback: re-query by the stored CSS selector
1188
- return iframeDoc.querySelector(change.selector) || null;
1279
+ return querySelectorResolved(iframeDoc, change.selector);
1189
1280
  } catch (e) {
1190
1281
  console.warn('[V2] resolveChangeEl:', e);
1191
1282
  return null;
@@ -1248,7 +1339,7 @@ function removeStateChange(idx) {
1248
1339
  syncDesignInput(change);
1249
1340
  stateChanges.splice(idx, 1);
1250
1341
  renderStatesTab();
1251
- markDirty();
1342
+ recomputeEditorDirty();
1252
1343
  }
1253
1344
 
1254
1345
  function clearAllStates() {
@@ -1258,7 +1349,7 @@ function clearAllStates() {
1258
1349
  });
1259
1350
  stateChanges = [];
1260
1351
  renderStatesTab();
1261
- markDirty();
1352
+ recomputeEditorDirty();
1262
1353
  }
1263
1354
 
1264
1355
  // \u2500\u2500 History tab (saved changesets from DB for active variation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -1283,10 +1374,11 @@ function persistActiveVariationChangesets(arr) {
1283
1374
 
1284
1375
  function entrySnapshotKey(entry) {
1285
1376
  if (!entry || !entry.selector) return '';
1377
+ var selKey = sanitizeSelectorForMatch(entry.selector) || entry.selector;
1286
1378
  return (
1287
- entry.selector +
1379
+ selKey +
1288
1380
  '\0' +
1289
- (entry.type || '') +
1381
+ normalizeChangesetType(entry) +
1290
1382
  '\0' +
1291
1383
  String(entry.property || '') +
1292
1384
  '\0' +
@@ -1304,7 +1396,7 @@ function captureChangesetSnapshotBeforeApply(entry, el, iframeDoc) {
1304
1396
  if (!entry || !el || entry.selector === '__vvveb_body__') return;
1305
1397
  var k = entrySnapshotKey(entry);
1306
1398
  if (appliedChangesetSnapshots[k]) return;
1307
- switch (entry.type) {
1399
+ switch (normalizeChangesetType(entry)) {
1308
1400
  case 'content':
1309
1401
  if (entry.html != null) {
1310
1402
  appliedChangesetSnapshots[k] = { kind: 'innerHTML', v: el.innerHTML };
@@ -1372,10 +1464,7 @@ function revertChangesetEntryOnDom(entry) {
1372
1464
  if (!iframeDoc) return false;
1373
1465
  var k = entrySnapshotKey(entry);
1374
1466
  var snap = appliedChangesetSnapshots[k];
1375
- var el = null;
1376
- try {
1377
- el = iframeDoc.querySelector(entry.selector);
1378
- } catch(_) {}
1467
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1379
1468
  if (!snap || !el) {
1380
1469
  softReloadEditorIframe();
1381
1470
  delete appliedChangesetSnapshots[k];
@@ -1402,7 +1491,7 @@ function revertChangesetEntryOnDom(entry) {
1402
1491
  function historyEntryTypeLabel(entry) {
1403
1492
  if (!entry) return 'Change';
1404
1493
  if (entry.selector === '__vvveb_body__') return 'Full page HTML';
1405
- var t = (entry.type || '').toLowerCase();
1494
+ var t = normalizeChangesetType(entry);
1406
1495
  if (t === 'content') return entry.html != null ? 'Inner HTML' : 'Text / content';
1407
1496
  if (t === 'style') return 'Style: ' + (entry.property || '');
1408
1497
  if (t === 'attribute') return 'Attribute: ' + (entry.attribute || '');
@@ -1416,7 +1505,8 @@ function historyEntryValuePreview(entry) {
1416
1505
  if (entry.selector === '__vvveb_body__') return '(body snapshot)';
1417
1506
  if (entry.html != null) return String(entry.html).slice(0, 120);
1418
1507
  if (entry.value != null) return String(entry.value).slice(0, 120);
1419
- if (entry.type === 'style' || entry.type === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1508
+ var nt = normalizeChangesetType(entry);
1509
+ if (nt === 'style' || nt === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1420
1510
  return '';
1421
1511
  }
1422
1512
 
@@ -1450,6 +1540,9 @@ function renderHistoryTab() {
1450
1540
  var val = historyEntryValuePreview(item.entry);
1451
1541
  html +=
1452
1542
  '<div class="state-item">' +
1543
+ '<span class="state-item-idx" style="opacity:0.55;font-size:10px;min-width:2.2em;display:inline-block" title="Order in saved changesets">#' +
1544
+ item.idx +
1545
+ '</span>' +
1453
1546
  '<span class="state-item-label">' +
1454
1547
  esc(lab) +
1455
1548
  '</span>' +
@@ -1458,7 +1551,9 @@ function renderHistoryTab() {
1458
1551
  '">' +
1459
1552
  esc(val) +
1460
1553
  '</span>' +
1461
- '<button type="button" class="state-remove" title="Remove from saved changesets" onclick="removeHistoryChangeset(' +
1554
+ '<button type="button" class="state-remove" title="Remove this saved row (#' +
1555
+ item.idx +
1556
+ ')" onclick="removeHistoryChangeset(' +
1462
1557
  item.idx +
1463
1558
  ')">&#x2715;</button>' +
1464
1559
  '</div>';
@@ -1468,6 +1563,16 @@ function renderHistoryTab() {
1468
1563
  container.innerHTML = html;
1469
1564
  }
1470
1565
 
1566
+ function changesetListHasStructural(arr) {
1567
+ if (!arr || !arr.length) return false;
1568
+ for (var i = 0; i < arr.length; i++) {
1569
+ var e = arr[i];
1570
+ var t = normalizeChangesetType(e);
1571
+ if (e && (t === 'insert' || t === 'reorder')) return true;
1572
+ }
1573
+ return false;
1574
+ }
1575
+
1471
1576
  function removeHistoryChangeset(idx) {
1472
1577
  var v = getActiveVariationForHistory();
1473
1578
  if (!v) return;
@@ -1480,18 +1585,31 @@ function removeHistoryChangeset(idx) {
1480
1585
  try {
1481
1586
  delete varHtmlCache[activeVarId];
1482
1587
  } catch(_) {}
1483
- if (!didReload) {
1588
+ // Re-applying remaining rows on top of current DOM duplicates insert/reorder nodes; reload when any
1589
+ // structural row remains or was removed (revert may already have started a reload for insert/body).
1590
+ var removedType = normalizeChangesetType(removed);
1591
+ var needsStructuralReload =
1592
+ !didReload &&
1593
+ (removedType === 'insert' ||
1594
+ removedType === 'reorder' ||
1595
+ changesetListHasStructural(arr));
1596
+ if (didReload) {
1597
+ /* revertChangesetEntryOnDom already kicked off iframe reload */
1598
+ } else if (needsStructuralReload) {
1599
+ softReloadEditorIframe();
1600
+ } else {
1484
1601
  try {
1602
+ appliedStructuralChangesetKeys = {};
1485
1603
  applyActiveVariationHtml();
1486
1604
  registerPendingGranularChangesets(
1487
1605
  arr,
1488
1606
  document.getElementById('iframeId').contentDocument,
1489
1607
  );
1608
+ saveCurrentVariationHtml();
1490
1609
  } catch(_) {}
1491
- saveCurrentVariationHtml();
1492
1610
  }
1493
1611
  if (currentMainTab === 'history') renderHistoryTab();
1494
- markDirty();
1612
+ recomputeEditorDirty();
1495
1613
  scheduleDomTreeRefresh();
1496
1614
  }
1497
1615
 
@@ -1501,15 +1619,82 @@ function clearAllHistoryChangesets() {
1501
1619
  if (!parseVariationChangesets(v).length) return;
1502
1620
  persistActiveVariationChangesets([]);
1503
1621
  appliedChangesetSnapshots = {};
1622
+ appliedStructuralChangesetKeys = {};
1504
1623
  try {
1505
1624
  delete varHtmlCache[activeVarId];
1506
1625
  } catch(_) {}
1507
1626
  if (currentMainTab === 'history') renderHistoryTab();
1508
- markDirty();
1627
+ recomputeEditorDirty();
1509
1628
  scheduleDomTreeRefresh();
1510
1629
  softReloadEditorIframe();
1511
1630
  }
1512
1631
 
1632
+ // \u2500\u2500 Persisted active variation (survives iframe / full page reload) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
1633
+ /** All Visual Editor iframe keys in localStorage use this prefix \u2014 cleared on close. */
1634
+ var VVE_LOCAL_STORAGE_PREFIX = 'vve:';
1635
+
1636
+ function clearVisualEditorLocalStorage() {
1637
+ try {
1638
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1639
+ var k = localStorage.key(i);
1640
+ if (k && k.indexOf(VVE_LOCAL_STORAGE_PREFIX) === 0) {
1641
+ localStorage.removeItem(k);
1642
+ }
1643
+ }
1644
+ } catch(_) {}
1645
+ }
1646
+
1647
+ function activeVariationStorageKeyFromPayload(data) {
1648
+ return (
1649
+ VVE_LOCAL_STORAGE_PREFIX +
1650
+ 'activeVar:' +
1651
+ String((data && data.experimentId) || '') +
1652
+ ':' +
1653
+ String((data && data.pageUrl) || '')
1654
+ );
1655
+ }
1656
+
1657
+ function readPersistedActiveVariationId(data) {
1658
+ try {
1659
+ var sk = activeVariationStorageKeyFromPayload(data);
1660
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return null;
1661
+ return localStorage.getItem(sk);
1662
+ } catch(_) {
1663
+ return null;
1664
+ }
1665
+ }
1666
+
1667
+ function writePersistedActiveVariationId(varId) {
1668
+ try {
1669
+ if (!experimentData || !experimentData.experimentId) return;
1670
+ var sk =
1671
+ VVE_LOCAL_STORAGE_PREFIX +
1672
+ 'activeVar:' +
1673
+ String(experimentData.experimentId || '') +
1674
+ ':' +
1675
+ String(experimentData.pageUrl || '');
1676
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return;
1677
+ if (varId) localStorage.setItem(sk, String(varId));
1678
+ } catch(_) {}
1679
+ }
1680
+
1681
+ /**
1682
+ * @param allowPrevMemory when true, keep in-session activeVarId if still valid (skip-reload path).
1683
+ */
1684
+ function pickActiveVariationIdForLoad(data, variationsArr, prevMemoryId, allowPrevMemory) {
1685
+ var baseline = variationsArr.find(function(v) { return v.baseline; });
1686
+ var fallback = (baseline || variationsArr[0] || {})._id || null;
1687
+ if (!variationsArr.length) return null;
1688
+ if (allowPrevMemory && prevMemoryId && variationsArr.some(function(v) { return v._id === prevMemoryId; })) {
1689
+ return prevMemoryId;
1690
+ }
1691
+ var stored = readPersistedActiveVariationId(data);
1692
+ if (stored && variationsArr.some(function(v) { return v._id === stored; })) {
1693
+ return stored;
1694
+ }
1695
+ return fallback;
1696
+ }
1697
+
1513
1698
  // \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
1514
1699
  function handleLoadExperiment(data) {
1515
1700
  clearPendingGranularChangesets();
@@ -1537,10 +1722,8 @@ function handleLoadExperiment(data) {
1537
1722
  experimentData = data;
1538
1723
  variations = Array.isArray(data.variations) ? data.variations : [];
1539
1724
  var prevActive = activeVarId;
1540
- var baseline = variations.find(function(v) { return v.baseline; });
1541
- var fallback = (baseline || variations[0] || {})._id || null;
1542
- activeVarId =
1543
- prevActive && variations.some(function(v) { return v._id === prevActive; }) ? prevActive : fallback;
1725
+ activeVarId = pickActiveVariationIdForLoad(data, variations, prevActive, true);
1726
+ writePersistedActiveVariationId(activeVarId);
1544
1727
  renderVariationTabs();
1545
1728
  var urlBarSkip = document.getElementById('url-bar');
1546
1729
  urlBarSkip.textContent = pageUrl;
@@ -1554,24 +1737,30 @@ function handleLoadExperiment(data) {
1554
1737
  if (ifrSkip && ifrSkip.contentDocument) attachIframeLoadingUntilComplete(ifrSkip);
1555
1738
  } catch(_) {}
1556
1739
  if (currentMainTab === 'history') renderHistoryTab();
1740
+ captureBaselineFromVariations(variations);
1741
+ recomputeEditorDirty();
1557
1742
  return;
1558
1743
  }
1559
1744
 
1560
1745
  if (!experimentData || prevKey !== nextKey) {
1561
1746
  varHtmlCache = {};
1747
+ sessionStructuralChainRowsByVarId = {};
1562
1748
  appliedChangesetSnapshots = {};
1749
+ appliedStructuralChangesetKeys = {};
1563
1750
  }
1564
1751
  experimentData = data;
1565
1752
  variations = Array.isArray(data.variations) ? data.variations : [];
1566
- // New document load: start on baseline so the first paint matches the live page.
1567
- var baseline = variations.find(function(v) { return v.baseline; });
1568
- activeVarId = (baseline || variations[0] || {})._id || null;
1753
+ var sameExpPage = prevKey === nextKey;
1754
+ activeVarId = pickActiveVariationIdForLoad(data, variations, activeVarId, sameExpPage);
1755
+ writePersistedActiveVariationId(activeVarId);
1569
1756
  renderVariationTabs();
1570
1757
 
1571
1758
  var urlBar = document.getElementById('url-bar');
1572
1759
  urlBar.textContent = pageUrl;
1573
1760
  urlBar.title = pageUrl;
1574
1761
  if (currentMainTab === 'history') renderHistoryTab();
1762
+ captureBaselineFromVariations(variations);
1763
+ recomputeEditorDirty();
1575
1764
  loadPage(proxyUrl);
1576
1765
  }
1577
1766
 
@@ -1671,9 +1860,7 @@ function granularAnySelectorMatches(doc, cs) {
1671
1860
  if (!doc || !cs || !cs.length) return false;
1672
1861
  var g = filterGranularChangesetEntries(cs);
1673
1862
  for (var i = 0; i < g.length; i++) {
1674
- try {
1675
- if (doc.querySelector(g[i].selector)) return true;
1676
- } catch(_) {}
1863
+ if (querySelectorResolved(doc, g[i].selector)) return true;
1677
1864
  }
1678
1865
  return false;
1679
1866
  }
@@ -1694,7 +1881,7 @@ function appendIframeReloadBust(url) {
1694
1881
  // applying variation changesets there is then wiped when the real navigation commits.
1695
1882
  function iframeDocMatchesNavigatedSrc(iframe, doc) {
1696
1883
  if (!iframe || !doc) return false;
1697
- var src = iframe.src || iframe.getAttribute('src') || '';
1884
+ var src = iframe.getAttribute('src') || iframe.src || '';
1698
1885
  if (!src || src === 'about:blank') return false;
1699
1886
  var loc = '';
1700
1887
  try {
@@ -1703,12 +1890,28 @@ function iframeDocMatchesNavigatedSrc(iframe, doc) {
1703
1890
  return false;
1704
1891
  }
1705
1892
  if (!loc || loc === 'about:blank') return false;
1706
- var rmSrc = src.match(/__ve_reload=([0-9]+)/);
1707
- if (rmSrc) return loc.indexOf('__ve_reload=' + rmSrc[1]) !== -1;
1708
1893
  try {
1709
1894
  var base = window.location.href;
1710
1895
  var su = new URL(src, base);
1896
+ if (su.searchParams && su.searchParams.has('__ve_reload')) {
1897
+ su.searchParams.delete('__ve_reload');
1898
+ }
1711
1899
  var du = new URL(loc, base);
1900
+ if (du.searchParams && du.searchParams.has('__ve_reload')) {
1901
+ du.searchParams.delete('__ve_reload');
1902
+ }
1903
+ // Same-origin proxy that keeps document address aligned with iframe src
1904
+ if (su.origin === du.origin && su.pathname + su.search === du.pathname + du.search) {
1905
+ return true;
1906
+ }
1907
+ // conversion-proxy: iframe src stays on our app, but doc.URL is usually the target site after redirects
1908
+ var p = su.pathname || '';
1909
+ var isRootProxyPath = p === '/api/conversion-proxy' || p.indexOf('/api/conversion-proxy/') === 0;
1910
+ var isNestedMalformedProxy = !isRootProxyPath && p.indexOf('api/conversion-proxy') !== -1;
1911
+ if (isNestedMalformedProxy) return false;
1912
+ if (isRootProxyPath || String(su.href).indexOf('conversion-proxy') !== -1) {
1913
+ return doc === iframe.contentDocument;
1914
+ }
1712
1915
  return su.pathname + su.search === du.pathname + du.search;
1713
1916
  } catch(_) {
1714
1917
  return false;
@@ -1741,6 +1944,7 @@ function resetIframeBindings() {
1741
1944
  detachIframeLoadingListeners();
1742
1945
  stopIframeContentApplyWatcher();
1743
1946
  appliedChangesetSnapshots = {};
1947
+ appliedStructuralChangesetKeys = {};
1744
1948
  clickAttachDoc = null;
1745
1949
  dragAttachDoc = null;
1746
1950
  changeObserverDoc = null;
@@ -1811,13 +2015,19 @@ function switchVariation(varId) {
1811
2015
  saveCurrentVariationHtml();
1812
2016
  clearPendingGranularChangesets();
1813
2017
  activeVarId = varId;
2018
+ writePersistedActiveVariationId(varId);
1814
2019
  renderVariationTabs();
1815
2020
  deselectElement();
1816
2021
  try {
1817
2022
  var iframe = document.getElementById('iframeId');
1818
2023
  var saved = varHtmlCache[varId];
1819
2024
  if (saved) {
1820
- iframe.contentDocument.body.innerHTML = saved;
2025
+ beginSuppressIframeMutationDirty();
2026
+ try {
2027
+ iframe.contentDocument.body.innerHTML = saved;
2028
+ } finally {
2029
+ endSuppressIframeMutationDirty();
2030
+ }
1821
2031
  detachIframeLoadingListeners();
1822
2032
  setIframePageLoadingUi(false);
1823
2033
  syncIframeInteractions('switch-variation-cache');
@@ -1840,6 +2050,7 @@ function switchVariation(varId) {
1840
2050
  }
1841
2051
  } catch(_) {}
1842
2052
  if (currentMainTab === 'history') renderHistoryTab();
2053
+ recomputeEditorDirty();
1843
2054
  }
1844
2055
 
1845
2056
  function saveCurrentVariationHtml() {
@@ -1878,31 +2089,123 @@ function parseVariationChangesets(variation) {
1878
2089
  }
1879
2090
  }
1880
2091
 
2092
+ /** Lowercase entry.type so persisted / API rows match switches (e.g. Insert vs insert). */
2093
+ function normalizeChangesetType(entry) {
2094
+ return String(entry && entry.type != null ? entry.type : '').toLowerCase();
2095
+ }
2096
+
1881
2097
  function filterGranularChangesetEntries(cs) {
1882
2098
  if (!cs || !cs.length) return [];
1883
2099
  var out = [];
1884
2100
  for (var i = 0; i < cs.length; i++) {
1885
2101
  var e = cs[i];
1886
- if (e && e.selector && e.selector !== '__vvveb_body__') out.push(e);
2102
+ if (!e || !e.selector || e.selector === '__vvveb_body__') continue;
2103
+ out.push(e);
1887
2104
  }
1888
2105
  return out;
1889
2106
  }
1890
2107
 
2108
+ /** Dedup key for merging chain-set rows (overlay wins over base). */
2109
+ function chainSetDedupKey(entry) {
2110
+ if (!entry || !entry.selector) return '';
2111
+ var t = normalizeChangesetType(entry);
2112
+ if (t === 'style') return entry.selector + '|s|' + String(entry.property || '');
2113
+ if (t === 'content') return entry.selector + '|c|' + (entry.html != null ? 'h' : 't');
2114
+ if (t === 'attribute') return entry.selector + '|a|' + String(entry.attribute || '');
2115
+ if (t === 'insert') return entry.selector + '|i|' + String(entry.action || '') + '|' + String(entry.html || '').slice(0, 120);
2116
+ if (t === 'remove') return entry.selector + '|r|';
2117
+ if (t === 'reorder') {
2118
+ return entry.selector + '|ro|' + String(entry.targetSelector || '') + '|' + String(entry.action || '');
2119
+ }
2120
+ try {
2121
+ return entry.selector + '|' + t + '|' + JSON.stringify(entry);
2122
+ } catch(_) {
2123
+ return entry.selector + '|' + t;
2124
+ }
2125
+ }
2126
+
2127
+ function mergeGranularChainSets(baseList, overlayList) {
2128
+ var map = {};
2129
+ var order = [];
2130
+ function ingest(arr) {
2131
+ if (!arr || !arr.length) return;
2132
+ for (var i = 0; i < arr.length; i++) {
2133
+ var e = arr[i];
2134
+ if (!e || !e.selector) continue;
2135
+ var k = chainSetDedupKey(e);
2136
+ if (!map[k]) order.push(k);
2137
+ map[k] = e;
2138
+ }
2139
+ }
2140
+ ingest(baseList);
2141
+ ingest(overlayList);
2142
+ var out = [];
2143
+ for (var j = 0; j < order.length; j++) {
2144
+ out.push(map[order[j]]);
2145
+ }
2146
+ return out;
2147
+ }
2148
+
2149
+ function appendSessionStructuralChainRow(varId, row) {
2150
+ if (!varId || !row) return;
2151
+ if (!sessionStructuralChainRowsByVarId[varId]) sessionStructuralChainRowsByVarId[varId] = [];
2152
+ sessionStructuralChainRowsByVarId[varId].push(row);
2153
+ }
2154
+
2155
+ /** One States-tab row -> Conversion.io chain-set shape (matches applyChangesetEntry). */
2156
+ function stateChangeToChainSet(c) {
2157
+ if (!c || !c.selector) return null;
2158
+ if (c.cssProp) {
2159
+ return { selector: c.selector, type: 'style', property: c.cssProp, value: c.value };
2160
+ }
2161
+ switch (c.inputId) {
2162
+ case 'pp-text':
2163
+ return { selector: c.selector, type: 'content', value: c.value };
2164
+ case 'pp-html':
2165
+ return { selector: c.selector, type: 'content', html: c.value };
2166
+ case 'pp-cls':
2167
+ return { selector: c.selector, type: 'attribute', attribute: 'class', value: c.value };
2168
+ case 'pp-id':
2169
+ return { selector: c.selector, type: 'attribute', attribute: 'id', value: c.value };
2170
+ case 'pp-href':
2171
+ return { selector: c.selector, type: 'attribute', attribute: 'href', value: c.value };
2172
+ case 'pp-target':
2173
+ return { selector: c.selector, type: 'attribute', attribute: 'target', value: c.value };
2174
+ case 'pp-src':
2175
+ return { selector: c.selector, type: 'attribute', attribute: 'src', value: c.value };
2176
+ case 'pp-alt':
2177
+ return { selector: c.selector, type: 'attribute', attribute: 'alt', value: c.value };
2178
+ case 'pp-ph':
2179
+ return { selector: c.selector, type: 'attribute', attribute: 'placeholder', value: c.value };
2180
+ case 'pp-css':
2181
+ return { selector: c.selector, type: 'attribute', attribute: 'style', value: c.value };
2182
+ case 'pp-mob-css':
2183
+ return { selector: c.selector, type: 'attribute', attribute: 'data-mobile-css', value: c.value };
2184
+ case 'pp-tab-css':
2185
+ return { selector: c.selector, type: 'attribute', attribute: 'data-tablet-css', value: c.value };
2186
+ default:
2187
+ return null;
2188
+ }
2189
+ }
2190
+
1891
2191
  function countUnresolvedGranularSelectors(iframeDoc, entries) {
1892
2192
  if (!iframeDoc || !entries || !entries.length) return 0;
1893
2193
  var n = 0;
1894
2194
  for (var i = 0; i < entries.length; i++) {
1895
- var el = null;
1896
- try { el = iframeDoc.querySelector(entries[i].selector); } catch(_) {}
1897
- if (!el) n++;
2195
+ if (!querySelectorResolved(iframeDoc, entries[i].selector)) n++;
1898
2196
  }
1899
2197
  return n;
1900
2198
  }
1901
2199
 
1902
2200
  function applyGranularChangesetEntries(iframeDoc, entries) {
1903
2201
  if (!iframeDoc || !entries || !entries.length) return;
1904
- for (var i = 0; i < entries.length; i++) {
1905
- applyChangesetEntry(entries[i], iframeDoc);
2202
+ beginSuppressIframeMutationDirty();
2203
+ try {
2204
+ for (var i = 0; i < entries.length; i++) {
2205
+ applyChangesetEntry(entries[i], iframeDoc);
2206
+ }
2207
+ } finally {
2208
+ endSuppressIframeMutationDirty();
1906
2209
  }
1907
2210
  }
1908
2211
 
@@ -1957,6 +2260,30 @@ function flushPendingGranularChangesets() {
1957
2260
  scheduleGranularChangesetReapply();
1958
2261
  }
1959
2262
 
2263
+ /** Stable key for structural changesets (insert/reorder) to avoid double-apply across early + full paint. */
2264
+ function structuralChangesetDedupKey(entry) {
2265
+ var nt = normalizeChangesetType(entry);
2266
+ if (!entry || (nt !== 'insert' && nt !== 'reorder')) return '';
2267
+ var vid = activeVarId || '';
2268
+ try {
2269
+ return (
2270
+ vid +
2271
+ '\0' +
2272
+ nt +
2273
+ '\0' +
2274
+ entry.selector +
2275
+ '\0' +
2276
+ String(entry.action || '') +
2277
+ '\0' +
2278
+ String(entry.html != null ? entry.html : '').slice(0, 240) +
2279
+ '\0' +
2280
+ String(entry.targetSelector || '')
2281
+ );
2282
+ } catch(_) {
2283
+ return vid + '\0' + nt + '\0' + entry.selector;
2284
+ }
2285
+ }
2286
+
1960
2287
  /**
1961
2288
  * Apply a single changeset entry inside the editor iframe.
1962
2289
  * Backend format: { selector, type: 'content'|'style'|'attribute'|'insert'|'remove', ... }
@@ -1976,21 +2303,34 @@ function applyChangesetEntry(entry, iframeDoc) {
1976
2303
  return;
1977
2304
  }
1978
2305
 
2306
+ var structuralDedupeKey = structuralChangesetDedupKey(entry);
2307
+ if (structuralDedupeKey && appliedStructuralChangesetKeys[structuralDedupeKey]) return;
2308
+
1979
2309
  // \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
1980
- var el = null;
1981
- try { el = iframeDoc.querySelector(entry.selector); } catch(_) {}
2310
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1982
2311
  if (!el) return;
1983
2312
 
1984
2313
  captureChangesetSnapshotBeforeApply(entry, el, iframeDoc);
1985
2314
 
1986
- switch (entry.type) {
2315
+ switch (normalizeChangesetType(entry)) {
1987
2316
  case 'content':
1988
2317
  if (entry.html != null) el.innerHTML = entry.html;
1989
2318
  else if (entry.value != null) el.textContent = entry.value;
1990
2319
  break;
1991
2320
  case 'style':
1992
- if (entry.property && entry.value != null) {
1993
- el.style[camelize(entry.property)] = entry.value;
2321
+ if (entry.property) {
2322
+ var propKebab = entry.property;
2323
+ var cam = camelize(propKebab);
2324
+ if (entry.value == null || entry.value === '') {
2325
+ try { el.style.removeProperty(propKebab); } catch(_) {}
2326
+ try { if (cam in el.style) el.style[cam] = ''; } catch(__) {}
2327
+ } else {
2328
+ try {
2329
+ el.style.setProperty(propKebab, entry.value, 'important');
2330
+ } catch(_) {
2331
+ el.style[cam] = entry.value;
2332
+ }
2333
+ }
1994
2334
  }
1995
2335
  break;
1996
2336
  case 'attribute':
@@ -2001,11 +2341,23 @@ function applyChangesetEntry(entry, iframeDoc) {
2001
2341
  case 'insert': {
2002
2342
  var pos = entry.action === 'before' ? 'beforebegin' : 'afterend';
2003
2343
  if (entry.html) el.insertAdjacentHTML(pos, entry.html);
2344
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2004
2345
  break;
2005
2346
  }
2006
2347
  case 'remove':
2007
2348
  el.style.display = 'none';
2008
2349
  break;
2350
+ case 'reorder': {
2351
+ var target = entry.targetSelector ? querySelectorResolved(iframeDoc, entry.targetSelector) : null;
2352
+ if (!target || !el.parentNode || !target.parentNode) break;
2353
+ if (entry.action === 'before') {
2354
+ target.parentNode.insertBefore(el, target);
2355
+ } else {
2356
+ target.parentNode.insertBefore(el, target.nextSibling);
2357
+ }
2358
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2359
+ break;
2360
+ }
2009
2361
  default: break;
2010
2362
  }
2011
2363
  }
@@ -2020,19 +2372,24 @@ function applyActiveVariationHtml() {
2020
2372
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2021
2373
  var cs = parseVariationChangesets(variation);
2022
2374
 
2023
- // If we have an in-session HTML snapshot, use it (user edited in this session)
2024
- var saved = varHtmlCache[activeVarId];
2025
- if (saved) {
2026
- try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2027
- return;
2028
- }
2375
+ beginSuppressIframeMutationDirty();
2376
+ try {
2377
+ // If we have an in-session HTML snapshot, use it (user edited in this session)
2378
+ var saved = varHtmlCache[activeVarId];
2379
+ if (saved) {
2380
+ try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2381
+ return;
2382
+ }
2029
2383
 
2030
- if (!cs.length) return;
2031
- for (var i = 0; i < cs.length; i++) {
2032
- applyChangesetEntry(cs[i], iframeDoc);
2384
+ if (!cs.length) return;
2385
+ for (var i = 0; i < cs.length; i++) {
2386
+ applyChangesetEntry(cs[i], iframeDoc);
2387
+ }
2388
+ // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2389
+ registerPendingGranularChangesets(cs, iframeDoc);
2390
+ } finally {
2391
+ endSuppressIframeMutationDirty();
2033
2392
  }
2034
- // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2035
- registerPendingGranularChangesets(cs, iframeDoc);
2036
2393
  }
2037
2394
 
2038
2395
  function changesetsHaveBodySnapshot(cs) {
@@ -2043,6 +2400,23 @@ function changesetsHaveBodySnapshot(cs) {
2043
2400
  return false;
2044
2401
  }
2045
2402
 
2403
+ /** Rows to persist for this variation on Finalize (same chain-set model as EditorShell \u2014 never __vvveb_body__). */
2404
+ function buildPersistedChainSetsForVariation(v) {
2405
+ if (!v || !v._id) return [];
2406
+ var parsed = parseVariationChangesets(v);
2407
+ var base = filterGranularChangesetEntries(parsed);
2408
+ var sessionExtra = sessionStructuralChainRowsByVarId[v._id] || [];
2409
+ if (v._id !== activeVarId) {
2410
+ return mergeGranularChainSets(base, sessionExtra);
2411
+ }
2412
+ var overlay = [];
2413
+ for (var si = 0; si < stateChanges.length; si++) {
2414
+ var row = stateChangeToChainSet(stateChanges[si]);
2415
+ if (row) overlay.push(row);
2416
+ }
2417
+ return mergeGranularChainSets(mergeGranularChainSets(base, sessionExtra), overlay);
2418
+ }
2419
+
2046
2420
  /**
2047
2421
  * While document.readyState === 'loading', apply only granular changesets (no __vvveb_body__
2048
2422
  * replacement) so the first painted nodes can receive edits before iframe/window load completes.
@@ -2053,10 +2427,15 @@ function applyVariationGranularOnly(iframeDoc) {
2053
2427
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2054
2428
  var cs = parseVariationChangesets(variation);
2055
2429
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2056
- for (var i = 0; i < cs.length; i++) {
2057
- applyChangesetEntry(cs[i], iframeDoc);
2430
+ beginSuppressIframeMutationDirty();
2431
+ try {
2432
+ for (var i = 0; i < cs.length; i++) {
2433
+ applyChangesetEntry(cs[i], iframeDoc);
2434
+ }
2435
+ registerPendingGranularChangesets(cs, iframeDoc);
2436
+ } finally {
2437
+ endSuppressIframeMutationDirty();
2058
2438
  }
2059
- registerPendingGranularChangesets(cs, iframeDoc);
2060
2439
  }
2061
2440
 
2062
2441
  /** Re-try granular entries without resetting pending registration (use between poll ticks). */
@@ -2066,8 +2445,13 @@ function reapplyActiveVariationGranular(iframeDoc) {
2066
2445
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2067
2446
  var cs = parseVariationChangesets(variation);
2068
2447
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2069
- for (var i = 0; i < cs.length; i++) {
2070
- applyChangesetEntry(cs[i], iframeDoc);
2448
+ beginSuppressIframeMutationDirty();
2449
+ try {
2450
+ for (var i = 0; i < cs.length; i++) {
2451
+ applyChangesetEntry(cs[i], iframeDoc);
2452
+ }
2453
+ } finally {
2454
+ endSuppressIframeMutationDirty();
2071
2455
  }
2072
2456
  }
2073
2457
 
@@ -2129,9 +2513,14 @@ function startIframeContentApplyWatcher(navGen) {
2129
2513
 
2130
2514
  // \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
2131
2515
  function selectElement(el) {
2132
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2133
- selectedEl = el;
2134
- try { el.classList.add('vve-selected'); } catch(_) {}
2516
+ beginSuppressIframeMutationDirty();
2517
+ try {
2518
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2519
+ selectedEl = el;
2520
+ try { el.classList.add('vve-selected'); } catch(_) {}
2521
+ } finally {
2522
+ endSuppressIframeMutationDirty();
2523
+ }
2135
2524
  document.getElementById('bc-path').textContent = buildSelector(el);
2136
2525
  document.getElementById('bc-path').style.color = 'var(--accent-txt)';
2137
2526
  document.getElementById('no-sel').style.display = 'none';
@@ -2146,7 +2535,12 @@ function selectElement(el) {
2146
2535
 
2147
2536
  function deselectElement() {
2148
2537
  setDragHandleActive(false);
2149
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2538
+ beginSuppressIframeMutationDirty();
2539
+ try {
2540
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2541
+ } finally {
2542
+ endSuppressIframeMutationDirty();
2543
+ }
2150
2544
  document.getElementById('no-sel').style.display = '';
2151
2545
  document.getElementById('el-info').style.display = 'none';
2152
2546
  document.getElementById('rp-accordion').style.display = 'none';
@@ -2170,18 +2564,27 @@ function injectIframeSelectionStyles(doc) {
2170
2564
  'html.vve-drag-armed .vve-selected{cursor:grab!important;}' +
2171
2565
  '.vve-dragging{opacity:0.92!important;outline:2px dashed #f59e0b!important;' +
2172
2566
  'outline-offset:2px!important;cursor:grabbing!important;box-shadow:none!important;}';
2173
- doc.head.appendChild(st);
2567
+ beginSuppressIframeMutationDirty();
2568
+ try {
2569
+ doc.head.appendChild(st);
2570
+ } finally {
2571
+ endSuppressIframeMutationDirty();
2572
+ }
2174
2573
  }
2175
2574
 
2176
2575
  function setDragHandleActive(on) {
2177
2576
  dragHandleActive = !!on;
2178
2577
  var b = document.getElementById('sf-drag');
2179
2578
  if (b) b.classList.toggle('active', dragHandleActive);
2579
+ beginSuppressIframeMutationDirty();
2180
2580
  try {
2181
2581
  var iframe = document.getElementById('iframeId');
2182
2582
  var d = iframe && iframe.contentDocument && iframe.contentDocument.documentElement;
2183
2583
  if (d) d.classList.toggle('vve-drag-armed', dragHandleActive);
2184
- } catch(_) {}
2584
+ } catch(_) {
2585
+ } finally {
2586
+ endSuppressIframeMutationDirty();
2587
+ }
2185
2588
  }
2186
2589
 
2187
2590
  function positionSelectionToolbar() {
@@ -2251,34 +2654,72 @@ function selectElementFromTree(el) {
2251
2654
 
2252
2655
  function duplicateSelectedEl() {
2253
2656
  if (!selectedEl || !selectedEl.parentNode) return;
2657
+ var anchorSel = buildSelector(selectedEl);
2254
2658
  var clone = selectedEl.cloneNode(true);
2255
2659
  clone.classList.remove('vve-selected');
2256
2660
  var all = clone.querySelectorAll ? clone.querySelectorAll('.vve-selected') : [];
2257
2661
  for (var i = 0; i < all.length; i++) all[i].classList.remove('vve-selected');
2258
2662
  clone.style.visibility = '';
2259
2663
  if (clone.removeAttribute) clone.removeAttribute('data-vve-hidden');
2664
+ stripDataVveInstanceSubtree(clone);
2665
+ try {
2666
+ if (clone.id) clone.removeAttribute('id');
2667
+ } catch(_) {}
2668
+ try {
2669
+ clone.setAttribute('data-vve-instance', generateVveInstanceId());
2670
+ } catch(_) {}
2671
+ if (activeVarId) {
2672
+ appendSessionStructuralChainRow(activeVarId, {
2673
+ selector: anchorSel,
2674
+ type: 'insert',
2675
+ action: 'after',
2676
+ html: clone.outerHTML,
2677
+ });
2678
+ }
2260
2679
  selectedEl.parentNode.insertBefore(clone, selectedEl.nextSibling);
2261
- markDirty();
2680
+ saveCurrentVariationHtml();
2681
+ recomputeEditorDirty();
2262
2682
  scheduleDomTreeRefresh();
2263
2683
  selectElement(clone);
2264
2684
  }
2265
2685
 
2266
2686
  function toggleHideSelectedEl() {
2267
2687
  if (!selectedEl) return;
2688
+ var hidSel = buildSelector(selectedEl);
2268
2689
  if (selectedEl.getAttribute('data-vve-hidden') === '1') {
2269
2690
  selectedEl.style.visibility = '';
2270
2691
  selectedEl.removeAttribute('data-vve-hidden');
2692
+ if (activeVarId) {
2693
+ appendSessionStructuralChainRow(activeVarId, {
2694
+ selector: hidSel,
2695
+ type: 'style',
2696
+ property: 'visibility',
2697
+ value: '',
2698
+ });
2699
+ }
2271
2700
  } else {
2272
2701
  selectedEl.style.visibility = 'hidden';
2273
2702
  selectedEl.setAttribute('data-vve-hidden', '1');
2703
+ if (activeVarId) {
2704
+ appendSessionStructuralChainRow(activeVarId, {
2705
+ selector: hidSel,
2706
+ type: 'style',
2707
+ property: 'visibility',
2708
+ value: 'hidden',
2709
+ });
2710
+ }
2274
2711
  }
2275
- markDirty();
2712
+ saveCurrentVariationHtml();
2713
+ recomputeEditorDirty();
2276
2714
  }
2277
2715
 
2278
2716
  function deleteSelectedEl() {
2279
2717
  if (!selectedEl || !selectedEl.parentNode) return;
2718
+ var delSel = buildSelector(selectedEl);
2280
2719
  selectedEl.remove();
2281
- markDirty();
2720
+ if (activeVarId) appendSessionStructuralChainRow(activeVarId, { selector: delSel, type: 'remove' });
2721
+ saveCurrentVariationHtml();
2722
+ recomputeEditorDirty();
2282
2723
  deselectElement();
2283
2724
  scheduleDomTreeRefresh();
2284
2725
  }
@@ -2570,14 +3011,12 @@ function renderImageSection(el) {
2570
3011
  var prev = document.querySelector('.img-preview');
2571
3012
  if (prev) prev.src = srcInp.value;
2572
3013
  logChange(sel, 'pp-src', srcInp.value, el, orig);
2573
- markDirty();
2574
3014
  });
2575
3015
  var altInp = document.getElementById('pp-img-alt');
2576
3016
  if (altInp) altInp.addEventListener('input', function() {
2577
3017
  var orig = getOriginalValue('pp-alt', el);
2578
3018
  el.setAttribute('alt', altInp.value);
2579
3019
  logChange(sel, 'pp-alt', altInp.value, el, orig);
2580
- markDirty();
2581
3020
  });
2582
3021
 
2583
3022
  // Wire srcset entry inputs
@@ -2586,7 +3025,8 @@ function renderImageSection(el) {
2586
3025
  var dInp = document.getElementById('pp-se-desc-'+i);
2587
3026
  function flushSrcset() {
2588
3027
  el.setAttribute('srcset', buildSrcset(_srcsetEntries));
2589
- markDirty();
3028
+ saveCurrentVariationHtml();
3029
+ recomputeEditorDirty();
2590
3030
  }
2591
3031
  if (uInp) uInp.addEventListener('input', function(){ _srcsetEntries[i].url = uInp.value; flushSrcset(); });
2592
3032
  if (dInp) dInp.addEventListener('input', function(){ _srcsetEntries[i].descriptor = dInp.value; flushSrcset(); });
@@ -2604,7 +3044,8 @@ function renderImageSection(el) {
2604
3044
  }
2605
3045
  function flushSizes() {
2606
3046
  el.setAttribute('sizes', buildSizes(_sizesEntries));
2607
- markDirty();
3047
+ saveCurrentVariationHtml();
3048
+ recomputeEditorDirty();
2608
3049
  }
2609
3050
  if (opInp) opInp.addEventListener('change', function(){ buildCondition(); flushSizes(); });
2610
3051
  if (valInp) valInp.addEventListener('input', function(){ buildCondition(); flushSizes(); });
@@ -2618,7 +3059,12 @@ function addSrcsetEntry() {
2618
3059
  }
2619
3060
  function removeSrcsetEntry(i) {
2620
3061
  _srcsetEntries.splice(i, 1);
2621
- if (_imageEl) { _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries)); renderImageSection(_imageEl); markDirty(); }
3062
+ if (_imageEl) {
3063
+ _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries));
3064
+ renderImageSection(_imageEl);
3065
+ saveCurrentVariationHtml();
3066
+ recomputeEditorDirty();
3067
+ }
2622
3068
  }
2623
3069
  function addSizesEntry() {
2624
3070
  _sizesEntries.push({condition:'max-width: 760px', value:'760px'});
@@ -2626,7 +3072,12 @@ function addSizesEntry() {
2626
3072
  }
2627
3073
  function removeSizesEntry(i) {
2628
3074
  _sizesEntries.splice(i, 1);
2629
- if (_imageEl) { _imageEl.setAttribute('sizes', buildSizes(_sizesEntries)); renderImageSection(_imageEl); markDirty(); }
3075
+ if (_imageEl) {
3076
+ _imageEl.setAttribute('sizes', buildSizes(_sizesEntries));
3077
+ renderImageSection(_imageEl);
3078
+ saveCurrentVariationHtml();
3079
+ recomputeEditorDirty();
3080
+ }
2630
3081
  }
2631
3082
 
2632
3083
  // \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
@@ -2831,20 +3282,51 @@ function renderRightPanel(el) {
2831
3282
  var orig = getOriginalValue(b[0], el);
2832
3283
  b[1](inp.value);
2833
3284
  logChange(sel, b[0], inp.value, el, orig);
2834
- markDirty();
2835
3285
  });
2836
3286
  });
2837
3287
  }
2838
3288
 
2839
3289
  // \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
3290
+ function generateVveInstanceId() {
3291
+ return 'v' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
3292
+ }
3293
+
3294
+ /** Editor-assigned clone marker so duplicated subtrees do not share the same CSS path as the original. */
3295
+ function stripDataVveInstanceSubtree(root) {
3296
+ if (!root || root.nodeType !== 1) return;
3297
+ try {
3298
+ root.removeAttribute('data-vve-instance');
3299
+ } catch(_) {}
3300
+ var sub = root.querySelectorAll ? root.querySelectorAll('[data-vve-instance]') : [];
3301
+ for (var i = 0; i < sub.length; i++) {
3302
+ try {
3303
+ sub[i].removeAttribute('data-vve-instance');
3304
+ } catch(_) {}
3305
+ }
3306
+ }
3307
+
2840
3308
  function buildSelector(el) {
2841
3309
  if (!el) return '';
3310
+ var doc = el.ownerDocument || document;
3311
+ var inst = el.getAttribute && el.getAttribute('data-vve-instance');
3312
+ if (inst && String(inst).trim()) {
3313
+ var safe = String(inst).split('"').join('\\"');
3314
+ var attrSel = '[data-vve-instance="' + safe + '"]';
3315
+ try {
3316
+ if (doc.querySelectorAll(attrSel).length === 1) return attrSel;
3317
+ } catch(_) {}
3318
+ }
2842
3319
  if (el.id) return '#' + el.id;
2843
3320
  var parts = [], node = el, depth = 0;
2844
3321
  while (node && node.nodeType === 1 && depth < 5) {
2845
3322
  if (node.id) { parts.unshift('#' + node.id); break; }
2846
3323
  var p = node.tagName.toLowerCase();
2847
- if (node.classList && node.classList.length) p += '.' + Array.from(node.classList).slice(0,2).join('.');
3324
+ if (node.classList && node.classList.length) {
3325
+ var clsArr = Array.from(node.classList).filter(function(c) {
3326
+ return c.indexOf('vve-') !== 0;
3327
+ });
3328
+ if (clsArr.length) p += '.' + clsArr.slice(0, 2).join('.');
3329
+ }
2848
3330
  var idx = 1, sib = node.previousElementSibling;
2849
3331
  while (sib) { if (sib.tagName === node.tagName) idx++; sib = sib.previousElementSibling; }
2850
3332
  if (idx > 1) p += ':nth-of-type(' + idx + ')';
@@ -2855,6 +3337,71 @@ function buildSelector(el) {
2855
3337
  return parts.join(' > ');
2856
3338
  }
2857
3339
 
3340
+ /**
3341
+ * Strip editor-only .vve-* class tokens from a selector string (fixes DB rows saved while an element was selected).
3342
+ */
3343
+ function sanitizeSelectorForMatch(sel) {
3344
+ if (!sel || typeof sel !== 'string') return '';
3345
+ var s0 = sel.replace(/.vve-[a-zA-Z0-9_-]+/gi, '');
3346
+ var parts = s0.split(/s*>s*/).map(function(seg) {
3347
+ var t = seg.replace(/.+/g, '.').replace(/.$/, '');
3348
+ return t.trim();
3349
+ });
3350
+ return parts.filter(Boolean).join(' > ');
3351
+ }
3352
+
3353
+ /** Drop the rightmost :nth-of-type(n) (hydration / layout often shifts sibling indices). */
3354
+ function stripRightmostNthOfType(sel) {
3355
+ if (!sel || typeof sel !== 'string') return null;
3356
+ var idx = sel.lastIndexOf(':nth-of-type(');
3357
+ if (idx === -1) return null;
3358
+ var j = idx + ':nth-of-type('.length;
3359
+ while (j < sel.length && sel.charCodeAt(j) >= 48 && sel.charCodeAt(j) <= 57) j++;
3360
+ if (j >= sel.length || sel.charAt(j) !== ')') return null;
3361
+ return (sel.slice(0, idx) + sel.slice(j + 1))
3362
+ .replace(/s{2,}/g, ' ')
3363
+ .replace(/s*>s*>/g, ' >')
3364
+ .trim();
3365
+ }
3366
+
3367
+ function querySelectorResolved(iframeDoc, selector) {
3368
+ if (!iframeDoc || !selector) return null;
3369
+ var seen = {};
3370
+ function tryOne(s) {
3371
+ if (!s || seen[s]) return null;
3372
+ seen[s] = true;
3373
+ try {
3374
+ return iframeDoc.querySelector(s) || null;
3375
+ } catch(_) {
3376
+ return null;
3377
+ }
3378
+ }
3379
+ function walkRelax(base) {
3380
+ if (!base) return null;
3381
+ var el = tryOne(base);
3382
+ if (el) return el;
3383
+ var cur = base;
3384
+ for (var g = 0; g < 28; g++) {
3385
+ var nxt = stripRightmostNthOfType(cur);
3386
+ if (!nxt || nxt === cur) break;
3387
+ cur = nxt;
3388
+ el = tryOne(cur);
3389
+ if (el) return el;
3390
+ }
3391
+ return null;
3392
+ }
3393
+ var alt = sanitizeSelectorForMatch(selector);
3394
+ // Prefer sanitized + nth relax FIRST: raw selectors often still contain .vve-* from
3395
+ // save-time selection; those only match after clicking (we re-add vve-selected).
3396
+ var el = walkRelax(alt || selector);
3397
+ if (el) return el;
3398
+ if (alt !== selector) {
3399
+ el = walkRelax(selector);
3400
+ if (el) return el;
3401
+ }
3402
+ return null;
3403
+ }
3404
+
2858
3405
  // \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
2859
3406
  function repositionDragSibling(dragEl, clientY) {
2860
3407
  var p = dragEl.parentElement;
@@ -2880,6 +3427,27 @@ function repositionDragSibling(dragEl, clientY) {
2880
3427
  }
2881
3428
  }
2882
3429
 
3430
+ function recordReorderAfterDrag(movedEl) {
3431
+ if (!activeVarId || !movedEl || !movedEl.parentElement) return;
3432
+ var prev = movedEl.previousElementSibling;
3433
+ var next = movedEl.nextElementSibling;
3434
+ if (prev) {
3435
+ appendSessionStructuralChainRow(activeVarId, {
3436
+ selector: buildSelector(movedEl),
3437
+ type: 'reorder',
3438
+ targetSelector: buildSelector(prev),
3439
+ action: 'after',
3440
+ });
3441
+ } else if (next) {
3442
+ appendSessionStructuralChainRow(activeVarId, {
3443
+ selector: buildSelector(movedEl),
3444
+ type: 'reorder',
3445
+ targetSelector: buildSelector(next),
3446
+ action: 'before',
3447
+ });
3448
+ }
3449
+ }
3450
+
2883
3451
  function attachDragReposition() {
2884
3452
  try {
2885
3453
  var iframe = document.getElementById('iframeId');
@@ -2933,7 +3501,9 @@ function attachDragReposition() {
2933
3501
  } catch(_) {}
2934
3502
  suppressClickUntil = Date.now() + 200;
2935
3503
  setDragHandleActive(false);
2936
- markDirty();
3504
+ if (activeVarId) recordReorderAfterDrag(selectedEl);
3505
+ saveCurrentVariationHtml();
3506
+ recomputeEditorDirty();
2937
3507
  updateSelectionToolbar();
2938
3508
  scheduleDomTreeRefresh();
2939
3509
  }
@@ -2987,8 +3557,7 @@ function attachChangeObserver() {
2987
3557
  changeObserverDoc = null;
2988
3558
  }
2989
3559
  changeObserver = new MutationObserver(function() {
2990
- markDirty();
2991
- // Debounced full rebuild of Elements panel as the live DOM grows / changes
3560
+ // Dirty state is derived from changesets baseline + stateChanges (not raw DOM mutations).
2992
3561
  scheduleDomTreeRefresh();
2993
3562
  scheduleGranularChangesetReapply();
2994
3563
  });
@@ -3024,6 +3593,7 @@ function syncIframeInteractions(reason) {
3024
3593
  var inp = document.getElementById('comp-search');
3025
3594
  renderDomTree(inp ? inp.value : '');
3026
3595
  updateSelectionToolbar();
3596
+ recomputeEditorDirty();
3027
3597
  } catch(_) {}
3028
3598
  }
3029
3599
 
@@ -3075,8 +3645,11 @@ function insertHtml(html) {
3075
3645
  console.warn('[V2] insertHtml: iframe document not ready');
3076
3646
  return;
3077
3647
  }
3648
+ var htmlStr = String(html).trim();
3649
+ var anchorSel =
3650
+ selectedEl && selectedEl !== doc.body && selectedEl.parentNode ? buildSelector(selectedEl) : 'body';
3078
3651
  var t = doc.createElement('template');
3079
- t.innerHTML = String(html).trim();
3652
+ t.innerHTML = htmlStr;
3080
3653
  var frag = doc.createDocumentFragment();
3081
3654
  var firstEl = null;
3082
3655
  while (t.content.firstChild) {
@@ -3092,7 +3665,16 @@ function insertHtml(html) {
3092
3665
  doc.body.appendChild(frag);
3093
3666
  }
3094
3667
  if (firstEl) selectElement(firstEl);
3095
- markDirty();
3668
+ if (activeVarId) {
3669
+ appendSessionStructuralChainRow(activeVarId, {
3670
+ selector: anchorSel,
3671
+ type: 'insert',
3672
+ action: 'after',
3673
+ html: htmlStr,
3674
+ });
3675
+ }
3676
+ saveCurrentVariationHtml();
3677
+ recomputeEditorDirty();
3096
3678
  scheduleDomTreeRefresh();
3097
3679
  } catch(err) { console.warn('[V2] insertHtml:', err); }
3098
3680
  }
@@ -3184,15 +3766,37 @@ document.getElementById('btn-close').addEventListener('click', handleClose);
3184
3766
  function handleSave() {
3185
3767
  saveCurrentVariationHtml();
3186
3768
  var updatedVariations = variations.map(function(v) {
3187
- var saved = varHtmlCache[v._id];
3188
- if (!saved) return Object.assign({}, v);
3189
- return Object.assign({}, v, { changesets: JSON.stringify([{ selector: '__vvveb_body__', html: saved, mutations: [] }]) });
3769
+ var prevParsed = parseVariationChangesets(v);
3770
+ var granularPrev = filterGranularChangesetEntries(prevParsed);
3771
+ var bodyOnlyLegacy = changesetsHaveBodySnapshot(prevParsed) && granularPrev.length === 0;
3772
+
3773
+ var rows = buildPersistedChainSetsForVariation(v);
3774
+ if (bodyOnlyLegacy && rows.length === 0) {
3775
+ return Object.assign({}, v);
3776
+ }
3777
+ var json = '[]';
3778
+ try {
3779
+ json = JSON.stringify(rows || []);
3780
+ } catch(_) {
3781
+ json = '[]';
3782
+ }
3783
+ return Object.assign({}, v, { changesets: json });
3190
3784
  });
3785
+ variations = updatedVariations;
3786
+ varHtmlCache = {};
3787
+ sessionStructuralChainRowsByVarId = {};
3788
+ stateChanges = [];
3789
+ if (currentMainTab === 'states') renderStatesTab();
3790
+ captureBaselineFromVariations(variations);
3791
+ if (experimentData && Array.isArray(experimentData.variations)) {
3792
+ experimentData.variations = updatedVariations;
3793
+ }
3191
3794
  send('save-experiment', { experimentId: experimentData ? experimentData.experimentId : null, variations: updatedVariations });
3192
- markClean();
3795
+ setEditorDirty(false);
3193
3796
  }
3194
3797
 
3195
3798
  function handleClose() {
3799
+ clearVisualEditorLocalStorage();
3196
3800
  // Unsaved-changes UX lives in the parent (PlatformVisualEditorV2); avoid double confirm here.
3197
3801
  send('close-editor', {});
3198
3802
  }
@@ -3200,8 +3804,22 @@ function handleClose() {
3200
3804
  // \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
3201
3805
  document.addEventListener('keydown', function(e) {
3202
3806
  var meta = e.metaKey || e.ctrlKey;
3203
- if (meta && !e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.undo(); }
3204
- if (meta && e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.redo(); }
3807
+ if (meta && !e.shiftKey && e.key === 'z') {
3808
+ e.preventDefault();
3809
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3810
+ Vvveb.Undo.undo();
3811
+ saveCurrentVariationHtml();
3812
+ recomputeEditorDirty();
3813
+ }
3814
+ }
3815
+ if (meta && e.shiftKey && e.key === 'z') {
3816
+ e.preventDefault();
3817
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3818
+ Vvveb.Undo.redo();
3819
+ saveCurrentVariationHtml();
3820
+ recomputeEditorDirty();
3821
+ }
3822
+ }
3205
3823
  if (meta && e.key === 's') { e.preventDefault(); handleSave(); }
3206
3824
  if (e.key === 'Escape') {
3207
3825
  var openTips = document.querySelectorAll('.ve-pl-tip.is-tip-open');