mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): pages about one row update in place instead of reloading
Nav.refresh() fetches the current page and compares the server's HTML now with the HTML the page started from; only what differs is patched into the live page (text, attributes, class changes, rows added or taken away). Typed input stays (the browser keeps what the user changed while the default value follows the server), and parts built by the page's scripts are left alone. Where a patch would lose script-built or script-bound parts, or the page's layout changed, the page is swapped in again with its scripts, keeping the scroll position, open tabs, open folding parts and typed input; while the user is typing it offers a refresh instead. Live.refreshPage (account, character, bug report and play key pages) uses it instead of reloading the page, and the "This changed while you were editing" banner's Refresh does too. Check: open a play key; in another tab change its uses or active switch: the first tab's values and badge change without a reload, and notes typed there stay. Same on a bug report page with a half typed resolution when it's resolved elsewhere. On a character page, an in-game save updates it without losing the open tab or scroll. Test: NavRulesJs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -189,7 +189,10 @@
|
||||
button.type = 'button';
|
||||
button.className = 'btn btn-sm btn-primary';
|
||||
button.textContent = 'Refresh';
|
||||
button.addEventListener('click', reloadKeepingScroll);
|
||||
button.addEventListener('click', function () {
|
||||
banner.remove();
|
||||
if (window.Nav) Nav.refresh({ force: true }); else reloadKeepingScroll();
|
||||
});
|
||||
banner.append(text, button);
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
@@ -237,15 +240,19 @@
|
||||
Live.on(table, throttle(function () { if (generation === pageGeneration) dataTable.ajax.reload(null, false); }, RELOAD_THROTTLE_MS));
|
||||
},
|
||||
/**
|
||||
* For server-rendered pages about one row: reload the page when that row changes in any of the given tables
|
||||
* (or any row, with anyRow). If the user is typing or has a dialog open, offer a refresh instead.
|
||||
* For server-rendered pages about one row: update the page in place when that row changes in any of the given
|
||||
* tables (or any row, with anyRow). Nav.refresh patches only what the server shows differently, so what is being
|
||||
* typed stays; where it can't (the page would be rebuilt) and the user is busy, it offers a refresh instead.
|
||||
* Without nav.js the page reloads, or offers a refresh while the user is busy.
|
||||
*/
|
||||
refreshPage: function (tables, id, options) {
|
||||
options = options || {};
|
||||
var generation = pageGeneration;
|
||||
var refresh = throttle(function () {
|
||||
if (generation !== pageGeneration) return;
|
||||
if (isBusy()) showStaleBanner(); else reloadKeepingScroll();
|
||||
if (window.Nav) Nav.refresh();
|
||||
else if (isBusy()) showStaleBanner();
|
||||
else reloadKeepingScroll();
|
||||
}, PAGE_REFRESH_MIN_MS);
|
||||
[].concat(tables).forEach(function (table) {
|
||||
Live.on(table, function (e) {
|
||||
@@ -254,6 +261,8 @@
|
||||
});
|
||||
},
|
||||
throttle: throttle,
|
||||
isBusy: isBusy,
|
||||
showStaleBanner: showStaleBanner,
|
||||
onStatus: function (cb) { statusWatchers.push(cb); },
|
||||
// Forget what the page registered (nav.js, when it swaps in another page). Topics only it asked for are dropped.
|
||||
resetPage: function () {
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
* the permissions or user changed, or either page is one that has to load on its own: module scripts, an import map
|
||||
* (the 3D views) or data-nav="reload" anywhere in it. A link with data-nav="off" always loads normally.
|
||||
*
|
||||
* Nav.go(url) opens a page from a script.
|
||||
* Nav.refresh() updates the current page in place: the server's HTML for the page is compared with the HTML the page
|
||||
* started from, and only what differs is patched into the live page, so what scripts put there and what the user is
|
||||
* typing stay. Where that isn't possible (the page's layout changed, or a changed part holds what a script built) the
|
||||
* page is swapped in again. Nav.go(url) opens a page from a script.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -46,6 +49,14 @@
|
||||
if (a.length !== b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
},
|
||||
// Classes to take out of and put into a live element when the server's class list went from `before` to `after`
|
||||
classChanges: function (before, after) {
|
||||
var b = String(before || '').split(/\s+/).filter(Boolean), a = String(after || '').split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
remove: b.filter(function (c) { return a.indexOf(c) === -1; }),
|
||||
add: a.filter(function (c) { return b.indexOf(c) === -1; })
|
||||
};
|
||||
}
|
||||
};
|
||||
window.NavRules = rules;
|
||||
@@ -66,7 +77,7 @@
|
||||
var keepInBody = Array.prototype.slice.call(document.body.children);
|
||||
var inFlight = 0; // fetches of any kind, to know when a swapped-in page has loaded its data
|
||||
|
||||
function newPage() { return { listeners: [], intervals: [], hard: null }; }
|
||||
function newPage() { return { listeners: [], intervals: [], hard: null, base: null, map: new WeakMap(), server: new WeakSet() }; }
|
||||
|
||||
function isJqueryHandle(target, fn) {
|
||||
try { var data = window.jQuery && jQuery._data && jQuery._data(target); return !!(data && data.handle === fn); } catch (e) { return false; }
|
||||
@@ -299,6 +310,97 @@
|
||||
if (offcanvas) offcanvas.hide();
|
||||
}
|
||||
|
||||
// ---- Server HTML and the live page ----
|
||||
|
||||
function key(n) {
|
||||
if (n.nodeType === 1) return n.tagName + (n.id ? '#' + n.id : '');
|
||||
return '#' + n.nodeType;
|
||||
}
|
||||
function keys(list) { return Array.prototype.map.call(list, key); }
|
||||
// Remember which live node each node of the server's HTML became (walks two identical trees side by side)
|
||||
function pair(server, live) {
|
||||
page.map.set(server, live);
|
||||
page.server.add(live);
|
||||
var a = server.childNodes, b = live.childNodes;
|
||||
for (var i = 0; i < a.length && i < b.length; i++) pair(a[i], b[i]);
|
||||
}
|
||||
// Map `next` onto the live nodes `base` stands for (the two are equal)
|
||||
function remap(base, next) {
|
||||
var live = page.map.get(base);
|
||||
if (live) page.map.set(next, live);
|
||||
var a = base.childNodes, b = next.childNodes;
|
||||
for (var i = 0; i < a.length && i < b.length; i++) remap(a[i], b[i]);
|
||||
}
|
||||
function hasScriptContent(live) {
|
||||
// Something a script built (a node that didn't come from the server) somewhere under live
|
||||
if (!page.server.has(live)) return true;
|
||||
for (var c = live.firstChild; c; c = c.nextSibling) if (hasScriptContent(c)) return true;
|
||||
return false;
|
||||
}
|
||||
// Controls a page script may have bound itself (no inline handler), and scripts: HTML that only works once the
|
||||
// page's scripts have run over it
|
||||
var SCRIPTED = 'button:not([onclick]):not([type="submit"]):not([data-bs-toggle]):not([data-bs-dismiss]), ' +
|
||||
'input[type="checkbox"]:not([onchange]):not([onclick]), input[type="radio"]:not([onchange]):not([onclick]), ' +
|
||||
'select:not([onchange]), script:not([type="application/json"])';
|
||||
function needsScripts(nodes) {
|
||||
return Array.prototype.some.call(nodes, function (n) {
|
||||
return n.nodeType === 1 && (n.matches(SCRIPTED) || !!n.querySelector(SCRIPTED));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The changes that turn the live page from `base` (the server's HTML it started from) into `next` (the server's
|
||||
* HTML now), as functions to run; null when that can't be done without rebuilding the page.
|
||||
*/
|
||||
function plan(base, next, ops, top) {
|
||||
var a = base.childNodes, b = next.childNodes;
|
||||
if (!rules.sameKeys(keys(a), keys(b))) {
|
||||
if (top) return null;
|
||||
var parent = page.map.get(base);
|
||||
if (!parent || !parent.isConnected) return ops;
|
||||
var old = Array.prototype.filter.call(a, function (n) { var l = page.map.get(n); return l && l.parentNode === parent; }).map(function (n) { return page.map.get(n); });
|
||||
// What a script built or bound would be lost: swap the page in and run its scripts instead
|
||||
if (old.some(hasScriptContent) || needsScripts(old) || needsScripts(b)) return null;
|
||||
ops.push(function () {
|
||||
var anchor = old.length ? old[0] : null;
|
||||
Array.prototype.forEach.call(b, function (n) {
|
||||
var el = document.importNode(n, true);
|
||||
parent.insertBefore(el, anchor);
|
||||
pair(n, el);
|
||||
});
|
||||
old.forEach(function (el) { el.remove(); });
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i].isEqualNode(b[i])) { ops.push(remap.bind(null, a[i], b[i])); continue; }
|
||||
var live = page.map.get(a[i]);
|
||||
if (!live || !live.isConnected) continue; // a script took it over
|
||||
if (a[i].nodeType !== 1) {
|
||||
ops.push(function (l, n) { l.nodeValue = n.nodeValue; page.map.set(n, l); }.bind(null, live, b[i]));
|
||||
continue;
|
||||
}
|
||||
ops.push(patchAttributes.bind(null, a[i], b[i], live));
|
||||
if (!plan(a[i], b[i], ops, false)) return null;
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
function patchAttributes(base, next, live) {
|
||||
page.map.set(next, live);
|
||||
Array.prototype.forEach.call(next.attributes, function (attr) {
|
||||
var before = base.getAttribute(attr.name);
|
||||
if (before === attr.value) return;
|
||||
if (attr.name === 'class') {
|
||||
var change = rules.classChanges(before, attr.value);
|
||||
change.remove.forEach(function (c) { live.classList.remove(c); });
|
||||
change.add.forEach(function (c) { live.classList.add(c); });
|
||||
} else live.setAttribute(attr.name, attr.value); // value/checked: the browser keeps what the user changed
|
||||
});
|
||||
Array.prototype.forEach.call(base.attributes, function (attr) {
|
||||
if (!next.hasAttribute(attr.name)) live.removeAttribute(attr.name);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Swapping a page in ----
|
||||
|
||||
function scriptsIn(root) {
|
||||
@@ -333,6 +435,46 @@
|
||||
}, function (e) { runningScripts = false; readyQueue = []; throw e; });
|
||||
}
|
||||
|
||||
// What a page reloaded in place should keep: where it was scrolled, open tabs and folding parts, typed input
|
||||
function capture() {
|
||||
var s = { scroll: window.scrollY, tabs: [], open: [], values: [], focus: null };
|
||||
main.querySelectorAll('[data-bs-toggle="tab"].active, [data-bs-toggle="pill"].active').forEach(function (t) {
|
||||
var target = t.getAttribute('data-bs-target') || t.getAttribute('href');
|
||||
if (target) s.tabs.push(target);
|
||||
});
|
||||
main.querySelectorAll('.collapse.show[id], details[open][id]').forEach(function (el) { s.open.push(el.id); });
|
||||
main.querySelectorAll('input[id], textarea[id], select[id]').forEach(function (el) {
|
||||
if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked !== el.defaultChecked) s.values.push({ id: el.id, checked: el.checked }); }
|
||||
else if (el.tagName === 'SELECT') { if (Array.prototype.some.call(el.options, function (o) { return o.selected !== o.defaultSelected; })) s.values.push({ id: el.id, value: el.value }); }
|
||||
else if (el.type !== 'file' && el.type !== 'password' && el.value !== el.defaultValue) s.values.push({ id: el.id, value: el.value });
|
||||
});
|
||||
if (document.activeElement && document.activeElement.id && main.contains(document.activeElement)) s.focus = document.activeElement.id;
|
||||
return s;
|
||||
}
|
||||
function restoreInputs(s) {
|
||||
s.values.forEach(function (v) {
|
||||
var el = document.getElementById(v.id);
|
||||
if (!el) return;
|
||||
if ('checked' in v) el.checked = v.checked; else el.value = v.value;
|
||||
});
|
||||
s.open.forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (el.tagName === 'DETAILS') el.open = true;
|
||||
else {
|
||||
el.classList.add('show');
|
||||
document.querySelectorAll('[data-bs-target="#' + CSS.escape(id) + '"]').forEach(function (b) { b.classList.remove('collapsed'); b.setAttribute('aria-expanded', 'true'); });
|
||||
}
|
||||
});
|
||||
}
|
||||
function restoreTabs(s) {
|
||||
s.tabs.forEach(function (target) {
|
||||
var t = main.querySelector('[data-bs-target="' + CSS.escape(target) + '"], [href="' + CSS.escape(target) + '"]');
|
||||
if (t && window.bootstrap && !t.classList.contains('active')) bootstrap.Tab.getOrCreateInstance(t).show();
|
||||
});
|
||||
if (s.focus) { var f = document.getElementById(s.focus); if (f) f.focus({ preventScroll: true }); }
|
||||
}
|
||||
|
||||
// Once the page's first requests are done (or after a few seconds): scroll again, the content has its height now
|
||||
function whenLoaded(fn) {
|
||||
var until = Date.now() + 3000;
|
||||
@@ -344,7 +486,7 @@
|
||||
|
||||
/**
|
||||
* Swap the fetched page in. options: push (a new history entry) or replace, from (the page it was reached from),
|
||||
* scroll (restore to this position).
|
||||
* scroll (restore to this position), keep (a captured state to restore).
|
||||
*/
|
||||
function swap(doc, url, options) {
|
||||
var head = prepareHead(doc);
|
||||
@@ -362,18 +504,23 @@
|
||||
var banner = document.getElementById('restart-banner');
|
||||
if (banner) banner.remove();
|
||||
var nextMain = doc.querySelector('main');
|
||||
var base = nextMain.cloneNode(true);
|
||||
var nodes = Array.prototype.slice.call(nextMain.childNodes).map(function (n) { return document.adoptNode(n); });
|
||||
main.replaceChildren.apply(main, nodes);
|
||||
page.base = base;
|
||||
page.map.set(base, main);
|
||||
for (var i = 0; i < nodes.length; i++) pair(base.childNodes[i], nodes[i]);
|
||||
if (banner) main.insertBefore(banner, main.firstChild);
|
||||
|
||||
var scripts = document.getElementById('page-scripts');
|
||||
scripts.replaceChildren.apply(scripts, Array.prototype.slice.call(doc.getElementById('page-scripts').childNodes).map(function (n) { return document.adoptNode(n); }));
|
||||
|
||||
if (window.Prefs && Prefs.apply) Prefs.apply(main);
|
||||
if (options.keep) restoreInputs(options.keep);
|
||||
if (window.Crumbs && Crumbs.start) Crumbs.start(options.from || '');
|
||||
var hash = new URL(url, location.href).hash;
|
||||
var target = hash ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
|
||||
var y = options.scroll, landed = 0;
|
||||
var y = options.keep ? options.keep.scroll : options.scroll, landed = 0;
|
||||
if (typeof y === 'number') {
|
||||
// The page gets its full height once its data is in: hold enough height until then to scroll back to y
|
||||
main.style.minHeight = Math.max(0, y + window.innerHeight - (main.getBoundingClientRect().top + window.scrollY)) + 'px';
|
||||
@@ -381,9 +528,10 @@
|
||||
landed = window.scrollY;
|
||||
} else if (target) target.scrollIntoView();
|
||||
else window.scrollTo(0, 0);
|
||||
main.focus({ preventScroll: true });
|
||||
if (!options.keep) main.focus({ preventScroll: true });
|
||||
|
||||
return runScripts(scriptsIn(main).concat(scriptsIn(scripts))).then(function () {
|
||||
if (options.keep) restoreTabs(options.keep);
|
||||
if (window.fitLayout) fitLayout();
|
||||
try { document.dispatchEvent(new CustomEvent('dash:page')); } catch (e) { console.error(e); }
|
||||
if (typeof y === 'number') {
|
||||
@@ -468,7 +616,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
window.Nav = { go: go };
|
||||
/**
|
||||
* Update this page in place with what the server shows now (see the top). options.force: swap the page in again
|
||||
* even while the user is busy.
|
||||
*/
|
||||
var refreshing = null, refreshAgain = false;
|
||||
function refresh(options) {
|
||||
options = options || {};
|
||||
if (currentIsHard()) { window.location.reload(); return Promise.resolve(); }
|
||||
if (refreshing) { refreshAgain = true; return refreshing; }
|
||||
var url = location.href, at = sequence;
|
||||
refreshing = fetchPage(url).then(function (r) {
|
||||
if (at !== sequence || location.href !== url) return; // went elsewhere meanwhile
|
||||
if (!r.doc || unusable(r.doc)) { window.location.reload(); return; }
|
||||
var ops = page.base ? plan(page.base, r.doc.querySelector('main'), [], true) : null;
|
||||
if (ops) {
|
||||
ops.forEach(function (op) { op(); });
|
||||
page.base = r.doc.querySelector('main');
|
||||
page.map.set(page.base, main);
|
||||
document.title = r.doc.title;
|
||||
try { document.dispatchEvent(new CustomEvent('dash:refreshed')); } catch (e) { console.error(e); }
|
||||
return;
|
||||
}
|
||||
if (!options.force && window.Live && Live.isBusy && Live.isBusy()) { Live.showStaleBanner(); return; }
|
||||
return swap(r.doc, url, { replace: true, from: rendered, keep: capture() });
|
||||
}).catch(function (e) { console.error(e); }).finally(function () {
|
||||
refreshing = null;
|
||||
if (refreshAgain) { refreshAgain = false; refresh(options); }
|
||||
});
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
window.Nav = { go: go, refresh: refresh };
|
||||
|
||||
// The first page: its server HTML, before its scripts change it (they run after this file)
|
||||
page.base = main.cloneNode(true);
|
||||
pair(page.base, main);
|
||||
|
||||
// ---- Links, forms, back and forward ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user