/** * 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 * setActiveId(id)} * /> * ``` */ export const DocumentViewer: React.FC = ({ content, activeId, onActiveChange, }) => { const containerRef = useRef(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 ( {block.sectionNumber && {block.sectionNumber}} {block.text} ); } case 'paragraph': { return (

{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 ( {run.text} ); })}

); } 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 = { key: `row-${rowIndex}` }; row.cells.forEach((cell, cellIndex) => { rowData[`col${cellIndex}`] = cell.text; }); return rowData; }); return (
{/* 内联样式 - 移除竖线 */}
); } default: return null; } }; // Handle empty content if (!content || content.length === 0) { return (

文档内容为空

); } return (
{content.map((block) => renderBlock(block))}
); }; export default DocumentViewer;