Files
leaudit-platform-frontend/app/components/collabora/hooks.ts
T

235 lines
6.4 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 相关的自定义 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<CollaboraConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLIFrameElement>,
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<typeof setTimeout> | 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<HTMLIFrameElement>) {
/**
* 滚动到文档顶部
*/
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
]
);
}