/** * Collabora Online 相关的自定义 hooks * * 功能: * - useCollaboraConfig: 加载 Collabora 配置(使用 Remix useFetcher) * - useDocumentReady: 监听文档加载完成 * - useCollaboraUnoCommands: UNO 命令封装 * * @encoding UTF-8 */ import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import { COLLABORA_URL } from '~/config/api-config'; import { toastService } from '../ui/Toast'; import { unoScrollToTop, unoReplaceAll } 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 [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!fileId) { setConfig(null); setError('文件路径不能为空'); setLoading(false); return; } const controller = new AbortController(); const params = new URLSearchParams({ fileId, mode, userId, userName, }); async function loadConfig() { setLoading(true); setError(null); setConfig(null); try { const response = await fetch(`/api/collabora/config?${params}`, { method: 'GET', signal: controller.signal, headers: { Accept: 'application/json', }, }); const result = (await response.json()) as CollaboraConfig | { error?: string }; if (!response.ok || ('error' in result && result.error)) { const errorMessage = ('error' in result && result.error) || `加载配置失败 (${response.status})`; setError(errorMessage); toastService.error(`加载文档配置失败: ${errorMessage}`); return; } setConfig(result as CollaboraConfig); } catch (err) { if (controller.signal.aborted) return; const errorMessage = err instanceof Error ? err.message : '加载配置失败'; setError(errorMessage); toastService.error(`加载文档配置失败: ${errorMessage}`); } finally { if (!controller.signal.aborted) { setLoading(false); } } } void loadConfig(); return () => controller.abort(); }, [fileId, mode, userId, userName]); return { config, loading, error, }; } // ==================== 2. 文档加载状态监听 ==================== /** * 监听文档加载完成 * @param iframeRef - iframe 引用 * @returns 文档加载状态 */ export function useDocumentReady( iframeRef: RefObject, iframeUrl?: string ) { const [isDocumentLoaded, setIsDocumentLoaded] = useState(false); useEffect(() => { setIsDocumentLoaded(false); const iframe = iframeRef.current; const expectedOrigin = iframeUrl ? new URL(iframeUrl).origin : new URL(COLLABORA_URL).origin; let fallbackTimer: ReturnType | null = null; const markLoaded = (source: string) => { setIsDocumentLoaded((prev) => { if (!prev) { console.log(`[DocumentReady] 文档已就绪(${source})`); } return true; }); }; const handleIframeLoad = () => { // 某些环境下 Collabora 不一定会抛出 Document_Loaded 消息, // 先在 iframe load 后给一个兜底超时,避免页面一直被遮罩层盖住。 fallbackTimer = setTimeout(() => { markLoaded('iframe-load-fallback'); }, 1500); }; const handleMessage = (event: MessageEvent) => { if (event.origin !== expectedOrigin) { return; } if (iframeRef.current?.contentWindow && event.source !== iframeRef.current.contentWindow) { return; } try { const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; if ( msg?.MessageId === 'App_LoadingStatus' && ['Document_Loaded', 'Document_Loaded_Editing', 'UI_Loaded'].includes(msg.Values?.Status) ) { if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; } markLoaded(`postmessage:${msg.Values?.Status}`); } } catch { // Collabora 也会发送非 JSON 消息,这里忽略即可。 } }; iframe?.addEventListener('load', handleIframeLoad); window.addEventListener('message', handleMessage); return () => { if (fallbackTimer) { clearTimeout(fallbackTimer); } iframe?.removeEventListener('load', handleIframeLoad); window.removeEventListener('message', handleMessage); }; }, [iframeRef, iframeUrl]); return { isDocumentLoaded }; } // ==================== 3. UNO 命令封装 ==================== /** * UNO 命令封装(React Hook) * @param iframeRef - iframe 引用 * @returns UNO 命令方法集合 */ export function useCollaboraUnoCommands(iframeRef: RefObject) { /** * 滚动到文档顶部 */ 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]); /** * 替换所有匹配项 * @param searchText 要搜索的文本 * @param replaceText 替换后的文本 */ const replaceAll = useCallback(async (searchText: string, replaceText: string) => { if (!iframeRef.current?.contentWindow) { console.warn('[UNO] iframe 不可用'); return; } console.log('[UNO] 替换全部:', searchText, '->', replaceText); await unoReplaceAll(iframeRef.current.contentWindow, searchText, replaceText); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); return useMemo( () => ({ scrollToTop, replaceAll }), [ scrollToTop, replaceAll ] ); }