@glitchr/transparent 1.0.82 → 1.1.1

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/js/transparent.js +737 -478
@@ -180,77 +180,44 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
180
180
  "smoothscroll_speed" : 0,
181
181
  "smoothscroll_easing" : "swing",
182
182
  "exceptions": [],
183
- // headlock: list of url substrings/regex to preserve in <head> across page transitions
184
- // (e.g. third-party widgets that inject <style>/<link> dynamically).
185
- // In addition, head nodes injected dynamically AFTER initial DOMContentLoaded are
186
- // preserved automatically. Use data-headlock="false" on a head element to opt-out.
187
- "headlock": []
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
188
219
  };
189
220
 
190
- // Set of <head> children present on initial load. Anything added after is treated
191
- // as dynamically injected and preserved across transitions.
192
- var originalHeadNodes = new WeakSet();
193
- function snapshotHeadNodes() {
194
- var head = document.head;
195
- if(!head) return;
196
- for(var i = 0; i < head.children.length; i++)
197
- originalHeadNodes.add(head.children[i]);
198
- }
199
- // Snapshot synchronously at module-eval time (scripts at end of <body> run before any
200
- // async script can inject <style> tags, so the snapshot is clean).
201
- // A DOMContentLoaded fallback is kept for the rare case where document.head is null
202
- // (e.g. script loaded inside <head> before it finishes parsing).
203
- snapshotHeadNodes();
204
- if(!document.head)
205
- document.addEventListener("DOMContentLoaded", snapshotHeadNodes, { once: true });
206
-
207
- Transparent.isHeadlocked = function(el) {
208
- if(!el || el.nodeType !== 1) return false;
209
- // Explicit opt-out
210
- var attr = el.getAttribute && el.getAttribute("data-headlock");
211
- if(attr === "false") return false;
212
- // Explicit opt-in via attribute
213
- if(attr !== null && attr !== undefined) return true;
214
- // Dynamically injected after initial load
215
- if(!originalHeadNodes.has(el)) return true;
216
- // URL pattern match (src/href attributes)
217
- var patterns = Settings["headlock"] || [];
218
- if(!patterns.length) return false;
219
- var url = el.getAttribute && (el.getAttribute("src") || el.getAttribute("href"));
220
- // <style> elements have no src/href — match against CSS textContent instead
221
- if(!url && el.tagName === 'STYLE') url = el.textContent || '';
222
- if(!url) return false;
223
- for(var i = 0; i < patterns.length; i++) {
224
- var p = patterns[i];
225
- if(p instanceof RegExp) { if(p.test(url)) return true; }
226
- else if(typeof p === "string" && p.length && url.indexOf(p) !== -1) return true;
227
- }
228
- return false;
229
- }
230
-
231
- // ─── NAVIGATION TRACE LOG ───────────────────────────────────────
232
- // Gated on Settings.debug. Set 'debug': true in Transparent.ready({...})
233
- // to surface a per-step trace prefixed with "[TX]" in the console.
234
- // Cheap when disabled — single boolean check, no allocation.
235
- function _tx(tag, extra) {
236
- if (!Settings || !Settings.debug) return;
237
- try {
238
- var cls = document.documentElement.className;
239
- var t = performance.now().toFixed(1);
240
- var here = (document.querySelector("#page") || document.documentElement);
241
- var lay = here.getAttribute && (here.getAttribute("data-layout") || "?");
242
- var path = location.pathname;
243
- console.log("%c[TX]", "color:#0a0;font-weight:bold",
244
- "+" + t + "ms", tag,
245
- "path=" + path,
246
- "layout=" + lay,
247
- "ajaxSem=" + (typeof ajaxSemaphore === "undefined" ? "?" : ajaxSemaphore),
248
- "classes=[" + cls + "]",
249
- extra || "");
250
- } catch(e) {}
251
- }
252
- // ────────────────────────────────────────────────────────────────
253
-
254
221
  const State = Transparent.state = {
255
222
 
256
223
  ROOT : "transparent",
@@ -269,53 +236,17 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
269
236
  CLICK : "click",
270
237
 
271
238
  PREACTIVE : "pre-active",
272
- FADEIN : "fade-in",
239
+ ACTIVEIN : "active-in",
273
240
  ACTIVE : "active",
274
- FADEOUT : "fade-out",
241
+ ACTIVEOUT : "active-out",
275
242
  POSTACTIVE : "post-active",
276
243
 
277
- NOTIFICATION: "notification",
278
- OFFLINE : "offline"
244
+ NOTIFICATION: "notification"
279
245
  };
280
246
 
281
247
  var isReady = false;
282
248
  var rescueMode = false;
283
249
 
284
- // ─── OFFLINE DETECTION ──────────────────────────────────────────
285
- // Two-source signal:
286
- // 1. window 'online'/'offline' events fired by the browser when the
287
- // OS-level connectivity changes (Wi-Fi off, airplane mode, etc.)
288
- // 2. AJAX network errors during navigation — if a request fails with
289
- // status 0 (and wasn't aborted), the device probably can't reach
290
- // the server even though navigator.onLine may still be true.
291
- // The `html.offline` class is the public surface — the project's CSS
292
- // styles the YouTube-style "Offline" banner from there. Custom events
293
- // `transparent:offline` and `transparent:online` give JS hooks too.
294
- var isOnline = (typeof navigator !== "undefined") ? navigator.onLine !== false : true;
295
- Transparent.isOnline = function() { return isOnline; }
296
- function setOnlineStatus(online) {
297
- if (online === isOnline) return; // no change
298
- isOnline = online;
299
- if (online) {
300
- $($(document).find("html")[0]).removeClass(State.OFFLINE);
301
- dispatchEvent(new Event("transparent:online"));
302
- } else {
303
- $($(document).find("html")[0]).addClass(State.OFFLINE);
304
- dispatchEvent(new Event("transparent:offline"));
305
- }
306
- }
307
- if (typeof window !== "undefined") {
308
- window.addEventListener("online", function() { setOnlineStatus(true); });
309
- window.addEventListener("offline", function() { setOnlineStatus(false); });
310
- }
311
- // Apply initial state synchronously so first-paint reflects offline if applicable.
312
- if (!isOnline) {
313
- // Use the document element directly here — Transparent.html isn't initialized yet at module-eval time.
314
- var _htmlEl = document.documentElement;
315
- if (_htmlEl && _htmlEl.classList) _htmlEl.classList.add(State.OFFLINE);
316
- }
317
- // ────────────────────────────────────────────────────────────────
318
-
319
250
  Transparent.html = $($(document).find("html")[0]);
320
251
  Transparent.html.addClass(Transparent.state.ROOT+ " " + Transparent.state.LOADING + " " + Transparent.state.FIRST);
321
252
 
@@ -324,6 +255,91 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
324
255
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
325
256
  }
326
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
+
327
343
  window.addEventListener("DOMContentLoaded", function()
328
344
  {
329
345
  Transparent.loader = $($(document).find(Settings.loader)[0] ?? Transparent.html);
@@ -414,17 +430,84 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
414
430
  return this;
415
431
  }
416
432
 
433
+ // ── In-memory live-DOM cache ────────────────────────────────────────────
434
+ //
435
+ // Turbo-style: store cloned <html> Element nodes per uuid so popstate
436
+ // (back/forward) can short-circuit the XHR + sessionStorage round-trip
437
+ // + DOMParser.parseFromString cycle. Result: back/forward feels instant.
438
+ //
439
+ // Key vs sessionStorage flow:
440
+ // - sessionStorage: outerHTML serialize (slow) → string (5-10MB
441
+ // quota) → JSON read → DOMParser parse (slow). Whole cycle on
442
+ // every popstate. Used as the fallback when the live cache misses.
443
+ // - liveDomCache: cloneNode(true) of the rendered <html> element
444
+ // stored in a Map<uuid, {node, scroll, ts}>. Bounded with LRU.
445
+ // No serialization, no parsing — just the live DOM node ready
446
+ // for the swap to consume.
447
+ //
448
+ // setResponse populates BOTH (live cache + sessionStorage). The
449
+ // sessionStorage write is kept so tab reloads and cross-process
450
+ // restores keep working. getLiveResponse() is the new fast-path API
451
+ // used by handleResponse before the DOMParser fallback.
452
+ Transparent._liveDomCache = new Map();
453
+ Transparent.liveDomCacheMax = 25;
454
+ Transparent.liveDomCacheTTL = 5 * 60 * 1000; // 5 min
455
+
456
+ Transparent.getLiveResponse = function(uuid) {
457
+ var entry = Transparent._liveDomCache.get(uuid);
458
+ if (!entry) return null;
459
+ if (Date.now() - entry.ts > Transparent.liveDomCacheTTL) {
460
+ Transparent._liveDomCache.delete(uuid);
461
+ return null;
462
+ }
463
+ // LRU bump: re-insert to move to the back of the iteration order.
464
+ Transparent._liveDomCache.delete(uuid);
465
+ Transparent._liveDomCache.set(uuid, entry);
466
+ // Return a CLONE so the caller's swap mutations don't poison
467
+ // the cache for future popstate hits. The clone is detached
468
+ // from any document so it's safe to pass to the swap.
469
+ return entry.node.cloneNode(true);
470
+ };
471
+
472
+ Transparent.setLiveResponse = function(uuid, htmlEl, scrollableXY) {
473
+ if (!htmlEl || htmlEl.nodeType !== 1) return;
474
+ Transparent._liveDomCache.set(uuid, {
475
+ node: htmlEl.cloneNode(true),
476
+ scroll: scrollableXY || [],
477
+ ts: Date.now()
478
+ });
479
+ // LRU evict — Map iteration order is insertion order.
480
+ while (Transparent._liveDomCache.size > Transparent.liveDomCacheMax) {
481
+ var firstKey = Transparent._liveDomCache.keys().next().value;
482
+ Transparent._liveDomCache.delete(firstKey);
483
+ }
484
+ };
485
+
486
+ Transparent.clearLiveResponse = function() {
487
+ Transparent._liveDomCache.clear();
488
+ };
489
+
417
490
  Transparent.setResponse = function(uuid, responseText, scrollableXY, exceptionRaised = false)
418
491
  {
419
- if(isDomEntity(responseText))
492
+ // Populate live-DOM cache FIRST while we still have the node.
493
+ // The outerHTML conversion below loses the node identity.
494
+ if (isDomEntity(responseText)) {
495
+ Transparent.setLiveResponse(uuid, responseText, scrollableXY);
420
496
  responseText = responseText.outerHTML;
497
+ }
421
498
 
422
499
  var array = JSON.parse(sessionStorage.getItem('transparent')) || [];
423
500
  if (!array.includes(uuid)) {
424
501
 
425
502
  array.push(uuid);
503
+ // Enforce the LRU cap. NB: entries are stored under
504
+ // `transparent[response][<uuid>]` / `transparent[position][<uuid>]`,
505
+ // so eviction must remove THOSE keys — the previous code removed
506
+ // `transparent[<uuid>]`, which never existed, leaving the real
507
+ // response/position blobs orphaned. They then accumulated past the
508
+ // cap until QuotaExceededError forced a full sessionStorage.clear().
426
509
  while(array.length > Settings["response_limit"])
427
- sessionStorage.removeItem('transparent['+array.shift()+']');
510
+ removeResponseEntry(array.shift());
428
511
  }
429
512
 
430
513
  try {
@@ -438,15 +521,42 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
438
521
 
439
522
  } catch(e) {
440
523
 
524
+ // On quota, evict the oldest cached pages (targeted) and retry
525
+ // once, instead of nuking ALL sessionStorage — sessionStorage.clear()
526
+ // also wipes unrelated app state and the entire page cache.
527
+ if (e.name === 'QuotaExceededError' && exceptionRaised === false) {
528
+ evictOldestResponses(Math.max(1, Math.ceil(array.length / 2)));
529
+ return Transparent.setResponse(uuid, responseText, scrollableXY, true);
530
+ }
531
+ // Last resort if a single page is itself too big to ever fit.
441
532
  if (e.name === 'QuotaExceededError')
442
533
  sessionStorage.clear();
443
534
 
444
- return exceptionRaised === false ? Transparent.setResponse(uuid, responseText, scrollableXY, true) : this;
535
+ return this;
445
536
  }
446
537
 
447
538
  return this;
448
539
  }
449
540
 
541
+ // Remove both blobs for a cached page uuid (response HTML + scroll position).
542
+ function removeResponseEntry(uuid) {
543
+ try {
544
+ sessionStorage.removeItem('transparent[response]['+uuid+']');
545
+ sessionStorage.removeItem('transparent[position]['+uuid+']');
546
+ } catch (e) {}
547
+ }
548
+
549
+ // Drop the N oldest cached pages and rewrite the index. Used to recover
550
+ // from a QuotaExceededError without discarding the whole cache.
551
+ function evictOldestResponses(count) {
552
+ try {
553
+ var array = JSON.parse(sessionStorage.getItem('transparent')) || [];
554
+ for (var i = 0; i < count && array.length; i++)
555
+ removeResponseEntry(array.shift());
556
+ sessionStorage.setItem('transparent', JSON.stringify(array));
557
+ } catch (e) {}
558
+ }
559
+
450
560
  Transparent.setResponseText = function(uuid, responseText, exceptionRaised = false)
451
561
  {
452
562
  if(isDomEntity(responseText))
@@ -554,7 +664,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
554
664
 
555
665
  if($(Transparent.html).hasClass(Transparent.state.FIRST)) {
556
666
  Transparent.scrollToHash(location.hash, {}, function() {
557
- Transparent.fadeOut(() => Transparent.html.removeClass(Transparent.state.FIRST));
667
+ Transparent.activeOut(() => Transparent.html.removeClass(Transparent.state.FIRST));
558
668
  });
559
669
  }
560
670
 
@@ -895,37 +1005,28 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
895
1005
 
896
1006
  return {delay:delay, duration:duration};
897
1007
  }
898
- var fadeInTime = 0;
899
- var fadeInRemainingTime = 0;
900
- // Schedule fn for after the currently in-flight fadeIn animation has
901
- // finished. Used by handleResponse's three reload/redirect paths to
902
- // ensure the loader is at full opacity before the browser unloads.
903
- function _waitForFadeIn(fn) {
904
- var elapsed = Date.now() - fadeInTime;
905
- var remaining = fadeInRemainingTime - elapsed;
906
- if (remaining > 0) setTimeout(fn, remaining + 30);
907
- else fn();
908
- }
909
- Transparent.fadeIn = function(activeCallback = function() {}) {
910
- _tx("fadeIn ENTRY");
1008
+ var activeInTime = 0;
1009
+ var activeInRemainingTime = 0;
1010
+ Transparent.activeIn = function(activeCallback = function() {}) {
1011
+
911
1012
  if(!Transparent.html.hasClass(Transparent.state.PREACTIVE)) {
912
1013
  Transparent.html.addClass(Transparent.state.PREACTIVE);
913
1014
  dispatchEvent(new Event('transparent:'+Transparent.state.PREACTIVE));
914
1015
  }
915
1016
 
916
1017
  var active = Transparent.activeTime();
917
- fadeInTime = Date.now();
918
- fadeInRemainingTime = active.delay+active.duration;
1018
+ activeInTime = Date.now();
1019
+ activeInRemainingTime = active.delay+active.duration;
919
1020
 
920
1021
  Transparent.html.removeClass(Transparent.state.PREACTIVE);
921
- if(!Transparent.html.hasClass(Transparent.state.FADEIN)) {
922
- Transparent.html.addClass(Transparent.state.FADEIN);
923
- dispatchEvent(new Event('transparent:'+Transparent.state.FADEIN));
1022
+ if(!Transparent.html.hasClass(Transparent.state.ACTIVEIN)) {
1023
+ Transparent.html.addClass(Transparent.state.ACTIVEIN);
1024
+ dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVEIN));
924
1025
  }
925
1026
 
926
1027
  Transparent.callback(function() {
927
1028
 
928
- Transparent.html.removeClass(Transparent.state.FADEIN);
1029
+ Transparent.html.removeClass(Transparent.state.ACTIVEIN);
929
1030
  if(!Transparent.html.hasClass(Transparent.state.ACTIVE)) {
930
1031
  Transparent.html.addClass(Transparent.state.ACTIVE);
931
1032
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
@@ -935,23 +1036,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
935
1036
  Transparent.callback(function() {
936
1037
 
937
1038
  activeCallback();
938
- fadeInRemainingTime = 0;
1039
+ activeInRemainingTime = 0;
939
1040
 
940
1041
  }.bind(this), active.duration);
941
1042
 
942
1043
  }.bind(this), active.delay);
943
1044
  }
944
1045
 
945
- Transparent.fadeOut = function(activeCallback = function() {}) {
946
- _tx("fadeOut ENTRY");
1046
+ Transparent.activeOut = function(activeCallback = function() {}) {
1047
+
947
1048
  if(!Transparent.html.hasClass(Transparent.state.ACTIVE)) {
948
1049
  Transparent.html.addClass(Transparent.state.ACTIVE);
949
1050
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
950
1051
  }
951
1052
 
952
- if(!Transparent.html.hasClass(Transparent.state.FADEOUT)) {
953
- Transparent.html.addClass(Transparent.state.FADEOUT);
954
- dispatchEvent(new Event('transparent:'+Transparent.state.FADEOUT));
1053
+ if(!Transparent.html.hasClass(Transparent.state.ACTIVEOUT)) {
1054
+ Transparent.html.addClass(Transparent.state.ACTIVEOUT);
1055
+ dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVEOUT));
955
1056
  }
956
1057
 
957
1058
  var active = Transparent.activeTime();
@@ -963,7 +1064,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
963
1064
  var active = Transparent.activeTime();
964
1065
  Transparent.callback(function() {
965
1066
 
966
- Transparent.html.removeClass(Transparent.state.FADEOUT);
1067
+ Transparent.html.removeClass(Transparent.state.ACTIVEOUT);
967
1068
  if(Transparent.html.hasClass(Transparent.state.LOADING)) {
968
1069
 
969
1070
  dispatchEvent(new Event('transparent:'+Transparent.state.LOADING));
@@ -1068,25 +1169,10 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1068
1169
  Transparent.evalScript($("body")[0]);
1069
1170
  }
1070
1171
 
1071
- Transparent.fadeOut();
1172
+ Transparent.activeOut();
1072
1173
  }
1073
1174
 
1074
- Transparent.userScroll = function(el = undefined) {
1075
- // Defensive: closestScrollable() can return a value without .prop
1076
- // when called from event handlers on transient DOM (e.g. ajaxer
1077
- // result containers, sticky-scrollpercent triggers fired during
1078
- // infinite-scroll while the page is transitioning). The app-defer.js
1079
- // wrapper around $.fn.closestScrollable is supposed to enforce the
1080
- // jQuery return, but races with timing-sensitive callers can still
1081
- // hit this. Default to true ("user is scrolling, don't autoscroll").
1082
- try {
1083
- var $target = $(el === undefined ? document.documentElement : el);
1084
- if (!$target || !$target.length) return true;
1085
- var $scroll = $target.closestScrollable && $target.closestScrollable();
1086
- if (!$scroll || typeof $scroll.prop !== "function") return true;
1087
- return $scroll.prop("user-scroll") ?? true;
1088
- } catch (e) { return true; }
1089
- }
1175
+ Transparent.userScroll = function(el = undefined) { return $(el === undefined ? document.documentElement : el).closestScrollable().prop("user-scroll") ?? true; }
1090
1176
  Transparent.scrollTo = function(dict, el = window, callback = function() {})
1091
1177
  {
1092
1178
  setTimeout(function() {
@@ -1221,8 +1307,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1221
1307
  image.onload = function() {
1222
1308
  this.classList.add("loaded");
1223
1309
  this.classList.remove("loading");
1310
+ this.classList.remove("error");
1224
1311
  if(lazybox) lazybox.classList.add("loaded");
1225
1312
  if(lazybox) lazybox.classList.remove("loading");
1313
+ if(lazybox) lazybox.classList.remove("error");
1314
+ };
1315
+
1316
+ // Error handler for broken / missing images (404, ACL,
1317
+ // DNS failure, malformed URL). Without this, lazy-loaded
1318
+ // images that fail just stay invisible. The .error
1319
+ // class lets project CSS render a placeholder.
1320
+ image.onerror = function() {
1321
+ this.classList.add("error");
1322
+ this.classList.remove("loading");
1323
+ this.classList.remove("loaded");
1324
+ if(lazybox) lazybox.classList.add("error");
1325
+ if(lazybox) lazybox.classList.remove("loading");
1326
+ if(lazybox) lazybox.classList.remove("loaded");
1226
1327
  };
1227
1328
 
1228
1329
  if(lazybox) lazybox.classList.add("loading");
@@ -1350,42 +1451,60 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1350
1451
  $(this).stop();
1351
1452
  });
1352
1453
 
1353
- // Defer the DOM swap until #page's opacity transition has had a
1354
- // chance to finish. Read the transition-duration from getComputedStyle
1355
- // (closure-local — no module state shared between navigations) so
1356
- // this stays in sync with whatever the project's CSS uses. Without
1357
- // this delay, a fast/cached AJAX response can land the swap while
1358
- // #page is still partway through the LOADING-induced fade-out,
1359
- // making the content change visible to the user (the original
1360
- // flicker).
1361
- var _swapDelay = 1;
1362
- try {
1363
- var _pageEl = $(Settings.identifier)[0];
1364
- if (_pageEl) {
1365
- var _dur = 1000 * Transparent.parseDuration(
1366
- window.getComputedStyle(_pageEl).transitionDuration || "0"
1367
- );
1368
- if (_dur > 1) _swapDelay = _dur;
1369
- }
1370
- } catch(e) {}
1454
+ activeInRemainingTime = activeInRemainingTime - (Date.now() - activeInTime);
1371
1455
 
1372
- setTimeout(function() {
1456
+ // Whole-swap body extracted so we can dispatch it either through
1457
+ // document.startViewTransition() for browsers that support it (with
1458
+ // Settings.use_view_transitions on), or directly for the legacy path.
1459
+ // Identical behavior in both branches — VT just wraps it so the
1460
+ // browser captures OLD/NEW snapshots and crossfades natively.
1461
+ var _doSwapBody = function() {
1373
1462
 
1374
- _tx("onLoad BODY (after " + _swapDelay + "ms)");
1375
1463
  // Transfert attributes
1376
1464
  Transparent.transferAttributes(dom);
1377
1465
 
1466
+ // ── Track-reload check ──────────────────────────────────────
1467
+ // Mirrors Turbo's <... data-turbo-track="reload"> mechanism.
1468
+ // Put `data-track="reload"` on critical <script> / <link>
1469
+ // bundles in <head>. On nav, if the set of tracked URLs
1470
+ // differs between current and new HTML, force a full reload
1471
+ // instead of an SPA swap — because the user's loaded JS/CSS
1472
+ // no longer matches what the server is serving (e.g. after
1473
+ // a deploy that bumped asset hashes). The browser then
1474
+ // re-downloads everything cleanly.
1475
+ //
1476
+ // Match key: tagName + src/href (or textContent for inline).
1477
+ // Same logic as Turbo: the SET of tracked URLs must match.
1478
+ (function checkTrackedReload() {
1479
+ function trackedSrcs(root) {
1480
+ var srcs = [];
1481
+ var els = root.querySelectorAll('[data-track="reload"]');
1482
+ for (var i = 0; i < els.length; i++) {
1483
+ var el = els[i];
1484
+ var src = el.getAttribute('src') || el.getAttribute('href') ||
1485
+ (el.textContent || '').slice(0, 200);
1486
+ if (src) srcs.push(el.tagName + ':' + src);
1487
+ }
1488
+ return srcs.sort();
1489
+ }
1490
+ var currentSrcs = trackedSrcs(document.head);
1491
+ if (!currentSrcs.length) return; // nothing tracked → skip
1492
+ var newDoc = dom.documentElement ? dom : (dom[0] || dom);
1493
+ var newHead = newDoc.head || newDoc.querySelector('head');
1494
+ if (!newHead) return;
1495
+ var newSrcs = trackedSrcs(newHead);
1496
+ // Compare as JSON of sorted arrays — order-independent.
1497
+ if (JSON.stringify(currentSrcs) === JSON.stringify(newSrcs)) return;
1498
+ if (Settings.debug) {
1499
+ console.log('Transparent track-reload: asset mismatch, forcing reload',
1500
+ { current: currentSrcs, new: newSrcs });
1501
+ }
1502
+ // Full reload to the requested URL.
1503
+ window.location.href = window.location.toString();
1504
+ })();
1505
+
1378
1506
  // Replace head..
1379
1507
  var head = $(dom).find("head");
1380
-
1381
- // Snapshot hrefs of already-loaded stylesheets so we can detect new ones
1382
- // added by the head merge and wait for them to finish loading before
1383
- // making #page visible (prevents FOUC on cold-cache layout transitions).
1384
- var _existingStyleHrefs = {};
1385
- $("head").children("link[rel='stylesheet']").each(function() {
1386
- var h = this.getAttribute("href"); if(h) _existingStyleHrefs[h] = true;
1387
- });
1388
-
1389
1508
  $("head").children().each(function() {
1390
1509
 
1391
1510
  var el = this;
@@ -1394,16 +1513,16 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1394
1513
  head.children().each(function() {
1395
1514
 
1396
1515
  found = this.isEqualNode(el);
1397
- // Also match identical <style> tags by content
1398
- if(!found && el.tagName === 'STYLE' && this.tagName === 'STYLE' &&
1399
- el.textContent && this.textContent &&
1400
- el.textContent.length > 100 && this.textContent.length === el.textContent.length) {
1401
- found = this.textContent === el.textContent;
1402
- }
1403
1516
  return !found;
1404
1517
  });
1405
1518
 
1406
- // Preserve headlocked nodes (dynamically injected widgets, url-matched, etc.)
1519
+ // Preserve headlocked nodes: anything injected dynamically
1520
+ // after initial load (auto), URL-pattern matches in
1521
+ // Settings.headlock, or explicit data-headlock="true".
1522
+ // This is the win over the previous "never remove SCRIPT/
1523
+ // STYLE" rule — per-page server-rendered <style> blocks
1524
+ // (e.g. layout1 inline CSS) still get swapped, only
1525
+ // third-party / dynamically-injected ones are locked.
1407
1526
  if(!found && Transparent.isHeadlocked(el)) found = true;
1408
1527
  if(!found) this.remove();
1409
1528
  });
@@ -1416,35 +1535,20 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1416
1535
  $("head").children().each(function() { found |= this.isEqualNode(el); });
1417
1536
  if(!found) {
1418
1537
 
1419
- if(this.tagName == "SCRIPT" && Settings["global_code"] != true) {
1420
-
1421
- // For inline scripts (without src), recreate so the browser will execute.
1422
- // Simply re-appending the same <script> node doesn't execute it.
1423
- if(!this.src || this.src === '') {
1424
- var script = document.createElement("script");
1425
- script.text = this.innerHTML;
1426
- var i = -1, attrs = this.attributes, attr;
1427
- var N = attrs.length;
1428
- while ( ++i < N ) {
1429
- if(attrs[i].name !== 'src') {
1430
- script.setAttribute( attrs[i].name, attrs[i].value );
1431
- }
1432
- }
1433
- $("head").append(script);
1434
- originalHeadNodes.add(script);
1435
- } else {
1436
- $("head").append(this);
1437
- originalHeadNodes.add(this);
1438
- }
1538
+
1539
+ if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
1540
+
1541
+ var clone = this.cloneNode(true);
1542
+ $("head").append(clone);
1543
+ // Register the new node as "original" so it falls
1544
+ // through to URL-pattern matching on the next swap
1545
+ // (and isn't auto-locked as third-party content).
1546
+ originalHeadNodes.add(clone);
1439
1547
 
1440
1548
  } else {
1441
1549
 
1442
- var clonedEl = this.cloneNode(true);
1443
- $("head").append(clonedEl);
1444
- // Register as an "original" node so it falls through to URL-pattern
1445
- // matching on future transitions — prevents layout CSS added by
1446
- // Transparent itself from being auto-headlocked as third-party content.
1447
- originalHeadNodes.add(clonedEl);
1550
+ $("head").append(this);
1551
+ originalHeadNodes.add(this);
1448
1552
  }
1449
1553
  }
1450
1554
  });
@@ -1458,27 +1562,10 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1458
1562
  $("body").children().each(function() { found |= this.isEqualNode(el); });
1459
1563
  if(!found) {
1460
1564
 
1461
- if(this.tagName == "SCRIPT" && Settings["global_code"] != true) {
1462
-
1463
- // Same inline-script recreation as for <head>.
1464
- if(!this.src || this.src === '') {
1465
- var script = document.createElement("script");
1466
- script.text = this.innerHTML;
1467
- var i = -1, attrs = this.attributes, attr;
1468
- var N = attrs.length;
1469
- while ( ++i < N ) {
1470
- if(attrs[i].name !== 'src') {
1471
- script.setAttribute( attrs[i].name, attrs[i].value );
1472
- }
1473
- }
1474
- $("body").append(script);
1475
- } else {
1476
- $("body").append(this);
1477
- }
1478
-
1479
- } else {
1480
-
1565
+ if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
1481
1566
  $("body").append(this.cloneNode(true));
1567
+ } else {
1568
+ $("body").append(this);
1482
1569
  }
1483
1570
  }
1484
1571
  });
@@ -1495,13 +1582,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1495
1582
  // Make sure name/layout keep the same after a page change (tolerance for POST or GET requests)
1496
1583
  if(oldPage.attr("data-layout") != undefined && page.attr("data-layout") != undefined) {
1497
1584
 
1498
- // X = prevLayout, Y = newLayout — must match the formula in handleResponse
1499
- // (line ~1852: SWITCH.replace("X", prevLayout).replace("Y", newLayout)).
1500
- // If these disagreed, the cleanup filter below would not recognize the
1501
- // switchLayout class that handleResponse added to <html> and would strip
1502
- // it before its CSS transition could play — visible as a race only in
1503
- // whichever direction the project's CSS actually styles.
1504
- var switchLayout = Transparent.state.SWITCH.replace("X", oldPage.attr("data-layout")).replace("Y", page.attr("data-layout"));
1585
+ var switchLayout = Transparent.state.SWITCH.replace("X", page.attr("data-layout")).replace("Y", oldPage.attr("data-layout"));
1505
1586
  page.attr("data-layout-prev", oldPage.attr("data-layout"));
1506
1587
  }
1507
1588
 
@@ -1510,13 +1591,10 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1510
1591
  var oldHtmlClass = Array.from(($(Transparent.html).attr("class") || "").split(" "));
1511
1592
  var removeHtmlClass = oldHtmlClass.filter(x => !htmlClass.includes(x) && switchLayout != x && !states.includes(x));
1512
1593
 
1513
- _tx("onLoad classMgmt", "switchLayout=" + switchLayout + " remove=[" + removeHtmlClass.join(",") + "] add=[" + htmlClass.join(",") + "]");
1514
1594
  Transparent.html.removeClass(removeHtmlClass).addClass(htmlClass);
1515
- _tx("onLoad PAGE_SWAP_BEGIN", "oldLayout=" + (oldPage.attr("data-layout")||"?") + " newLayout=" + (page.attr("data-layout")||"?"));
1516
1595
  $(page).insertBefore(oldPage);
1517
1596
 
1518
1597
  oldPage.remove();
1519
- _tx("onLoad PAGE_SWAP_DONE");
1520
1598
 
1521
1599
  if(Settings["global_code"] == true) Transparent.evalScript($(page)[0]);
1522
1600
  document.dispatchEvent(new Event('DOMContentLoaded'));
@@ -1552,77 +1630,41 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1552
1630
  }
1553
1631
  }
1554
1632
 
1555
- // Collect link[rel="stylesheet"] elements inserted by the head merge above
1556
- var _newStyleLinks = [];
1557
- $("head").children("link[rel='stylesheet']").each(function() {
1558
- var h = this.getAttribute("href");
1559
- if(h && !_existingStyleHrefs[h]) _newStyleLinks.push(this);
1633
+ $('head').append(function() {
1634
+
1635
+ $(Settings.identifier).append(function() {
1636
+
1637
+ // Callback if needed, or any other actions
1638
+ callback();
1639
+
1640
+ // Trigger onload event
1641
+ dispatchEvent(new Event('transparent:load'));
1642
+ dispatchEvent(new Event('load'));
1643
+ });
1560
1644
  });
1561
1645
 
1562
- // Wait for any newly added layout stylesheets to finish loading before
1563
- // calling callback() / fadeOut() — otherwise #page becomes visible while
1564
- // the new CSS is still being parsed, causing a flash of unstyled content.
1565
- (function() {
1566
- function doCallback() {
1567
- _tx("doCallback FIRES → callback() (which starts fadeOut)");
1568
- // requestAnimationFrame here guarantees the browser has had one
1569
- // full frame to apply the new page's stylesheets and re-layout
1570
- // before fadeOut starts. Without it, fadeOut can animate the
1571
- // loader away while the new page is still rendered with the
1572
- // previous layout's styles — the article→home flicker the
1573
- // [TX] trace masked via instrumentation overhead.
1574
- requestAnimationFrame(function() {
1575
- $('head').append(function() {
1576
- $(Settings.identifier).append(function() {
1577
- callback();
1578
- dispatchEvent(new Event('transparent:load'));
1579
- dispatchEvent(new Event('load'));
1580
- });
1581
- });
1582
- });
1583
- }
1584
- // For cached stylesheets, the browser may fire `load` synchronously on
1585
- // DOM insertion — BEFORE we can attach a listener — so listener-only
1586
- // waits get stuck on the 3 s guard. `.sheet !== null` indicates the
1587
- // CSSStyleSheet is already parsed and ready, which is the right
1588
- // condition to count it as "done." Cross-origin sheets still expose
1589
- // `.sheet` even though `.cssRules` throws — `.sheet !== null` is
1590
- // portable.
1591
- function isStyleLoaded(link) {
1592
- try { return link.sheet !== null; } catch(e) { return true; }
1593
- }
1594
- var pending = _newStyleLinks.filter(function(l) { return !isStyleLoaded(l); });
1595
- _tx("stylesheet-wait BEGIN", "newLinks=" + _newStyleLinks.length + " cachedSkipped=" + (_newStyleLinks.length - pending.length) + " pending=" + pending.length);
1596
- if(pending.length === 0) {
1597
- _tx("stylesheet-wait IMMEDIATE → doCallback");
1598
- doCallback();
1599
- } else {
1600
- var remaining = pending.length;
1601
- var fired = false;
1602
- // Safety valve: if a stylesheet fails or stalls, don't block forever.
1603
- var guard = setTimeout(function() {
1604
- if(!fired) {
1605
- _tx("stylesheet-wait GUARD fired (3s)", "remaining=" + remaining);
1606
- fired = true; doCallback();
1607
- }
1608
- }, 3000);
1609
- pending.forEach(function(link) {
1610
- function onDone(e) {
1611
- _tx("stylesheet-wait link.load", "remaining=" + (remaining-1) + " href=" + link.getAttribute("href"));
1612
- if(--remaining <= 0 && !fired) {
1613
- fired = true;
1614
- clearTimeout(guard);
1615
- _tx("stylesheet-wait ALL_LOADED → doCallback");
1616
- doCallback();
1617
- }
1618
- }
1619
- link.addEventListener('load', onDone, {once:true});
1620
- link.addEventListener('error', onDone, {once:true});
1621
- });
1622
- }
1623
- })();
1646
+ }.bind(this);
1624
1647
 
1625
- }.bind(this), _swapDelay);
1648
+ // Dispatch the swap. With VT enabled AND supported, wrap in
1649
+ // document.startViewTransition() so the browser captures OLD/NEW
1650
+ // snapshots and crossfades natively. The setTimeout wait is for
1651
+ // the CSS fade-out to complete BEFORE VT begins, so VT captures
1652
+ // the already-faded state cleanly. Errors inside the callback
1653
+ // don't abort the swap — fall back to direct execution.
1654
+ var _vtEnabled = Settings["use_view_transitions"]
1655
+ && typeof document.startViewTransition === "function";
1656
+ setTimeout(function() {
1657
+ if (_vtEnabled) {
1658
+ try {
1659
+ document.startViewTransition(_doSwapBody);
1660
+ } catch (e) {
1661
+ if (Settings.debug) console.warn("Transparent VT failed, falling back:", e);
1662
+ _doSwapBody();
1663
+ }
1664
+ } else {
1665
+ _doSwapBody();
1666
+ }
1667
+ }, activeInRemainingTime > 0 ? activeInRemainingTime : 1);
1626
1668
  }
1627
1669
 
1628
1670
  function uuidv4() {
@@ -1733,8 +1775,326 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1733
1775
 
1734
1776
  var ajaxSemaphore = false;
1735
1777
  var formSubmission = false;
1778
+
1779
+ // ── User-typed form dirty tracking ──────────────────────────────────────
1780
+ //
1781
+ // True if the user has modified any form input via a real keystroke /
1782
+ // click / select. False after a fresh navigation OR after a form submit.
1783
+ // Used by onbeforeunload to decide whether to confirm.
1784
+ //
1785
+ // `e.isTrusted` filters out programmatic value mutations from JS init
1786
+ // code (Select2 setting the hidden field after dropdown selection,
1787
+ // Editor.js syncing JSON to a hidden <textarea>, datepicker init writing
1788
+ // a normalized value, etc.). Without this filter, the previous value-
1789
+ // comparison logic (formDataBefore vs formDataAfter) flagged every
1790
+ // page-with-form as "dirty" by the time the user hit Ctrl+W, even
1791
+ // when they hadn't typed anything.
1792
+ var formDirty = false;
1793
+ document.addEventListener('input', function(e) {
1794
+ if (!e.isTrusted) return;
1795
+ if (!e.target || !e.target.form) return;
1796
+ formDirty = true;
1797
+ }, true);
1798
+ document.addEventListener('change', function(e) {
1799
+ if (!e.isTrusted) return;
1800
+ if (!e.target || !e.target.form) return;
1801
+ formDirty = true;
1802
+ }, true);
1803
+ // Reset on user-initiated submit so the post-submit redirect doesn't
1804
+ // double-prompt. The existing `formSubmission` flag already handles
1805
+ // the synchronous prompt path; this clears state for any follow-up.
1806
+ document.addEventListener('submit', function() { formDirty = false; }, true);
1807
+ // Reset on every navigation. transparent.js re-dispatches DOMContentLoaded
1808
+ // after each SPA swap (see _doSwap, ~line 1567), so this fires on the
1809
+ // initial load AND on each in-place navigation. Without it the flag leaks
1810
+ // across SPA navigations: typing on page A, then navigating to a form
1811
+ // page B, would leave formDirty=true and wrongly prompt on reload of B
1812
+ // even though the user never touched B. A freshly-loaded page is never
1813
+ // dirty until the user types on it (any restored draft is already saved).
1814
+ document.addEventListener('DOMContentLoaded', function() { formDirty = false; });
1815
+
1816
+ // ── Transparent.formMemory ──────────────────────────────────────────────
1817
+ //
1818
+ // Persistent draft store for forms — survives accidental close, refresh,
1819
+ // power loss, browser crash. Keyed by URL + form identity (name or id).
1820
+ //
1821
+ // Save triggers:
1822
+ // - debounced (500ms) on user `input`/`change` events
1823
+ // - synchronously on beforeunload (last-resort capture for fields
1824
+ // mutated by JS like Editor.js / Select2 that don't fire trusted
1825
+ // `input` events)
1826
+ //
1827
+ // Restore: silently on initial DOMContentLoaded AND after each SPA swap
1828
+ // (transparent.js re-dispatches DOMContentLoaded post-swap, line ~1375).
1829
+ // Restored fields get `data-restored-from-draft=""` for optional
1830
+ // project-level toast / styling.
1831
+ //
1832
+ // Clear: on form submit + on TTL expiry (7 days) + manually via
1833
+ // `Transparent.formMemory.clear(form)`.
1834
+ //
1835
+ // Opt-out:
1836
+ // - `<form data-no-persist>` — entire form skipped
1837
+ // - `<input data-no-persist>` — single field skipped
1838
+ // - Auto-skipped: type="password", type="file", type="submit"/button,
1839
+ // and any hidden field whose name contains `_token` or `csrf`
1840
+ // (Symfony CSRF token field). These are never persisted.
1841
+ //
1842
+ // Editor.js compatibility: Editor.js reads its initial JSON from
1843
+ // `data-edjs` attribute (not `.value`). On restore, if the field is a
1844
+ // <textarea data-edjs>, we mirror the restored value into `data-edjs`
1845
+ // too so Editor.js renders the draft when its init pass runs.
1846
+ Transparent.formMemory = (function() {
1847
+ var KEY_PREFIX = 'tx-form-memory:';
1848
+ var DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
1849
+ var DEBOUNCE = 500;
1850
+
1851
+ var saveTimers = new WeakMap();
1852
+ var api = {
1853
+ enabled : true,
1854
+ ttl : DEFAULT_TTL,
1855
+ debounce: DEBOUNCE,
1856
+ };
1857
+
1858
+ function shouldSkipField(field) {
1859
+ if (!field.name) return true;
1860
+ var type = (field.type || '').toLowerCase();
1861
+ if (type === 'password' || type === 'file') return true;
1862
+ if (type === 'submit' || type === 'button' || type === 'reset') return true;
1863
+ if (field.hasAttribute && field.hasAttribute('data-no-persist')) return true;
1864
+ // CSRF tokens — never persist. Matches Symfony's `_token` and
1865
+ // any other token-named hidden input (`csrf`, `_csrf_token`, ...).
1866
+ if (type === 'hidden') {
1867
+ var n = field.name.toLowerCase();
1868
+ if (n.indexOf('_token') !== -1 || n.indexOf('csrf') !== -1) return true;
1869
+ }
1870
+ return false;
1871
+ }
1872
+
1873
+ function shouldSkipForm(form) {
1874
+ if (!form) return true;
1875
+ if (form.hasAttribute && form.hasAttribute('data-no-persist')) return true;
1876
+ if (!form.name && !form.id) return true; // need identity for key
1877
+ return false;
1878
+ }
1879
+
1880
+ function getKey(form) {
1881
+ return KEY_PREFIX + location.pathname + ':' + (form.name || form.id);
1882
+ }
1883
+
1884
+ function readLS(key) {
1885
+ try { return localStorage.getItem(key); } catch (e) { return null; }
1886
+ }
1887
+ function writeLS(key, val) {
1888
+ try { localStorage.setItem(key, val); } catch (e) {
1889
+ // Quota exceeded or storage disabled — fail silent. The
1890
+ // user keeps their work in memory, just loses persistence.
1891
+ }
1892
+ }
1893
+ function removeLS(key) {
1894
+ try { localStorage.removeItem(key); } catch (e) {}
1895
+ }
1896
+
1897
+ api.save = function(form) {
1898
+ if (!api.enabled) return;
1899
+ if (shouldSkipForm(form)) return;
1900
+
1901
+ var data = {};
1902
+ var elements = form.elements;
1903
+ for (var i = 0; i < elements.length; i++) {
1904
+ var field = elements[i];
1905
+ if (shouldSkipField(field)) continue;
1906
+
1907
+ var type = (field.type || '').toLowerCase();
1908
+ if (type === 'checkbox' || type === 'radio') {
1909
+ if (!field.checked) continue;
1910
+ if (data[field.name] === undefined) {
1911
+ data[field.name] = field.value;
1912
+ } else if (Array.isArray(data[field.name])) {
1913
+ data[field.name].push(field.value);
1914
+ } else {
1915
+ data[field.name] = [data[field.name], field.value];
1916
+ }
1917
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1918
+ var sel = [];
1919
+ for (var j = 0; j < field.options.length; j++) {
1920
+ if (field.options[j].selected) sel.push(field.options[j].value);
1921
+ }
1922
+ data[field.name] = sel;
1923
+ } else {
1924
+ data[field.name] = field.value;
1925
+ }
1926
+ }
1927
+
1928
+ if (Object.keys(data).length === 0) {
1929
+ // No persistable fields with data — clear any stale entry
1930
+ // rather than write an empty record (avoids restoring "all
1931
+ // empty" later and overwriting newly-pre-filled fields).
1932
+ removeLS(getKey(form));
1933
+ return;
1934
+ }
1935
+
1936
+ writeLS(getKey(form), JSON.stringify({ t: Date.now(), d: data }));
1937
+ };
1938
+
1939
+ api.restore = function(form) {
1940
+ if (!api.enabled) return;
1941
+ if (shouldSkipForm(form)) return;
1942
+
1943
+ var key = getKey(form);
1944
+ var raw = readLS(key);
1945
+ if (!raw) return;
1946
+
1947
+ var entry;
1948
+ try { entry = JSON.parse(raw); }
1949
+ catch (e) { removeLS(key); return; }
1950
+
1951
+ if (!entry || !entry.t || !entry.d) { removeLS(key); return; }
1952
+ if (Date.now() - entry.t > api.ttl) { removeLS(key); return; }
1953
+
1954
+ Object.keys(entry.d).forEach(function(name) {
1955
+ var value = entry.d[name];
1956
+ // Use attr-selector with CSS.escape to handle names like
1957
+ // `Article[content]` that contain brackets.
1958
+ var sel = '[name="' + (typeof CSS !== 'undefined' && CSS.escape
1959
+ ? CSS.escape(name)
1960
+ : name.replace(/(["\\\[\]])/g, '\\$1')) + '"]';
1961
+ var fields = form.querySelectorAll(sel);
1962
+ if (fields.length === 0) return;
1963
+
1964
+ for (var k = 0; k < fields.length; k++) {
1965
+ var field = fields[k];
1966
+ if (shouldSkipField(field)) continue;
1967
+
1968
+ var type = (field.type || '').toLowerCase();
1969
+ if (type === 'checkbox' || type === 'radio') {
1970
+ field.checked = Array.isArray(value)
1971
+ ? (value.indexOf(field.value) !== -1)
1972
+ : (field.value === value);
1973
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1974
+ if (Array.isArray(value)) {
1975
+ for (var m = 0; m < field.options.length; m++) {
1976
+ field.options[m].selected = (value.indexOf(field.options[m].value) !== -1);
1977
+ }
1978
+ }
1979
+ } else {
1980
+ field.value = value;
1981
+ // Editor.js mirrors: Editor.js reads its initial JSON
1982
+ // from the `data-edjs` attribute, not from .value. So
1983
+ // we have to mirror the restored value into the attr
1984
+ // for the upcoming Editor.js init pass to pick it up.
1985
+ if (field.tagName === 'TEXTAREA' && field.hasAttribute('data-edjs')) {
1986
+ field.setAttribute('data-edjs', value);
1987
+ }
1988
+ }
1989
+ field.setAttribute('data-restored-from-draft', '');
1990
+ }
1991
+ });
1992
+ };
1993
+
1994
+ api.clear = function(form) {
1995
+ if (!form) return;
1996
+ if (shouldSkipForm(form)) return;
1997
+ removeLS(getKey(form));
1998
+ };
1999
+
2000
+ api.restoreAll = function() {
2001
+ var forms = document.querySelectorAll('form');
2002
+ for (var i = 0; i < forms.length; i++) api.restore(forms[i]);
2003
+ };
2004
+
2005
+ api.saveAll = function() {
2006
+ var forms = document.querySelectorAll('form');
2007
+ for (var i = 0; i < forms.length; i++) api.save(forms[i]);
2008
+ };
2009
+
2010
+ api.clearExpired = function() {
2011
+ var ttl = api.ttl;
2012
+ var now = Date.now();
2013
+ try {
2014
+ for (var i = localStorage.length - 1; i >= 0; i--) {
2015
+ var key = localStorage.key(i);
2016
+ if (!key || key.indexOf(KEY_PREFIX) !== 0) continue;
2017
+ var raw;
2018
+ try { raw = localStorage.getItem(key); }
2019
+ catch (e) { continue; }
2020
+ var entry = null;
2021
+ try { entry = JSON.parse(raw); } catch (e) {}
2022
+ if (!entry || !entry.t || (now - entry.t > ttl)) {
2023
+ removeLS(key);
2024
+ }
2025
+ }
2026
+ } catch (e) {}
2027
+ };
2028
+
2029
+ // Debounced per-form save scheduler. WeakMap → no leak when forms
2030
+ // get removed from the DOM (e.g., on SPA swap).
2031
+ function debouncedSave(form) {
2032
+ if (!form || shouldSkipForm(form)) return;
2033
+ var existing = saveTimers.get(form);
2034
+ if (existing) clearTimeout(existing);
2035
+ saveTimers.set(form, setTimeout(function() {
2036
+ api.save(form);
2037
+ }, api.debounce));
2038
+ }
2039
+
2040
+ // Event wiring. Capture phase + isTrusted gate matches the
2041
+ // formDirty pattern above — programmatic field mutations from
2042
+ // Select2/Editor.js init code don't trigger a save here, which is
2043
+ // correct (those are not "user changes"; saving them would persist
2044
+ // server-rendered defaults as if they were user input).
2045
+ document.addEventListener('input', function(e) {
2046
+ if (!e.isTrusted) return;
2047
+ if (!e.target || !e.target.form) return;
2048
+ debouncedSave(e.target.form);
2049
+ }, true);
2050
+ document.addEventListener('change', function(e) {
2051
+ if (!e.isTrusted) return;
2052
+ if (!e.target || !e.target.form) return;
2053
+ debouncedSave(e.target.form);
2054
+ }, true);
2055
+
2056
+ // Successful submit clears the draft. We listen in capture so we
2057
+ // run before any user-side submit handler that might cancel.
2058
+ document.addEventListener('submit', function(e) {
2059
+ if (e.target && e.target.tagName === 'FORM') {
2060
+ api.clear(e.target);
2061
+ }
2062
+ }, true);
2063
+
2064
+ // Last-resort save on page exit. Catches state mutated only by JS
2065
+ // (Editor.js content syncs, Select2 hidden-field writes) that
2066
+ // never fired trusted input/change events. Synchronous because
2067
+ // beforeunload doesn't await microtasks.
2068
+ window.addEventListener('beforeunload', function() {
2069
+ if (!api.enabled) return;
2070
+ // Only save if the user has actually typed something — same
2071
+ // gate as the unload-confirm prompt above. If formDirty is
2072
+ // false, drafts written from the debounced handler are stale
2073
+ // and we don't want to refresh them on every page-close.
2074
+ if (formDirty) api.saveAll();
2075
+ });
2076
+
2077
+ // Initial restore. If we're already past DOMContentLoaded (defer
2078
+ // scripts run after parsing but before DCL), run synchronously.
2079
+ // For SPA navs, transparent.js re-dispatches DOMContentLoaded in
2080
+ // _doSwap (around line 1375), so this same listener fires again
2081
+ // on every swap — no extra wiring needed.
2082
+ if (document.readyState === 'loading') {
2083
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
2084
+ } else {
2085
+ api.restoreAll();
2086
+ }
2087
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
2088
+
2089
+ // Cleanup expired entries — once at startup, deferred so it
2090
+ // doesn't block first paint.
2091
+ setTimeout(api.clearExpired, 2000);
2092
+
2093
+ return api;
2094
+ })();
2095
+
1736
2096
  function __main__(e) {
1737
- _tx("__main__ ENTRY", "event=" + e.type + (e.target && e.target.tagName ? " target=" + e.target.tagName : ""));
2097
+
1738
2098
  // Disable transparent JS (e.g. during development..)
1739
2099
  if(Settings.disable) return;
1740
2100
 
@@ -1826,17 +2186,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1826
2186
  if (ajaxSemaphore) return;
1827
2187
  if (url == location) return;
1828
2188
 
1829
- // Block navigation when offline. Project CSS / JS can react to
1830
- // html.offline + the transparent:offline event to surface a banner.
1831
- // The event is re-dispatched here on each attempted navigation so a
1832
- // listener can briefly flash/highlight the banner to acknowledge the
1833
- // click instead of doing nothing silently.
1834
- if (!isOnline || (typeof navigator !== "undefined" && navigator.onLine === false)) {
1835
- setOnlineStatus(false);
1836
- dispatchEvent(new Event("transparent:offline"));
1837
- return;
1838
- }
1839
-
1840
2189
  if((e.type == Transparent.state.CLICK || e.type == Transparent.state.HASHCHANGE) && url.pathname == location.pathname && url.search == location.search && type != "POST") {
1841
2190
 
1842
2191
  if(!url.hash) return;
@@ -1862,7 +2211,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1862
2211
  $(Transparent.html).stop();
1863
2212
 
1864
2213
  Transparent.html.addClass(Transparent.state.LOADING);
1865
- Transparent.fadeIn();
2214
+ Transparent.activeIn();
1866
2215
 
1867
2216
  function isJsonResponse(str) {
1868
2217
  try { JSON.parse(str); return true; }
@@ -1870,7 +2219,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1870
2219
  }
1871
2220
 
1872
2221
  function handleResponse(uuid, status = 200, method = null, data = null, xhr = null, request = null) {
1873
- _tx("handleResponse ENTRY", "status=" + status + " method=" + method);
1874
2222
 
1875
2223
  ajaxSemaphore = false;
1876
2224
 
@@ -1906,7 +2254,26 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1906
2254
  Transparent.setResponse(uuid, responseText);
1907
2255
  }
1908
2256
 
1909
- var dom = new DOMParser().parseFromString(responseText, "text/html");
2257
+ // Try the in-memory live-DOM cache first. On popstate (back/forward)
2258
+ // we just stored the outgoing page node via setLiveResponse, so the
2259
+ // round-trip is: snapshot → cache → instant retrieve. No serialize,
2260
+ // no DOMParser cost. Falls back to the parse path on miss (first-
2261
+ // time nav, cache eviction, expired entry, etc.).
2262
+ var dom = null;
2263
+ if (Transparent.getLiveResponse) {
2264
+ var liveNode = Transparent.getLiveResponse(uuid);
2265
+ if (liveNode) {
2266
+ // The cache stores the full <html> element. Wrap it in a
2267
+ // minimal Document-shaped object that the swap path can
2268
+ // navigate the same way it navigates a DOMParser result.
2269
+ var docShell = document.implementation.createHTMLDocument('');
2270
+ docShell.replaceChild(liveNode, docShell.documentElement);
2271
+ dom = docShell;
2272
+ }
2273
+ }
2274
+ if (!dom) {
2275
+ dom = new DOMParser().parseFromString(responseText, "text/html");
2276
+ }
1910
2277
  if(request && request.getResponseHeader("Content-Type") == "application/json") {
1911
2278
 
1912
2279
  if(!isJsonResponse(responseText)) {
@@ -1956,18 +2323,12 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1956
2323
  history.pushState({uuid: uuid, status:status, method: method, data: {}, href: responseURL}, '', responseURL);
1957
2324
 
1958
2325
  // Page not recognized.. just go fetch by yourself.. no POST information transmitted..
1959
- // Defer the redirect until fadeIn settles, same reasoning as the
1960
- // html.reload path above.
1961
- if(!Transparent.isPage(dom)) {
1962
- _waitForFadeIn(function() { window.location.href = url; });
1963
- return;
1964
- }
2326
+ if(!Transparent.isPage(dom))
2327
+ return window.location.href = url;
1965
2328
 
1966
2329
  // Layout not compatible.. needs to be reloaded (exception when POST is detected..)
1967
- if(!Transparent.isCompatiblePage(dom, method, data)) {
1968
- _waitForFadeIn(function() { window.location.href = url; });
1969
- return;
1970
- }
2330
+ if(!Transparent.isCompatiblePage(dom, method, data))
2331
+ return window.location.href = url;
1971
2332
 
1972
2333
  // Mark layout as known
1973
2334
  if(!Transparent.isKnownLayout(dom)) {
@@ -1995,46 +2356,16 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1995
2356
  Transparent.html.addClass(Transparent.state.SAME);
1996
2357
 
1997
2358
  var switchLayout = Transparent.state.SWITCH.replace("X", prevLayout).replace("Y", newLayout);
1998
- _tx("handleResponse switchLayout", "prev=" + prevLayout + " new=" + newLayout + " adds=." + switchLayout);
1999
2359
  Transparent.html.addClass(switchLayout);
2000
2360
 
2001
2361
  dispatchEvent(new Event('transparent:'+switchLayout));
2002
2362
 
2003
- if($(dom).find("html").hasClass(Transparent.state.RELOAD) || $(dom).find("html").hasClass(Transparent.state.DISABLE)) {
2004
- // Defer the reload until fadeIn has finished, so the loader is
2005
- // at full opacity when the browser unloads. Without this, a
2006
- // fast AJAX response can fire reload() while fadeIn is still
2007
- // mid-animation: the browser swaps to a page that starts with
2008
- // .active (loader at 100%), and the user perceives a snap
2009
- // from in-progress opacity to full. Reading as "fade-out then
2010
- // fade-in" because the partial loader receded as the browser
2011
- // swapped frames.
2012
- _waitForFadeIn(function() { window.location.reload(); });
2013
- return;
2014
- }
2015
-
2016
- // Kick off preloads for stylesheets the new page needs but aren't yet in <head>.
2017
- // They download in parallel during the fadeIn animation so onLoad() finds them
2018
- // already cached — eliminating FOUC on cold-cache layout transitions.
2019
- (function() {
2020
- var loaded = {};
2021
- $("head").children("link[rel='stylesheet']").each(function() {
2022
- var h = this.getAttribute("href"); if(h) loaded[h] = true;
2023
- });
2024
-
2025
- $(dom).find("head").children("link[rel='stylesheet']").each(function() {
2026
- var h = this.getAttribute("href");
2027
- if(!h || loaded[h]) return;
2028
- if($("head").find("link[rel='preload'][href='" + h.replace(/'/g, "\\'") + "']").length) return;
2029
- var pl = document.createElement("link");
2030
- pl.rel = "preload"; pl.as = "style"; pl.href = h;
2031
- document.head.appendChild(pl);
2032
- });
2033
- })();
2363
+ if($(dom).find("html").hasClass(Transparent.state.RELOAD) || $(dom).find("html").hasClass(Transparent.state.DISABLE))
2364
+ return window.location.reload();
2034
2365
 
2035
2366
  return Transparent.onLoad(uuid, dom, function() {
2036
2367
 
2037
- Transparent.fadeOut(function() {
2368
+ Transparent.activeOut(function() {
2038
2369
 
2039
2370
  Transparent.html
2040
2371
  .removeClass(switchLayout)
@@ -2057,7 +2388,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
2057
2388
  if(history.state)
2058
2389
  Transparent.setResponse(history.state.uuid, Transparent.html[0], Transparent.getScrollableElementXY());
2059
2390
 
2060
- $(Transparent.html).prop("user-scroll", false); // make sure to avoid page jump during transition (cancelled in fadeIn callback)
2391
+ $(Transparent.html).prop("user-scroll", false); // make sure to avoid page jump during transition (cancelled in activeIn callback)
2061
2392
 
2062
2393
  // Submit ajax request..
2063
2394
  if(form) form.dispatchEvent(new SubmitEvent("submit", { submitter: formTrigger }));
@@ -2072,18 +2403,8 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
2072
2403
  processData: false,
2073
2404
  headers: Settings["headers"] || {},
2074
2405
  xhr: function () { return xhr; },
2075
- success: function (html, status, request) { _tx("ajax SUCCESS", "status=" + request.status); return handleResponse(uuid, request.status, type, data, xhr, request); },
2076
- error: function (request, ajaxOptions, thrownError) {
2077
- _tx("ajax ERROR", "status=" + request.status + " textStatus=" + ajaxOptions);
2078
- // status=0 with non-abort textStatus typically means the device
2079
- // couldn't reach the server: dropped connection, DNS failure,
2080
- // captive portal, etc. Flip to offline so the project's banner
2081
- // surfaces even if navigator.onLine still reports true.
2082
- if (request.status === 0 && ajaxOptions !== "abort") {
2083
- setOnlineStatus(false);
2084
- }
2085
- return handleResponse(uuid, request.status, type, data, xhr, request);
2086
- }
2406
+ success: function (html, status, request) { return handleResponse(uuid, request.status, type, data, xhr, request); },
2407
+ error: function (request, ajaxOptions, thrownError) { return handleResponse(uuid, request.status, type, data, xhr, request); }
2087
2408
  });
2088
2409
  }
2089
2410
 
@@ -2114,100 +2435,38 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
2114
2435
  window.onpopstate = __main__; // Onpopstate pop out straight to previous page.. this creates a jump while changing pages with hash..
2115
2436
  window.onhashchange = __main__;
2116
2437
 
2117
- var formDataBefore = {};
2118
- $(window).on("load", function() {
2119
-
2120
- formDataBefore = {};
2121
- $("form").each(function() {
2122
-
2123
- var formData = new FormData();
2124
- var formInput = $("[name^='"+this.name+"\[']");
2125
- formInput.each(function() {
2126
-
2127
- if(this.type == "file") {
2128
-
2129
- for(var i = 0; i < this.files.length; i++)
2130
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
2131
-
2132
- } else formData.append(this.name, this.value);
2133
- });
2134
-
2135
- for (var [fieldName,fieldValue] of formData.entries()) {
2136
-
2137
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
2138
- formDataBefore[fieldName] = fieldValue;
2139
- }
2140
- });
2141
- });
2142
-
2143
- window.onbeforeunload = function(e) {
2144
-
2145
- if(Settings.debug) console.log("Transparent onbeforeunload event called..");
2146
-
2147
- if(formSubmission) return; // Do not display on form submission
2438
+ // onbeforeunload confirmation REMOVED. It produced spurious "are you
2439
+ // sure you want to leave?" blocks on any page with a form even when the
2440
+ // user never typed: the dirty flag was flipped by any trusted change
2441
+ // event (a <select>/checkbox toggle, Select2/datepicker init firing a
2442
+ // real change, browser autofill, …), and the `e.currentTarget == window`
2443
+ // guard below is unreliable across browsers. More importantly it's now
2444
+ // obsolete: Transparent.formMemory persists every form's content to
2445
+ // localStorage (debounced on input + saved synchronously on unload) and
2446
+ // restores it on the next load, so reloading or closing the tab never
2447
+ // loses typed input. The draft save on beforeunload (in the formMemory
2448
+ // IIFE above) is kept; only the blocking confirmation is gone.
2449
+ var __onbeforeunload_disabled = function(e) {
2450
+ if(Settings.debug) console.log("Transparent onbeforeunload (no-op; drafts auto-saved)");
2451
+ if(formSubmission) return;
2148
2452
  if(Settings.disable) return;
2453
+ // No return value → browser never shows the leave/reload confirmation.
2454
+ };
2149
2455
 
2150
- if(e.currentTarget == window) return;
2151
-
2152
- var preventDefault = false;
2153
- var formDataAfter = [];
2154
- $("form").each(function() {
2155
-
2156
- var formData = new FormData();
2157
- var formInput = $("[name^='"+this.name+"\[']");
2158
- formInput.each(function() {
2159
-
2160
- if(this.type == "file") {
2161
-
2162
- for(var i = 0; i < this.files.length; i++)
2163
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
2164
-
2165
- } else formData.append(this.name, this.value);
2166
- });
2167
-
2168
- for (var [fieldName,fieldValue] of formData.entries()) {
2169
-
2170
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
2171
- formDataAfter[fieldName] = fieldValue;
2172
- }
2173
- });
2174
-
2175
- var formDataBeforeKeys = Object.keys(formDataBefore);
2176
- var formDataAfterKeys = Object.keys(formDataAfter);
2177
- function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
2178
- function sameKeys(a, b) {
2179
-
2180
- var aKeys = Object.keys(a).sort();
2181
- var bKeys = Object.keys(b).sort();
2182
- return JSON.stringify(aKeys) === JSON.stringify(bKeys);
2183
- }
2184
-
2185
- if(!sameKeys(formDataBeforeKeys, formDataAfterKeys)) preventDefault = true;
2186
- else {
2187
-
2188
- for (var [fieldName,fieldValueAfter] of Object.entries(formDataAfter)) {
2189
-
2190
- var fieldValueBefore = formDataBefore[fieldName];
2191
- if(fieldValueBefore instanceof File) {
2192
-
2193
- if(!fieldValueAfter instanceof File) preventDefault = true;
2194
- else if (fieldValueBefore.size != fieldValueAfter.size) preventDefault = true;
2195
-
2196
- } else if(fieldValueBefore != fieldValueAfter) {
2197
- preventDefault = true;
2198
- }
2199
- }
2200
- }
2201
-
2202
- if(Settings.debug || preventDefault) {
2203
-
2204
- if(preventDefault) Transparent.html.addClass(Transparent.state.READY);
2205
- if(preventDefault) Transparent.fadeOut();
2206
- if(preventDefault) dispatchEvent(new Event('load'));
2207
-
2208
- return "Dude, are you sure you want to leave? Think of the kittens!";
2209
- }
2210
- }
2456
+ // Legacy snapshot-and-compare confirm (formDataBefore vs formDataAfter)
2457
+ // gave
2458
+ // false positives because JS init code mutates form values after
2459
+ // load Select2 writes selected text to hidden fields, Editor.js
2460
+ // serializes its JSON to a <textarea>, datepicker normalizes
2461
+ // formats, etc. — so by the time the user hit Ctrl+W the snapshot
2462
+ // and the current state always differed, and the browser always
2463
+ // prompted even on read-only pages.
2464
+ //
2465
+ // false positives because JS init code mutated form values after load.
2466
+ // Both that and the formDirty replacement are gone: the content is
2467
+ // already in localStorage (saved on input + on unload), so leaving the
2468
+ // page never loses it and a confirmation only gets in the way.
2469
+ window.onbeforeunload = __onbeforeunload_disabled;
2211
2470
 
2212
2471
  document.addEventListener('click', __main__, false);
2213
2472