| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- /**
- * DocumentViewer Component
- *
- * Renders structured document content with:
- * - Headings with proper hierarchy
- * - Paragraphs with text formatting
- * - Tables with borders and styling
- * - Scroll spy for active section tracking
- *
- * @module components/DocumentViewer
- */
- import React, { useEffect, useRef } from 'react';
- import { Table } from 'antd';
- import type { ContentBlock } from '../../services/documentContentService';
- // ── Styles ────────────────────────────────────────────────────────────────
- const containerStyle: React.CSSProperties = {
- height: '100%',
- overflow: 'auto',
- padding: '32px 48px',
- backgroundColor: '#ffffff',
- };
- const contentStyle: React.CSSProperties = {
- maxWidth: '900px',
- margin: '0 auto',
- fontSize: '14px',
- lineHeight: '1.8',
- color: '#262626',
- };
- const headingBaseStyle: React.CSSProperties = {
- fontWeight: 600,
- color: '#262626',
- marginTop: '32px',
- marginBottom: '16px',
- scrollMarginTop: '80px', // For smooth scroll with fixed headers
- };
- const paragraphStyle: React.CSSProperties = {
- marginBottom: '16px',
- textAlign: 'justify',
- lineHeight: '1.8',
- };
- const sectionNumberStyle: React.CSSProperties = {
- marginRight: '12px',
- color: '#8c8c8c',
- fontWeight: 'normal',
- };
- // ── Component Props ─────────────────────────────────────────────────────────
- export interface DocumentViewerProps {
- /** Content blocks to render */
- content: ContentBlock[];
- /** Currently active section ID */
- activeId?: string;
- /** Callback when active section changes (scroll spy) */
- onActiveChange?: (id: string) => void;
- }
- // ── Component ────────────────────────────────────────────────────────────────
- /**
- * DocumentViewer
- *
- * Displays structured document content with headings, paragraphs, and tables.
- * Supports scroll-based navigation and active section tracking.
- *
- * @example
- * ```tsx
- * <DocumentViewer
- * content={documentContent}
- * activeId="heading-5"
- * onActiveChange={(id) => setActiveId(id)}
- * />
- * ```
- */
- export const DocumentViewer: React.FC<DocumentViewerProps> = ({
- content,
- activeId,
- onActiveChange,
- }) => {
- const containerRef = useRef<HTMLDivElement>(null);
- /**
- * Scroll to active element when activeId changes
- */
- useEffect(() => {
- if (activeId && containerRef.current) {
- const element = containerRef.current.querySelector(`#${activeId}`);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- }
- }, [activeId]);
- /**
- * Setup scroll spy to track active section
- */
- useEffect(() => {
- if (!onActiveChange || !containerRef.current) return;
- const container = containerRef.current;
- const headings = container.querySelectorAll('[data-heading-id]');
- const observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- const id = entry.target.getAttribute('data-heading-id');
- if (id) {
- onActiveChange(id);
- }
- }
- });
- },
- {
- root: container,
- rootMargin: '-80px 0px -80% 0px',
- threshold: 0,
- }
- );
- headings.forEach((heading) => observer.observe(heading));
- return () => {
- observer.disconnect();
- };
- }, [content, onActiveChange]);
- /**
- * Render a single content block
- */
- const renderBlock = (block: ContentBlock) => {
- switch (block.type) {
- case 'heading': {
- const headingStyle: React.CSSProperties = {
- ...headingBaseStyle,
- fontSize: block.level === 1 ? '28px' : block.level === 2 ? '24px' : block.level === 3 ? '20px' : '16px',
- borderBottom: block.level === 1 ? '1px solid #e8e8e8' : undefined,
- paddingBottom: block.level === 1 ? '8px' : undefined,
- };
- const HeadingTag = `h${block.level}` as keyof JSX.IntrinsicElements;
- return (
- <HeadingTag
- key={block.id}
- id={block.id}
- data-heading-id={block.id}
- style={headingStyle}
- >
- {block.sectionNumber && <span style={sectionNumberStyle}>{block.sectionNumber}</span>}
- {block.text}
- </HeadingTag>
- );
- }
- case 'paragraph': {
- return (
- <p key={block.id} id={block.id} style={paragraphStyle}>
- {block.runs.map((run, runIndex) => {
- const runStyle: React.CSSProperties = {
- fontWeight: run.bold ? 600 : undefined,
- fontStyle: run.italic ? 'italic' : undefined,
- textDecoration: run.underline ? 'underline' : undefined,
- fontFamily: run.fontName,
- fontSize: run.fontSize ? `${run.fontSize}px` : undefined,
- color: run.color,
- };
- return (
- <span key={runIndex} style={runStyle}>
- {run.text}
- </span>
- );
- })}
- </p>
- );
- }
- case 'table': {
- // Convert table data to Ant Design Table format
- const columns = block.rows[0]?.cells.map((cell, cellIndex) => ({
- title: block.hasHeader ? cell.text : `列 ${cellIndex + 1}`,
- dataIndex: `col${cellIndex}`,
- key: `col${cellIndex}`,
- render: (text: string) => text || '-',
- })) || [];
- const dataSource = (block.hasHeader ? block.rows.slice(1) : block.rows).map((row, rowIndex) => {
- const rowData: Record<string, string> = { key: `row-${rowIndex}` };
- row.cells.forEach((cell, cellIndex) => {
- rowData[`col${cellIndex}`] = cell.text;
- });
- return rowData;
- });
- return (
- <div key={block.id} id={block.id} style={{ marginBottom: '24px' }}>
- {/* 内联样式 - 移除竖线 */}
- <style>{`
- /* 移除表格竖线 - 只保留横线 */
- .document-viewer-table .ant-table-thead > tr > th,
- .document-viewer-table .ant-table-tbody > tr > td {
- border-right: none !important;
- border-left: none !important;
- }
-
- /* 移除表头分隔线 */
- .document-viewer-table .ant-table-thead > tr > th::before {
- display: none !important;
- }
-
- /* 只保留横线 */
- .document-viewer-table .ant-table-container {
- border-left: none !important;
- border-right: none !important;
- }
-
- /* 表格整体样式 */
- .document-viewer-table .ant-table {
- border-left: none !important;
- border-right: none !important;
- }
- `}</style>
- <div className="document-viewer-table">
- <Table
- columns={columns}
- dataSource={dataSource}
- pagination={false}
- bordered={false}
- size="small"
- style={{ fontSize: '13px' }}
- />
- </div>
- </div>
- );
- }
- default:
- return null;
- }
- };
- // Handle empty content
- if (!content || content.length === 0) {
- return (
- <div style={containerStyle} ref={containerRef}>
- <div style={{ ...contentStyle, textAlign: 'center', color: '#8c8c8c', padding: '40px 0' }}>
- <p>文档内容为空</p>
- </div>
- </div>
- );
- }
- return (
- <div style={containerStyle} ref={containerRef} data-testid="document-viewer">
- <div style={contentStyle}>
- {content.map((block) => renderBlock(block))}
- </div>
- </div>
- );
- };
- export default DocumentViewer;
|