235 lines
6.4 KiB
TypeScript
235 lines
6.4 KiB
TypeScript
/**
|
||
* 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
|
||
]
|
||
);
|
||
}
|