| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- /**
- * 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<BlockMenuProps> = ({
- blockId,
- isEmpty = false,
- onDelete,
- onInsertHeading,
- onInsertParagraph,
- onInsertOrderedList,
- onInsertImage,
- onInsertTable,
- }) => {
- const uploadRef = React.useRef<HTMLDivElement>(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<HTMLInputElement>('input[type="file"]');
- input?.click();
- };
- // 插入面板,只保留已实现的功能
- const insertMenuItems: MenuProps['items'] = [
- { key: 'paragraph', icon: <FileTextOutlined />, label: '正文', onClick: onInsertParagraph },
- { key: 'heading', icon: <FontSizeOutlined />, label: '标题', children: insertHeadingMenuItems },
- { key: 'ordered-list', icon: <OrderedListOutlined />, label: '有序列表', onClick: onInsertOrderedList },
- { key: 'image', icon: <PictureOutlined />, label: '图片', onClick: handleInsertImage },
- { key: 'table', icon: <TableOutlined />, label: '表格', onClick: onInsertTable },
- ];
- // 加号面板同时承载插入和原三点菜单中的块操作。
- const plusMenuItems: MenuProps['items'] = [
- ...insertMenuItems,
- { type: 'divider' },
- {
- key: 'delete',
- icon: <DeleteOutlined />,
- label: '删除',
- danger: true,
- onClick: onDelete,
- },
- ];
- return (
- <div className={`block-menu${isEmpty ? ' block-menu-empty' : ''}`} data-block-id={blockId}>
- <Dropdown
- menu={{ items: plusMenuItems }}
- trigger={['click']}
- placement="bottomLeft"
- overlayClassName="block-insert-dropdown"
- >
- <Button
- type="text"
- size="small"
- icon={<PlusOutlined />}
- className="block-menu-add"
- aria-label="插入块"
- />
- </Dropdown>
- {/* 隐藏的图片上传组件 */}
- <div ref={uploadRef} style={{ display: 'none' }}>
- <Upload
- accept="image/*"
- beforeUpload={handleImageUpload}
- showUploadList={false}
- >
- <Button />
- </Upload>
- </div>
- </div>
- );
- };
- export default BlockMenu;
|