temp:临时备份,测试合并兼容性
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Collabora Online 文档查看器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 加载 Collabora Online iframe
|
||||
* - 管理文档加载状态
|
||||
* - 提供 UNO 命令接口
|
||||
* - 支持只读和编辑模式
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import type { CollaboraViewerProps } from './types';
|
||||
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
|
||||
|
||||
/**
|
||||
* Collabora 文档查看器组件
|
||||
* @param props - 组件属性
|
||||
*/
|
||||
export function CollaboraViewer({
|
||||
fileId,
|
||||
mode = 'view',
|
||||
userId = 'guest',
|
||||
userName = '访客',
|
||||
}: CollaboraViewerProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// 1. 加载 Collabora 配置
|
||||
const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName);
|
||||
|
||||
// 2. 监听文档加载状态
|
||||
const { isDocumentLoaded } = useDocumentReady(iframeRef);
|
||||
|
||||
// 3. UNO 命令封装
|
||||
const unoCommands = useCollaboraUnoCommands(iframeRef);
|
||||
|
||||
// 加载中状态
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="collabora-viewer relative w-full h-full min-h-[600px]">
|
||||
{/* 文档加载提示 */}
|
||||
{!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 */}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* 调试信息(开发环境) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-2 p-2 bg-gray-100 text-xs rounded">
|
||||
<div>
|
||||
<strong>文档状态:</strong> {isDocumentLoaded ? '已加载' : '加载中...'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>模式:</strong> {config.mode === 'edit' ? '编辑' : '只读'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>文件:</strong> {config.fileName}
|
||||
</div>
|
||||
<div>
|
||||
<strong>用户:</strong> {userName} ({userId})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 导出 UNO 命令 hook 供父组件使用(如果需要)
|
||||
export { useCollaboraUnoCommands };
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Collabora Online UNO 命令工具函数
|
||||
*
|
||||
* 职责: 封装 Collabora iframe 的 UNO 命令调用
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
/**
|
||||
* 发送 UNO 命令到 Collabora iframe
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param command - UNO 命令名称,如 '.uno:ExecuteSearch'
|
||||
* @param args - 命令参数
|
||||
*/
|
||||
export function sendUnoCommand(
|
||||
iframeWindow: Window,
|
||||
command: string,
|
||||
args: Record<string, any> = {}
|
||||
): void {
|
||||
const message = {
|
||||
MessageId: 'Send_UNO_Command',
|
||||
SendTime: Date.now(),
|
||||
Values: {
|
||||
Command: command,
|
||||
Args: args,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[UNO] 发送命令:', command, args);
|
||||
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文本
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param text - 要搜索的文本
|
||||
*/
|
||||
export function unoSearchText(iframeWindow: Window, text: string): void {
|
||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||
'SearchItem.SearchString': { type: 'string', value: text },
|
||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = Search Next (搜索下一个)
|
||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||
'SearchItem.Pattern': { type: 'boolean', value: false },
|
||||
'SearchItem.Content': { type: 'boolean', value: false },
|
||||
'SearchItem.AsianOptions': { type: 'boolean', value: false },
|
||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 }, // 普通搜索
|
||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||
'SearchItem.Start': { type: 'boolean', value: true }, // 从头开始搜索
|
||||
'SearchItem.Quiet': { type: 'boolean', value: true }, // 静默模式
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换文本
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param searchText - 要搜索的文本
|
||||
* @param replaceText - 替换后的文本
|
||||
*/
|
||||
export function unoReplaceText(
|
||||
iframeWindow: Window,
|
||||
searchText: string,
|
||||
replaceText: string
|
||||
): void {
|
||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||
'SearchItem.SearchString': { type: 'string', value: searchText },
|
||||
'SearchItem.ReplaceString': { type: 'string', value: replaceText },
|
||||
'SearchItem.Command': { type: 'long', value: 3 }, // 3 = ReplaceAll
|
||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||
'Quiet': { type: 'boolean', value: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮文本
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param text - 要高亮的文本
|
||||
* @param color - 高亮颜色,默认 16776960 = 黄色
|
||||
*/
|
||||
export function unoHighlightText(
|
||||
iframeWindow: Window,
|
||||
text: string,
|
||||
color: number = 16776960
|
||||
): void {
|
||||
// 1. 查找所有
|
||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||
'SearchItem.SearchString': { type: 'string', value: text },
|
||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||
'Quiet': { type: 'boolean', value: true },
|
||||
});
|
||||
|
||||
// 2. 设置背景色
|
||||
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
||||
BackColor: { type: 'long', value: color },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除高亮
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
* @param text - 要移除高亮的文本
|
||||
*/
|
||||
export function unoRemoveHighlight(iframeWindow: Window, text: string): void {
|
||||
// 1. 查找所有
|
||||
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
|
||||
'SearchItem.SearchString': { type: 'string', value: text },
|
||||
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll
|
||||
'SearchItem.SearchFlags': { type: 'long', value: 0 },
|
||||
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
|
||||
'SearchItem.Backward': { type: 'boolean', value: false },
|
||||
'Quiet': { type: 'boolean', value: true },
|
||||
});
|
||||
|
||||
// 2. 移除背景色 -1 = 无色
|
||||
sendUnoCommand(iframeWindow, '.uno:BackColor', {
|
||||
BackColor: { type: 'long', value: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 - Escape
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
*/
|
||||
export function unoEscape(iframeWindow: Window): void {
|
||||
sendUnoCommand(iframeWindow, '.uno:Escape', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到文档开头
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
*/
|
||||
export function unoScrollToTop(iframeWindow: Window): void {
|
||||
sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文档
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
*/
|
||||
export function unoSave(iframeWindow: Window): void {
|
||||
sendUnoCommand(iframeWindow, '.uno:Save');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档状态 (用于检测命令队列完成)
|
||||
* @param iframeWindow - iframe 的 contentWindow
|
||||
*
|
||||
* 说明: 发送 Get_State 命令作为"哨兵命令",利用 Collabora 的单线程命令队列机制。
|
||||
* 当收到 Doc_ModifiedStatus 类型的回调时,证明前面队列中的所有命令都已执行完毕。
|
||||
*
|
||||
* 响应格式: { MessageId: 'Doc_ModifiedStatus', Values: {...} }
|
||||
*/
|
||||
export function unoGetState(iframeWindow: Window): void {
|
||||
const message = {
|
||||
MessageId: 'Get_State',
|
||||
SendTime: Date.now(),
|
||||
Values: {
|
||||
CommandName: '.uno:ModifiedStatus',
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[UNO] 发送 Get_State (.uno:ModifiedStatus) - 等待命令队列执行完成');
|
||||
iframeWindow.postMessage(JSON.stringify(message), '*');
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Collabora Online 相关的自定义 hooks
|
||||
*
|
||||
* 功能:
|
||||
* - useCollaboraConfig: 加载 Collabora 配置(使用 Remix useFetcher)
|
||||
* - useDocumentReady: 监听文档加载完成
|
||||
* - useCollaboraUnoCommands: UNO 命令封装
|
||||
*
|
||||
* @encoding UTF-8
|
||||
*/
|
||||
|
||||
import { RefObject, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { toastService } from '../ui/Toast';
|
||||
import type { CollaboraConfig } from './types';
|
||||
import {
|
||||
unoSearchText,
|
||||
unoReplaceText,
|
||||
unoHighlightText,
|
||||
unoRemoveHighlight,
|
||||
unoEscape,
|
||||
unoScrollToTop,
|
||||
unoSave,
|
||||
} from './Uno';
|
||||
import { COLLABORA_URL } from '~/config/api-config';
|
||||
|
||||
// ==================== 1. 配置加载 ====================
|
||||
|
||||
/**
|
||||
* 加载 Collabora 配置(使用 Remix useFetcher)
|
||||
* @param fileId - 文件路径
|
||||
* @param mode - 模式(view 或 edit)
|
||||
* @param userId - 用户 ID
|
||||
* @param userName - 用户名
|
||||
* @returns 配置、加载状态、错误信息
|
||||
*/
|
||||
export function useCollaboraConfig(
|
||||
fileId: string,
|
||||
mode: 'view' | 'edit',
|
||||
userId: string,
|
||||
userName: string
|
||||
) {
|
||||
const fetcher = useFetcher<CollaboraConfig>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === 'idle' && !fetcher.data) {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
fileId,
|
||||
mode,
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
|
||||
// 加载配置
|
||||
fetcher.load(`/api/collabora/config?${params}`);
|
||||
}
|
||||
}, [fileId, mode, userId, userName, fetcher]);
|
||||
|
||||
// 检查错误
|
||||
useEffect(() => {
|
||||
if (fetcher.data && 'error' in fetcher.data) {
|
||||
const errorMessage = (fetcher.data as any).error || '加载配置失败';
|
||||
setError(errorMessage);
|
||||
toastService.error(`加载文档配置失败: ${errorMessage}`);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
return {
|
||||
config: fetcher.data && !('error' in fetcher.data) ? fetcher.data : null,
|
||||
loading: fetcher.state === 'loading',
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 2. 文档加载状态监听 ====================
|
||||
|
||||
/**
|
||||
* 监听文档加载完成
|
||||
* @param iframeRef - iframe 引用
|
||||
* @returns 文档加载状态
|
||||
*/
|
||||
export function useDocumentReady(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
const [isDocumentLoaded, setIsDocumentLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// 验证消息来源
|
||||
const collaboraOrigin = new URL(COLLABORA_URL).origin;
|
||||
|
||||
if (event.origin !== collaboraOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
|
||||
if (msg.MessageId === 'App_LoadingStatus' && msg.Values?.Status === 'Document_Loaded') {
|
||||
console.log('[DocumentReady] 文档加载完成');
|
||||
setIsDocumentLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[DocumentReady] 解析消息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [iframeRef]);
|
||||
|
||||
return { isDocumentLoaded };
|
||||
}
|
||||
|
||||
// ==================== 3. UNO 命令封装 ====================
|
||||
|
||||
/**
|
||||
* UNO 命令封装(React Hook)
|
||||
* @param iframeRef - iframe 引用
|
||||
* @returns UNO 命令方法集合
|
||||
*/
|
||||
export function useCollaboraUnoCommands(iframeRef: RefObject<HTMLIFrameElement>) {
|
||||
/**
|
||||
* 搜索文本(用于定位)
|
||||
*/
|
||||
const searchText = useCallback(
|
||||
async (text: string) => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UNO] 搜索文本: "${text}"`);
|
||||
unoSearchText(iframeRef.current.contentWindow, text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
},
|
||||
[iframeRef]
|
||||
);
|
||||
|
||||
/**
|
||||
* 定位文本(搜索 + 立即取消选中)
|
||||
* 用于"只看不改"的场景,避免蓝色选中背景
|
||||
*/
|
||||
const locateText = useCallback(
|
||||
async (text: string) => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UNO] 定位文本(无选中): "${text}"`);
|
||||
|
||||
// 1. 执行搜索(滚动到目标并选中)
|
||||
await searchText(text);
|
||||
|
||||
// 2. 等待渲染完成
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// 3. 取消选中(去除蓝色背景,保留视图位置)
|
||||
unoEscape(iframeRef.current.contentWindow);
|
||||
|
||||
console.log(`[UNO] 定位完成,已取消选中`);
|
||||
},
|
||||
[searchText, iframeRef]
|
||||
);
|
||||
|
||||
/**
|
||||
* 替换文本(ReplaceAll)
|
||||
*/
|
||||
const replaceText = useCallback(
|
||||
async (searchText: string, replaceText: string) => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UNO] 替换文本: "${searchText}" -> "${replaceText}"`);
|
||||
unoReplaceText(iframeRef.current.contentWindow, searchText, replaceText);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
},
|
||||
[iframeRef]
|
||||
);
|
||||
|
||||
/**
|
||||
* 高亮文本
|
||||
*/
|
||||
const highlightText = useCallback(
|
||||
async (text: string, color?: number) => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UNO] 高亮文本: "${text}"`);
|
||||
unoHighlightText(iframeRef.current.contentWindow, text, color);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
},
|
||||
[iframeRef]
|
||||
);
|
||||
|
||||
/**
|
||||
* 移除高亮
|
||||
*/
|
||||
const removeHighlight = useCallback(
|
||||
async (text: string) => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UNO] 移除高亮: "${text}"`);
|
||||
unoRemoveHighlight(iframeRef.current.contentWindow, text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
},
|
||||
[iframeRef]
|
||||
);
|
||||
|
||||
/**
|
||||
* 取消选中(Escape)
|
||||
*/
|
||||
const escapeSelection = useCallback(async () => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UNO] 取消选中');
|
||||
unoEscape(iframeRef.current.contentWindow);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}, [iframeRef]);
|
||||
|
||||
/**
|
||||
* 滚动到文档顶部
|
||||
*/
|
||||
const scrollToTop = useCallback(async () => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UNO] 滚动到顶部');
|
||||
unoScrollToTop(iframeRef.current.contentWindow);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}, [iframeRef]);
|
||||
|
||||
/**
|
||||
* 保存文档
|
||||
*/
|
||||
const saveDocument = useCallback(async () => {
|
||||
if (!iframeRef.current?.contentWindow) {
|
||||
console.warn('[UNO] iframe 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[UNO] 保存文档');
|
||||
unoSave(iframeRef.current.contentWindow);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}, [iframeRef]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
searchText,
|
||||
locateText,
|
||||
replaceText,
|
||||
highlightText,
|
||||
removeHighlight,
|
||||
escapeSelection,
|
||||
scrollToTop,
|
||||
saveDocument,
|
||||
}),
|
||||
[
|
||||
searchText,
|
||||
locateText,
|
||||
replaceText,
|
||||
highlightText,
|
||||
removeHighlight,
|
||||
escapeSelection,
|
||||
scrollToTop,
|
||||
saveDocument,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Collabora Online 相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collabora 配置信息
|
||||
*/
|
||||
export interface CollaboraConfig {
|
||||
/** Collabora iframe URL */
|
||||
iframeUrl: string;
|
||||
/** WOPI access token */
|
||||
accessToken: string;
|
||||
/** 文件名 */
|
||||
fileName: string;
|
||||
/** 文件 ID */
|
||||
fileId: string;
|
||||
/** Collabora 服务器 URL */
|
||||
collaboraUrl: string;
|
||||
/** WOPI Src URL */
|
||||
wopiSrc: string;
|
||||
/** 模式 */
|
||||
mode: 'view' | 'edit';
|
||||
}
|
||||
|
||||
/**
|
||||
* CollaboraViewer 组件 Props
|
||||
*/
|
||||
export interface CollaboraViewerProps {
|
||||
/** 文件路径(例如:contracts/test.docx) */
|
||||
fileId: string;
|
||||
/** 查看模式:view=只读,edit=可编辑 */
|
||||
mode?: 'view' | 'edit';
|
||||
/** 用户 ID */
|
||||
userId?: string;
|
||||
/** 用户名称 */
|
||||
userName?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user