DocumentViewer.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /**
  2. * DocumentViewer Component
  3. *
  4. * Renders structured document content with:
  5. * - Headings with proper hierarchy
  6. * - Paragraphs with text formatting
  7. * - Tables with borders and styling
  8. * - Scroll spy for active section tracking
  9. *
  10. * @module components/DocumentViewer
  11. */
  12. import React, { useEffect, useRef } from 'react';
  13. import { Table } from 'antd';
  14. import type { ContentBlock } from '../../services/documentContentService';
  15. // ── Styles ────────────────────────────────────────────────────────────────
  16. const containerStyle: React.CSSProperties = {
  17. height: '100%',
  18. overflow: 'auto',
  19. padding: '32px 48px',
  20. backgroundColor: '#ffffff',
  21. };
  22. const contentStyle: React.CSSProperties = {
  23. maxWidth: '900px',
  24. margin: '0 auto',
  25. fontSize: '14px',
  26. lineHeight: '1.8',
  27. color: '#262626',
  28. };
  29. const headingBaseStyle: React.CSSProperties = {
  30. fontWeight: 600,
  31. color: '#262626',
  32. marginTop: '32px',
  33. marginBottom: '16px',
  34. scrollMarginTop: '80px', // For smooth scroll with fixed headers
  35. };
  36. const paragraphStyle: React.CSSProperties = {
  37. marginBottom: '16px',
  38. textAlign: 'justify',
  39. lineHeight: '1.8',
  40. };
  41. const sectionNumberStyle: React.CSSProperties = {
  42. marginRight: '12px',
  43. color: '#8c8c8c',
  44. fontWeight: 'normal',
  45. };
  46. // ── Component Props ─────────────────────────────────────────────────────────
  47. export interface DocumentViewerProps {
  48. /** Content blocks to render */
  49. content: ContentBlock[];
  50. /** Currently active section ID */
  51. activeId?: string;
  52. /** Callback when active section changes (scroll spy) */
  53. onActiveChange?: (id: string) => void;
  54. }
  55. // ── Component ────────────────────────────────────────────────────────────────
  56. /**
  57. * DocumentViewer
  58. *
  59. * Displays structured document content with headings, paragraphs, and tables.
  60. * Supports scroll-based navigation and active section tracking.
  61. *
  62. * @example
  63. * ```tsx
  64. * <DocumentViewer
  65. * content={documentContent}
  66. * activeId="heading-5"
  67. * onActiveChange={(id) => setActiveId(id)}
  68. * />
  69. * ```
  70. */
  71. export const DocumentViewer: React.FC<DocumentViewerProps> = ({
  72. content,
  73. activeId,
  74. onActiveChange,
  75. }) => {
  76. const containerRef = useRef<HTMLDivElement>(null);
  77. /**
  78. * Scroll to active element when activeId changes
  79. */
  80. useEffect(() => {
  81. if (activeId && containerRef.current) {
  82. const element = containerRef.current.querySelector(`#${activeId}`);
  83. if (element) {
  84. element.scrollIntoView({ behavior: 'smooth', block: 'start' });
  85. }
  86. }
  87. }, [activeId]);
  88. /**
  89. * Setup scroll spy to track active section
  90. */
  91. useEffect(() => {
  92. if (!onActiveChange || !containerRef.current) return;
  93. const container = containerRef.current;
  94. const headings = container.querySelectorAll('[data-heading-id]');
  95. const observer = new IntersectionObserver(
  96. (entries) => {
  97. entries.forEach((entry) => {
  98. if (entry.isIntersecting) {
  99. const id = entry.target.getAttribute('data-heading-id');
  100. if (id) {
  101. onActiveChange(id);
  102. }
  103. }
  104. });
  105. },
  106. {
  107. root: container,
  108. rootMargin: '-80px 0px -80% 0px',
  109. threshold: 0,
  110. }
  111. );
  112. headings.forEach((heading) => observer.observe(heading));
  113. return () => {
  114. observer.disconnect();
  115. };
  116. }, [content, onActiveChange]);
  117. /**
  118. * Render a single content block
  119. */
  120. const renderBlock = (block: ContentBlock) => {
  121. switch (block.type) {
  122. case 'heading': {
  123. const headingStyle: React.CSSProperties = {
  124. ...headingBaseStyle,
  125. fontSize: block.level === 1 ? '28px' : block.level === 2 ? '24px' : block.level === 3 ? '20px' : '16px',
  126. borderBottom: block.level === 1 ? '1px solid #e8e8e8' : undefined,
  127. paddingBottom: block.level === 1 ? '8px' : undefined,
  128. };
  129. const HeadingTag = `h${block.level}` as keyof JSX.IntrinsicElements;
  130. return (
  131. <HeadingTag
  132. key={block.id}
  133. id={block.id}
  134. data-heading-id={block.id}
  135. style={headingStyle}
  136. >
  137. {block.sectionNumber && <span style={sectionNumberStyle}>{block.sectionNumber}</span>}
  138. {block.text}
  139. </HeadingTag>
  140. );
  141. }
  142. case 'paragraph': {
  143. return (
  144. <p key={block.id} id={block.id} style={paragraphStyle}>
  145. {block.runs.map((run, runIndex) => {
  146. const runStyle: React.CSSProperties = {
  147. fontWeight: run.bold ? 600 : undefined,
  148. fontStyle: run.italic ? 'italic' : undefined,
  149. textDecoration: run.underline ? 'underline' : undefined,
  150. fontFamily: run.fontName,
  151. fontSize: run.fontSize ? `${run.fontSize}px` : undefined,
  152. color: run.color,
  153. };
  154. return (
  155. <span key={runIndex} style={runStyle}>
  156. {run.text}
  157. </span>
  158. );
  159. })}
  160. </p>
  161. );
  162. }
  163. case 'table': {
  164. // Convert table data to Ant Design Table format
  165. const columns = block.rows[0]?.cells.map((cell, cellIndex) => ({
  166. title: block.hasHeader ? cell.text : `列 ${cellIndex + 1}`,
  167. dataIndex: `col${cellIndex}`,
  168. key: `col${cellIndex}`,
  169. render: (text: string) => text || '-',
  170. })) || [];
  171. const dataSource = (block.hasHeader ? block.rows.slice(1) : block.rows).map((row, rowIndex) => {
  172. const rowData: Record<string, string> = { key: `row-${rowIndex}` };
  173. row.cells.forEach((cell, cellIndex) => {
  174. rowData[`col${cellIndex}`] = cell.text;
  175. });
  176. return rowData;
  177. });
  178. return (
  179. <div key={block.id} id={block.id} style={{ marginBottom: '24px' }}>
  180. {/* 内联样式 - 移除竖线 */}
  181. <style>{`
  182. /* 移除表格竖线 - 只保留横线 */
  183. .document-viewer-table .ant-table-thead > tr > th,
  184. .document-viewer-table .ant-table-tbody > tr > td {
  185. border-right: none !important;
  186. border-left: none !important;
  187. }
  188. /* 移除表头分隔线 */
  189. .document-viewer-table .ant-table-thead > tr > th::before {
  190. display: none !important;
  191. }
  192. /* 只保留横线 */
  193. .document-viewer-table .ant-table-container {
  194. border-left: none !important;
  195. border-right: none !important;
  196. }
  197. /* 表格整体样式 */
  198. .document-viewer-table .ant-table {
  199. border-left: none !important;
  200. border-right: none !important;
  201. }
  202. `}</style>
  203. <div className="document-viewer-table">
  204. <Table
  205. columns={columns}
  206. dataSource={dataSource}
  207. pagination={false}
  208. bordered={false}
  209. size="small"
  210. style={{ fontSize: '13px' }}
  211. />
  212. </div>
  213. </div>
  214. );
  215. }
  216. default:
  217. return null;
  218. }
  219. };
  220. // Handle empty content
  221. if (!content || content.length === 0) {
  222. return (
  223. <div style={containerStyle} ref={containerRef}>
  224. <div style={{ ...contentStyle, textAlign: 'center', color: '#8c8c8c', padding: '40px 0' }}>
  225. <p>文档内容为空</p>
  226. </div>
  227. </div>
  228. );
  229. }
  230. return (
  231. <div style={containerStyle} ref={containerRef} data-testid="document-viewer">
  232. <div style={contentStyle}>
  233. {content.map((block) => renderBlock(block))}
  234. </div>
  235. </div>
  236. );
  237. };
  238. export default DocumentViewer;