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.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() {
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;
}
}