+function resultItemHtml(item) {
+ const titleHtml = `${highlightText(item.File, STATE.query)}
`;
+ const pendingSnippet = STATE.fulltext && item.snippet_complete !== true;
+ const snippetHighlights = Array.isArray(item.snippet_highlights) ? item.snippet_highlights : null;
+ const snippet = pendingSnippet && !item.snippet
+ ? '
'
+ : STATE.fulltext ? highlightText(item.snippet, STATE.query, snippetHighlights) : escapeHTML(item.snippet || "");
+ const snippetHtml = `
+
+
${ICON_HTML.file}
+
+
${titleHtml}
+
${resultPathHtml(item)}
+
${escapeHTML(formatSize(item.Size))}
-
`;
- }
- DOM.resultsList.innerHTML = html;
}
-function clearResultsSkeleton() {
- DOM.resultsList.querySelectorAll(".result-skeleton-item").forEach((row) => row.remove());
+function appendResults(items) {
+ if (!items.length) return;
+ DOM.emptyState.style.display = "none";
+ DOM.resultsList.insertAdjacentHTML("beforeend", items.map(resultItemHtml).join(""));
+ DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none";
+ observeResultSnippets();
}
-function renderResults(animate = false) {
- pendingResultEntrance = false;
- clearResultsSkeleton();
- if (STATE.results.length === 0) {
+function renderResults({ animate = false } = {}) {
+ resetSnippetObserver();
+ if (!STATE.results.length) {
DOM.resultsList.innerHTML = "";
- resetVirtualScrollState();
DOM.emptyState.style.display = "flex";
- DOM.emptyDesc.textContent = STATE.query ? `没有找到与 "${STATE.query}" 相关的结果` : "暂无数据";
- DOM.multiToggleLabel.style.display = "none";
+ DOM.emptyDesc.textContent = STATE.query ? `没有找到与“${STATE.query}”相关的结果` : "暂无数据";
DOM.multiActionBar.style.display = "none";
return;
}
DOM.emptyState.style.display = "none";
- DOM.multiToggleLabel.style.display = "";
- ensureResultTemplateCache();
- ensureVirtualHeights(STATE.results.length);
- VSCROLL.renderStart = 0;
- VSCROLL.renderEnd = 0;
- pendingResultEntrance = animate;
- renderVisible();
-}
-
-function animateVisibleResultRows() {
- let order = 0;
- DOM.resultsList.querySelectorAll(".result-item[data-index]").forEach((row) => {
- if (Number(row.dataset.index) >= 30) return;
- row.style.setProperty("--result-enter-delay", `${order * 3}ms`);
+ DOM.resultsList.innerHTML = STATE.results.map(resultItemHtml).join("");
+ if (animate) animateResultRows(Array.from(DOM.resultsList.children).slice(0, 15));
+ DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none";
+ observeResultSnippets();
+}
+
+function animateResultRows(rows) {
+ if (!motionAllowed()) return;
+ rows.slice(0, 15).forEach((row, index) => {
+ row.style.setProperty("--result-enter-delay", `${Math.round(24 * Math.sqrt(index))}ms`);
row.classList.add("result-enter");
row.addEventListener("animationend", () => {
row.classList.remove("result-enter");
row.style.removeProperty("--result-enter-delay");
}, { once: true });
- order += 1;
});
}
-function buildResultHTML(rec, idx) {
- const iconType = getFileIconType(rec.Extension);
- const titleHTML = highlightText(rec.File, STATE.query);
- const repoShort = (rec.Repo || "").split("/").pop();
- const sizeStr = formatSize(rec.Size);
- const recordLink = getRecordLink(rec);
- const recordPath = getRecordPath(rec);
- const readerRecord = applyReaderAsset(rec, rec.Repo || "", buildRecordRelativePath(rec), recordLink);
- const breadcrumb = (rec.Folder || []).map((f, i) => {
- const accum = (rec.Folder || []).slice(0, i + 1).join("/");
- const folderDisplay = STATE.searchFolders ? highlightText(f, STATE.query) : escapeHTML(f);
- const separator = i < (rec.Folder || []).length - 1 ? '
/' : "";
- return `
${folderDisplay}${separator}`;
- }).join("");
- const repoSeparator = (rec.Folder || []).length ? '
/' : "";
- return `
-
-
${ICONS[iconType] || ICONS.file}
-
-
${titleHTML}${rec.Extension ? `.${escapeHTML(rec.Extension)}` : ""}
-
${repoShort}${repoSeparator}${breadcrumb}
-
- ${STATE.mode === "global" ? `${repoShort}` : ""}
- ${sizeStr ? `${sizeStr}` : ""}
-
-
-
-
-
-
仓库查看
- ${isReadableRecord(readerRecord) ? `
` : ""}
-
`;
-}
-
-function getResultsHTMLCacheKey() {
- return [
- STATE.query || "",
- STATE.searchFolders ? "1" : "0",
- STATE.useMirrorLinks ? "1" : "0",
- STATE.mode || "",
- STATE.repoFull || "",
- DOM.sortSelect ? DOM.sortSelect.value : "relevance",
- STATE.filterRepos.join(","),
- STATE.filterExtensions.join(","),
- STATE.filterFolderSelfs.join(","),
- STATE.filterFolderSubtrees.join(","),
- STATE.filterMinSize == null ? "" : String(STATE.filterMinSize),
- STATE.filterMaxSize == null ? "" : String(STATE.filterMaxSize),
- ].join("|");
-}
-
-function clearResultTemplateCache() {
- VSCROLL.templateCache.clear();
- VSCROLL.templateCacheKey = getResultsHTMLCacheKey();
- VSCROLL.contentVersion++;
- VSCROLL.measuredWindowKey = "";
- VSCROLL.measuredRowKeys = [];
-}
-
-function ensureResultTemplateCache() {
- const key = getResultsHTMLCacheKey();
- if (VSCROLL.templateCacheKey !== key) clearResultTemplateCache();
-}
-
-function createResultRow(rec, idx) {
- ensureResultTemplateCache();
- let template = VSCROLL.templateCache.get(idx);
- if (!template) {
- template = document.createElement("div");
- template.className = "result-item" + (idx % 2 === 1 ? " is-alt" : "");
- template.dataset.index = String(idx);
- template.dataset.contentVersion = String(VSCROLL.contentVersion);
- template.innerHTML = buildResultHTML(rec, idx);
- VSCROLL.templateCache.set(idx, template);
- if (VSCROLL.templateCache.size > 240) {
- VSCROLL.templateCache.delete(VSCROLL.templateCache.keys().next().value);
- }
- } else {
- VSCROLL.templateCache.delete(idx);
- VSCROLL.templateCache.set(idx, template);
- }
- return template.cloneNode(true);
-}
-
-function reconcileVirtualRows(items, start, end, topH, bottomH) {
- let topSpacer = DOM.resultsList.querySelector(".virtual-spacer-top");
- let bottomSpacer = DOM.resultsList.querySelector(".virtual-spacer-bottom");
- if (!topSpacer) {
- topSpacer = document.createElement("div");
- topSpacer.className = "virtual-spacer virtual-spacer-top";
- DOM.resultsList.prepend(topSpacer);
- }
- if (!bottomSpacer) {
- bottomSpacer = document.createElement("div");
- bottomSpacer.className = "virtual-spacer virtual-spacer-bottom";
- DOM.resultsList.append(bottomSpacer);
- }
- topSpacer.style.height = topH + "px";
- bottomSpacer.style.height = bottomH + "px";
-
- const existing = new Map();
- DOM.resultsList.querySelectorAll(".result-item[data-index]").forEach((row) => {
- const idx = Number(row.dataset.index);
- if (idx < start || idx >= end || Number(row.dataset.contentVersion) !== VSCROLL.contentVersion) row.remove();
- else existing.set(idx, row);
- });
- let cursor = topSpacer.nextSibling;
- for (let idx = start; idx < end; idx++) {
- const row = existing.get(idx) || createResultRow(items[idx], idx);
- if (row !== cursor) DOM.resultsList.insertBefore(row, cursor || bottomSpacer);
- cursor = row.nextSibling;
- }
- if (DOM.resultsList.lastElementChild !== bottomSpacer) DOM.resultsList.append(bottomSpacer);
+function resetSnippetObserver() {
+ if (snippetObserver) snippetObserver.disconnect();
+ snippetObserver = null;
+ snippetQueue.length = 0;
+ for (const controller of snippetControllers) controller.abort();
+ snippetControllers.clear();
}
-function scheduleVirtualRender() {
- if (VSCROLL.renderFrame) return;
- VSCROLL.renderFrame = requestAnimationFrame(() => {
- VSCROLL.renderFrame = 0;
- renderVisible();
+function pauseSnippetLoadingForPreview() {
+ resetSnippetObserver();
+ DOM.resultsList.querySelectorAll("[data-snippet-pending='1']").forEach((node) => {
+ delete node.dataset.snippetObserved;
});
}
-function renderVisible() {
- const items = STATE.results;
- const len = items.length;
- if (len === 0) {
- updateScrollTrack();
- return;
- }
- const container = DOM.resultsContainer;
- const scrollTop = container.scrollTop;
- const viewH = container.clientHeight;
- const est = VSCROLL.estimatedHeight;
- const overscanItems = Math.max(10, Math.floor(viewH / (est || 60)));
- const now = performance.now();
- const elapsed = VSCROLL.lastScrollTime ? Math.max(1, now - VSCROLL.lastScrollTime) : 16;
- const instantVelocity = Math.abs(scrollTop - VSCROLL.lastScrollTop) / elapsed;
- VSCROLL.scrollVelocity = VSCROLL.scrollVelocity * 0.7 + instantVelocity * 0.3;
- VSCROLL.lastScrollTime = now;
- const extraScreens = VSCROLL.isDraggingThumb ? 0 : Math.min(3, Math.floor(VSCROLL.scrollVelocity / 1.5));
- const baseOverscanPx = overscanItems * (est || 60);
- const velocityOverscanPx = extraScreens * viewH;
- ensureHeightTree();
- const scrollingDown = scrollTop >= VSCROLL.lastScrollTop;
- VSCROLL.lastScrollTop = scrollTop;
- const safeStart = findVirtualIndex(Math.max(0, scrollTop - baseOverscanPx * 0.35));
- const safeEnd = Math.min(len, findVirtualIndex(scrollTop + viewH + baseOverscanPx * 0.35) + 1);
- if (!pendingResultEntrance && VSCROLL.renderStart <= safeStart && VSCROLL.renderEnd >= safeEnd) return;
- const beforePx = VSCROLL.isDraggingThumb
- ? viewH * 0.35
- : baseOverscanPx * (scrollingDown ? 1 : 2) + (scrollingDown ? 0 : velocityOverscanPx);
- const afterPx = VSCROLL.isDraggingThumb
- ? viewH * 0.35
- : baseOverscanPx * (scrollingDown ? 2 : 1) + (scrollingDown ? velocityOverscanPx : 0);
- let start = findVirtualIndex(Math.max(0, scrollTop - beforePx));
- let end = Math.min(len, findVirtualIndex(scrollTop + viewH + afterPx) + 1);
- if (end - start < 10 && len > 10) end = Math.min(start + 30, len);
- if (pendingResultEntrance && start === 0) end = Math.min(len, Math.max(end, 30));
- if (start === VSCROLL.renderStart && end === VSCROLL.renderEnd) return;
- VSCROLL.renderStart = start;
- VSCROLL.renderEnd = end;
- const totalH = fenwickSum(VSCROLL.heightTree, len);
- const topH = fenwickSum(VSCROLL.heightTree, start);
- const endH = fenwickSum(VSCROLL.heightTree, end);
- const bottomH = Math.max(0, totalH - endH);
- reconcileVirtualRows(items, start, end, topH, bottomH);
- if (DOM.multiSelectToggle && DOM.multiSelectToggle.checked) updateSelectionUI();
- requestAnimationFrame(() => {
- if (VSCROLL.isDraggingThumb) return;
- if (measureHeights(start, end)) {
- VSCROLL.renderStart = -1;
- VSCROLL.renderEnd = -1;
- scheduleVirtualRender();
- return;
- }
- updateScrollTrack();
- if (pendingResultEntrance) {
- pendingResultEntrance = false;
- animateVisibleResultRows();
- }
+function observeResultSnippets() {
+ if (!STATE.fulltext || !STATE.query) return;
+ if (!snippetObserver) {
+ snippetObserver = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ snippetObserver.unobserve(entry.target);
+ queueExactSnippet(entry.target);
+ }
+ }, { root: DOM.resultsContainer, rootMargin: "300px 0px" });
+ }
+ DOM.resultsList.querySelectorAll("[data-snippet-doc-id]").forEach((node) => {
+ if (node.dataset.snippetPending !== "1") return;
+ if (node.dataset.snippetObserved === "1") return;
+ node.dataset.snippetObserved = "1";
+ // Empty snippet elements can have zero height and never intersect. Load them
+ // immediately so indexes that are still building do not leave blank rows.
+ if (!node.textContent.trim() || node.querySelector(".snippet-loading")) queueExactSnippet(node);
+ else snippetObserver.observe(node);
});
}
-function ensureHeightTree() {
- const len = VSCROLL.heights.length;
- if (!VSCROLL.heightsDirty && VSCROLL.heightTree.length === len + 1) return;
- const tree = new Array(len + 1).fill(0);
- const est = VSCROLL.estimatedHeight || 60;
- for (let i = 1; i <= len; i++) {
- tree[i] += VSCROLL.heights[i - 1] || est;
- const parent = i + (i & -i);
- if (parent <= len) tree[parent] += tree[i];
- }
- VSCROLL.heightTree = tree;
- VSCROLL.heightsDirty = false;
+function queueExactSnippet(node) {
+ const docId = node.dataset.snippetDocId;
+ const query = STATE.query;
+ if (!docId || !query) return;
+ snippetQueue.push({ node, docId, query, exact: STATE.exact, sequence: STATE.searchSequence });
+ drainSnippetQueue();
}
-function fenwickAdd(tree, idx, delta) {
- for (let i = idx; i < tree.length; i += i & -i) tree[i] += delta;
-}
-
-function fenwickSum(tree, idx) {
- let sum = 0;
- for (let i = idx; i > 0; i -= i & -i) sum += tree[i];
- return sum;
-}
-
-function getVirtualTotalHeight() {
- ensureHeightTree();
- return fenwickSum(VSCROLL.heightTree, VSCROLL.heights.length);
-}
-
-function getVirtualOffset(index) {
- ensureHeightTree();
- return fenwickSum(VSCROLL.heightTree, Math.max(0, Math.min(index, VSCROLL.heights.length)));
-}
-
-function findVirtualIndex(offset) {
- ensureHeightTree();
- const len = VSCROLL.heights.length;
- let idx = 0;
- let bit = 1;
- while ((bit << 1) < VSCROLL.heightTree.length) bit <<= 1;
- let sum = 0;
- for (; bit > 0; bit >>= 1) {
- const next = idx + bit;
- if (next < VSCROLL.heightTree.length && sum + VSCROLL.heightTree[next] <= offset) {
- idx = next;
- sum += VSCROLL.heightTree[next];
- }
- }
- return Math.min(Math.max(0, idx), Math.max(0, STATE.results.length - 1));
-}
-
-function resetVirtualScrollState() {
- if (VSCROLL.renderFrame) cancelAnimationFrame(VSCROLL.renderFrame);
- VSCROLL.renderFrame = 0;
- VSCROLL.renderStart = 0;
- VSCROLL.renderEnd = 0;
- VSCROLL.heights = [];
- VSCROLL.heightTree = [];
- VSCROLL.heightsDirty = true;
- VSCROLL.lastScrollTop = 0;
- VSCROLL.lastScrollTime = 0;
- VSCROLL.scrollVelocity = 0;
- clearResultTemplateCache();
- updateScrollTrack();
-}
-
-function prepareRouteTransitionResults() {
- if (!DOM.resultsContainer || STATE.results.length === 0) return;
- if (VSCROLL.renderFrame) cancelAnimationFrame(VSCROLL.renderFrame);
- VSCROLL.renderFrame = 0;
- DOM.resultsContainer.scrollTop = 0;
- VSCROLL.renderStart = -1;
- VSCROLL.renderEnd = -1;
- VSCROLL.heights = [];
- VSCROLL.heightTree = [];
- VSCROLL.heightsDirty = true;
- VSCROLL.lastScrollTop = 0;
- VSCROLL.lastScrollTime = 0;
- VSCROLL.scrollVelocity = 0;
- clearResultTemplateCache();
- ensureVirtualHeights(Math.min(STATE.results.length, STATE.pageSize));
- renderVisible();
-}
-
-function ensureVirtualHeights(len) {
- if (VSCROLL.heights.length >= len) return;
- const oldLen = VSCROLL.heights.length;
- const canExtendTree = !VSCROLL.heightsDirty && VSCROLL.heightTree.length === oldLen + 1;
- VSCROLL.heights.length = len;
- VSCROLL.measuredRowKeys.length = len;
- for (let i = oldLen; i < len; i++) {
- VSCROLL.heights[i] = VSCROLL.estimatedHeight;
- }
- if (canExtendTree) {
- const newPrefix = new Array(len - oldLen + 1).fill(0);
- for (let i = oldLen; i < len; i++) newPrefix[i - oldLen + 1] = newPrefix[i - oldLen] + VSCROLL.heights[i];
- VSCROLL.heightTree.length = len + 1;
- for (let i = oldLen + 1; i <= len; i++) {
- const rangeStart = i - (i & -i) + 1;
- const oldStart = Math.max(1, rangeStart);
- const oldEnd = Math.min(oldLen, i);
- const oldSum = oldEnd >= oldStart
- ? fenwickSum(VSCROLL.heightTree, oldEnd) - fenwickSum(VSCROLL.heightTree, oldStart - 1)
- : 0;
- const newStart = Math.max(oldLen + 1, rangeStart);
- const newSum = newPrefix[i - oldLen] - newPrefix[newStart - oldLen - 1];
- VSCROLL.heightTree[i] = oldSum + newSum;
- }
- } else {
- VSCROLL.heightsDirty = true;
+function drainSnippetQueue() {
+ while (snippetActive < SNIPPET_CONCURRENCY && snippetQueue.length) {
+ const task = snippetQueue.shift();
+ snippetActive += 1;
+ loadExactSnippet(task).finally(() => {
+ snippetActive -= 1;
+ drainSnippetQueue();
+ });
}
}
-function refreshVirtualAfterAppend() {
- ensureVirtualHeights(STATE.results.length);
- const topSpacer = DOM.resultsList.querySelector(".virtual-spacer-top");
- const bottomSpacer = DOM.resultsList.querySelector(".virtual-spacer-bottom");
- if (!topSpacer || !bottomSpacer) {
- VSCROLL.renderStart = -1;
- VSCROLL.renderEnd = -1;
- renderVisible();
- return;
- }
- ensureHeightTree();
- const topH = fenwickSum(VSCROLL.heightTree, VSCROLL.renderStart);
- const endH = fenwickSum(VSCROLL.heightTree, VSCROLL.renderEnd);
- const totalH = fenwickSum(VSCROLL.heightTree, VSCROLL.heights.length);
- topSpacer.style.height = topH + "px";
- bottomSpacer.style.height = Math.max(0, totalH - endH) + "px";
-}
-
-function ensureVirtualViewportCovered() {
- if (VSCROLL.renderStart < 0 || VSCROLL.renderEnd <= VSCROLL.renderStart) return;
- const viewTop = DOM.resultsContainer.scrollTop;
- const viewBottom = viewTop + DOM.resultsContainer.clientHeight;
- if (viewTop < getVirtualOffset(VSCROLL.renderStart) || viewBottom > getVirtualOffset(VSCROLL.renderEnd)) renderVisible();
-}
-
-function measureHeights(start = VSCROLL.renderStart, end = VSCROLL.renderEnd) {
- const containerWidth = DOM.resultsContainer.clientWidth;
- const rowMeasureKey = VSCROLL.contentVersion + ":" + containerWidth;
- const measureKey = [rowMeasureKey, start, end].join(":");
- if (VSCROLL.measuredWindowKey === measureKey) return false;
- const els = DOM.resultsList.querySelectorAll(".result-item");
- const measurements = [];
- let measuredSum = 0;
- let measuredCount = 0;
- let changed = false;
- for (let i = 0; i < els.length; i++) {
- const idx = parseInt(els[i].dataset.index);
- if (idx < 0) continue;
- if (VSCROLL.measuredRowKeys[idx] === rowMeasureKey && VSCROLL.heights[idx] > 0) {
- measuredSum += VSCROLL.heights[idx];
- measuredCount++;
- continue;
- }
- const height = els[i].getBoundingClientRect().height;
- if (height <= 0) continue;
- measurements.push([idx, height]);
- measuredSum += height;
- measuredCount++;
- VSCROLL.measuredRowKeys[idx] = rowMeasureKey;
- }
- for (let i = 0; i < measurements.length; i++) {
- const idx = measurements[i][0];
- const height = measurements[i][1];
- if (VSCROLL.heights[idx] !== height) {
- const prev = VSCROLL.heights[idx] || VSCROLL.estimatedHeight || 60;
- VSCROLL.heights[idx] = height;
- if (!VSCROLL.heightsDirty && VSCROLL.heightTree.length === VSCROLL.heights.length + 1) {
- fenwickAdd(VSCROLL.heightTree, idx + 1, height - prev);
- } else {
- VSCROLL.heightsDirty = true;
+async function loadExactSnippet(task) {
+ const cacheKey = `${task.docId}\n${task.query}\n${task.exact}`;
+ let data = snippetCache.get(cacheKey);
+ try {
+ if (!data) {
+ const controller = new AbortController();
+ snippetControllers.add(controller);
+ try {
+ data = await API.snippet(task.docId, task.query, task.exact, controller.signal);
+ } finally {
+ snippetControllers.delete(controller);
+ }
+ if (data && !data.error) {
+ snippetCache.set(cacheKey, data);
+ while (snippetCache.size > 300) snippetCache.delete(snippetCache.keys().next().value);
}
- changed = true;
}
- }
- if (measuredCount > 10) {
- const nextEstimate = measuredSum / measuredCount;
- if (Math.abs(nextEstimate - VSCROLL.estimatedHeight) > 1) {
- VSCROLL.estimatedHeight = nextEstimate;
- VSCROLL.heightsDirty = true;
- changed = true;
+ } catch (_error) {
+ if (task.node.isConnected && task.sequence === STATE.searchSequence) {
+ const loading = task.node.querySelector(".snippet-loading");
+ if (loading) loading.remove();
}
- }
- VSCROLL.measuredWindowKey = measureKey;
- return changed;
-}
-
-function updateStatusBar() {
- DOM.resultCount.textContent = STATE.isLoading
- ? "搜索中…"
- : (STATE.total > 0 ? `共 ${STATE.total.toLocaleString()} 条结果` : "");
- const has = STATE.filterRepos.length || STATE.filterExtensions.length || STATE.filterFolderSelfs.length || STATE.filterFolderSubtrees.length || STATE.filterMinSize !== null || STATE.filterMaxSize !== null;
- DOM.clearFiltersBtn.style.display = has ? "" : "none";
- if (DOM.multiToggleLabel) DOM.multiToggleLabel.style.display = STATE.total > 0 ? "" : "none";
-}
-
-function setSearchVisualLoading(loading) {
- document.getElementById("search-box").classList.toggle("is-searching", loading);
- updateStatusBar();
-}
-
-function updateLoadInfo() {
- if (STATE.total === 0 && STATE.results.length === 0) { DOM.loadInfo.style.display = "none"; return; }
- DOM.loadInfo.style.display = "";
- DOM.loadedCount.textContent = STATE.results.length.toLocaleString();
- DOM.totalCount.textContent = STATE.total.toLocaleString();
- requestAnimationFrame(updateScrollTrack);
-}
-
-function prefetchNextPage() {
- var nextPage = STATE._loadedPage + 1;
- var totalPages = Math.ceil(STATE.total / STATE.pageSize);
- if (nextPage > totalPages) return;
- if (STATE._pageCache[nextPage]) return;
- var reqId = searchRequestId;
- var q = STATE.query || "";
- var isRepo = !!STATE.repo;
- var base = isRepo ? "/api/search/" + STATE.repo : "/api/search";
- var body = {};
- if (q) body.q = q;
- body.page = nextPage;
- body.page_size = STATE.pageSize;
- if (!isRepo && STATE.filterRepos.length > 0) body.repos = STATE.filterRepos;
- if (STATE.filterExtensions.length > 0) body.extensions = STATE.filterExtensions;
- if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) {
- body.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path)));
- body.folder_match_mode = "mixed";
- body.folder_selfs = STATE.filterFolderSelfs;
- body.folder_subtrees = STATE.filterFolderSubtrees;
- } else if (STATE.filterFolders.length > 0) {
- body.folders = STATE.filterFolders;
- }
- if (STATE.filterMinSize !== null) body.min_size = STATE.filterMinSize;
- if (STATE.filterMaxSize !== null) body.max_size = STATE.filterMaxSize;
- body.sort = DOM.sortSelect.value;
- if (!STATE.searchFolders) body.search_folders = false;
- if (STATE.exact) body.exact = true;
- var cacheKey = base + "|" + stableSearchStringify(body);
- var cached = getCachedSearchResponse(cacheKey);
- if (cached && cached.results) {
- STATE._pageCache[nextPage] = cached.results;
return;
}
- if (searchPrefetchAbortController && searchPrefetchKey === cacheKey) return;
- if (searchPrefetchAbortController) searchPrefetchAbortController.abort();
- var prefetchController = new AbortController();
- searchPrefetchAbortController = prefetchController;
- searchPrefetchKey = cacheKey;
- fetch(base, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- signal: prefetchController.signal,
- }).then(function(resp) {
- if (!resp.ok) return;
- return resp.json();
- }).then(function(data) {
- if (reqId !== searchRequestId) return;
- if (data && data.results) {
- setCachedSearchResponse(cacheKey, data);
- STATE._pageCache[nextPage] = data.results;
- }
- }).catch(function(err) {
- if (err && err.name === "AbortError") return;
- }).finally(function() {
- if (searchPrefetchAbortController === prefetchController) {
- searchPrefetchAbortController = null;
- searchPrefetchKey = null;
- }
- });
+ if (!data || data.error || task.sequence !== STATE.searchSequence || task.query !== STATE.query || task.exact !== STATE.exact || !task.node.isConnected) return;
+ task.node.innerHTML = highlightText(data.snippet || "", task.query, data.highlights || []);
}
-function consumeCachedAppendPage() {
- if (!STATE._pageCache[STATE.page]) return false;
- var cp = STATE.page;
- STATE.results = STATE.results.concat(STATE._pageCache[cp]);
- delete STATE._pageCache[cp];
- STATE._loadedPage = cp;
- var np = cp + 1;
- while (STATE._pageCache[np]) {
- STATE.results = STATE.results.concat(STATE._pageCache[np]);
- delete STATE._pageCache[np];
- np++;
- }
- STATE._loadedPage = np - 1;
- STATE.hasMore = STATE.results.length < STATE.total;
- STATE._pendingPage = 0;
- STATE.isLoading = false;
- refreshVirtualAfterAppend();
- updateStatusBar();
- updateLoadInfo();
- syncStateToURL(true);
- prefetchNextPage();
- return true;
-}
-
-async function doSearch(append = false) {
- if (!append) {
- selectedIndices = {};
- lastSelectedIndex = -1;
- if (searchAbortController) searchAbortController.abort();
- if (searchPrefetchAbortController) searchPrefetchAbortController.abort();
- searchAbortController = new AbortController();
- searchPrefetchAbortController = null;
- searchPrefetchKey = null;
- STATE._pageCache = {};
- STATE._loadedPage = 0;
- STATE._pendingPage = 0;
- STATE._deferredAppendWhileDragging = false;
- if (scrollLoadTimer) {
- clearTimeout(scrollLoadTimer);
- scrollLoadTimer = null;
- }
- }
- if (append && STATE._pendingPage === STATE.page) return;
- if (append && STATE._pageCache[STATE.page]) {
- if (VSCROLL.isDraggingThumb) {
- STATE._deferredAppendWhileDragging = true;
- STATE._pendingPage = 0;
- STATE.isLoading = false;
- return;
- }
- consumeCachedAppendPage();
- return;
- }
- STATE.isLoading = true;
- if (!append && STATE.results.length > 0) DOM.resultsList.classList.add("results-pending");
- if (!append) setSearchVisualLoading(true);
- STATE._pendingPage = STATE.page;
- const currentRequestId = ++searchRequestId;
- const requestBody = {
- q: STATE.query,
- page: STATE.page,
- page_size: STATE.pageSize,
- sort: DOM.sortSelect.value,
- signal: searchAbortController.signal,
- };
- if (STATE.mode === "global" && STATE.filterRepos.length > 0) requestBody.repos = STATE.filterRepos;
- if (STATE.filterExtensions.length > 0) requestBody.extensions = STATE.filterExtensions;
- if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) {
- requestBody.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path)));
- requestBody.folder_match_mode = "mixed";
- requestBody.folder_selfs = STATE.filterFolderSelfs;
- requestBody.folder_subtrees = STATE.filterFolderSubtrees;
- } else if (STATE.filterFolders.length > 0) {
- requestBody.folders = STATE.filterFolders;
- }
- if (STATE.filterMinSize !== null) requestBody.min_size = STATE.filterMinSize;
- if (STATE.filterMaxSize !== null) requestBody.max_size = STATE.filterMaxSize;
- if (!STATE.searchFolders) requestBody.search_folders = false;
- if (STATE.exact) requestBody.exact = true;
- STATE.resultsSkeletonActive = shouldShowResultsSkeleton(append);
- DOM.resultsLoading.style.display = (!append && STATE.results.length > 0) ? 'none' : ((!append && !STATE.resultsSkeletonActive) ? 'flex' : 'none');
- if (!append) {
- DOM.emptyState.style.display = 'none';
- if (STATE.resultsSkeletonActive) {
- DOM.resultsContainer.scrollTop = 0;
- renderResultsSkeleton();
- } else {
- clearResultsSkeleton();
- }
+async function openPreview(docId, keyword, options = {}) {
+ if (options.sync !== false) {
+ STATE.previewDocId = docId;
+ syncUrl(history.state && history.state.preview === true);
}
+ if (previewController) previewController.abort();
+ pauseSnippetLoadingForPreview();
+ const controller = new AbortController();
+ previewController = controller;
try {
- const data = await API.search(requestBody);
- if (currentRequestId !== searchRequestId) return;
- STATE.total = data.total;
- const newItems = data.results;
- if (append) {
- if (VSCROLL.isDraggingThumb) {
- STATE._pageCache[data.page] = newItems;
- STATE._deferredAppendWhileDragging = true;
- STATE._pendingPage = 0;
- STATE.isLoading = false;
- DOM.resultsLoading.style.display = 'none';
- return;
- }
- STATE._pageCache[data.page] = newItems;
- let nextPage = STATE._loadedPage + 1;
- while (STATE._pageCache[nextPage]) {
- const pageItems = STATE._pageCache[nextPage];
- STATE.results = STATE.results.concat(pageItems);
- delete STATE._pageCache[nextPage];
- nextPage++;
- }
- STATE._loadedPage = nextPage - 1;
- refreshVirtualAfterAppend();
- } else {
- STATE.results = newItems;
- STATE._loadedPage = 1;
- STATE._pageCache = {};
- DOM.resultsContainer.scrollTop = 0;
- resetVirtualScrollState();
- renderResults(true);
- }
- STATE.hasMore = STATE.results.length < STATE.total;
- updateStatusBar();
- updateLoadInfo();
- syncStateToURL(true);
- prefetchNextPage();
- warmConnection();
- } catch (err) {
- if (err.name === 'AbortError') return;
- if (append && currentRequestId === searchRequestId) {
- STATE.page = Math.max(1, STATE.page - 1);
+ const data = await API.preview(docId, controller.signal);
+ if (STATE.previewDocId !== docId) return;
+ if (data.error) {
+ showToast("预览失败");
+ closePreview();
+ return;
}
- console.error('搜索失败:', err);
- showToast('搜索失败');
+ const rawText = data.text || "";
+ const shouldHighlight = keyword && rawText.length < 500000;
+ let text = "";
+ if (shouldHighlight) {
+ text = highlightText(rawText, keyword);
+ } else if (keyword && rawText.length >= 500000) {
+ showToast("文本较大,已跳过预览高亮");
+ }
+ DOM.previewPanel.innerHTML = `
+
+
`;
+ const previewBody = DOM.previewPanel.querySelector(".preview-body");
+ if (shouldHighlight) previewBody.innerHTML = text;
+ else previewBody.textContent = rawText;
+ DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
+ document.body.classList.add("preview-open");
+ if (motionAllowed()) {
+ DOM.previewPanel.classList.remove("preview-enter");
+ void DOM.previewPanel.offsetWidth;
+ DOM.previewPanel.classList.add("preview-enter");
+ const finishPreviewEnter = (event) => {
+ if (event.target !== DOM.previewPanel) return;
+ DOM.previewPanel.classList.remove("preview-enter");
+ DOM.previewPanel.removeEventListener("animationend", finishPreviewEnter);
+ };
+ DOM.previewPanel.addEventListener("animationend", finishPreviewEnter);
+ }
+ STATE.previewDocId = docId;
+ if (options.scroll !== false) DOM.previewPanel.scrollIntoView({ behavior: "smooth", block: "start" });
+ } catch (error) {
+ if (STATE.previewDocId !== docId) return;
+ if (error.name === "AbortError") return;
+ console.error(error);
+ showToast("预览失败");
+ closePreview();
} finally {
- if (currentRequestId === searchRequestId) {
- STATE._pendingPage = 0;
- STATE.isLoading = false;
- STATE.resultsSkeletonActive = false;
- DOM.resultsLoading.style.display = 'none';
- if (!append) setSearchVisualLoading(false);
- else updateStatusBar();
- if (!append) DOM.resultsList.classList.remove("results-pending");
+ if (previewController === controller) {
+ previewController = null;
+ observeResultSnippets();
}
}
}
-async function renderFilters(routeId) {
- if (STATE.mode === "global") {
- DOM.filterRepoSection.style.display = "";
- if (!STATE.repoList || STATE.repoList.length === 0) try {
- const repos = await API.getRepos();
- if (routeId && routeId !== routeRenderId) return;
- STATE.repoList = repos;
- } catch (e) { }
- if (routeId && routeId !== routeRenderId) return;
- const repos = Array.isArray(STATE.repoList) ? STATE.repoList : [];
- renderCheckboxList(DOM.filterRepoList, repos.map(r => ({ key: r.name, label: r.name.split("/").pop(), count: r.count })), STATE.filterRepos, (vals) => { STATE.filterRepos = vals; STATE.page = 1; doSearch(); });
- } else {
- DOM.filterRepoSection.style.display = "none";
- }
- if (STATE.mode === "repo") {
- DOM.filterFolderSection.style.display = "";
- DOM.filterFolderTree.innerHTML = '
加载中...
';
- if (!STATE.folderTree || !STATE.folderTree.length) try {
- const repo = STATE.repo;
- if (repo && folderTreeCache.has(repo)) {
- STATE.folderTree = folderTreeCache.get(repo);
- } else {
- const folders = await API.getFolders(repo);
- if (repo && folders) folderTreeCache.set(repo, folders);
- if (routeId && routeId !== routeRenderId) return;
- if (STATE.mode !== "repo" || STATE.repo !== repo) return;
- STATE.folderTree = normalizeFolderTreeRoots(folders);
- }
- if (STATE.folderTree && STATE.folderTree.length) {
- STATE.folderTreeCollapsed = {};
- initializeFolderTreeCollapsed(STATE.folderTree);
- }
- } catch (e) { }
- if (routeId && routeId !== routeRenderId) return;
- renderFilterFolderTree();
- } else {
- DOM.filterFolderSection.style.display = "none";
- }
- try {
- const repo = STATE.repo;
- const extensions = await API.getExtensions(repo);
- if (routeId && routeId !== routeRenderId) return;
- if (STATE.repo !== repo) return;
- STATE.extensionList = extensions;
- } catch (e) {
- if (routeId && routeId !== routeRenderId) return;
- STATE.extensionList = [];
- }
- if (routeId && routeId !== routeRenderId) return;
- renderExtensionFilter();
-}
-
-function renderExtensionFilter() {
- const allExt = Array.isArray(STATE.extensionList) ? STATE.extensionList.slice() : [];
- const extMap = new Set(allExt.map(e => e && e.name).filter(Boolean));
- for (const selectedExt of STATE.filterExtensions || []) {
- if (selectedExt && !extMap.has(selectedExt)) {
- allExt.push({ name: selectedExt, count: 0 });
- extMap.add(selectedExt);
- }
+function closePreview() {
+ if (previewController) {
+ previewController.abort();
+ previewController = null;
}
- if (allExt.length === 0) {
- DOM.filterExtList.innerHTML = '
暂无
';
+ if (history.state && history.state.previewEntry === true) {
+ history.back();
return;
}
- const ordered = [];
- const rest = [];
- for (const e of allExt) {
- if (!e || typeof e.name !== 'string') continue;
- const idx = ORDERED_EXTENSIONS.indexOf(e.name);
- if (idx >= 0) {
- ordered.push({ key: e.name, label: '.' + e.name, count: e.count || 0, _idx: idx, _name: e.name });
- } else {
- rest.push(e);
- }
- }
- ordered.sort((a, b) => a._idx - b._idx || a._name.localeCompare(b._name));
- const items = ordered.map(e => ({ key: e.key, label: e.label, count: e.count }));
- renderExtensionTree(DOM.filterExtList, items, rest, STATE.filterExtensions, (vals) => {
- STATE.filterExtensions = vals;
- STATE.page = 1;
- saveStoredExtensionFilters();
- doSearch();
- });
+ STATE.previewDocId = null;
+ DOM.previewPanel.innerHTML = "";
+ DOM.previewPanel.style.display = "none";
+ document.body.classList.remove("preview-open");
+ observeResultSnippets();
+ syncUrl();
}
-function renderExtensionTree(container, items, rest, selected, onChange) {
- if (items.length === 0 && rest.length === 0) {
- container.innerHTML = '
暂无
';
+function restorePreviewFromUrl(options = {}) {
+ if (!STATE.previewDocId) {
+ DOM.previewPanel.innerHTML = "";
+ DOM.previewPanel.style.display = "none";
+ document.body.classList.remove("preview-open");
return;
}
- const selectedSet = new Set(selected || []);
- const html = [];
- for (const item of items) {
- html.push(`
`);
- }
- if (rest.length > 0) {
- const total = rest.reduce((s, e) => s + (e.count || 0), 0);
- const restSelectedCount = rest.reduce((s, e) => s + (selectedSet.has(e.name) ? 1 : 0), 0);
- const parentChecked = restSelectedCount === rest.length;
- const collapsed = STATE.extensionOtherCollapsed !== false;
- html.push(`
`);
- html.push(`
`);
- for (const item of rest.slice().sort((a, b) => a.name.localeCompare(b.name))) {
- html.push(``);
- }
- html.push('
');
- }
- container.innerHTML = html.join("");
- const parentCb = container.querySelector('input[value="__OTHER__"]');
- if (parentCb) {
- const selectedRestCount = rest.reduce((sum, item) => sum + (selectedSet.has(item.name) ? 1 : 0), 0);
- parentCb.indeterminate = selectedRestCount > 0 && selectedRestCount < rest.length;
- }
- const refreshExtOtherParentState = (nextSet) => {
- const parent = container.querySelector('input[value="__OTHER__"]');
- if (!parent || rest.length === 0) return;
- const selectedRestCount = rest.reduce((sum, item) => sum + (nextSet.has(item.name) ? 1 : 0), 0);
- parent.checked = selectedRestCount === rest.length;
- parent.indeterminate = selectedRestCount > 0 && selectedRestCount < rest.length;
- };
- const emit = (nextSet) => onChange(Array.from(nextSet));
- container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
- cb.addEventListener("change", () => {
- const nextSet = new Set(STATE.filterExtensions || []);
- if (cb.value === "__OTHER__") {
- for (const item of rest) {
- if (cb.checked) nextSet.add(item.name);
- else nextSet.delete(item.name);
- }
- } else if (cb.checked) nextSet.add(cb.value);
- else nextSet.delete(cb.value);
- for (const input of container.querySelectorAll('input[type="checkbox"]')) {
- if (input.value !== "__OTHER__") input.checked = nextSet.has(input.value);
- }
- refreshExtOtherParentState(nextSet);
- emit(nextSet);
+ openPreview(STATE.previewDocId, STATE.query, { sync: false, scroll: options.scroll === true });
+}
+
+function renderCheckboxList(container, items, selected, onChange) {
+ container.innerHTML = items.map((item) => `
+
`).join("");
+ container.querySelectorAll("input").forEach((input) => {
+ input.addEventListener("change", () => {
+ const values = Array.from(container.querySelectorAll("input:checked")).map((item) => item.value);
+ onChange(values);
});
});
- const toggleButton = container.querySelector(".ext-other-toggle");
- if (toggleButton) toggleButton.addEventListener("click", () => toggleExtensionOther(toggleButton));
-}
-
-function toggleExtensionOther(button) {
- const children = button.parentElement && button.parentElement.nextElementSibling;
- if (!children || !children.classList.contains("ext-other-children")) return false;
- const expanding = STATE.extensionOtherCollapsed !== false;
- STATE.extensionOtherCollapsed = !expanding;
- button.setAttribute("aria-expanded", expanding ? "true" : "false");
- children.getAnimations().forEach(animation => animation.cancel());
- if (expanding) {
- children.style.display = "block";
- children.animate([
- { height: "0px", opacity: 0, transform: "translateY(-4px)", overflow: "hidden" },
- { height: `${children.scrollHeight}px`, opacity: 1, transform: "translateY(0)", overflow: "hidden" },
- ], { duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" });
- } else {
- const animation = children.animate([
- { height: `${children.scrollHeight}px`, opacity: 1, transform: "translateY(0)", overflow: "hidden" },
- { height: "0px", opacity: 0, transform: "translateY(-4px)", overflow: "hidden" },
- ], { duration: 150, easing: "cubic-bezier(0.22, 1, 0.36, 1)" });
- animation.addEventListener("finish", () => {
- if (STATE.extensionOtherCollapsed) children.style.display = "none";
- }, { once: true });
- }
- return false;
}
+async function renderSourcesFilter() {
+ renderCheckboxList(DOM.filterSourceList, STATE.sources.map((source) => ({ key: source.slug, label: source.name, count: source.count })), STATE.selectedSources, (values) => {
+ STATE.selectedSources = values;
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
+ });
+}
-function renderCheckboxList(container, items, selected, onChange) {
- if (items.length === 0) { container.innerHTML = '
暂无
'; container._itemsKey = ''; return; }
- const itemsKey = items.map(i => i.key).join(',') + '|' + items.map(i => i.count||0).join(',');
- if (container._itemsKey !== itemsKey) {
- container.innerHTML = items.map(item => `
`).join("");
- container._itemsKey = itemsKey;
- container._onChange = onChange;
- if (!container._hasDelegate) {
- container.addEventListener("change", () => {
- if (container._updating) return;
- container._onChange(Array.from(container.querySelectorAll("input:checked")).map(c => c.value));
- });
- container._hasDelegate = true;
- }
- } else {
- container._updating = true;
- container.querySelectorAll("input").forEach(cb => {
- cb.checked = selected.includes(cb.value);
- });
- container._updating = false;
+function flattenNodes(nodes, into) {
+ for (const node of nodes) {
+ if (node.path) into.push(node.path);
+ flattenNodes(node.children || [], into);
}
- container._onChange = onChange;
}
-function renderFilterFolderTree() {
- DOM.filterFolderTree.innerHTML = "";
- if (!STATE.folderTree || STATE.folderTree.length === 0) {
- DOM.filterFolderTree.innerHTML = '
暂无目录
';
- return;
+function buildFolderNodeMap(nodes, map = {}) {
+ for (const node of nodes) {
+ if (node.path) map[node.path] = node;
+ buildFolderNodeMap(node.children || [], map);
}
- renderFilterTreeNodes(DOM.filterFolderTree, STATE.folderTree, 0);
-}
-
-function toggleFolderChildrenAnimated(childContainer, toggle, expanding) {
- if (!childContainer || !toggle) return;
- childContainer.getAnimations().forEach((animation) => animation.cancel());
- toggle.getAnimations().forEach((animation) => animation.cancel());
- const glyph = toggle.querySelector(".tree-toggle-glyph");
- if (glyph) glyph.getAnimations().forEach((animation) => animation.cancel());
- const resetChildStyles = () => {
- childContainer.style.height = "";
- childContainer.style.opacity = "";
- childContainer.style.transform = "";
- childContainer.style.overflow = "";
- childContainer.style.transition = "";
- };
- const stopTransition = () => {
- if (childContainer._transitionCleanup) {
- childContainer.removeEventListener("transitionend", childContainer._transitionCleanup);
- childContainer._transitionCleanup = null;
- }
- if (childContainer._transitionTimer) {
- clearTimeout(childContainer._transitionTimer);
- childContainer._transitionTimer = null;
- }
- };
- stopTransition();
- resetChildStyles();
- if (expanding) {
- childContainer.style.display = "block";
- const targetHeight = childContainer.scrollHeight;
- childContainer.style.height = "0px";
- childContainer.style.opacity = "0";
- childContainer.style.transform = "translateY(-6px)";
- childContainer.style.overflow = "hidden";
- void childContainer.offsetHeight;
- childContainer.style.transition = "height 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms cubic-bezier(0.22, 1, 0.36, 1), transform 220ms cubic-bezier(0.22, 1, 0.36, 1)";
- childContainer.style.height = `${targetHeight}px`;
- childContainer.style.opacity = "1";
- childContainer.style.transform = "translateY(0)";
- childContainer._transitionCleanup = (event) => {
- if (event.target !== childContainer || event.propertyName !== "height") return;
- stopTransition();
- resetChildStyles();
- childContainer.style.display = "block";
- };
- childContainer.addEventListener("transitionend", childContainer._transitionCleanup);
- childContainer._transitionTimer = setTimeout(() => {
- if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" });
- }, 260);
- toggle.classList.add("expanded");
- if (glyph) {
- glyph.animate([
- { transform: "rotate(-45deg)" },
- { transform: "rotate(45deg)" },
- ], {
- duration: 220,
- easing: "cubic-bezier(0.22, 1, 0.36, 1)",
- fill: "forwards",
- });
+ return map;
+}
+
+function getVisibleFolderNodes(nodes, depth = 0, into = []) {
+ for (const node of nodes) {
+ into.push({ node, depth });
+ if (node.children && node.children.length && !STATE.folderCollapsed[node.path]) {
+ getVisibleFolderNodes(node.children, depth + 1, into);
}
- return;
}
- childContainer.style.display = "block";
- const startHeight = childContainer.scrollHeight;
- childContainer.style.height = `${startHeight}px`;
- childContainer.style.opacity = "1";
- childContainer.style.transform = "translateY(0)";
- childContainer.style.overflow = "hidden";
- void childContainer.offsetHeight;
- childContainer.style.transition = "height 190ms cubic-bezier(0.22, 1, 0.36, 1), opacity 190ms cubic-bezier(0.22, 1, 0.36, 1), transform 190ms cubic-bezier(0.22, 1, 0.36, 1)";
- childContainer.style.height = "0px";
- childContainer.style.opacity = "0";
- childContainer.style.transform = "translateY(-6px)";
- childContainer._transitionCleanup = (event) => {
- if (event.target !== childContainer || event.propertyName !== "height") return;
- stopTransition();
- childContainer.style.display = "none";
- resetChildStyles();
- };
- childContainer.addEventListener("transitionend", childContainer._transitionCleanup);
- childContainer._transitionTimer = setTimeout(() => {
- if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" });
- }, 230);
- toggle.classList.remove("expanded");
- if (glyph) {
- glyph.animate([
- { transform: "rotate(45deg)" },
- { transform: "rotate(-45deg)" },
- ], {
- duration: 190,
- easing: "cubic-bezier(0.22, 1, 0.36, 1)",
- fill: "forwards",
- });
+ return into;
+}
+
+function collectVisibleDescendantPaths(node, into = []) {
+ for (const child of node.children || []) {
+ if (!child.path) continue;
+ into.push(child.path);
+ if (child.children && child.children.length && !STATE.folderCollapsed[child.path]) {
+ collectVisibleDescendantPaths(child, into);
+ }
}
+ return into;
}
-function normalizeFolderTreeRoots(nodes) {
+function normalizeFolderTree(nodes) {
const roots = Array.isArray(nodes) ? nodes : [];
if (roots.length === 1) {
const only = roots[0];
- const hasChildren = Array.isArray(only.children) && only.children.length > 0;
- if ((only.isRoot || !only.path || only.path === STATE.repo || only.name === STATE.repo) && hasChildren) {
+ const hasChildren = Array.isArray(only.children) && only.children.length;
+ if ((only.isRoot || !only.path || only.path === STATE.source || only.name === STATE.source) && hasChildren) {
return only.children;
}
}
return roots;
}
-function initializeFolderTreeCollapsed(nodes) {
- for (const node of nodes || []) {
- if (node.path && !(node.path in STATE.folderTreeCollapsed)) STATE.folderTreeCollapsed[node.path] = false;
- if (node.children && node.children.length > 0) initializeFolderTreeCollapsed(node.children);
- }
-}
-
-function getFolderSubtreeSet() {
- return new Set(STATE.filterFolderSubtrees || []);
+function initializeFolderCollapsedState(nodes) {
+ const next = {};
+ const visit = (items, depth = 0) => {
+ for (const node of items) {
+ if (node.path && !(node.path in STATE.folderCollapsed)) next[node.path] = depth > 0;
+ visit(node.children || [], depth + 1);
+ }
+ };
+ visit(nodes, 0);
+ STATE.folderCollapsed = { ...next, ...STATE.folderCollapsed };
}
-function getFolderSelfSet() {
- return new Set(STATE.filterFolderSelfs || []);
+function isPathCovered(path, selectionSet) {
+ if (!path) return false;
+ if (selectionSet.has(path)) return true;
+ const parts = path.split("/");
+ while (parts.length > 1) {
+ parts.pop();
+ if (selectionSet.has(parts.join("/"))) return true;
+ }
+ return false;
}
-function isNodeFullySelected(node, subtreeSet, selfSet) {
- if (!node) return false;
- if (node.isRoot) {
- const childNodes = node.children || [];
- if (childNodes.length === 0) return false;
- for (let i = 0; i < childNodes.length; i++) {
- if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false;
+function getFolderSelectionState(node, selectionSet, cache = new Map()) {
+ const cacheKey = node.path || `__virtual__:${node.name}:${(node.children || []).length}`;
+ if (cache.has(cacheKey)) return cache.get(cacheKey);
+ let state = "unchecked";
+ if (node.path && isPathCovered(node.path, selectionSet)) {
+ state = "checked";
+ } else {
+ const children = node.children || [];
+ if (children.length) {
+ const childStates = children.map((child) => getFolderSelectionState(child, selectionSet, cache));
+ if (childStates.every((item) => item === "checked")) state = "checked";
+ else if (childStates.some((item) => item !== "unchecked")) state = "indeterminate";
}
- return true;
- }
- if (node.showSelfToggle && !selfSet.has(node.path)) return false;
- if (!node.hasChildren) {
- if (node.hasDirectFiles) return selfSet.has(node.path) || subtreeSet.has(node.path);
- return subtreeSet.has(node.path);
}
- const childNodes = node.children || [];
- for (let i = 0; i < childNodes.length; i++) {
- if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false;
- }
- return !node.hasDirectFiles || selfSet.has(node.path);
+ cache.set(cacheKey, state);
+ return state;
}
-function isNodePartiallySelected(node, subtreeSet, selfSet) {
- if (!node) return false;
- if (isNodeFullySelected(node, subtreeSet, selfSet)) return false;
- if (selfSet.has(node.path) || subtreeSet.has(node.path)) return true;
- const childNodes = node.children || [];
- for (let i = 0; i < childNodes.length; i++) {
- if (isNodeFullySelected(childNodes[i], subtreeSet, selfSet) || isNodePartiallySelected(childNodes[i], subtreeSet, selfSet)) return true;
- }
- return false;
+function normalizeFolderSelections(selections) {
+ const unique = Array.from(new Set((selections || []).filter(Boolean)));
+ return unique.filter((path) => !unique.some((other) => other !== path && path.startsWith(other + "/")));
}
-function setNodeSubtreeSelection(node, enabled, subtreeSet, selfSet) {
- if (!node) return;
- if (!node.isRoot && enabled) subtreeSet.add(node.path);
- if (node.isRoot && !enabled) {
- subtreeSet.clear();
- selfSet.clear();
- }
- if (enabled) {
- if (node.hasDirectFiles && !node.isRoot) selfSet.add(node.path);
- const childNodes = node.children || [];
- for (let i = 0; i < childNodes.length; i++) {
- setNodeSubtreeSelection(childNodes[i], true, subtreeSet, selfSet);
- }
+function selectFolderPath(path) {
+ const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections));
+ if (isPathCovered(path, selectionSet)) return Array.from(selectionSet);
+ const next = Array.from(selectionSet).filter((item) => !(item === path || item.startsWith(path + "/")));
+ next.push(path);
+ return normalizeFolderSelections(next);
+}
+
+function collectSelectionsExcept(node, excludedPath, into) {
+ if (!node.path) {
+ for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into);
return;
}
- subtreeSet.delete(node.path);
- if (node.hasDirectFiles) selfSet.delete(node.path);
- const childNodes = node.children || [];
- for (let i = 0; i < childNodes.length; i++) {
- setNodeSubtreeSelection(childNodes[i], false, subtreeSet, selfSet);
+ if (node.path === excludedPath || excludedPath.startsWith(node.path + "/")) {
+ for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into);
+ return;
}
+ into.push(node.path);
+}
+
+function deselectFolderPath(path) {
+ let next = normalizeFolderSelections(STATE.folderSelections).filter((item) => !(item === path || item.startsWith(path + "/")));
+ const parts = path.split("/");
+ while (parts.length > 1) {
+ parts.pop();
+ const ancestorPath = parts.join("/");
+ const ancestorIndex = next.indexOf(ancestorPath);
+ if (ancestorIndex === -1) continue;
+ next.splice(ancestorIndex, 1);
+ const ancestorNode = STATE.folderNodeMap[ancestorPath];
+ if (ancestorNode) {
+ const expanded = [];
+ collectSelectionsExcept(ancestorNode, path, expanded);
+ next.push(...expanded);
+ }
+ break;
+ }
+ return normalizeFolderSelections(next);
+}
+
+function invertFolderSelections() {
+ const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections));
+ const stateCache = new Map();
+ const next = [];
+ const invertNode = (node) => {
+ const state = getFolderSelectionState(node, selectionSet, stateCache);
+ if (state === "checked") return;
+ if (state === "unchecked") {
+ if (node.path) next.push(node.path);
+ else for (const child of node.children || []) invertNode(child);
+ return;
+ }
+ for (const child of node.children || []) invertNode(child);
+ };
+ for (const node of STATE.folderTree) invertNode(node);
+ return normalizeFolderSelections(next);
+}
+
+function refreshFolderTreeSelectionState() {
+ if (!DOM.filterFolderTree) return;
+ const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections));
+ const stateCache = new Map();
+ DOM.filterFolderTree.querySelectorAll("input[data-folder-path]").forEach((checkbox) => {
+ const node = STATE.folderNodeMap[checkbox.dataset.folderPath];
+ if (!node) return;
+ const state = getFolderSelectionState(node, selectionSet, stateCache);
+ checkbox.checked = state === "checked";
+ checkbox.indeterminate = state === "indeterminate";
+ });
+ DOM.filterFolderTree.querySelectorAll(".tree-toggle[data-folder-path]").forEach((toggle) => {
+ const path = toggle.dataset.folderPath;
+ const collapsed = !!STATE.folderCollapsed[path];
+ toggle.classList.toggle("expanded", !collapsed);
+ toggle.setAttribute("aria-label", collapsed ? "展开子文件夹" : "收起子文件夹");
+ toggle.setAttribute("title", collapsed ? "展开" : "收起");
+ });
}
-function persistFolderSelection(subtreeSet, selfSet) {
- STATE.filterFolderSubtrees = Array.from(subtreeSet);
- STATE.filterFolderSelfs = Array.from(selfSet);
- const merged = [];
- selfSet.forEach(path => { if (path) merged.push(path); });
- subtreeSet.forEach(path => { if (path && !merged.includes(path)) merged.push(path); });
- STATE.filterFolders = merged;
+function applyFolderSelections(nextSelections) {
+ STATE.folderSelections = normalizeFolderSelections(nextSelections);
STATE.page = 1;
- STATE.results = [];
- syncStateToURL(true);
+ refreshFolderTreeSelectionState();
+ syncUrl();
doSearch();
}
-function collectFolderNodePaths(nodes, subtreePaths, selfPaths) {
- for (let i = 0; i < nodes.length; i++) {
- const node = nodes[i];
- if (!node.isRoot && node.path) subtreePaths.push(node.path);
- if (!node.isRoot && node.hasDirectFiles) selfPaths.push(node.path);
- if (node.children && node.children.length > 0) {
- collectFolderNodePaths(node.children, subtreePaths, selfPaths);
+function renderFolderTreeRows(animation = {}) {
+ const container = DOM.filterFolderTree;
+ if (!container) return;
+ const firstRects = new Map();
+ container.querySelectorAll(".filter-folder-item[data-folder-path]").forEach((row) => {
+ firstRects.set(row.dataset.folderPath, row.getBoundingClientRect());
+ });
+ container.innerHTML = "";
+ renderFolderNodes(container, STATE.folderTree, 0);
+ refreshFolderTreeSelectionState();
+ if (!motionAllowed()) return;
+ const newRows = container.querySelectorAll(".filter-folder-item[data-folder-path]");
+ newRows.forEach((row) => {
+ const path = row.dataset.folderPath;
+ const lastRect = row.getBoundingClientRect();
+ const firstRect = firstRects.get(path);
+ if (firstRect) {
+ const deltaY = firstRect.top - lastRect.top;
+ if (Math.abs(deltaY) > 0.5) {
+ row.animate([
+ { transform: `translateY(${deltaY}px)` },
+ { transform: "translateY(0)" },
+ ], {
+ duration: 460,
+ easing: "cubic-bezier(0.05, 0.7, 0.1, 1)",
+ });
+ }
+ } else {
+ row.animate([
+ { opacity: 0, transform: "translateY(-18px) scale(0.94)" },
+ { opacity: 1, transform: "translateY(2px) scale(1.012)", offset: 0.66 },
+ { opacity: 1, transform: "translateY(0)" },
+ ], {
+ duration: 460,
+ easing: "cubic-bezier(0.05, 0.7, 0.1, 1)",
+ });
}
- }
+ if (animation.expanding && animation.toggledPath && row.dataset.folderPath === animation.toggledPath) {
+ const glyph = row.querySelector(".tree-toggle-glyph");
+ if (glyph) {
+ const fromTransform = "rotate(-45deg)";
+ const toTransform = "rotate(45deg)";
+ glyph.animate([
+ { transform: fromTransform },
+ { transform: toTransform },
+ ], {
+ duration: 420,
+ easing: "cubic-bezier(0.18, 1.32, 0.32, 1)",
+ });
+ }
+ }
+ });
}
-function applyFolderSelectionToNode(node, row, subtreeSet, selfSet) {
- const cb = row.querySelector("input[type='checkbox']");
- if (!cb) return;
- const full = isNodeFullySelected(node, subtreeSet, selfSet);
- const partial = isNodePartiallySelected(node, subtreeSet, selfSet);
- cb.checked = full;
- cb.indeterminate = !full && partial;
- const selfBtn = row.querySelector(".folder-self-toggle");
- if (selfBtn) {
- const selfOn = selfSet.has(node.path);
- selfBtn.classList.toggle("active", selfOn);
- selfBtn.setAttribute("aria-pressed", selfOn ? "true" : "false");
+function animateFolderCollapse(nodePath, exitingPaths) {
+ const container = DOM.filterFolderTree;
+ if (!container) return 180;
+ const duration = motionDuration(180);
+ if (!duration) return 0;
+ const glyph = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(nodePath)}"] .tree-toggle-glyph`);
+ if (glyph) {
+ glyph.animate([
+ { transform: "rotate(45deg)" },
+ { transform: "rotate(-45deg)" },
+ ], {
+ duration: 180,
+ easing: "cubic-bezier(0.3, 0, 0.8, 0.15)",
+ fill: "forwards",
+ });
+ }
+ for (const path of exitingPaths) {
+ const row = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(path)}"]`);
+ if (!row) continue;
+ row.animate([
+ { opacity: 1, transform: "translateY(0) scaleY(1)" },
+ { opacity: 0, transform: "translateY(-10px) scaleY(0.96)" },
+ ], {
+ duration,
+ easing: "cubic-bezier(0.3, 0, 0.8, 0.15)",
+ fill: "forwards",
+ });
}
+ return duration;
}
-function renderFilterTreeNodes(container, nodes, depth) {
- const subtreeSet = getFolderSubtreeSet();
- const selfSet = getFolderSelfSet();
- for (const node of nodes) {
- const has = node.children && node.children.length > 0;
+function renderFolderNodes(container, nodes, depth) {
+ const visibleNodes = getVisibleFolderNodes(nodes, depth);
+ for (const { node, depth: itemDepth } of visibleNodes) {
+ const hasChildren = !!(node.children && node.children.length);
const row = document.createElement("div");
row.className = "filter-folder-item";
- row.style.setProperty("--fdepth", depth);
- row.dataset.path = node.path;
- const collapsed = !!STATE.folderTreeCollapsed[node.path];
- row.innerHTML = `${has ? `
` : '
'}
${escapeHTML(node.name)}${node.showSelfToggle ? `
` : ''}
${(node.count || 0).toLocaleString()}`;
+ row.dataset.folderPath = node.path;
+ row.style.setProperty("--fdepth", itemDepth);
+ row.innerHTML = `${hasChildren ? `
` : '
'}
${escapeHTML(node.name)}${(node.count || 0).toLocaleString()}`;
const toggle = row.querySelector(".tree-toggle");
- const cb = row.querySelector("input");
- const selfBtn = row.querySelector(".folder-self-toggle");
- applyFolderSelectionToNode(node, row, subtreeSet, selfSet);
- cb.addEventListener("click", function(e) {
- e.preventDefault();
- handleFolderCheckboxChange(node);
- });
- if (selfBtn) {
- selfBtn.addEventListener("click", function(e) {
- e.preventDefault();
- e.stopPropagation();
- handleFolderSelfToggle(node);
- });
- }
- if (has) {
- const childDiv = document.createElement("div");
- childDiv.className = "tree-children";
- if (collapsed) childDiv.style.display = "none";
- renderFilterTreeNodes(childDiv, node.children, depth + 1);
- toggle.addEventListener("click", (e) => {
- e.stopPropagation();
- const expanding = !!STATE.folderTreeCollapsed[node.path];
- STATE.folderTreeCollapsed[node.path] = !expanding;
- toggleFolderChildrenAnimated(childDiv, toggle, expanding);
+ if (toggle) {
+ toggle.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const expanding = !!STATE.folderCollapsed[node.path];
+ if (expanding) {
+ STATE.folderCollapsed[node.path] = false;
+ renderFolderTreeRows({ toggledPath: node.path, expanding: true });
+ return;
+ }
+ const exitingPaths = collectVisibleDescendantPaths(node);
+ const duration = animateFolderCollapse(node.path, exitingPaths);
+ STATE.folderCollapsed[node.path] = true;
+ window.setTimeout(() => {
+ renderFolderTreeRows({ toggledPath: node.path, expanding: false });
+ }, duration);
});
- container.appendChild(row);
- container.appendChild(childDiv);
- } else {
- container.appendChild(row);
}
+ const checkbox = row.querySelector("input");
+ checkbox.addEventListener("change", () => {
+ const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections));
+ const state = getFolderSelectionState(node, selectionSet);
+ if (state === "checked") applyFolderSelections(deselectFolderPath(node.path));
+ else applyFolderSelections(selectFolderPath(node.path));
+ });
+ container.appendChild(row);
}
}
-function handleFolderCheckboxChange(node) {
- const subtreeSet = getFolderSubtreeSet();
- const selfSet = getFolderSelfSet();
- const full = isNodeFullySelected(node, subtreeSet, selfSet);
- setNodeSubtreeSelection(node, !full, subtreeSet, selfSet);
- persistFolderSelection(subtreeSet, selfSet);
- renderFilterFolderTree();
+async function renderFolderTree(forceReload = false, routeId = routeRenderId) {
+ updateFilterVisibility();
+ if (!STATE.source) {
+ DOM.filterFolderTree.innerHTML = '
进入单仓库后可按路径过滤
';
+ STATE.folderTree = [];
+ STATE.folderTreeSource = null;
+ STATE.folderNodeMap = {};
+ return;
+ }
+ if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) {
+ const source = STATE.source;
+ let data;
+ try {
+ data = await API.getFolders(source);
+ } catch (error) {
+ if (routeId === routeRenderId && STATE.source === source) {
+ DOM.filterFolderTree.innerHTML = '';
+ }
+ return;
+ }
+ if (routeId !== routeRenderId || STATE.source !== source) return;
+ STATE.folderTree = normalizeFolderTree(data);
+ STATE.folderTreeSource = STATE.source;
+ STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree);
+ STATE.folderCollapsed = {};
+ initializeFolderCollapsedState(STATE.folderTree);
+ renderFolderTreeRows();
+ }
+ refreshFolderTreeSelectionState();
}
-function handleFolderSelfToggle(node) {
- const subtreeSet = getFolderSubtreeSet();
- const selfSet = getFolderSelfSet();
- if (selfSet.has(node.path)) selfSet.delete(node.path);
- else selfSet.add(node.path);
- persistFolderSelection(subtreeSet, selfSet);
- renderFilterFolderTree();
+function retrySidebar(source, path, routeId) {
+ const retryKey = `${source}\n${path || ""}`;
+ const tries = sidebarRetryCounts.get(retryKey) || 0;
+ if (tries >= 2) {
+ DOM.sidebarContent.innerHTML = '';
+ return;
+ }
+ sidebarRetryCounts.set(retryKey, tries + 1);
+ DOM.sidebarContent.innerHTML = '';
+ setTimeout(() => {
+ if (routeId !== routeRenderId || STATE.source !== source) return;
+ if (path) openFolder(path, routeId);
+ else renderSidebar(routeId);
+ }, 1200);
}
-async function fetchHitokoto() {
- try {
- const resp = await fetch("https://vomebook-hitokoto.hf.space/");
- const data = await resp.json();
- const text = data.hitokoto || data.text || data.content || data.sentence || "";
- if (text) typewriter(DOM.hitokoto, text);
- } catch (e) { DOM.hitokoto.textContent = ""; }
+function renderSourceListSidebar(sources) {
+ DOM.sidebarTitle.textContent = "数据包";
+ DOM.sidebarContent.innerHTML = (sources || []).map((source) => `
${ICON_HTML.source}${escapeHTML(source.name)}${(source.count || 0).toLocaleString()}
`).join("");
}
-function typewriter(el, text, speed = 60) {
- el.style.opacity = "0";
- el.textContent = "";
- setTimeout(() => { el.style.transition = "opacity 0.5s ease"; el.style.opacity = "0.55"; }, 100);
- let i = 0;
- const t = setInterval(() => {
- el.textContent = text.slice(0, i + 1);
- if (++i >= text.length) clearInterval(t);
- }, speed);
+function browserContentHTML(data, path, includeBreadcrumb) {
+ const parts = path ? path.split("/") : [];
+ let html = '
返回全局搜索
';
+ if (includeBreadcrumb) {
+ html += ``;
+ }
+ html += (data.folders || []).map((item) => `
${ICON_HTML.folder}${escapeHTML(item.name)}${(item.count || 0).toLocaleString()}
`).join("");
+ html += (data.files || []).map((item) => `
${ICON_HTML.file}${escapeHTML(item.name)}.txt${escapeHTML(formatSize(item.size))}
`).join("");
+ return html;
}
-async function randomBook() {
+async function applySidebarInitial(routeId) {
try {
- showToast("正在随机下载书籍...");
- const rec = await API.getRandom(STATE.repo);
- const link = rec ? (rec.Link || rec.link || "") : "";
- if (link) {
- const filename = (rec.File || "file") + (rec.Extension ? '.' + rec.Extension : '');
- downloadFile(filename, link, { skipCheck: true });
- } else {
- showToast("暂无可下载书籍");
- }
- } catch (e) {
- console.error(e);
- showToast("随机下载失败");
+ const data = await loadSidebarInitial(STATE.source);
+ if (routeId !== routeRenderId) return false;
+ if (!data) return false;
+ if (!STATE.source) {
+ if (!Array.isArray(data.sources)) return false;
+ renderSourceListSidebar(data.sources);
+ return true;
+ }
+ if (data.source !== STATE.source || (data.path || "") !== "") return false;
+ DOM.sidebarTitle.textContent = (STATE.sourceMap[STATE.source] && STATE.sourceMap[STATE.source].name) || STATE.source;
+ DOM.sidebarContent.innerHTML = browserContentHTML(data, "", false);
+ STATE.folderContentsCache.set(`${STATE.source}\n`, data);
+ return true;
+ } catch (_error) {
+ return false;
}
}
-let randomReaderRequestId = 0;
-
-function openReaderRecord(rec, returnUrl = location.href) {
- if (!rec || location.href !== returnUrl) return false;
- const url = getReaderLink(rec, returnUrl);
- if (!url) return false;
- if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
- return navigateToReader(url, returnUrl);
-}
-
-async function randomTxt() {
- const requestId = ++randomReaderRequestId;
- const returnUrl = location.href;
+async function renderSidebar(routeId = routeRenderId) {
+ const initialApplied = await applySidebarInitial(routeId);
+ if (!STATE.source) {
+ if (!initialApplied) renderSourceListSidebar(STATE.sources);
+ return;
+ }
+ const source = STATE.source;
+ sidebarCurrentPath = "";
+ DOM.sidebarTitle.textContent = (STATE.sourceMap[source] && STATE.sourceMap[source].name) || source;
+ if (initialApplied) return;
+ DOM.sidebarContent.innerHTML = '';
+ let data;
try {
- showToast("正在随机打开书籍...");
- const url = STATE.repo ? `/api/random-reader?repo=${encodeURIComponent(STATE.repo)}` : "/api/random-reader";
- const resp = await fetch(url);
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- const rec = await resp.json();
- if (requestId !== randomReaderRequestId || location.href !== returnUrl) return;
- if (!openReaderRecord(rec, returnUrl)) {
- showToast("暂无可读书籍");
- }
- } catch (e) {
- console.error(e);
- showToast("随机打开失败");
+ data = await API.getContents(source, "");
+ } catch (error) {
+ if (routeId === routeRenderId && STATE.source === source) retrySidebar(source, "", routeId);
+ return;
}
+ if (routeId !== routeRenderId || STATE.source !== source) return;
+ sidebarRetryCounts.delete(`${source}\n`);
+ DOM.sidebarContent.innerHTML = browserContentHTML(data, "", false);
}
-let toastTimer;
-function showToast(msg, dur = 2000) {
- DOM.toast.textContent = msg;
- DOM.toast.style.display = "";
- DOM.toast.style.animation = "none";
- void DOM.toast.offsetWidth;
- DOM.toast.style.animation = "toast-in 0.2s ease";
- clearTimeout(toastTimer);
- toastTimer = setTimeout(() => { DOM.toast.style.display = "none"; }, dur);
-}
-function maybeLoadNextPage() {
- if (VSCROLL.isDraggingThumb) return;
- if (STATE.isLoading || !STATE.hasMore) return;
- const scrollTop = DOM.resultsContainer.scrollTop;
- const loadedHeight = DOM.resultsContainer.scrollHeight;
- const triggerPoint = loadedHeight * 0.05;
- if (scrollTop >= triggerPoint) {
- STATE.page++;
- doSearch(true);
+async function openFolder(path, routeId = routeRenderId) {
+ const source = STATE.source;
+ sidebarCurrentPath = path || "";
+ DOM.sidebarContent.innerHTML = '';
+ let data;
+ try {
+ data = await API.getContents(source, path);
+ } catch (error) {
+ if (routeId === routeRenderId && STATE.source === source && sidebarCurrentPath === (path || "")) retrySidebar(source, path, routeId);
+ return;
}
+ if (routeId !== routeRenderId || STATE.source !== source || sidebarCurrentPath !== (path || "")) return;
+ sidebarRetryCounts.delete(`${source}\n${path || ""}`);
+ DOM.sidebarContent.innerHTML = browserContentHTML(data, path, true);
}
-function recoverScrollState() {
- scrollRecoveryTimer = null;
- scrollTicking = false;
- if (scrollLoadTimer) {
- clearTimeout(scrollLoadTimer);
- scrollLoadTimer = null;
- }
- VSCROLL.isDraggingThumb = false;
- if (DOM.scrollThumb) DOM.scrollThumb.classList.remove("dragging");
- if (STATE._deferredAppendWhileDragging) {
- STATE._deferredAppendWhileDragging = false;
- if (consumeCachedAppendPage()) return;
- }
- VSCROLL.renderStart = -1;
- VSCROLL.renderEnd = -1;
- renderVisible();
- updateScrollTrack();
- prefetchNextPage();
- scheduleScrollLoad(0);
- recoverSidebarState();
+function updateSidebarVisibility() {
+ DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
+ DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen);
+ DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
+ DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
+ DOM.overlay.style.display = "";
+ DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
}
function recoverSidebarState() {
- if (!DOM.sidebarContent) return;
- const stuck = DOM.sidebarContent.querySelector(".sidebar-loading");
- if (!stuck) return;
- if (API._browserPending) API._browserPending.clear();
+ if (!DOM.sidebarContent || !DOM.sidebarContent.querySelector(".sidebar-loading")) return;
+ folderContentsPending.clear();
+ folderTreePending.clear();
sidebarRetryCounts.clear();
- renderSidebar(routeRenderId);
- if (STATE.rightSidebarOpen) renderFilters(routeRenderId);
+ const routeId = ++routeRenderId;
+ if (STATE.source && sidebarCurrentPath) openFolder(sidebarCurrentPath, routeId);
+ else renderSidebar(routeId);
+ renderFolderTree(false, routeId);
}
-function scheduleScrollRecovery(delay = 0) {
- if (scrollRecoveryTimer) clearTimeout(scrollRecoveryTimer);
- scrollRecoveryTimer = setTimeout(recoverScrollState, delay);
+function recoverScrollState() {
+ if (!DOM.resultsContainer) return;
+ if (STATE.isLoading && !STATE.searchController) {
+ STATE.isLoading = false;
+ DOM.resultsLoading.style.display = "none";
+ }
+ const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 240;
+ if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) {
+ STATE.page += 1;
+ doSearch();
+ } else {
+ prefetchNextPage();
+ }
}
-function scheduleScrollLoad(delay) {
- if (VSCROLL.isDraggingThumb && delay > 0) return;
- if (scrollLoadTimer) clearTimeout(scrollLoadTimer);
- scrollLoadTimer = setTimeout(() => {
- scrollLoadTimer = null;
- if (VSCROLL.isDraggingThumb) return;
- if (STATE._deferredAppendWhileDragging) {
- STATE._deferredAppendWhileDragging = false;
- if (consumeCachedAppendPage()) return;
- }
- maybeLoadNextPage();
+function schedulePageRecovery(delay = 0) {
+ if (!initComplete) return;
+ clearTimeout(scrollRecoveryTimer);
+ scrollRecoveryTimer = setTimeout(() => {
+ recoverSidebarState();
+ recoverScrollState();
}, delay);
}
-function setupInfiniteScroll() {
- DOM.resultsContainer.addEventListener("scroll", () => {
- if (!VSCROLL.isDraggingThumb) ensureVirtualViewportCovered();
- if (!scrollTicking) {
- requestAnimationFrame(() => {
- renderVisible();
- if (VSCROLL.isDraggingThumb) updateScrollThumb();
- else {
- updateScrollTrack();
- maybeLoadNextPage();
- }
- scrollTicking = false;
- });
- scrollTicking = true;
- }
- }, { passive: true });
+function clearFilters() {
+ STATE.page = 1;
+ STATE.folderSelections = [];
+ STATE.selectedSources = [];
+ STATE.minSize = null;
+ STATE.maxSize = null;
+ DOM.filterMinSize.value = "";
+ DOM.filterMaxSize.value = "";
+ DOM.filterMinUnit.value = "MB";
+ DOM.filterMaxUnit.value = "MB";
+ renderSourcesFilter();
+ refreshFolderTreeSelectionState();
+ syncUrl();
+ doSearch();
}
-function updateScrollTrack() {
- if (DOM.scrollTrack) {
- DOM.scrollTrack.style.top = DOM.resultsContainer.offsetTop + "px";
- DOM.scrollTrack.style.height = DOM.resultsContainer.clientHeight + "px";
- DOM.scrollTrack.style.bottom = "auto";
- DOM.scrollTrack.style.right = "";
- updateScrollThumb();
- }
-}
-
-function updateScrollThumb() {
- const scrollTop = DOM.resultsContainer.scrollTop;
- const scrollHeight = DOM.resultsContainer.scrollHeight;
- const clientHeight = DOM.resultsContainer.clientHeight;
- if (scrollHeight <= clientHeight || !DOM.scrollTrack.clientHeight) { DOM.scrollTrack.classList.remove("visible"); return; }
- DOM.scrollTrack.classList.add("visible");
- const trackHeight = DOM.scrollTrack.clientHeight;
- const th = Math.max(40, Math.min(trackHeight, (clientHeight / scrollHeight) * trackHeight));
- const tt = (scrollTop / Math.max(1, scrollHeight - clientHeight)) * (trackHeight - th);
- DOM.scrollThumb.style.height = th + "px";
- DOM.scrollThumb.style.transform = "translateY(" + tt + "px)";
-}
-
-function setupQuickScroll() {
- let dragging = false, startY, startST, dragRange, maxScrollTop;
- let dragFrame = 0;
- let pendingScrollTop = null;
- function applyPendingScrollTop() {
- dragFrame = 0;
- if (pendingScrollTop === null) return;
- DOM.resultsContainer.scrollTop = pendingScrollTop;
- pendingScrollTop = null;
- renderVisible();
- updateScrollThumb();
- }
- function setResultScrollTop(value) {
- pendingScrollTop = Math.max(0, Math.min(value, maxScrollTop));
- if (!dragFrame) dragFrame = requestAnimationFrame(applyPendingScrollTop);
- }
- function beginDrag(clientY) {
- const scrollEl = DOM.resultsContainer;
- startY = clientY;
- startST = scrollEl.scrollTop;
- dragRange = Math.max(1, DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight);
- maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
- dragging = true;
- VSCROLL.isDraggingThumb = true;
- DOM.scrollThumb.classList.add("dragging");
- }
- function finishDrag() {
- if (dragFrame) cancelAnimationFrame(dragFrame);
- applyPendingScrollTop();
- dragging = false;
- VSCROLL.isDraggingThumb = false;
- DOM.scrollThumb.classList.remove("dragging");
- ensureVirtualViewportCovered();
- updateScrollTrack();
- }
- function onMouseMove(e) {
- const delta = e.clientY - startY;
- const ratio = delta / dragRange;
- setResultScrollTop(startST + ratio * maxScrollTop);
- }
- function onMouseUp() {
- document.removeEventListener("mousemove", onMouseMove);
- document.removeEventListener("mouseup", onMouseUp);
- finishDrag();
- if (STATE._deferredAppendWhileDragging) {
- STATE._deferredAppendWhileDragging = false;
- if (consumeCachedAppendPage()) return;
- }
- maybeLoadNextPage();
- }
- DOM.scrollThumb.addEventListener("mousedown", (e) => {
- if (dragging) return;
- beginDrag(e.clientY); e.preventDefault(); e.stopPropagation();
- document.addEventListener("mousemove", onMouseMove);
- document.addEventListener("mouseup", onMouseUp);
- });
- function onTouchMove(e) {
- e.preventDefault();
- const delta = e.touches[0].clientY - startY;
- const ratio = delta / dragRange;
- setResultScrollTop(startST + ratio * maxScrollTop);
- }
- function onTouchEnd() {
- document.removeEventListener("touchmove", onTouchMove);
- document.removeEventListener("touchend", onTouchEnd);
- document.removeEventListener("touchcancel", onTouchEnd);
- finishDrag();
- if (STATE._deferredAppendWhileDragging) {
- STATE._deferredAppendWhileDragging = false;
- if (consumeCachedAppendPage()) return;
+async function randomDoc() {
+ try {
+ const data = await API.random(STATE.source);
+ if (!data.doc_id) {
+ showToast("暂无可用文档");
+ return;
}
- maybeLoadNextPage();
+ openPreview(data.doc_id, "");
+ } catch (error) {
+ console.error(error);
+ showToast("随机文章加载失败");
}
- DOM.scrollThumb.addEventListener("touchstart", (e) => {
- beginDrag(e.touches[0].clientY); e.stopPropagation();
- document.addEventListener("touchmove", onTouchMove, { passive: false });
- document.addEventListener("touchend", onTouchEnd);
- document.addEventListener("touchcancel", onTouchEnd);
- });
-}
-
-function toggleTheme() {
- const btn = DOM.themeBtn;
- const rect = btn.getBoundingClientRect();
- const ripple = document.createElement("div");
- ripple.className = "theme-ripple";
- ripple.style.left = (rect.left + rect.width / 2) + "px";
- ripple.style.top = (rect.top + rect.height / 2) + "px";
- ripple.style.background = STATE.isDark ? "#fff" : "#1a1c1e";
- document.body.appendChild(ripple);
- document.body.classList.add("theme-transitioning");
- STATE.isDark = !STATE.isDark;
- applyTheme();
- localStorage.setItem("theme", STATE.isDark ? "dark" : "light");
- ripple.addEventListener("animationend", () => {
- ripple.remove();
- document.body.classList.remove("theme-transitioning");
- });
-}
-
-function applyTheme() {
- if (STATE.isDark) { document.body.classList.remove("light"); DOM.themeIconLight.style.display = "none"; DOM.themeIconDark.style.display = ""; }
- else { document.body.classList.add("light"); DOM.themeIconLight.style.display = ""; DOM.themeIconDark.style.display = "none"; }
}
-function toggleMobile() { STATE.isMobile = !STATE.isMobile; applyMobileMode(); localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); }
-
-function applyMobileMode() {
- if (STATE.isMobile) {
- document.body.classList.add("mobile"); document.body.classList.remove("force-desktop");
- DOM.mobileIconPhone.style.display = ""; DOM.mobileIconDesktop.style.display = "none";
- STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false;
- } else {
- document.body.classList.remove("mobile"); document.body.classList.add("force-desktop");
- DOM.mobileIconPhone.style.display = "none"; DOM.mobileIconDesktop.style.display = "";
- STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false;
- }
- updateSidebarVisibility();
- document.documentElement.classList.remove("mobile-boot");
- if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none";
- updateSelectionUI();
- requestAnimationFrame(updateScrollTrack);
+function updateMultiUi() {
+ STATE.multiSelect = DOM.multiSelectToggle.checked;
+ DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none";
+ document.body.classList.toggle("multiselect", STATE.multiSelect);
+ if (!STATE.multiSelect) STATE.selectedIds.clear();
+ DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : "";
+ renderResults();
}
-function autoDetectMobile() { return window.innerWidth <= 768; }
-
-function toggleLeftSidebar() {
- STATE.leftSidebarOpen = !STATE.leftSidebarOpen;
- if (!STATE.leftSidebarOpen) {
- DOM.leftSidebar.classList.remove("expanded-wide");
- DOM.sidebarExpandBtn.textContent = "↔";
+function attachEvents() {
+ DOM.headerLogo.addEventListener("click", (event) => {
+ if (window.location.pathname !== "/") return;
+ event.preventDefault();
+ window.location.href = DOM.headerLogo.href;
+ });
+ DOM.hamburgerBtn.addEventListener("click", () => {
+ STATE.leftSidebarOpen = !STATE.leftSidebarOpen;
+ if (!STATE.leftSidebarOpen) {
+ DOM.leftSidebar.classList.remove("expanded-wide");
+ if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.textContent = "↔";
+ }
+ if (STATE.isMobile && STATE.leftSidebarOpen) STATE.rightSidebarOpen = false;
+ updateSidebarVisibility();
+ syncUrl();
+ });
+ if (DOM.sidebarExpandBtn) {
+ DOM.sidebarExpandBtn.addEventListener("click", () => {
+ DOM.leftSidebar.classList.toggle("expanded-wide");
+ DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
+ syncUrl();
+ });
}
- if (STATE.isMobile && STATE.leftSidebarOpen && STATE.rightSidebarOpen) STATE.rightSidebarOpen = false;
- updateSidebarVisibility();
- syncStateToURL(true);
-}
-
-function toggleRightSidebar() {
- STATE.rightSidebarOpen = !STATE.rightSidebarOpen;
- if (STATE.rightSidebarOpen && STATE.leftSidebarOpen && STATE.isMobile) STATE.leftSidebarOpen = false;
- updateSidebarVisibility();
- syncStateToURL(true);
-}
-
-function updateSidebarVisibility() {
- DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen);
- DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen);
- DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen);
- DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen);
- DOM.overlay.style.display = "";
- DOM.overlay.classList.toggle("open", STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen));
-}
-let keyboardResultIndex = -1;
-
-function focusKeyboardResult(index) {
- keyboardResultIndex = Math.max(0, Math.min(index, STATE.results.length - 1));
- const top = getVirtualOffset(keyboardResultIndex);
- const bottom = getVirtualOffset(keyboardResultIndex + 1);
- const viewTop = DOM.resultsContainer.scrollTop;
- const viewBottom = viewTop + DOM.resultsContainer.clientHeight;
- if (top < viewTop || bottom > viewBottom) {
- DOM.resultsContainer.scrollTop = top;
- }
- VSCROLL.renderStart = 0;
- VSCROLL.renderEnd = 0;
- renderVisible();
- requestAnimationFrame(() => {
- DOM.resultsList.querySelectorAll(".result-item.keyboard-focus").forEach((it) => it.classList.remove("keyboard-focus"));
- const el = DOM.resultsList.querySelector('.result-item[data-index="' + keyboardResultIndex + '"]');
- if (el) el.classList.add("keyboard-focus");
+ DOM.settingsBtn.addEventListener("click", () => {
+ STATE.rightSidebarOpen = !STATE.rightSidebarOpen;
+ if (STATE.isMobile && STATE.rightSidebarOpen) STATE.leftSidebarOpen = false;
+ updateSidebarVisibility();
+ syncUrl();
});
-}
-
-function setupKeyboard() {
- document.addEventListener("keydown", (e) => {
- const tag = document.activeElement.tagName;
- const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
- if (e.key === "/" && !isInput) { e.preventDefault(); DOM.searchInput.focus(); DOM.searchInput.select(); return; }
- if (e.key === "Escape") {
- if (STATE.rightSidebarOpen || STATE.leftSidebarOpen) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); return; }
- if (DOM.searchInput.value) { DOM.searchInput.value = ""; STATE.query = ""; STATE.page = 1; STATE.results = []; doSearch(); return; }
- DOM.searchInput.blur(); return;
- }
- if (e.key === "b" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleLeftSidebar(); return; }
- if (e.key === "ArrowDown" || e.key === "ArrowUp") {
- if (STATE.results.length === 0) return;
- e.preventDefault();
- if (e.key === "ArrowDown") focusKeyboardResult(keyboardResultIndex < 0 ? 0 : keyboardResultIndex + 1);
- else focusKeyboardResult(keyboardResultIndex < 0 ? 0 : keyboardResultIndex - 1);
- return;
- }
- if (e.key === "Enter") {
- if (keyboardResultIndex >= 0 && keyboardResultIndex < STATE.results.length) {
- const rec = STATE.results[keyboardResultIndex];
- if (rec) openExternalWindow(getRecordLink(rec));
- return;
- }
- }
+ DOM.closeFiltersBtn.addEventListener("click", () => {
+ STATE.rightSidebarOpen = false;
+ updateSidebarVisibility();
+ syncUrl();
});
- DOM.searchInput.addEventListener("keydown", (e) => {
- if (e.key === "Enter") {
- e.preventDefault(); STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; keyboardResultIndex = -1;
- doSearch(); DOM.searchInput.blur();
- }
+ DOM.overlay.addEventListener("click", () => {
+ STATE.leftSidebarOpen = false;
+ STATE.rightSidebarOpen = false;
+ updateSidebarVisibility();
+ syncUrl();
});
-}
-
-function clearAllFilters() {
- STATE.filterRepos = []; STATE.filterExtensions = []; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = [];
- STATE.filterMinSize = null; STATE.filterMaxSize = null;
- saveStoredExtensionFilters();
- STATE.page = 1; STATE.results = [];
- DOM.filterMinSize.value = ""; DOM.filterMaxSize.value = "";
- DOM.filterMinUnit.value = "MB"; DOM.filterMaxUnit.value = "MB";
- renderFilters(routeRenderId); doSearch(); showToast("已清空所有筛选条件");
- syncStateToURL(true);
-}
-
-function setupResultDelegation() {
- DOM.resultsList.addEventListener("click", async (e) => {
- const actionBtn = e.target.closest("[data-action]");
- if (actionBtn) {
- e.preventDefault();
- const action = actionBtn.dataset.action;
- if (action === "copy") {
- try { await navigator.clipboard.writeText(actionBtn.dataset.link); showToast("链接已复制"); } catch { showToast("复制失败"); }
- return;
- }
- if (action === "download") {
- await downloadFile(actionBtn.dataset.filename || "file", actionBtn.dataset.link || "");
- return;
- }
- if (action === "read") {
- if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
- navigateToReader(actionBtn.dataset.readerUrl);
- return;
- }
- }
- const repoTag = e.target.closest(".result-repo-tag");
- if (repoTag) { ROUTER.navigate("repo", repoTag.dataset.repo); return; }
- const folderLink = e.target.closest(".path-folder");
- if (folderLink) {
- const folder = folderLink.dataset.folder;
- const repo = folderLink.dataset.repo;
- if (repo && STATE.mode === "global") {
- var params = new URLSearchParams();
- if (STATE.query) params.set("q", STATE.query);
- if (STATE.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize));
- if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize));
- if (STATE.filterExtensions.length > 0) params.set("ext", STATE.filterExtensions.join(","));
- if (!STATE.recordHistory) params.set("history", "0");
- if (!STATE.useMirrorLinks) params.set("mirror", "0");
- if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value);
- if (!STATE.searchFolders) params.set("search_folders", "false");
- if (!STATE.exact) params.set("exact", "0");
- if (!STATE.leftSidebarOpen) params.set("sidebar", "0");
- if (STATE.rightSidebarOpen) params.set("filters", "1");
- if (DOM.leftSidebar.classList.contains("expanded-wide")) params.set("wide", "1");
- if (folder) params.append("folder_self", folder);
- var qs = params.toString();
- history.pushState(null, "", "/" + encodeURIComponent(repo) + (qs ? "?" + qs : ""));
- ROUTER.apply();
- return;
- }
- if (folder !== undefined) {
- STATE.filterFolders = folder ? [folder] : [];
- STATE.filterFolderSubtrees = [];
- STATE.filterFolderSelfs = folder ? [folder] : [];
- STATE.page = 1; STATE.results = [];
- syncStateToURL(true);
- renderFilters(routeRenderId); doSearch();
- }
- return;
- }
+ DOM.themeBtn.addEventListener("click", toggleTheme);
+ DOM.mobileToggleBtn.addEventListener("click", () => {
+ STATE.isMobile = !STATE.isMobile;
+ applyMobileMode();
+ localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop");
+ applyUiUrlState();
+ updateSidebarVisibility();
+ syncUrl();
});
- DOM.sidebarContent.addEventListener("click", (e) => {
- const repoItem = e.target.closest(".repo-list-item");
- if (repoItem) { ROUTER.navigate("repo", repoItem.dataset.repo); return; }
- const browserItem = e.target.closest(".browser-item");
- if (!browserItem) return;
- if (e.target.closest(".browser-action")) {
- e.stopPropagation();
- const fileLink = browserItem.dataset.link;
- if (fileLink) downloadFile(browserItem.dataset.filename || "file", fileLink);
- return;
- }
- if (browserItem.dataset.type === "folder") {
- renderBrowser(browserItem.dataset.path, ++routeRenderId);
+ let historyDropdownActive = false;
+ DOM.searchInput.addEventListener("focus", () => {
+ renderHistoryDropdown();
+ warmConnection();
+ });
+ DOM.searchInput.addEventListener("blur", () => setTimeout(() => { if (!historyDropdownActive) setHistoryDropdownOpen(false); }, 120));
+ DOM.searchHistoryDropdown.addEventListener("mouseenter", () => { historyDropdownActive = true; });
+ DOM.searchHistoryDropdown.addEventListener("mouseleave", () => { historyDropdownActive = false; });
+ DOM.searchHistoryDropdown.addEventListener("click", (event) => {
+ const delBtn = event.target.closest(".history-del");
+ if (delBtn) {
+ event.stopPropagation();
+ removeHistory(delBtn.dataset.del);
+ historyDropdownActive = true;
+ DOM.searchInput.focus();
return;
}
- const readUrl = browserItem.dataset.readUrl;
- if (readUrl) {
- if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }
- navigateToReader(readUrl);
+ const clearBtn = event.target.closest(".history-clear-all");
+ if (clearBtn) {
+ clearHistory();
+ historyDropdownActive = true;
+ DOM.searchInput.focus();
return;
}
- const fileLink = browserItem.dataset.link;
- if (fileLink) downloadFile(browserItem.dataset.filename || "file", fileLink);
- });
-}
-
-async function init() {
- cacheDOM();
- setupReaderIntentWarming();
- STATE.isDark = localStorage.getItem("theme") !== "light";
- applyTheme();
- const savedMobile = localStorage.getItem("mobileMode");
- if (savedMobile === "mobile") STATE.isMobile = true;
- else if (savedMobile === "desktop") STATE.isMobile = false;
- else STATE.isMobile = autoDetectMobile();
- applyMobileMode();
- DOM.searchInput.addEventListener("input", debouncedSearch);
- DOM.searchInput.addEventListener("compositionstart", () => {
- searchComposing = true;
- });
- DOM.searchInput.addEventListener("compositionend", () => {
- searchComposing = false;
- debouncedSearch();
+ const item = event.target.closest("[data-query]");
+ if (!item) return;
+ DOM.searchInput.value = item.dataset.query;
+ STATE.query = item.dataset.query;
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
- const renderDropdown = () => {
- var list = getHistory();
- if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; }
- var html = "";
- for (var h = 0; h < list.length; h++) {
- html += '
' +
- '
' +
- '
' + escapeHTML(list[h]) + '' +
- '
' +
- '
';
+ let historyLongPressTimer = null;
+ const cancelHistoryLongPress = () => {
+ if (historyLongPressTimer) {
+ clearTimeout(historyLongPressTimer);
+ historyLongPressTimer = null;
}
- html += '';
- DOM.historyDropdown.innerHTML = html;
- DOM.historyDropdown.style.display = "";
- };
- const hideDropdown = () => {
- setTimeout(() => { if (!dropdownActive) DOM.historyDropdown.style.display = "none"; }, 150);
};
- var dropdownActive = false;
- DOM.searchInput.addEventListener("focus", () => { renderDropdown(); });
- DOM.searchInput.addEventListener("blur", hideDropdown);
- var longPressTimer = null;
- DOM.historyDropdown.addEventListener("mouseenter", () => { dropdownActive = true; });
- DOM.historyDropdown.addEventListener("mouseleave", () => { dropdownActive = false; });
- DOM.historyDropdown.addEventListener("mousedown", (e) => {
- if (e.target.closest(".history-del")) return;
- if (e.target.closest(".history-clear-all")) { saveHistory([]); DOM.historyDropdown.style.display = "none"; return; }
- var item = e.target.closest(".history-item");
- if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600);
+ DOM.searchHistoryDropdown.addEventListener("mousedown", (event) => {
+ const item = event.target.closest("[data-query]");
+ if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return;
+ cancelHistoryLongPress();
+ historyLongPressTimer = setTimeout(() => {
+ removeHistory(item.dataset.query);
+ historyLongPressTimer = null;
+ }, 600);
});
- DOM.historyDropdown.addEventListener("mouseup", () => { clearTimeout(longPressTimer); });
- DOM.historyDropdown.addEventListener("mouseleave", () => { clearTimeout(longPressTimer); });
- DOM.historyDropdown.addEventListener("touchstart", (e) => {
- if (e.target.closest(".history-del")) return;
- var item = e.target.closest(".history-item");
- if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600);
+ DOM.searchHistoryDropdown.addEventListener("mouseup", cancelHistoryLongPress);
+ DOM.searchHistoryDropdown.addEventListener("mouseleave", cancelHistoryLongPress);
+ DOM.searchHistoryDropdown.addEventListener("touchstart", (event) => {
+ const item = event.target.closest("[data-query]");
+ if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return;
+ cancelHistoryLongPress();
+ historyLongPressTimer = setTimeout(() => {
+ removeHistory(item.dataset.query);
+ historyLongPressTimer = null;
+ }, 650);
}, { passive: true });
- DOM.historyDropdown.addEventListener("touchend", () => { clearTimeout(longPressTimer); });
- DOM.historyDropdown.addEventListener("touchmove", () => { clearTimeout(longPressTimer); });
- DOM.historyDropdown.addEventListener("click", (e) => {
- var delBtn = e.target.closest(".history-del");
- if (delBtn) { removeHistoryItem(delBtn.dataset.del); return; }
- var item = e.target.closest(".history-item");
- if (item) {
- DOM.searchInput.value = item.dataset.query;
- STATE.query = item.dataset.query;
- STATE.page = 1;
- STATE.results = [];
+ DOM.searchHistoryDropdown.addEventListener("touchend", cancelHistoryLongPress, { passive: true });
+ DOM.searchHistoryDropdown.addEventListener("touchmove", cancelHistoryLongPress, { passive: true });
+ let timer = null;
+ DOM.searchInput.addEventListener("input", () => {
+ if (searchComposing) return;
+ clearTimeout(timer);
+ if (STATE.searchController) STATE.searchController.abort();
+ STATE.searchSequence += 1;
+ STATE.query = DOM.searchInput.value.trim();
+ STATE.page = 1;
+ const scheduledValue = DOM.searchInput.value;
+ timer = setTimeout(() => {
+ if (DOM.searchInput.value !== scheduledValue) return;
+ syncUrl();
+ addHistory(STATE.query);
+ renderHistoryDropdown();
doSearch();
- hideDropdown();
- DOM.searchInput.blur();
- return;
- }
- });
- DOM.historyToggle.addEventListener("change", () => {
- STATE.recordHistory = DOM.historyToggle.checked;
- if (!STATE.recordHistory) saveHistory([]);
- syncStateToURL(true);
+ }, SEARCH_DEBOUNCE_MS);
});
- if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.addEventListener("change", () => {
- STATE.useMirrorLinks = DOM.mirrorLinksToggle.checked;
- syncStateToURL(true);
- if (STATE.results.length > 0) renderResults();
+ DOM.searchInput.addEventListener("compositionstart", () => {
+ searchComposing = true;
});
- if (DOM.multiSelectToggle) DOM.multiSelectToggle.addEventListener("change", updateSelectionUI);
- DOM.resultsList.addEventListener("click", (e) => {
- if (!DOM.multiSelectToggle || !DOM.multiSelectToggle.checked) return;
- var cb = e.target.closest(".result-checkbox");
- if (!cb) return;
- e.stopPropagation();
- var idx = parseInt(cb.dataset.index);
- if (e.shiftKey && lastSelectedIndex >= 0) {
- var lo = Math.min(lastSelectedIndex, idx);
- var hi = Math.max(lastSelectedIndex, idx);
- for (var si = lo; si <= hi; si++) selectedIndices[si] = true;
- } else if (cb.checked) {
- selectedIndices[idx] = true;
- } else {
- delete selectedIndices[idx];
- }
- lastSelectedIndex = idx;
- updateSelectionUI();
+ DOM.searchInput.addEventListener("compositionend", () => {
+ searchComposing = false;
+ DOM.searchInput.dispatchEvent(new Event("input", { bubbles: true }));
});
- if (DOM.multiCopyLinks) DOM.multiCopyLinks.addEventListener("click", () => {
- var indices = Object.keys(selectedIndices).map(Number);
- var links = [];
- for (var li = 0; li < indices.length; li++) {
- var rec = STATE.results[indices[li]];
- if (rec) links.push(getCopyableLink(getRecordLink(rec)));
- }
- if (links.length === 0) { showToast("未选中任何文件"); return; }
- navigator.clipboard.writeText(links.join("\n")).then(() => showToast("已复制 " + links.length + " 条链接")).catch(() => showToast("复制失败"));
+ DOM.sortSelect.addEventListener("change", () => {
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
- if (DOM.multiBatchDownload) DOM.multiBatchDownload.addEventListener("click", () => {
- var indices = Object.keys(selectedIndices).map(Number);
- if (indices.length === 0) { showToast("未选中任何文件"); return; }
- for (var bi = 0; bi < indices.length; bi++) {
- var rec = STATE.results[indices[bi]];
- var filename = (rec.File || "file") + (rec.Extension ? "." + rec.Extension : "");
- setTimeout((name, link) => downloadFile(name, link), bi * 300, filename, getRecordLink(rec));
- }
- showToast("正在下载 " + indices.length + " 个文件");
+ DOM.exactToggle.addEventListener("change", () => {
+ STATE.exact = DOM.exactToggle.checked;
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
- if (DOM.multiDeselect) DOM.multiDeselect.addEventListener("click", () => {
- selectedIndices = {};
- lastSelectedIndex = -1;
- updateSelectionUI();
+ DOM.historyToggle.addEventListener("change", () => {
+ STATE.historyEnabled = DOM.historyToggle.checked;
+ if (!STATE.historyEnabled) saveHistory([]);
+ syncUrl();
});
- if (DOM.multiSelectAll) DOM.multiSelectAll.addEventListener("click", () => {
- for (var si = 0; si < STATE.results.length; si++) selectedIndices[si] = true;
- lastSelectedIndex = STATE.results.length > 0 ? STATE.results.length - 1 : -1;
- updateSelectionUI();
+ DOM.fulltextToggle.addEventListener("change", () => {
+ STATE.fulltext = DOM.fulltextToggle.checked;
+ DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
- DOM.hamburgerBtn.addEventListener("click", toggleLeftSidebar);
- DOM.settingsBtn.addEventListener("click", toggleRightSidebar);
- DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
- DOM.sidebarExpandBtn.addEventListener("click", () => {
- DOM.leftSidebar.classList.toggle("expanded-wide");
- DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔";
- syncStateToURL(true);
+ DOM.searchPathsToggle.addEventListener("change", () => {
+ STATE.searchPaths = DOM.searchPathsToggle.checked;
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
- DOM.themeBtn.addEventListener("click", toggleTheme);
- DOM.mobileToggleBtn.addEventListener("click", toggleMobile);
- DOM.clearFiltersBtn.addEventListener("click", clearAllFilters);
- DOM.searchFoldersToggle.addEventListener("change", () => {
- STATE.searchFolders = DOM.searchFoldersToggle.checked;
+ DOM.clearFiltersBtn.addEventListener("click", clearFilters);
+ DOM.emptyRandomBtn.addEventListener("click", randomDoc);
+ if (DOM.randomBookBtn) DOM.randomBookBtn.addEventListener("click", randomDoc);
+ DOM.filterMinSize.addEventListener("input", () => {
+ STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value);
STATE.page = 1;
- STATE.results = [];
+ syncUrl();
doSearch();
});
- DOM.exactSearchToggle.addEventListener("change", () => {
- STATE.exact = DOM.exactSearchToggle.checked;
+ DOM.filterMaxSize.addEventListener("input", () => {
+ STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value);
STATE.page = 1;
- STATE.results = [];
+ syncUrl();
doSearch();
});
- DOM.sortSelect.addEventListener("change", () => {
+ DOM.filterMinUnit.addEventListener("change", () => {
+ STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value);
STATE.page = 1;
- STATE.results = [];
+ syncUrl();
doSearch();
- syncStateToURL(true);
});
- DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); });
- DOM.randomBookBtn.addEventListener('click', randomBook);
- if (DOM.randomTxtBtn) DOM.randomTxtBtn.addEventListener('click', randomTxt);
- DOM.emptyRandomBtn.addEventListener('click', randomTxt);
- let sizeTimer;
- const sizeInputToBytes = (input, unitSelect) => {
- var val = parseFloat(input.value);
- if (isNaN(val) || val < 0) return null;
- var unit = unitSelect.value;
- if (unit === "KB") val *= 1024;
- else if (unit === "MB") val *= 1048576;
- else if (unit === "GB") val *= 1073741824;
- return Math.round(val);
- };
- const applySizeFilter = () => { clearTimeout(sizeTimer); sizeTimer = setTimeout(() => { STATE.filterMinSize = sizeInputToBytes(DOM.filterMinSize, DOM.filterMinUnit); STATE.filterMaxSize = sizeInputToBytes(DOM.filterMaxSize, DOM.filterMaxUnit); STATE.page = 1; STATE.results = []; doSearch(); }, 500); };
- DOM.filterMinSize.addEventListener("input", applySizeFilter);
- DOM.filterMaxSize.addEventListener("input", applySizeFilter);
- DOM.filterMinUnit.addEventListener("change", applySizeFilter);
- DOM.filterMaxUnit.addEventListener("change", applySizeFilter);
- DOM.extSelectAll.addEventListener("click", () => { STATE.filterExtensions = (STATE.extensionList || []).map(e => e.name); STATE.page = 1; saveStoredExtensionFilters(); renderExtensionFilter(); doSearch(); });
- DOM.extDeselectAll.addEventListener("click", () => {
- const allExtNames = (STATE.extensionList || []).map(e => e.name);
- const currentSet = new Set(STATE.filterExtensions);
- STATE.filterExtensions = allExtNames.filter(e => !currentSet.has(e));
- STATE.page = 1;
- saveStoredExtensionFilters();
- renderExtensionFilter();
- doSearch();
+ DOM.filterMaxUnit.addEventListener("change", () => {
+ STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value);
+ STATE.page = 1;
+ syncUrl();
+ doSearch();
});
DOM.folderSelectAll.addEventListener("click", () => {
- if (!STATE.folderTree || STATE.folderTree.length === 0) return;
- const subtreeSet = new Set();
- const selfSet = new Set();
- for (let i = 0; i < STATE.folderTree.length; i++) {
- setNodeSubtreeSelection(STATE.folderTree[i], true, subtreeSet, selfSet);
- }
- persistFolderSelection(subtreeSet, selfSet);
- renderFilterFolderTree();
+ const paths = [];
+ flattenNodes(STATE.folderTree, paths);
+ applyFolderSelections(paths);
});
DOM.folderDeselectAll.addEventListener("click", () => {
- if (!STATE.folderTree || STATE.folderTree.length === 0) return;
- const subtreeSet = getFolderSubtreeSet();
- const selfSet = getFolderSelfSet();
- const allSubtreePaths = [];
- const allSelfPaths = [];
- collectFolderNodePaths(STATE.folderTree, allSubtreePaths, allSelfPaths);
- const nextSubtreeSet = new Set();
- const nextSelfSet = new Set();
- for (let i = 0; i < allSubtreePaths.length; i++) {
- if (!subtreeSet.has(allSubtreePaths[i])) nextSubtreeSet.add(allSubtreePaths[i]);
- }
- for (let i = 0; i < allSelfPaths.length; i++) {
- if (!selfSet.has(allSelfPaths[i])) nextSelfSet.add(allSelfPaths[i]);
+ applyFolderSelections(invertFolderSelections());
+ });
+ DOM.multiSelectToggle.addEventListener("change", updateMultiUi);
+ DOM.multiSelectAll.addEventListener("click", () => {
+ STATE.selectedIds = new Set(STATE.results.map((item) => item.doc_id));
+ updateMultiUi();
+ });
+ DOM.multiDeselect.addEventListener("click", () => {
+ STATE.selectedIds.clear();
+ updateMultiUi();
+ });
+ DOM.multiBatchDownload.addEventListener("click", () => {
+ const ids = Array.from(STATE.selectedIds);
+ if (!ids.length) {
+ showToast("未选中任何文件");
+ return;
+ }
+ ids.forEach((id) => {
+ const frame = document.createElement("iframe");
+ frame.hidden = true;
+ frame.src = `/api/download/${encodeURIComponent(id)}`;
+ document.body.appendChild(frame);
+ setTimeout(() => frame.remove(), 60000);
+ });
+ showToast(`已请求下载 ${ids.length} 个文件`);
+ });
+ DOM.multiZipDownload.addEventListener("click", async () => {
+ const ids = Array.from(STATE.selectedIds);
+ if (!ids.length) {
+ showToast("未选中任何文件");
+ return;
+ }
+ if (ids.length > 500) {
+ showToast("合并下载最多支持 500 个文��,请减少选择", 3500);
+ return;
+ }
+ DOM.multiZipDownload.disabled = true;
+ showToast(`正在准备 ${ids.length} 个文件...`, 10000);
+ try {
+ const response = await fetch("/api/zip-prepare", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ doc_ids: ids }),
+ });
+ const data = await response.json();
+ if (!response.ok || !data.download_url) throw new Error(data.error || `HTTP ${response.status}`);
+ window.location.assign(data.download_url);
+ showToast(`已开始打包并下载 ${data.file_count} 个文件`);
+ } catch (error) {
+ console.error(error);
+ showToast(`合并下载失败: ${error.message || "未知错误"}`, 3500);
+ } finally {
+ DOM.multiZipDownload.disabled = false;
+ }
+ });
+ DOM.resultsList.addEventListener("click", async (event) => {
+ const checkbox = event.target.closest(".result-checkbox");
+ if (checkbox) {
+ const docId = checkbox.dataset.docId;
+ if (checkbox.checked) STATE.selectedIds.add(docId);
+ else STATE.selectedIds.delete(docId);
+ DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : "";
+ return;
+ }
+ const action = event.target.closest("[data-action='preview']");
+ if (action) {
+ event.preventDefault();
+ openPreview(action.dataset.docId, STATE.query);
+ return;
+ }
+ const folder = event.target.closest(".path-folder[data-folder]");
+ if (folder) {
+ const source = folder.dataset.source;
+ const path = folder.dataset.folder;
+ if (STATE.mode === "global" && source && !STATE.source) {
+ await navigateToSourceFolder(source, path);
+ return;
+ } else {
+ STATE.folderSelections = path ? [path] : [];
}
- persistFolderSelection(nextSubtreeSet, nextSelfSet);
- renderFilterFolderTree();
+ STATE.page = 1;
+ refreshFolderTreeSelectionState();
+ syncUrl();
+ doSearch();
+ }
+ });
+ DOM.sidebarContent.addEventListener("click", async (event) => {
+ const sourceItem = event.target.closest("[data-source]");
+ if (sourceItem) {
+ navigateToSource(sourceItem.dataset.source);
+ return;
+ }
+ const backHome = event.target.closest("#back-home");
+ if (backHome) {
+ navigateHome();
+ return;
+ }
+ const folderOpen = event.target.closest("[data-folder-open]");
+ if (folderOpen) {
+ openFolder(folderOpen.dataset.folderOpen, routeRenderId);
+ return;
+ }
+ const folderNav = event.target.closest("[data-folder-nav]");
+ if (folderNav) {
+ openFolder(folderNav.dataset.folderNav, routeRenderId);
+ return;
+ }
+ const docItem = event.target.closest("[data-doc-id]");
+ if (docItem) {
+ openPreview(docItem.dataset.docId, STATE.query);
+ }
});
- setupInfiniteScroll();
- setupQuickScroll();
- setupKeyboard();
- setupResultDelegation();
- window.addEventListener("popstate", () => ROUTER.apply());
- window.addEventListener("resize", () => {
- if (!localStorage.getItem("mobileMode")) {
- const wm = STATE.isMobile;
- STATE.isMobile = autoDetectMobile();
- if (wm !== STATE.isMobile) applyMobileMode();
+ DOM.resultsContainer.addEventListener("scroll", () => {
+ if (STATE.suppressAutoLoad) return;
+ const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 200;
+ if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) {
+ STATE.page += 1;
+ doSearch();
}
- scheduleScrollRecovery(60);
});
+ window.addEventListener("resize", () => schedulePageRecovery(120));
+ window.addEventListener("focus", () => {
+ schedulePageRecovery(0);
+ warmConnection();
+ });
+ window.addEventListener("pageshow", () => schedulePageRecovery(0));
+ window.addEventListener("online", () => warmConnection(true));
document.addEventListener("visibilitychange", () => {
- if (document.visibilityState === "visible") {
- scheduleScrollRecovery();
+ if (!document.hidden) {
+ schedulePageRecovery(0);
warmConnection(true);
}
});
- window.addEventListener("pageshow", () => scheduleScrollRecovery());
- window.addEventListener("focus", () => { scheduleScrollRecovery(); warmConnection(); });
- window.addEventListener("online", () => warmConnection(true));
+ DOM.previewPanel.addEventListener("click", (event) => {
+ const closeBtn = event.target.closest("[data-action='close-preview']");
+ if (closeBtn) {
+ closePreview();
+ return;
+ }
+ if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ setHistoryDropdownOpen(false);
+ if (STATE.previewDocId) closePreview();
+ STATE.leftSidebarOpen = false;
+ STATE.rightSidebarOpen = false;
+ updateSidebarVisibility();
+ return;
+ }
+ if (event.key === "/" && document.activeElement !== DOM.searchInput) {
+ event.preventDefault();
+ DOM.searchInput.focus();
+ DOM.searchInput.select();
+ }
+ });
+ window.addEventListener("popstate", async () => {
+ const nextPreviewDocId = new URLSearchParams(window.location.search).get("preview") || null;
+ if (previewController && STATE.previewDocId !== nextPreviewDocId) {
+ previewController.abort();
+ previewController = null;
+ observeResultSnippets();
+ }
+ const nextSearchUrl = searchUrlKey();
+ if (STATE.searchUrlWithoutPreview === nextSearchUrl && STATE.previewDocId !== nextPreviewDocId) {
+ STATE.previewDocId = nextPreviewDocId;
+ if (nextPreviewDocId) {
+ restorePreviewFromUrl({ scroll: false });
+ } else {
+ DOM.previewPanel.innerHTML = "";
+ DOM.previewPanel.style.display = "none";
+ document.body.classList.remove("preview-open");
+ }
+ return;
+ }
+ if (STATE.searchUrlWithoutPreview === nextSearchUrl) {
+ applyUiUrlState();
+ updateSidebarVisibility();
+ restorePreviewFromUrl({ scroll: false });
+ return;
+ }
+ STATE.searchUrlWithoutPreview = nextSearchUrl;
+ syncRoute();
+ loadUrlState();
+ const routeId = ++routeRenderId;
+ sidebarCurrentPath = "";
+ await renderSidebar(routeId);
+ await renderFolderTree(false, routeId);
+ updateSidebarVisibility();
+ restorePreviewFromUrl({ scroll: false });
+ doSearch();
+ });
+}
+
+async function init() {
+ cacheDom();
+ STATE.isDark = localStorage.getItem("theme") !== "light";
+ applyTheme();
+ const savedMobileMode = localStorage.getItem("mobileMode");
+ if (savedMobileMode === "mobile") STATE.isMobile = true;
+ else if (savedMobileMode === "desktop") STATE.isMobile = false;
+ else STATE.isMobile = window.innerWidth <= 768;
+ syncRoute();
+ applyMobileMode();
+ loadUrlState();
+ if (STATE.previewDocId && !(history.state && history.state.previewEntry === true)) {
+ const previewDocId = STATE.previewDocId;
+ STATE.previewDocId = null;
+ syncUrl();
+ STATE.previewDocId = previewDocId;
+ syncUrl(false);
+ }
+ attachEvents();
+ if (DOM.searchPathsToggle) {
+ DOM.searchPathsToggle.checked = STATE.searchPaths;
+ }
+ DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
+ updateSidebarVisibility();
+ syncUrl();
+ restorePreviewFromUrl({ scroll: false });
+ const initialSearch = doSearch();
+ const inlineSources = readInlineJson("initial-sidebar-sources");
+ STATE.sources = inlineSources && Array.isArray(inlineSources.sources)
+ ? inlineSources.sources
+ : await API.getSources();
+ STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source]));
+ await renderSourcesFilter();
+ const routeId = ++routeRenderId;
+ await renderSidebar(routeId);
+ await renderFolderTree(false, routeId);
+ updateFilterVisibility();
+ await initialSearch;
lastKeepaliveAt = Date.now();
window.setInterval(() => warmConnection(), KEEPALIVE_INTERVAL_MS);
- ROUTER.apply();
- loadReaderAssets().then(() => {
- clearResultTemplateCache();
- if (STATE.results.length > 0) renderResults();
- });
- fetchHitokoto();
- setInterval(fetchHitokoto, 30000);
+ initComplete = true;
}
document.addEventListener("DOMContentLoaded", init);