/** * BlockMenu.tsx - 块操作菜单组件 * * 悬浮在块左侧的操作菜单,提供删除和插入等快捷操作 * * @module components/Editor/blocks */ import React from 'react'; import { Button, Dropdown, Upload, message } from 'antd'; import type { MenuProps } from 'antd'; import type { RcFile } from 'antd/es/upload/interface'; import { isSafeImageSource, sanitizeImageAlt, validateImageDimensions, validateImageUpload, } from '../../../utils/imageUpload'; import { DeleteOutlined, PlusOutlined, FontSizeOutlined, FileTextOutlined, OrderedListOutlined, PictureOutlined, TableOutlined, } from '@ant-design/icons'; import './BlockMenu.css'; // ══════════════════════════════════════════════════════════════════════════════ // Component Props // ══════════════════════════════════════════════════════════════════════════════ export interface BlockMenuProps { blockId: string; isEmpty?: boolean; isFirst?: boolean; isLast?: boolean; onDelete?: () => void; onDuplicate?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; onInsertHeading?: (level: 1 | 2 | 3 | 4 | 5 | 6) => void; onInsertParagraph?: () => void; onInsertOrderedList?: () => void; onInsertImage?: (dataUrl: string, fileName: string, width: number, height: number) => void; onInsertTable?: () => void; blockType?: 'heading' | 'paragraph' | 'image' | 'table'; } // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ /** * BlockMenu - 块操作菜单 * * 显示在块左侧的操作按钮,hover时可见 */ export const BlockMenu: React.FC = ({ blockId, isEmpty = false, onDelete, onInsertHeading, onInsertParagraph, onInsertOrderedList, onInsertImage, onInsertTable, }) => { const uploadRef = React.useRef(null); const isMountedRef = React.useRef(true); React.useEffect(() => { return () => { isMountedRef.current = false; }; }, []); /** * 处理图片上传 */ const handleImageUpload = (file: RcFile): boolean => { const validationError = validateImageUpload(file); if (validationError) { message.error(validationError); return false; } // 读取文件并转为Base64 const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target?.result as string; if (!isMountedRef.current || typeof dataUrl !== 'string' || !isSafeImageSource(dataUrl)) { return; } // 使用Image对象获取图片尺寸 const img = new Image(); img.onload = () => { if (!isMountedRef.current) return; const dimensionError = validateImageDimensions(img.width, img.height); if (dimensionError) { message.error(dimensionError); return; } // 计算适合的显示尺寸(默认最大宽度15cm) const maxWidthCm = 15; const aspectRatio = img.height / img.width; const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm const heightCm = widthCm * aspectRatio; const safeFileName = sanitizeImageAlt(file.name); // 调用插入图片回调 onInsertImage?.(dataUrl, safeFileName, parseFloat(widthCm.toFixed(2)), parseFloat(heightCm.toFixed(2))); message.success('图片已插入'); }; img.onerror = () => { if (!isMountedRef.current) return; message.error('图片加载失败'); }; img.src = dataUrl; }; reader.onerror = () => { if (!isMountedRef.current) return; message.error('图片读取失败'); }; reader.readAsDataURL(file); return false; // 阻止默认上传行为 }; // 插入标题子菜单 const insertHeadingMenuItems: MenuProps['items'] = [ { key: 'h1', label: '标题 1', onClick: () => onInsertHeading?.(1), }, { key: 'h2', label: '标题 2', onClick: () => onInsertHeading?.(2), }, { key: 'h3', label: '标题 3', onClick: () => onInsertHeading?.(3), }, { key: 'h4', label: '标题 4', onClick: () => onInsertHeading?.(4), }, { key: 'h5', label: '标题 5', onClick: () => onInsertHeading?.(5), }, { key: 'h6', label: '标题 6', onClick: () => onInsertHeading?.(6), }, ]; const handleInsertImage = () => { const input = uploadRef.current?.querySelector('input[type="file"]'); input?.click(); }; // 插入面板,只保留已实现的功能 const insertMenuItems: MenuProps['items'] = [ { key: 'paragraph', icon: , label: '正文', onClick: onInsertParagraph }, { key: 'heading', icon: , label: '标题', children: insertHeadingMenuItems }, { key: 'ordered-list', icon: , label: '有序列表', onClick: onInsertOrderedList }, { key: 'image', icon: , label: '图片', onClick: handleInsertImage }, { key: 'table', icon: , label: '表格', onClick: onInsertTable }, ]; // 加号面板同时承载插入和原三点菜单中的块操作。 const plusMenuItems: MenuProps['items'] = [ ...insertMenuItems, { type: 'divider' }, { key: 'delete', icon: , label: '删除', danger: true, onClick: onDelete, }, ]; return (
); }; export default BlockMenu;