393 lines
11 KiB
JavaScript
393 lines
11 KiB
JavaScript
// 中文搜索擴展 - 基於 lunr.js 和 TinySegmenter
|
|
// 此文件將被 DocFX modern template 自動引入
|
|
|
|
// Substring search implementation for DocFX
|
|
export default {
|
|
// 啟動腳本
|
|
start: async () => {
|
|
// Override default search function
|
|
if (typeof window.searchData !== 'undefined') {
|
|
window._originalSearchData = window.searchData;
|
|
}
|
|
|
|
// Override the search function
|
|
window.searchData = {};
|
|
window.searchDataRequest = null;
|
|
|
|
await initSubstringSearch();
|
|
}
|
|
}
|
|
|
|
async function initSubstringSearch() {
|
|
// Wait for search box to be available
|
|
let attempts = 0;
|
|
while (!document.getElementById('search-query')) {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
attempts++;
|
|
if (attempts > 100) { // 10 seconds timeout
|
|
return;
|
|
}
|
|
}
|
|
|
|
const searchInput = document.getElementById('search-query');
|
|
const searchButton = document.querySelector('.search-query-button');
|
|
|
|
// Create search results container if it doesn't exist
|
|
let resultsContainer = document.getElementById('search-results');
|
|
if (!resultsContainer) {
|
|
resultsContainer = document.createElement('div');
|
|
resultsContainer.id = 'search-results';
|
|
resultsContainer.className = 'search-results';
|
|
// Insert after the search input's parent container
|
|
const searchContainer = searchInput.closest('.search-container') || searchInput.parentElement;
|
|
if (searchContainer && searchContainer.parentElement) {
|
|
searchContainer.parentElement.insertBefore(resultsContainer, searchContainer.nextSibling);
|
|
}
|
|
}
|
|
|
|
// Add some basic styles
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.search-results {
|
|
border: 1px solid var(--md-sys-color-outline-variant, #ddd);
|
|
border-radius: 4px;
|
|
box-shadow: var(--md-sys-elevation-level2, 0 2px 4px rgba(0,0,0,0.1));
|
|
padding: 16px;
|
|
margin: 16px;
|
|
max-width: calc(100% - 32px);
|
|
box-sizing: border-box;
|
|
}
|
|
.search-result {
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--md-sys-color-outline-variant, #eee);
|
|
margin-bottom: 8px;
|
|
}
|
|
.search-result:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.search-result-item {
|
|
text-decoration: none;
|
|
color: inherit;
|
|
display: block;
|
|
}
|
|
.search-result h3 {
|
|
color: var(--md-sys-color-primary, #0366d6);
|
|
margin: 0 0 8px 0;
|
|
font-size: 18px;
|
|
}
|
|
.search-result p {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
}
|
|
.search-highlight {
|
|
background-color: var(--md-sys-color-tertiary-container, #0c0);
|
|
padding: 2px 0;
|
|
border-radius: 2px;
|
|
font-weight: bold;
|
|
}
|
|
.no-results {
|
|
padding: 16px;
|
|
text-align: center;
|
|
font-size: 16px;
|
|
}
|
|
.search-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
border-bottom: 1px solid var(--md-sys-color-outline-variant, #eee);
|
|
padding-bottom: 8px;
|
|
}
|
|
.search-title {
|
|
font-size: 20px;
|
|
margin: 0;
|
|
}
|
|
.search-close {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
color: var(--md-sys-color-on-surface-variant, #666);
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
.search-close:hover {
|
|
background-color: var(--md-sys-color-surface-variant, #f0f0f0);
|
|
color: var(--md-sys-color-on-surface, #000);
|
|
}
|
|
.main-content-hidden {
|
|
display: none;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Enable search input if it's disabled
|
|
if (searchInput.disabled) {
|
|
searchInput.disabled = false;
|
|
}
|
|
|
|
// Try to get search data
|
|
try {
|
|
const searchData = await loadSearchData();
|
|
if (!searchData) {
|
|
return;
|
|
}
|
|
|
|
// Remove existing event listeners
|
|
const newSearchInput = searchInput.cloneNode(true);
|
|
searchInput.parentNode.replaceChild(newSearchInput, searchInput);
|
|
|
|
// Prevent form submission
|
|
const searchForm = newSearchInput.closest('form');
|
|
if (searchForm) {
|
|
searchForm.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Handle Enter key
|
|
newSearchInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const query = e.target.value.toLowerCase().trim();
|
|
if (query.length >= 2) {
|
|
const results = performSubstringSearch(query, searchData);
|
|
displaySearchResults(results);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Bind our search event
|
|
newSearchInput.addEventListener('input', debounce(async function(e) {
|
|
const query = e.target.value.toLowerCase().trim();
|
|
|
|
if (query.length < 2) {
|
|
clearSearchResults();
|
|
return;
|
|
}
|
|
|
|
const results = performSubstringSearch(query, searchData);
|
|
displaySearchResults(results);
|
|
}, 300));
|
|
|
|
// Handle search button click
|
|
if (searchButton) {
|
|
const newSearchButton = searchButton.cloneNode(true);
|
|
searchButton.parentNode.replaceChild(newSearchButton, searchButton);
|
|
newSearchButton.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const query = newSearchInput.value.toLowerCase().trim();
|
|
if (query.length >= 2) {
|
|
const results = performSubstringSearch(query, searchData);
|
|
displaySearchResults(results);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Position the search results container
|
|
const searchContainer = newSearchInput.closest('.search-container') || newSearchInput.parentElement;
|
|
searchContainer.style.position = 'relative';
|
|
|
|
} catch (error) {
|
|
console.error('Failed to initialize search:', error);
|
|
}
|
|
}
|
|
|
|
// Load search index from window.searchData
|
|
async function loadSearchData() {
|
|
try {
|
|
const baseUrl = document.querySelector('meta[name="docfx:rel"]')?.content || '';
|
|
const response = await fetch(baseUrl + 'index.json');
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
return transformSearchData(data, baseUrl);
|
|
} catch (error) {
|
|
console.error('Error loading search data:', {
|
|
message: error.message,
|
|
stack: error.stack
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Transform the data into searchable format
|
|
function transformSearchData(data, baseUrl = '/') {
|
|
if (!data || typeof data !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const searchableData = Object.values(data)
|
|
.filter(item => item && (item.title || item.summary))
|
|
.map(item => ({
|
|
title: item.title || '',
|
|
summary: item.summary || '',
|
|
href: baseUrl + (item.href || '').replace(/^\//, '') // Ensure proper base URL prefix
|
|
}));
|
|
|
|
return searchableData;
|
|
}
|
|
|
|
// Perform substring search
|
|
function performSubstringSearch(query, data) {
|
|
return data.filter(item => {
|
|
// 組合所有可搜索的文字
|
|
const searchableText = [
|
|
item.title,
|
|
item.summary
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
// 執行搜索匹配
|
|
return searchableText.includes(query.toLowerCase());
|
|
}).slice(0, 10); // 限制返回前10筆結果
|
|
}
|
|
|
|
// Get context around search query
|
|
function getSearchContext(text, query, contextLength = 50) {
|
|
if (!text) return '';
|
|
|
|
const lowerText = text.toLowerCase();
|
|
const lowerQuery = query.toLowerCase();
|
|
const index = lowerText.indexOf(lowerQuery);
|
|
|
|
if (index === -1) return text.slice(0, 100) + '...';
|
|
|
|
const start = Math.max(0, index - contextLength);
|
|
const end = Math.min(text.length, index + query.length + contextLength);
|
|
|
|
let context = text.slice(start, end);
|
|
|
|
// Add ellipsis if we're not at the start/end
|
|
if (start > 0) context = '...' + context;
|
|
if (end < text.length) context = context + '...';
|
|
|
|
return context;
|
|
}
|
|
|
|
// Highlight search query in text
|
|
function highlightText(text, query) {
|
|
if (!text || !query) return text;
|
|
const lowerText = text.toLowerCase();
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
let lastIndex = 0;
|
|
let result = '';
|
|
let currentIndex = lowerText.indexOf(lowerQuery);
|
|
|
|
while (currentIndex !== -1) {
|
|
// Add text before match
|
|
result += text.slice(lastIndex, currentIndex);
|
|
// Add highlighted match
|
|
result += `<mark class="search-highlight">${text.slice(currentIndex, currentIndex + query.length)}</mark>`;
|
|
|
|
lastIndex = currentIndex + query.length;
|
|
currentIndex = lowerText.indexOf(lowerQuery, lastIndex);
|
|
}
|
|
|
|
// Add remaining text
|
|
result += text.slice(lastIndex);
|
|
return result;
|
|
}
|
|
|
|
// Display search results with better Chinese support
|
|
function displaySearchResults(results) {
|
|
const mainContent = document.querySelector('main') || document.querySelector('.main') || document.querySelector('#main');
|
|
|
|
let resultsContainer = document.getElementById('search-results');
|
|
if (!resultsContainer) {
|
|
resultsContainer = document.createElement('div');
|
|
resultsContainer.id = 'search-results';
|
|
resultsContainer.className = 'search-results card';
|
|
|
|
if (mainContent) {
|
|
mainContent.parentElement.insertBefore(resultsContainer, mainContent);
|
|
} else {
|
|
document.body.appendChild(resultsContainer);
|
|
}
|
|
}
|
|
|
|
if (!results || results.length === 0) {
|
|
resultsContainer.innerHTML = '<p class="no-results">No results found</p>';
|
|
|
|
if (mainContent) {
|
|
mainContent.classList.remove('main-content-hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const searchInput = document.getElementById('search-query');
|
|
const query = searchInput ? searchInput.value.trim() : '';
|
|
|
|
const html = `
|
|
<div class="search-header">
|
|
<h2 class="search-title">Search Results (${results.length})</h2>
|
|
<button class="search-close" title="Close search results">✕</button>
|
|
</div>
|
|
<div class="sr-items">
|
|
${results.map(result => `
|
|
<div class="sr-item">
|
|
<div class="item-title">
|
|
<a href="${result.href}">
|
|
${highlightText(result.title, query) || 'No Title'}
|
|
</a>
|
|
</div>
|
|
<div class="item-href">${result.href}</div>
|
|
<div class="item-brief">
|
|
${result.summary ? `${highlightText(getSearchContext(result.summary, query), query)}` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
resultsContainer.innerHTML = html;
|
|
|
|
if (mainContent) {
|
|
mainContent.classList.add('main-content-hidden');
|
|
}
|
|
|
|
const closeButton = resultsContainer.querySelector('.search-close');
|
|
if (closeButton) {
|
|
closeButton.addEventListener('click', function() {
|
|
clearSearchResults();
|
|
if (mainContent) {
|
|
mainContent.classList.remove('main-content-hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
resultsContainer.style.display = 'block';
|
|
}
|
|
|
|
// Clear search results
|
|
function clearSearchResults() {
|
|
const resultsContainer = document.getElementById('search-results');
|
|
if (resultsContainer) {
|
|
resultsContainer.innerHTML = '';
|
|
resultsContainer.style.display = 'none';
|
|
}
|
|
|
|
// Show main content again
|
|
const mainContent = document.querySelector('main') || document.querySelector('.main') || document.querySelector('#main');
|
|
if (mainContent) {
|
|
mainContent.classList.remove('main-content-hidden');
|
|
}
|
|
}
|
|
|
|
// Debounce helper
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|