BlockCanvas.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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. for (let nextIndex = index + 1; nextIndex < sorted.length; nextIndex += 1) {
  53. const nextBlock = sorted[nextIndex];
  54. if (nextBlock.type === 'heading' && nextBlock.level <= block.level) break;
  55. collapsible.add(block.id);
  56. break;
  57. }
  58. });
  59. return {
  60. sortedBlocks: sorted,
  61. hasTOC: tocFound,
  62. headingNumbers: getHeadingNumberMap(sorted),
  63. firstHeadingIndex: headingIndex,
  64. orderedListNumbers: getOrderedListNumberMap(sorted),
  65. collapsibleHeadingIds: collapsible,
  66. };
  67. }, [blocks]);
  68. const visibleBlocks = React.useMemo(() => {
  69. const hiddenByLevel: number[] = [];
  70. return sortedBlocks.filter((block) => {
  71. if (block.type === 'heading') {
  72. while (hiddenByLevel.length > 0 && hiddenByLevel[hiddenByLevel.length - 1] >= block.level) {
  73. hiddenByLevel.pop();
  74. }
  75. const isHidden = hiddenByLevel.length > 0;
  76. if (collapsedHeadingIds.has(block.id)) hiddenByLevel.push(block.level);
  77. return !isHidden;
  78. }
  79. return hiddenByLevel.length === 0;
  80. });
  81. }, [sortedBlocks, collapsedHeadingIds]);
  82. const toggleHeadingCollapse = React.useCallback((headingId: string) => {
  83. setCollapsedHeadingIds((current) => {
  84. const next = new Set(current);
  85. if (next.has(headingId)) next.delete(headingId);
  86. else next.add(headingId);
  87. return next;
  88. });
  89. }, []);
  90. // 空状态
  91. if (visibleBlocks.length === 0) {
  92. return (
  93. <div className="block-canvas-empty">
  94. <p>文档为空,点击工具栏添加内容</p>
  95. </div>
  96. );
  97. }
  98. return (
  99. <div className="block-canvas" data-testid="block-canvas">
  100. {visibleBlocks.map((block, index) => {
  101. const prevBlock = index > 0 ? visibleBlocks[index - 1] : null;
  102. // 在标题上方显示TOC占位符的条件:
  103. // 1. 当前文档没有TOC块
  104. // 2. 当前块是标题块
  105. // 3. 不是只读模式
  106. const showPlaceholderBefore =
  107. !hasTOC && index === firstHeadingIndex && !readOnly;
  108. return (
  109. <React.Fragment key={block.id}>
  110. {/* 在标题上方显示TOC占位符 */}
  111. {showPlaceholderBefore && (
  112. <TOCPlaceholder
  113. afterBlockId={prevBlock?.id}
  114. readOnly={readOnly}
  115. />
  116. )}
  117. {/* 渲染当前块 */}
  118. <BlockRenderer
  119. block={block}
  120. index={index}
  121. blockCount={visibleBlocks.length}
  122. listMarker={block.type === 'heading'
  123. ? block.metadata.list_type === 'ordered'
  124. ? `${headingNumbers.get(block.id) ?? '1'}.`
  125. : block.metadata.list_type === 'unordered'
  126. ? '•'
  127. : undefined
  128. : block.type === 'paragraph'
  129. ? block.metadata.list_type === 'ordered'
  130. ? formatOrderedListMarker(
  131. orderedListNumbers.get(block.id) ?? 1,
  132. block.metadata.list_level ?? 0,
  133. )
  134. : block.metadata.list_type === 'unordered'
  135. ? '•'
  136. : undefined
  137. : undefined
  138. }
  139. collapsible={block.type === 'heading' && collapsibleHeadingIds.has(block.id)}
  140. collapsed={block.type === 'heading' && collapsedHeadingIds.has(block.id)}
  141. onToggleCollapse={block.type === 'heading'
  142. ? toggleHeadingCollapse
  143. : undefined}
  144. readOnly={readOnly}
  145. />
  146. </React.Fragment>
  147. );
  148. })}
  149. </div>
  150. );
  151. });
  152. export default BlockCanvas;