class TextSearchUI { /** * @param {object} [options] * @param {'light' | 'dark' | 'auto'} [options.theme] - 初始主题 */ constructor(options = {}) { this.searchBox = null; this.currentIndex = 0; this.totalMatches = 0; // 拖动相关属性 this.isDragging = false; this.dragOffset = { x: 0, y: 0 }; // --- 新增:主题管理 --- this.theme = options.theme || 'auto'; // 'light', 'dark', 'auto' // --- 新增:可搜索的作用域元素(默认 document.body) --- this.scopeElement = this._resolveScope(options.scope); this.beforeShow = options.beforeShow || null; this.init(); } init() { // 添加CSS样式 this.addStyles(); // 创建搜索UI this.createSearchBox(); // 监听Ctrl+F快捷键 this.addKeyboardShortcut(); // 添加拖动功能 this.addDragFunctionality(); } /** * --- 改进:使用CSS变量重构 --- * 所有颜色和主题相关的样式都已提取为CSS变量, * 以便通过 data-theme 属性轻松切换。 */ addStyles() { const style = document.createElement('style'); style.textContent = ` /* --- 全局高亮主题变量 --- */ :root { --search-highlight-bg: #ffeb3b; --search-highlight-color: #000; --search-highlight-current-bg: #ff9800; --search-highlight-current-color: #000; } /* 全局高亮 - 手动暗色 */ body[data-search-theme="dark"] { --search-highlight-bg: #f9a825; --search-highlight-color: #000; --search-highlight-current-bg: #ff6f00; --search-highlight-current-color: #fff; } /* 全局高亮 - 自动暗色 */ @media (prefers-color-scheme: dark) { body[data-search-theme="auto"] { --search-highlight-bg: #f9a825; --search-highlight-color: #000; --search-highlight-current-bg: #ff6f00; --search-highlight-current-color: #fff; } } /* --- 搜索框容器 --- */ .text-search-container { /* --- 主题变量定义 (浅色为默认) --- */ --search-bg: #ffffff; --search-border: #e0e0e0; --search-shadow: 0 4px 12px rgba(0,0,0,0.15); --search-color: #333; --search-shadow-dragging: 0 8px 24px rgba(0,0,0,0.25); --input-bg: #ffffff; --input-border: #ddd; --input-color: #333; --input-placeholder: #999; --input-focus-border: #4CAF50; --btn-primary-bg: #4CAF50; --btn-primary-hover: #45a049; --btn-primary-active: #3d8b40; --btn-secondary-bg: #2196F3; --btn-secondary-hover: #0b7dda; --btn-close-bg: #f44336; --btn-close-hover: #da190b; --info-color: #666; --nav-bg: #2196F3; --nav-hover: #0b7dda; --nav-disabled-bg: #ccc; --nav-disabled-color: #fff; /* --- 结构样式 --- */ position: fixed; top: 20px; right: 20px; background: var(--search-bg); border: 1px solid var(--search-border); border-radius: 8px; box-shadow: var(--search-shadow); color: var(--search-color); padding: 15px; z-index: 10000; display: none; min-width: 320px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; cursor: move; user-select: none; } /* --- 搜索框 - 暗色主题 --- */ /* 手动暗色 */ .text-search-container[data-theme="dark"] { --search-bg: #2a2a2a; --search-border: #404040; --search-shadow: 0 4px 12px rgba(0,0,0,0.4); --search-color: #e0e0e0; --search-shadow-dragging: 0 8px 24px rgba(0,0,0,0.6); --input-bg: #3a3a3a; --input-border: #555; --input-color: #e0e0e0; --input-placeholder: #999; --input-focus-border: #66BB6A; --btn-primary-bg: #66BB6A; --btn-primary-hover: #57A75A; --btn-primary-active: #4A9A4D; --btn-secondary-bg: #42A5F5; --btn-secondary-hover: #2196F3; --btn-close-bg: #EF5350; --btn-close-hover: #E53935; --info-color: #aaa; --nav-bg: #42A5F5; --nav-hover: #2196F3; --nav-disabled-bg: #555; --nav-disabled-color: #888; } /* 自动暗色 */ @media (prefers-color-scheme: dark) { .text-search-container[data-theme="auto"] { --search-bg: #2a2a2a; --search-border: #404040; --search-shadow: 0 4px 12px rgba(0,0,0,0.4); --search-color: #e0e0e0; --search-shadow-dragging: 0 8px 24px rgba(0,0,0,0.6); --input-bg: #3a3a3a; --input-border: #555; --input-color: #e0e0e0; --input-placeholder: #999; --input-focus-border: #66BB6A; --btn-primary-bg: #66BB6A; --btn-primary-hover: #57A75A; --btn-primary-active: #4A9A4D; --btn-secondary-bg: #42A5F5; --btn-secondary-hover: #2196F3; --btn-close-bg: #EF5350; --btn-close-hover: #E53935; --info-color: #aaa; --nav-bg: #42A5F5; --nav-hover: #2196F3; --nav-disabled-bg: #555; --nav-disabled-color: #888; } } /* --- 剩余组件样式 (已应用CSS变量和作用域) --- */ .text-search-container.active { display: block; } .text-search-container.dragging { box-shadow: var(--search-shadow-dragging); opacity: 0.95; } .text-search-container .search-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .text-search-container .search-input { flex: 1; padding: 8px 12px; border: 1px solid var(--input-border); border-radius: 4px; font-size: 14px; outline: none; cursor: text; background: var(--input-bg); color: var(--input-color); } .text-search-container .search-input::placeholder { color: var(--input-placeholder); } .text-search-container .search-input:focus { border-color: var(--input-focus-border); } .text-search-container .search-controls { display: flex; gap: 8px; align-items: center; } .text-search-container .search-btn { padding: 8px 12px; background: var(--btn-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background 0.2s; } .text-search-container .search-btn:hover { background: var(--btn-primary-hover); } .text-search-container .search-btn:active { background: var(--btn-primary-active); } .text-search-container .search-btn.secondary { background: var(--btn-secondary-bg); } .text-search-container .search-btn.secondary:hover { background: var(--btn-secondary-hover); } .text-search-container .close-btn { background: var(--btn-close-bg); width: 32px; height: 32px; padding: 0; font-size: 18px; line-height: 1; cursor: pointer; } .text-search-container .close-btn:hover { background: var(--btn-close-hover); } .text-search-container .search-info { font-size: 12px; color: var(--info-color); margin-top: 8px; text-align: center; } .text-search-container .nav-buttons { display: flex; gap: 5px; } .text-search-container .nav-btn { padding: 6px 10px; background: var(--nav-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; } .text-search-container .nav-btn:hover { background: var(--nav-hover); } .text-search-container .nav-btn:disabled { background: var(--nav-disabled-bg); color: var(--nav-disabled-color); cursor: not-allowed; } /* 高亮样式 (使用全局变量) */ mark.search-highlight { background-color: var(--search-highlight-bg); color: var(--search-highlight-color); padding: 2px 0; } mark.search-highlight.current { background-color: var(--search-highlight-current-bg); color: var(--search-highlight-current-color)!important; font-weight: bold; } `; document.head.appendChild(style); } createSearchBox() { const container = document.createElement('div'); container.className = 'text-search-container'; container.innerHTML = `
`; document.body.appendChild(container); this.searchBox = container; // --- 新增:应用初始主题 --- this.updateTheme(); // 绑定事件 this.bindEvents(); } bindEvents() { const input = this.searchBox.querySelector('.search-input'); const closeBtn = this.searchBox.querySelector('.close-btn'); const searchBtn = this.searchBox.querySelector('[data-action="search"]'); const clearBtn = this.searchBox.querySelector('[data-action="clear"]'); const prevBtn = this.searchBox.querySelector('[data-action="prev"]'); const nextBtn = this.searchBox.querySelector('[data-action="next"]'); // 关闭按钮 closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.hide(); }); // 查找按钮 searchBtn.addEventListener('click', (e) => { e.stopPropagation(); const searchTerm = input.value.trim(); if (searchTerm) { this.search(searchTerm); } }); // 清除按钮 clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this.clear(); }); // 上一个/下一个 prevBtn.addEventListener('click', (e) => { e.stopPropagation(); this.findPrevious(); }); nextBtn.addEventListener('click', (e) => { e.stopPropagation(); this.findNext(); }); // 输入框防止拖动 input.addEventListener('mousedown', (e) => { e.stopPropagation(); }); // 回车键搜索 input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { this.findPrevious(); } else { const searchTerm = input.value.trim(); if (searchTerm) { if (this.totalMatches > 0) { this.findNext(); } else { this.search(searchTerm); } } } } else if (e.key === 'Escape') { this.hide(); } }); // 实时搜索(可选) input.addEventListener('input', (e) => { const searchTerm = e.target.value.trim(); if (searchTerm.length >= 2) { this.search(searchTerm); } else if (searchTerm.length === 0) { this.clear(); } }); } addDragFunctionality() { const container = this.searchBox; // 鼠标按下开始拖动 const startDrag = (e) => { // 如果点击的是输入框、按钮等交互元素,不触发拖动 if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.closest('button')) { return; } this.isDragging = true; container.classList.add('dragging'); // 计算鼠标相对于容器的偏移量 const rect = container.getBoundingClientRect(); this.dragOffset.x = e.clientX - rect.left; this.dragOffset.y = e.clientY - rect.top; e.preventDefault(); }; // 鼠标移动时更新位置 const drag = (e) => { if (!this.isDragging) return; e.preventDefault(); // 计算新位置 let newX = e.clientX - this.dragOffset.x; let newY = e.clientY - this.dragOffset.y; // 获取容器尺寸和视口尺寸 const rect = container.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; // 限制在视口范围内 newX = Math.max(0, Math.min(newX, maxX)); newY = Math.max(0, Math.min(newY, maxY)); // 设置新位置 container.style.left = newX + 'px'; container.style.top = newY + 'px'; container.style.right = 'auto'; // 清除right定位 }; // 鼠标释放停止拖动 const stopDrag = () => { if (this.isDragging) { this.isDragging = false; container.classList.remove('dragging'); } }; // 绑定事件 container.addEventListener('mousedown', startDrag); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag); // 触摸设备支持 container.addEventListener('touchstart', (e) => { // 如果点击的是交互元素,不触发拖动 if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.closest('button')) { return; } const touch = e.touches[0]; const mouseEvent = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY }); mouseEvent.target = e.target; startDrag(mouseEvent); }); document.addEventListener('touchmove', (e) => { if (!this.isDragging) return; const touch = e.touches[0]; const mouseEvent = new MouseEvent('mousemove', { clientX: touch.clientX, clientY: touch.clientY }); drag(mouseEvent); }); document.addEventListener('touchend', stopDrag); } addKeyboardShortcut() { document.addEventListener('keydown', (e) => { // Ctrl+F 或 Cmd+F if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); this.show(); } }); } show() { if(this.beforeShow) this.beforeShow(); this.searchBox.classList.add('active'); const input = this.searchBox.querySelector('.search-input'); input.focus(); input.select(); } hide() { this.searchBox.classList.remove('active'); this.clear(); } search(searchTerm) { // 清除之前的搜索 this.clearHighlights(); if (!searchTerm) { this.updateInfo('请输入搜索内容'); return; } // 使用window.find进行搜索并高亮 this.highlightMatches(searchTerm); } highlightMatches(searchTerm) { const bodyText = this.scopeElement.innerText || this.scopeElement.textContent; const searchRegex = new RegExp(searchTerm, 'gi'); const matches = bodyText.match(searchRegex); if (!matches || matches.length === 0) { this.totalMatches = 0; this.updateInfo('未找到匹配项'); return; } this.totalMatches = matches.length; this.currentIndex = 0; // 高亮所有匹配项 this.highlightText(searchTerm); // 跳转到第一个匹配项 this.scrollToCurrent(); this.updateInfo(`找到 ${this.totalMatches} 个匹配项 (${this.currentIndex + 1}/${this.totalMatches})`); } highlightText(searchTerm) { const walker = document.createTreeWalker( this.scopeElement, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // 跳过脚本、样式和搜索框本身 if (node.parentElement.closest('script, style, .text-search-container')) { return NodeFilter.FILTER_REJECT; } if (node.textContent.toLowerCase().includes(searchTerm.toLowerCase())) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_REJECT; } } ); const nodesToReplace = []; let node; while (node = walker.nextNode()) { nodesToReplace.push(node); } nodesToReplace.forEach((textNode, index) => { const parent = textNode.parentNode; const text = textNode.textContent; const regex = new RegExp(`(${this.escapeRegex(searchTerm)})`, 'gi'); const fragment = document.createDocumentFragment(); let lastIndex = 0; let match; let matchIndex = 0; const tempRegex = new RegExp(this.escapeRegex(searchTerm), 'gi'); while (match = tempRegex.exec(text)) { // 添加匹配前的文本 if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } // 添加高亮的匹配文本 const mark = document.createElement('mark'); mark.className = 'search-highlight'; mark.textContent = match[0]; mark.dataset.searchIndex = matchIndex++; fragment.appendChild(mark); lastIndex = match.index + match[0].length; } // 添加剩余文本 if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } parent.replaceChild(fragment, textNode); }); } escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } clearHighlights() { // 仅清除当前作用域内的高亮 this.scopeElement.querySelectorAll('mark.search-highlight').forEach(mark => { const parent = mark.parentNode; parent.replaceChild(document.createTextNode(mark.textContent), mark); parent.normalize(); // 合并相邻的文本节点 }); } scrollToCurrent() { const highlights = this.scopeElement.querySelectorAll('mark.search-highlight'); if (highlights.length === 0) return; // 移除所有current类 highlights.forEach(h => h.classList.remove('current')); // 添加当前高亮 const current = highlights[this.currentIndex]; if (current) { current.classList.add('current'); current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } findNext() { if (this.totalMatches === 0) return; this.currentIndex = (this.currentIndex + 1) % this.totalMatches; this.scrollToCurrent(); this.updateInfo(`匹配项 ${this.currentIndex + 1}/${this.totalMatches}`); } findPrevious() { if (this.totalMatches === 0) return; this.currentIndex = (this.currentIndex - 1 + this.totalMatches) % this.totalMatches; this.scrollToCurrent(); this.updateInfo(`匹配项 ${this.currentIndex + 1}/${this.totalMatches}`); } clear() { this.clearHighlights(); this.totalMatches = 0; this.currentIndex = 0; this.updateInfo(''); const input = this.searchBox.querySelector('.search-input'); input.value = ''; } updateInfo(message) { const info = this.searchBox.querySelector('.search-info'); info.textContent = message; } // --- 新增:公共方法 - 设置主题 --- /** * 设置搜索框的主题 * @param {'light' | 'dark' | 'auto'} theme */ setTheme(theme) { if (['light', 'dark', 'auto'].includes(theme)) { this.theme = theme; this.updateTheme(); } else { console.warn(`Invalid theme: ${theme}. Must be 'light', 'dark', or 'auto'.`); } } // --- 新增:私有方法 - 更新DOM主题 --- /** * 更新DOM上的主题属性 * @private */ updateTheme() { // 更新容器的主题 if (this.searchBox) { this.searchBox.dataset.theme = this.theme; } // 更新body上的主题 (用于全局高亮) // 使用 data-search-theme 避免与页面其他主题设置冲突 document.body.dataset.searchTheme = this.theme; } // --- 新增:设置/解析搜索作用域 --- /** * 设置搜索作用域。参数可以是: * - CSS 选择器字符串(取第一个匹配元素) * - DOM Element * - null/undefined(恢复到 document.body) * @param {string|Element|null} scope */ setScope(scope) { this.scopeElement = this._resolveScope(scope); } _resolveScope(scope) { if (!scope) return document.body; if (typeof scope === 'string') { const el = document.querySelector(scope); return el || document.body; } if (scope instanceof Element) return scope; return document.body; } }