feat:完善合同起草页面点击高亮以及页面跳转问题

This commit is contained in:
PingChuan
2025-12-08 17:08:56 +08:00
parent 88e3d57351
commit e9c89d6d00
5 changed files with 155 additions and 63 deletions
+29 -33
View File
@@ -14,7 +14,6 @@ import { useRef, forwardRef, useImperativeHandle, useState, useEffect } from 're
import type { CollaboraViewerProps, CollaboraViewerHandle } from './types';
import { useCollaboraConfig, useDocumentReady, useCollaboraUnoCommands } from './hooks';
import { sendUnoCommand } from './Uno';
import { highlightText as performTextHighlight } from './lib/Highlightselecttext';
import { clearHighlights } from './lib/ClearHighlight';
import {
unoScrollToTop,
@@ -25,6 +24,8 @@ import {
unoReplaceAll,
unoCancelSearch,
replaceTextInPage,
unoHighlightText,
unoClearHighlight,
type PageInfo,
type GotoPageResponse
} from './lib';
@@ -184,6 +185,7 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
}), [unoCommands, isDocumentLoaded, mode]);
// 5. 监听 targetPage 和 highlightText 变化,自动跳转并高亮
// 使用 UNO 命令实现高亮,不再使用 Python 脚本
useEffect(() => {
// 如果文档未加载完成,不执行跳转和高亮
if (!isDocumentLoaded || !iframeRef.current?.contentWindow) {
@@ -193,27 +195,32 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
// 如果有高亮文本,执行高亮操作
if (highlightText && highlightText.trim() !== '') {
const performHighlight = async () => {
try {
const iframeWindow = iframeRef.current!.contentWindow!;
const textToHighlight = highlightText.trim();
const iframeWindow = iframeRef.current!.contentWindow!;
const textToHighlight = highlightText.trim();
// 🔥 在高亮新内容之前,先清除之前的所有高亮
// console.log('[CollaboraViewer] 清除旧高亮...');
try {
// 步骤1:清除之前的所有高亮(调用 Python 脚本)
console.log('[CollaboraViewer] 步骤1:清除旧高亮...');
await clearHighlights(iframeWindow, {
color: 16776960, // 黄色
timeout: 5000,
});
// 短暂延迟后执行新高亮,确保清除操作完成
// 短暂延迟,确保清除操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 执行新高亮
await performTextHighlight(
iframeWindow,
textToHighlight,
{ page: targetPage }
);
// console.log(`[CollaboraViewer] 已高亮文本: "${textToHighlight}"${targetPage ? ` (第${targetPage}页)` : ''}`);
// 步骤2:使用 UNO 命令高亮新文本(搜索 + 设置背景色)
console.log(`[CollaboraViewer] 步骤2:高亮文本 "${textToHighlight}"`);
unoHighlightText(iframeWindow, textToHighlight, 16776960); // 黄色
// 短暂延迟,确保高亮操作完成
await new Promise(resolve => setTimeout(resolve, 100));
// 步骤3:取消选中状态(避免高亮后文本仍被选中)
console.log('[CollaboraViewer] 步骤3:取消选中状态...');
sendUnoCommand(iframeWindow, '.uno:Escape', {});
console.log(`[CollaboraViewer] ✓ 高亮完成: "${textToHighlight}"`);
} catch (error) {
console.error('[CollaboraViewer] 高亮失败:', error);
}
@@ -319,9 +326,10 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
}, [searchText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 9. 当搜索参数更新完成后,自动执行替换(静默模式)
// 不跳转页面,直接在当前位置搜索并替换,替换后保留在替换位置
useEffect(() => {
if (shouldAutoReplaceRef.current && searchText && replaceText && searchReplacePageNumber && isDocumentLoaded) {
console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText, searchReplacePageNumber });
if (shouldAutoReplaceRef.current && searchText && replaceText && isDocumentLoaded) {
console.log('[CollaboraViewer] 静默替换模式:自动执行替换:', { searchText, replaceText });
// 重置标志
shouldAutoReplaceRef.current = false;
@@ -334,30 +342,18 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
}
try {
const pageNumber = parseInt(searchReplacePageNumber, 10);
// 步骤1:跳转到指定页
console.log(`[CollaboraViewer] 步骤1:跳转到第 ${pageNumber}`);
await customGotoPage(iframeRef.current.contentWindow, pageNumber);
// 等待页面渲染
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤2:搜索文本(确保文本被选中)
console.log(`[CollaboraViewer] 步骤2:搜索文本 "${searchText}"`);
// 步骤1:搜索文本(确保文本被选中,会自动跳转到匹配位置)
console.log(`[CollaboraViewer] 步骤1:搜索文本 "${searchText}"`);
unoSearchNext(iframeRef.current.contentWindow, searchText);
// 等待搜索完成
await new Promise(resolve => setTimeout(resolve, 300));
// 步骤3:执行替换
console.log(`[CollaboraViewer] 步骤3:替换为 "${replaceText}"`);
// 步骤2:执行替换(替换后光标保留在当前位置)
console.log(`[CollaboraViewer] 步骤2:替换为 "${replaceText}"`);
unoReplaceCurrent(iframeRef.current.contentWindow, searchText, replaceText);
console.log('[CollaboraViewer] ✓ 静默替换完成');
// 显示成功提示(可选)
// toastService.success(`已替换: "${searchText}" → "${replaceText}"`);
} catch (error) {
console.error('[CollaboraViewer] 静默替换失败:', error);
}
@@ -365,7 +361,7 @@ export const CollaboraViewer = forwardRef<CollaboraViewerHandle, CollaboraViewer
return () => clearTimeout(timer);
}
}, [searchText, replaceText, searchReplacePageNumber, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
}, [searchText, replaceText, isDocumentLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// 加载中状态
if (loading) {
@@ -304,3 +304,66 @@ export async function replaceTextInPage(
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 高亮文本(使用 UNO 命令)
* 流程:先搜索选中所有匹配项,再设置背景色
*
* @param iframeWindow - iframe 的 contentWindow
* @param text - 要高亮的文本
* @param color - 高亮颜色,默认 16776960 = 黄色
*/
export function unoHighlightText(
iframeWindow: Window,
text: string,
color: number = 16776960
): void {
// 1. 查找所有匹配项(FindAll
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
'SearchItem.SearchString': { type: 'string', value: text },
'SearchItem.Command': { type: 'long', value: 1 }, // 1 = FindAll / Search Next
'SearchItem.SearchFlags': { type: 'long', value: 0 },
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
'SearchItem.Backward': { type: 'boolean', value: false },
'SearchItem.SearchCaseSensitive': { type: 'boolean', value: false },
'Quiet': { type: 'boolean', value: true },
});
// 2. 设置背景色高亮
sendUnoCommand(iframeWindow, '.uno:BackColor', {
BackColor: { type: 'long', value: color },
});
console.log('[SearchReplace] 高亮文本:', text, '颜色:', color);
}
/**
* 清除高亮(使用 UNO 命令)
* 通过设置背景色为透明来清除高亮
*
* @param iframeWindow - iframe 的 contentWindow
* @param text - 要清除高亮的文本(可选,不传则清除当前选中)
*/
export function unoClearHighlight(
iframeWindow: Window,
text?: string
): void {
if (text) {
// 先搜索选中文本
sendUnoCommand(iframeWindow, '.uno:ExecuteSearch', {
'SearchItem.SearchString': { type: 'string', value: text },
'SearchItem.Command': { type: 'long', value: 1 },
'SearchItem.SearchFlags': { type: 'long', value: 0 },
'SearchItem.AlgorithmType': { type: 'short', value: 0 },
'SearchItem.Backward': { type: 'boolean', value: false },
'Quiet': { type: 'boolean', value: true },
});
}
// 设置背景色为透明(-1 表示无背景色)
sendUnoCommand(iframeWindow, '.uno:BackColor', {
BackColor: { type: 'long', value: -1 },
});
console.log('[SearchReplace] 清除高亮:', text || '当前选中');
}
+2
View File
@@ -41,6 +41,8 @@ export {
unoSearchAndReplace,
unoCancelSearch,
replaceTextInPage,
unoHighlightText,
unoClearHighlight,
SearchCommand,
type SearchOptions,
type ReplaceOptions,
+49 -16
View File
@@ -3,9 +3,9 @@
* 用于合同起草时填写占位符值
*/
import { useState, useEffect } from 'react';
import type { PlaceholderSchema } from '~/types/contract-draft';
import { useEffect, useState } from 'react';
import { messageService } from '~/components/ui/MessageModal';
import type { PlaceholderSchema } from '~/types/contract-draft';
interface PlaceholderFormProps {
schema: PlaceholderSchema | null;
@@ -28,6 +28,8 @@ export function PlaceholderForm({
}: PlaceholderFormProps) {
const [localValues, setLocalValues] = useState<Record<string, string>>(values);
const [replacingFields, setReplacingFields] = useState<Set<string>>(new Set());
// 【新增】记录当前高亮的字段(只存一个值),避免重复高亮导致焦点被抢
const [currentHighlightedField, setCurrentHighlightedField] = useState<string | null>(null);
// 同步外部 values 到本地状态
useEffect(() => {
@@ -41,11 +43,44 @@ export function PlaceholderForm({
onChange(newValues);
};
// 处理字段聚焦(高亮文档中的占位符)
const handleFieldFocus = (key: string) => {
// 处理字段点击(高亮文档中的占位符)
const handleFieldClick = async (e: React.MouseEvent<HTMLInputElement | HTMLTextAreaElement>, key: string) => {
// 1. 检查是否已经高亮当前字段
if (currentHighlightedField === key) {
console.log(`[PlaceholderForm] 字段 "${key}" 已高亮,跳过高亮操作`);
return;
}
// 2. 捕获当前输入框 DOM 元素
const inputElement = e.currentTarget;
// 3. 更新当前高亮字段(只保存一个)
setCurrentHighlightedField(key);
console.log(`[PlaceholderForm] 切换高亮字段到 "${key}"`);
// 4. 调用父组件的高亮回调
if (onFieldFocus) {
onFieldFocus(key);
}
// 5. 【核心】延迟后强制夺回焦点(UNO 命令会让 iframe 抢焦点)
// 分多次确保焦点回到输入框,防止被 iframe 再次抢走
const refocusWithRetry = () => {
console.log(`[PlaceholderForm] 夺回焦点到输入框 "${key}"`);
inputElement.focus();
// 保持光标在文字最后
const len = inputElement.value.length;
if ('setSelectionRange' in inputElement) {
inputElement.setSelectionRange(len, len);
}
};
// 第一次夺回焦点:150ms(高亮操作完成时)
setTimeout(refocusWithRetry, 150);
// 第二次确认焦点:300ms(确保没有被再次抢走)
setTimeout(refocusWithRetry, 300);
};
// 处理单个字段替换
@@ -125,11 +160,10 @@ export function PlaceholderForm({
<button
onClick={handleCompleteClick}
disabled={isDeleting}
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${
isDeleting
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
}`}
className={`flex items-center justify-center gap-1.5 px-6 py-2 text-sm font-medium rounded-lg transition-all duration-150 ${isDeleting
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 hover:shadow-md active:scale-[0.98]'
}`}
>
{isDeleting ? (
<>
@@ -163,7 +197,7 @@ export function PlaceholderForm({
<textarea
value={localValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
onFocus={() => handleFieldFocus(field.key)}
onClick={(e) => handleFieldClick(e, field.key)}
placeholder={field.placeholder || `请输入${field.label}`}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 resize-none bg-white text-gray-900 placeholder-gray-400 text-sm"
rows={3}
@@ -173,7 +207,7 @@ export function PlaceholderForm({
type={field.type}
value={localValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
onFocus={() => handleFieldFocus(field.key)}
onClick={(e) => handleFieldClick(e, field.key)}
placeholder={field.placeholder || `请输入${field.label}`}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all duration-150 bg-white text-gray-900 placeholder-gray-400 text-sm"
/>
@@ -183,11 +217,10 @@ export function PlaceholderForm({
<button
onClick={() => handleSingleReplace(field.key)}
disabled={!localValues[field.key] || replacingFields.has(field.key)}
className={`px-3 py-2 rounded-lg transition-all duration-150 flex items-center gap-1.5 text-sm font-medium whitespace-nowrap ${
!localValues[field.key] || replacingFields.has(field.key)
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-primary text-white hover:bg-primary-hover shadow-sm hover:shadow'
}`}
className={`px-3 py-2 rounded-lg transition-all duration-150 flex items-center gap-1.5 text-sm font-medium whitespace-nowrap ${!localValues[field.key] || replacingFields.has(field.key)
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-primary text-white hover:bg-primary-hover shadow-sm hover:shadow'
}`}
title="替换此占位符"
>
{replacingFields.has(field.key) ? (
+12 -14
View File
@@ -3,22 +3,19 @@
* 路由: /contract-draft/:draftId
*/
import type { LoaderFunctionArgs, MetaFunction, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useFetcher } from '@remix-run/react';
import { useState, useRef, useEffect } from 'react';
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { useFetcher, useLoaderData, useNavigate } from '@remix-run/react';
import { useEffect, useRef, useState } from 'react';
import { downloadFile } from '~/api/axios-client';
import type { ContractTemplate } from '~/api/contract-template/templates';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import { getUserSession } from '~/api/login/auth.server';
import { deleteFile } from '~/api/storage/minio-client';
import { toastService } from '~/components/ui/Toast';
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
import { messageService } from '~/components/ui/MessageModal';
import { downloadFile } from '~/api/axios-client';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import path from 'path';
import { toastService } from '~/components/ui/Toast';
import type { DraftedContract, PlaceholderSchema } from '~/types/contract-draft';
import type { ContractTemplate } from '~/api/contract-template/templates';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
@@ -301,10 +298,11 @@ export default function ContractDraftPage() {
// 设置高亮值,触发 FilePreview 中的高亮
setHighlightValue(placeholder);
// 短暂延迟清除高亮,以便下次点击可以重新触发
// 延迟清除高亮,给焦点夺回机制充足的时间
// PlaceholderForm 会在 150ms 后夺回焦点,所以这里延迟 250ms 清除
setTimeout(() => {
setHighlightValue(undefined);
}, 100);
}, 250);
};
// 导出文档(下载当前编辑的文件)- 不再需要手动保存