@glitchr/transparent 1.0.59 → 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 +0 -1
- package/package.json +1 -1
- package/src/js/transparent.js +549 -89
package/README.md
CHANGED
package/package.json
CHANGED
package/src/js/transparent.js
CHANGED
|
@@ -179,7 +179,43 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
179
179
|
"smoothscroll_duration": "200ms",
|
|
180
180
|
"smoothscroll_speed" : 0,
|
|
181
181
|
"smoothscroll_easing" : "swing",
|
|
182
|
-
"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
|
|
183
219
|
};
|
|
184
220
|
|
|
185
221
|
const State = Transparent.state = {
|
|
@@ -219,6 +255,91 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
219
255
|
dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
|
|
220
256
|
}
|
|
221
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
|
+
|
|
222
343
|
window.addEventListener("DOMContentLoaded", function()
|
|
223
344
|
{
|
|
224
345
|
Transparent.loader = $($(document).find(Settings.loader)[0] ?? Transparent.html);
|
|
@@ -954,7 +1075,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
954
1075
|
Transparent.evalScript($("body")[0]);
|
|
955
1076
|
}
|
|
956
1077
|
|
|
957
|
-
Transparent.scrollTo({top:0, left:0, duration:0});
|
|
958
1078
|
Transparent.activeOut();
|
|
959
1079
|
}
|
|
960
1080
|
|
|
@@ -1093,8 +1213,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1093
1213
|
image.onload = function() {
|
|
1094
1214
|
this.classList.add("loaded");
|
|
1095
1215
|
this.classList.remove("loading");
|
|
1216
|
+
this.classList.remove("error");
|
|
1096
1217
|
if(lazybox) lazybox.classList.add("loaded");
|
|
1097
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");
|
|
1098
1233
|
};
|
|
1099
1234
|
|
|
1100
1235
|
if(lazybox) lazybox.classList.add("loading");
|
|
@@ -1223,11 +1358,57 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1223
1358
|
});
|
|
1224
1359
|
|
|
1225
1360
|
activeInRemainingTime = activeInRemainingTime - (Date.now() - activeInTime);
|
|
1226
|
-
|
|
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() {
|
|
1227
1368
|
|
|
1228
1369
|
// Transfert attributes
|
|
1229
1370
|
Transparent.transferAttributes(dom);
|
|
1230
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
|
+
|
|
1231
1412
|
// Replace head..
|
|
1232
1413
|
var head = $(dom).find("head");
|
|
1233
1414
|
$("head").children().each(function() {
|
|
@@ -1241,6 +1422,14 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1241
1422
|
return !found;
|
|
1242
1423
|
});
|
|
1243
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;
|
|
1244
1433
|
if(!found) this.remove();
|
|
1245
1434
|
});
|
|
1246
1435
|
|
|
@@ -1255,11 +1444,17 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1255
1444
|
|
|
1256
1445
|
if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
|
|
1257
1446
|
|
|
1258
|
-
|
|
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);
|
|
1259
1453
|
|
|
1260
1454
|
} else {
|
|
1261
1455
|
|
|
1262
1456
|
$("head").append(this);
|
|
1457
|
+
originalHeadNodes.add(this);
|
|
1263
1458
|
}
|
|
1264
1459
|
}
|
|
1265
1460
|
});
|
|
@@ -1354,7 +1549,28 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1354
1549
|
});
|
|
1355
1550
|
});
|
|
1356
1551
|
|
|
1357
|
-
}.bind(this)
|
|
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);
|
|
1358
1574
|
}
|
|
1359
1575
|
|
|
1360
1576
|
function uuidv4() {
|
|
@@ -1465,6 +1681,316 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1465
1681
|
|
|
1466
1682
|
var ajaxSemaphore = false;
|
|
1467
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
|
+
|
|
1468
1994
|
function __main__(e) {
|
|
1469
1995
|
|
|
1470
1996
|
// Disable transparent JS (e.g. during development..)
|
|
@@ -1788,99 +2314,33 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
|
|
|
1788
2314
|
window.onpopstate = __main__; // Onpopstate pop out straight to previous page.. this creates a jump while changing pages with hash..
|
|
1789
2315
|
window.onhashchange = __main__;
|
|
1790
2316
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
|
|
1805
|
-
|
|
1806
|
-
} else formData.append(this.name, this.value);
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
for (var [fieldName,fieldValue] of formData.entries()) {
|
|
1810
|
-
|
|
1811
|
-
if(!fieldName.endsWith("[]") && fieldName != "undefined")
|
|
1812
|
-
formDataBefore[fieldName] = fieldValue;
|
|
1813
|
-
}
|
|
1814
|
-
});
|
|
1815
|
-
});
|
|
1816
|
-
|
|
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.
|
|
1817
2330
|
window.onbeforeunload = function(e) {
|
|
1818
2331
|
|
|
1819
2332
|
if(Settings.debug) console.log("Transparent onbeforeunload event called..");
|
|
1820
2333
|
|
|
1821
2334
|
if(formSubmission) return; // Do not display on form submission
|
|
1822
2335
|
if(Settings.disable) return;
|
|
1823
|
-
|
|
1824
2336
|
if(e.currentTarget == window) return;
|
|
2337
|
+
if(!formDirty) return; // ← user hasn't modified anything; no prompt
|
|
1825
2338
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
var formData = new FormData();
|
|
1831
|
-
var formInput = $("[name^='"+this.name+"\[']");
|
|
1832
|
-
formInput.each(function() {
|
|
1833
|
-
|
|
1834
|
-
if(this.type == "file") {
|
|
1835
|
-
|
|
1836
|
-
for(var i = 0; i < this.files.length; i++)
|
|
1837
|
-
formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
|
|
1838
|
-
|
|
1839
|
-
} else formData.append(this.name, this.value);
|
|
1840
|
-
});
|
|
1841
|
-
|
|
1842
|
-
for (var [fieldName,fieldValue] of formData.entries()) {
|
|
1843
|
-
|
|
1844
|
-
if(!fieldName.endsWith("[]") && fieldName != "undefined")
|
|
1845
|
-
formDataAfter[fieldName] = fieldValue;
|
|
1846
|
-
}
|
|
1847
|
-
});
|
|
1848
|
-
|
|
1849
|
-
var formDataBeforeKeys = Object.keys(formDataBefore);
|
|
1850
|
-
var formDataAfterKeys = Object.keys(formDataAfter);
|
|
1851
|
-
function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
|
|
1852
|
-
function sameKeys(a, b) {
|
|
1853
|
-
|
|
1854
|
-
var aKeys = Object.keys(a).sort();
|
|
1855
|
-
var bKeys = Object.keys(b).sort();
|
|
1856
|
-
return JSON.stringify(aKeys) === JSON.stringify(bKeys);
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
if(!sameKeys(formDataBeforeKeys, formDataAfterKeys)) preventDefault = true;
|
|
1860
|
-
else {
|
|
1861
|
-
|
|
1862
|
-
for (var [fieldName,fieldValueAfter] of Object.entries(formDataAfter)) {
|
|
1863
|
-
|
|
1864
|
-
var fieldValueBefore = formDataBefore[fieldName];
|
|
1865
|
-
if(fieldValueBefore instanceof File) {
|
|
1866
|
-
|
|
1867
|
-
if(!fieldValueAfter instanceof File) preventDefault = true;
|
|
1868
|
-
else if (fieldValueBefore.size != fieldValueAfter.size) preventDefault = true;
|
|
2339
|
+
Transparent.html.addClass(Transparent.state.READY);
|
|
2340
|
+
Transparent.activeOut();
|
|
2341
|
+
dispatchEvent(new Event('load'));
|
|
1869
2342
|
|
|
1870
|
-
|
|
1871
|
-
preventDefault = true;
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
if(Settings.debug || preventDefault) {
|
|
1877
|
-
|
|
1878
|
-
if(preventDefault) Transparent.html.addClass(Transparent.state.READY);
|
|
1879
|
-
if(preventDefault) Transparent.activeOut();
|
|
1880
|
-
if(preventDefault) dispatchEvent(new Event('load'));
|
|
1881
|
-
|
|
1882
|
-
return "Dude, are you sure you want to leave? Think of the kittens!";
|
|
1883
|
-
}
|
|
2343
|
+
return "Dude, are you sure you want to leave? Think of the kittens!";
|
|
1884
2344
|
}
|
|
1885
2345
|
|
|
1886
2346
|
document.addEventListener('click', __main__, false);
|