|
70 | 70 | <button class="lang-btn${lang === 'en' ? ' active' : ''}" data-lang="en">EN</button> |
71 | 71 | <button class="lang-btn${lang === 'cn' ? ' active' : ''}" data-lang="cn">中文</button> |
72 | 72 | </div> |
73 | | - <input type="search" class="nav-search" placeholder="${lang === 'cn' ? '搜索页面...' : 'Search pages...'}" id="nav-search-input" /> |
| 73 | + <input type="search" class="nav-search" placeholder="${lang === 'cn' ? '搜索 (Ctrl+K)...' : 'Search (Ctrl+K)...'}" id="nav-search-input" /> |
74 | 74 | <div class="nav-section-label">${lang === 'cn' ? '知识领域' : 'Knowledge Domains'}</div> |
75 | 75 | <ul class="nav-tree">`; |
76 | 76 |
|
|
107 | 107 | const searchInput = document.getElementById("nav-search-input"); |
108 | 108 | searchInput.addEventListener("input", (e) => { |
109 | 109 | const q = e.target.value.toLowerCase(); |
| 110 | + // Filter nav items |
110 | 111 | nav.querySelectorAll(".nav-page").forEach((el) => { |
111 | 112 | const match = el.textContent.toLowerCase().includes(q); |
112 | 113 | el.style.display = match || !q ? "" : "none"; |
|
115 | 116 | nav.querySelectorAll(".nav-children").forEach((el) => el.classList.add("open")); |
116 | 117 | nav.querySelectorAll(".nav-domain").forEach((el) => el.classList.add("expanded")); |
117 | 118 | } |
| 119 | + // Full-text search results dropdown |
| 120 | + showSearchResults(e.target.value); |
| 121 | + }); |
| 122 | + searchInput.addEventListener("focus", () => { |
| 123 | + if (searchInput.value.trim()) showSearchResults(searchInput.value); |
| 124 | + }); |
| 125 | + searchInput.addEventListener("blur", (e) => { |
| 126 | + setTimeout(() => removeSearchDropdown(), 200); |
118 | 127 | }); |
119 | 128 | } |
120 | 129 |
|
|
562 | 571 | }); |
563 | 572 | } |
564 | 573 |
|
| 574 | + // === Full-text Search Engine === |
| 575 | + |
| 576 | + function buildSearchIndex() { |
| 577 | + const index = []; |
| 578 | + |
| 579 | + for (const [id, page] of Object.entries(WIKI.pages)) { |
| 580 | + if (page.type === "index") continue; |
| 581 | + const title = t("pages", id, "title") || page.title; |
| 582 | + const subtitle = t("pages", id, "subtitle") || page.subtitle || ""; |
| 583 | + const body = t("pages", id, "body") || page.body || ""; |
| 584 | + const plainBody = body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "); |
| 585 | + |
| 586 | + index.push({ |
| 587 | + type: "page", |
| 588 | + id: id, |
| 589 | + title: title, |
| 590 | + text: `${title} ${subtitle} ${plainBody}`, |
| 591 | + subtitle: subtitle, |
| 592 | + domain: page.domain || "" |
| 593 | + }); |
| 594 | + } |
| 595 | + |
| 596 | + if (WIKI.concepts) { |
| 597 | + for (const [id, concept] of Object.entries(WIKI.concepts)) { |
| 598 | + const name = t("concepts", id, "name") || concept.name; |
| 599 | + const summary = t("concepts", id, "summary") || concept.summary || ""; |
| 600 | + const definition = t("concepts", id, "definition") || concept.definition || ""; |
| 601 | + const plainDef = definition.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "); |
| 602 | + |
| 603 | + index.push({ |
| 604 | + type: "concept", |
| 605 | + id: id, |
| 606 | + title: name, |
| 607 | + text: `${name} ${summary} ${plainDef}`, |
| 608 | + subtitle: summary, |
| 609 | + pageLinks: concept.usedIn || [] |
| 610 | + }); |
| 611 | + } |
| 612 | + } |
| 613 | + |
| 614 | + return index; |
| 615 | + } |
| 616 | + |
| 617 | + function searchQuery(query, index) { |
| 618 | + if (!query.trim()) return []; |
| 619 | + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); |
| 620 | + const results = []; |
| 621 | + |
| 622 | + for (const entry of index) { |
| 623 | + const text = entry.text.toLowerCase(); |
| 624 | + let score = 0; |
| 625 | + let allMatch = true; |
| 626 | + |
| 627 | + for (const term of terms) { |
| 628 | + const titleLower = entry.title.toLowerCase(); |
| 629 | + if (titleLower === term) { |
| 630 | + score += 100; |
| 631 | + } else if (titleLower.includes(term)) { |
| 632 | + score += 50; |
| 633 | + } else if (text.includes(term)) { |
| 634 | + score += 10; |
| 635 | + } else { |
| 636 | + // Fuzzy: allow 1 character difference for terms > 3 chars |
| 637 | + if (term.length > 3 && fuzzyMatch(text, term)) { |
| 638 | + score += 5; |
| 639 | + } else { |
| 640 | + allMatch = false; |
| 641 | + } |
| 642 | + } |
| 643 | + } |
| 644 | + |
| 645 | + if (allMatch && score > 0) { |
| 646 | + results.push({ ...entry, score }); |
| 647 | + } |
| 648 | + } |
| 649 | + |
| 650 | + results.sort((a, b) => b.score - a.score); |
| 651 | + return results.slice(0, 20); |
| 652 | + } |
| 653 | + |
| 654 | + function fuzzyMatch(text, term) { |
| 655 | + for (let i = 0; i <= text.length - term.length; i++) { |
| 656 | + let diffs = 0; |
| 657 | + for (let j = 0; j < term.length; j++) { |
| 658 | + if (text[i + j] !== term[j]) diffs++; |
| 659 | + if (diffs > 1) break; |
| 660 | + } |
| 661 | + if (diffs <= 1) return true; |
| 662 | + } |
| 663 | + return false; |
| 664 | + } |
| 665 | + |
| 666 | + function getSnippet(text, query, maxLen = 120) { |
| 667 | + const plain = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " "); |
| 668 | + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); |
| 669 | + const lower = plain.toLowerCase(); |
| 670 | + |
| 671 | + let bestPos = 0; |
| 672 | + for (const term of terms) { |
| 673 | + const idx = lower.indexOf(term); |
| 674 | + if (idx !== -1) { bestPos = idx; break; } |
| 675 | + } |
| 676 | + |
| 677 | + const start = Math.max(0, bestPos - 30); |
| 678 | + const end = Math.min(plain.length, start + maxLen); |
| 679 | + let snippet = plain.slice(start, end).trim(); |
| 680 | + if (start > 0) snippet = "..." + snippet; |
| 681 | + if (end < plain.length) snippet += "..."; |
| 682 | + |
| 683 | + // Highlight matching terms in snippet |
| 684 | + for (const term of terms) { |
| 685 | + const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); |
| 686 | + snippet = snippet.replace(regex, "<mark>$1</mark>"); |
| 687 | + } |
| 688 | + |
| 689 | + return snippet; |
| 690 | + } |
| 691 | + |
| 692 | + let searchIndex = null; |
| 693 | + let searchDropdown = null; |
| 694 | + |
| 695 | + function initSearch() { |
| 696 | + searchIndex = buildSearchIndex(); |
| 697 | + } |
| 698 | + |
| 699 | + function showSearchResults(query) { |
| 700 | + if (!searchIndex) initSearch(); |
| 701 | + removeSearchDropdown(); |
| 702 | + |
| 703 | + if (!query.trim()) return; |
| 704 | + |
| 705 | + const results = searchQuery(query, searchIndex); |
| 706 | + if (results.length === 0) { |
| 707 | + const dd = createSearchDropdown(); |
| 708 | + dd.innerHTML = `<div class="search-empty">${lang === 'cn' ? '未找到结果' : 'No results found'}</div>`; |
| 709 | + return; |
| 710 | + } |
| 711 | + |
| 712 | + const dd = createSearchDropdown(); |
| 713 | + let html = ""; |
| 714 | + for (const r of results) { |
| 715 | + const icon = r.type === "page" ? "📄" : "💡"; |
| 716 | + const snippet = getSnippet(r.text, query); |
| 717 | + const meta = r.type === "page" ? r.domain : (lang === 'cn' ? '概念' : 'Concept'); |
| 718 | + html += `<div class="search-result" data-type="${r.type}" data-id="${r.id}">`; |
| 719 | + html += `<div class="search-result-header">`; |
| 720 | + html += `<span class="search-result-icon">${icon}</span>`; |
| 721 | + html += `<span class="search-result-title">${r.title}</span>`; |
| 722 | + html += `<span class="search-result-meta">${meta}</span>`; |
| 723 | + html += `</div>`; |
| 724 | + html += `<div class="search-result-snippet">${snippet}</div>`; |
| 725 | + html += `</div>`; |
| 726 | + } |
| 727 | + dd.innerHTML = html; |
| 728 | + |
| 729 | + dd.querySelectorAll(".search-result").forEach((el) => { |
| 730 | + el.addEventListener("click", () => { |
| 731 | + const type = el.dataset.type; |
| 732 | + const id = el.dataset.id; |
| 733 | + removeSearchDropdown(); |
| 734 | + document.getElementById("nav-search-input").value = ""; |
| 735 | + if (type === "page") { |
| 736 | + navigate(id); |
| 737 | + } else { |
| 738 | + // For concepts, navigate to the first page that uses it |
| 739 | + const concept = WIKI.concepts?.[id]; |
| 740 | + if (concept?.usedIn?.length > 0) { |
| 741 | + navigate(concept.usedIn[0]); |
| 742 | + } |
| 743 | + } |
| 744 | + }); |
| 745 | + }); |
| 746 | + } |
| 747 | + |
| 748 | + function createSearchDropdown() { |
| 749 | + removeSearchDropdown(); |
| 750 | + const dd = document.createElement("div"); |
| 751 | + dd.className = "search-dropdown"; |
| 752 | + dd.id = "search-dropdown"; |
| 753 | + nav.appendChild(dd); |
| 754 | + searchDropdown = dd; |
| 755 | + return dd; |
| 756 | + } |
| 757 | + |
| 758 | + function removeSearchDropdown() { |
| 759 | + const existing = document.getElementById("search-dropdown"); |
| 760 | + if (existing) existing.remove(); |
| 761 | + searchDropdown = null; |
| 762 | + } |
| 763 | + |
| 764 | + // Global keyboard shortcuts |
| 765 | + document.addEventListener("keydown", (e) => { |
| 766 | + if ((e.ctrlKey || e.metaKey) && e.key === "k") { |
| 767 | + e.preventDefault(); |
| 768 | + const input = document.getElementById("nav-search-input"); |
| 769 | + if (input) { input.focus(); input.select(); } |
| 770 | + } |
| 771 | + if (e.key === "Escape") { |
| 772 | + removeSearchDropdown(); |
| 773 | + const input = document.getElementById("nav-search-input"); |
| 774 | + if (input) input.blur(); |
| 775 | + } |
| 776 | + }); |
| 777 | + |
565 | 778 | // === Init === |
566 | 779 | navigate("index"); |
567 | 780 | })(); |
0 commit comments