Files
leaudit-platform-frontend/app/components/collabora/CollaboraViewer.tsx
T

1119 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Collabora Online 文档查看器组件
*
* 功能:
* - 加载 Collabora Online iframe
* - 管理文档加载状态
* - 提供 UNO 命令接口
* - 支持只读和编辑模式
*
* @encoding UTF-8
*/
import { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
import type { CollaboraViewerProps, CollaboraViewerHandle } from './types';
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
import { sendUnoCommand } from './Uno';
import { highlightText as performTextHighlight } from './lib/Highlightselecttext';
import { clearHighlights } from './lib/ClearHighlight';
import {
unoScrollToTop,
requestPageInfo,
customGotoPage,
unoSearchNext,
unoReplaceCurrent,
unoReplaceAll,
unoCancelSearch,
replaceTextInPage,
type PageInfo,
type GotoPageResponse
} from './lib';
/**
* Collabora 文档查看器组件
* @param props - 组件属性
* @param ref - 父组件传入的 ref,用于暴露命令接口
*/
export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewerProps>(
function CollaboraViewer(
{
fileId,
mode = 'view',
userId = 'guest',
userName = '',
targetPage,
highlightText,
aiSuggestionReplace,
},
ref
) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// 保存 iframe 的 contentWindow 引用,用于组件卸载时清除高亮
const iframeWindowRef = useRef<Window | null>(null);
// 搜索替换面板显示状态
const [showSearchReplacePanel, setShowSearchReplacePanel] = useState(false);
// 标记是否应该自动执行搜索
const shouldAutoSearchRef = useRef(false);
// 标记是否应该自动执行替换(静默模式)
const shouldAutoReplaceRef = useRef(false);
// 高亮测试面板状态
const [highlightTextInput, setHighlightTextInput] = useState('');
const [highlightPage, setHighlightPage] = useState('');
const [previousHighlightText, setPreviousHighlightText] = useState<string | null>(null);
const [highlightResult, setHighlightResult] = useState<string | null>(null);
// 清除高亮测试面板状态
const [clearHighlightResult, setClearHighlightResult] = useState<string | null>(null);
const [isClearing, setIsClearing] = useState(false);
// 页数信息测试状态
const [pageInfoResult, setPageInfoResult] = useState<string | null>(null);
const [isLoadingPageInfo, setIsLoadingPageInfo] = useState(false);
// 页面跳转测试状态
const [gotoPageInput, setGotoPageInput] = useState('');
const [gotoPageResult, setGotoPageResult] = useState<string | null>(null);
const [isJumping, setIsJumping] = useState(false);
// UNO 命令测试面板状态
const [unoCmd, setUnoCmd] = useState('.uno:Bold');
const [unoArgs, setUnoArgs] = useState('');
const [unoResult, setUnoResult] = useState<string | null>(null);
// 搜索替换测试面板状态
const [searchText, setSearchText] = useState('');
const [replaceText, setReplaceText] = useState('');
const [searchReplaceResult, setSearchReplaceResult] = useState<string | null>(null);
const [replaceAllMode, setReplaceAllMode] = useState(false);
const [searchReplacePageNumber, setSearchReplacePageNumber] = useState('');
// 记录上一次搜索的参数,用于判断是否需要重新跳转页面
const [lastSearchParams, setLastSearchParams] = useState<{
text: string;
page: string;
}>({ text: '', page: '' });
// 页面定点替换状态
const [replaceInPageNumber, setReplaceInPageNumber] = useState('');
const [replaceInPageSearch, setReplaceInPageSearch] = useState('');
const [replaceInPageReplace, setReplaceInPageReplace] = useState('');
const [replaceInPageResult, setReplaceInPageResult] = useState<string | null>(null);
const [isReplacingInPage, setIsReplacingInPage] = useState(false);
// 1. 加载 Collabora 配置
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
// 2. 监听文档加载状态
const { isDocumentLoaded } = useDocumentReady(iframeRef);
// 2.5. 保存 iframe window 引用并在文档加载时清除所有高亮
useEffect(() => {
if (isDocumentLoaded && iframeRef.current?.contentWindow) {
iframeWindowRef.current = iframeRef.current.contentWindow;
// console.log('[CollaboraViewer] 已保存 iframe window 引用');
// 🔥 文档加载完成后主动清除一次高亮(防止缓存的高亮状态)
// console.log('[CollaboraViewer] 🧹 文档加载完成,清除可能存在的缓存高亮');
clearHighlights(iframeRef.current.contentWindow, {
color: 16776960,
timeout: 5000,
}).then((result) => {
if (result.count && result.count > 0) {
// console.log(`[CollaboraViewer] ✓ 清除了 ${result.count} 个缓存的高亮区域`);
} else {
// console.log('[CollaboraViewer] ✓ 文档无缓存高亮,已确认干净');
}
}).catch(error => {
console.warn('[CollaboraViewer] ⚠️ 清除缓存高亮失败:', error);
});
}
}, [isDocumentLoaded]);
// 3. UNO 命令封装
const unoCommands = useCollaboraUnoCommands(iframeRef);
// 4. 暴露接口给父组件(包括清除高亮方法和保存方法)
useImperativeHandle(ref, () => ({
unoCommands,
isReady: isDocumentLoaded,
mode,
getIframeWindow: () => iframeRef.current?.contentWindow || null,
clearAllHighlights: async () => {
const savedWindow = iframeWindowRef.current || iframeRef.current?.contentWindow;
if (savedWindow) {
// console.log('[CollaboraViewer] 🧹 父组件调用清除高亮');
await clearHighlights(savedWindow, {
color: 16776960,
timeout: 5000,
});
// console.log('[CollaboraViewer] ✓ 清除高亮完成');
} else {
console.warn('[CollaboraViewer] ⚠️ 无法清除高亮:iframe window 不可用');
}
},
saveDocument: async () => {
const savedWindow = iframeWindowRef.current || iframeRef.current?.contentWindow;
if (savedWindow) {
console.log('[CollaboraViewer] 💾 父组件调用保存文档');
try {
// 步骤1:发送保存命令
sendUnoCommand(savedWindow, '.uno:Save', {});
// console.log('[CollaboraViewer] ✓ 保存命令已发送');
// 步骤2:等待 WOPI PutFile 请求完成(增加到 2000ms
// await new Promise(resolve => setTimeout(resolve, 2000));
// 步骤3:再次发送保存命令确保完全保存
sendUnoCommand(savedWindow, '.uno:Save', {});
// console.log('[CollaboraViewer] ✓ 二次保存命令已发送');
// 步骤4:再等待一段时间确保保存完成
// await new Promise(resolve => setTimeout(resolve, 1000));
// console.log('[CollaboraViewer] ✓ 文档保存完成(总等待 3000ms');
} catch (error) {
console.error('[CollaboraViewer] ✗ 保存文档失败:', error);
throw error;
}
} else {
console.warn('[CollaboraViewer] ⚠️ 无法保存文档:iframe window 不可用');
throw new Error('iframe window 不可用');
}
},
}), [unoCommands, isDocumentLoaded, mode]);
// 5. 监听 targetPage 和 highlightText 变化,自动跳转并高亮
useEffect(() => {
// 如果文档未加载完成,不执行跳转和高亮
if (!isDocumentLoaded || !iframeRef.current?.contentWindow) {
return;
}
// 如果有高亮文本,执行高亮操作
if (highlightText && highlightText.trim() !== '') {
const performHighlight = async () => {
try {
const iframeWindow = iframeRef.current!.contentWindow!;
const textToHighlight = highlightText.trim();
// 🔥 在高亮新内容之前,先清除之前的所有高亮
// console.log('[CollaboraViewer] 清除旧高亮...');
await clearHighlights(iframeWindow, {
color: 16776960, // 黄色
timeout: 5000,
});
// 短暂延迟后执行新高亮,确保清除操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 执行新高亮
await performTextHighlight(
iframeWindow,
textToHighlight,
{ page: targetPage }
);
// console.log(`[CollaboraViewer] 已高亮文本: "${textToHighlight}"${targetPage ? ` (第${targetPage}页)` : ''}`);
} catch (error) {
console.error('[CollaboraViewer] 高亮失败:', error);
}
};
performHighlight();
}
}, [targetPage, highlightText, isDocumentLoaded]);
// 6. 组件销毁时保存文档并清除所有高亮(使用保存的 window 引用)
useEffect(() => {
// 返回清理函数,在组件卸载时执行
return () => {
const savedWindow = iframeWindowRef.current;
if (savedWindow) {
// console.log('[CollaboraViewer] 🔥 组件即将销毁,触发文档保存和清除高亮');
// 步骤1:发送保存命令(如果是编辑模式)
if (mode === 'edit') {
try {
console.log('[CollaboraViewer] 💾 组件销毁时发送保存命令');
sendUnoCommand(savedWindow, '.uno:Save', {});
// 再次发送确保保存
setTimeout(() => {
sendUnoCommand(savedWindow, '.uno:Save', {});
console.log('[CollaboraViewer] ✓ 二次保存命令已发送');
}, 100);
} catch (error) {
console.error('[CollaboraViewer] ✗ 组件销毁时保存失败:', error);
}
}
// 步骤2:清除高亮
void clearHighlights(savedWindow, {
color: 16776960, // 黄色
timeout: 3000,
}).then(() => {
console.log('[CollaboraViewer] ✓ 组件销毁时高亮清除成功');
}).catch(error => {
console.error('[CollaboraViewer] ✗ 组件销毁时清除高亮失败:', error);
});
// 清空引用
iframeWindowRef.current = null;
} else {
console.warn('[CollaboraViewer] ⚠️ 组件销毁时未找到保存的 window 引用');
}
};
}, [mode]);
// 7. 监听 AI 建议替换参数变化,设置搜索参数
useEffect(() => {
if (!aiSuggestionReplace || !isDocumentLoaded) {
return;
}
console.log('[CollaboraViewer] 收到 AI 建议替换参数:', aiSuggestionReplace);
const { searchText: newSearchText, replaceText: newReplaceText, pageNumber, silentReplace } = aiSuggestionReplace;
// 根据 silentReplace 标志决定是否显示面板
if (silentReplace) {
// 静默替换模式:不显示面板
console.log('[CollaboraViewer] 静默替换模式,不显示面板');
} else {
// 显示搜索替换面板
setShowSearchReplacePanel(true);
}
// 设置搜索、替换和页码输入框的值
setSearchText(newSearchText);
setReplaceText(newReplaceText);
setSearchReplacePageNumber(String(pageNumber));
// 根据模式设置对应的自动执行标志
if (silentReplace) {
// 静默替换:自动执行替换
shouldAutoReplaceRef.current = true;
console.log('[CollaboraViewer] 已设置自动替换标志');
} else {
// 普通模式:仅自动执行查找
shouldAutoSearchRef.current = true;
console.log('[CollaboraViewer] 已设置搜索参数,等待状态更新后自动执行查找');
}
}, [aiSuggestionReplace, isDocumentLoaded]);
// 8. 当搜索参数更新完成后,自动执行查找
useEffect(() => {
if (shouldAutoSearchRef.current && searchText && searchReplacePageNumber && isDocumentLoaded) {
console.log('[CollaboraViewer] 状态更新完成,执行自动查找:', { searchText, searchReplacePageNumber });
// 重置标志
shouldAutoSearchRef.current = false;
// 延迟执行,确保 DOM 更新完成
const timer = setTimeout(() => {
// console.log('[CollaboraViewer] 开始执行自动查找操作');
handleSearchNext();
}, 100);
return () => clearTimeout(timer);
}
}, [searchText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 9. 当搜索参数更新完成后,自动执行替换(静默模式)
useEffect(() => {
if (shouldAutoReplaceRef.current && searchText && replaceText && searchReplacePageNumber && isDocumentLoaded) {
console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText, searchReplacePageNumber });
// 重置标志
shouldAutoReplaceRef.current = false;
// 延迟执行,确保 DOM 更新完成
const timer = setTimeout(async () => {
if (!iframeRef.current?.contentWindow) {
console.error('[CollaboraViewer] iframe 未就绪,无法执行替换');
return;
}
try {
const pageNumber = parseInt(searchReplacePageNumber, 10);
// 步骤1:跳转到指定页
console.log(`[CollaboraViewer] 步骤1:跳转到第 ${pageNumber}`);
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤2:搜索文本(确保文本被选中)
console.log(`[CollaboraViewer] 步骤2:搜索文本 "${searchText}"`);
unoSearchNext(iframeRef.current.contentWindow, searchText);
// 等待搜索完成
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤3:执行替换
console.log(`[CollaboraViewer] 步骤3:替换为 "${replaceText}"`);
unoReplaceCurrent(iframeRef.current.contentWindow, searchText, replaceText);
console.log('[CollaboraViewer] ✓ 静默替换完成');
// 显示成功提示(可选)
// toastService.success(`已替换: "${searchText}" → "${replaceText}"`);
} catch (error) {
console.error('[CollaboraViewer] 静默替换失败:', error);
}
}, 300);
return () => clearTimeout(timer);
}
}, [searchText, replaceText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 加载中状态
if (loading) {
return (
<div className="flex justify-center items-center h-full min-h-[600px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
);
}
// 错误状态
if (error || !config) {
return (
<div className="flex justify-center items-center h-full min-h-[600px]">
<div className="text-center text-red-500">
<i className="ri-error-warning-line text-4xl mb-2"></i>
<p className="text-lg">{error}</p>
{/* <p className="text-sm text-gray-500 mt-2">请刷新页面重试或联系管理员</p> */}
</div>
</div>
);
}
// 发送 UNO 命令的处理函数(测试用)
const sendUno = () => {
if (!iframeRef.current?.contentWindow) {
setUnoResult('iframe 不可用');
return;
}
let args: Record<string, unknown> = {};
const raw = (unoArgs || '').trim();
if (raw !== '') {
try {
args = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
try {
// fallback: replace single quotes with double quotes and parse
args = JSON.parse(raw.replace(/'(.*?)'/g, '"$1"')) as Record<string, unknown>;
} catch (err2) {
console.error('解析 UNO Args 失败:', err2);
setUnoResult('Args 解析失败,请使用有效 JSON');
return;
}
}
}
try {
// 使用正确的 sendUnoCommand 方法
sendUnoCommand(iframeRef.current.contentWindow, unoCmd, args);
setUnoResult(`已发送: ${unoCmd}`);
console.log('[UNO Debug] 发送命令:', unoCmd, args);
} catch (e) {
console.error('发送 UNO 失败:', e);
setUnoResult('发送失败,请查看控制台');
}
};
// 高亮测试处理函数
const handleSwitchHighlight = async () => {
if (!iframeRef.current?.contentWindow) {
// setHighlightResult('iframe 未就绪');
return;
}
if (!highlightTextInput || highlightTextInput.trim() === '') {
setHighlightResult('请输入要高亮的文本');
return;
}
try {
// 解析页码 (可选)
const page = highlightPage && highlightPage.trim() !== ''
? parseInt(highlightPage.trim(), 10)
: undefined;
// 验证页码
if (page !== undefined && (isNaN(page) || page < 1)) {
setHighlightResult('页码必须是大于0的整数');
return;
}
await performTextHighlight(
iframeRef.current.contentWindow,
highlightTextInput.trim(),
{ page }
);
// 更新上一次高亮的文本
setPreviousHighlightText(highlightTextInput.trim());
const pageInfo = page ? ` (第${page}页)` : '';
setHighlightResult(`✓ 已切换高亮: ${highlightTextInput.trim()}${pageInfo}`);
} catch (e) {
console.error('切换高亮失败:', e);
setHighlightResult(`切换失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 滚动到顶部处理函数
const handleScrollToTop = async () => {
if (!iframeRef.current?.contentWindow) {
console.error('iframe 未就绪');
return;
}
try {
await unoScrollToTop(iframeRef.current.contentWindow);
// console.log('[CollaboraViewer] 已滚动到文档顶部');
} catch (e) {
console.error('滚动到顶部失败:', e);
}
};
// 获取页数信息处理函数
const handleGetPageInfo = async () => {
if (!iframeRef.current?.contentWindow) {
// setPageInfoResult('iframe 未就绪');
return;
}
setIsLoadingPageInfo(true);
setPageInfoResult('正在获取页数信息...');
try {
const info: PageInfo = await requestPageInfo(iframeRef.current.contentWindow);
setPageInfoResult(
`✓ 总页数: ${info.totalPages} | 当前页: ${info.currentPage + 1} (索引: ${info.currentPage})`
);
console.log('[CollaboraViewer] 页数信息:', info);
// 3秒后清除提示
setTimeout(() => {
setPageInfoResult(null);
}, 3000);
} catch (e) {
console.error('获取页数信息失败:', e);
setPageInfoResult(`✗ 获取失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setPageInfoResult(null);
}, 5000);
} finally {
setIsLoadingPageInfo(false);
}
};
// 页面跳转处理函数
const handleGotoPage = async () => {
if (!iframeRef.current?.contentWindow) {
// setGotoPageResult('iframe 未就绪');
return;
}
const pageNumber = parseInt(gotoPageInput.trim(), 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setGotoPageResult('请输入有效的页码 (大于0的整数)');
return;
}
setIsJumping(true);
// setGotoPageResult(`正在跳转到第 ${pageNumber} 页...`);
try {
const response: GotoPageResponse = await customGotoPage(
iframeRef.current.contentWindow,
pageNumber
);
setGotoPageResult(
`✓ 已跳转到第 ${response.pageNumber} 页 (总页数: ${response.totalPages})`
);
// console.log('[CollaboraViewer] 页面跳转成功:', response);
// 3秒后清除提示
setTimeout(() => {
setGotoPageResult(null);
}, 3000);
} catch (e) {
console.error('页面跳转失败:', e);
setGotoPageResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setGotoPageResult(null);
}, 5000);
} finally {
setIsJumping(false);
}
};
// 清除高亮处理函数
const handleClearHighlights = async () => {
if (!iframeRef.current?.contentWindow) {
// setClearHighlightResult('iframe 未就绪');
return;
}
setIsClearing(true);
setClearHighlightResult('正在清除高亮...');
try {
const result = await clearHighlights(iframeRef.current.contentWindow, {
color: 16776960, // 黄色
timeout: 10000,
});
if (result.success) {
setClearHighlightResult(`${result.message}`);
// console.log('[CollaboraViewer] 清除高亮成功:', result);
// 清空之前记录的高亮文本
setPreviousHighlightText(null);
} else {
setClearHighlightResult(`✗ 清除失败: ${result.message}`);
console.error('[CollaboraViewer] 清除高亮失败:', result);
}
// 3秒后清除提示
setTimeout(() => {
setClearHighlightResult(null);
}, 3000);
} catch (e) {
console.error('清除高亮失败:', e);
setClearHighlightResult(`✗ 清除失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => {
setClearHighlightResult(null);
}, 5000);
} finally {
setIsClearing(false);
}
};
// 搜索下一个处理函数
const handleSearchNext = async () => {
if (!iframeRef.current?.contentWindow) {
// setSearchReplaceResult('iframe 未就绪');
return;
}
if (!searchText.trim()) {
setSearchReplaceResult('请输入搜索文本');
return;
}
try {
const currentSearchText = searchText.trim();
const currentPageNumber = searchReplacePageNumber.trim();
// 判断是否需要跳转页面(搜索文本或页码发生变化)
const needsPageJump =
lastSearchParams.text !== currentSearchText ||
lastSearchParams.page !== currentPageNumber;
// 如果指定了页码且需要跳转,先跳转到该页
if (needsPageJump && currentPageNumber !== '') {
const pageNumber = parseInt(currentPageNumber, 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setSearchReplaceResult('✗ 页码必须是大于0的整数');
return;
}
// setSearchReplaceResult(`正在跳转到第 ${pageNumber} 页...`);
try {
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 短暂延迟等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
} catch (e) {
console.error('跳转页面失败:', e);
setSearchReplaceResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
return;
}
}
// 执行搜索(如果是页面内循环查找,会自动找到下一个匹配项)
unoSearchNext(iframeRef.current.contentWindow, currentSearchText);
// 更新搜索参数记录
setLastSearchParams({
text: currentSearchText,
page: currentPageNumber
});
// const pageInfo = currentPageNumber !== ''
// ? ` (第${currentPageNumber}页内循环查找)`
// : '';
// const jumpInfo = needsPageJump && currentPageNumber !== '' ? ' [已跳转]' : '';
// setSearchReplaceResult(`✓ 搜索: "${currentSearchText}"${pageInfo}${jumpInfo}`);
// 3秒后清除提示
setTimeout(() => setSearchReplaceResult(null), 3000);
} catch (e) {
console.error('搜索失败:', e);
setSearchReplaceResult(`✗ 搜索失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 替换处理函数
const handleReplace = async () => {
if (!iframeRef.current?.contentWindow) {
// setSearchReplaceResult('iframe 未就绪');
return;
}
if (!searchText.trim()) {
setSearchReplaceResult('请输入搜索文本');
return;
}
try {
// 如果指定了页码,先跳转到该页
if (searchReplacePageNumber && searchReplacePageNumber.trim() !== '') {
const pageNumber = parseInt(searchReplacePageNumber.trim(), 10);
if (isNaN(pageNumber) || pageNumber < 1) {
setSearchReplaceResult('✗ 页码必须是大于0的整数');
return;
}
// setSearchReplaceResult(`正在跳转到第 ${pageNumber} 页...`);
try {
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 短暂延迟等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
} catch (e) {
console.error('跳转页面失败:', e);
setSearchReplaceResult(`✗ 跳转失败: ${e instanceof Error ? e.message : '未知错误'}`);
return;
}
}
const pageInfo = searchReplacePageNumber && searchReplacePageNumber.trim() !== ''
? ` (第${searchReplacePageNumber}页)`
: '';
if (replaceAllMode) {
// 替换全部
unoReplaceAll(iframeRef.current.contentWindow, searchText.trim(), replaceText);
setSearchReplaceResult(`✓ 已替换全部: "${searchText.trim()}" → "${replaceText}"${pageInfo}`);
} else {
// 替换当前选中项:先搜索,再替换
console.log('[handleReplace] 开始替换流程:先搜索,再替换');
// 步骤1:先执行搜索,确保文本被选中
unoSearchNext(iframeRef.current.contentWindow, searchText.trim());
// 步骤2:等待搜索完成后执行替换
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤3:执行替换
unoReplaceCurrent(iframeRef.current.contentWindow, searchText.trim(), replaceText);
setSearchReplaceResult(`✓ 已替换: "${searchText.trim()}" → "${replaceText}"${pageInfo}`);
console.log('[handleReplace] 替换完成');
}
// 3秒后清除提示
setTimeout(() => setSearchReplaceResult(null), 3000);
} catch (e) {
console.error('替换失败:', e);
setSearchReplaceResult(`✗ 替换失败: ${e instanceof Error ? e.message : '未知错误'}`);
}
};
// 取消搜索处理函数
const handleCancelSearch = () => {
if (!iframeRef.current?.contentWindow) {
return;
}
try {
unoCancelSearch(iframeRef.current.contentWindow);
// 重置搜索参数记录,下次搜索会重新定位
setLastSearchParams({ text: '', page: '' });
setSearchReplaceResult('✓ 已取消搜索');
setTimeout(() => setSearchReplaceResult(null), 2000);
} catch (e) {
console.error('取消搜索失败:', e);
}
};
// 页面定点替换处理函数
const handleReplaceInPage = async () => {
if (!iframeRef.current?.contentWindow) {
// setReplaceInPageResult('iframe 未就绪');
return;
}
const pageNum = parseInt(replaceInPageNumber.trim(), 10);
if (isNaN(pageNum) || pageNum < 1) {
setReplaceInPageResult('请输入有效的页码 (大于0的整数)');
return;
}
if (!replaceInPageSearch.trim()) {
setReplaceInPageResult('请输入原文');
return;
}
setIsReplacingInPage(true);
setReplaceInPageResult(`正在第 ${pageNum} 页执行替换...`);
try {
const result = await replaceTextInPage(
iframeRef.current.contentWindow,
pageNum,
replaceInPageSearch.trim(),
replaceInPageReplace
);
if (result.success) {
setReplaceInPageResult(`${result.message}`);
} else {
setReplaceInPageResult(`${result.message}`);
}
// 5秒后清除提示
setTimeout(() => setReplaceInPageResult(null), 5000);
} catch (e) {
console.error('页面定点替换失败:', e);
setReplaceInPageResult(`✗ 执行失败: ${e instanceof Error ? e.message : '未知错误'}`);
// 5秒后清除错误提示
setTimeout(() => setReplaceInPageResult(null), 5000);
} finally {
setIsReplacingInPage(false);
}
};
return (
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
{/* UNO 命令测试面板 - 临时隐藏 */}
{/* <div className="absolute top-2 right-2 z-50 bg-white bg-opacity-95 px-3 py-2 rounded shadow-lg flex items-center gap-2 border border-gray-200">
<span className="text-xs text-gray-500 font-medium">UNO:</span>
<input
className="px-2 py-1 border rounded text-sm w-48 font-mono"
value={unoCmd}
onChange={(e) => setUnoCmd(e.target.value)}
placeholder="UNO 命令 (如 .uno:Bold)"
aria-label="UNO 命令"
onKeyPress={(e) => {
if (e.key === 'Enter') {
sendUno();
}
}}
/>
<input
className="px-2 py-1 border rounded text-sm w-64 font-mono"
value={unoArgs}
onChange={(e) => setUnoArgs(e.target.value)}
placeholder='Args (JSON, 如 {"SearchItem.SearchString":{"type":"string","value":"test"}})'
aria-label="UNO Args (JSON)"
onKeyPress={(e) => {
if (e.key === 'Enter') {
sendUno();
}
}}
/>
<button
className="px-3 py-1.5 bg-indigo-600 text-white rounded hover:bg-indigo-700 text-sm font-medium disabled:bg-gray-400"
onClick={sendUno}
disabled={!isDocumentLoaded}
>
发送
</button>
{unoResult && (
<span className={`text-xs ml-1 ${unoResult.startsWith('已发送') ? 'text-green-600' : 'text-red-500'}`}>
{unoResult}
</span>
)}
</div> */}
{/* 高亮测试面板 - 临时隐藏 */}
{/* <div className="absolute top-2 left-2 z-50 bg-white bg-opacity-90 px-3 py-2 rounded shadow flex items-center gap-2">
<input
className="px-3 py-1.5 border rounded text-sm w-64"
value={highlightTextInput}
onChange={(e) => setHighlightTextInput(e.target.value)}
placeholder="输入要高亮的文本 (如: 合同编号)"
aria-label="高亮文本"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSwitchHighlight();
}
}}
/>
<input
className="px-3 py-1.5 border rounded text-sm w-20"
value={highlightPage}
onChange={(e) => setHighlightPage(e.target.value)}
placeholder="页码"
aria-label="页码"
type="number"
min="1"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSwitchHighlight();
}
}}
/>
<button
className="px-4 py-1.5 bg-yellow-500 text-white rounded hover:bg-yellow-600 text-sm font-medium"
onClick={handleSwitchHighlight}
disabled={!isDocumentLoaded}
>
切换高亮
</button>
{previousHighlightText && (
<span className="text-xs text-gray-600">
上次: {previousHighlightText}
</span>
)}
{highlightResult && (
<span className="text-xs text-gray-700 ml-2">{highlightResult}</span>
)}
</div> */}
{/* 清除高亮测试面板 - 临时隐藏 */}
{/* <div className="absolute top-16 left-2 z-50 bg-white bg-opacity-90 px-3 py-2 rounded shadow flex items-center gap-2">
<button
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${
isClearing
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-red-500 text-white hover:bg-red-600'
}`}
onClick={handleClearHighlights}
disabled={!isDocumentLoaded || isClearing}
>
{isClearing ? '清除中...' : '清除所有高亮'}
</button>
{clearHighlightResult && (
<span className={`text-xs ml-2 ${
clearHighlightResult.startsWith('✓') ? 'text-green-600' :
clearHighlightResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{clearHighlightResult}
</span>
)}
</div> */}
{/* 搜索替换测试面板 - 移动到左上角并添加关闭按钮 */}
{showSearchReplacePanel && (
<div className="absolute top-2 left-2 z-50 bg-white bg-opacity-10 px-3 py-2 rounded shadow-lg border border-gray-200">
{/* 标题栏和关闭按钮 */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-700 font-medium"></span>
<button
onClick={() => setShowSearchReplacePanel(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="关闭面板"
aria-label="关闭搜索替换面板"
>
<i className="ri-close-line text-lg"></i>
</button>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm w-48"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="输入要搜索的文本"
aria-label="搜索文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearchNext();
}
}}
/>
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm w-16"
value={searchReplacePageNumber}
onChange={(e) => setSearchReplacePageNumber(e.target.value)}
placeholder="页码"
aria-label="页码"
type="number"
min="1"
title="可选:指定搜索/替换的页码"
/>
<button
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleSearchNext}
disabled={!isDocumentLoaded}
>
</button>
<button
className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleCancelSearch}
disabled={!isDocumentLoaded}
title="取消搜索选中"
>
</button>
</div>
<div className="flex items-center gap-8 justify-between">
<div className="flex items-center gap-2 flex-1">
<span className="text-xs text-gray-500 font-medium w-12">:</span>
<input
className="px-2 py-1 border rounded text-sm flex-1"
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
placeholder="输入替换后的文本"
aria-label="替换文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleReplace();
}
}}
/>
<button
className="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm font-medium disabled:bg-gray-400"
onClick={handleReplace}
disabled={!isDocumentLoaded}
>
{replaceAllMode ? '全部确认替换' : '确认替换'}
</button>
{/* <label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={replaceAllMode}
onChange={(e) => setReplaceAllMode(e.target.checked)}
className="w-3 h-3"
/>
全部
</label> */}
</div>
<button
className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 text-sm font-medium transition-colors"
onClick={() => setShowSearchReplacePanel(false)}
title="关闭搜索替换面板"
aria-label="关闭"
>
</button>
</div>
{searchReplaceResult && (
<div className={`mt-2 text-xs ${
searchReplaceResult.startsWith('✓') ? 'text-green-600' :
searchReplaceResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{searchReplaceResult}
</div>
)}
</div>
)}
{/* 页面定点替换测试面板 - 临时隐藏 */}
{/* <div className="absolute top-56 left-2 z-50 bg-white bg-opacity-95 px-3 py-2 rounded shadow-lg border border-purple-200">
<div className="text-xs text-purple-600 font-medium mb-2">页面定点替换</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 font-medium w-12">页码:</span>
<input
className="px-2 py-1 border rounded text-sm w-16"
value={replaceInPageNumber}
onChange={(e) => setReplaceInPageNumber(e.target.value)}
placeholder="如: 12"
aria-label="页码"
type="number"
min="1"
/>
<span className="text-xs text-gray-500 font-medium w-12">原文:</span>
<input
className="px-2 py-1 border rounded text-sm w-32"
value={replaceInPageSearch}
onChange={(e) => setReplaceInPageSearch(e.target.value)}
placeholder="要替换的文本"
aria-label="原文"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 font-medium w-12">新文本:</span>
<input
className="px-2 py-1 border rounded text-sm w-48"
value={replaceInPageReplace}
onChange={(e) => setReplaceInPageReplace(e.target.value)}
placeholder="替换后的文本"
aria-label="新文本"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleReplaceInPage();
}
}}
/>
<button
className={`px-4 py-1 rounded text-sm font-medium transition-colors ${
isReplacingInPage
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-purple-500 text-white hover:bg-purple-600'
}`}
onClick={handleReplaceInPage}
disabled={!isDocumentLoaded || isReplacingInPage}
>
{isReplacingInPage ? '执行中...' : '定点替换'}
</button>
</div>
{replaceInPageResult && (
<div className={`mt-2 text-xs ${
replaceInPageResult.startsWith('✓') ? 'text-green-600' :
replaceInPageResult.startsWith('✗') ? 'text-red-600' :
'text-gray-600'
}`}>
{replaceInPageResult}
</div>
)}
</div> */}
{/* 文档加载提示 */}
{!isDocumentLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 z-10">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p className="mt-4 text-gray-600">...</p>
<p className="text-sm text-gray-500 mt-2">{config.fileName}</p>
</div>
</div>
)}
{/* Collabora iframe - tabIndex is needed for keyboard navigation */}
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
<iframe
ref={iframeRef}
src={config.iframeUrl}
className="w-full h-full border-0"
style={{
minHeight: '600px',
height: '100%',
}}
allow="clipboard-read; clipboard-write"
title={`Collabora Online - ${config.fileName}`}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
tabIndex={0}
/>
{/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
</div>
);
});
// 导出类型和 hook
export type { CollaboraViewerHandle };
export { useCollaboraUnoCommands };