// 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() { // Snapshot expanded paths + selection BEFORE setRoot clears the // tree, then re-apply after the new root is in place. Keeps // the user's layout (which folders were open, which row was // highlighted, what the preview was pinned to) stable across // a refresh — including the auto-refresh triggered by the // "Show hidden files" toggle. var snap = tree.snapshotState(); if (state.source === 'server') { var raw; try { raw = await loader.fetchServerChildren(state.currentPath); } catch (e) { statusError('Refresh failed: ' + e.message); return; } tree.setRoot(raw); await tree.restoreState(snap); 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); await tree.restoreState(snap); 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); // Tree autofilter — parses input through zddc.filter.parse so // the same query grammar that the archive app uses (terms, // quotes, !negation, multi-word AND) works here. The AST is // cached on state.filterAST; tree.render reads it and skips // non-matching rows. Escape clears. var filterInput = document.getElementById('treeFilter'); if (filterInput) { var filterDebounce = null; var applyFilter = function () { var raw = filterInput.value || ''; state.filterText = raw; state.filterAST = raw ? window.zddc.filter.parse(raw) : null; filterInput.classList.toggle('filter-active', !!raw); tree.render(); }; filterInput.addEventListener('input', function () { if (filterDebounce) clearTimeout(filterDebounce); filterDebounce = setTimeout(applyFilter, 80); }); filterInput.addEventListener('keydown', function (e) { if (e.key === 'Escape' && filterInput.value) { e.preventDefault(); filterInput.value = ''; applyFilter(); } }); } // No view-mode buttons; mode is derived from the URL on every // scope change (resolveViewMode below). Pass-through for the // initial path. applyResolvedViewMode(); // 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); }); // Right-click → context menu. Two surfaces: // - on a tree row: per-row menu (Open, Rename, Delete, …) // - on empty space in the pane: directory-scope menu // (New folder, Refresh, Sort by, …) treeBody.addEventListener('contextmenu', function (e) { e.preventDefault(); var row = e.target.closest('.tree-row'); if (row) { var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; state.selectedId = id; tree.render(); window.zddc.menu.open({ x: e.clientX, y: e.clientY, context: { node: node, row: row }, items: buildTreeRowMenu }); } else { window.zddc.menu.open({ x: e.clientX, y: e.clientY, context: { dir: state.currentPath || '/' }, items: buildPaneMenu }); } }); // Per-row drag-drop. Any row is a drop target — folders // upload into themselves; files upload into their parent // folder. Highlighting is purely visual; server-side ACL // is the source of truth (a 403 surfaces as an error toast). wirePerRowDrop(treeBody); } } // ── Per-row drag/drop targets ───────────────────────────────────────── // Translate a node into the directory that should receive uploads // dropped onto its row. Folders → themselves; files → their parent. // Returns a server path with a trailing slash, or null when there's // no usable destination (offline mode, virtual node, etc.). function targetDirForNode(node) { if (!node || node.virtual) return null; if (state.source !== 'server') return null; if (node.isZip) return null; // can't upload INTO a zip via PUT var dirNode = node; if (!node.isDir) { if (node.parentId == null) { // Top-level file → upload to current scope. return state.currentPath || '/'; } dirNode = state.nodes.get(node.parentId); if (!dirNode) return null; } var p = tree.pathFor(dirNode); if (!p.endsWith('/')) p += '/'; return p; } function dragHasFiles(e) { if (!e.dataTransfer || !e.dataTransfer.types) return false; var types = e.dataTransfer.types; for (var i = 0; i < types.length; i++) { if (types[i] === 'Files') return true; } return false; } function wirePerRowDrop(treeBody) { var lastOver = null; function clearHighlight() { if (lastOver) { lastOver.classList.remove('is-droptarget'); lastOver = null; } } treeBody.addEventListener('dragover', function (e) { if (!dragHasFiles(e)) return; var row = e.target.closest('.tree-row'); if (!row) { clearHighlight(); return; } var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; var dest = targetDirForNode(node); if (!dest) { if (e.dataTransfer) e.dataTransfer.dropEffect = 'none'; clearHighlight(); return; } e.preventDefault(); // signals "this is a drop target" e.stopPropagation(); // suppress doc-level overlay if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (lastOver !== row) { clearHighlight(); row.classList.add('is-droptarget'); lastOver = row; } }); treeBody.addEventListener('dragleave', function (e) { // dragleave fires on row crossings too — only clear when the // pointer actually leaves the tree body. if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) { clearHighlight(); } }); treeBody.addEventListener('drop', async function (e) { if (!dragHasFiles(e)) return; var row = e.target.closest('.tree-row'); clearHighlight(); if (!row) return; var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; var dest = targetDirForNode(node); if (!dest) return; e.preventDefault(); e.stopPropagation(); // pre-empt doc-level handler var up = window.app.modules.upload; if (!up) return; try { await up.uploadToDir(dest, e.dataTransfer); } catch (err) { statusError('Upload failed: ' + (err.message || err)); } }); } // ── Create new folder / file (server mode) ──────────────────────────── // Reject names with path separators, leading dots, or empty input — // mirrors the server-side hidden-segment / no-traversal guards so // the user sees the rejection without a round-trip. function validateName(name) { name = (name || '').trim(); if (!name) return { ok: false, msg: 'Name required.' }; if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' }; if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' }; if (name.charAt(0) === '.' || name.charAt(0) === '_') { return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' }; } return { ok: true, name: name }; } // Resolve "the directory new items go into" for a given row. // Folders/zips: create inside them. Files: create alongside (in // their parent). Used by the row-context New menu items. function parentDirFor(node) { var parentDir; if (!node) { parentDir = state.currentPath || '/'; } else if (node.isDir || node.isZip) { parentDir = tree.pathFor(node); } else if (node.parentId != null) { var parent = state.nodes.get(node.parentId); parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/'); } else { parentDir = state.currentPath || '/'; } if (!parentDir.endsWith('/')) parentDir += '/'; return parentDir; } async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; var promptMsg = kind === 'folder' ? 'New folder name (under ' + parentDir + '):' : 'New markdown filename (under ' + parentDir + '):'; var defaultName = kind === 'folder' ? 'new-folder' : 'new.md'; var raw = window.prompt(promptMsg, defaultName); if (raw == null) return; var v = validateName(raw); if (!v.ok) { statusError(v.msg); return; } try { if (kind === 'folder') { await up.makeDir(parentDir, v.name); statusInfo('Created folder ' + v.name); } else { var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md'; var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8'); statusInfo('Created ' + name); } await reloadDir(parentDir); } catch (e) { statusError('Create failed: ' + (e.message || e)); } } function createInside(node, kind) { return createInDir(parentDirFor(node), kind); } // Reload a directory's children in the tree so a create/delete/ // rename is reflected. Works for both the current scope (root) // and any expanded subdirectory. async function reloadDir(dirPath) { var loader = window.app.modules.loader; if (!loader) return; if (!dirPath.endsWith('/')) dirPath += '/'; // Root-scope reload — refresh the visible top-level listing. if (dirPath === state.currentPath) { try { var es = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); tree.setRoot(es); } catch (_e) { /* swallow */ } tree.render(); return; } // Otherwise find the node whose path matches and reload it. var noSlash = dirPath.replace(/\/$/, ''); var hit = null; state.nodes.forEach(function (n) { if (hit || !n.isDir) return; if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; }); if (hit) { try { var raw = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); tree.setChildren(hit.id, raw); hit.expanded = true; } catch (_e) { /* swallow */ } tree.render(); } } // ── Rename / Delete ─────────────────────────────────────────────────── async function renameNode(node) { var up = window.app.modules.upload; if (!up || !up.canMutate(node)) return; var raw = window.prompt('Rename "' + node.name + '" to:', node.name); if (raw == null) return; var v = validateName(raw); if (!v.ok) { statusError(v.msg); return; } if (v.name === node.name) return; try { await up.renameNode(node, v.name); statusInfo('Renamed to ' + v.name); var parentPath = node.parentId != null ? tree.pathFor(state.nodes.get(node.parentId)) : (state.currentPath || '/'); await reloadDir(parentPath); } catch (e) { statusError('Rename failed: ' + (e.message || e)); } } async function deleteNode(node) { var up = window.app.modules.upload; if (!up || !up.canMutate(node)) return; var what = node.isDir ? 'folder' : 'file'; // Native confirm() is intentional — destructive actions // benefit from the browser's blocking, OS-styled dialog // (signals "this is serious"). A custom modal would look // friendlier; we want it to NOT look friendly. var msg = 'Permanently delete this ' + what + '?\n\n' + node.name; if (node.isDir) { msg += '\n\nThis will remove every file inside it.'; } if (!window.confirm(msg)) return; try { await up.removeNode(node); statusInfo('Deleted ' + node.name); // Clear selection / preview when they pointed at the // now-gone node, so the right pane doesn't keep a ghost. if (state.selectedId === node.id) state.selectedId = null; if (state.lastPreviewedNodeId === node.id) { state.lastPreviewedNodeId = null; var pb = document.getElementById('previewBody'); if (pb) pb.innerHTML = '