feat(nav): partial HTMX navigation — swap main content without full reload
Intercept internal links and load only .main-wrapper via htmx.ajax, keeping the sidebar/header shell in place. Adds history pushState/popstate handling, sidebar active-state resync and document title update. Moves page script blocks inside #main-content so they run on swap, and makes editor/board scripts idempotent. Routes navigateTo/breadcrumb/library/local-workspace through window.fdNavigate and fixes the duplicated navigateTo definition.
This commit is contained in:
@@ -233,3 +233,141 @@
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
// ── Partial navigation (HTMX) ───────────────────────────────
|
||||
// The shell (sidebar + topbar) stays in place and only `.main-wrapper`
|
||||
// is swapped, so clicking the left navigation no longer reloads the
|
||||
// whole document. Internal links are intercepted and loaded with
|
||||
// htmx.ajax(); history is managed manually so the back button works.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function isPlainLeftClick(evt) {
|
||||
return evt.button === 0 && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey && !evt.altKey;
|
||||
}
|
||||
|
||||
// Returns true when a link should be handled by the partial navigator.
|
||||
// External links, downloads, auth, API/static files and links already
|
||||
// managed by Alpine or HTMX keep their default behaviour.
|
||||
function shouldIntercept(a, evt) {
|
||||
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
|
||||
if (a.hasAttribute('data-no-nav')) return false;
|
||||
var target = (a.getAttribute('target') || '').toLowerCase();
|
||||
if (target && target !== '_self') return false;
|
||||
if (a.hasAttribute('download')) return false;
|
||||
if (a.getAttribute('hx-boost') === 'false') return false;
|
||||
var attrs = a.getAttributeNames();
|
||||
for (var i = 0; i < attrs.length; i++) {
|
||||
var n = attrs[i];
|
||||
if (n.indexOf('@click') === 0 || n.indexOf('x-on:click') === 0) return false;
|
||||
if (n.indexOf('hx-') === 0) return false;
|
||||
}
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (!href || href.charAt(0) === '#') return false;
|
||||
if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
|
||||
var u;
|
||||
try { u = new URL(a.href, window.location.href); } catch { return false; }
|
||||
if (u.origin !== window.location.origin) return false;
|
||||
var p = u.pathname;
|
||||
if (p === '/auth' || p.indexOf('/auth/') === 0) return false;
|
||||
if (p.indexOf('/api/') === 0) return false;
|
||||
if (p.indexOf('/static/') === 0) return false;
|
||||
if (p === '/p' || p.indexOf('/p/') === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
var loading = false;
|
||||
|
||||
function fdLoad(url, push) {
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
document.documentElement.classList.add('fd-navigating');
|
||||
function done() {
|
||||
loading = false;
|
||||
document.documentElement.classList.remove('fd-navigating');
|
||||
}
|
||||
var p;
|
||||
try {
|
||||
p = window.htmx.ajax('GET', url, {
|
||||
target: '.main-wrapper',
|
||||
select: '.main-wrapper',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
} catch {
|
||||
done();
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
if (p && typeof p.then === 'function') {
|
||||
p.then(function() {
|
||||
done();
|
||||
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
||||
}).catch(function() {
|
||||
done();
|
||||
window.location.href = url;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(evt) {
|
||||
var a = evt.target && evt.target.closest ? evt.target.closest('a[href]') : null;
|
||||
if (!shouldIntercept(a, evt)) return;
|
||||
evt.preventDefault();
|
||||
fdLoad(a.href, true);
|
||||
});
|
||||
|
||||
// Back/forward buttons reload the content for the target URL.
|
||||
window.addEventListener('popstate', function() {
|
||||
fdLoad(window.location.href, false);
|
||||
});
|
||||
|
||||
// Programmatic navigation used by Alpine helpers.
|
||||
window.fdNavigate = function(url, opts) {
|
||||
opts = opts || {};
|
||||
if (!url) return;
|
||||
if (opts.full || !window.htmx) { window.location.href = url; return; }
|
||||
fdLoad(url, true);
|
||||
};
|
||||
|
||||
function syncAfterSwap(xhr) {
|
||||
try {
|
||||
if (xhr && xhr.responseText) {
|
||||
var doc = new DOMParser().parseFromString(xhr.responseText, 'text/html');
|
||||
var title = doc.querySelector('title');
|
||||
if (title && title.textContent) document.title = title.textContent;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
var path = window.location.pathname;
|
||||
var search = window.location.search;
|
||||
|
||||
document.querySelectorAll('.sidebar-item.active').forEach(function(el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.sidebar-item').forEach(function(el) {
|
||||
var href = el.getAttribute('href');
|
||||
var a = el.querySelector('a[href]');
|
||||
if (a) href = a.getAttribute('href');
|
||||
if (href && (href === path || href === path + search)) el.classList.add('active');
|
||||
var pid = el.getAttribute('data-page-id');
|
||||
if (pid && ('/pages/' + String(pid).replace('page/', '')) === path) el.classList.add('active');
|
||||
var dbid = el.getAttribute('data-id');
|
||||
if (dbid && search.indexOf('folder=' + dbid) !== -1) el.classList.add('active');
|
||||
});
|
||||
|
||||
if (window.appState) {
|
||||
var m = search.match(/[?&]folder=(\d+)/);
|
||||
window.appState.activeNodeId = m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
syncAfterSwap(evt.detail && evt.detail.xhr);
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user