@accelerated-agency/visual-editor 0.2.4 → 0.2.6

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
@@ -619,7 +619,7 @@ select.pr-inp{cursor:pointer;background:#fff}
619
619
  </div>
620
620
  <!-- btn-close: hidden visually, kept for JS event listener -->
621
621
  <button id="btn-close" style="display:none" title="Close editor"></button>
622
- <button class="tb-sim-btn" id="btn-simulate"><i class="bi bi-lightning-charge-fill"></i> Simulate</button>
622
+ <button class="tb-sim-btn" id="btn-simulate" onclick="simulateExperiment()"><i class="bi bi-lightning-charge-fill"></i> Simulate</button>
623
623
  <button class="tb-fin-btn" id="btn-save">Finalize</button>
624
624
  </div>
625
625
 
@@ -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>
@@ -893,6 +906,52 @@ function send(type, payload) {
893
906
  window.parent.postMessage({ channel: CHANNEL, type: type, payload: payload || {} }, '*');
894
907
  }
895
908
 
909
+ function generatePreviewUrlString(args) {
910
+ var baseUrl = (args && args.url) || '';
911
+ var test = (args && args.test) || {};
912
+ var variation = (args && args.variation) || {};
913
+ if (!baseUrl) return '';
914
+ var testId = test.iid || test.experimentId || test._id || '';
915
+ var variationId = variation.iid || variation._id || '';
916
+ var cId = String(testId || '') + '_' + String(variationId || '');
917
+ var hasQueryParams = String(baseUrl).indexOf('?') >= 0;
918
+ return (
919
+ baseUrl +
920
+ (hasQueryParams ? '&' : '?') +
921
+ 'codebase_debug=true&cId=' +
922
+ encodeURIComponent(cId)
923
+ );
924
+ }
925
+
926
+ function simulateExperiment() {
927
+ var test = experimentData || {};
928
+ var activeVariation = null;
929
+ if (Array.isArray(variations) && variations.length) {
930
+ activeVariation =
931
+ variations.find(function(v) { return v && v._id === activeVarId; }) ||
932
+ variations[0];
933
+ }
934
+ var targetUrl =
935
+ (Array.isArray(test.urltargeting) && test.urltargeting[0]) ||
936
+ test.pageUrl ||
937
+ (test.metadata_1 && test.metadata_1.editor_url) ||
938
+ '';
939
+ var url = generatePreviewUrlString({
940
+ url: targetUrl,
941
+ test: test,
942
+ variation: activeVariation || {},
943
+ });
944
+ if (!url) {
945
+ console.warn('[V2] simulateExperiment: missing target URL');
946
+ return;
947
+ }
948
+ try {
949
+ window.open(url, '_blank');
950
+ } catch(err) {
951
+ console.warn('[V2] simulateExperiment:', err);
952
+ }
953
+ }
954
+
896
955
  window.addEventListener('message', function(e) {
897
956
  if (!e.data || e.data.channel !== CHANNEL) return;
898
957
  switch (e.data.type) {
@@ -906,6 +965,8 @@ var experimentData = null;
906
965
  var variations = [];
907
966
  var activeVarId = null;
908
967
  var varHtmlCache = {};
968
+ /** Per-variation chain rows from structural actions (insert/duplicate/delete/reorder/hide), merged on Finalize. */
969
+ var sessionStructuralChainRowsByVarId = {};
909
970
  /** Last iframe proxy URL we navigated to \u2014 used to skip redundant reloads when parent re-sends load-experiment */
910
971
  var lastLoadedProxyUrl = '';
911
972
  /** API changeset rows (excluding __vvveb_body__) reapplied until selectors match late-hydrated DOM */
@@ -919,6 +980,8 @@ var iframeContentNavGen = 0;
919
980
  var iframeContentApplyTimer = null;
920
981
  var iframeEarlyGranularPrimedForGen = null;
921
982
  var iframeEarlySyncPrimedForGen = null;
983
+ /** insert/reorder entries are applied from early granular + full apply \u2014 skip exact duplicates per iframe nav */
984
+ var appliedStructuralChangesetKeys = {};
922
985
  var isDirty = false;
923
986
  var vvvebReady = false;
924
987
  var currentMode = 'editor';
@@ -938,6 +1001,15 @@ var selectionResizeBound = false;
938
1001
  var clickAttachDoc = null;
939
1002
  var changeObserver = null;
940
1003
  var changeObserverDoc = null;
1004
+ /** Incremented while applying changesets / selection chrome so MutationObserver does not mark dirty */
1005
+ var suppressIframeMutationDirty = 0;
1006
+ /** Throttled reconcile timer to keep DOM aligned with saved changesets. */
1007
+ var consistencyReconcileTimer = null;
1008
+ /** Background watchdog for late client-side re-renders that wipe applied changesets. */
1009
+ var consistencyWatchTimer = null;
1010
+ var consistencyWatchDoc = null;
1011
+ var consistencyWatchTicks = 0;
1012
+ var CONSISTENCY_WATCH_MAX_TICKS = 160;
941
1013
  /** { doc, onRS } \u2014 iframe document readystate until complete */
942
1014
  var iframeDocLoadingListeners = null;
943
1015
  // Each entry: {selector, label, cssProp, value, targetEl}
@@ -945,12 +1017,89 @@ var iframeDocLoadingListeners = null;
945
1017
  var stateChanges = [];
946
1018
  /** Pre-apply DOM snapshots for granular + body changesets (used when removing History rows) */
947
1019
  var appliedChangesetSnapshots = {};
1020
+ /** Canonical JSON fingerprints of persisted changesets per variation (last load / finalize) */
1021
+ var baselineChangesetsByVarId = {};
1022
+
1023
+ // \u2500\u2500 Dirty tracking (compare DB baseline + session stateChanges vs current export) \u2500\u2500
1024
+ function beginSuppressIframeMutationDirty() {
1025
+ suppressIframeMutationDirty += 1;
1026
+ }
1027
+
1028
+ function endSuppressIframeMutationDirty() {
1029
+ suppressIframeMutationDirty = Math.max(0, suppressIframeMutationDirty - 1);
1030
+ }
1031
+
1032
+ /** Stable stringify of a variation's changesets field (string or array from API). */
1033
+ function fingerprintChangesetsField(raw) {
1034
+ if (raw == null) return '[]';
1035
+ if (Array.isArray(raw)) {
1036
+ try {
1037
+ return JSON.stringify(raw);
1038
+ } catch(_) {
1039
+ return '[]';
1040
+ }
1041
+ }
1042
+ if (typeof raw !== 'string') return '[]';
1043
+ var s = raw.trim();
1044
+ if (!s) return '[]';
1045
+ try {
1046
+ var p = JSON.parse(s);
1047
+ return JSON.stringify(Array.isArray(p) ? p : []);
1048
+ } catch(_) {
1049
+ return '[]';
1050
+ }
1051
+ }
1052
+
1053
+ function captureBaselineFromVariations(list) {
1054
+ baselineChangesetsByVarId = {};
1055
+ if (!list || !list.length) return;
1056
+ for (var i = 0; i < list.length; i++) {
1057
+ var v = list[i];
1058
+ if (!v || !v._id) continue;
1059
+ baselineChangesetsByVarId[v._id] = fingerprintChangesetsField(v.changesets);
1060
+ }
1061
+ }
1062
+
1063
+ /** Fingerprint of what Finalize would send for this variation (matches buildPersistedChainSetsForVariation). */
1064
+ function persistedExportFingerprintForVariation(v) {
1065
+ if (!v || !v._id) return '[]';
1066
+ try {
1067
+ var rows = buildPersistedChainSetsForVariation(v);
1068
+ return JSON.stringify(rows || []);
1069
+ } catch(_) {
1070
+ return '[]';
1071
+ }
1072
+ }
1073
+
1074
+ function setEditorDirty(dirty) {
1075
+ var was = isDirty;
1076
+ isDirty = !!dirty;
1077
+ var dot = document.getElementById('dirty-dot');
1078
+ if (dot) dot.classList.toggle('on', isDirty);
1079
+ if (isDirty && !was) send('mutations-changed', {});
1080
+ if (!isDirty && was) send('editor-dirty', { dirty: false });
1081
+ if (!isDirty) {
1082
+ savedAt = Date.now();
1083
+ updateSaveTime();
1084
+ }
1085
+ }
948
1086
 
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', {});
1087
+ function recomputeEditorDirty() {
1088
+ var d = stateChanges.length > 0;
1089
+ if (!d && variations && variations.length) {
1090
+ for (var i = 0; i < variations.length; i++) {
1091
+ var v = variations[i];
1092
+ var vid = v._id;
1093
+ var cur = persistedExportFingerprintForVariation(v);
1094
+ var base = baselineChangesetsByVarId[vid];
1095
+ if (base == null) base = '[]';
1096
+ if (cur !== base) {
1097
+ d = true;
1098
+ break;
1099
+ }
1100
+ }
1101
+ }
1102
+ setEditorDirty(d);
954
1103
  }
955
1104
  var savedAt = null;
956
1105
  function updateSaveTime() {
@@ -960,12 +1109,6 @@ function updateSaveTime() {
960
1109
  el.textContent = s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago';
961
1110
  }
962
1111
  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
1112
 
970
1113
  // \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
1114
  function setMode(mode) {
@@ -1142,6 +1285,7 @@ function logChange(selector, inputId, value, targetEl, originalValue) {
1142
1285
  if (idx >= 0) { stateChanges[idx] = entry; } else { stateChanges.push(entry); }
1143
1286
  }
1144
1287
  if (currentMainTab === 'states') renderStatesTab();
1288
+ recomputeEditorDirty();
1145
1289
  }
1146
1290
 
1147
1291
  function renderStatesTab() {
@@ -1175,7 +1319,7 @@ function renderStatesTab() {
1175
1319
 
1176
1320
  // Resolve a live DOM element for a state-change entry.
1177
1321
  // Tries the stored direct reference first; if it's detached or missing,
1178
- // falls back to querySelector(selector) inside the iframe document.
1322
+ // falls back to querySelector (with .vve-* class stripped) inside the iframe document.
1179
1323
  function resolveChangeEl(change) {
1180
1324
  try {
1181
1325
  var iframeDoc = document.getElementById('iframeId').contentDocument;
@@ -1185,7 +1329,7 @@ function resolveChangeEl(change) {
1185
1329
  return change.targetEl;
1186
1330
  }
1187
1331
  // Fallback: re-query by the stored CSS selector
1188
- return iframeDoc.querySelector(change.selector) || null;
1332
+ return querySelectorResolved(iframeDoc, change.selector);
1189
1333
  } catch (e) {
1190
1334
  console.warn('[V2] resolveChangeEl:', e);
1191
1335
  return null;
@@ -1248,7 +1392,7 @@ function removeStateChange(idx) {
1248
1392
  syncDesignInput(change);
1249
1393
  stateChanges.splice(idx, 1);
1250
1394
  renderStatesTab();
1251
- markDirty();
1395
+ recomputeEditorDirty();
1252
1396
  }
1253
1397
 
1254
1398
  function clearAllStates() {
@@ -1258,7 +1402,7 @@ function clearAllStates() {
1258
1402
  });
1259
1403
  stateChanges = [];
1260
1404
  renderStatesTab();
1261
- markDirty();
1405
+ recomputeEditorDirty();
1262
1406
  }
1263
1407
 
1264
1408
  // \u2500\u2500 History tab (saved changesets from DB for active variation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -1283,10 +1427,11 @@ function persistActiveVariationChangesets(arr) {
1283
1427
 
1284
1428
  function entrySnapshotKey(entry) {
1285
1429
  if (!entry || !entry.selector) return '';
1430
+ var selKey = sanitizeSelectorForMatch(entry.selector) || entry.selector;
1286
1431
  return (
1287
- entry.selector +
1432
+ selKey +
1288
1433
  '\0' +
1289
- (entry.type || '') +
1434
+ normalizeChangesetType(entry) +
1290
1435
  '\0' +
1291
1436
  String(entry.property || '') +
1292
1437
  '\0' +
@@ -1304,7 +1449,7 @@ function captureChangesetSnapshotBeforeApply(entry, el, iframeDoc) {
1304
1449
  if (!entry || !el || entry.selector === '__vvveb_body__') return;
1305
1450
  var k = entrySnapshotKey(entry);
1306
1451
  if (appliedChangesetSnapshots[k]) return;
1307
- switch (entry.type) {
1452
+ switch (normalizeChangesetType(entry)) {
1308
1453
  case 'content':
1309
1454
  if (entry.html != null) {
1310
1455
  appliedChangesetSnapshots[k] = { kind: 'innerHTML', v: el.innerHTML };
@@ -1372,10 +1517,7 @@ function revertChangesetEntryOnDom(entry) {
1372
1517
  if (!iframeDoc) return false;
1373
1518
  var k = entrySnapshotKey(entry);
1374
1519
  var snap = appliedChangesetSnapshots[k];
1375
- var el = null;
1376
- try {
1377
- el = iframeDoc.querySelector(entry.selector);
1378
- } catch(_) {}
1520
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1379
1521
  if (!snap || !el) {
1380
1522
  softReloadEditorIframe();
1381
1523
  delete appliedChangesetSnapshots[k];
@@ -1402,7 +1544,7 @@ function revertChangesetEntryOnDom(entry) {
1402
1544
  function historyEntryTypeLabel(entry) {
1403
1545
  if (!entry) return 'Change';
1404
1546
  if (entry.selector === '__vvveb_body__') return 'Full page HTML';
1405
- var t = (entry.type || '').toLowerCase();
1547
+ var t = normalizeChangesetType(entry);
1406
1548
  if (t === 'content') return entry.html != null ? 'Inner HTML' : 'Text / content';
1407
1549
  if (t === 'style') return 'Style: ' + (entry.property || '');
1408
1550
  if (t === 'attribute') return 'Attribute: ' + (entry.attribute || '');
@@ -1416,7 +1558,8 @@ function historyEntryValuePreview(entry) {
1416
1558
  if (entry.selector === '__vvveb_body__') return '(body snapshot)';
1417
1559
  if (entry.html != null) return String(entry.html).slice(0, 120);
1418
1560
  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);
1561
+ var nt = normalizeChangesetType(entry);
1562
+ if (nt === 'style' || nt === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1420
1563
  return '';
1421
1564
  }
1422
1565
 
@@ -1450,6 +1593,9 @@ function renderHistoryTab() {
1450
1593
  var val = historyEntryValuePreview(item.entry);
1451
1594
  html +=
1452
1595
  '<div class="state-item">' +
1596
+ '<span class="state-item-idx" style="opacity:0.55;font-size:10px;min-width:2.2em;display:inline-block" title="Order in saved changesets">#' +
1597
+ item.idx +
1598
+ '</span>' +
1453
1599
  '<span class="state-item-label">' +
1454
1600
  esc(lab) +
1455
1601
  '</span>' +
@@ -1458,7 +1604,9 @@ function renderHistoryTab() {
1458
1604
  '">' +
1459
1605
  esc(val) +
1460
1606
  '</span>' +
1461
- '<button type="button" class="state-remove" title="Remove from saved changesets" onclick="removeHistoryChangeset(' +
1607
+ '<button type="button" class="state-remove" title="Remove this saved row (#' +
1608
+ item.idx +
1609
+ ')" onclick="removeHistoryChangeset(' +
1462
1610
  item.idx +
1463
1611
  ')">&#x2715;</button>' +
1464
1612
  '</div>';
@@ -1468,6 +1616,16 @@ function renderHistoryTab() {
1468
1616
  container.innerHTML = html;
1469
1617
  }
1470
1618
 
1619
+ function changesetListHasStructural(arr) {
1620
+ if (!arr || !arr.length) return false;
1621
+ for (var i = 0; i < arr.length; i++) {
1622
+ var e = arr[i];
1623
+ var t = normalizeChangesetType(e);
1624
+ if (e && (t === 'insert' || t === 'reorder')) return true;
1625
+ }
1626
+ return false;
1627
+ }
1628
+
1471
1629
  function removeHistoryChangeset(idx) {
1472
1630
  var v = getActiveVariationForHistory();
1473
1631
  if (!v) return;
@@ -1480,18 +1638,31 @@ function removeHistoryChangeset(idx) {
1480
1638
  try {
1481
1639
  delete varHtmlCache[activeVarId];
1482
1640
  } catch(_) {}
1483
- if (!didReload) {
1641
+ // Re-applying remaining rows on top of current DOM duplicates insert/reorder nodes; reload when any
1642
+ // structural row remains or was removed (revert may already have started a reload for insert/body).
1643
+ var removedType = normalizeChangesetType(removed);
1644
+ var needsStructuralReload =
1645
+ !didReload &&
1646
+ (removedType === 'insert' ||
1647
+ removedType === 'reorder' ||
1648
+ changesetListHasStructural(arr));
1649
+ if (didReload) {
1650
+ /* revertChangesetEntryOnDom already kicked off iframe reload */
1651
+ } else if (needsStructuralReload) {
1652
+ softReloadEditorIframe();
1653
+ } else {
1484
1654
  try {
1655
+ appliedStructuralChangesetKeys = {};
1485
1656
  applyActiveVariationHtml();
1486
1657
  registerPendingGranularChangesets(
1487
1658
  arr,
1488
1659
  document.getElementById('iframeId').contentDocument,
1489
1660
  );
1661
+ saveCurrentVariationHtml();
1490
1662
  } catch(_) {}
1491
- saveCurrentVariationHtml();
1492
1663
  }
1493
1664
  if (currentMainTab === 'history') renderHistoryTab();
1494
- markDirty();
1665
+ recomputeEditorDirty();
1495
1666
  scheduleDomTreeRefresh();
1496
1667
  }
1497
1668
 
@@ -1501,15 +1672,82 @@ function clearAllHistoryChangesets() {
1501
1672
  if (!parseVariationChangesets(v).length) return;
1502
1673
  persistActiveVariationChangesets([]);
1503
1674
  appliedChangesetSnapshots = {};
1675
+ appliedStructuralChangesetKeys = {};
1504
1676
  try {
1505
1677
  delete varHtmlCache[activeVarId];
1506
1678
  } catch(_) {}
1507
1679
  if (currentMainTab === 'history') renderHistoryTab();
1508
- markDirty();
1680
+ recomputeEditorDirty();
1509
1681
  scheduleDomTreeRefresh();
1510
1682
  softReloadEditorIframe();
1511
1683
  }
1512
1684
 
1685
+ // \u2500\u2500 Persisted active variation (survives iframe / full page reload) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
1686
+ /** All Visual Editor iframe keys in localStorage use this prefix \u2014 cleared on close. */
1687
+ var VVE_LOCAL_STORAGE_PREFIX = 'vve:';
1688
+
1689
+ function clearVisualEditorLocalStorage() {
1690
+ try {
1691
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1692
+ var k = localStorage.key(i);
1693
+ if (k && k.indexOf(VVE_LOCAL_STORAGE_PREFIX) === 0) {
1694
+ localStorage.removeItem(k);
1695
+ }
1696
+ }
1697
+ } catch(_) {}
1698
+ }
1699
+
1700
+ function activeVariationStorageKeyFromPayload(data) {
1701
+ return (
1702
+ VVE_LOCAL_STORAGE_PREFIX +
1703
+ 'activeVar:' +
1704
+ String((data && data.experimentId) || '') +
1705
+ ':' +
1706
+ String((data && data.pageUrl) || '')
1707
+ );
1708
+ }
1709
+
1710
+ function readPersistedActiveVariationId(data) {
1711
+ try {
1712
+ var sk = activeVariationStorageKeyFromPayload(data);
1713
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return null;
1714
+ return localStorage.getItem(sk);
1715
+ } catch(_) {
1716
+ return null;
1717
+ }
1718
+ }
1719
+
1720
+ function writePersistedActiveVariationId(varId) {
1721
+ try {
1722
+ if (!experimentData || !experimentData.experimentId) return;
1723
+ var sk =
1724
+ VVE_LOCAL_STORAGE_PREFIX +
1725
+ 'activeVar:' +
1726
+ String(experimentData.experimentId || '') +
1727
+ ':' +
1728
+ String(experimentData.pageUrl || '');
1729
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return;
1730
+ if (varId) localStorage.setItem(sk, String(varId));
1731
+ } catch(_) {}
1732
+ }
1733
+
1734
+ /**
1735
+ * @param allowPrevMemory when true, keep in-session activeVarId if still valid (skip-reload path).
1736
+ */
1737
+ function pickActiveVariationIdForLoad(data, variationsArr, prevMemoryId, allowPrevMemory) {
1738
+ var baseline = variationsArr.find(function(v) { return v.baseline; });
1739
+ var fallback = (baseline || variationsArr[0] || {})._id || null;
1740
+ if (!variationsArr.length) return null;
1741
+ if (allowPrevMemory && prevMemoryId && variationsArr.some(function(v) { return v._id === prevMemoryId; })) {
1742
+ return prevMemoryId;
1743
+ }
1744
+ var stored = readPersistedActiveVariationId(data);
1745
+ if (stored && variationsArr.some(function(v) { return v._id === stored; })) {
1746
+ return stored;
1747
+ }
1748
+ return fallback;
1749
+ }
1750
+
1513
1751
  // \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
1752
  function handleLoadExperiment(data) {
1515
1753
  clearPendingGranularChangesets();
@@ -1537,10 +1775,8 @@ function handleLoadExperiment(data) {
1537
1775
  experimentData = data;
1538
1776
  variations = Array.isArray(data.variations) ? data.variations : [];
1539
1777
  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;
1778
+ activeVarId = pickActiveVariationIdForLoad(data, variations, prevActive, true);
1779
+ writePersistedActiveVariationId(activeVarId);
1544
1780
  renderVariationTabs();
1545
1781
  var urlBarSkip = document.getElementById('url-bar');
1546
1782
  urlBarSkip.textContent = pageUrl;
@@ -1554,24 +1790,30 @@ function handleLoadExperiment(data) {
1554
1790
  if (ifrSkip && ifrSkip.contentDocument) attachIframeLoadingUntilComplete(ifrSkip);
1555
1791
  } catch(_) {}
1556
1792
  if (currentMainTab === 'history') renderHistoryTab();
1793
+ captureBaselineFromVariations(variations);
1794
+ recomputeEditorDirty();
1557
1795
  return;
1558
1796
  }
1559
1797
 
1560
1798
  if (!experimentData || prevKey !== nextKey) {
1561
1799
  varHtmlCache = {};
1800
+ sessionStructuralChainRowsByVarId = {};
1562
1801
  appliedChangesetSnapshots = {};
1802
+ appliedStructuralChangesetKeys = {};
1563
1803
  }
1564
1804
  experimentData = data;
1565
1805
  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;
1806
+ var sameExpPage = prevKey === nextKey;
1807
+ activeVarId = pickActiveVariationIdForLoad(data, variations, activeVarId, sameExpPage);
1808
+ writePersistedActiveVariationId(activeVarId);
1569
1809
  renderVariationTabs();
1570
1810
 
1571
1811
  var urlBar = document.getElementById('url-bar');
1572
1812
  urlBar.textContent = pageUrl;
1573
1813
  urlBar.title = pageUrl;
1574
1814
  if (currentMainTab === 'history') renderHistoryTab();
1815
+ captureBaselineFromVariations(variations);
1816
+ recomputeEditorDirty();
1575
1817
  loadPage(proxyUrl);
1576
1818
  }
1577
1819
 
@@ -1671,9 +1913,7 @@ function granularAnySelectorMatches(doc, cs) {
1671
1913
  if (!doc || !cs || !cs.length) return false;
1672
1914
  var g = filterGranularChangesetEntries(cs);
1673
1915
  for (var i = 0; i < g.length; i++) {
1674
- try {
1675
- if (doc.querySelector(g[i].selector)) return true;
1676
- } catch(_) {}
1916
+ if (querySelectorResolved(doc, g[i].selector)) return true;
1677
1917
  }
1678
1918
  return false;
1679
1919
  }
@@ -1694,7 +1934,7 @@ function appendIframeReloadBust(url) {
1694
1934
  // applying variation changesets there is then wiped when the real navigation commits.
1695
1935
  function iframeDocMatchesNavigatedSrc(iframe, doc) {
1696
1936
  if (!iframe || !doc) return false;
1697
- var src = iframe.src || iframe.getAttribute('src') || '';
1937
+ var src = iframe.getAttribute('src') || iframe.src || '';
1698
1938
  if (!src || src === 'about:blank') return false;
1699
1939
  var loc = '';
1700
1940
  try {
@@ -1703,12 +1943,28 @@ function iframeDocMatchesNavigatedSrc(iframe, doc) {
1703
1943
  return false;
1704
1944
  }
1705
1945
  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
1946
  try {
1709
1947
  var base = window.location.href;
1710
1948
  var su = new URL(src, base);
1949
+ if (su.searchParams && su.searchParams.has('__ve_reload')) {
1950
+ su.searchParams.delete('__ve_reload');
1951
+ }
1711
1952
  var du = new URL(loc, base);
1953
+ if (du.searchParams && du.searchParams.has('__ve_reload')) {
1954
+ du.searchParams.delete('__ve_reload');
1955
+ }
1956
+ // Same-origin proxy that keeps document address aligned with iframe src
1957
+ if (su.origin === du.origin && su.pathname + su.search === du.pathname + du.search) {
1958
+ return true;
1959
+ }
1960
+ // conversion-proxy: iframe src stays on our app, but doc.URL is usually the target site after redirects
1961
+ var p = su.pathname || '';
1962
+ var isRootProxyPath = p === '/api/conversion-proxy' || p.indexOf('/api/conversion-proxy/') === 0;
1963
+ var isNestedMalformedProxy = !isRootProxyPath && p.indexOf('api/conversion-proxy') !== -1;
1964
+ if (isNestedMalformedProxy) return false;
1965
+ if (isRootProxyPath || String(su.href).indexOf('conversion-proxy') !== -1) {
1966
+ return doc === iframe.contentDocument;
1967
+ }
1712
1968
  return su.pathname + su.search === du.pathname + du.search;
1713
1969
  } catch(_) {
1714
1970
  return false;
@@ -1737,10 +1993,73 @@ function clearPendingGranularChangesets() {
1737
1993
  }
1738
1994
  }
1739
1995
 
1996
+ function stopConsistencyWatchdog() {
1997
+ if (consistencyWatchTimer) {
1998
+ clearInterval(consistencyWatchTimer);
1999
+ consistencyWatchTimer = null;
2000
+ }
2001
+ consistencyWatchDoc = null;
2002
+ consistencyWatchTicks = 0;
2003
+ }
2004
+
2005
+ function runConsistencyReconcile() {
2006
+ if (!activeVarId) return;
2007
+ var iframe = document.getElementById('iframeId');
2008
+ var doc = iframe && iframe.contentDocument;
2009
+ if (!doc || !doc.body) return;
2010
+ var variation = variations.find(function(v) { return v._id === activeVarId; });
2011
+ var cs = parseVariationChangesets(variation);
2012
+ if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2013
+ var granular = filterGranularChangesetEntries(cs);
2014
+ var unresolved = countUnresolvedGranularSelectors(doc, granular);
2015
+ if (unresolved > 0 || changesetListHasStructural(cs)) {
2016
+ reapplyActiveVariationGranular(doc);
2017
+ registerPendingGranularChangesets(cs, doc);
2018
+ }
2019
+ }
2020
+
2021
+ function scheduleConsistencyReconcile() {
2022
+ if (consistencyReconcileTimer) return;
2023
+ consistencyReconcileTimer = setTimeout(function() {
2024
+ consistencyReconcileTimer = null;
2025
+ runConsistencyReconcile();
2026
+ }, 80);
2027
+ }
2028
+
2029
+ function startConsistencyWatchdog(doc) {
2030
+ if (!doc || !doc.body) return;
2031
+ if (consistencyWatchDoc === doc && consistencyWatchTimer) return;
2032
+ stopConsistencyWatchdog();
2033
+ consistencyWatchDoc = doc;
2034
+ consistencyWatchTicks = 0;
2035
+ consistencyWatchTimer = setInterval(function() {
2036
+ if (consistencyWatchDoc !== doc) {
2037
+ stopConsistencyWatchdog();
2038
+ return;
2039
+ }
2040
+ var iframe = document.getElementById('iframeId');
2041
+ if (!iframe || iframe.contentDocument !== doc || !doc.body) {
2042
+ stopConsistencyWatchdog();
2043
+ return;
2044
+ }
2045
+ consistencyWatchTicks += 1;
2046
+ scheduleConsistencyReconcile();
2047
+ if (consistencyWatchTicks >= CONSISTENCY_WATCH_MAX_TICKS) {
2048
+ stopConsistencyWatchdog();
2049
+ }
2050
+ }, 400);
2051
+ }
2052
+
1740
2053
  function resetIframeBindings() {
1741
2054
  detachIframeLoadingListeners();
1742
2055
  stopIframeContentApplyWatcher();
2056
+ stopConsistencyWatchdog();
2057
+ if (consistencyReconcileTimer) {
2058
+ clearTimeout(consistencyReconcileTimer);
2059
+ consistencyReconcileTimer = null;
2060
+ }
1743
2061
  appliedChangesetSnapshots = {};
2062
+ appliedStructuralChangesetKeys = {};
1744
2063
  clickAttachDoc = null;
1745
2064
  dragAttachDoc = null;
1746
2065
  changeObserverDoc = null;
@@ -1811,13 +2130,19 @@ function switchVariation(varId) {
1811
2130
  saveCurrentVariationHtml();
1812
2131
  clearPendingGranularChangesets();
1813
2132
  activeVarId = varId;
2133
+ writePersistedActiveVariationId(varId);
1814
2134
  renderVariationTabs();
1815
2135
  deselectElement();
1816
2136
  try {
1817
2137
  var iframe = document.getElementById('iframeId');
1818
2138
  var saved = varHtmlCache[varId];
1819
2139
  if (saved) {
1820
- iframe.contentDocument.body.innerHTML = saved;
2140
+ beginSuppressIframeMutationDirty();
2141
+ try {
2142
+ iframe.contentDocument.body.innerHTML = saved;
2143
+ } finally {
2144
+ endSuppressIframeMutationDirty();
2145
+ }
1821
2146
  detachIframeLoadingListeners();
1822
2147
  setIframePageLoadingUi(false);
1823
2148
  syncIframeInteractions('switch-variation-cache');
@@ -1840,6 +2165,7 @@ function switchVariation(varId) {
1840
2165
  }
1841
2166
  } catch(_) {}
1842
2167
  if (currentMainTab === 'history') renderHistoryTab();
2168
+ recomputeEditorDirty();
1843
2169
  }
1844
2170
 
1845
2171
  function saveCurrentVariationHtml() {
@@ -1878,31 +2204,123 @@ function parseVariationChangesets(variation) {
1878
2204
  }
1879
2205
  }
1880
2206
 
2207
+ /** Lowercase entry.type so persisted / API rows match switches (e.g. Insert vs insert). */
2208
+ function normalizeChangesetType(entry) {
2209
+ return String(entry && entry.type != null ? entry.type : '').toLowerCase();
2210
+ }
2211
+
1881
2212
  function filterGranularChangesetEntries(cs) {
1882
2213
  if (!cs || !cs.length) return [];
1883
2214
  var out = [];
1884
2215
  for (var i = 0; i < cs.length; i++) {
1885
2216
  var e = cs[i];
1886
- if (e && e.selector && e.selector !== '__vvveb_body__') out.push(e);
2217
+ if (!e || !e.selector || e.selector === '__vvveb_body__') continue;
2218
+ out.push(e);
2219
+ }
2220
+ return out;
2221
+ }
2222
+
2223
+ /** Dedup key for merging chain-set rows (overlay wins over base). */
2224
+ function chainSetDedupKey(entry) {
2225
+ if (!entry || !entry.selector) return '';
2226
+ var t = normalizeChangesetType(entry);
2227
+ if (t === 'style') return entry.selector + '|s|' + String(entry.property || '');
2228
+ if (t === 'content') return entry.selector + '|c|' + (entry.html != null ? 'h' : 't');
2229
+ if (t === 'attribute') return entry.selector + '|a|' + String(entry.attribute || '');
2230
+ if (t === 'insert') return entry.selector + '|i|' + String(entry.action || '') + '|' + String(entry.html || '').slice(0, 120);
2231
+ if (t === 'remove') return entry.selector + '|r|';
2232
+ if (t === 'reorder') {
2233
+ return entry.selector + '|ro|' + String(entry.targetSelector || '') + '|' + String(entry.action || '');
2234
+ }
2235
+ try {
2236
+ return entry.selector + '|' + t + '|' + JSON.stringify(entry);
2237
+ } catch(_) {
2238
+ return entry.selector + '|' + t;
2239
+ }
2240
+ }
2241
+
2242
+ function mergeGranularChainSets(baseList, overlayList) {
2243
+ var map = {};
2244
+ var order = [];
2245
+ function ingest(arr) {
2246
+ if (!arr || !arr.length) return;
2247
+ for (var i = 0; i < arr.length; i++) {
2248
+ var e = arr[i];
2249
+ if (!e || !e.selector) continue;
2250
+ var k = chainSetDedupKey(e);
2251
+ if (!map[k]) order.push(k);
2252
+ map[k] = e;
2253
+ }
2254
+ }
2255
+ ingest(baseList);
2256
+ ingest(overlayList);
2257
+ var out = [];
2258
+ for (var j = 0; j < order.length; j++) {
2259
+ out.push(map[order[j]]);
1887
2260
  }
1888
2261
  return out;
1889
2262
  }
1890
2263
 
2264
+ function appendSessionStructuralChainRow(varId, row) {
2265
+ if (!varId || !row) return;
2266
+ if (!sessionStructuralChainRowsByVarId[varId]) sessionStructuralChainRowsByVarId[varId] = [];
2267
+ sessionStructuralChainRowsByVarId[varId].push(row);
2268
+ }
2269
+
2270
+ /** One States-tab row -> Conversion.io chain-set shape (matches applyChangesetEntry). */
2271
+ function stateChangeToChainSet(c) {
2272
+ if (!c || !c.selector) return null;
2273
+ if (c.cssProp) {
2274
+ return { selector: c.selector, type: 'style', property: c.cssProp, value: c.value };
2275
+ }
2276
+ switch (c.inputId) {
2277
+ case 'pp-text':
2278
+ return { selector: c.selector, type: 'content', value: c.value };
2279
+ case 'pp-html':
2280
+ return { selector: c.selector, type: 'content', html: c.value };
2281
+ case 'pp-cls':
2282
+ return { selector: c.selector, type: 'attribute', attribute: 'class', value: c.value };
2283
+ case 'pp-id':
2284
+ return { selector: c.selector, type: 'attribute', attribute: 'id', value: c.value };
2285
+ case 'pp-href':
2286
+ return { selector: c.selector, type: 'attribute', attribute: 'href', value: c.value };
2287
+ case 'pp-target':
2288
+ return { selector: c.selector, type: 'attribute', attribute: 'target', value: c.value };
2289
+ case 'pp-src':
2290
+ return { selector: c.selector, type: 'attribute', attribute: 'src', value: c.value };
2291
+ case 'pp-alt':
2292
+ return { selector: c.selector, type: 'attribute', attribute: 'alt', value: c.value };
2293
+ case 'pp-ph':
2294
+ return { selector: c.selector, type: 'attribute', attribute: 'placeholder', value: c.value };
2295
+ case 'pp-css':
2296
+ return { selector: c.selector, type: 'attribute', attribute: 'style', value: c.value };
2297
+ case 'pp-mob-css':
2298
+ return { selector: c.selector, type: 'attribute', attribute: 'data-mobile-css', value: c.value };
2299
+ case 'pp-tab-css':
2300
+ return { selector: c.selector, type: 'attribute', attribute: 'data-tablet-css', value: c.value };
2301
+ default:
2302
+ return null;
2303
+ }
2304
+ }
2305
+
1891
2306
  function countUnresolvedGranularSelectors(iframeDoc, entries) {
1892
2307
  if (!iframeDoc || !entries || !entries.length) return 0;
1893
2308
  var n = 0;
1894
2309
  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++;
2310
+ if (!querySelectorResolved(iframeDoc, entries[i].selector)) n++;
1898
2311
  }
1899
2312
  return n;
1900
2313
  }
1901
2314
 
1902
2315
  function applyGranularChangesetEntries(iframeDoc, entries) {
1903
2316
  if (!iframeDoc || !entries || !entries.length) return;
1904
- for (var i = 0; i < entries.length; i++) {
1905
- applyChangesetEntry(entries[i], iframeDoc);
2317
+ beginSuppressIframeMutationDirty();
2318
+ try {
2319
+ for (var i = 0; i < entries.length; i++) {
2320
+ applyChangesetEntry(entries[i], iframeDoc);
2321
+ }
2322
+ } finally {
2323
+ endSuppressIframeMutationDirty();
1906
2324
  }
1907
2325
  }
1908
2326
 
@@ -1957,6 +2375,30 @@ function flushPendingGranularChangesets() {
1957
2375
  scheduleGranularChangesetReapply();
1958
2376
  }
1959
2377
 
2378
+ /** Stable key for structural changesets (insert/reorder) to avoid double-apply across early + full paint. */
2379
+ function structuralChangesetDedupKey(entry) {
2380
+ var nt = normalizeChangesetType(entry);
2381
+ if (!entry || (nt !== 'insert' && nt !== 'reorder')) return '';
2382
+ var vid = activeVarId || '';
2383
+ try {
2384
+ return (
2385
+ vid +
2386
+ '\0' +
2387
+ nt +
2388
+ '\0' +
2389
+ entry.selector +
2390
+ '\0' +
2391
+ String(entry.action || '') +
2392
+ '\0' +
2393
+ String(entry.html != null ? entry.html : '').slice(0, 240) +
2394
+ '\0' +
2395
+ String(entry.targetSelector || '')
2396
+ );
2397
+ } catch(_) {
2398
+ return vid + '\0' + nt + '\0' + entry.selector;
2399
+ }
2400
+ }
2401
+
1960
2402
  /**
1961
2403
  * Apply a single changeset entry inside the editor iframe.
1962
2404
  * Backend format: { selector, type: 'content'|'style'|'attribute'|'insert'|'remove', ... }
@@ -1976,21 +2418,34 @@ function applyChangesetEntry(entry, iframeDoc) {
1976
2418
  return;
1977
2419
  }
1978
2420
 
2421
+ var structuralDedupeKey = structuralChangesetDedupKey(entry);
2422
+ if (structuralDedupeKey && appliedStructuralChangesetKeys[structuralDedupeKey]) return;
2423
+
1979
2424
  // \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(_) {}
2425
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1982
2426
  if (!el) return;
1983
2427
 
1984
2428
  captureChangesetSnapshotBeforeApply(entry, el, iframeDoc);
1985
2429
 
1986
- switch (entry.type) {
2430
+ switch (normalizeChangesetType(entry)) {
1987
2431
  case 'content':
1988
2432
  if (entry.html != null) el.innerHTML = entry.html;
1989
2433
  else if (entry.value != null) el.textContent = entry.value;
1990
2434
  break;
1991
2435
  case 'style':
1992
- if (entry.property && entry.value != null) {
1993
- el.style[camelize(entry.property)] = entry.value;
2436
+ if (entry.property) {
2437
+ var propKebab = entry.property;
2438
+ var cam = camelize(propKebab);
2439
+ if (entry.value == null || entry.value === '') {
2440
+ try { el.style.removeProperty(propKebab); } catch(_) {}
2441
+ try { if (cam in el.style) el.style[cam] = ''; } catch(__) {}
2442
+ } else {
2443
+ try {
2444
+ el.style.setProperty(propKebab, entry.value, 'important');
2445
+ } catch(_) {
2446
+ el.style[cam] = entry.value;
2447
+ }
2448
+ }
1994
2449
  }
1995
2450
  break;
1996
2451
  case 'attribute':
@@ -2001,11 +2456,23 @@ function applyChangesetEntry(entry, iframeDoc) {
2001
2456
  case 'insert': {
2002
2457
  var pos = entry.action === 'before' ? 'beforebegin' : 'afterend';
2003
2458
  if (entry.html) el.insertAdjacentHTML(pos, entry.html);
2459
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2004
2460
  break;
2005
2461
  }
2006
2462
  case 'remove':
2007
2463
  el.style.display = 'none';
2008
2464
  break;
2465
+ case 'reorder': {
2466
+ var target = entry.targetSelector ? querySelectorResolved(iframeDoc, entry.targetSelector) : null;
2467
+ if (!target || !el.parentNode || !target.parentNode) break;
2468
+ if (entry.action === 'before') {
2469
+ target.parentNode.insertBefore(el, target);
2470
+ } else {
2471
+ target.parentNode.insertBefore(el, target.nextSibling);
2472
+ }
2473
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2474
+ break;
2475
+ }
2009
2476
  default: break;
2010
2477
  }
2011
2478
  }
@@ -2020,19 +2487,24 @@ function applyActiveVariationHtml() {
2020
2487
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2021
2488
  var cs = parseVariationChangesets(variation);
2022
2489
 
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
- }
2490
+ beginSuppressIframeMutationDirty();
2491
+ try {
2492
+ // If we have an in-session HTML snapshot, use it (user edited in this session)
2493
+ var saved = varHtmlCache[activeVarId];
2494
+ if (saved) {
2495
+ try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2496
+ return;
2497
+ }
2029
2498
 
2030
- if (!cs.length) return;
2031
- for (var i = 0; i < cs.length; i++) {
2032
- applyChangesetEntry(cs[i], iframeDoc);
2499
+ if (!cs.length) return;
2500
+ for (var i = 0; i < cs.length; i++) {
2501
+ applyChangesetEntry(cs[i], iframeDoc);
2502
+ }
2503
+ // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2504
+ registerPendingGranularChangesets(cs, iframeDoc);
2505
+ } finally {
2506
+ endSuppressIframeMutationDirty();
2033
2507
  }
2034
- // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2035
- registerPendingGranularChangesets(cs, iframeDoc);
2036
2508
  }
2037
2509
 
2038
2510
  function changesetsHaveBodySnapshot(cs) {
@@ -2043,6 +2515,23 @@ function changesetsHaveBodySnapshot(cs) {
2043
2515
  return false;
2044
2516
  }
2045
2517
 
2518
+ /** Rows to persist for this variation on Finalize (same chain-set model as EditorShell \u2014 never __vvveb_body__). */
2519
+ function buildPersistedChainSetsForVariation(v) {
2520
+ if (!v || !v._id) return [];
2521
+ var parsed = parseVariationChangesets(v);
2522
+ var base = filterGranularChangesetEntries(parsed);
2523
+ var sessionExtra = sessionStructuralChainRowsByVarId[v._id] || [];
2524
+ if (v._id !== activeVarId) {
2525
+ return mergeGranularChainSets(base, sessionExtra);
2526
+ }
2527
+ var overlay = [];
2528
+ for (var si = 0; si < stateChanges.length; si++) {
2529
+ var row = stateChangeToChainSet(stateChanges[si]);
2530
+ if (row) overlay.push(row);
2531
+ }
2532
+ return mergeGranularChainSets(mergeGranularChainSets(base, sessionExtra), overlay);
2533
+ }
2534
+
2046
2535
  /**
2047
2536
  * While document.readyState === 'loading', apply only granular changesets (no __vvveb_body__
2048
2537
  * replacement) so the first painted nodes can receive edits before iframe/window load completes.
@@ -2053,10 +2542,15 @@ function applyVariationGranularOnly(iframeDoc) {
2053
2542
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2054
2543
  var cs = parseVariationChangesets(variation);
2055
2544
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2056
- for (var i = 0; i < cs.length; i++) {
2057
- applyChangesetEntry(cs[i], iframeDoc);
2545
+ beginSuppressIframeMutationDirty();
2546
+ try {
2547
+ for (var i = 0; i < cs.length; i++) {
2548
+ applyChangesetEntry(cs[i], iframeDoc);
2549
+ }
2550
+ registerPendingGranularChangesets(cs, iframeDoc);
2551
+ } finally {
2552
+ endSuppressIframeMutationDirty();
2058
2553
  }
2059
- registerPendingGranularChangesets(cs, iframeDoc);
2060
2554
  }
2061
2555
 
2062
2556
  /** Re-try granular entries without resetting pending registration (use between poll ticks). */
@@ -2066,8 +2560,13 @@ function reapplyActiveVariationGranular(iframeDoc) {
2066
2560
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2067
2561
  var cs = parseVariationChangesets(variation);
2068
2562
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2069
- for (var i = 0; i < cs.length; i++) {
2070
- applyChangesetEntry(cs[i], iframeDoc);
2563
+ beginSuppressIframeMutationDirty();
2564
+ try {
2565
+ for (var i = 0; i < cs.length; i++) {
2566
+ applyChangesetEntry(cs[i], iframeDoc);
2567
+ }
2568
+ } finally {
2569
+ endSuppressIframeMutationDirty();
2071
2570
  }
2072
2571
  }
2073
2572
 
@@ -2129,9 +2628,14 @@ function startIframeContentApplyWatcher(navGen) {
2129
2628
 
2130
2629
  // \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
2630
  function selectElement(el) {
2132
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2133
- selectedEl = el;
2134
- try { el.classList.add('vve-selected'); } catch(_) {}
2631
+ beginSuppressIframeMutationDirty();
2632
+ try {
2633
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2634
+ selectedEl = el;
2635
+ try { el.classList.add('vve-selected'); } catch(_) {}
2636
+ } finally {
2637
+ endSuppressIframeMutationDirty();
2638
+ }
2135
2639
  document.getElementById('bc-path').textContent = buildSelector(el);
2136
2640
  document.getElementById('bc-path').style.color = 'var(--accent-txt)';
2137
2641
  document.getElementById('no-sel').style.display = 'none';
@@ -2146,7 +2650,12 @@ function selectElement(el) {
2146
2650
 
2147
2651
  function deselectElement() {
2148
2652
  setDragHandleActive(false);
2149
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2653
+ beginSuppressIframeMutationDirty();
2654
+ try {
2655
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2656
+ } finally {
2657
+ endSuppressIframeMutationDirty();
2658
+ }
2150
2659
  document.getElementById('no-sel').style.display = '';
2151
2660
  document.getElementById('el-info').style.display = 'none';
2152
2661
  document.getElementById('rp-accordion').style.display = 'none';
@@ -2170,18 +2679,27 @@ function injectIframeSelectionStyles(doc) {
2170
2679
  'html.vve-drag-armed .vve-selected{cursor:grab!important;}' +
2171
2680
  '.vve-dragging{opacity:0.92!important;outline:2px dashed #f59e0b!important;' +
2172
2681
  'outline-offset:2px!important;cursor:grabbing!important;box-shadow:none!important;}';
2173
- doc.head.appendChild(st);
2682
+ beginSuppressIframeMutationDirty();
2683
+ try {
2684
+ doc.head.appendChild(st);
2685
+ } finally {
2686
+ endSuppressIframeMutationDirty();
2687
+ }
2174
2688
  }
2175
2689
 
2176
2690
  function setDragHandleActive(on) {
2177
2691
  dragHandleActive = !!on;
2178
2692
  var b = document.getElementById('sf-drag');
2179
2693
  if (b) b.classList.toggle('active', dragHandleActive);
2694
+ beginSuppressIframeMutationDirty();
2180
2695
  try {
2181
2696
  var iframe = document.getElementById('iframeId');
2182
2697
  var d = iframe && iframe.contentDocument && iframe.contentDocument.documentElement;
2183
2698
  if (d) d.classList.toggle('vve-drag-armed', dragHandleActive);
2184
- } catch(_) {}
2699
+ } catch(_) {
2700
+ } finally {
2701
+ endSuppressIframeMutationDirty();
2702
+ }
2185
2703
  }
2186
2704
 
2187
2705
  function positionSelectionToolbar() {
@@ -2251,34 +2769,72 @@ function selectElementFromTree(el) {
2251
2769
 
2252
2770
  function duplicateSelectedEl() {
2253
2771
  if (!selectedEl || !selectedEl.parentNode) return;
2772
+ var anchorSel = buildSelector(selectedEl);
2254
2773
  var clone = selectedEl.cloneNode(true);
2255
2774
  clone.classList.remove('vve-selected');
2256
2775
  var all = clone.querySelectorAll ? clone.querySelectorAll('.vve-selected') : [];
2257
2776
  for (var i = 0; i < all.length; i++) all[i].classList.remove('vve-selected');
2258
2777
  clone.style.visibility = '';
2259
2778
  if (clone.removeAttribute) clone.removeAttribute('data-vve-hidden');
2779
+ stripDataVveInstanceSubtree(clone);
2780
+ try {
2781
+ if (clone.id) clone.removeAttribute('id');
2782
+ } catch(_) {}
2783
+ try {
2784
+ clone.setAttribute('data-vve-instance', generateVveInstanceId());
2785
+ } catch(_) {}
2786
+ if (activeVarId) {
2787
+ appendSessionStructuralChainRow(activeVarId, {
2788
+ selector: anchorSel,
2789
+ type: 'insert',
2790
+ action: 'after',
2791
+ html: clone.outerHTML,
2792
+ });
2793
+ }
2260
2794
  selectedEl.parentNode.insertBefore(clone, selectedEl.nextSibling);
2261
- markDirty();
2795
+ saveCurrentVariationHtml();
2796
+ recomputeEditorDirty();
2262
2797
  scheduleDomTreeRefresh();
2263
2798
  selectElement(clone);
2264
2799
  }
2265
2800
 
2266
2801
  function toggleHideSelectedEl() {
2267
2802
  if (!selectedEl) return;
2803
+ var hidSel = buildSelector(selectedEl);
2268
2804
  if (selectedEl.getAttribute('data-vve-hidden') === '1') {
2269
2805
  selectedEl.style.visibility = '';
2270
2806
  selectedEl.removeAttribute('data-vve-hidden');
2807
+ if (activeVarId) {
2808
+ appendSessionStructuralChainRow(activeVarId, {
2809
+ selector: hidSel,
2810
+ type: 'style',
2811
+ property: 'visibility',
2812
+ value: '',
2813
+ });
2814
+ }
2271
2815
  } else {
2272
2816
  selectedEl.style.visibility = 'hidden';
2273
2817
  selectedEl.setAttribute('data-vve-hidden', '1');
2818
+ if (activeVarId) {
2819
+ appendSessionStructuralChainRow(activeVarId, {
2820
+ selector: hidSel,
2821
+ type: 'style',
2822
+ property: 'visibility',
2823
+ value: 'hidden',
2824
+ });
2825
+ }
2274
2826
  }
2275
- markDirty();
2827
+ saveCurrentVariationHtml();
2828
+ recomputeEditorDirty();
2276
2829
  }
2277
2830
 
2278
2831
  function deleteSelectedEl() {
2279
2832
  if (!selectedEl || !selectedEl.parentNode) return;
2833
+ var delSel = buildSelector(selectedEl);
2280
2834
  selectedEl.remove();
2281
- markDirty();
2835
+ if (activeVarId) appendSessionStructuralChainRow(activeVarId, { selector: delSel, type: 'remove' });
2836
+ saveCurrentVariationHtml();
2837
+ recomputeEditorDirty();
2282
2838
  deselectElement();
2283
2839
  scheduleDomTreeRefresh();
2284
2840
  }
@@ -2570,14 +3126,12 @@ function renderImageSection(el) {
2570
3126
  var prev = document.querySelector('.img-preview');
2571
3127
  if (prev) prev.src = srcInp.value;
2572
3128
  logChange(sel, 'pp-src', srcInp.value, el, orig);
2573
- markDirty();
2574
3129
  });
2575
3130
  var altInp = document.getElementById('pp-img-alt');
2576
3131
  if (altInp) altInp.addEventListener('input', function() {
2577
3132
  var orig = getOriginalValue('pp-alt', el);
2578
3133
  el.setAttribute('alt', altInp.value);
2579
3134
  logChange(sel, 'pp-alt', altInp.value, el, orig);
2580
- markDirty();
2581
3135
  });
2582
3136
 
2583
3137
  // Wire srcset entry inputs
@@ -2586,7 +3140,8 @@ function renderImageSection(el) {
2586
3140
  var dInp = document.getElementById('pp-se-desc-'+i);
2587
3141
  function flushSrcset() {
2588
3142
  el.setAttribute('srcset', buildSrcset(_srcsetEntries));
2589
- markDirty();
3143
+ saveCurrentVariationHtml();
3144
+ recomputeEditorDirty();
2590
3145
  }
2591
3146
  if (uInp) uInp.addEventListener('input', function(){ _srcsetEntries[i].url = uInp.value; flushSrcset(); });
2592
3147
  if (dInp) dInp.addEventListener('input', function(){ _srcsetEntries[i].descriptor = dInp.value; flushSrcset(); });
@@ -2604,7 +3159,8 @@ function renderImageSection(el) {
2604
3159
  }
2605
3160
  function flushSizes() {
2606
3161
  el.setAttribute('sizes', buildSizes(_sizesEntries));
2607
- markDirty();
3162
+ saveCurrentVariationHtml();
3163
+ recomputeEditorDirty();
2608
3164
  }
2609
3165
  if (opInp) opInp.addEventListener('change', function(){ buildCondition(); flushSizes(); });
2610
3166
  if (valInp) valInp.addEventListener('input', function(){ buildCondition(); flushSizes(); });
@@ -2618,7 +3174,12 @@ function addSrcsetEntry() {
2618
3174
  }
2619
3175
  function removeSrcsetEntry(i) {
2620
3176
  _srcsetEntries.splice(i, 1);
2621
- if (_imageEl) { _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries)); renderImageSection(_imageEl); markDirty(); }
3177
+ if (_imageEl) {
3178
+ _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries));
3179
+ renderImageSection(_imageEl);
3180
+ saveCurrentVariationHtml();
3181
+ recomputeEditorDirty();
3182
+ }
2622
3183
  }
2623
3184
  function addSizesEntry() {
2624
3185
  _sizesEntries.push({condition:'max-width: 760px', value:'760px'});
@@ -2626,7 +3187,12 @@ function addSizesEntry() {
2626
3187
  }
2627
3188
  function removeSizesEntry(i) {
2628
3189
  _sizesEntries.splice(i, 1);
2629
- if (_imageEl) { _imageEl.setAttribute('sizes', buildSizes(_sizesEntries)); renderImageSection(_imageEl); markDirty(); }
3190
+ if (_imageEl) {
3191
+ _imageEl.setAttribute('sizes', buildSizes(_sizesEntries));
3192
+ renderImageSection(_imageEl);
3193
+ saveCurrentVariationHtml();
3194
+ recomputeEditorDirty();
3195
+ }
2630
3196
  }
2631
3197
 
2632
3198
  // \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 +3397,51 @@ function renderRightPanel(el) {
2831
3397
  var orig = getOriginalValue(b[0], el);
2832
3398
  b[1](inp.value);
2833
3399
  logChange(sel, b[0], inp.value, el, orig);
2834
- markDirty();
2835
3400
  });
2836
3401
  });
2837
3402
  }
2838
3403
 
2839
3404
  // \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
3405
+ function generateVveInstanceId() {
3406
+ return 'v' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
3407
+ }
3408
+
3409
+ /** Editor-assigned clone marker so duplicated subtrees do not share the same CSS path as the original. */
3410
+ function stripDataVveInstanceSubtree(root) {
3411
+ if (!root || root.nodeType !== 1) return;
3412
+ try {
3413
+ root.removeAttribute('data-vve-instance');
3414
+ } catch(_) {}
3415
+ var sub = root.querySelectorAll ? root.querySelectorAll('[data-vve-instance]') : [];
3416
+ for (var i = 0; i < sub.length; i++) {
3417
+ try {
3418
+ sub[i].removeAttribute('data-vve-instance');
3419
+ } catch(_) {}
3420
+ }
3421
+ }
3422
+
2840
3423
  function buildSelector(el) {
2841
3424
  if (!el) return '';
3425
+ var doc = el.ownerDocument || document;
3426
+ var inst = el.getAttribute && el.getAttribute('data-vve-instance');
3427
+ if (inst && String(inst).trim()) {
3428
+ var safe = String(inst).split('"').join('\\"');
3429
+ var attrSel = '[data-vve-instance="' + safe + '"]';
3430
+ try {
3431
+ if (doc.querySelectorAll(attrSel).length === 1) return attrSel;
3432
+ } catch(_) {}
3433
+ }
2842
3434
  if (el.id) return '#' + el.id;
2843
3435
  var parts = [], node = el, depth = 0;
2844
3436
  while (node && node.nodeType === 1 && depth < 5) {
2845
3437
  if (node.id) { parts.unshift('#' + node.id); break; }
2846
3438
  var p = node.tagName.toLowerCase();
2847
- if (node.classList && node.classList.length) p += '.' + Array.from(node.classList).slice(0,2).join('.');
3439
+ if (node.classList && node.classList.length) {
3440
+ var clsArr = Array.from(node.classList).filter(function(c) {
3441
+ return c.indexOf('vve-') !== 0;
3442
+ });
3443
+ if (clsArr.length) p += '.' + clsArr.slice(0, 2).join('.');
3444
+ }
2848
3445
  var idx = 1, sib = node.previousElementSibling;
2849
3446
  while (sib) { if (sib.tagName === node.tagName) idx++; sib = sib.previousElementSibling; }
2850
3447
  if (idx > 1) p += ':nth-of-type(' + idx + ')';
@@ -2855,6 +3452,71 @@ function buildSelector(el) {
2855
3452
  return parts.join(' > ');
2856
3453
  }
2857
3454
 
3455
+ /**
3456
+ * Strip editor-only .vve-* class tokens from a selector string (fixes DB rows saved while an element was selected).
3457
+ */
3458
+ function sanitizeSelectorForMatch(sel) {
3459
+ if (!sel || typeof sel !== 'string') return '';
3460
+ var s0 = sel.replace(/.vve-[a-zA-Z0-9_-]+/gi, '');
3461
+ var parts = s0.split(/s*>s*/).map(function(seg) {
3462
+ var t = seg.replace(/.+/g, '.').replace(/.$/, '');
3463
+ return t.trim();
3464
+ });
3465
+ return parts.filter(Boolean).join(' > ');
3466
+ }
3467
+
3468
+ /** Drop the rightmost :nth-of-type(n) (hydration / layout often shifts sibling indices). */
3469
+ function stripRightmostNthOfType(sel) {
3470
+ if (!sel || typeof sel !== 'string') return null;
3471
+ var idx = sel.lastIndexOf(':nth-of-type(');
3472
+ if (idx === -1) return null;
3473
+ var j = idx + ':nth-of-type('.length;
3474
+ while (j < sel.length && sel.charCodeAt(j) >= 48 && sel.charCodeAt(j) <= 57) j++;
3475
+ if (j >= sel.length || sel.charAt(j) !== ')') return null;
3476
+ return (sel.slice(0, idx) + sel.slice(j + 1))
3477
+ .replace(/s{2,}/g, ' ')
3478
+ .replace(/s*>s*>/g, ' >')
3479
+ .trim();
3480
+ }
3481
+
3482
+ function querySelectorResolved(iframeDoc, selector) {
3483
+ if (!iframeDoc || !selector) return null;
3484
+ var seen = {};
3485
+ function tryOne(s) {
3486
+ if (!s || seen[s]) return null;
3487
+ seen[s] = true;
3488
+ try {
3489
+ return iframeDoc.querySelector(s) || null;
3490
+ } catch(_) {
3491
+ return null;
3492
+ }
3493
+ }
3494
+ function walkRelax(base) {
3495
+ if (!base) return null;
3496
+ var el = tryOne(base);
3497
+ if (el) return el;
3498
+ var cur = base;
3499
+ for (var g = 0; g < 28; g++) {
3500
+ var nxt = stripRightmostNthOfType(cur);
3501
+ if (!nxt || nxt === cur) break;
3502
+ cur = nxt;
3503
+ el = tryOne(cur);
3504
+ if (el) return el;
3505
+ }
3506
+ return null;
3507
+ }
3508
+ var alt = sanitizeSelectorForMatch(selector);
3509
+ // Prefer sanitized + nth relax FIRST: raw selectors often still contain .vve-* from
3510
+ // save-time selection; those only match after clicking (we re-add vve-selected).
3511
+ var el = walkRelax(alt || selector);
3512
+ if (el) return el;
3513
+ if (alt !== selector) {
3514
+ el = walkRelax(selector);
3515
+ if (el) return el;
3516
+ }
3517
+ return null;
3518
+ }
3519
+
2858
3520
  // \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
3521
  function repositionDragSibling(dragEl, clientY) {
2860
3522
  var p = dragEl.parentElement;
@@ -2880,6 +3542,27 @@ function repositionDragSibling(dragEl, clientY) {
2880
3542
  }
2881
3543
  }
2882
3544
 
3545
+ function recordReorderAfterDrag(movedEl) {
3546
+ if (!activeVarId || !movedEl || !movedEl.parentElement) return;
3547
+ var prev = movedEl.previousElementSibling;
3548
+ var next = movedEl.nextElementSibling;
3549
+ if (prev) {
3550
+ appendSessionStructuralChainRow(activeVarId, {
3551
+ selector: buildSelector(movedEl),
3552
+ type: 'reorder',
3553
+ targetSelector: buildSelector(prev),
3554
+ action: 'after',
3555
+ });
3556
+ } else if (next) {
3557
+ appendSessionStructuralChainRow(activeVarId, {
3558
+ selector: buildSelector(movedEl),
3559
+ type: 'reorder',
3560
+ targetSelector: buildSelector(next),
3561
+ action: 'before',
3562
+ });
3563
+ }
3564
+ }
3565
+
2883
3566
  function attachDragReposition() {
2884
3567
  try {
2885
3568
  var iframe = document.getElementById('iframeId');
@@ -2933,7 +3616,9 @@ function attachDragReposition() {
2933
3616
  } catch(_) {}
2934
3617
  suppressClickUntil = Date.now() + 200;
2935
3618
  setDragHandleActive(false);
2936
- markDirty();
3619
+ if (activeVarId) recordReorderAfterDrag(selectedEl);
3620
+ saveCurrentVariationHtml();
3621
+ recomputeEditorDirty();
2937
3622
  updateSelectionToolbar();
2938
3623
  scheduleDomTreeRefresh();
2939
3624
  }
@@ -2986,11 +3671,31 @@ function attachChangeObserver() {
2986
3671
  changeObserver = null;
2987
3672
  changeObserverDoc = null;
2988
3673
  }
2989
- changeObserver = new MutationObserver(function() {
2990
- markDirty();
2991
- // Debounced full rebuild of Elements panel as the live DOM grows / changes
3674
+ changeObserver = new MutationObserver(function(mutations) {
3675
+ // Dirty state is derived from changesets baseline + stateChanges (not raw DOM mutations).
3676
+ var bodyReplaced = false;
3677
+ for (var mi = 0; mi < mutations.length; mi++) {
3678
+ var m = mutations[mi];
3679
+ if (
3680
+ m &&
3681
+ m.type === 'childList' &&
3682
+ m.target === doc.body &&
3683
+ m.addedNodes &&
3684
+ m.removedNodes &&
3685
+ m.addedNodes.length > 0 &&
3686
+ m.removedNodes.length > 0
3687
+ ) {
3688
+ bodyReplaced = true;
3689
+ break;
3690
+ }
3691
+ }
3692
+ if (bodyReplaced) {
3693
+ // Page JS replaced body children; allow structural rows (insert/reorder) to apply again.
3694
+ appliedStructuralChangesetKeys = {};
3695
+ }
2992
3696
  scheduleDomTreeRefresh();
2993
3697
  scheduleGranularChangesetReapply();
3698
+ scheduleConsistencyReconcile();
2994
3699
  });
2995
3700
  changeObserver.observe(doc.body, {
2996
3701
  childList: true, subtree: true, attributes: true, characterData: true
@@ -3020,10 +3725,13 @@ function syncIframeInteractions(reason) {
3020
3725
  attachClickHandler();
3021
3726
  attachDragReposition();
3022
3727
  attachChangeObserver();
3728
+ startConsistencyWatchdog(doc);
3729
+ scheduleConsistencyReconcile();
3023
3730
  bindSelectionToolbarScroll();
3024
3731
  var inp = document.getElementById('comp-search');
3025
3732
  renderDomTree(inp ? inp.value : '');
3026
3733
  updateSelectionToolbar();
3734
+ recomputeEditorDirty();
3027
3735
  } catch(_) {}
3028
3736
  }
3029
3737
 
@@ -3075,8 +3783,11 @@ function insertHtml(html) {
3075
3783
  console.warn('[V2] insertHtml: iframe document not ready');
3076
3784
  return;
3077
3785
  }
3786
+ var htmlStr = String(html).trim();
3787
+ var anchorSel =
3788
+ selectedEl && selectedEl !== doc.body && selectedEl.parentNode ? buildSelector(selectedEl) : 'body';
3078
3789
  var t = doc.createElement('template');
3079
- t.innerHTML = String(html).trim();
3790
+ t.innerHTML = htmlStr;
3080
3791
  var frag = doc.createDocumentFragment();
3081
3792
  var firstEl = null;
3082
3793
  while (t.content.firstChild) {
@@ -3092,7 +3803,16 @@ function insertHtml(html) {
3092
3803
  doc.body.appendChild(frag);
3093
3804
  }
3094
3805
  if (firstEl) selectElement(firstEl);
3095
- markDirty();
3806
+ if (activeVarId) {
3807
+ appendSessionStructuralChainRow(activeVarId, {
3808
+ selector: anchorSel,
3809
+ type: 'insert',
3810
+ action: 'after',
3811
+ html: htmlStr,
3812
+ });
3813
+ }
3814
+ saveCurrentVariationHtml();
3815
+ recomputeEditorDirty();
3096
3816
  scheduleDomTreeRefresh();
3097
3817
  } catch(err) { console.warn('[V2] insertHtml:', err); }
3098
3818
  }
@@ -3184,15 +3904,37 @@ document.getElementById('btn-close').addEventListener('click', handleClose);
3184
3904
  function handleSave() {
3185
3905
  saveCurrentVariationHtml();
3186
3906
  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: [] }]) });
3907
+ var prevParsed = parseVariationChangesets(v);
3908
+ var granularPrev = filterGranularChangesetEntries(prevParsed);
3909
+ var bodyOnlyLegacy = changesetsHaveBodySnapshot(prevParsed) && granularPrev.length === 0;
3910
+
3911
+ var rows = buildPersistedChainSetsForVariation(v);
3912
+ if (bodyOnlyLegacy && rows.length === 0) {
3913
+ return Object.assign({}, v);
3914
+ }
3915
+ var json = '[]';
3916
+ try {
3917
+ json = JSON.stringify(rows || []);
3918
+ } catch(_) {
3919
+ json = '[]';
3920
+ }
3921
+ return Object.assign({}, v, { changesets: json });
3190
3922
  });
3923
+ variations = updatedVariations;
3924
+ varHtmlCache = {};
3925
+ sessionStructuralChainRowsByVarId = {};
3926
+ stateChanges = [];
3927
+ if (currentMainTab === 'states') renderStatesTab();
3928
+ captureBaselineFromVariations(variations);
3929
+ if (experimentData && Array.isArray(experimentData.variations)) {
3930
+ experimentData.variations = updatedVariations;
3931
+ }
3191
3932
  send('save-experiment', { experimentId: experimentData ? experimentData.experimentId : null, variations: updatedVariations });
3192
- markClean();
3933
+ setEditorDirty(false);
3193
3934
  }
3194
3935
 
3195
3936
  function handleClose() {
3937
+ clearVisualEditorLocalStorage();
3196
3938
  // Unsaved-changes UX lives in the parent (PlatformVisualEditorV2); avoid double confirm here.
3197
3939
  send('close-editor', {});
3198
3940
  }
@@ -3200,8 +3942,22 @@ function handleClose() {
3200
3942
  // \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
3943
  document.addEventListener('keydown', function(e) {
3202
3944
  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(); }
3945
+ if (meta && !e.shiftKey && e.key === 'z') {
3946
+ e.preventDefault();
3947
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3948
+ Vvveb.Undo.undo();
3949
+ saveCurrentVariationHtml();
3950
+ recomputeEditorDirty();
3951
+ }
3952
+ }
3953
+ if (meta && e.shiftKey && e.key === 'z') {
3954
+ e.preventDefault();
3955
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3956
+ Vvveb.Undo.redo();
3957
+ saveCurrentVariationHtml();
3958
+ recomputeEditorDirty();
3959
+ }
3960
+ }
3205
3961
  if (meta && e.key === 's') { e.preventDefault(); handleSave(); }
3206
3962
  if (e.key === 'Escape') {
3207
3963
  var openTips = document.querySelectorAll('.ve-pl-tip.is-tip-open');