BlockCanvas.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. /**
  2. * BlockCanvas.tsx - 块画布组件
  3. *
  4. * 渲染所有的文档块,管理块的排序和交互
  5. * 支持在标题上方显示TOC占位符
  6. *
  7. * @module components/Editor
  8. */
  9. import React from 'react';
  10. import type { DocumentBlock } from '../../types/editor';
  11. import { BlockRenderer } from './BlockRenderer';
  12. import { TOCPlaceholder } from './blocks/TOCPlaceholder';
  13. import { getHeadingNumberMap } from '../../utils/headingNumbering';
  14. import { formatOrderedListMarker, getOrderedListNumberMap } from '../../utils/listNumbering';
  15. import './BlockCanvas.css';
  16. // ══════════════════════════════════════════════════════════════════════════════
  17. // Component Props
  18. // ══════════════════════════════════════════════════════════════════════════════
  19. export interface BlockCanvasProps {
  20. /** 文档块数组 */
  21. blocks: DocumentBlock[];
  22. /** 是否只读 */
  23. readOnly?: boolean;
  24. }
  25. // ══════════════════════════════════════════════════════════════════════════════
  26. // Component
  27. // ══════════════════════════════════════════════════════════════════════════════
  28. /**
  29. * BlockCanvas - 块画布
  30. *
  31. * 在第一个标题块上方显示TOC占位符(如果当前没有TOC块)
  32. * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
  33. */
  34. export const BlockCanvas = React.memo(function BlockCanvas({
  35. blocks,
  36. readOnly = false,
  37. }: BlockCanvasProps) {
  38. const [collapsedHeadingIds, setCollapsedHeadingIds] = React.useState<Set<string>>(new Set());
  39. const { sortedBlocks, hasTOC, firstHeadingIndex, headingNumbers, orderedListNumbers, collapsibleHeadingIds } = React.useMemo(() => {
  40. const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
  41. let tocFound = false;
  42. let headingIndex = -1;
  43. sorted.forEach((block, index) => {
  44. if (block.type === 'toc') tocFound = true;
  45. if (headingIndex === -1 && block.type === 'heading') {
  46. headingIndex = index;
  47. }
  48. });
  49. const collapsible = new Set<string>();
  50. sorted.forEach((block, index) => {
  51. if (block.type !== 'heading') return;
  52. const nextBlock = sorted[index + 1];
  53. if (
  54. nextBlock &&
  55. (nextBlock.type !== 'heading' || nextBlock.level > block.level)
  56. ) {
  57. collapsible.add(block.id);
  58. }
  59. });
  60. return {
  61. sortedBlocks: sorted,
  62. hasTOC: tocFound,
  63. headingNumbers: getHeadingNumberMap(sorted),
  64. firstHeadingIndex: headingIndex,
  65. orderedListNumbers: getOrderedListNumberMap(sorted),
  66. collapsibleHeadingIds: collapsible,
  67. };
  68. }, [blocks]);
  69. const visibleBlocks = React.useMemo(() => {
  70. const hiddenByLevel: number[] = [];
  71. return sortedBlocks.filter((block) => {
  72. if (block.type === 'heading') {
  73. while (hiddenByLevel.length > 0 && hiddenByLevel[hiddenByLevel.length - 1] >= block.level) {
  74. hiddenByLevel.pop();
  75. }
  76. const isHidden = hiddenByLevel.length > 0;
  77. if (collapsedHeadingIds.has(block.id)) hiddenByLevel.push(block.level);
  78. return !isHidden;
  79. }
  80. return hiddenByLevel.length === 0;
  81. });
  82. }, [sortedBlocks, collapsedHeadingIds]);
  83. const toggleHeadingCollapse = React.useCallback((headingId: string) => {
  84. setCollapsedHeadingIds((current) => {
  85. const next = new Set(current);
  86. if (next.has(headingId)) next.delete(headingId);
  87. else next.add(headingId);
  88. return next;
  89. });
  90. }, []);
  91. // 空状态
  92. if (visibleBlocks.length === 0) {
  93. return (
  94. <div className="block-canvas-empty">
  95. <p>文档为空,点击工具栏添加内容</p>
  96. </div>
  97. );
  98. }
  99. return (
  100. <div className="block-canvas" data-testid="block-canvas">
  101. {visibleBlocks.map((block, index) => {
  102. const prevBlock = index > 0 ? visibleBlocks[index - 1] : null;
  103. // 在标题上方显示TOC占位符的条件:
  104. // 1. 当前文档没有TOC块
  105. // 2. 当前块是标题块
  106. // 3. 不是只读模式
  107. const showPlaceholderBefore =
  108. !hasTOC && index === firstHeadingIndex && !readOnly;
  109. return (
  110. <React.Fragment key={block.id}>
  111. {/* 在标题上方显示TOC占位符 */}
  112. {showPlaceholderBefore && (
  113. <TOCPlaceholder
  114. afterBlockId={prevBlock?.id}
  115. readOnly={readOnly}
  116. />
  117. )}
  118. {/* 渲染当前块 */}
  119. <BlockRenderer
  120. block={block}
  121. index={index}
  122. blockCount={visibleBlocks.length}
  123. listMarker={block.type === 'heading'
  124. ? block.metadata.list_type === 'ordered'
  125. ? `${headingNumbers.get(block.id) ?? '1'}.`
  126. : block.metadata.list_type === 'unordered'
  127. ? '•'
  128. : undefined
  129. : block.type === 'paragraph'
  130. ? block.metadata.list_type === 'ordered'
  131. ? formatOrderedListMarker(
  132. orderedListNumbers.get(block.id) ?? 1,
  133. block.metadata.list_level ?? 0,
  134. )
  135. : block.metadata.list_type === 'unordered'
  136. ? '•'
  137. : undefined
  138. : undefined
  139. }
  140. collapsible={block.type === 'heading' && collapsibleHeadingIds.has(block.id)}
  141. collapsed={block.type === 'heading' && collapsedHeadingIds.has(block.id)}
  142. onToggleCollapse={block.type === 'heading'
  143. ? toggleHeadingCollapse
  144. : undefined}
  145. readOnly={readOnly}
  146. />
  147. </React.Fragment>
  148. );
  149. })}
  150. </div>
  151. );
  152. });
  153. export default BlockCanvas;