/** * 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, unoZoomPlus, unoZoomMinus, unoSetZoom, unoGotoPage, unoFirstPage, unoLastPage, } from './lib'; 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(); 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 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) { 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] ); /** * 跳转到指定页面 * @param pageNumber - 页码(从1开始) */ const gotoPage = useCallback( async (pageNumber: number) => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log(`[UNO] 跳转到第 ${pageNumber} 页`); await unoGotoPage(iframeRef.current.contentWindow, pageNumber); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef] ); /** * 跳转到第一页 */ const gotoFirstPage = useCallback(async () => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log('[UNO] 跳转到第一页'); await unoFirstPage(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); /** * 跳转到最后一页 */ const gotoLastPage = useCallback(async () => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log('[UNO] 跳转到最后一页'); await unoLastPage(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); return useMemo( () => ({ searchText, locateText, replaceText, highlightText, removeHighlight, escapeSelection, scrollToTop, saveDocument, zoomIn, zoomOut, setZoom, gotoPage, gotoFirstPage, gotoLastPage, }), [ searchText, locateText, replaceText, highlightText, removeHighlight, escapeSelection, scrollToTop, saveDocument, zoomIn, zoomOut, setZoom, gotoPage, gotoFirstPage, gotoLastPage, ] ); }