FilePreviewModal.tsx 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963
  1. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  2. import { createPortal } from "react-dom";
  3. import {
  4. ArrowLeft,
  5. ChevronDown,
  6. ChevronUp,
  7. Expand,
  8. FileWarning,
  9. Link2,
  10. Maximize2,
  11. Minimize2,
  12. RefreshCw,
  13. Search,
  14. Shrink,
  15. X,
  16. } from "lucide-react";
  17. import { renderAsync } from "docx-preview";
  18. import { HttpError } from "../../shared/api/errors";
  19. import { AllowedAction, DocumentPasswordAction, DocumentType } from "../../shared/api/enums";
  20. import { useCtrlWheelZoom } from "../../shared/hooks/useCtrlWheelZoom";
  21. import { useDragPan } from "../../shared/hooks/useDragPan";
  22. import {
  23. cachePassword,
  24. getCachedPassword,
  25. requestDocumentPassword,
  26. withDocumentPassword,
  27. } from "../../shared/state/documentPassword";
  28. import { attachmentApi } from "../attachments/attachmentApi";
  29. import type { DocumentSummary } from "../documents/documentTypes";
  30. import type { DocumentRelationItem } from "../documents/documentRelationTypes";
  31. import { documentApi } from "../documents/documentApi";
  32. import { invalidateDetail } from "../documents/documentState";
  33. import { tipProps } from "../../shared/ui/hoverTip";
  34. import { documentMutationApi } from "../mutations/documentMutationApi";
  35. import { notifyContentChanged } from "../../shared/state/contentRefresh";
  36. import { acquirePreview } from "./fileState";
  37. import { PdfViewer } from "./PdfViewer";
  38. import {
  39. renderParagraphWithHighlights,
  40. useKeywordSearch,
  41. } from "./keywordSearch";
  42. const PDF_MIME = "application/pdf";
  43. const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
  44. // 图片类扩展名:后端原图直出(image/* MIME),前端 <img> 渲染
  45. const IMAGE_PREVIEW_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
  46. function previewError(value: unknown, requestId = "unknown"): HttpError {
  47. if (value instanceof HttpError) return value;
  48. return new HttpError({
  49. httpStatus: 0,
  50. code: "INTERNAL_ERROR",
  51. message: value instanceof Error ? value.message : "文件预览失败",
  52. details: null,
  53. requestId,
  54. cause: value,
  55. });
  56. }
  57. function errorText(error: HttpError, extension: string): string {
  58. if (error.code === "PREVIEW_UNAVAILABLE" || error.httpStatus === 415) {
  59. return ["doc", "wps"].includes(extension.toLowerCase())
  60. ? "当前DOC/WPS格式暂不支持在线预览,请下载原始文件。"
  61. : "当前文件类型暂不支持在线预览";
  62. }
  63. if (error.httpStatus === 403 || error.code === "DOCUMENT_VIEW_FORBIDDEN") return "当前账号无权查看该文件";
  64. if (error.code === "DOCUMENT_PASSWORD_REQUIRED") return "该方案已设置密码保护,查看需输入方案密码";
  65. if (error.code === "DOCUMENT_PASSWORD_INVALID") return "方案密码不正确";
  66. if (error.httpStatus === 404 || error.code === "FILE_NOT_FOUND") return "文件不存在或已被删除";
  67. if (error.httpStatus === 500) return "预览服务暂时不可用";
  68. if (error.httpStatus === 0) return "网络连接失败或文档渲染失败";
  69. return error.message;
  70. }
  71. function sanitizeRenderedContent(container: HTMLElement): void {
  72. container.querySelectorAll("a").forEach((anchor) => {
  73. const href = anchor.getAttribute("href") ?? "";
  74. if (href.trim().toLowerCase().startsWith("javascript:")) anchor.removeAttribute("href");
  75. if (!/^(https?:|mailto:|#)/i.test(href)) anchor.removeAttribute("href");
  76. anchor.setAttribute("rel", "noopener noreferrer");
  77. if (/^https?:/i.test(href)) anchor.setAttribute("target", "_blank");
  78. });
  79. container.querySelectorAll("img").forEach((image) => {
  80. const src = image.getAttribute("src") ?? "";
  81. if (src && !/^(blob:|data:)/i.test(src)) image.removeAttribute("src");
  82. // 图片可点击放大(见 ImageLightbox):渲染后统一给出提示光标
  83. image.style.cursor = "zoom-in";
  84. });
  85. }
  86. // ─── 图片浏览遮罩:点击文档内图片后全屏查看 ──────────────────────────────────
  87. // Ctrl+滚轮缩放(乘法步进,范围 0.2~8 倍)+ 按住拖拽平移 + 右上角关闭按钮。
  88. const IMAGE_LIGHTBOX_MIN = 0.2;
  89. const IMAGE_LIGHTBOX_MAX = 8;
  90. function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) {
  91. const [scale, setScale] = useState(1);
  92. const [offset, setOffset] = useState({ x: 0, y: 0 });
  93. const rootRef = useRef<HTMLDivElement>(null);
  94. const dragRef = useRef<{ pointerId: number; startX: number; startY: number; baseX: number; baseY: number } | null>(null);
  95. // Ctrl+滚轮缩放:React onWheel 是 passive 合成事件无法 preventDefault,
  96. // 须以原生 wheel 监听拦截浏览器页面缩放快捷键(与 useCtrlWheelZoom 同理,
  97. // 但图片缩放用乘法步进与更宽范围,故独立实现)。
  98. useEffect(() => {
  99. const element = rootRef.current;
  100. if (!element) return;
  101. const handler = (event: WheelEvent) => {
  102. if (!event.ctrlKey) return;
  103. event.preventDefault();
  104. // 阻断冒泡:modal 模式下 lightbox 渲染在弹窗遮罩根(modalZoom 监听
  105. // 挂载点)内部,不阻断会让一次滚轮同时缩放图片与底层文档。
  106. event.stopPropagation();
  107. setScale((current) => {
  108. const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
  109. const next = Math.round(current * factor * 100) / 100;
  110. return Math.min(IMAGE_LIGHTBOX_MAX, Math.max(IMAGE_LIGHTBOX_MIN, next));
  111. });
  112. };
  113. element.addEventListener("wheel", handler, { passive: false });
  114. return () => element.removeEventListener("wheel", handler);
  115. }, []);
  116. return (
  117. <div
  118. ref={rootRef}
  119. role="dialog"
  120. aria-label="图片浏览"
  121. className="fixed inset-0 z-[100] flex items-center justify-center overflow-hidden select-none"
  122. style={{ background: "rgba(var(--dms-deep-rgb),.88)" }}
  123. >
  124. <img
  125. src={src}
  126. alt="文档图片"
  127. draggable={false}
  128. className="max-w-[90%] max-h-[90%] object-contain shadow-2xl"
  129. style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, cursor: "grab" }}
  130. onPointerDown={(event) => {
  131. if (event.button !== 0) return;
  132. event.preventDefault();
  133. dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, baseX: offset.x, baseY: offset.y };
  134. event.currentTarget.setPointerCapture(event.pointerId);
  135. }}
  136. onPointerMove={(event) => {
  137. const drag = dragRef.current;
  138. if (!drag || drag.pointerId !== event.pointerId) return;
  139. setOffset({ x: drag.baseX + event.clientX - drag.startX, y: drag.baseY + event.clientY - drag.startY });
  140. }}
  141. onPointerUp={() => { dragRef.current = null; }}
  142. onPointerCancel={() => { dragRef.current = null; }}
  143. />
  144. <button
  145. type="button"
  146. onClick={onClose}
  147. aria-label="关闭图片浏览"
  148. {...tipProps("关闭图片浏览")}
  149. className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-sm text-white transition-colors hover:bg-white/25"
  150. >
  151. <X size={24} />
  152. </button>
  153. </div>
  154. );
  155. }
  156. /**
  157. * 图片直出预览面板(显式像素尺寸)。
  158. *
  159. * onload 时按容器余量算一次 fit 基准尺寸(等比、不放大),此后 img 显示尺寸
  160. * = fit × zoom(区域缩放倍率由上层传入,直接放大布局尺寸,滚动范围真实增长、
  161. * 滚动条自然出现)。maxWidth/maxHeight:none 覆盖 Tailwind preflight 的
  162. * img{max-width:100%},保持显示尺寸不被百分比约束改写。居中用 flex 子项
  163. * margin:auto(溢出时退化为 0,不裁起点),溢出方向始终可由容器
  164. * overflow-auto 滚动到达;拖动平移由 useDragPan 提供。
  165. */
  166. function ImageViewPane({ url, alt, onZoomActivate, onFitRatio, zoom = 1 }: {
  167. url: string;
  168. alt: string;
  169. onZoomActivate: (src: string) => void;
  170. /** 原尺寸/适配尺寸比上报(= 1/fit 缩放比),上层据此把缩放上限提到 3 倍比 */
  171. onFitRatio?: (ratio: number) => void;
  172. /** 区域缩放倍率(Ctrl+滚轮):img 显示尺寸 = fit × zoom */
  173. zoom?: number;
  174. }) {
  175. const [fit, setFit] = useState<{ w: number; h: number } | null>(null);
  176. const panRef = useDragPan<HTMLDivElement>();
  177. return (
  178. <div ref={panRef} className="absolute inset-0 flex overflow-auto p-6">
  179. {/* 点击进入全屏浏览遮罩(复用 ImageLightbox);区域内 Ctrl+滚轮缩放 +
  180. 拖动平移(拖动不触发点击放大) */}
  181. <img
  182. src={url}
  183. alt={alt}
  184. draggable={false}
  185. onClick={() => onZoomActivate(url)}
  186. onLoad={(event) => {
  187. const container = panRef.current;
  188. const img = event.currentTarget;
  189. if (!container) return;
  190. // p-6 上下/左右各 24px 余量;等比缩到容器内且不超出原始尺寸
  191. const scale = Math.min(
  192. 1,
  193. (container.clientWidth - 48) / img.naturalWidth,
  194. (container.clientHeight - 48) / img.naturalHeight,
  195. );
  196. onFitRatio?.(1 / scale);
  197. setFit({
  198. w: Math.max(1, Math.round(img.naturalWidth * scale)),
  199. h: Math.max(1, Math.round(img.naturalHeight * scale)),
  200. });
  201. }}
  202. className="shadow-sm cursor-zoom-in"
  203. style={{
  204. width: fit ? Math.max(1, Math.round(fit.w * zoom)) : undefined,
  205. height: fit ? Math.max(1, Math.round(fit.h * zoom)) : undefined,
  206. margin: "auto",
  207. flex: "none",
  208. maxWidth: "none",
  209. maxHeight: "none",
  210. // 尺寸就绪前隐藏,避免未约束的自然尺寸闪现
  211. display: fit ? "block" : "none",
  212. }}
  213. />
  214. </div>
  215. );
  216. }
  217. export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style, onZoomMaxChange }: {
  218. item: DocumentSummary;
  219. onClose: () => void;
  220. mode?: "side" | "modal";
  221. /**
  222. * 仅 side 模式生效:把外部 ref 与 style 合并到 panel 根 aside。
  223. * modal 模式是临时全屏弹窗(含遮罩),不参与外部区域缩放。
  224. */
  225. asideRef?: React.Ref<HTMLElement>;
  226. style?: React.CSSProperties;
  227. /**
  228. * 仅 side 模式生效:缩放上限变更上抛给外部区域缩放 hook(App rightZoom)。
  229. * 上限 = 3 × 原尺寸/适配尺寸比(适应宽度会把超宽文档先缩小,固定上限
  230. * 会导致宽文档永远放不回原尺寸)。modal 模式上限由内部 modalZoom 自管。
  231. */
  232. onZoomMaxChange?: (max: number) => void;
  233. }) {
  234. const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
  235. const [imageBlob, setImageBlob] = useState<Blob | null>(null);
  236. const [error, setError] = useState<HttpError | null>(null);
  237. const [loading, setLoading] = useState(true);
  238. const [retryVersion, setRetryVersion] = useState(0);
  239. const [contentText, setContentText] = useState<string | null>(null);
  240. const [contentLoading, setContentLoading] = useState(false);
  241. // modal 窗口状态:normal 常规弹窗 / maximized 最大化 / fullscreen 最大化且隐藏标题栏
  242. const [windowState, setWindowState] = useState<"normal" | "maximized" | "fullscreen">("normal");
  243. // 补充说明:modal 模式下展示;具备编辑权限的用户可编辑保存
  244. const [description, setDescription] = useState<string | null>(null);
  245. const [descriptionDraft, setDescriptionDraft] = useState("");
  246. const [savingDescription, setSavingDescription] = useState(false);
  247. const [descriptionMessage, setDescriptionMessage] = useState("");
  248. // 图片浏览遮罩:点击 docx 渲染区内的图片后全屏查看(Ctrl+滚轮缩放)
  249. const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
  250. // useRef<T | null>(null) 取可写引用(setDocxContainer 需要与其他 ref 合并赋值)
  251. const docxContainer = useRef<HTMLDivElement | null>(null);
  252. // 统一交互:查看弹窗(modal)内容区 Ctrl+滚轮缩放。倍率经 contentZoom 传入
  253. // 各渲染面板,由面板直接放大内容的布局尺寸(PDF 显示盒 / img 显式像素 /
  254. // docx 占位层),滚动范围随之真实增长、滚动条自然出现——不能在包装层上用
  255. // transform: scale(不参与布局,放大后滚动条不出现、居中空白跟着放大),
  256. // 也不能用 CSS zoom(挂派生尺寸包装层是视觉 no-op,实测已弃用)。
  257. // 上限按文档动态调整(applyFitRatio,目标 = 原尺寸的 3 倍),初始 3;
  258. // side 模式沿用外部 rightZoom,上限经 onZoomMaxChange 上抛。
  259. const modalZoom = useCtrlWheelZoom<HTMLDivElement>(1, 3);
  260. const docxPanRef = useDragPan<HTMLDivElement>();
  261. // docx 内容自然尺寸(渲染完成后测量一次):缩放时占位层按 自然尺寸×zoom
  262. // 撑开滚动范围,内层按自然尺寸渲染后 transform 放大(docx 是 pt 固定排版,
  263. // 无法像 PDF/img 那样逐元素放大布局,是三个面板中唯一保留 transform 的)
  264. const [docxSize, setDocxSize] = useState<{ w: number; h: number } | null>(null);
  265. // docxContainer 仅是 renderAsync 渲染目标;拖动平移挂在外层滚动容器上
  266. const setDocxContainer = (node: HTMLDivElement | null) => {
  267. docxContainer.current = node;
  268. };
  269. const requestVersion = useRef(0);
  270. const isModal = mode === "modal";
  271. // 缩放上限动态化:子面板(PdfViewer/ImageViewPane)测得 原尺寸/适配尺寸 比
  272. // 后上报,上限 = 3 × 比值(即最多放大到原尺寸的 3 倍,适应宽度缩得越多
  273. // 上限越高);modal 作用于内部 modalZoom,side 上抛给 App 的 rightZoom。
  274. // docx-preview 回退分支在 ignoreWidth(side)下拿不到页面原始宽度,不上报,
  275. // 保持初始上限 3(实际 doc/docx 几乎都被后端转为 PDF,走 PdfViewer 分支)。
  276. const applyFitRatio = useCallback((ratio: number) => {
  277. const max = Math.max(1, Math.round(ratio * 3 * 100) / 100);
  278. if (isModal) modalZoom.setMax(max);
  279. else onZoomMaxChange?.(max);
  280. }, [isModal, onZoomMaxChange, modalZoom.setMax]);
  281. // PDF 预览固定 pdfjs 渲染(弹窗与侧栏一致):canvas 位图天然不可选中文字、
  282. // 滚动容器可拖动平移,且文内高亮/跳转依赖 pdfjs 文本层坐标——这三个要求
  283. // iframe 内置查看器(独立文档,样式与事件均无法注入)一个都做不到,
  284. // 故引擎切换(原 DMS_PDF_VIEWER_ENGINE)退役,pdfUrl/iframe 分支一并移除。
  285. // 关联方案跳转:modal 模式下点击关联项后在同一弹窗内切换查看目标方案;
  286. // side 模式不展示关联区块,navigated 恒为 null,current 即 item。
  287. const [navigated, setNavigated] = useState<DocumentSummary | null>(null);
  288. const current = navigated ?? item;
  289. // 切换文档(含关联跳转)时复位缩放上限为初始 3,新文档渲染完成后会再次上报精确值
  290. useEffect(() => { applyFitRatio(1); }, [current.id, applyFitRatio]);
  291. // 关联清单(仅 modal + 非附件加载;附件不可发起关联,后端对附件返回 404)
  292. const [relations, setRelations] = useState<DocumentRelationItem[]>([]);
  293. const [relationsError, setRelationsError] = useState("");
  294. const [relationJumping, setRelationJumping] = useState(false);
  295. useEffect(() => { setNavigated(null); }, [item.id]);
  296. // 列表全量可见(需求3):无权限方案允许打开预览面板,但不发任何请求,
  297. // 直接呈现"无权限访问"提示;后端预览/详情接口仍独立拦截作为第二道防线。
  298. const canView = current.allowedActions.includes(AllowedAction.VIEW);
  299. // ─── side 模式:与中间列表 1:1 平分 + 左边缘拖拽调宽 ──────────────────────
  300. // null = 未拖动,aside 用 flex-1 与中间列(同为 flex-1)平分剩余空间;
  301. // 拖动后记录显式像素宽,中间列继续 flex-1 吸收变化,两侧内容自适应。
  302. const MIN_WIDTH = 360;
  303. const [sideWidth, setSideWidth] = useState<number | null>(null);
  304. const asideEl = useRef<HTMLElement | null>(null);
  305. // 拖动中不改 aside 宽度(避免 PDF/DOCX 渲染器随宽度实时重排导致卡顿),
  306. // 只移动全屏蓝色指示线标识新边界(transform 合成层动画,零重排零重渲染),
  307. // 松手时一次性提交最终宽度。
  308. const guideEl = useRef<HTMLDivElement | null>(null);
  309. const dragState = useRef({ active: false, x: 0, width: 0, latest: 0 });
  310. // side 模式:外部 style 仅携带区域缩放倍率(App rightZoom);倍率只传给
  311. // 内容渲染面板(与弹窗 modalZoom 同款),面板标题栏等装饰不参与缩放,
  312. // 其余样式合并进 aside 根。
  313. const { zoom: sideZoomRaw, ...asideRestStyle } = style ?? {};
  314. const sideZoom = typeof sideZoomRaw === "number" ? sideZoomRaw : 1;
  315. // 内容区当前缩放倍率:modal 用内部 modalZoom,side 用外部 rightZoom
  316. const contentZoom = isModal ? modalZoom.zoom : sideZoom;
  317. // 外部 asideRef(区域缩放)与内部 ref 合并到同一节点。
  318. const setSideAside = (node: HTMLElement | null) => {
  319. asideEl.current = node;
  320. if (!asideRef) return;
  321. if (typeof asideRef === "function") asideRef(node);
  322. else (asideRef as { current: HTMLElement | null }).current = node;
  323. };
  324. // 手柄持有指针捕获:即使鼠标滑入预览区内的 iframe(独立文档,事件不冒泡到 window),
  325. // pointermove/up 仍派发到手柄本尊——这是"经常拖不动"的根因修复。
  326. const handleDragEnd = () => {
  327. if (!dragState.current.active) return;
  328. dragState.current.active = false;
  329. if (guideEl.current) guideEl.current.style.display = "none";
  330. document.body.style.cursor = "";
  331. document.body.style.userSelect = "";
  332. setSideWidth(dragState.current.latest);
  333. };
  334. // 图片预览:<img> 只消费对象 URL,切换/卸载时释放。
  335. const imageUrl = useMemo(
  336. () => (imageBlob ? URL.createObjectURL(imageBlob) : null),
  337. [imageBlob],
  338. );
  339. useEffect(() => {
  340. if (!imageUrl) return;
  341. return () => URL.revokeObjectURL(imageUrl);
  342. }, [imageUrl]);
  343. // modal 模式下额外拉取 detail 以展示提取文本(contentText 仅在 detail DTO 暴露)。
  344. // side 模式宽度不足以分栏,跳过拉取以节省请求。
  345. useEffect(() => {
  346. setContentText(null);
  347. setContentLoading(false);
  348. setDescription(null);
  349. if (!isModal || !canView) return;
  350. const controller = new AbortController();
  351. setContentLoading(true);
  352. const isAttachment = current.documentType === DocumentType.ATTACHMENT;
  353. const request = isAttachment
  354. ? attachmentApi.detail(current.id, controller.signal)
  355. : documentApi.detail(current.id, controller.signal, getCachedPassword(current.id));
  356. request
  357. .then((detail) => {
  358. if (!controller.signal.aborted) {
  359. setContentText(detail.contentText ?? null);
  360. setDescription(detail.description ?? null);
  361. }
  362. })
  363. .catch(() => {
  364. // 静默失败:右侧降级为"暂无正文"
  365. })
  366. .finally(() => {
  367. if (!controller.signal.aborted) setContentLoading(false);
  368. });
  369. return () => controller.abort();
  370. }, [current.id, current.documentType, isModal, canView, retryVersion]);
  371. // 关联方案清单:仅 modal 模式且非附件时加载(附件不可发起关联,直接跳过)。
  372. useEffect(() => {
  373. setRelations([]);
  374. setRelationsError("");
  375. if (!isModal || !canView || current.documentType === DocumentType.ATTACHMENT) return;
  376. const controller = new AbortController();
  377. documentApi.listRelations(current.id, controller.signal, getCachedPassword(current.id))
  378. .then((result) => {
  379. if (!controller.signal.aborted) setRelations(result.items);
  380. })
  381. .catch(() => {
  382. if (!controller.signal.aborted) setRelationsError("关联方案加载失败");
  383. });
  384. return () => controller.abort();
  385. }, [current.id, current.documentType, isModal, canView]);
  386. /** 跳转查看关联方案:带密码锁重试;成功后整个弹窗切换为目标方案 */
  387. const jumpToRelation = async (relation: DocumentRelationItem) => {
  388. if (relationJumping) return;
  389. setRelationJumping(true);
  390. setRelationsError("");
  391. try {
  392. const detail = await withDocumentPassword(
  393. relation.relatedDocumentId,
  394. relation.relatedDocumentName,
  395. DocumentPasswordAction.VIEW,
  396. (password) => documentApi.detail(relation.relatedDocumentId, undefined, password),
  397. );
  398. setNavigated(detail);
  399. } catch (value) {
  400. if (value instanceof HttpError) {
  401. setRelationsError(
  402. value.code === "DOCUMENT_PASSWORD_REQUIRED" || value.code === "DOCUMENT_PASSWORD_INVALID"
  403. ? "该方案已设置密码保护,已取消跳转"
  404. : value.httpStatus === 403 || value.code === "DOCUMENT_VIEW_FORBIDDEN"
  405. ? "当前账号无权查看该方案"
  406. : value.message,
  407. );
  408. } else {
  409. setRelationsError("跳转失败,请稍后重试");
  410. }
  411. } finally {
  412. setRelationJumping(false);
  413. }
  414. };
  415. // 按空行(≥1 个空行)分段;过滤纯空白段。
  416. const paragraphs = useMemo(() => {
  417. if (!contentText) return [];
  418. return contentText
  419. .split(/\n\s*\n+/)
  420. .map((segment) => segment.trim())
  421. .filter((segment) => segment.length > 0);
  422. }, [contentText]);
  423. // 正文搜索:在提取文本上做跨空白(含换行)匹配,主要解决 PDF 转换后
  424. // 关键词被换行符拆开导致直接 indexOf 漏匹配的问题。仅 modal 模式展示
  425. // 搜索 UI;side 模式下 contentText 始终为 null,hook 内部为空操作。
  426. // 决策1:pdfjs 渲染激活时以 PDF 侧实际可定位的命中数为计数权威;
  427. // 提取文本面板保留自身高亮,序号与 PDF 侧共用同一 currentIndex。
  428. const [pdfMatchCount, setPdfMatchCount] = useState<number | null>(null);
  429. const pdfSearchActive = !!pdfBlob;
  430. const search = useKeywordSearch(contentText, paragraphs, pdfSearchActive ? pdfMatchCount : null);
  431. const textPanelRef = useRef<HTMLElement>(null);
  432. // 补充说明:加载/保存成功后草稿与服务端值对齐
  433. useEffect(() => {
  434. setDescriptionDraft(description ?? "");
  435. setDescriptionMessage("");
  436. }, [description]);
  437. const canEditDescription = isModal && current.allowedActions.includes(AllowedAction.EDIT);
  438. const saveDescription = async () => {
  439. if (!canEditDescription || savingDescription) return;
  440. setSavingDescription(true);
  441. setDescriptionMessage("");
  442. try {
  443. const detail = await withDocumentPassword(
  444. current.id,
  445. current.documentName,
  446. DocumentPasswordAction.EDIT,
  447. (password) => documentMutationApi.updateDescription(
  448. current.id,
  449. descriptionDraft.trim() || null,
  450. undefined,
  451. password,
  452. ),
  453. );
  454. setDescription(detail.description ?? null);
  455. invalidateDetail(
  456. current.documentType === DocumentType.ATTACHMENT ? "attachment" : "document",
  457. current.id,
  458. );
  459. notifyContentChanged(
  460. current.documentType === DocumentType.ATTACHMENT ? "attachments" : "documents",
  461. );
  462. setDescriptionMessage("说明已保存。");
  463. } catch (value) {
  464. setDescriptionMessage(
  465. value instanceof HttpError
  466. ? `${value.message}${value.requestId ? `(错误编号:${value.requestId})` : ""}`
  467. : "说明保存失败,请稍后重试",
  468. );
  469. } finally {
  470. setSavingDescription(false);
  471. }
  472. };
  473. // 当前命中切换时滚动到右侧文本面板的可视区域。用 ref 限定查找范围,
  474. // 避免与同时挂载的 side 预览实例的 DOM 冲突。
  475. useEffect(() => {
  476. if (search.totalCount === 0) return;
  477. const root = textPanelRef.current;
  478. if (!root) return;
  479. const hit = root.querySelector<HTMLElement>(
  480. 'mark[data-search-hit="current"]',
  481. );
  482. if (hit) hit.scrollIntoView({ block: "center" });
  483. }, [search.currentIndex, search.totalCount]);
  484. useEffect(() => {
  485. if (!canView) {
  486. // 无权限:不发预览请求,直接给出提示(复用 403 文案路径)。
  487. setPdfBlob(null);
  488. setPdfMatchCount(null);
  489. setImageBlob(null);
  490. setError(new HttpError({
  491. httpStatus: 403,
  492. code: "DOCUMENT_VIEW_FORBIDDEN",
  493. message: "当前账号无权查看该文件",
  494. details: null,
  495. requestId: "",
  496. }));
  497. setLoading(false);
  498. return;
  499. }
  500. const version = ++requestVersion.current;
  501. const preview = acquirePreview(
  502. current.id,
  503. `${current.rowVersion}:${current.updatedAt}:${retryVersion}`,
  504. getCachedPassword(current.id),
  505. );
  506. let responseRequestId = "unknown";
  507. setPdfBlob(null);
  508. setPdfMatchCount(null);
  509. setImageBlob(null);
  510. setError(null);
  511. setLoading(true);
  512. setLightboxSrc(null);
  513. setDocxSize(null);
  514. if (docxContainer.current) docxContainer.current.replaceChildren();
  515. void preview.promise.then(async (result) => {
  516. responseRequestId = result.requestId;
  517. if (version !== requestVersion.current) return;
  518. const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase();
  519. const extension = current.fileExtension.trim().toLowerCase();
  520. // 预览:PDF/DOC/DOCX/WPS/XLS/XLSX/ET 统一转换返回 application/pdf;图片原图直出 image/*
  521. if ((extension === "pdf" || extension === "doc" || extension === "docx" || extension === "wps"
  522. || extension === "xls" || extension === "xlsx" || extension === "et") && mime === PDF_MIME) {
  523. if (version !== requestVersion.current) return;
  524. setPdfBlob(result.blob);
  525. return;
  526. }
  527. if (IMAGE_PREVIEW_EXTENSIONS.has(extension) && mime.startsWith("image/")) {
  528. if (version !== requestVersion.current) return;
  529. setImageBlob(result.blob);
  530. return;
  531. }
  532. if (extension === "docx") {
  533. // 受控回退:仅当后端明确返回 DOCX 时使用 docx-preview
  534. if (mime !== DOCX_MIME) {
  535. throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { expected: DOCX_MIME, actual: mime }, requestId: result.requestId });
  536. }
  537. const buffer = await result.blob.arrayBuffer();
  538. if (version !== requestVersion.current || !docxContainer.current) return;
  539. docxContainer.current.replaceChildren();
  540. await renderAsync(buffer, docxContainer.current, undefined, {
  541. inWrapper: true,
  542. ignoreWidth: mode === "side",
  543. ignoreHeight: true,
  544. breakPages: true,
  545. useBase64URL: true,
  546. });
  547. if (version !== requestVersion.current || !docxContainer.current) return;
  548. sanitizeRenderedContent(docxContainer.current);
  549. // 测量自然尺寸供缩放占位层使用(此时无占位/缩放约束,scrollWidth/Height
  550. // 即内容真实布局尺寸;docxContainer 自身的 transform 不影响该读数)
  551. setDocxSize({
  552. w: docxContainer.current.scrollWidth,
  553. h: docxContainer.current.scrollHeight,
  554. });
  555. return;
  556. }
  557. throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { extension, actual: mime }, requestId: result.requestId });
  558. }).catch((value: unknown) => {
  559. if (version !== requestVersion.current) return;
  560. const httpError = previewError(value, responseRequestId);
  561. // 设密主案:弹窗获取方案密码,验证通过后带密码重试预览
  562. if (
  563. httpError.code === "DOCUMENT_PASSWORD_REQUIRED" ||
  564. httpError.code === "DOCUMENT_PASSWORD_INVALID"
  565. ) {
  566. void requestDocumentPassword(current.id, current.documentName, DocumentPasswordAction.VIEW)
  567. .then((password) => {
  568. if (version !== requestVersion.current) return;
  569. if (!password) {
  570. setError(httpError);
  571. return;
  572. }
  573. cachePassword(current.id, password);
  574. setRetryVersion((current) => current + 1);
  575. });
  576. return;
  577. }
  578. setError(httpError);
  579. }).finally(() => {
  580. if (version === requestVersion.current) setLoading(false);
  581. });
  582. return () => {
  583. requestVersion.current += 1;
  584. preview.release();
  585. if (docxContainer.current) docxContainer.current.replaceChildren();
  586. };
  587. }, [current.id, current.rowVersion, current.updatedAt, retryVersion, mode, canView]);
  588. const previewChildren = (
  589. <>
  590. {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
  591. {pdfBlob && (
  592. <PdfViewer
  593. blob={pdfBlob}
  594. documentName={current.documentName}
  595. onImageActivate={setLightboxSrc}
  596. searchKeyword={search.keyword}
  597. currentMatchIndex={search.currentIndex}
  598. onMatchCount={setPdfMatchCount}
  599. onFitRatio={applyFitRatio}
  600. zoom={contentZoom}
  601. />
  602. )}
  603. {imageUrl && (
  604. <ImageViewPane
  605. key={imageUrl}
  606. url={imageUrl}
  607. alt={current.documentName}
  608. onZoomActivate={setLightboxSrc}
  609. onFitRatio={applyFitRatio}
  610. zoom={contentZoom}
  611. />
  612. )}
  613. {/* docx 滚动容器:拖动平移与图片点击委托都挂在这层 */}
  614. <div
  615. ref={(node) => { docxPanRef.current = node; }}
  616. // 文档内图片点击 → 全屏图片浏览遮罩:docx-preview 动态渲染的 img 无法逐个绑定
  617. // prop,在容器上事件委托统一接管(PDF 为 canvas 渲染,无 img,天然不触发)。
  618. onClick={(event) => {
  619. const target = event.target;
  620. if (target instanceof HTMLImageElement && target.src) setLightboxSrc(target.src);
  621. }}
  622. className="h-full overflow-auto"
  623. >
  624. {/* 缩放占位层:transform 不参与布局,须显式按 自然尺寸×zoom 撑开滚动范围;
  625. 内层按自然尺寸渲染后 transform 放大(origin 左上,章节居中关系随占位层
  626. 等比保持)。未测量(渲染中/非 docx)时退化为普通流式容器。 */}
  627. <div style={docxSize
  628. ? { width: Math.round(docxSize.w * contentZoom), height: Math.round(docxSize.h * contentZoom), minHeight: "100%" }
  629. : { minHeight: "100%" }}
  630. >
  631. <div
  632. ref={setDocxContainer}
  633. className={`q3-docx-preview [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`}
  634. style={docxSize
  635. ? { width: docxSize.w, height: docxSize.h, transform: `scale(${contentZoom})`, transformOrigin: "0 0" }
  636. : undefined}
  637. />
  638. </div>
  639. </div>
  640. {/* 错误提示必须绝对定位铺满:上方 docx 滚动容器带 h-full,即使为空也占满
  641. 内容区,流式提示会被 overflow-hidden 裁掉而不可见(403 无权/415 不支持等同理) */}
  642. {error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center" style={{ color: "var(--dms-deep)" }}>
  643. <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
  644. <div className="text-base font-black leading-6">{errorText(error, current.fileExtension)}</div>
  645. {error.requestId && <div className="text-sm font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
  646. {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-base font-black border" style={{ borderColor: "var(--dms-bright)" }}>重试预览</button>}
  647. </div>}
  648. </>
  649. );
  650. const textPanel = (
  651. <aside ref={textPanelRef} className="w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.2)" }}>
  652. {/* 补充说明:具备编辑权限的可编辑保存,其他用户只读展示 */}
  653. <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }}>
  654. <div className="text-sm font-black mb-2" style={{ color: "var(--dms-deep)" }}>补充说明</div>
  655. {canEditDescription ? (
  656. <>
  657. <textarea
  658. value={descriptionDraft}
  659. onChange={(event) => setDescriptionDraft(event.target.value)}
  660. placeholder="输入文档补充说明…"
  661. className="w-full h-28 px-2.5 py-2 text-base font-bold leading-6 rounded-sm outline-none resize-none border"
  662. style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.3)", background: "rgba(var(--dms-line-rgb),.03)" }}
  663. />
  664. <div className="flex items-center gap-2 mt-2">
  665. <button
  666. type="button"
  667. onClick={() => void saveDescription()}
  668. disabled={savingDescription}
  669. className="px-3 py-1 text-base font-black text-white rounded-sm disabled:opacity-60"
  670. style={{ background: "var(--dms-bright)" }}
  671. >
  672. {savingDescription ? "保存中…" : "保存说明"}
  673. </button>
  674. <button
  675. type="button"
  676. onClick={() => { setDescriptionDraft(description ?? ""); setDescriptionMessage(""); }}
  677. disabled={savingDescription}
  678. className="px-3 py-1 text-base font-black border rounded-sm disabled:opacity-60"
  679. style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.35)" }}
  680. >
  681. 重置
  682. </button>
  683. {descriptionMessage && (
  684. <span className="text-base font-bold opacity-70 break-all" style={{ color: "var(--dms-deep)" }}>{descriptionMessage}</span>
  685. )}
  686. </div>
  687. </>
  688. ) : (
  689. <div className="max-h-56 overflow-y-auto text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
  690. {description?.trim() ? description : "暂无说明"}
  691. </div>
  692. )}
  693. </div>
  694. {/* 关联方案:modal 模式展示,点击后在本弹窗内跳转查看目标方案 */}
  695. {(relations.length > 0 || relationsError) && (
  696. <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }} data-testid="preview-relations">
  697. <div className="text-base font-black mb-2 flex items-center gap-1" style={{ color: "var(--dms-deep)" }}><Link2 size={13} />关联方案</div>
  698. {relationsError && <div className="text-base font-bold opacity-70 mb-1" style={{ color: "var(--dms-deep)" }}>{relationsError}</div>}
  699. <div className="space-y-1">
  700. {relations.map((relation) => (
  701. <button
  702. key={relation.relatedDocumentId}
  703. type="button"
  704. disabled={relationJumping}
  705. onClick={() => void jumpToRelation(relation)}
  706. className="block w-full text-left px-2.5 py-1.5 text-base font-bold rounded-sm transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] disabled:opacity-60"
  707. style={{ color: "var(--dms-deep)" }}
  708. {...tipProps("点击查看该方案")}
  709. >
  710. {relation.relationType} · {relation.relatedDocumentName}
  711. </button>
  712. ))}
  713. </div>
  714. </div>
  715. )}
  716. <div className="px-4 py-3 text-base font-black flex-shrink-0" style={{ color: "var(--dms-deep)", borderBottom: "1px solid rgba(var(--dms-line-rgb),.15)" }}>
  717. 提取文本
  718. </div>
  719. <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-2.5">
  720. {contentLoading && <div className="text-sm font-bold opacity-60">正在加载正文…</div>}
  721. {!contentLoading && paragraphs.length === 0 && (
  722. <div className="text-sm font-bold opacity-60">暂无正文</div>
  723. )}
  724. {search.paragraphMatches.map((info, index) => {
  725. const hasCurrent = info.globalIndices.includes(search.currentIndex);
  726. return (
  727. <div
  728. key={index}
  729. className={`rounded-sm px-2.5 py-2 ${hasCurrent ? "ring-1 ring-orange-400" : ""}`}
  730. style={{ background: "rgba(var(--dms-line-rgb),.04)" }}
  731. >
  732. <div className="text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
  733. {renderParagraphWithHighlights(info, search.currentIndex)}
  734. </div>
  735. <div className="mt-1.5 text-base font-black opacity-50" style={{ color: "var(--dms-deep)" }}>
  736. 第 {index + 1} 段 · 共 {info.text.length} 字{info.ranges.length > 0 && ` · 命中 ${info.ranges.length} 处`}
  737. </div>
  738. </div>
  739. );
  740. })}
  741. </div>
  742. </aside>
  743. );
  744. const panel = (
  745. <aside
  746. ref={!isModal ? setSideAside : undefined}
  747. aria-label={mode === "modal" ? "完整文档查看" : "文档右侧预览"}
  748. className={mode === "modal"
  749. ? windowState === "normal"
  750. ? "w-[min(1400px,95vw)] h-[90vh] flex flex-col bg-white shadow-2xl min-h-0"
  751. : "fixed inset-0 z-[80] flex flex-col bg-white min-h-0"
  752. : sideWidth == null
  753. ? "relative flex-1 min-w-[360px] flex flex-col border-l bg-white min-h-0"
  754. : "relative flex-shrink-0 flex flex-col border-l bg-white min-h-0"}
  755. style={{ borderColor: "rgba(var(--dms-line-rgb),.2)", ...(isModal ? null : { ...asideRestStyle, ...(sideWidth == null ? null : { width: sideWidth }) }) }}
  756. >
  757. {!isModal && (
  758. <div
  759. role="separator"
  760. aria-orientation="vertical"
  761. aria-label="拖动调整预览区宽度"
  762. {...tipProps("拖动调整预览区宽度")}
  763. className="absolute left-0 top-0 bottom-0 z-10 w-2.5 -ml-[5px] cursor-col-resize transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.35)]"
  764. onPointerDown={(event) => {
  765. if (event.button !== 0) return;
  766. event.preventDefault();
  767. const node = asideEl.current;
  768. if (!node) return;
  769. const width = node.offsetWidth;
  770. dragState.current = { active: true, x: event.clientX, width, latest: width };
  771. event.currentTarget.setPointerCapture(event.pointerId);
  772. if (guideEl.current) {
  773. guideEl.current.style.display = "block";
  774. guideEl.current.style.transform = `translateX(${event.clientX}px)`;
  775. }
  776. document.body.style.cursor = "col-resize";
  777. document.body.style.userSelect = "none";
  778. }}
  779. onPointerMove={(event) => {
  780. if (!dragState.current.active) return;
  781. event.preventDefault();
  782. const node = asideEl.current;
  783. if (!node) return;
  784. const { x, width } = dragState.current;
  785. const containerWidth = node.parentElement?.clientWidth ?? window.innerWidth;
  786. // 给左面板(~296px)与压缩后的中间列表至少保留 ~520px,
  787. // 挤压时列表名称/部门列以 truncate 收缩为"XXX…",不会被挤没。
  788. const max = Math.max(MIN_WIDTH, containerWidth - 520);
  789. // 绝对式计算:始终 = 拖动起始宽 + (起点X - 当前X) 总位移,
  790. // 不可把结果累加回 width——该公式已含总位移,累加会越拖越快。
  791. const next = Math.min(max, Math.max(MIN_WIDTH, width + (x - event.clientX)));
  792. dragState.current.latest = next;
  793. // 指示线停在钳制后的新边界,与松手实际生效宽度一致。
  794. if (guideEl.current) {
  795. guideEl.current.style.transform = `translateX(${x - (next - width)}px)`;
  796. }
  797. }}
  798. onPointerUp={handleDragEnd}
  799. onPointerCancel={handleDragEnd}
  800. />
  801. )}
  802. {!(isModal && windowState === "fullscreen") && (
  803. <div
  804. className={`px-4 py-3 flex gap-3 text-white ${isModal ? "items-center" : "items-start"}`}
  805. style={{ background: "var(--dms-deep)" }}
  806. onDoubleClick={isModal ? (event) => {
  807. if ((event.target as HTMLElement).closest("input, button")) return;
  808. setWindowState((current) => (current === "maximized" ? "normal" : "maximized"));
  809. } : undefined}
  810. >
  811. {isModal && navigated && (
  812. <button
  813. type="button"
  814. onClick={() => setNavigated(null)}
  815. aria-label="返回原方案"
  816. {...tipProps(`返回原方案:${item.documentName}`)}
  817. className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-base font-black border border-white/40 rounded-sm"
  818. >
  819. <ArrowLeft size={13} />返回
  820. </button>
  821. )}
  822. <div className="min-w-0 flex-1">
  823. <div className="text-base font-black truncate" {...tipProps(current.documentName)}>{mode === "modal" ? `文件查看 · ${current.documentName}` : current.documentName}</div>
  824. <div className="text-base font-bold opacity-70 mt-1">{current.fileExtension?.toUpperCase() || "无扩展名"} · {new Date(current.updatedAt).toLocaleString("zh-CN")}</div>
  825. </div>
  826. {isModal && (
  827. <div className="flex items-center gap-1.5 flex-shrink-0">
  828. <div className="relative">
  829. <Search size={12} className="absolute left-1.5 top-1/2 -translate-y-1/2 opacity-60" style={{ color: "var(--dms-deep)" }} />
  830. <input
  831. value={search.keyword}
  832. onChange={(e) => search.setKeyword(e.target.value)}
  833. onKeyDown={(e) => {
  834. if (e.key === "Enter") {
  835. e.preventDefault();
  836. if (e.shiftKey) search.gotoPrev();
  837. else search.gotoNext();
  838. } else if (e.key === "Escape") {
  839. e.preventDefault();
  840. search.setKeyword("");
  841. }
  842. }}
  843. placeholder="搜索正文"
  844. className="w-52 pl-6 pr-2 py-1 text-base rounded-sm bg-white/95 placeholder-black/45 text-black"
  845. />
  846. </div>
  847. <span className="text-base font-bold opacity-90 whitespace-nowrap min-w-[44px] text-center">
  848. {search.keyword.trim() ? `${search.totalCount === 0 ? 0 : search.currentIndex + 1} / ${search.totalCount}` : ""}
  849. </span>
  850. <button onClick={search.gotoPrev} disabled={search.totalCount === 0} aria-label="上一处命中" className="disabled:opacity-40"><ChevronUp size={16} /></button>
  851. <button onClick={search.gotoNext} disabled={search.totalCount === 0} aria-label="下一处命中" className="disabled:opacity-40"><ChevronDown size={16} /></button>
  852. </div>
  853. )}
  854. {isModal && (
  855. <button
  856. type="button"
  857. onClick={() => setWindowState((current) => (current === "maximized" ? "normal" : "maximized"))}
  858. aria-label={windowState === "maximized" ? "还原窗口" : "最大化窗口"}
  859. {...tipProps(windowState === "maximized" ? "还原窗口(或双击标题栏)" : "最大化窗口(或双击标题栏)")}
  860. className="flex-shrink-0">
  861. {windowState === "maximized" ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
  862. </button>
  863. )}
  864. {isModal && (
  865. <button type="button" onClick={() => setWindowState("fullscreen")} aria-label="全屏展示" {...tipProps("全屏展示(隐藏标题栏)")} className="flex-shrink-0">
  866. <Expand size={16} />
  867. </button>
  868. )}
  869. <button aria-label="关闭预览" onClick={onClose} className="flex-shrink-0"><X size={17} /></button>
  870. </div>
  871. )}
  872. {isModal && windowState === "fullscreen" && (
  873. <button
  874. type="button"
  875. onClick={() => setWindowState("normal")}
  876. className="absolute top-3 right-4 z-30 flex items-center gap-1.5 px-3 py-1.5 text-base font-black text-white rounded-sm shadow-lg"
  877. style={{ background: "rgba(var(--dms-deep-rgb),.85)" }}
  878. >
  879. <Shrink size={14} />退出全屏
  880. </button>
  881. )}
  882. <div className={`flex-1 min-h-0 overflow-hidden relative ${isModal ? "flex flex-row" : ""}`} style={{ background: "var(--dms-canvas)" }}>
  883. {isModal ? (
  884. /* 外层 flex 子项容器把内容区限制在文本面板左侧(textPanel 360px),
  885. absolute inset-0 相对本层定位 */
  886. <div className="flex-1 min-w-0 relative overflow-hidden">
  887. {/* 内容区:倍率经 contentZoom 传入各渲染面板放大真实布局尺寸,
  888. 滚动条随内容增长自然出现,标题栏等面板装饰不参与缩放。
  889. Ctrl+滚轮监听不挂本层——modal 模式统一挂在弹窗遮罩根
  890. (见下方 createPortal 分支),标题栏/提取文本面板/遮罩上的
  891. 滚轮同样缩放文档并阻止浏览器原生整页缩放。 */}
  892. <div className="absolute inset-0">
  893. {previewChildren}
  894. </div>
  895. </div>
  896. ) : (
  897. /* side 模式同款:外部 rightZoom 的倍率同样只传给内容渲染面板,
  898. 标题栏等面板装饰不随缩放(监听仍在 aside 上,事件自内容区冒泡) */
  899. <div className="absolute inset-0">
  900. {previewChildren}
  901. </div>
  902. )}
  903. {isModal && textPanel}
  904. </div>
  905. </aside>
  906. );
  907. if (mode === "modal") {
  908. // Portal 到 body:modal 弹窗必须脱离调用方布局树。共享资料库(F4AttachmentLibrary)
  909. // 渲染在中栏容器内——那里挂着区域缩放(CSS zoom + Ctrl+滚轮监听),弹窗留在其内
  910. // 会导致一次滚轮双命中两个监听器(文档与整个中栏一同缩放),且 CSS zoom 祖先会把
  911. // fixed 遮罩一并缩放。portal 到 body 后 wheel 冒泡链上只有 modalZoom,视觉不变。
  912. // modalZoom.ref 挂遮罩根:弹窗全域(标题栏/提取文本面板/遮罩)Ctrl+滚轮统一缩放
  913. // 文档并 preventDefault,与 side 模式(监听挂 aside 根)语义一致。
  914. return createPortal(
  915. <div ref={modalZoom.ref} className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.58)" }}><div>{panel}</div>{lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}</div>,
  916. document.body,
  917. );
  918. }
  919. // 指示线挂在 aside 外层,与面板同级统一渲染
  920. return (
  921. <>
  922. <div
  923. ref={guideEl}
  924. aria-hidden
  925. className="fixed left-0 top-0 bottom-0 z-[90] w-0.5 pointer-events-none"
  926. style={{ display: "none", background: "var(--dms-bright)", willChange: "transform" }}
  927. />
  928. {panel}
  929. {/* 图片浏览遮罩在内容区之外渲染:docx 缩放后内层带 transform,
  930. transform 祖先会成为 fixed 定位的包含块 */}
  931. {lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
  932. </>
  933. );
  934. }