HeadingBlock.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. /**
  2. * HeadingBlock.tsx - 标题块组件
  3. *
  4. * @module components/Editor/blocks
  5. */
  6. import React, { useCallback } from 'react';
  7. import { message } from 'antd';
  8. import type { HeadingBlock as HeadingBlockType, RichText } from '../../../types/editor';
  9. import { useEditorStore } from '../../../stores/editorStore';
  10. import { RichTextEditor } from '../RichTextEditor';
  11. import { resolveBlockStyle } from '../../../utils/styleResolver';
  12. import { BlockMenu } from './BlockMenu';
  13. import './HeadingBlock.css';
  14. export interface HeadingBlockProps {
  15. block: HeadingBlockType;
  16. readOnly?: boolean;
  17. }
  18. /**
  19. * HeadingBlock - 标题块(带富文本编辑)
  20. */
  21. export const HeadingBlock: React.FC<HeadingBlockProps> = ({
  22. block,
  23. readOnly,
  24. }) => {
  25. const updateBlock = useEditorStore((state) => state.updateBlock);
  26. const saveBlock = useEditorStore((state) => state.saveBlock);
  27. const deleteBlock = useEditorStore((state) => state.deleteBlock);
  28. const addBlock = useEditorStore((state) => state.addBlock);
  29. const blocks = useEditorStore((state) => state.blocks);
  30. // 解析样式
  31. const blockStyle = resolveBlockStyle(block.word_style, block.style);
  32. // 添加对齐样式
  33. if (block.style?.align) {
  34. blockStyle.textAlign = block.style.align;
  35. }
  36. // 查找当前块的位置
  37. const currentIndex = blocks.findIndex((b) => b.id === block.id);
  38. const isFirst = currentIndex === 0;
  39. const isLast = currentIndex === blocks.length - 1;
  40. // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
  41. const handleChange = useCallback(
  42. (newContent: RichText[]) => {
  43. updateBlock(block.id, { content: newContent });
  44. },
  45. [block.id, updateBlock]
  46. );
  47. // 处理对齐方式变更
  48. const handleAlignChange = useCallback(
  49. (align: 'left' | 'center' | 'right' | 'justify') => {
  50. updateBlock(block.id, {
  51. style: {
  52. ...block.style,
  53. align
  54. }
  55. });
  56. },
  57. [block.id, block.style, updateBlock]
  58. );
  59. const handleContentFormatChange = useCallback(async (
  60. format: 'paragraph' | 'ordered-list' | `heading-${1 | 2 | 3 | 4 | 5 | 6}`
  61. ) => {
  62. const isParagraph = format === 'paragraph' || format === 'ordered-list';
  63. const isOrderedList = format === 'ordered-list';
  64. const level = isParagraph
  65. ? 0
  66. : Number(format.split('-')[1]) as 1 | 2 | 3 | 4 | 5 | 6;
  67. const style = block.style.align ? { align: block.style.align } : {};
  68. updateBlock(block.id, isParagraph
  69. ? {
  70. type: 'paragraph',
  71. level: 0,
  72. word_style: 'Normal',
  73. style,
  74. metadata: {
  75. parent_heading_id: block.metadata.parent_id ?? null,
  76. ...(isOrderedList ? { list_type: 'ordered' } : {}),
  77. },
  78. }
  79. : {
  80. level,
  81. word_style: `Heading ${level}`,
  82. style,
  83. }
  84. );
  85. try {
  86. await saveBlock(block.id);
  87. message.success(
  88. isOrderedList ? '已设为有序列表' : isParagraph ? '已设为正文' : `已设为${level}级标题`
  89. );
  90. } catch (error) {
  91. message.error(error instanceof Error ? error.message : '标题格式更新失败');
  92. }
  93. }, [block.id, block.metadata.parent_id, block.style.align, saveBlock, updateBlock]);
  94. // ── 块操作回调 ──────────────────────────────────────────────────────────
  95. const handleDelete = useCallback(async () => {
  96. try {
  97. await deleteBlock(block.id);
  98. message.success('已删除标题');
  99. } catch (error) {
  100. // 错误已经在deleteBlock中处理了
  101. }
  102. }, [block.id, deleteBlock]);
  103. const handleDuplicate = useCallback(() => {
  104. addBlock(
  105. {
  106. type: 'heading',
  107. level: block.level,
  108. content: block.content,
  109. word_style: block.word_style,
  110. style: block.style,
  111. metadata: block.metadata,
  112. },
  113. block.id // 在当前块后插入
  114. );
  115. message.success('已复制标题');
  116. }, [block, addBlock]);
  117. const handleMoveUp = useCallback(() => {
  118. if (currentIndex > 0) {
  119. const prevBlock = blocks[currentIndex - 1];
  120. const prevOrder = currentIndex > 1 ? blocks[currentIndex - 2].block_order : -100;
  121. const newOrder = Math.floor((prevOrder + prevBlock.block_order) / 2);
  122. updateBlock(block.id, { block_order: newOrder });
  123. message.success('已上移');
  124. }
  125. }, [currentIndex, blocks, block.id, updateBlock]);
  126. const handleMoveDown = useCallback(() => {
  127. if (currentIndex < blocks.length - 1) {
  128. const nextBlock = blocks[currentIndex + 1];
  129. const nextNextOrder = currentIndex < blocks.length - 2 ? blocks[currentIndex + 2].block_order : nextBlock.block_order + 200;
  130. const newOrder = Math.floor((nextBlock.block_order + nextNextOrder) / 2);
  131. updateBlock(block.id, { block_order: newOrder });
  132. message.success('已下移');
  133. }
  134. }, [currentIndex, blocks, block.id, updateBlock]);
  135. // ── 插入操作回调 ────────────────────────────────────────────────────────
  136. const handleInsertHeading = useCallback((level: 1 | 2 | 3 | 4 | 5 | 6) => {
  137. addBlock(
  138. {
  139. type: 'heading' as const,
  140. level,
  141. content: '',
  142. word_style: `Heading ${level}`,
  143. style: {},
  144. metadata: {
  145. parent_id: null,
  146. },
  147. },
  148. block.id // 在当前块后插入
  149. );
  150. message.success(`已插入${level}级标题`);
  151. }, [block.id, addBlock]);
  152. const handleInsertParagraph = useCallback(() => {
  153. addBlock(
  154. {
  155. type: 'paragraph' as const,
  156. level: 0,
  157. content: '',
  158. word_style: 'Normal',
  159. style: {},
  160. metadata: {
  161. parent_heading_id: null,
  162. },
  163. },
  164. block.id // 在当前块后插入
  165. );
  166. message.success('已插入段落');
  167. }, [block.id, addBlock]);
  168. const handleInsertImage = useCallback((dataUrl: string, fileName: string, width: number, height: number) => {
  169. addBlock(
  170. {
  171. type: 'image' as const,
  172. level: 0,
  173. content: dataUrl,
  174. word_style: 'Normal',
  175. style: {
  176. width,
  177. height,
  178. unit: 'cm' as const,
  179. align: 'center' as const,
  180. },
  181. metadata: {
  182. alt: fileName,
  183. para_style: 'Normal',
  184. parent_heading_id: null,
  185. },
  186. },
  187. block.id // 在当前块后插入
  188. );
  189. }, [block.id, addBlock]);
  190. const handleInsertTable = useCallback(() => {
  191. // 创建一个5行5列的表格
  192. const rows = 5;
  193. const cols = 5;
  194. const defaultColWidth = 95.6; // 478pt总宽 / 5列 = 95.6pt每列
  195. const defaultRowHeight = 58; // 默认行高58磅
  196. // 创建空单元格数组
  197. const createEmptyCell = (colIndex: number) => ({
  198. text: '',
  199. rowspan: 1,
  200. colspan: 1,
  201. col_index: colIndex + 1,
  202. width: defaultColWidth,
  203. style: {},
  204. word_style: 'Normal',
  205. });
  206. // 创建表格行
  207. const tableRows = Array(rows).fill(null).map(() => ({
  208. cells: Array(cols).fill(null).map((_, colIdx) => createEmptyCell(colIdx)),
  209. height: defaultRowHeight,
  210. }));
  211. addBlock(
  212. {
  213. type: 'table' as const,
  214. level: 0,
  215. index: 0,
  216. content: {
  217. rows: tableRows,
  218. col_widths: Array(cols).fill(defaultColWidth),
  219. },
  220. word_style: 'Table Grid', // 表格专用样式
  221. style: {},
  222. metadata: {
  223. cols,
  224. rows,
  225. table_width: 100,
  226. table_width_unit: 'percent' as const,
  227. col_widths: Array(cols).fill(100 / cols), // 平均分配百分比
  228. parent_heading_id: null,
  229. },
  230. },
  231. block.id // 在当前块后插入
  232. );
  233. message.success('已插入表格');
  234. }, [block.id, addBlock]);
  235. const Tag = `h${block.level}` as keyof JSX.IntrinsicElements;
  236. return (
  237. <div className="heading-block-wrapper" data-block-id={block.id} style={{ position: 'relative' }}>
  238. {/* 块操作菜单 */}
  239. {!readOnly && (
  240. <BlockMenu
  241. blockId={block.id}
  242. isFirst={isFirst}
  243. isLast={isLast}
  244. onDelete={handleDelete}
  245. onDuplicate={handleDuplicate}
  246. onMoveUp={handleMoveUp}
  247. onMoveDown={handleMoveDown}
  248. onInsertHeading={handleInsertHeading}
  249. onInsertParagraph={handleInsertParagraph}
  250. onInsertImage={handleInsertImage}
  251. onInsertTable={handleInsertTable}
  252. />
  253. )}
  254. <Tag className={`heading-block heading-${block.level}`}>
  255. <RichTextEditor
  256. value={block.content}
  257. onChange={handleChange}
  258. currentContentFormat={`heading-${block.level}`}
  259. onContentFormatChange={handleContentFormatChange}
  260. onAlignChange={handleAlignChange}
  261. currentAlign={block.style?.align}
  262. readOnly={readOnly}
  263. placeholder={`${block.level}级标题`}
  264. singleLine={true}
  265. baseStyle={blockStyle}
  266. />
  267. </Tag>
  268. </div>
  269. );
  270. };
  271. export default HeadingBlock;