|
|
|
|
@ -5,54 +5,74 @@ import PopupModal from './components/PopupModal';
|
|
|
|
|
import SearchIndex from './search-index.js';
|
|
|
|
|
import i18nLib from '@tryghost/i18n';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 搜索应用的主组件 - 负责管理整个搜索功能的布局和状态
|
|
|
|
|
*/
|
|
|
|
|
export default class App extends React.Component {
|
|
|
|
|
constructor(props) {
|
|
|
|
|
super(props);
|
|
|
|
|
|
|
|
|
|
// ==================== 国际化设置 ====================
|
|
|
|
|
// 设置语言环境,默认为英语
|
|
|
|
|
const i18nLanguage = this.props.locale || 'en';
|
|
|
|
|
const i18n = i18nLib(i18nLanguage, 'search');
|
|
|
|
|
const dir = i18n.dir() || 'ltr';
|
|
|
|
|
const dir = i18n.dir() || 'ltr'; // 文字方向(左到右或右到左)
|
|
|
|
|
|
|
|
|
|
// ==================== 搜索索引初始化 ====================
|
|
|
|
|
// 创建搜索索引实例,用于处理搜索逻辑
|
|
|
|
|
const searchIndex = new SearchIndex({
|
|
|
|
|
adminUrl: props.adminUrl,
|
|
|
|
|
apiKey: props.apiKey,
|
|
|
|
|
dir: dir
|
|
|
|
|
adminUrl: props.adminUrl, // 管理后台URL
|
|
|
|
|
apiKey: props.apiKey, // API密钥
|
|
|
|
|
dir: dir // 文字方向
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ==================== 组件状态初始化 ====================
|
|
|
|
|
this.state = {
|
|
|
|
|
searchIndex,
|
|
|
|
|
showPopup: false,
|
|
|
|
|
indexStarted: false,
|
|
|
|
|
indexComplete: false,
|
|
|
|
|
t: i18n.t,
|
|
|
|
|
dir: dir,
|
|
|
|
|
scrollbarWidth: 0
|
|
|
|
|
searchIndex, // 搜索索引实例
|
|
|
|
|
showPopup: false, // 是否显示搜索弹窗
|
|
|
|
|
indexStarted: false, // 是否开始构建索引
|
|
|
|
|
indexComplete: false, // 索引构建是否完成
|
|
|
|
|
t: i18n.t, // 国际化翻译函数
|
|
|
|
|
dir: dir, // 文字方向
|
|
|
|
|
scrollbarWidth: 0 // 滚动条宽度(用于防止布局偏移)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 创建输入框的引用,用于直接操作DOM元素
|
|
|
|
|
this.inputRef = React.createRef();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 组件挂载后立即执行
|
|
|
|
|
*/
|
|
|
|
|
componentDidMount() {
|
|
|
|
|
// 计算并保存滚动条宽度
|
|
|
|
|
const scrollbarWidth = this.getScrollbarWidth();
|
|
|
|
|
this.setState({scrollbarWidth});
|
|
|
|
|
|
|
|
|
|
// 初始化搜索设置
|
|
|
|
|
this.initSetup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 组件更新时执行,主要用于处理弹窗显示/隐藏时的滚动条逻辑
|
|
|
|
|
*/
|
|
|
|
|
componentDidUpdate(_prevProps, prevState) {
|
|
|
|
|
// ==================== 弹窗显示状态变化处理 ====================
|
|
|
|
|
if (prevState.showPopup !== this.state.showPopup) {
|
|
|
|
|
/** Remove background scroll when popup is opened */
|
|
|
|
|
/** 当弹窗打开时移除背景滚动,防止页面抖动 */
|
|
|
|
|
try {
|
|
|
|
|
if (this.state.showPopup) {
|
|
|
|
|
/** When modal is opened, store current overflow and set as hidden */
|
|
|
|
|
/** 当弹窗打开时,保存当前的overflow状态并设置为hidden */
|
|
|
|
|
this.bodyScroll = window.document?.body?.style?.overflow;
|
|
|
|
|
this.bodyMargin = window.getComputedStyle(document.body).getPropertyValue('margin-right');
|
|
|
|
|
window.document.body.style.overflow = 'hidden';
|
|
|
|
|
|
|
|
|
|
// 如果有滚动条且内容高度超过视口,调整右边距防止布局偏移
|
|
|
|
|
if (this.state.scrollbarWidth && document.body.scrollHeight > window.innerHeight) {
|
|
|
|
|
window.document.body.style.marginRight = `calc(${this.bodyMargin} + ${this.state.scrollbarWidth}px)`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
/** When the modal is hidden, reset overflow property for body */
|
|
|
|
|
/** 当弹窗隐藏时,恢复body的overflow属性 */
|
|
|
|
|
window.document.body.style.overflow = this.bodyScroll || '';
|
|
|
|
|
if (!this.bodyMargin || this.bodyMargin === '0px') {
|
|
|
|
|
window.document.body.style.marginRight = '';
|
|
|
|
|
@ -61,74 +81,93 @@ export default class App extends React.Component {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
/** Ignore any errors for scroll handling */
|
|
|
|
|
/** 忽略滚动处理中的任何错误 */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==================== 弹窗关闭时清空搜索值 ====================
|
|
|
|
|
if (this.state.showPopup !== prevState?.showPopup && !this.state.showPopup) {
|
|
|
|
|
this.setState({
|
|
|
|
|
searchValue: ''
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==================== 弹窗打开时初始化搜索索引 ====================
|
|
|
|
|
if (this.state.showPopup && !this.state.indexStarted) {
|
|
|
|
|
this.setupSearchIndex();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 设置搜索索引 - 异步初始化搜索功能
|
|
|
|
|
*/
|
|
|
|
|
async setupSearchIndex() {
|
|
|
|
|
this.setState({
|
|
|
|
|
indexStarted: true
|
|
|
|
|
indexStarted: true // 标记索引构建开始
|
|
|
|
|
});
|
|
|
|
|
await this.state.searchIndex.init();
|
|
|
|
|
await this.state.searchIndex.init(); // 等待索引初始化完成
|
|
|
|
|
this.setState({
|
|
|
|
|
indexComplete: true
|
|
|
|
|
indexComplete: true // 标记索引构建完成
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 组件卸载前的清理工作
|
|
|
|
|
*/
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
|
/**Clear timeouts and event listeners on unmount */
|
|
|
|
|
/** 清除超时和事件监听器 */
|
|
|
|
|
window.removeEventListener('hashchange', this.hashHandler, false);
|
|
|
|
|
window.removeEventListener('keydown', this.handleKeyDown, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 初始化搜索设置
|
|
|
|
|
*/
|
|
|
|
|
initSetup() {
|
|
|
|
|
// Listen to preview mode changes
|
|
|
|
|
// 监听预览模式变化
|
|
|
|
|
this.handleSearchUrl();
|
|
|
|
|
this.addKeyboardShortcuts();
|
|
|
|
|
this.setupCustomTriggerButton();
|
|
|
|
|
|
|
|
|
|
// 哈希变化处理器(用于URL路由)
|
|
|
|
|
this.hashHandler = () => {
|
|
|
|
|
this.handleSearchUrl();
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('hashchange', this.hashHandler, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// User for adding trailing margin to prevent layout shift when popup appears
|
|
|
|
|
/**
|
|
|
|
|
* 计算滚动条宽度 - 用于防止弹窗出现时的布局偏移
|
|
|
|
|
*/
|
|
|
|
|
getScrollbarWidth() {
|
|
|
|
|
// Create a temporary div
|
|
|
|
|
// 创建临时div元素
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.style.visibility = 'hidden';
|
|
|
|
|
div.style.overflow = 'scroll'; // forcing scrollbar to appear
|
|
|
|
|
div.style.overflow = 'scroll'; // 强制显示滚动条
|
|
|
|
|
document.body.appendChild(div);
|
|
|
|
|
|
|
|
|
|
// Calculate the width difference
|
|
|
|
|
// 计算宽度差异(这就是滚动条的宽度)
|
|
|
|
|
const scrollbarWidth = div.offsetWidth - div.clientWidth;
|
|
|
|
|
|
|
|
|
|
// Clean up
|
|
|
|
|
// 清理临时元素
|
|
|
|
|
document.body.removeChild(div);
|
|
|
|
|
|
|
|
|
|
return scrollbarWidth;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Setup custom trigger buttons handling on page */
|
|
|
|
|
/**
|
|
|
|
|
* 设置页面上的自定义触发按钮处理
|
|
|
|
|
*/
|
|
|
|
|
setupCustomTriggerButton() {
|
|
|
|
|
// Handler for custom buttons
|
|
|
|
|
// 自定义按钮的点击处理器
|
|
|
|
|
this.clickHandler = (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.setState({
|
|
|
|
|
showPopup: true
|
|
|
|
|
showPopup: true // 显示搜索弹窗
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ==================== 焦点管理技巧 ====================
|
|
|
|
|
// 创建临时输入元素来解决焦点问题
|
|
|
|
|
const tmpElement = document.createElement('input');
|
|
|
|
|
tmpElement.style.opacity = '0';
|
|
|
|
|
tmpElement.style.position = 'fixed';
|
|
|
|
|
@ -136,12 +175,14 @@ export default class App extends React.Component {
|
|
|
|
|
document.body.appendChild(tmpElement);
|
|
|
|
|
tmpElement.focus();
|
|
|
|
|
|
|
|
|
|
// 延迟后将焦点转移到真正的搜索输入框
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.inputRef.current.focus();
|
|
|
|
|
document.body.removeChild(tmpElement);
|
|
|
|
|
}, 150);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 获取所有自定义触发按钮并添加点击事件
|
|
|
|
|
this.customTriggerButtons = this.getCustomTriggerButtons();
|
|
|
|
|
this.customTriggerButtons.forEach((customTriggerButton) => {
|
|
|
|
|
customTriggerButton.removeEventListener('click', this.clickHandler);
|
|
|
|
|
@ -149,27 +190,39 @@ export default class App extends React.Component {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 获取页面上的自定义触发按钮
|
|
|
|
|
*/
|
|
|
|
|
getCustomTriggerButtons() {
|
|
|
|
|
const customTriggerSelector = '[data-ghost-search]';
|
|
|
|
|
const customTriggerSelector = '[data-ghost-search]'; // 通过data属性选择按钮
|
|
|
|
|
return document.querySelectorAll(customTriggerSelector) || [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 处理搜索URL - 当URL哈希为/#search时自动打开搜索弹窗
|
|
|
|
|
*/
|
|
|
|
|
handleSearchUrl() {
|
|
|
|
|
const [path] = window.location.hash.substr(1).split('?');
|
|
|
|
|
if (path === '/search' || path === '/search/') {
|
|
|
|
|
this.setState({
|
|
|
|
|
showPopup: true
|
|
|
|
|
});
|
|
|
|
|
// 替换历史状态,移除哈希部分
|
|
|
|
|
window.history.replaceState('', document.title, window.location.pathname);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 添加快捷键支持 - Cmd+K 打开搜索
|
|
|
|
|
*/
|
|
|
|
|
addKeyboardShortcuts() {
|
|
|
|
|
const customTriggerButtons = this.getCustomTriggerButtons();
|
|
|
|
|
if (!customTriggerButtons?.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handleKeyDown = (e) => {
|
|
|
|
|
// 检测 Cmd+K (Mac) 或 Ctrl+K (Windows)
|
|
|
|
|
if (e.key === 'k' && e.metaKey) {
|
|
|
|
|
this.setState({
|
|
|
|
|
showPopup: true
|
|
|
|
|
@ -182,19 +235,26 @@ export default class App extends React.Component {
|
|
|
|
|
document.addEventListener('keydown', this.handleKeyDown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 渲染组件 - 这里是整个搜索应用的布局入口
|
|
|
|
|
*/
|
|
|
|
|
render() {
|
|
|
|
|
return (
|
|
|
|
|
/**
|
|
|
|
|
* 使用Context API提供全局状态给所有子组件
|
|
|
|
|
* 这是React中管理全局状态的常用模式
|
|
|
|
|
*/
|
|
|
|
|
<AppContext.Provider value={{
|
|
|
|
|
page: 'search',
|
|
|
|
|
showPopup: this.state.showPopup,
|
|
|
|
|
adminUrl: this.props.adminUrl,
|
|
|
|
|
stylesUrl: this.props.stylesUrl,
|
|
|
|
|
searchIndex: this.state.searchIndex,
|
|
|
|
|
indexComplete: this.state.indexComplete,
|
|
|
|
|
searchValue: this.state.searchValue,
|
|
|
|
|
inputRef: this.inputRef,
|
|
|
|
|
onAction: () => {},
|
|
|
|
|
dispatch: (action, data) => {
|
|
|
|
|
page: 'search', // 当前页面标识
|
|
|
|
|
showPopup: this.state.showPopup, // 弹窗显示状态
|
|
|
|
|
adminUrl: this.props.adminUrl, // 管理后台URL
|
|
|
|
|
stylesUrl: this.props.stylesUrl, // 样式文件URL
|
|
|
|
|
searchIndex: this.state.searchIndex, // 搜索索引实例
|
|
|
|
|
indexComplete: this.state.indexComplete, // 索引完成状态
|
|
|
|
|
searchValue: this.state.searchValue, // 搜索关键词
|
|
|
|
|
inputRef: this.inputRef, // 输入框引用
|
|
|
|
|
onAction: () => {}, // 动作处理器
|
|
|
|
|
dispatch: (action, data) => { // 状态更新函数
|
|
|
|
|
if (action === 'update') {
|
|
|
|
|
this.setState({
|
|
|
|
|
...this.state,
|
|
|
|
|
@ -202,11 +262,12 @@ export default class App extends React.Component {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
t: this.state.t,
|
|
|
|
|
dir: this.state.dir
|
|
|
|
|
t: this.state.t, // 翻译函数
|
|
|
|
|
dir: this.state.dir // 文字方向
|
|
|
|
|
}}>
|
|
|
|
|
{/* 渲染搜索弹窗组件 - 这是主要的UI组件 */}
|
|
|
|
|
<PopupModal />
|
|
|
|
|
</AppContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|