211 lines
7.8 KiB
JavaScript
211 lines
7.8 KiB
JavaScript
// Persistent left navigation rail. Renders the wiki folder/file tree, expanded
|
|
// to the current page's ancestor chain, and lets the user navigate by clicking
|
|
// folders (links) or open files locally via the companion (a.tree-file, wired
|
|
// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with
|
|
// tree-picker.js but does not reuse its row builder — that one is modal-select
|
|
// behavior, this one is navigation behavior. Hidden in edit mode (the server
|
|
// omits the container).
|
|
|
|
(function () {
|
|
var container = document.querySelector('aside.tree-sidebar');
|
|
if (!container) return;
|
|
|
|
function joinPath(parent, name) {
|
|
if (parent === '/' || parent === '') return '/' + name;
|
|
return parent.replace(/\/+$/, '') + '/' + name;
|
|
}
|
|
|
|
function encodeSegments(p) {
|
|
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
|
}
|
|
|
|
function folderHref(p) {
|
|
if (p === '/' || p === '') return '/';
|
|
return '/' + encodeSegments(p) + '/';
|
|
}
|
|
|
|
function fileHref(p) {
|
|
return '/' + encodeSegments(p);
|
|
}
|
|
|
|
function fetchFolder(path, expandTo) {
|
|
var url = folderHref(path) + '?tree=1';
|
|
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo);
|
|
return fetch(url, { credentials: 'same-origin' }).then(function (r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
});
|
|
}
|
|
|
|
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
|
|
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
|
|
// thus fullPath) are decoded, so we must decode here too or paths containing
|
|
// spaces / non-ASCII never match a row and the chain never expands.
|
|
var activePath = (function () {
|
|
var p = window.location.pathname.replace(/\/+$/, '');
|
|
if (p === '') return '/';
|
|
try { return decodeURIComponent(p); } catch (e) { return p; }
|
|
})();
|
|
var activeRow = null;
|
|
|
|
function disabledRow(text) {
|
|
var row = document.createElement('div');
|
|
row.className = 'tree-row is-disabled';
|
|
row.textContent = text;
|
|
return row;
|
|
}
|
|
|
|
// renderInto fills containerEl with rows for `entries` (children of
|
|
// parentPath). Folders carrying a `children` array (the pre-expanded
|
|
// ancestor chain from ?expandTo) are opened immediately and recurse.
|
|
function renderInto(containerEl, parentPath, entries) {
|
|
if (!entries || entries.length === 0) {
|
|
containerEl.appendChild(disabledRow('(empty)'));
|
|
return;
|
|
}
|
|
entries.forEach(function (entry) {
|
|
var built = buildRow(parentPath, entry);
|
|
containerEl.appendChild(built.row);
|
|
if (built.preExpand) built.open();
|
|
});
|
|
}
|
|
|
|
function buildRow(parentPath, entry) {
|
|
var fullPath = joinPath(parentPath, entry.name);
|
|
var isFolder = entry.kind === 'folder';
|
|
|
|
var row = document.createElement('div');
|
|
row.className = 'tree-row';
|
|
|
|
var chevron = document.createElement('span');
|
|
chevron.className = 'tree-chevron';
|
|
if (isFolder) chevron.textContent = '▸'; // ▸
|
|
else chevron.classList.add('is-leaf');
|
|
row.appendChild(chevron);
|
|
|
|
|
|
var link = document.createElement('a');
|
|
link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file');
|
|
link.textContent = entry.name;
|
|
link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath);
|
|
row.appendChild(link);
|
|
|
|
if (isFolder && fullPath === activePath) {
|
|
row.classList.add('is-active');
|
|
activeRow = row;
|
|
}
|
|
|
|
var preChildren = (isFolder && entry.children) ? entry.children : null;
|
|
var childrenEl = null;
|
|
var loaded = false;
|
|
var isOpen = false;
|
|
|
|
function ensureChildrenEl() {
|
|
if (!childrenEl) {
|
|
childrenEl = document.createElement('div');
|
|
childrenEl.className = 'tree-children';
|
|
}
|
|
return childrenEl;
|
|
}
|
|
|
|
function loadChildren() {
|
|
var el = ensureChildrenEl();
|
|
el.textContent = '';
|
|
el.appendChild(disabledRow('…'));
|
|
fetchFolder(fullPath).then(function (resp) {
|
|
el.textContent = '';
|
|
renderInto(el, fullPath, resp.entries);
|
|
loaded = true;
|
|
}).catch(function () {
|
|
el.textContent = '';
|
|
var err = document.createElement('div');
|
|
err.className = 'tree-row';
|
|
err.textContent = '(failed — tap to retry)';
|
|
err.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
loadChildren();
|
|
});
|
|
el.appendChild(err);
|
|
});
|
|
}
|
|
|
|
function open() {
|
|
if (!isFolder || isOpen) return;
|
|
var el = ensureChildrenEl();
|
|
row.parentNode.insertBefore(el, row.nextSibling);
|
|
chevron.textContent = '▾'; // ▾
|
|
isOpen = true;
|
|
if (!loaded) {
|
|
if (preChildren) {
|
|
renderInto(el, fullPath, preChildren);
|
|
loaded = true;
|
|
} else {
|
|
loadChildren();
|
|
}
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
if (!isFolder || !isOpen) return;
|
|
if (childrenEl && childrenEl.parentNode) {
|
|
childrenEl.parentNode.removeChild(childrenEl);
|
|
}
|
|
chevron.textContent = '▸';
|
|
isOpen = false;
|
|
}
|
|
|
|
// Clicking the name link navigates (folder) or opens the file; clicking
|
|
// anywhere else on the row toggles expansion for folders.
|
|
row.addEventListener('click', function (e) {
|
|
if (e.target.closest('a.tree-name')) return;
|
|
if (!isFolder) return;
|
|
if (isOpen) close(); else open();
|
|
});
|
|
|
|
return { row: row, open: open, preExpand: !!preChildren };
|
|
}
|
|
|
|
function render() {
|
|
container.textContent = '';
|
|
var listing = document.createElement('div');
|
|
container.appendChild(listing);
|
|
listing.appendChild(disabledRow('…'));
|
|
|
|
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) {
|
|
listing.textContent = '';
|
|
renderInto(listing, '/', resp.entries);
|
|
if (activeRow) {
|
|
try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
|
}
|
|
}).catch(function () {
|
|
listing.textContent = '';
|
|
var err = document.createElement('div');
|
|
err.className = 'tree-row';
|
|
err.textContent = '(failed — tap to retry)';
|
|
err.addEventListener('click', function () { render(); });
|
|
listing.appendChild(err);
|
|
});
|
|
}
|
|
|
|
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
|
|
// opens the rail's content in the full-viewport Overlay (overlay.js). The
|
|
// rail container always exists when not editing, so — per the layout spec —
|
|
// the FAB always renders; the overlay auto-closes when a folder/file link
|
|
// inside it navigates. Hidden on desktop by .fab CSS.
|
|
function setupFab() {
|
|
var fab = document.createElement('button');
|
|
fab.type = 'button';
|
|
fab.className = 'btn btn-fab fab fab-tree';
|
|
fab.title = 'Folder tree';
|
|
fab.setAttribute('aria-label', 'Folder tree');
|
|
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>';
|
|
fab.addEventListener('click', function () {
|
|
if (typeof openOverlay === 'function') openOverlay(container);
|
|
});
|
|
document.body.appendChild(fab);
|
|
}
|
|
|
|
render();
|
|
setupFab();
|
|
})();
|