diff --git a/app/components/collabora/CollaboraViewer.tsx b/app/components/collabora/CollaboraViewer.tsx index a26e3e1..c181edc 100644 --- a/app/components/collabora/CollaboraViewer.tsx +++ b/app/components/collabora/CollaboraViewer.tsx @@ -10,30 +10,63 @@ * @encoding UTF-8 */ -import { useRef } from 'react'; -import type { CollaboraViewerProps } from './types'; +import { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 'react'; +import type { CollaboraViewerProps, CollaboraViewerHandle } from './types'; import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks'; +import { sendUnoCommand } from './Uno'; /** * Collabora 文档查看器组件 * @param props - 组件属性 + * @param ref - 父组件传入的 ref,用于暴露命令接口 */ -export function CollaboraViewer({ - fileId, - mode = 'view', - userId = 'guest', - userName = '访客', -}: CollaboraViewerProps) { - const iframeRef = useRef(null); +export const CollaboraViewer = forwardRef( + function CollaboraViewer( + { + fileId, + mode = 'view', + userId = 'guest', + userName = '访客', + }, + ref + ) { + const iframeRef = useRef(null); - // 1. 加载 Collabora 配置 - const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName); + // 调试面板状态 + const [unoCmd, setUnoCmd] = useState('.uno:GoToStartOfDoc'); + const [unoArgs, setUnoArgs] = useState('{}'); + const [unoResult, setUnoResult] = useState(null); - // 2. 监听文档加载状态 - const { isDocumentLoaded } = useDocumentReady(iframeRef); + // 1. 加载 Collabora 配置 + const { config, loading, error } = useCollaboraConfig(fileId, mode, userId, userName); - // 3. UNO 命令封装 - const unoCommands = useCollaboraUnoCommands(iframeRef); + // 2. 监听文档加载状态 + const { isDocumentLoaded } = useDocumentReady(iframeRef); + + // 3. UNO 命令封装 + const unoCommands = useCollaboraUnoCommands(iframeRef); + + // 4. 暴露接口给父组件 + useImperativeHandle(ref, () => ({ + unoCommands, + isReady: isDocumentLoaded, + mode, + }), [unoCommands, isDocumentLoaded, mode]); + + // 5. 将 sendUnoCommand 挂载到 window 对象,供调试面板和控制台使用 + useEffect(() => { + if (iframeRef.current?.contentWindow) { + (window as any).sendUno = (cmd: string, args: any = {}) => { + if (iframeRef.current?.contentWindow) { + sendUnoCommand(iframeRef.current.contentWindow, cmd, args); + } + }; + } + + return () => { + delete (window as any).sendUno; + }; + }, [isDocumentLoaded]); // 加载中状态 if (loading) { @@ -60,8 +93,75 @@ export function CollaboraViewer({ ); } + // 发送 UNO 命令的处理函数 + const sendUno = () => { + if (!iframeRef.current?.contentWindow) { + setUnoResult('iframe 不可用'); + return; + } + + if (!(window as any).sendUno) { + setUnoResult('window.sendUno 未初始化'); + return; + } + + let args: any = {}; + const raw = (unoArgs || '').trim(); + if (raw !== '') { + try { + args = JSON.parse(raw); + } catch (err) { + try { + // fallback: replace single quotes with double quotes and parse + args = JSON.parse(raw.replace(/'(.*?)'/g, '"$1"')); + } catch (err2) { + console.error('解析 UNO Args 失败:', err2); + setUnoResult('Args 解析失败,请使用有效 JSON'); + return; + } + } + } + + try { + // 先让 iframe 获得焦点 + iframeRef.current.focus(); + console.log('[调试面板] 已聚焦 iframe'); + + (window as any).sendUno?.(unoCmd, args); + setUnoResult(`已发送: ${unoCmd}`); + } catch (e) { + console.error('发送 UNO 失败:', e); + setUnoResult('发送失败,请查看控制台'); + } + }; + return (
+ {/* UNO 命令测试面板 */} +
+ setUnoCmd(e.target.value)} + placeholder="UNO 命令" + aria-label="UNO 命令" + /> + setUnoArgs(e.target.value)} + placeholder="UNO Args (JSON)" + aria-label="UNO Args (JSON)" + /> + + {unoResult && {unoResult}} +
+ {/* 文档加载提示 */} {!isDocumentLoaded && (
@@ -85,10 +185,12 @@ export function CollaboraViewer({ allow="clipboard-read; clipboard-write" title={`Collabora Online - ${config.fileName}`} sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals" + tabIndex={0} />
); -} +}); -// 导出 UNO 命令 hook 供父组件使用(如果需要) +// 导出类型和 hook +export type { CollaboraViewerHandle }; export { useCollaboraUnoCommands }; diff --git a/app/components/collabora/Uno.ts b/app/components/collabora/Uno.ts index c20af04..f67a551 100644 --- a/app/components/collabora/Uno.ts +++ b/app/components/collabora/Uno.ts @@ -19,7 +19,6 @@ export function sendUnoCommand( ): void { const message = { MessageId: 'Send_UNO_Command', - SendTime: Date.now(), Values: { Command: command, Args: args, @@ -130,10 +129,25 @@ export function unoEscape(iframeWindow: Window): void { } /** - * 滚动到文档开头 + * 滚动到文档开头 (带焦点请求) * @param iframeWindow - iframe 的 contentWindow */ -export function unoScrollToTop(iframeWindow: Window): void { +export async function unoScrollToTop(iframeWindow: Window): Promise { + // 1. 先请求 iframe 获取焦点 + const focusMessage = { + MessageId: 'custompostMessage', + Values: { + Command: 'REQUEST_FOCUS', + Args: {}, + }, + }; + console.log('[custompostMessage] 请求焦点 (滚动到顶部)'); + iframeWindow.postMessage(JSON.stringify(focusMessage), '*'); + + // 2. 等待焦点激活 + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 3. 发送滚动命令 sendUnoCommand(iframeWindow, '.uno:GoToStartOfDoc', {}); } @@ -166,3 +180,64 @@ export function unoGetState(iframeWindow: Window): void { console.log('[UNO] 发送 Get_State (.uno:ModifiedStatus) - 等待命令队列执行完成'); iframeWindow.postMessage(JSON.stringify(message), '*'); } + +/** + * 放大文档(固定步长) + * @param iframeWindow - iframe 的 contentWindow + */ +export function unoZoomPlus(iframeWindow: Window): void { + sendUnoCommand(iframeWindow, '.uno:ZoomPlus', {}); +} + +/** + * 缩小文档(固定步长) + * @param iframeWindow - iframe 的 contentWindow + */ +export function unoZoomMinus(iframeWindow: Window): void { + sendUnoCommand(iframeWindow, '.uno:ZoomMinus', {}); +} + +/** + * 设置文档缩放比例 + * @param iframeWindow - iframe 的 contentWindow + * @param percentage - 缩放百分比(例如:100 表示 100%) + */ +export function unoSetZoom(iframeWindow: Window, percentage: number): void { + sendUnoCommand(iframeWindow, '.uno:Zoom', { + Zoom: { + type: 'short', + value: percentage, + }, + }); +} + +/** + * 跳转到指定页面 + * @param iframeWindow - iframe 的 contentWindow + * @param pageNumber - 页码(从1开始) + */ +export function unoGotoPage(iframeWindow: Window, pageNumber: number): void { + sendUnoCommand(iframeWindow, '.uno:GotoPage', { + Page: { + type: 'long', + value: pageNumber, + }, + }); +} + +/** + * 跳转到第一页 + * @param iframeWindow - iframe 的 contentWindow + */ +export function unoFirstPage(iframeWindow: Window): void { + sendUnoCommand(iframeWindow, '.uno:FirstPage', {}); +} + +/** + * 跳转到最后一页 + * @param iframeWindow - iframe 的 contentWindow + */ +export function unoLastPage(iframeWindow: Window): void { + sendUnoCommand(iframeWindow, '.uno:LastPage', {}); +} + diff --git a/app/components/collabora/hooks.ts b/app/components/collabora/hooks.ts index 37e4815..d7ef109 100644 --- a/app/components/collabora/hooks.ts +++ b/app/components/collabora/hooks.ts @@ -21,6 +21,12 @@ import { unoEscape, unoScrollToTop, unoSave, + unoZoomPlus, + unoZoomMinus, + unoSetZoom, + unoGotoPage, + unoFirstPage, + unoLastPage, } from './Uno'; import { COLLABORA_URL } from '~/config/api-config'; @@ -131,9 +137,8 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) console.warn('[UNO] iframe 不可用'); return; } - console.log(`[UNO] 搜索文本: "${text}"`); - unoSearchText(iframeRef.current.contentWindow, text); + await unoSearchText(iframeRef.current.contentWindow, text); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef] @@ -175,9 +180,8 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) console.warn('[UNO] iframe 不可用'); return; } - console.log(`[UNO] 替换文本: "${searchText}" -> "${replaceText}"`); - unoReplaceText(iframeRef.current.contentWindow, searchText, replaceText); + await unoReplaceText(iframeRef.current.contentWindow, searchText, replaceText); await new Promise((resolve) => setTimeout(resolve, 200)); }, [iframeRef] @@ -192,9 +196,8 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) console.warn('[UNO] iframe 不可用'); return; } - console.log(`[UNO] 高亮文本: "${text}"`); - unoHighlightText(iframeRef.current.contentWindow, text, color); + await unoHighlightText(iframeRef.current.contentWindow, text, color); await new Promise((resolve) => setTimeout(resolve, 200)); }, [iframeRef] @@ -209,9 +212,8 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) console.warn('[UNO] iframe 不可用'); return; } - console.log(`[UNO] 移除高亮: "${text}"`); - unoRemoveHighlight(iframeRef.current.contentWindow, text); + await unoRemoveHighlight(iframeRef.current.contentWindow, text); await new Promise((resolve) => setTimeout(resolve, 200)); }, [iframeRef] @@ -227,7 +229,7 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) } console.log('[UNO] 取消选中'); - unoEscape(iframeRef.current.contentWindow); + await unoEscape(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 50)); }, [iframeRef]); @@ -241,7 +243,7 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) } console.log('[UNO] 滚动到顶部'); - unoScrollToTop(iframeRef.current.contentWindow); + await unoScrollToTop(iframeRef.current.contentWindow); await new Promise((resolve) => setTimeout(resolve, 100)); }, [iframeRef]); @@ -255,10 +257,102 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) } console.log('[UNO] 保存文档'); - unoSave(iframeRef.current.contentWindow); + 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, @@ -269,6 +363,12 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) escapeSelection, scrollToTop, saveDocument, + zoomIn, + zoomOut, + setZoom, + gotoPage, + gotoFirstPage, + gotoLastPage, }), [ searchText, @@ -279,6 +379,12 @@ export function useCollaboraUnoCommands(iframeRef: RefObject) escapeSelection, scrollToTop, saveDocument, + zoomIn, + zoomOut, + setZoom, + gotoPage, + gotoFirstPage, + gotoLastPage, ] ); } diff --git a/app/components/collabora/types.ts b/app/components/collabora/types.ts index 94e4a36..72e920c 100644 --- a/app/components/collabora/types.ts +++ b/app/components/collabora/types.ts @@ -35,3 +35,30 @@ export interface CollaboraViewerProps { /** 用户名称 */ userName?: string; } + +/** + * CollaboraViewer 暴露给父组件的方法接口 + */ +export interface CollaboraViewerHandle { + /** UNO 命令方法集合 */ + unoCommands: { + searchText: (text: string) => Promise; + locateText: (text: string) => Promise; + replaceText: (searchText: string, replaceText: string) => Promise; + highlightText: (text: string, color?: number) => Promise; + removeHighlight: (text: string) => Promise; + escapeSelection: () => Promise; + scrollToTop: () => Promise; + saveDocument: () => Promise; + zoomIn: () => Promise; + zoomOut: () => Promise; + setZoom: (percentage: number) => Promise; + gotoPage: (pageNumber: number) => Promise; + gotoFirstPage: () => Promise; + gotoLastPage: () => Promise; + }; + /** 文档是否已加载完成 */ + isReady: boolean; + /** 当前模式 */ + mode: 'view' | 'edit'; +} diff --git a/app/components/reviews/FilePreview.tsx b/app/components/reviews/FilePreview.tsx index be6ca10..352c3ea 100644 --- a/app/components/reviews/FilePreview.tsx +++ b/app/components/reviews/FilePreview.tsx @@ -5,7 +5,7 @@ import { useState, useEffect, useRef, ChangeEvent } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { DOCUMENT_URL } from '~/api/axios-client'; -import { CollaboraViewer } from '~/components/collabora/CollaboraViewer'; +import { CollaboraViewer, type CollaboraViewerHandle } from '~/components/collabora/CollaboraViewer'; // 设置worker路径为public目录下的worker文件 // 使用已经下载的兼容版本 (pdfjs-dist v2.12.313) @@ -85,10 +85,17 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage const [zoomLevel, setZoomLevel] = useState(100); // const [highlightsVisible, setHighlightsVisible] = useState(true); const contentRef = useRef(null); + const collaboraViewerRef = useRef(null); const [numPages, setNumPages] = useState(null); const [loadError, setLoadError] = useState(null); const [pageInputValue, setPageInputValue] = useState(''); - + + // 获取文件类型 + const real_path = fileContent.path || fileContent.template_contract_path || ''; + const fileExtension = real_path.split('.').pop()?.toLowerCase(); + const isDocx = fileExtension === 'docx'; + const isPdf = fileExtension === 'pdf'; + // 拖拽状态管理 const [dragMode, setDragMode] = useState(false); // 是否处于拖拽模式 const [isDragging, setIsDragging] = useState(false); @@ -97,15 +104,35 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // 放大文档 const handleZoomIn = () => { - if (zoomLevel < 200) { - setZoomLevel(prevZoom => prevZoom + 10); + if (isDocx) { + // DOCX 文件:调用 Collabora UNO 命令 + if (!collaboraViewerRef.current?.isReady) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + collaboraViewerRef.current?.unoCommands.zoomIn(); + } else if (isPdf) { + // PDF 文件:修改 zoomLevel 状态 + if (zoomLevel < 200) { + setZoomLevel(prevZoom => prevZoom + 10); + } } }; - + // 缩小文档 const handleZoomOut = () => { - if (zoomLevel > 50) { - setZoomLevel(prevZoom => prevZoom - 10); + if (isDocx) { + // DOCX 文件:调用 Collabora UNO 命令 + if (!collaboraViewerRef.current?.isReady) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + collaboraViewerRef.current?.unoCommands.zoomOut(); + } else if (isPdf) { + // PDF 文件:修改 zoomLevel 状态 + if (zoomLevel > 50) { + setZoomLevel(prevZoom => prevZoom - 10); + } } }; @@ -243,21 +270,37 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // 处理页码跳转 const handlePageJump = () => { - if (!pageInputValue || !numPages) return; - + if (!pageInputValue) return; + const targetPageNum = parseInt(pageInputValue, 10); - - // 验证页码是否在有效范围内 - if (targetPageNum > 0 && targetPageNum <= numPages) { - // 找到目标页面元素并滚动到该位置 - const pageElement = document.getElementById(`page-${targetPageNum}`); - if (pageElement) { - pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + if (isDocx) { + // DOCX 文件:调用 Collabora UNO 命令 + if (!collaboraViewerRef.current?.isReady) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + if (targetPageNum > 0) { + collaboraViewerRef.current?.unoCommands.gotoPage(targetPageNum); + } else { + toastService.warning('请输入有效页码'); + setPageInputValue(''); + } + } else if (isPdf) { + // PDF 文件:验证页码并滚动到目标页面 + if (!numPages) return; + + if (targetPageNum > 0 && targetPageNum <= numPages) { + // 找到目标页面元素并滚动到该位置 + const pageElement = document.getElementById(`page-${targetPageNum}`); + if (pageElement) { + pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else { + // 页码超出范围,显示错误信息或重置输入 + toastService.warning(`请输入有效页码 (1-${numPages})`); + setPageInputValue(''); } - } else { - // 页码超出范围,显示错误信息或重置输入 - toastService.warning(`请输入有效页码 (1-${numPages})`); - setPageInputValue(''); } }; @@ -285,8 +328,18 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // 滚动到顶部 const handleScrollToTop = () => { - if (contentRef.current) { - contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + if (isDocx) { + // DOCX 文件:调用 Collabora UNO 命令 + if (!collaboraViewerRef.current?.isReady) { + toastService.warning('文档尚未加载完成,请稍候...'); + return; + } + collaboraViewerRef.current?.unoCommands.scrollToTop(); + } else { + // PDF 文件:滚动容器到顶部 + if (contentRef.current) { + contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }); + } } }; @@ -385,8 +438,6 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage // 渲染文档内容 const renderDocumentContent = () => { - const real_path = fileContent.path || fileContent.template_contract_path || ''; - // 如果路径无效,显示错误信息 if (!real_path) { if(!fileContent.template_contract_path){ @@ -404,9 +455,7 @@ export function FilePreview({ fileContent, activeReviewPointResultId, targetPage } // console.log('real_path',real_path); - // 获取文件扩展名 - const fileExtension = real_path.split('.').pop()?.toLowerCase(); - + // PDF内容渲染 const renderPdfContent = () => (