@glitchr/transparent 1.0.60 → 1.0.61

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/README.md CHANGED
@@ -1,2 +1 @@
1
1
  # TransparentJS
2
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glitchr/transparent",
3
- "version": "1.0.60",
3
+ "version": "1.0.61",
4
4
  "description": "Transparent SPA Application",
5
5
  "main": "src/index.js",
6
6
  "access": "public",
@@ -1,5 +1,3 @@
1
- import $ from 'jquery';
2
-
3
1
  // Modern browser: use passive event listeners where appropriate for better performance
4
2
  jQuery.event.special.touchstart = { setup: function( _, ns, handle ) { this.addEventListener("touchstart", handle, { passive: !ns.includes("noPreventDefault") }); } };
5
3
  jQuery.event.special.touchmove = { setup: function( _, ns, handle ) { this.addEventListener("touchmove", handle, { passive: !ns.includes("noPreventDefault") }); } };
@@ -14,7 +12,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
14
12
  } else if (typeof exports === 'object') {
15
13
  module.exports = factory();
16
14
  } else {
17
- root = window;
18
15
  root.Transparent = factory();
19
16
  }
20
17
 
@@ -182,7 +179,43 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
182
179
  "smoothscroll_duration": "200ms",
183
180
  "smoothscroll_speed" : 0,
184
181
  "smoothscroll_easing" : "swing",
185
- "exceptions": []
182
+ "exceptions": [],
183
+ // headlock: list of URL substrings or regex patterns to preserve in
184
+ // <head> across page transitions (e.g. third-party widgets that
185
+ // inject <style>/<link> dynamically). Anything matching is treated
186
+ // as "locked" and never removed during the head merge.
187
+ //
188
+ // In addition, head nodes injected dynamically AFTER initial
189
+ // DOMContentLoaded are auto-preserved (snapshotted on load → not
190
+ // in the set → preserved).
191
+ //
192
+ // Per-element overrides:
193
+ // <link data-headlock="false"> → opt-out (allow normal removal)
194
+ // <link data-headlock="true"> → opt-in (always preserve)
195
+ //
196
+ // Ported from upstream 1.0.82's `headlock` design (cleaner API than
197
+ // the previous hardcoded SCRIPT/STYLE-never-remove heuristic).
198
+ "headlock": [],
199
+ // ── View Transitions API ────────────────────────────────────────────
200
+ // When true, the DOM swap is wrapped in document.startViewTransition()
201
+ // so the browser captures an OLD snapshot, applies the swap callback,
202
+ // then crossfades to the NEW state natively. Falls back transparently
203
+ // to the CSS-only transition path on browsers without VT support
204
+ // (Firefox < 144, Safari < 18.2, old Chromium). Per-element morph
205
+ // is opt-in via CSS:
206
+ //
207
+ // #page { view-transition-name: page; }
208
+ // .article-hero img { view-transition-name: article-hero; }
209
+ //
210
+ // Pairs the named element across the swap so the browser animates
211
+ // its position/size change instead of crossfading the snapshots.
212
+ //
213
+ // skip_transition_for_cache: when true, in-DOM cache hits bypass VT
214
+ // entirely (for Turbo-style instant-back UX). Default false because
215
+ // VT's 200ms crossfade is fast enough that the consistency win
216
+ // outweighs the saved frames.
217
+ "use_view_transitions": false,
218
+ "skip_transition_for_cache": false
186
219
  };
187
220
 
188
221
  const State = Transparent.state = {
@@ -222,6 +255,91 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
222
255
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
223
256
  }
224
257
 
258
+ // ── Server-rendered element tracking ───────────────────────────────────
259
+ //
260
+ // SPA head/body merging removes elements not present in the new page's
261
+ // HTML response — correct for server-rendered tags (per-page CSS,
262
+ // <meta>, page-specific <script>), but it ALSO nukes anything that a
263
+ // third-party loader injected at runtime: Brevo's chat widget <style>
264
+ // and <div>, Google Analytics tags, Intercom, Crisp, Hotjar, etc.
265
+ // These elements are never in any page's HTML response, so the merge
266
+ // removes them on every nav → user sees the chat widget lose all its
267
+ // CSS and explode to full screen (visible in the user's screenshot of
268
+ // navigating /articles → /).
269
+ //
270
+ // Fix: maintain a WeakSet of elements known to be server-rendered. The
271
+ // initial document's head + body children are server-rendered. Anything
272
+ // we ADD via the SPA merge later is also server-rendered (it came from
273
+ // a server HTML response). Anything else — third-party script
274
+ // injections that happen AFTER snapshot — is never added to the set
275
+ // and is therefore preserved across navs.
276
+ //
277
+ // WeakSet means removed elements get garbage-collected → no leak.
278
+ //
279
+ // MutationObserver auto-tags elements added by ANY route, so even
280
+ // body-script appends or head-script appends elsewhere in this library
281
+ // stay correctly classified.
282
+ // Ported from upstream 1.0.82 — `headlock` design.
283
+ //
284
+ // Replaces the previous hardcoded "never remove SCRIPT/STYLE" rule
285
+ // with a finer-grained API: snapshot the initial head children, then
286
+ // on the next swap, *preserve* anything that's either (a) not in the
287
+ // snapshot (= dynamically injected after load), or (b) matches a
288
+ // configured URL pattern in Settings.headlock, or (c) has a
289
+ // data-headlock attribute (true/non-empty = lock, "false" = unlock).
290
+ //
291
+ // Two key wins over the previous local impl:
292
+ // 1. The previous impl never removed any <script>/<style> at all,
293
+ // which caused per-page stylesheets to leak across navigations
294
+ // (e.g. layout1's inline <style> persisting on layout2). The new
295
+ // design only locks dynamically-injected ones.
296
+ // 2. Project code can pass `headlock: ["brevo.com", "googletag", /hotjar/]`
297
+ // in Transparent.ready({...}) to explicitly opt-in third-party
298
+ // URLs by substring or regex.
299
+ var originalHeadNodes = new WeakSet();
300
+ function snapshotHeadNodes() {
301
+ if (document.head) {
302
+ for (var i = 0; i < document.head.children.length; i++) {
303
+ originalHeadNodes.add(document.head.children[i]);
304
+ }
305
+ }
306
+ }
307
+ // Snapshot synchronously at module-eval time. Defer scripts run after
308
+ // the parser has built the <head> but before async third-party loaders
309
+ // execute, so the snapshot captures the server-rendered <head> cleanly.
310
+ // The DOMContentLoaded fallback handles the rare case where the script
311
+ // ran before <head> was complete (e.g. in-head non-defer placement).
312
+ snapshotHeadNodes();
313
+ if (!document.head) {
314
+ document.addEventListener("DOMContentLoaded", snapshotHeadNodes, { once: true });
315
+ }
316
+
317
+ Transparent.isHeadlocked = function(el) {
318
+ if (!el || el.nodeType !== 1) return false;
319
+ // Explicit attribute opt-out wins over everything else.
320
+ var attr = el.getAttribute && el.getAttribute("data-headlock");
321
+ if (attr === "false") return false;
322
+ // Explicit attribute opt-in (any non-"false" value).
323
+ if (attr !== null && attr !== undefined) return true;
324
+ // Auto-lock anything injected after the initial snapshot — by
325
+ // definition, third-party widgets and their CSS.
326
+ if (!originalHeadNodes.has(el)) return true;
327
+ // URL-pattern matching: src for <script>/<link>/<iframe>, href for
328
+ // <link>, or full textContent for inline <style> blocks. Substring
329
+ // matches on strings; .test() matches on RegExp.
330
+ var patterns = Settings["headlock"] || [];
331
+ if (!patterns.length) return false;
332
+ var url = el.getAttribute && (el.getAttribute("src") || el.getAttribute("href"));
333
+ if (!url && el.tagName === 'STYLE') url = el.textContent || '';
334
+ if (!url) return false;
335
+ for (var i = 0; i < patterns.length; i++) {
336
+ var p = patterns[i];
337
+ if (p instanceof RegExp) { if (p.test(url)) return true; }
338
+ else if (typeof p === "string" && p.length && url.indexOf(p) !== -1) return true;
339
+ }
340
+ return false;
341
+ };
342
+
225
343
  window.addEventListener("DOMContentLoaded", function()
226
344
  {
227
345
  Transparent.loader = $($(document).find(Settings.loader)[0] ?? Transparent.html);
@@ -957,7 +1075,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
957
1075
  Transparent.evalScript($("body")[0]);
958
1076
  }
959
1077
 
960
- Transparent.scrollTo({top:0, left:0, duration:0});
961
1078
  Transparent.activeOut();
962
1079
  }
963
1080
 
@@ -1096,8 +1213,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1096
1213
  image.onload = function() {
1097
1214
  this.classList.add("loaded");
1098
1215
  this.classList.remove("loading");
1216
+ this.classList.remove("error");
1099
1217
  if(lazybox) lazybox.classList.add("loaded");
1100
1218
  if(lazybox) lazybox.classList.remove("loading");
1219
+ if(lazybox) lazybox.classList.remove("error");
1220
+ };
1221
+
1222
+ // Error handler for broken / missing images (404, ACL,
1223
+ // DNS failure, malformed URL). Without this, lazy-loaded
1224
+ // images that fail just stay invisible. The .error
1225
+ // class lets project CSS render a placeholder.
1226
+ image.onerror = function() {
1227
+ this.classList.add("error");
1228
+ this.classList.remove("loading");
1229
+ this.classList.remove("loaded");
1230
+ if(lazybox) lazybox.classList.add("error");
1231
+ if(lazybox) lazybox.classList.remove("loading");
1232
+ if(lazybox) lazybox.classList.remove("loaded");
1101
1233
  };
1102
1234
 
1103
1235
  if(lazybox) lazybox.classList.add("loading");
@@ -1226,11 +1358,57 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1226
1358
  });
1227
1359
 
1228
1360
  activeInRemainingTime = activeInRemainingTime - (Date.now() - activeInTime);
1229
- setTimeout(function() {
1361
+
1362
+ // Whole-swap body extracted so we can dispatch it either through
1363
+ // document.startViewTransition() for browsers that support it (with
1364
+ // Settings.use_view_transitions on), or directly for the legacy path.
1365
+ // Identical behavior in both branches — VT just wraps it so the
1366
+ // browser captures OLD/NEW snapshots and crossfades natively.
1367
+ var _doSwapBody = function() {
1230
1368
 
1231
1369
  // Transfert attributes
1232
1370
  Transparent.transferAttributes(dom);
1233
1371
 
1372
+ // ── Track-reload check ──────────────────────────────────────
1373
+ // Mirrors Turbo's <... data-turbo-track="reload"> mechanism.
1374
+ // Put `data-track="reload"` on critical <script> / <link>
1375
+ // bundles in <head>. On nav, if the set of tracked URLs
1376
+ // differs between current and new HTML, force a full reload
1377
+ // instead of an SPA swap — because the user's loaded JS/CSS
1378
+ // no longer matches what the server is serving (e.g. after
1379
+ // a deploy that bumped asset hashes). The browser then
1380
+ // re-downloads everything cleanly.
1381
+ //
1382
+ // Match key: tagName + src/href (or textContent for inline).
1383
+ // Same logic as Turbo: the SET of tracked URLs must match.
1384
+ (function checkTrackedReload() {
1385
+ function trackedSrcs(root) {
1386
+ var srcs = [];
1387
+ var els = root.querySelectorAll('[data-track="reload"]');
1388
+ for (var i = 0; i < els.length; i++) {
1389
+ var el = els[i];
1390
+ var src = el.getAttribute('src') || el.getAttribute('href') ||
1391
+ (el.textContent || '').slice(0, 200);
1392
+ if (src) srcs.push(el.tagName + ':' + src);
1393
+ }
1394
+ return srcs.sort();
1395
+ }
1396
+ var currentSrcs = trackedSrcs(document.head);
1397
+ if (!currentSrcs.length) return; // nothing tracked → skip
1398
+ var newDoc = dom.documentElement ? dom : (dom[0] || dom);
1399
+ var newHead = newDoc.head || newDoc.querySelector('head');
1400
+ if (!newHead) return;
1401
+ var newSrcs = trackedSrcs(newHead);
1402
+ // Compare as JSON of sorted arrays — order-independent.
1403
+ if (JSON.stringify(currentSrcs) === JSON.stringify(newSrcs)) return;
1404
+ if (Settings.debug) {
1405
+ console.log('Transparent track-reload: asset mismatch, forcing reload',
1406
+ { current: currentSrcs, new: newSrcs });
1407
+ }
1408
+ // Full reload to the requested URL.
1409
+ window.location.href = window.location.toString();
1410
+ })();
1411
+
1234
1412
  // Replace head..
1235
1413
  var head = $(dom).find("head");
1236
1414
  $("head").children().each(function() {
@@ -1244,6 +1422,14 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1244
1422
  return !found;
1245
1423
  });
1246
1424
 
1425
+ // Preserve headlocked nodes: anything injected dynamically
1426
+ // after initial load (auto), URL-pattern matches in
1427
+ // Settings.headlock, or explicit data-headlock="true".
1428
+ // This is the win over the previous "never remove SCRIPT/
1429
+ // STYLE" rule — per-page server-rendered <style> blocks
1430
+ // (e.g. layout1 inline CSS) still get swapped, only
1431
+ // third-party / dynamically-injected ones are locked.
1432
+ if(!found && Transparent.isHeadlocked(el)) found = true;
1247
1433
  if(!found) this.remove();
1248
1434
  });
1249
1435
 
@@ -1258,11 +1444,17 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1258
1444
 
1259
1445
  if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
1260
1446
 
1261
- $("head").append(this.cloneNode(true));
1447
+ var clone = this.cloneNode(true);
1448
+ $("head").append(clone);
1449
+ // Register the new node as "original" so it falls
1450
+ // through to URL-pattern matching on the next swap
1451
+ // (and isn't auto-locked as third-party content).
1452
+ originalHeadNodes.add(clone);
1262
1453
 
1263
1454
  } else {
1264
1455
 
1265
1456
  $("head").append(this);
1457
+ originalHeadNodes.add(this);
1266
1458
  }
1267
1459
  }
1268
1460
  });
@@ -1357,7 +1549,28 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1357
1549
  });
1358
1550
  });
1359
1551
 
1360
- }.bind(this), activeInRemainingTime > 0 ? activeInRemainingTime : 1);
1552
+ }.bind(this);
1553
+
1554
+ // Dispatch the swap. With VT enabled AND supported, wrap in
1555
+ // document.startViewTransition() so the browser captures OLD/NEW
1556
+ // snapshots and crossfades natively. The setTimeout wait is for
1557
+ // the CSS fade-out to complete BEFORE VT begins, so VT captures
1558
+ // the already-faded state cleanly. Errors inside the callback
1559
+ // don't abort the swap — fall back to direct execution.
1560
+ var _vtEnabled = Settings["use_view_transitions"]
1561
+ && typeof document.startViewTransition === "function";
1562
+ setTimeout(function() {
1563
+ if (_vtEnabled) {
1564
+ try {
1565
+ document.startViewTransition(_doSwapBody);
1566
+ } catch (e) {
1567
+ if (Settings.debug) console.warn("Transparent VT failed, falling back:", e);
1568
+ _doSwapBody();
1569
+ }
1570
+ } else {
1571
+ _doSwapBody();
1572
+ }
1573
+ }, activeInRemainingTime > 0 ? activeInRemainingTime : 1);
1361
1574
  }
1362
1575
 
1363
1576
  function uuidv4() {
@@ -1468,6 +1681,316 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1468
1681
 
1469
1682
  var ajaxSemaphore = false;
1470
1683
  var formSubmission = false;
1684
+
1685
+ // ── User-typed form dirty tracking ──────────────────────────────────────
1686
+ //
1687
+ // True if the user has modified any form input via a real keystroke /
1688
+ // click / select. False after a fresh navigation OR after a form submit.
1689
+ // Used by onbeforeunload to decide whether to confirm.
1690
+ //
1691
+ // `e.isTrusted` filters out programmatic value mutations from JS init
1692
+ // code (Select2 setting the hidden field after dropdown selection,
1693
+ // Editor.js syncing JSON to a hidden <textarea>, datepicker init writing
1694
+ // a normalized value, etc.). Without this filter, the previous value-
1695
+ // comparison logic (formDataBefore vs formDataAfter) flagged every
1696
+ // page-with-form as "dirty" by the time the user hit Ctrl+W, even
1697
+ // when they hadn't typed anything.
1698
+ var formDirty = false;
1699
+ document.addEventListener('input', function(e) {
1700
+ if (!e.isTrusted) return;
1701
+ if (!e.target || !e.target.form) return;
1702
+ formDirty = true;
1703
+ }, true);
1704
+ document.addEventListener('change', function(e) {
1705
+ if (!e.isTrusted) return;
1706
+ if (!e.target || !e.target.form) return;
1707
+ formDirty = true;
1708
+ }, true);
1709
+ // Reset on user-initiated submit so the post-submit redirect doesn't
1710
+ // double-prompt. The existing `formSubmission` flag already handles
1711
+ // the synchronous prompt path; this clears state for any follow-up.
1712
+ document.addEventListener('submit', function() { formDirty = false; }, true);
1713
+
1714
+ // ── Transparent.formMemory ──────────────────────────────────────────────
1715
+ //
1716
+ // Persistent draft store for forms — survives accidental close, refresh,
1717
+ // power loss, browser crash. Keyed by URL + form identity (name or id).
1718
+ //
1719
+ // Save triggers:
1720
+ // - debounced (500ms) on user `input`/`change` events
1721
+ // - synchronously on beforeunload (last-resort capture for fields
1722
+ // mutated by JS like Editor.js / Select2 that don't fire trusted
1723
+ // `input` events)
1724
+ //
1725
+ // Restore: silently on initial DOMContentLoaded AND after each SPA swap
1726
+ // (transparent.js re-dispatches DOMContentLoaded post-swap, line ~1375).
1727
+ // Restored fields get `data-restored-from-draft=""` for optional
1728
+ // project-level toast / styling.
1729
+ //
1730
+ // Clear: on form submit + on TTL expiry (7 days) + manually via
1731
+ // `Transparent.formMemory.clear(form)`.
1732
+ //
1733
+ // Opt-out:
1734
+ // - `<form data-no-persist>` — entire form skipped
1735
+ // - `<input data-no-persist>` — single field skipped
1736
+ // - Auto-skipped: type="password", type="file", type="submit"/button,
1737
+ // and any hidden field whose name contains `_token` or `csrf`
1738
+ // (Symfony CSRF token field). These are never persisted.
1739
+ //
1740
+ // Editor.js compatibility: Editor.js reads its initial JSON from
1741
+ // `data-edjs` attribute (not `.value`). On restore, if the field is a
1742
+ // <textarea data-edjs>, we mirror the restored value into `data-edjs`
1743
+ // too so Editor.js renders the draft when its init pass runs.
1744
+ Transparent.formMemory = (function() {
1745
+ var KEY_PREFIX = 'tx-form-memory:';
1746
+ var DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
1747
+ var DEBOUNCE = 500;
1748
+
1749
+ var saveTimers = new WeakMap();
1750
+ var api = {
1751
+ enabled : true,
1752
+ ttl : DEFAULT_TTL,
1753
+ debounce: DEBOUNCE,
1754
+ };
1755
+
1756
+ function shouldSkipField(field) {
1757
+ if (!field.name) return true;
1758
+ var type = (field.type || '').toLowerCase();
1759
+ if (type === 'password' || type === 'file') return true;
1760
+ if (type === 'submit' || type === 'button' || type === 'reset') return true;
1761
+ if (field.hasAttribute && field.hasAttribute('data-no-persist')) return true;
1762
+ // CSRF tokens — never persist. Matches Symfony's `_token` and
1763
+ // any other token-named hidden input (`csrf`, `_csrf_token`, ...).
1764
+ if (type === 'hidden') {
1765
+ var n = field.name.toLowerCase();
1766
+ if (n.indexOf('_token') !== -1 || n.indexOf('csrf') !== -1) return true;
1767
+ }
1768
+ return false;
1769
+ }
1770
+
1771
+ function shouldSkipForm(form) {
1772
+ if (!form) return true;
1773
+ if (form.hasAttribute && form.hasAttribute('data-no-persist')) return true;
1774
+ if (!form.name && !form.id) return true; // need identity for key
1775
+ return false;
1776
+ }
1777
+
1778
+ function getKey(form) {
1779
+ return KEY_PREFIX + location.pathname + ':' + (form.name || form.id);
1780
+ }
1781
+
1782
+ function readLS(key) {
1783
+ try { return localStorage.getItem(key); } catch (e) { return null; }
1784
+ }
1785
+ function writeLS(key, val) {
1786
+ try { localStorage.setItem(key, val); } catch (e) {
1787
+ // Quota exceeded or storage disabled — fail silent. The
1788
+ // user keeps their work in memory, just loses persistence.
1789
+ }
1790
+ }
1791
+ function removeLS(key) {
1792
+ try { localStorage.removeItem(key); } catch (e) {}
1793
+ }
1794
+
1795
+ api.save = function(form) {
1796
+ if (!api.enabled) return;
1797
+ if (shouldSkipForm(form)) return;
1798
+
1799
+ var data = {};
1800
+ var elements = form.elements;
1801
+ for (var i = 0; i < elements.length; i++) {
1802
+ var field = elements[i];
1803
+ if (shouldSkipField(field)) continue;
1804
+
1805
+ var type = (field.type || '').toLowerCase();
1806
+ if (type === 'checkbox' || type === 'radio') {
1807
+ if (!field.checked) continue;
1808
+ if (data[field.name] === undefined) {
1809
+ data[field.name] = field.value;
1810
+ } else if (Array.isArray(data[field.name])) {
1811
+ data[field.name].push(field.value);
1812
+ } else {
1813
+ data[field.name] = [data[field.name], field.value];
1814
+ }
1815
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1816
+ var sel = [];
1817
+ for (var j = 0; j < field.options.length; j++) {
1818
+ if (field.options[j].selected) sel.push(field.options[j].value);
1819
+ }
1820
+ data[field.name] = sel;
1821
+ } else {
1822
+ data[field.name] = field.value;
1823
+ }
1824
+ }
1825
+
1826
+ if (Object.keys(data).length === 0) {
1827
+ // No persistable fields with data — clear any stale entry
1828
+ // rather than write an empty record (avoids restoring "all
1829
+ // empty" later and overwriting newly-pre-filled fields).
1830
+ removeLS(getKey(form));
1831
+ return;
1832
+ }
1833
+
1834
+ writeLS(getKey(form), JSON.stringify({ t: Date.now(), d: data }));
1835
+ };
1836
+
1837
+ api.restore = function(form) {
1838
+ if (!api.enabled) return;
1839
+ if (shouldSkipForm(form)) return;
1840
+
1841
+ var key = getKey(form);
1842
+ var raw = readLS(key);
1843
+ if (!raw) return;
1844
+
1845
+ var entry;
1846
+ try { entry = JSON.parse(raw); }
1847
+ catch (e) { removeLS(key); return; }
1848
+
1849
+ if (!entry || !entry.t || !entry.d) { removeLS(key); return; }
1850
+ if (Date.now() - entry.t > api.ttl) { removeLS(key); return; }
1851
+
1852
+ Object.keys(entry.d).forEach(function(name) {
1853
+ var value = entry.d[name];
1854
+ // Use attr-selector with CSS.escape to handle names like
1855
+ // `Article[content]` that contain brackets.
1856
+ var sel = '[name="' + (typeof CSS !== 'undefined' && CSS.escape
1857
+ ? CSS.escape(name)
1858
+ : name.replace(/(["\\\[\]])/g, '\\$1')) + '"]';
1859
+ var fields = form.querySelectorAll(sel);
1860
+ if (fields.length === 0) return;
1861
+
1862
+ for (var k = 0; k < fields.length; k++) {
1863
+ var field = fields[k];
1864
+ if (shouldSkipField(field)) continue;
1865
+
1866
+ var type = (field.type || '').toLowerCase();
1867
+ if (type === 'checkbox' || type === 'radio') {
1868
+ field.checked = Array.isArray(value)
1869
+ ? (value.indexOf(field.value) !== -1)
1870
+ : (field.value === value);
1871
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1872
+ if (Array.isArray(value)) {
1873
+ for (var m = 0; m < field.options.length; m++) {
1874
+ field.options[m].selected = (value.indexOf(field.options[m].value) !== -1);
1875
+ }
1876
+ }
1877
+ } else {
1878
+ field.value = value;
1879
+ // Editor.js mirrors: Editor.js reads its initial JSON
1880
+ // from the `data-edjs` attribute, not from .value. So
1881
+ // we have to mirror the restored value into the attr
1882
+ // for the upcoming Editor.js init pass to pick it up.
1883
+ if (field.tagName === 'TEXTAREA' && field.hasAttribute('data-edjs')) {
1884
+ field.setAttribute('data-edjs', value);
1885
+ }
1886
+ }
1887
+ field.setAttribute('data-restored-from-draft', '');
1888
+ }
1889
+ });
1890
+ };
1891
+
1892
+ api.clear = function(form) {
1893
+ if (!form) return;
1894
+ if (shouldSkipForm(form)) return;
1895
+ removeLS(getKey(form));
1896
+ };
1897
+
1898
+ api.restoreAll = function() {
1899
+ var forms = document.querySelectorAll('form');
1900
+ for (var i = 0; i < forms.length; i++) api.restore(forms[i]);
1901
+ };
1902
+
1903
+ api.saveAll = function() {
1904
+ var forms = document.querySelectorAll('form');
1905
+ for (var i = 0; i < forms.length; i++) api.save(forms[i]);
1906
+ };
1907
+
1908
+ api.clearExpired = function() {
1909
+ var ttl = api.ttl;
1910
+ var now = Date.now();
1911
+ try {
1912
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1913
+ var key = localStorage.key(i);
1914
+ if (!key || key.indexOf(KEY_PREFIX) !== 0) continue;
1915
+ var raw;
1916
+ try { raw = localStorage.getItem(key); }
1917
+ catch (e) { continue; }
1918
+ var entry = null;
1919
+ try { entry = JSON.parse(raw); } catch (e) {}
1920
+ if (!entry || !entry.t || (now - entry.t > ttl)) {
1921
+ removeLS(key);
1922
+ }
1923
+ }
1924
+ } catch (e) {}
1925
+ };
1926
+
1927
+ // Debounced per-form save scheduler. WeakMap → no leak when forms
1928
+ // get removed from the DOM (e.g., on SPA swap).
1929
+ function debouncedSave(form) {
1930
+ if (!form || shouldSkipForm(form)) return;
1931
+ var existing = saveTimers.get(form);
1932
+ if (existing) clearTimeout(existing);
1933
+ saveTimers.set(form, setTimeout(function() {
1934
+ api.save(form);
1935
+ }, api.debounce));
1936
+ }
1937
+
1938
+ // Event wiring. Capture phase + isTrusted gate matches the
1939
+ // formDirty pattern above — programmatic field mutations from
1940
+ // Select2/Editor.js init code don't trigger a save here, which is
1941
+ // correct (those are not "user changes"; saving them would persist
1942
+ // server-rendered defaults as if they were user input).
1943
+ document.addEventListener('input', function(e) {
1944
+ if (!e.isTrusted) return;
1945
+ if (!e.target || !e.target.form) return;
1946
+ debouncedSave(e.target.form);
1947
+ }, true);
1948
+ document.addEventListener('change', function(e) {
1949
+ if (!e.isTrusted) return;
1950
+ if (!e.target || !e.target.form) return;
1951
+ debouncedSave(e.target.form);
1952
+ }, true);
1953
+
1954
+ // Successful submit clears the draft. We listen in capture so we
1955
+ // run before any user-side submit handler that might cancel.
1956
+ document.addEventListener('submit', function(e) {
1957
+ if (e.target && e.target.tagName === 'FORM') {
1958
+ api.clear(e.target);
1959
+ }
1960
+ }, true);
1961
+
1962
+ // Last-resort save on page exit. Catches state mutated only by JS
1963
+ // (Editor.js content syncs, Select2 hidden-field writes) that
1964
+ // never fired trusted input/change events. Synchronous because
1965
+ // beforeunload doesn't await microtasks.
1966
+ window.addEventListener('beforeunload', function() {
1967
+ if (!api.enabled) return;
1968
+ // Only save if the user has actually typed something — same
1969
+ // gate as the unload-confirm prompt above. If formDirty is
1970
+ // false, drafts written from the debounced handler are stale
1971
+ // and we don't want to refresh them on every page-close.
1972
+ if (formDirty) api.saveAll();
1973
+ });
1974
+
1975
+ // Initial restore. If we're already past DOMContentLoaded (defer
1976
+ // scripts run after parsing but before DCL), run synchronously.
1977
+ // For SPA navs, transparent.js re-dispatches DOMContentLoaded in
1978
+ // _doSwap (around line 1375), so this same listener fires again
1979
+ // on every swap — no extra wiring needed.
1980
+ if (document.readyState === 'loading') {
1981
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
1982
+ } else {
1983
+ api.restoreAll();
1984
+ }
1985
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
1986
+
1987
+ // Cleanup expired entries — once at startup, deferred so it
1988
+ // doesn't block first paint.
1989
+ setTimeout(api.clearExpired, 2000);
1990
+
1991
+ return api;
1992
+ })();
1993
+
1471
1994
  function __main__(e) {
1472
1995
 
1473
1996
  // Disable transparent JS (e.g. during development..)
@@ -1791,99 +2314,33 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1791
2314
  window.onpopstate = __main__; // Onpopstate pop out straight to previous page.. this creates a jump while changing pages with hash..
1792
2315
  window.onhashchange = __main__;
1793
2316
 
1794
- var formDataBefore = {};
1795
- $(window).on("load", function() {
1796
-
1797
- formDataBefore = {};
1798
- $("form").each(function() {
1799
-
1800
- var formData = new FormData();
1801
- var formInput = $("[name^='"+this.name+"\[']");
1802
- formInput.each(function() {
1803
-
1804
- if(this.type == "file") {
1805
-
1806
- for(var i = 0; i < this.files.length; i++)
1807
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
1808
-
1809
- } else formData.append(this.name, this.value);
1810
- });
1811
-
1812
- for (var [fieldName,fieldValue] of formData.entries()) {
1813
-
1814
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
1815
- formDataBefore[fieldName] = fieldValue;
1816
- }
1817
- });
1818
- });
1819
-
2317
+ // onbeforeunload: confirm only if the user has actually typed/edited
2318
+ // a form input on this page. Pre-Turbo this used a snapshot-and-
2319
+ // compare approach (formDataBefore vs formDataAfter) which gave
2320
+ // false positives because JS init code mutates form values after
2321
+ // load — Select2 writes selected text to hidden fields, Editor.js
2322
+ // serializes its JSON to a <textarea>, datepicker normalizes
2323
+ // formats, etc. so by the time the user hit Ctrl+W the snapshot
2324
+ // and the current state always differed, and the browser always
2325
+ // prompted even on read-only pages.
2326
+ //
2327
+ // The replacement is the `formDirty` flag declared above, which is
2328
+ // set only by trusted (user-originated) `input`/`change` events.
2329
+ // No prompt unless the user genuinely typed something.
1820
2330
  window.onbeforeunload = function(e) {
1821
2331
 
1822
2332
  if(Settings.debug) console.log("Transparent onbeforeunload event called..");
1823
2333
 
1824
2334
  if(formSubmission) return; // Do not display on form submission
1825
2335
  if(Settings.disable) return;
1826
-
1827
2336
  if(e.currentTarget == window) return;
2337
+ if(!formDirty) return; // ← user hasn't modified anything; no prompt
1828
2338
 
1829
- var preventDefault = false;
1830
- var formDataAfter = [];
1831
- $("form").each(function() {
1832
-
1833
- var formData = new FormData();
1834
- var formInput = $("[name^='"+this.name+"\[']");
1835
- formInput.each(function() {
1836
-
1837
- if(this.type == "file") {
1838
-
1839
- for(var i = 0; i < this.files.length; i++)
1840
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
1841
-
1842
- } else formData.append(this.name, this.value);
1843
- });
1844
-
1845
- for (var [fieldName,fieldValue] of formData.entries()) {
1846
-
1847
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
1848
- formDataAfter[fieldName] = fieldValue;
1849
- }
1850
- });
1851
-
1852
- var formDataBeforeKeys = Object.keys(formDataBefore);
1853
- var formDataAfterKeys = Object.keys(formDataAfter);
1854
- function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
1855
- function sameKeys(a, b) {
1856
-
1857
- var aKeys = Object.keys(a).sort();
1858
- var bKeys = Object.keys(b).sort();
1859
- return JSON.stringify(aKeys) === JSON.stringify(bKeys);
1860
- }
1861
-
1862
- if(!sameKeys(formDataBeforeKeys, formDataAfterKeys)) preventDefault = true;
1863
- else {
1864
-
1865
- for (var [fieldName,fieldValueAfter] of Object.entries(formDataAfter)) {
1866
-
1867
- var fieldValueBefore = formDataBefore[fieldName];
1868
- if(fieldValueBefore instanceof File) {
1869
-
1870
- if(!fieldValueAfter instanceof File) preventDefault = true;
1871
- else if (fieldValueBefore.size != fieldValueAfter.size) preventDefault = true;
2339
+ Transparent.html.addClass(Transparent.state.READY);
2340
+ Transparent.activeOut();
2341
+ dispatchEvent(new Event('load'));
1872
2342
 
1873
- } else if(fieldValueBefore != fieldValueAfter) {
1874
- preventDefault = true;
1875
- }
1876
- }
1877
- }
1878
-
1879
- if(Settings.debug || preventDefault) {
1880
-
1881
- if(preventDefault) Transparent.html.addClass(Transparent.state.READY);
1882
- if(preventDefault) Transparent.activeOut();
1883
- if(preventDefault) dispatchEvent(new Event('load'));
1884
-
1885
- return "Dude, are you sure you want to leave? Think of the kittens!";
1886
- }
2343
+ return "Dude, are you sure you want to leave? Think of the kittens!";
1887
2344
  }
1888
2345
 
1889
2346
  document.addEventListener('click', __main__, false);