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