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:
@@ -247,7 +247,11 @@ document.addEventListener('alpine:init', function () {
|
||||
this.activeItems = [];
|
||||
},
|
||||
|
||||
go: function (url) { if (url) window.location.href = url; }
|
||||
go: function (url) {
|
||||
if (!url) return;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,9 @@ document.addEventListener('alpine:init', function () {
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
destroy() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
|
||||
@@ -37,6 +37,19 @@ window.__fdRT = (function () {
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
@@ -459,9 +472,14 @@ window.__fdRT = (function () {
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
|
||||
window.addEventListener('scroll', scheduleDraw, true);
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</script>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
if (!window.__fdEditorScriptsLoaded) {
|
||||
window.__fdEditorScriptsLoaded = true;
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
@@ -2120,4 +2122,6 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
window.editorState = editorState;
|
||||
}
|
||||
</script>
|
||||
|
||||
+13
-13
@@ -211,7 +211,7 @@
|
||||
<div class="um-sep"></div>
|
||||
<div class="um-version">FlowDeck v{{ app_version }}</div>
|
||||
<div class="um-sep"></div>
|
||||
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
{% else %}
|
||||
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
|
||||
{% endif %}
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -598,6 +598,7 @@
|
||||
<!-- Content -->
|
||||
<div class="content-area" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1255,11 +1256,6 @@
|
||||
toggleWorkspaceMenu() {
|
||||
this.workspaceMenuOpen = !this.workspaceMenuOpen;
|
||||
},
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
||||
|
||||
// ── Tree state ──
|
||||
@@ -1328,12 +1324,15 @@
|
||||
},
|
||||
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarCollapsed = true;
|
||||
// Preserve workspace context when navigating to library/trash
|
||||
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
|
||||
const parts = this.workspaceKey.split('/');
|
||||
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
showContextMenu(event, pageId, pageName) {
|
||||
@@ -1669,7 +1668,8 @@
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
|
||||
else window.location = `/pages/${pageId}`;
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
@@ -1689,7 +1689,8 @@
|
||||
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
|
||||
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
|
||||
} catch(e) {}
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
|
||||
else window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
createEmptyPage() {
|
||||
@@ -1841,8 +1842,7 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v=2.4.6" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="/static/js/app.js?v=2.4.7" defer></script>
|
||||
|
||||
<style>
|
||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||
@@ -2034,7 +2034,7 @@
|
||||
function choose(i){
|
||||
var it = state.items[i]; if(!it) return;
|
||||
if(it.run){ var ok = it.run(); if(ok) close(); return; }
|
||||
if(it.url){ window.location.href=it.url; close(); return; }
|
||||
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
|
||||
}
|
||||
|
||||
function onKey(e){
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -1303,7 +1303,8 @@ function libraryPage() {
|
||||
// ── Item actions ──
|
||||
openItem(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.location.href = '/pages/' + item.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
|
||||
else window.location.href = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
startRename(item) {
|
||||
@@ -1361,7 +1362,8 @@ function libraryPage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/' + d.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
|
||||
@@ -867,7 +867,7 @@ window._wsInitData = (function() {
|
||||
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
document.getElementById('ws-preview-title').textContent = node.name;
|
||||
var openBtn = document.getElementById('ws-preview-open-btn');
|
||||
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
||||
openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else window.location = '/pages/' + node.id; };
|
||||
var favBtn = document.getElementById('ws-preview-fav-btn');
|
||||
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
|
||||
var copyBtn = document.getElementById('ws-preview-copy-btn');
|
||||
@@ -1248,7 +1248,7 @@ window._wsInitData = (function() {
|
||||
} else {
|
||||
// Click to rename, double-click to open
|
||||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||||
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' ondblclick="window.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' title="Click to rename, double-click to open"'
|
||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
||||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
|
||||
@@ -1640,11 +1640,12 @@ window._wsInitData = (function() {
|
||||
navigateToPath() {
|
||||
var path = (this.$el.querySelector('.path-input') || {}).value || '';
|
||||
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
|
||||
if (parts.length === 0) { window.location = '/local-workspace'; return; }
|
||||
if (parts.length === 0) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
|
||||
// Walk tree to find matching folder
|
||||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||||
if (targetId) {
|
||||
window.location = '/local-workspace?folder=' + targetId;
|
||||
if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
|
||||
else window.location = '/local-workspace?folder=' + targetId;
|
||||
} else {
|
||||
window.showToast('Path not found: ' + parts.join('/'), 'error');
|
||||
}
|
||||
@@ -1963,7 +1964,7 @@ window._wsInitData = (function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
openPage(id) { window.location = '/pages/'+id; },
|
||||
openPage(id) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else window.location = '/pages/' + id; },
|
||||
openSidePeek(id) {
|
||||
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
|
||||
if (node) this.selectForPreview(node);
|
||||
|
||||
@@ -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