/** * Collabora Online 相关的自定义 hooks * * 功能: * - useCollaboraConfig: 加载 Collabora 配置(使用 Remix useFetcher) * - useDocumentReady: 监听文档加载完成 * - useCollaboraUnoCommands: UNO 命令封装 * * @encoding UTF-8 */ import { useFetcher } from '@remix-run/react'; import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import { COLLABORA_URL } from '~/config/api-config'; import { toastService } from '../ui/Toast'; import { unoEscape, // unoHighlightText, unoReplaceText, unoSave, unoScrollToTop, unoSearchText, unoSetZoom, unoZoomMinus, unoZoomPlus, } from './lib'; import type { CollaboraConfig } from './types'; // ==================== 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(); const [error, setError] = useState(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 errorData = fetcher.data as { error: string }; const errorMessage = errorData.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) { 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) { /** * 搜索文本(用于定位) */ const searchText = useCallback( async (text: string) => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log(`[UNO] 搜索文本: "${text}"`); await 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}"`); await 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}"`); // await 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}"`); // await 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] 取消选中'); await 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] 滚动到顶部'); await 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] 保存文档'); await unoSave(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 1000)); }, [iframeRef]); /** * 放大文档 */ const zoomIn = useCallback(async () => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log('[UNO] 放大文档'); await unoZoomPlus(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); /** * 缩小文档 */ const zoomOut = useCallback(async () => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log('[UNO] 缩小文档'); await unoZoomMinus(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); /** * 设置缩放比例 * @param percentage - 缩放百分比 (例如:100 表示 100%) */ const setZoom = useCallback( async (percentage: number) => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log(`[UNO] 设置缩放比例: ${percentage}%`); await unoSetZoom(iframeRef.current.contentWindow, percentage); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef] ); return useMemo( () => ({ searchText, // locateText, replaceText, // highlightText, // removeHighlight, escapeSelection, scrollToTop, saveDocument, zoomIn, zoomOut, setZoom, }), [ searchText, // locateText, replaceText, // highlightText, // removeHighlight, escapeSelection, scrollToTop, saveDocument, zoomIn, zoomOut, setZoom, ] ); }