BlockMenu.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. /**
  2. * BlockMenu.tsx - 块操作菜单组件
  3. *
  4. * 悬浮在块左侧的操作菜单,提供删除和插入等快捷操作
  5. *
  6. * @module components/Editor/blocks
  7. */
  8. import React from 'react';
  9. import { Button, Dropdown, Upload, message } from 'antd';
  10. import type { MenuProps } from 'antd';
  11. import type { RcFile } from 'antd/es/upload/interface';
  12. import {
  13. isSafeImageSource,
  14. sanitizeImageAlt,
  15. validateImageDimensions,
  16. validateImageUpload,
  17. } from '../../../utils/imageUpload';
  18. import {
  19. DeleteOutlined,
  20. PlusOutlined,
  21. FontSizeOutlined,
  22. FileTextOutlined,
  23. OrderedListOutlined,
  24. PictureOutlined,
  25. TableOutlined,
  26. } from '@ant-design/icons';
  27. import './BlockMenu.css';
  28. // ══════════════════════════════════════════════════════════════════════════════
  29. // Component Props
  30. // ══════════════════════════════════════════════════════════════════════════════
  31. export interface BlockMenuProps {
  32. blockId: string;
  33. isEmpty?: boolean;
  34. isFirst?: boolean;
  35. isLast?: boolean;
  36. onDelete?: () => void;
  37. onDuplicate?: () => void;
  38. onMoveUp?: () => void;
  39. onMoveDown?: () => void;
  40. onInsertHeading?: (level: 1 | 2 | 3 | 4 | 5 | 6) => void;
  41. onInsertParagraph?: () => void;
  42. onInsertOrderedList?: () => void;
  43. onInsertImage?: (dataUrl: string, fileName: string, width: number, height: number) => void;
  44. onInsertTable?: () => void;
  45. blockType?: 'heading' | 'paragraph' | 'image' | 'table';
  46. }
  47. // ══════════════════════════════════════════════════════════════════════════════
  48. // Component
  49. // ══════════════════════════════════════════════════════════════════════════════
  50. /**
  51. * BlockMenu - 块操作菜单
  52. *
  53. * 显示在块左侧的操作按钮,hover时可见
  54. */
  55. export const BlockMenu: React.FC<BlockMenuProps> = ({
  56. blockId,
  57. isEmpty = false,
  58. onDelete,
  59. onInsertHeading,
  60. onInsertParagraph,
  61. onInsertOrderedList,
  62. onInsertImage,
  63. onInsertTable,
  64. }) => {
  65. const uploadRef = React.useRef<HTMLDivElement>(null);
  66. const isMountedRef = React.useRef(true);
  67. React.useEffect(() => {
  68. return () => {
  69. isMountedRef.current = false;
  70. };
  71. }, []);
  72. /**
  73. * 处理图片上传
  74. */
  75. const handleImageUpload = (file: RcFile): boolean => {
  76. const validationError = validateImageUpload(file);
  77. if (validationError) {
  78. message.error(validationError);
  79. return false;
  80. }
  81. // 读取文件并转为Base64
  82. const reader = new FileReader();
  83. reader.onload = (e) => {
  84. const dataUrl = e.target?.result as string;
  85. if (!isMountedRef.current || typeof dataUrl !== 'string' || !isSafeImageSource(dataUrl)) {
  86. return;
  87. }
  88. // 使用Image对象获取图片尺寸
  89. const img = new Image();
  90. img.onload = () => {
  91. if (!isMountedRef.current) return;
  92. const dimensionError = validateImageDimensions(img.width, img.height);
  93. if (dimensionError) {
  94. message.error(dimensionError);
  95. return;
  96. }
  97. // 计算适合的显示尺寸(默认最大宽度15cm)
  98. const maxWidthCm = 15;
  99. const aspectRatio = img.height / img.width;
  100. const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm
  101. const heightCm = widthCm * aspectRatio;
  102. const safeFileName = sanitizeImageAlt(file.name);
  103. // 调用插入图片回调
  104. onInsertImage?.(dataUrl, safeFileName, parseFloat(widthCm.toFixed(2)), parseFloat(heightCm.toFixed(2)));
  105. message.success('图片已插入');
  106. };
  107. img.onerror = () => {
  108. if (!isMountedRef.current) return;
  109. message.error('图片加载失败');
  110. };
  111. img.src = dataUrl;
  112. };
  113. reader.onerror = () => {
  114. if (!isMountedRef.current) return;
  115. message.error('图片读取失败');
  116. };
  117. reader.readAsDataURL(file);
  118. return false; // 阻止默认上传行为
  119. };
  120. // 插入标题子菜单
  121. const insertHeadingMenuItems: MenuProps['items'] = [
  122. {
  123. key: 'h1',
  124. label: '标题 1',
  125. onClick: () => onInsertHeading?.(1),
  126. },
  127. {
  128. key: 'h2',
  129. label: '标题 2',
  130. onClick: () => onInsertHeading?.(2),
  131. },
  132. {
  133. key: 'h3',
  134. label: '标题 3',
  135. onClick: () => onInsertHeading?.(3),
  136. },
  137. {
  138. key: 'h4',
  139. label: '标题 4',
  140. onClick: () => onInsertHeading?.(4),
  141. },
  142. {
  143. key: 'h5',
  144. label: '标题 5',
  145. onClick: () => onInsertHeading?.(5),
  146. },
  147. {
  148. key: 'h6',
  149. label: '标题 6',
  150. onClick: () => onInsertHeading?.(6),
  151. },
  152. ];
  153. const handleInsertImage = () => {
  154. const input = uploadRef.current?.querySelector<HTMLInputElement>('input[type="file"]');
  155. input?.click();
  156. };
  157. // 插入面板,只保留已实现的功能
  158. const insertMenuItems: MenuProps['items'] = [
  159. { key: 'paragraph', icon: <FileTextOutlined />, label: '正文', onClick: onInsertParagraph },
  160. { key: 'heading', icon: <FontSizeOutlined />, label: '标题', children: insertHeadingMenuItems },
  161. { key: 'ordered-list', icon: <OrderedListOutlined />, label: '有序列表', onClick: onInsertOrderedList },
  162. { key: 'image', icon: <PictureOutlined />, label: '图片', onClick: handleInsertImage },
  163. { key: 'table', icon: <TableOutlined />, label: '表格', onClick: onInsertTable },
  164. ];
  165. // 加号面板同时承载插入和原三点菜单中的块操作。
  166. const plusMenuItems: MenuProps['items'] = [
  167. ...insertMenuItems,
  168. { type: 'divider' },
  169. {
  170. key: 'delete',
  171. icon: <DeleteOutlined />,
  172. label: '删除',
  173. danger: true,
  174. onClick: onDelete,
  175. },
  176. ];
  177. return (
  178. <div className={`block-menu${isEmpty ? ' block-menu-empty' : ''}`} data-block-id={blockId}>
  179. <Dropdown
  180. menu={{ items: plusMenuItems }}
  181. trigger={['click']}
  182. placement="bottomLeft"
  183. overlayClassName="block-insert-dropdown"
  184. >
  185. <Button
  186. type="text"
  187. size="small"
  188. icon={<PlusOutlined />}
  189. className="block-menu-add"
  190. aria-label="插入块"
  191. />
  192. </Dropdown>
  193. {/* 隐藏的图片上传组件 */}
  194. <div ref={uploadRef} style={{ display: 'none' }}>
  195. <Upload
  196. accept="image/*"
  197. beforeUpload={handleImageUpload}
  198. showUploadList={false}
  199. >
  200. <Button />
  201. </Upload>
  202. </div>
  203. </div>
  204. );
  205. };
  206. export default BlockMenu;