// 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); // Sort dropdown — change → tree re-renders with the new sort. // Format of option value: ":". Defaults match // state.sort initial values (name:asc). var sortSel = document.getElementById('sortBy'); if (sortSel) { sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc'); sortSel.addEventListener('change', function () { var parts = sortSel.value.split(':'); var key = parts[0]; var dir = parts[1] === 'desc' ? -1 : 1; tree.setSortExplicit(key, dir); }); } // View-mode toggle (Browse vs Grid) var btnBrowse = document.getElementById('viewModeBrowse'); var btnGrid = document.getElementById('viewModeGrid'); if (btnBrowse) btnBrowse.addEventListener('click', function () { setViewMode('browse'); }); if (btnGrid) btnGrid.addEventListener('click', function () { setViewMode('grid'); }); // Pop-out preview button — opens the current preview in a separate window. var popout = document.getElementById('previewPopout'); if (popout) popout.addEventListener('click', function () { var p = previewMod(); if (p && state.lastPreviewedNodeId != null) { var n = state.nodes.get(state.lastPreviewedNodeId); if (n) p.showFilePreview(n, { popup: true }); } }); // Pane resizer (tree pane width). Drag horizontally; clamps to // [180, 60% of viewport]. State stays in-memory only — refresh // resets to the default 360px. var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]'); var treePane = document.getElementById('treePane'); if (resizer && treePane) { var dragging = false; var startX = 0; var startWidth = 0; resizer.addEventListener('mousedown', function (e) { dragging = true; resizer.classList.add('is-dragging'); startX = e.clientX; startWidth = treePane.getBoundingClientRect().width; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var dx = e.clientX - startX; var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx)); treePane.style.width = w + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; resizer.classList.remove('is-dragging'); }); } // Tree-row clicks (event delegation on the tree body). // Click semantics on a folder row: // - plain click → toggle expand (deferred so dblclick wins) // - shift-click → recursive expand/collapse of the subtree // - alt-click → ALSO recursive // - dblclick → navigate into the folder // File rows: plain click → preview in right pane; modifier-click // and middle-click open in new tab. // // The plain-click toggle for folders is intentionally deferred // via setTimeout. Reason: toggling re-renders the tree, which // replaces the clicked row element. The browser detects a // double-click only when the second click lands on the same // target element as the first; replacing the row breaks that // continuity and the dblclick event never fires. The deferred // toggle lets a pending dblclick cancel it. var pendingFolderToggle = null; var treeBody = document.getElementById('treeBody'); if (treeBody) { treeBody.addEventListener('click', function (e) { var row = e.target.closest('.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'; if (isExpandable) { e.preventDefault(); if (e.shiftKey || e.altKey) { // Modifier-click skips the dblclick race — it's // an explicit recursive toggle, never followed // by a dblclick. if (node.expanded) tree.collapseSubtree(id); else tree.expandSubtree(id); return; } // ZIPs don't navigate-into; toggle immediately. if (row.dataset.iszip === 'true') { tree.toggleFolder(id); return; } // Folder: defer the toggle so a pending dblclick // can pre-empt it. if (pendingFolderToggle) { clearTimeout(pendingFolderToggle.timer); } pendingFolderToggle = { id: id, timer: setTimeout(function () { pendingFolderToggle = null; tree.toggleFolder(id); }, 220) }; return; } // File row: modifier-click → open URL in new tab if // available (server mode preserves the original URL, // useful for direct download / sharing). if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) { if (node.url) window.open(node.url, '_blank', 'noopener'); return; } // Plain click → preview in the right pane. e.preventDefault(); state.selectedId = id; state.lastPreviewedNodeId = id; tree.render(); // refresh selection highlight var p = previewMod(); if (p) p.showFilePreview(node); }); // Double-click on a folder → "navigate into" it. Distinct // from single-click (which expands inline) so users keep // both UX models. Server mode jumps to the folder URL — // zddc-server returns a fresh browse instance scoped to // that directory. FS-API mode swaps state.rootHandle to // the folder's handle and re-loads, so the user sees // only that subtree at the root level. // // Files: dblclick is left alone — the single-click preview // is already a "look at this file" action; a separate // navigate-into doesn't apply. // ZIPs: skipped too — they're inspected via inline // expansion (JSZip), not navigated into. treeBody.addEventListener('dblclick', function (e) { var row = e.target.closest('.tree-row'); if (!row) return; if (row.dataset.isdir !== 'true') return; var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; e.preventDefault(); // Pre-empt the deferred single-click toggle so the user // doesn't see a flicker of expand/collapse before nav. if (pendingFolderToggle) { clearTimeout(pendingFolderToggle.timer); pendingFolderToggle = null; } navigateIntoFolder(node); }); } } function setViewMode(mode) { state.viewMode = mode; var browseView = document.getElementById('browseView'); var gridView = document.getElementById('gridView'); var btnBrowse = document.getElementById('viewModeBrowse'); var btnGrid = document.getElementById('viewModeGrid'); if (mode === 'grid') { if (browseView) browseView.classList.add('hidden'); if (gridView) gridView.classList.remove('hidden'); if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'false'); if (btnGrid) btnGrid.setAttribute('aria-selected', 'true'); // Lazily mount classifier on first activation. var grid = window.app.modules.grid; if (grid && grid.activate) grid.activate(); } else { if (browseView) browseView.classList.remove('hidden'); if (gridView) gridView.classList.add('hidden'); if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'true'); if (btnGrid) btnGrid.setAttribute('aria-selected', 'false'); } } async function navigateIntoFolder(node) { if (state.source === 'server') { var url = window.app.modules.tree.pathFor(node); if (!url.endsWith('/')) url += '/'; window.location.assign(url); return; } if (state.source === 'fs') { if (!node.handle || node.handle.kind !== 'directory') return; state.rootHandle = node.handle; state.currentPath = node.handle.name + '/'; var raw; try { raw = await loader.fetchFsChildren(node.handle); } catch (e) { statusError('Failed to enter ' + node.name + ': ' + e.message); return; } tree.setRoot(raw); tree.render(); statusInfo('Entered ' + node.name); } } // Public API window.app.modules.events = { init: init, statusError: statusError, statusInfo: statusInfo, statusClear: statusClear, showBrowseRoot: showBrowseRoot }; })();