import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowLeft, ChevronDown, ChevronUp, Expand, FileWarning, Link2, Maximize2, Minimize2, RefreshCw, Search, Shrink, X, } from "lucide-react"; import { renderAsync } from "docx-preview"; import { HttpError } from "../../shared/api/errors"; import { AllowedAction, DocumentPasswordAction, DocumentType } from "../../shared/api/enums"; import { useCtrlWheelZoom } from "../../shared/hooks/useCtrlWheelZoom"; import { useDragPan } from "../../shared/hooks/useDragPan"; import { cachePassword, getCachedPassword, requestDocumentPassword, withDocumentPassword, } from "../../shared/state/documentPassword"; import { attachmentApi } from "../attachments/attachmentApi"; import type { DocumentSummary } from "../documents/documentTypes"; import type { DocumentRelationItem } from "../documents/documentRelationTypes"; import { documentApi } from "../documents/documentApi"; import { invalidateDetail } from "../documents/documentState"; import { tipProps } from "../../shared/ui/hoverTip"; import { documentMutationApi } from "../mutations/documentMutationApi"; import { notifyContentChanged } from "../../shared/state/contentRefresh"; import { acquirePreview } from "./fileState"; import { PdfViewer } from "./PdfViewer"; import { renderParagraphWithHighlights, useKeywordSearch, } from "./keywordSearch"; const PDF_MIME = "application/pdf"; const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; // 图片类扩展名:后端原图直出(image/* MIME),前端 渲染 const IMAGE_PREVIEW_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]); function previewError(value: unknown, requestId = "unknown"): HttpError { if (value instanceof HttpError) return value; return new HttpError({ httpStatus: 0, code: "INTERNAL_ERROR", message: value instanceof Error ? value.message : "文件预览失败", details: null, requestId, cause: value, }); } function errorText(error: HttpError, extension: string): string { if (error.code === "PREVIEW_UNAVAILABLE" || error.httpStatus === 415) { return ["doc", "wps"].includes(extension.toLowerCase()) ? "当前DOC/WPS格式暂不支持在线预览,请下载原始文件。" : "当前文件类型暂不支持在线预览"; } if (error.httpStatus === 403 || error.code === "DOCUMENT_VIEW_FORBIDDEN") return "当前账号无权查看该文件"; if (error.code === "DOCUMENT_PASSWORD_REQUIRED") return "该方案已设置密码保护,查看需输入方案密码"; if (error.code === "DOCUMENT_PASSWORD_INVALID") return "方案密码不正确"; if (error.httpStatus === 404 || error.code === "FILE_NOT_FOUND") return "文件不存在或已被删除"; if (error.httpStatus === 500) return "预览服务暂时不可用"; if (error.httpStatus === 0) return "网络连接失败或文档渲染失败"; return error.message; } function sanitizeRenderedContent(container: HTMLElement): void { container.querySelectorAll("a").forEach((anchor) => { const href = anchor.getAttribute("href") ?? ""; if (href.trim().toLowerCase().startsWith("javascript:")) anchor.removeAttribute("href"); if (!/^(https?:|mailto:|#)/i.test(href)) anchor.removeAttribute("href"); anchor.setAttribute("rel", "noopener noreferrer"); if (/^https?:/i.test(href)) anchor.setAttribute("target", "_blank"); }); container.querySelectorAll("img").forEach((image) => { const src = image.getAttribute("src") ?? ""; if (src && !/^(blob:|data:)/i.test(src)) image.removeAttribute("src"); // 图片可点击放大(见 ImageLightbox):渲染后统一给出提示光标 image.style.cursor = "zoom-in"; }); } // ─── 图片浏览遮罩:点击文档内图片后全屏查看 ────────────────────────────────── // Ctrl+滚轮缩放(乘法步进,范围 0.2~8 倍)+ 按住拖拽平移 + 右上角关闭按钮。 const IMAGE_LIGHTBOX_MIN = 0.2; const IMAGE_LIGHTBOX_MAX = 8; function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) { const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const rootRef = useRef(null); const dragRef = useRef<{ pointerId: number; startX: number; startY: number; baseX: number; baseY: number } | null>(null); // Ctrl+滚轮缩放:React onWheel 是 passive 合成事件无法 preventDefault, // 须以原生 wheel 监听拦截浏览器页面缩放快捷键(与 useCtrlWheelZoom 同理, // 但图片缩放用乘法步进与更宽范围,故独立实现)。 useEffect(() => { const element = rootRef.current; if (!element) return; const handler = (event: WheelEvent) => { if (!event.ctrlKey) return; event.preventDefault(); // 阻断冒泡:modal 模式下 lightbox 渲染在弹窗遮罩根(modalZoom 监听 // 挂载点)内部,不阻断会让一次滚轮同时缩放图片与底层文档。 event.stopPropagation(); setScale((current) => { const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1; const next = Math.round(current * factor * 100) / 100; return Math.min(IMAGE_LIGHTBOX_MAX, Math.max(IMAGE_LIGHTBOX_MIN, next)); }); }; element.addEventListener("wheel", handler, { passive: false }); return () => element.removeEventListener("wheel", handler); }, []); return (
文档图片 { if (event.button !== 0) return; event.preventDefault(); dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, baseX: offset.x, baseY: offset.y }; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; setOffset({ x: drag.baseX + event.clientX - drag.startX, y: drag.baseY + event.clientY - drag.startY }); }} onPointerUp={() => { dragRef.current = null; }} onPointerCancel={() => { dragRef.current = null; }} />
); } /** * 图片直出预览面板(显式像素尺寸)。 * * onload 时按容器余量算一次 fit 基准尺寸(等比、不放大),此后 img 显示尺寸 * = fit × zoom(区域缩放倍率由上层传入,直接放大布局尺寸,滚动范围真实增长、 * 滚动条自然出现)。maxWidth/maxHeight:none 覆盖 Tailwind preflight 的 * img{max-width:100%},保持显示尺寸不被百分比约束改写。居中用 flex 子项 * margin:auto(溢出时退化为 0,不裁起点),溢出方向始终可由容器 * overflow-auto 滚动到达;拖动平移由 useDragPan 提供。 */ function ImageViewPane({ url, alt, onZoomActivate, onFitRatio, zoom = 1 }: { url: string; alt: string; onZoomActivate: (src: string) => void; /** 原尺寸/适配尺寸比上报(= 1/fit 缩放比),上层据此把缩放上限提到 3 倍比 */ onFitRatio?: (ratio: number) => void; /** 区域缩放倍率(Ctrl+滚轮):img 显示尺寸 = fit × zoom */ zoom?: number; }) { const [fit, setFit] = useState<{ w: number; h: number } | null>(null); const panRef = useDragPan(); return (
{/* 点击进入全屏浏览遮罩(复用 ImageLightbox);区域内 Ctrl+滚轮缩放 + 拖动平移(拖动不触发点击放大) */} {alt} onZoomActivate(url)} onLoad={(event) => { const container = panRef.current; const img = event.currentTarget; if (!container) return; // p-6 上下/左右各 24px 余量;等比缩到容器内且不超出原始尺寸 const scale = Math.min( 1, (container.clientWidth - 48) / img.naturalWidth, (container.clientHeight - 48) / img.naturalHeight, ); onFitRatio?.(1 / scale); setFit({ w: Math.max(1, Math.round(img.naturalWidth * scale)), h: Math.max(1, Math.round(img.naturalHeight * scale)), }); }} className="shadow-sm cursor-zoom-in" style={{ width: fit ? Math.max(1, Math.round(fit.w * zoom)) : undefined, height: fit ? Math.max(1, Math.round(fit.h * zoom)) : undefined, margin: "auto", flex: "none", maxWidth: "none", maxHeight: "none", // 尺寸就绪前隐藏,避免未约束的自然尺寸闪现 display: fit ? "block" : "none", }} />
); } export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style, onZoomMaxChange }: { item: DocumentSummary; onClose: () => void; mode?: "side" | "modal"; /** * 仅 side 模式生效:把外部 ref 与 style 合并到 panel 根 aside。 * modal 模式是临时全屏弹窗(含遮罩),不参与外部区域缩放。 */ asideRef?: React.Ref; style?: React.CSSProperties; /** * 仅 side 模式生效:缩放上限变更上抛给外部区域缩放 hook(App rightZoom)。 * 上限 = 3 × 原尺寸/适配尺寸比(适应宽度会把超宽文档先缩小,固定上限 * 会导致宽文档永远放不回原尺寸)。modal 模式上限由内部 modalZoom 自管。 */ onZoomMaxChange?: (max: number) => void; }) { const [pdfBlob, setPdfBlob] = useState(null); const [imageBlob, setImageBlob] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [retryVersion, setRetryVersion] = useState(0); const [contentText, setContentText] = useState(null); const [contentLoading, setContentLoading] = useState(false); // modal 窗口状态:normal 常规弹窗 / maximized 最大化 / fullscreen 最大化且隐藏标题栏 const [windowState, setWindowState] = useState<"normal" | "maximized" | "fullscreen">("normal"); // 补充说明:modal 模式下展示;具备编辑权限的用户可编辑保存 const [description, setDescription] = useState(null); const [descriptionDraft, setDescriptionDraft] = useState(""); const [savingDescription, setSavingDescription] = useState(false); const [descriptionMessage, setDescriptionMessage] = useState(""); // 图片浏览遮罩:点击 docx 渲染区内的图片后全屏查看(Ctrl+滚轮缩放) const [lightboxSrc, setLightboxSrc] = useState(null); // useRef(null) 取可写引用(setDocxContainer 需要与其他 ref 合并赋值) const docxContainer = useRef(null); // 统一交互:查看弹窗(modal)内容区 Ctrl+滚轮缩放。倍率经 contentZoom 传入 // 各渲染面板,由面板直接放大内容的布局尺寸(PDF 显示盒 / img 显式像素 / // docx 占位层),滚动范围随之真实增长、滚动条自然出现——不能在包装层上用 // transform: scale(不参与布局,放大后滚动条不出现、居中空白跟着放大), // 也不能用 CSS zoom(挂派生尺寸包装层是视觉 no-op,实测已弃用)。 // 上限按文档动态调整(applyFitRatio,目标 = 原尺寸的 3 倍),初始 3; // side 模式沿用外部 rightZoom,上限经 onZoomMaxChange 上抛。 const modalZoom = useCtrlWheelZoom(1, 3); const docxPanRef = useDragPan(); // docx 内容自然尺寸(渲染完成后测量一次):缩放时占位层按 自然尺寸×zoom // 撑开滚动范围,内层按自然尺寸渲染后 transform 放大(docx 是 pt 固定排版, // 无法像 PDF/img 那样逐元素放大布局,是三个面板中唯一保留 transform 的) const [docxSize, setDocxSize] = useState<{ w: number; h: number } | null>(null); // docxContainer 仅是 renderAsync 渲染目标;拖动平移挂在外层滚动容器上 const setDocxContainer = (node: HTMLDivElement | null) => { docxContainer.current = node; }; const requestVersion = useRef(0); const isModal = mode === "modal"; // 缩放上限动态化:子面板(PdfViewer/ImageViewPane)测得 原尺寸/适配尺寸 比 // 后上报,上限 = 3 × 比值(即最多放大到原尺寸的 3 倍,适应宽度缩得越多 // 上限越高);modal 作用于内部 modalZoom,side 上抛给 App 的 rightZoom。 // docx-preview 回退分支在 ignoreWidth(side)下拿不到页面原始宽度,不上报, // 保持初始上限 3(实际 doc/docx 几乎都被后端转为 PDF,走 PdfViewer 分支)。 const applyFitRatio = useCallback((ratio: number) => { const max = Math.max(1, Math.round(ratio * 3 * 100) / 100); if (isModal) modalZoom.setMax(max); else onZoomMaxChange?.(max); }, [isModal, onZoomMaxChange, modalZoom.setMax]); // PDF 预览固定 pdfjs 渲染(弹窗与侧栏一致):canvas 位图天然不可选中文字、 // 滚动容器可拖动平移,且文内高亮/跳转依赖 pdfjs 文本层坐标——这三个要求 // iframe 内置查看器(独立文档,样式与事件均无法注入)一个都做不到, // 故引擎切换(原 DMS_PDF_VIEWER_ENGINE)退役,pdfUrl/iframe 分支一并移除。 // 关联方案跳转:modal 模式下点击关联项后在同一弹窗内切换查看目标方案; // side 模式不展示关联区块,navigated 恒为 null,current 即 item。 const [navigated, setNavigated] = useState(null); const current = navigated ?? item; // 切换文档(含关联跳转)时复位缩放上限为初始 3,新文档渲染完成后会再次上报精确值 useEffect(() => { applyFitRatio(1); }, [current.id, applyFitRatio]); // 关联清单(仅 modal + 非附件加载;附件不可发起关联,后端对附件返回 404) const [relations, setRelations] = useState([]); const [relationsError, setRelationsError] = useState(""); const [relationJumping, setRelationJumping] = useState(false); useEffect(() => { setNavigated(null); }, [item.id]); // 列表全量可见(需求3):无权限方案允许打开预览面板,但不发任何请求, // 直接呈现"无权限访问"提示;后端预览/详情接口仍独立拦截作为第二道防线。 const canView = current.allowedActions.includes(AllowedAction.VIEW); // ─── side 模式:与中间列表 1:1 平分 + 左边缘拖拽调宽 ────────────────────── // null = 未拖动,aside 用 flex-1 与中间列(同为 flex-1)平分剩余空间; // 拖动后记录显式像素宽,中间列继续 flex-1 吸收变化,两侧内容自适应。 const MIN_WIDTH = 360; const [sideWidth, setSideWidth] = useState(null); const asideEl = useRef(null); // 拖动中不改 aside 宽度(避免 PDF/DOCX 渲染器随宽度实时重排导致卡顿), // 只移动全屏蓝色指示线标识新边界(transform 合成层动画,零重排零重渲染), // 松手时一次性提交最终宽度。 const guideEl = useRef(null); const dragState = useRef({ active: false, x: 0, width: 0, latest: 0 }); // side 模式:外部 style 仅携带区域缩放倍率(App rightZoom);倍率只传给 // 内容渲染面板(与弹窗 modalZoom 同款),面板标题栏等装饰不参与缩放, // 其余样式合并进 aside 根。 const { zoom: sideZoomRaw, ...asideRestStyle } = style ?? {}; const sideZoom = typeof sideZoomRaw === "number" ? sideZoomRaw : 1; // 内容区当前缩放倍率:modal 用内部 modalZoom,side 用外部 rightZoom const contentZoom = isModal ? modalZoom.zoom : sideZoom; // 外部 asideRef(区域缩放)与内部 ref 合并到同一节点。 const setSideAside = (node: HTMLElement | null) => { asideEl.current = node; if (!asideRef) return; if (typeof asideRef === "function") asideRef(node); else (asideRef as { current: HTMLElement | null }).current = node; }; // 手柄持有指针捕获:即使鼠标滑入预览区内的 iframe(独立文档,事件不冒泡到 window), // pointermove/up 仍派发到手柄本尊——这是"经常拖不动"的根因修复。 const handleDragEnd = () => { if (!dragState.current.active) return; dragState.current.active = false; if (guideEl.current) guideEl.current.style.display = "none"; document.body.style.cursor = ""; document.body.style.userSelect = ""; setSideWidth(dragState.current.latest); }; // 图片预览: 只消费对象 URL,切换/卸载时释放。 const imageUrl = useMemo( () => (imageBlob ? URL.createObjectURL(imageBlob) : null), [imageBlob], ); useEffect(() => { if (!imageUrl) return; return () => URL.revokeObjectURL(imageUrl); }, [imageUrl]); // modal 模式下额外拉取 detail 以展示提取文本(contentText 仅在 detail DTO 暴露)。 // side 模式宽度不足以分栏,跳过拉取以节省请求。 useEffect(() => { setContentText(null); setContentLoading(false); setDescription(null); if (!isModal || !canView) return; const controller = new AbortController(); setContentLoading(true); const isAttachment = current.documentType === DocumentType.ATTACHMENT; const request = isAttachment ? attachmentApi.detail(current.id, controller.signal) : documentApi.detail(current.id, controller.signal, getCachedPassword(current.id)); request .then((detail) => { if (!controller.signal.aborted) { setContentText(detail.contentText ?? null); setDescription(detail.description ?? null); } }) .catch(() => { // 静默失败:右侧降级为"暂无正文" }) .finally(() => { if (!controller.signal.aborted) setContentLoading(false); }); return () => controller.abort(); }, [current.id, current.documentType, isModal, canView, retryVersion]); // 关联方案清单:仅 modal 模式且非附件时加载(附件不可发起关联,直接跳过)。 useEffect(() => { setRelations([]); setRelationsError(""); if (!isModal || !canView || current.documentType === DocumentType.ATTACHMENT) return; const controller = new AbortController(); documentApi.listRelations(current.id, controller.signal, getCachedPassword(current.id)) .then((result) => { if (!controller.signal.aborted) setRelations(result.items); }) .catch(() => { if (!controller.signal.aborted) setRelationsError("关联方案加载失败"); }); return () => controller.abort(); }, [current.id, current.documentType, isModal, canView]); /** 跳转查看关联方案:带密码锁重试;成功后整个弹窗切换为目标方案 */ const jumpToRelation = async (relation: DocumentRelationItem) => { if (relationJumping) return; setRelationJumping(true); setRelationsError(""); try { const detail = await withDocumentPassword( relation.relatedDocumentId, relation.relatedDocumentName, DocumentPasswordAction.VIEW, (password) => documentApi.detail(relation.relatedDocumentId, undefined, password), ); setNavigated(detail); } catch (value) { if (value instanceof HttpError) { setRelationsError( value.code === "DOCUMENT_PASSWORD_REQUIRED" || value.code === "DOCUMENT_PASSWORD_INVALID" ? "该方案已设置密码保护,已取消跳转" : value.httpStatus === 403 || value.code === "DOCUMENT_VIEW_FORBIDDEN" ? "当前账号无权查看该方案" : value.message, ); } else { setRelationsError("跳转失败,请稍后重试"); } } finally { setRelationJumping(false); } }; // 按空行(≥1 个空行)分段;过滤纯空白段。 const paragraphs = useMemo(() => { if (!contentText) return []; return contentText .split(/\n\s*\n+/) .map((segment) => segment.trim()) .filter((segment) => segment.length > 0); }, [contentText]); // 正文搜索:在提取文本上做跨空白(含换行)匹配,主要解决 PDF 转换后 // 关键词被换行符拆开导致直接 indexOf 漏匹配的问题。仅 modal 模式展示 // 搜索 UI;side 模式下 contentText 始终为 null,hook 内部为空操作。 // 决策1:pdfjs 渲染激活时以 PDF 侧实际可定位的命中数为计数权威; // 提取文本面板保留自身高亮,序号与 PDF 侧共用同一 currentIndex。 const [pdfMatchCount, setPdfMatchCount] = useState(null); const pdfSearchActive = !!pdfBlob; const search = useKeywordSearch(contentText, paragraphs, pdfSearchActive ? pdfMatchCount : null); const textPanelRef = useRef(null); // 补充说明:加载/保存成功后草稿与服务端值对齐 useEffect(() => { setDescriptionDraft(description ?? ""); setDescriptionMessage(""); }, [description]); const canEditDescription = isModal && current.allowedActions.includes(AllowedAction.EDIT); const saveDescription = async () => { if (!canEditDescription || savingDescription) return; setSavingDescription(true); setDescriptionMessage(""); try { const detail = await withDocumentPassword( current.id, current.documentName, DocumentPasswordAction.EDIT, (password) => documentMutationApi.updateDescription( current.id, descriptionDraft.trim() || null, undefined, password, ), ); setDescription(detail.description ?? null); invalidateDetail( current.documentType === DocumentType.ATTACHMENT ? "attachment" : "document", current.id, ); notifyContentChanged( current.documentType === DocumentType.ATTACHMENT ? "attachments" : "documents", ); setDescriptionMessage("说明已保存。"); } catch (value) { setDescriptionMessage( value instanceof HttpError ? `${value.message}${value.requestId ? `(错误编号:${value.requestId})` : ""}` : "说明保存失败,请稍后重试", ); } finally { setSavingDescription(false); } }; // 当前命中切换时滚动到右侧文本面板的可视区域。用 ref 限定查找范围, // 避免与同时挂载的 side 预览实例的 DOM 冲突。 useEffect(() => { if (search.totalCount === 0) return; const root = textPanelRef.current; if (!root) return; const hit = root.querySelector( 'mark[data-search-hit="current"]', ); if (hit) hit.scrollIntoView({ block: "center" }); }, [search.currentIndex, search.totalCount]); useEffect(() => { if (!canView) { // 无权限:不发预览请求,直接给出提示(复用 403 文案路径)。 setPdfBlob(null); setPdfMatchCount(null); setImageBlob(null); setError(new HttpError({ httpStatus: 403, code: "DOCUMENT_VIEW_FORBIDDEN", message: "当前账号无权查看该文件", details: null, requestId: "", })); setLoading(false); return; } const version = ++requestVersion.current; const preview = acquirePreview( current.id, `${current.rowVersion}:${current.updatedAt}:${retryVersion}`, getCachedPassword(current.id), ); let responseRequestId = "unknown"; setPdfBlob(null); setPdfMatchCount(null); setImageBlob(null); setError(null); setLoading(true); setLightboxSrc(null); setDocxSize(null); if (docxContainer.current) docxContainer.current.replaceChildren(); void preview.promise.then(async (result) => { responseRequestId = result.requestId; if (version !== requestVersion.current) return; const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase(); const extension = current.fileExtension.trim().toLowerCase(); // 预览:PDF/DOC/DOCX/WPS/XLS/XLSX/ET 统一转换返回 application/pdf;图片原图直出 image/* if ((extension === "pdf" || extension === "doc" || extension === "docx" || extension === "wps" || extension === "xls" || extension === "xlsx" || extension === "et") && mime === PDF_MIME) { if (version !== requestVersion.current) return; setPdfBlob(result.blob); return; } if (IMAGE_PREVIEW_EXTENSIONS.has(extension) && mime.startsWith("image/")) { if (version !== requestVersion.current) return; setImageBlob(result.blob); return; } if (extension === "docx") { // 受控回退:仅当后端明确返回 DOCX 时使用 docx-preview if (mime !== DOCX_MIME) { throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { expected: DOCX_MIME, actual: mime }, requestId: result.requestId }); } const buffer = await result.blob.arrayBuffer(); if (version !== requestVersion.current || !docxContainer.current) return; docxContainer.current.replaceChildren(); await renderAsync(buffer, docxContainer.current, undefined, { inWrapper: true, ignoreWidth: mode === "side", ignoreHeight: true, breakPages: true, useBase64URL: true, }); if (version !== requestVersion.current || !docxContainer.current) return; sanitizeRenderedContent(docxContainer.current); // 测量自然尺寸供缩放占位层使用(此时无占位/缩放约束,scrollWidth/Height // 即内容真实布局尺寸;docxContainer 自身的 transform 不影响该读数) setDocxSize({ w: docxContainer.current.scrollWidth, h: docxContainer.current.scrollHeight, }); return; } throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { extension, actual: mime }, requestId: result.requestId }); }).catch((value: unknown) => { if (version !== requestVersion.current) return; const httpError = previewError(value, responseRequestId); // 设密主案:弹窗获取方案密码,验证通过后带密码重试预览 if ( httpError.code === "DOCUMENT_PASSWORD_REQUIRED" || httpError.code === "DOCUMENT_PASSWORD_INVALID" ) { void requestDocumentPassword(current.id, current.documentName, DocumentPasswordAction.VIEW) .then((password) => { if (version !== requestVersion.current) return; if (!password) { setError(httpError); return; } cachePassword(current.id, password); setRetryVersion((current) => current + 1); }); return; } setError(httpError); }).finally(() => { if (version === requestVersion.current) setLoading(false); }); return () => { requestVersion.current += 1; preview.release(); if (docxContainer.current) docxContainer.current.replaceChildren(); }; }, [current.id, current.rowVersion, current.updatedAt, retryVersion, mode, canView]); const previewChildren = ( <> {loading &&
正在加载预览…
} {pdfBlob && ( )} {imageUrl && ( )} {/* docx 滚动容器:拖动平移与图片点击委托都挂在这层 */}
{ docxPanRef.current = node; }} // 文档内图片点击 → 全屏图片浏览遮罩:docx-preview 动态渲染的 img 无法逐个绑定 // prop,在容器上事件委托统一接管(PDF 为 canvas 渲染,无 img,天然不触发)。 onClick={(event) => { const target = event.target; if (target instanceof HTMLImageElement && target.src) setLightboxSrc(target.src); }} className="h-full overflow-auto" > {/* 缩放占位层:transform 不参与布局,须显式按 自然尺寸×zoom 撑开滚动范围; 内层按自然尺寸渲染后 transform 放大(origin 左上,章节居中关系随占位层 等比保持)。未测量(渲染中/非 docx)时退化为普通流式容器。 */}
{/* 错误提示必须绝对定位铺满:上方 docx 滚动容器带 h-full,即使为空也占满 内容区,流式提示会被 overflow-hidden 裁掉而不可见(403 无权/415 不支持等同理) */} {error &&
{errorText(error, current.fileExtension)}
{error.requestId &&
错误编号:{error.requestId}
} {error.httpStatus !== 401 && }
} ); const textPanel = (