diff --git a/dDashboardServer/static/js/dashboard.js b/dDashboardServer/static/js/dashboard.js index 6bfd06dd5..2f85bb82e 100644 --- a/dDashboardServer/static/js/dashboard.js +++ b/dDashboardServer/static/js/dashboard.js @@ -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 () { diff --git a/dDashboardServer/static/js/nav.js b/dDashboardServer/static/js/nav.js index bcf7d7515..fbd24e6d1 100644 --- a/dDashboardServer/static/js/nav.js +++ b/dDashboardServer/static/js/nav.js @@ -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 ---- diff --git a/docs/Dashboard.md b/docs/Dashboard.md index f6c7a4030..6500f90dd 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -360,8 +360,9 @@ reports are kept as the record of what happened. ## Live updates Pages update on their own: world servers tell the dashboard (through master) as soon as they write something it shows, -and it pushes that to open browsers. Pages about one thing (a character, an account) reload in place when it changes, -unless you're typing, in which case they offer a refresh instead. +and it pushes that to open browsers. Pages about one thing (a character, an account) update in place when it changes: +only the parts the server now shows differently change, and what you're typing stays. When the page can't be patched +that way (its layout changed) and you're typing, it offers a refresh instead. Moving between pages doesn't reload the dashboard: the next page is fetched and swapped in, and the menu, the top bar and the live connection stay. A thin bar at the top shows while it loads; if it can't be fetched, the page says so with @@ -372,8 +373,9 @@ them load normally. Leaving a page with unsaved changes (Settings, Vanity) asks For page scripts (`static/js/nav.js`): listeners a page adds to `document` or `window`, its `setInterval` timers, its `Live` watchers and its DataTables are removed when another page is swapped in; its scripts run again when it's opened again, and `DOMContentLoaded`/`load` handlers they add run once they have all run. Elements a page appends to `
` -are removed unless marked `data-nav-keep`. `Nav.go(url)` opens a page. `document` gets `dash:page` after a page is -swapped in and `dash:leave` before. A page that must always load on its own adds `data-nav="reload"` to any element; a link with `data-nav="off"` +are removed unless marked `data-nav-keep`. `Nav.go(url)` opens a page, `Nav.refresh()` updates the current one in place. +`document` gets `dash:page` after a page is swapped in, `dash:leave` before, and `dash:refreshed` after an in-place +update. A page that must always load on its own adds `data-nav="reload"` to any element; a link with `data-nav="off"` always loads normally. View choices you make on the pages (show staff, filters, the 3D viewer's switches, ...) are saved to your account, so diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 6dd167f7f..496a42712 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -94,7 +94,7 @@ if(NODE_EXECUTABLE) # The sidebar's saved group state (the script at the end of templates/header.jinja2) add_test(NAME SidebarStateJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/sidebar-state.test.mjs" "${PROJECT_SOURCE_DIR}/dDashboardServer/templates/header.jinja2") - # Which links the dashboard follows without a reload (static/js/nav.js) + # Which links the dashboard follows without a reload, and its in-place update rules (static/js/nav.js) add_test(NAME NavRulesJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/nav.test.mjs" "${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/nav.js") endif() diff --git a/tests/dWebTests/nav.test.mjs b/tests/dWebTests/nav.test.mjs index a409ff7c5..0f4049d92 100644 --- a/tests/dWebTests/nav.test.mjs +++ b/tests/dWebTests/nav.test.mjs @@ -1,4 +1,4 @@ -// Which links the dashboard follows without a reload (static/js/nav.js). +// Which links the dashboard follows without a reload, and the rules its in-place updates use (static/js/nav.js). // Run by ctest: node nav.test.mjs