| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171 |
- /**
- * BlockCanvas.tsx - 块画布组件
- *
- * 渲染所有的文档块,管理块的排序和交互
- * 支持在标题上方显示TOC占位符
- *
- * @module components/Editor
- */
- import React from 'react';
- import type { DocumentBlock } from '../../types/editor';
- import { BlockRenderer } from './BlockRenderer';
- import { TOCPlaceholder } from './blocks/TOCPlaceholder';
- import { getHeadingNumberMap } from '../../utils/headingNumbering';
- import { formatOrderedListMarker, getOrderedListNumberMap } from '../../utils/listNumbering';
- import './BlockCanvas.css';
- // ══════════════════════════════════════════════════════════════════════════════
- // Component Props
- // ══════════════════════════════════════════════════════════════════════════════
- export interface BlockCanvasProps {
- /** 文档块数组 */
- blocks: DocumentBlock[];
- /** 是否只读 */
- readOnly?: boolean;
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Component
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * BlockCanvas - 块画布
- *
- * 在第一个标题块上方显示TOC占位符(如果当前没有TOC块)
- * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
- */
- export const BlockCanvas = React.memo(function BlockCanvas({
- blocks,
- readOnly = false,
- }: BlockCanvasProps) {
- const [collapsedHeadingIds, setCollapsedHeadingIds] = React.useState<Set<string>>(new Set());
- const { sortedBlocks, hasTOC, firstHeadingIndex, headingNumbers, orderedListNumbers, collapsibleHeadingIds } = React.useMemo(() => {
- const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
- let tocFound = false;
- let headingIndex = -1;
- sorted.forEach((block, index) => {
- if (block.type === 'toc') tocFound = true;
- if (headingIndex === -1 && block.type === 'heading') {
- headingIndex = index;
- }
- });
- const collapsible = new Set<string>();
- sorted.forEach((block, index) => {
- if (block.type !== 'heading') return;
- const nextBlock = sorted[index + 1];
- if (
- nextBlock &&
- (nextBlock.type !== 'heading' || nextBlock.level > block.level)
- ) {
- collapsible.add(block.id);
- }
- });
- return {
- sortedBlocks: sorted,
- hasTOC: tocFound,
- headingNumbers: getHeadingNumberMap(sorted),
- firstHeadingIndex: headingIndex,
- orderedListNumbers: getOrderedListNumberMap(sorted),
- collapsibleHeadingIds: collapsible,
- };
- }, [blocks]);
- const visibleBlocks = React.useMemo(() => {
- const hiddenByLevel: number[] = [];
- return sortedBlocks.filter((block) => {
- if (block.type === 'heading') {
- while (hiddenByLevel.length > 0 && hiddenByLevel[hiddenByLevel.length - 1] >= block.level) {
- hiddenByLevel.pop();
- }
- const isHidden = hiddenByLevel.length > 0;
- if (collapsedHeadingIds.has(block.id)) hiddenByLevel.push(block.level);
- return !isHidden;
- }
- return hiddenByLevel.length === 0;
- });
- }, [sortedBlocks, collapsedHeadingIds]);
- const toggleHeadingCollapse = React.useCallback((headingId: string) => {
- setCollapsedHeadingIds((current) => {
- const next = new Set(current);
- if (next.has(headingId)) next.delete(headingId);
- else next.add(headingId);
- return next;
- });
- }, []);
- // 空状态
- if (visibleBlocks.length === 0) {
- return (
- <div className="block-canvas-empty">
- <p>文档为空,点击工具栏添加内容</p>
- </div>
- );
- }
- return (
- <div className="block-canvas" data-testid="block-canvas">
- {visibleBlocks.map((block, index) => {
- const prevBlock = index > 0 ? visibleBlocks[index - 1] : null;
-
- // 在标题上方显示TOC占位符的条件:
- // 1. 当前文档没有TOC块
- // 2. 当前块是标题块
- // 3. 不是只读模式
- const showPlaceholderBefore =
- !hasTOC && index === firstHeadingIndex && !readOnly;
-
- return (
- <React.Fragment key={block.id}>
- {/* 在标题上方显示TOC占位符 */}
- {showPlaceholderBefore && (
- <TOCPlaceholder
- afterBlockId={prevBlock?.id}
- readOnly={readOnly}
- />
- )}
-
- {/* 渲染当前块 */}
- <BlockRenderer
- block={block}
- index={index}
- blockCount={visibleBlocks.length}
- listMarker={block.type === 'heading'
- ? block.metadata.list_type === 'ordered'
- ? `${headingNumbers.get(block.id) ?? '1'}.`
- : block.metadata.list_type === 'unordered'
- ? '•'
- : undefined
- : block.type === 'paragraph'
- ? block.metadata.list_type === 'ordered'
- ? formatOrderedListMarker(
- orderedListNumbers.get(block.id) ?? 1,
- block.metadata.list_level ?? 0,
- )
- : block.metadata.list_type === 'unordered'
- ? '•'
- : undefined
- : undefined
- }
- collapsible={block.type === 'heading' && collapsibleHeadingIds.has(block.id)}
- collapsed={block.type === 'heading' && collapsedHeadingIds.has(block.id)}
- onToggleCollapse={block.type === 'heading'
- ? toggleHeadingCollapse
- : undefined}
- readOnly={readOnly}
- />
- </React.Fragment>
- );
- })}
- </div>
- );
- });
- export default BlockCanvas;
|