Bundles Phase 2 polish + the user-requested header/breadcrumb work:
- Breadcrumbs replacing the plain currentPath span. Server mode
renders linkified ancestor segments (each <a> navigates to that
directory; the browser fetches browse.html, the new instance
auto-loads the listing). FS-API mode renders the rootHandle name
as a non-link (no ancestor handles to navigate). Both prefix the
path with a 🏠 root icon. Trailing slash + bold-current segment
match common file-explorer conventions.
- Subdued 'Select Directory' button in server mode. Once browse is
serving a real directory listing, the local-folder switcher is
available but visually quiet (btn--subtle: transparent, muted
color). FS-API mode keeps the primary styling (it's how the user
got there). New btn--subtle CSS class added to browse's tree.css.
A refresh button (⟳) appears next to it in both modes; clicking
it re-fetches the current root listing.
- Header consistency: browse now matches archive's header layout
(refresh + help buttons in addition to theme on the right). Help
is a placeholder for future help dialog wiring.
- File preview popup. Click a file row → opens a popup window with
the file rendered. Plain types (PDF, HTML, image) load in
iframes; TIFF + ZIP listings via shared/preview-lib.js's
renderTiff / renderZipListing helpers; text via <pre>; unknown
types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
shift) and middle-click still open the file in a new tab via the
underlying <a target=_blank>. Single popup window is reused
across multiple file clicks (matches archive's UX).
- ZIP inline expansion. .zip files have a chevron and act like
folders in the tree. First expand fetches the zip bytes
(server URL or FS handle or parent-zip read), parses with JSZip
(auto-loaded from CDN), and synthesizes the entry tree. Nested
directories within the zip lazy-expand on demand by re-walking
the cached entry list at the right path prefix. Click on a
zip-entry file opens the preview popup with bytes read from
JSZip. Recursive expand-all skips zip archives by design — they
can be very large, and explicit click-to-expand is safer.
- Extension multi-select filter. Toolbar now has a <select
multiple> populated with extensions present in the current
view. Filter is OR-of-selected; combined with the name filter
it's AND-of-both. Folders pass through (so expanding a folder
whose name doesn't match the ext filter still shows its file
children that do match).
223 lines
8.4 KiB
JavaScript
223 lines
8.4 KiB
JavaScript
// events.js — wires up DOM listeners. Idempotent so app.js can call
|
|
// init() once on load.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
var tree = window.app.modules.tree;
|
|
var loader = window.app.modules.loader;
|
|
// preview module is loaded later (concat order); look it up at
|
|
// call time, not at IIFE-eval time.
|
|
function previewMod() { return window.app.modules.preview; }
|
|
|
|
function status(msg, kind) {
|
|
var el = document.getElementById('statusBar');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error', 'status-bar--info');
|
|
if (kind === 'error') el.classList.add('status-bar--error');
|
|
if (kind === 'info') el.classList.add('status-bar--info');
|
|
}
|
|
|
|
function statusError(msg) { status(msg, 'error'); }
|
|
function statusInfo(msg) { status(msg, 'info'); }
|
|
function statusClear() { status('', null); }
|
|
|
|
async function pickLocalDir() {
|
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
|
|
return;
|
|
}
|
|
var handle;
|
|
try {
|
|
handle = await window.showDirectoryPicker({ mode: 'read' });
|
|
} catch (e) {
|
|
// User cancelled — silent
|
|
return;
|
|
}
|
|
state.source = 'fs';
|
|
state.rootHandle = handle;
|
|
state.currentPath = handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(handle);
|
|
} catch (e) {
|
|
statusError('Failed to read directory: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
showBrowseRoot();
|
|
tree.render();
|
|
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
|
}
|
|
|
|
function showBrowseRoot() {
|
|
var empty = document.getElementById('emptyState');
|
|
var root = document.getElementById('browseRoot');
|
|
if (empty) empty.classList.add('hidden');
|
|
if (root) root.classList.remove('hidden');
|
|
applySourceUI();
|
|
}
|
|
|
|
// Visual state of the "Select Directory" button + the refresh
|
|
// button depends on the source. In server mode the user is
|
|
// already viewing a server-backed listing — Select Directory
|
|
// becomes a quiet "switch to local" affordance (subtle styling),
|
|
// and the refresh button is shown. In FS mode the button is
|
|
// primary (it's how you got here) and refresh is hidden (the
|
|
// listing was already a fresh enumeration).
|
|
function applySourceUI() {
|
|
var add = document.getElementById('addDirectoryBtn');
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (add) {
|
|
if (state.source === 'server') {
|
|
add.classList.remove('btn-primary');
|
|
add.classList.add('btn--subtle');
|
|
} else {
|
|
add.classList.add('btn-primary');
|
|
add.classList.remove('btn--subtle');
|
|
}
|
|
}
|
|
if (refresh) {
|
|
if (state.source) {
|
|
refresh.classList.remove('hidden');
|
|
} else {
|
|
refresh.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshListing() {
|
|
if (state.source === 'server') {
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchServerChildren(state.currentPath);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
tree.render();
|
|
statusInfo('Refreshed (' + raw.length + ' item'
|
|
+ (raw.length === 1 ? '' : 's') + ')');
|
|
} else if (state.source === 'fs' && state.rootHandle) {
|
|
var raw2;
|
|
try {
|
|
raw2 = await loader.fetchFsChildren(state.rootHandle);
|
|
} catch (e) {
|
|
statusError('Refresh failed: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw2);
|
|
tree.render();
|
|
statusInfo('Refreshed');
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
// Header buttons
|
|
var btn = document.getElementById('addDirectoryBtn');
|
|
if (btn) btn.addEventListener('click', pickLocalDir);
|
|
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
|
|
|
// Filter input
|
|
var filter = document.getElementById('filterInput');
|
|
if (filter) {
|
|
filter.addEventListener('input', function () {
|
|
tree.setFilter(filter.value);
|
|
});
|
|
}
|
|
|
|
// Extension multi-select
|
|
var extSel = document.getElementById('extFilter');
|
|
if (extSel) {
|
|
extSel.addEventListener('change', function () {
|
|
var picked = [];
|
|
for (var i = 0; i < extSel.options.length; i++) {
|
|
if (extSel.options[i].selected) picked.push(extSel.options[i].value);
|
|
}
|
|
tree.setExtFilter(picked);
|
|
});
|
|
}
|
|
|
|
// Sort headers
|
|
var ths = document.querySelectorAll('#browseTable thead th.sortable');
|
|
for (var i = 0; i < ths.length; i++) {
|
|
(function (th) {
|
|
th.addEventListener('click', function () {
|
|
tree.setSort(th.dataset.sort);
|
|
});
|
|
})(ths[i]);
|
|
}
|
|
|
|
// Tree-row clicks (event delegation on tbody).
|
|
// Click semantics on a folder row:
|
|
// - plain click → toggle just this folder
|
|
// - shift-click → recursive expand/collapse of the whole
|
|
// subtree (matches common file-explorer
|
|
// convention; e.g. Finder, VSCode tree,
|
|
// Windows Explorer)
|
|
// - alt-click → ALSO recursive (alt is sometimes the
|
|
// expand-all key on Linux DEs; bind both
|
|
// so muscle memory works either way)
|
|
// File rows: let the <a> tag's natural target=_blank do its
|
|
// job — don't intercept.
|
|
var tbody = document.getElementById('browseTbody');
|
|
if (tbody) {
|
|
tbody.addEventListener('click', function (e) {
|
|
var row = e.target.closest('tr.tree-row');
|
|
if (!row) return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
|
|
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
|
var clickedChevron = !!e.target.closest('.tree-name__chevron');
|
|
|
|
if (isExpandable) {
|
|
// For folders + zips: click anywhere on the row
|
|
// toggles. Modifier-click → recursive expand.
|
|
e.preventDefault();
|
|
if (e.shiftKey || e.altKey) {
|
|
if (node.expanded) tree.collapseSubtree(id);
|
|
else tree.expandSubtree(id);
|
|
} else {
|
|
tree.toggleFolder(id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Plain file row.
|
|
// Modifier-click (ctrl/cmd) and middle-click → fall
|
|
// through to the <a> tag's natural target=_blank
|
|
// behavior (open in new tab). For server-backed
|
|
// files, that opens the real URL via zddc-server.
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
|
return;
|
|
}
|
|
// Plain click → preview popup. Intercept default nav.
|
|
e.preventDefault();
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(node);
|
|
});
|
|
|
|
// Middle-click (auxclick) — same fall-through logic.
|
|
tbody.addEventListener('auxclick', function (e) {
|
|
if (e.button !== 1) return; // middle only
|
|
// Browser handles target=_blank natively for middle
|
|
// click; don't preventDefault, just don't intercept.
|
|
});
|
|
}
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.events = {
|
|
init: init,
|
|
statusError: statusError,
|
|
statusInfo: statusInfo,
|
|
statusClear: statusClear,
|
|
showBrowseRoot: showBrowseRoot
|
|
};
|
|
})();
|