Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(二)

Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(二)低代码可视化拖拽页面编辑器 随着大前端的不断发展,越来越解放开发的双手,感觉要失业啦(^_^),针对一些简单模板处理,可直接通过个拖拉拽放,就可简单实现一些不错的 UI 功能。

紧接着前面的Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(一)

Vue3版本请点击

  • 主页面结构:左侧菜单栏可选组件列表、中间容器画布、右侧编辑组件定义的属性;
  • 左侧菜单栏可选组件列表渲染;
  • 从菜单栏拖拽组件到容器;
  • 组件(Block)在容器的选中状态;
  • 容器内组件可移动位置;
  • 容器内的组件单选、多选、全选;
  • 命令队列及对应的快捷键;
  • 操作栏按钮:
    • 撤销、重做 重难点
    • 置顶、置底;
    • 删除、清空;
    • 预览、关闭编辑模式;
    • 导入、导出;
  • 右键菜单;
  • 拖拽参考线;
  • 组件可以拖动调整高度和宽度(height,width);
  • 组件可以设置预定好的属性(props);
  • 组件绑定值(model);
  • 设置组件标识(soltName),根据这个标识,定义某个组件的行为(函数触发)和插槽的实现(自定义视图);
  • 完善可选组件列表:
    • 输入框:双向绑定值,调整宽度;
    • 按钮:类型、文字、大小尺寸、拖拽调整宽高;
    • 图片:自定义图片地址、拖拽调整图片宽高
    • 下拉框:预定义选项值、双向绑定字段;

六、画布容器中组件选中

是否选中,为每个元素组件绑定一个字段 focus 做判断处理,可以按住 Shift 选中多个,点击画布容器空白处,取消所有的选中状态。

// VisualEditor.tsx

const methods = {
    /** * 更新 block 数据,触发视图重新渲染 * @param blocks */
    updateBlocks: (blocks: VisualEditorBlockData[]) => {
        props.onChange({
            ...props.value,
            blocks: [...blocks]
        })
    },
}
//#region 画布容器中 block 组件选中
const focusHandler = (() => {
    const mousedownBlock = (e: React.MouseEvent<HTMLDivElement>, block: VisualEditorBlockData, index: number) => {
        e.stopPropagation();
        if (preview) return;
        e.preventDefault();

        if (e.shiftKey) {
            // 如果摁住了shift键,如果此时没有选中的 block,就选中该 block,否则使该 block 的数据选中状态取反
            if (focusData.focus.length <= 1) {
                block.focus = true;
            } else {
                block.focus = !block.focus;
            }
            methods.updateBlocks(props.value.blocks);
        } else {
            // 如果点击的这个 block 没有被选中,才清空这个其他选中的 block,否则不做任何事情。放置拖拽多个 block,取消其他 block 的选中状态
            if (!block.focus) {
                block.focus = true;
                methods.clearFocus(block);
            }
        }

        setSelectIndex(block.focus ? index : -1);
        // 使用延时器保证,数据时渲染后的正确数据,否则有 BUG
        setTimeout(() => {
            blockDraggier.mousedown(e, block);
        });
    };
    const mousedownContainer = (e: React.MouseEvent<HTMLDivElement>) => {
        e.stopPropagation();
        if (preview) return;
        e.preventDefault();

        // 右键不作任何处理
        if (e.button === 1) return;
        // 判断不是点击了 container 容器就返回
        if (e.target !== e.currentTarget) return;

        // console.log('点击了 Contanier');
        if (!e.shiftKey) {
            // 点击空白出清空所有的选中的 block
            methods.clearFocus();
            setSelectIndex(-1);
        }
    };

    return {
        block: mousedownBlock,
        container: mousedownContainer
    }
})();
//#endregion

传送门

七、选中的组件进行拖拽

多个选中也一起移动

// VisualEditor.tsx

const methods = {
  /** * 更新 block 数据,触发视图重新渲染 * @param blocks */
  updateBlocks: (blocks: VisualEditorBlockData[]) => {
    props.onChange({
      ...props.value,
      blocks: [...blocks]
    })
  },
  /** * 清空选中的数据 */
  clearFocus: (external?: VisualEditorBlockData) => {
    let blocks = [...props.value.blocks];
    if (!blocks.length) return;
    if (external) {
      blocks = blocks.filter(item => item !== external);
    }
    blocks.forEach(block => block.focus = false);
    methods.updateBlocks(props.value.blocks);
  },
}

//#region 画布容器组件的拖拽
  const blockDraggier = (() => {

    const [mark, setMark] = useState({x: null as null | number, y: null as null | number});

    // 存储拖拽时的数据
    const dragData = useRef({
      startX: 0, // 鼠标拖拽开始的,鼠标的横坐标
      startY: 0, // 鼠标拖拽开始的,鼠标的纵坐标
      startLeft: 0, // 鼠标拖拽开始的,拖拽的 block 横坐标
      startTop: 0, // 鼠标拖拽开始的,拖拽的 block 纵坐标
      startPosArray: [] as { top: number, left: number }[], // 鼠标拖拽开始的, 所有选中的 block 元素的横纵坐标值

      shiftKey: false, // 当前是否按住了 shift 键
      moveX: 0, // 拖拽过程中的时候, 鼠标的 left 值
      moveY: 0, // 拖拽过程中的时候, 鼠标的 top 值
      containerBar: {
        startScrollTop: 0, // 拖拽开始的时候, scrollTop 值
        moveScrollTop: 0, // 拖拽过程中的时候, scrollTop 值
      },
      
      dragging: false, // 当前是否属于拖拽状态
      markLines: { // 拖拽元素时,计算当前未选中的数据中,与拖拽元素之间参考辅助线的显示位置
        x: [] as {left: number, showLeft: number}[],
        y: [] as {top: number, showTop: number}[]
      }
    });
    const moveHandler = useCallbackRef(() => {
      if (!dragData.current.dragging) {
        dragData.current.dragging = true;
      }
      
      let {
        startX, 
        startY, 
        startPosArray, 
        moveX,
        moveY,
        containerBar,
        startLeft, 
        startTop, 
        markLines,
        shiftKey
      } = dragData.current;

      moveY = moveY + (containerBar.moveScrollTop - containerBar.startScrollTop);

      // 移动时, 同时按住 shift 键,只在一个方向移动
      if (shiftKey) {
        const n = 12; // 预定差值
        if (Math.abs(moveX - startX) > Math.abs(moveY - startY) + n) {
          moveY = startY;
        } else {
          moveX = startX;
        }
      }

      const durX = moveX - startX;
      const durY = moveY - startY;

      focusData.focus.forEach((block, index) => {
        const { left, top } = startPosArray[index];
        block.left = left + durX;
        block.top = top + durY;
      });
      methods.updateBlocks(props.value.blocks);

    });

    const scrollHandler = useCallbackRef((e: Event) => {
      dragData.current.containerBar.moveScrollTop = (e.target as HTMLDivElement).scrollTop;
      moveHandler();
    }); 

    const mousemove = useCallbackRef((e: MouseEvent) => {
      dragData.current.moveX = e.clientX;
      dragData.current.moveY = e.clientY;
      moveHandler();
    });
    const mouseup = useCallbackRef((e: MouseEvent) => {
      document.removeEventListener('mousemove', mousemove);
      document.removeEventListener('mouseup', mouseup);

      if (dragData.current.dragging) {
        dragData.current.dragging = false;
      }
    });
    const mousedown = useCallbackRef((e: React.MouseEvent<HTMLDivElement>, block: VisualEditorBlockData) => {
      
      document.addEventListener('mousemove', mousemove);
      document.addEventListener('mouseup', mouseup);
      
      dragData.current = {
        startX: e.clientX,
        startY: e.clientY,
        startLeft: block.left,
        startTop: block.top,
        startPosArray: focusData.focus.map(({ top, left }) => ({ top, left })),
        moveX: e.clientX,
        moveY: e.clientY,
        shiftKey: e.shiftKey,
        containerBar: {
          startScrollTop: 0,
          moveScrollTop: 0,
        },
        dragging: false,
        markLines: (() => {
          const x = [{ left: 0, showLeft: 0}];
          const y = [{ top: 0, showTop: 0}];
          return { x, y }
        })()
      }
    });
    return {
      mousedown,
      mark
    }
  })();
  //#endregion

传送门

八、顶部操作栏

编辑的操作必要的一些删除,撤销,重做,导入,导出

// VisualEditor.tsx

//#region 功能操作栏按钮组
const buttons: {
  label: string | (() => string),
  icon: string | (() => string),
  tip?: string | (() => string),
  handler: () => void,
}[] = [
    {
      label: '撤销',
      icon: 'icon-back',
      handler: () => {
        console.log('撤销')
      },
      tip: 'ctrl+z'
    },
    {
      label: '重做',
      icon: 'icon-forward',
      handler: () => {
        console.log('重做')
      },
      tip: 'ctrl+y, ctrl+shift+z'
    },
    {
      label: '导入',
      icon: 'icon-import',
      handler: async () => {
        console.log('导入')
      }
    },
    {
      label: '导出',
      icon: 'icon-export',
      handler: () => {
        console.log('导出')
      }
    },
    {
      label: '置顶',
      icon: 'icon-place-top',
      handler: () => {
        console.log('置顶')
      },
      tip: 'ctrl+up'
    },
    {
      label: '置底',
      icon: 'icon-place-bottom',
      handler: () => {
        console.log('置底')
      },
      tip: 'ctrl+down'
    },
    {
      label: '删除',
      icon: 'icon-delete',
      handler: () => {
        console.log('删除')
      }, tip: 'ctrl+d, backspace, delete'
    },
    {
      label: '清空',
      icon: 'icon-reset',
      handler: () => {
        console.log('清空')
      }
    },
    {
      label: () => preview ? '编辑' : '预览',
      icon: () => preview ? 'icon-edit' : 'icon-browse',
      handler: () => {
        if (!preview && !editing) {
          methods.clearFocus();
        }
        innerMethods.togglePreview();
      },
    },
    {
      label: '关闭',
      icon: 'icon-close',
      handler: () => {
        if (!editing) {
          methods.clearFocus();
        }
        innerMethods.toggleEditing();
      }
    }
  ]
//#endregion

// 省略代码......

<div className={classModule['visual-editor__head']}>
  {
    buttons.map((btn, index) => {
      const label = typeof btn.label === "function" ? btn.label() : btn.label
      const icon = typeof btn.icon === "function" ? btn.icon() : btn.icon
      const content = (<div key={index} className={classModule['editor-head__button']} onClick={btn.handler}> <i className={`iconfont ${icon}`} /> <span>{label}</span> </div>)
      return !btn.tip ? content : <Tooltip title={btn.tip} placement="bottom" key={index}> {content} </Tooltip>
    })
  }
</div>

效果图

顶部操作栏.png

传送门

九、撤销,重做,删除、清空命令实现

  • 基础命令初始化
// command.plugin.ts
import { useCallback, useRef, useState } from "react";

// command 的 execute 执行完之后,需要返回 undo、redo。execute 执行后会立即返回 redo,后续撤销的时候会执行 undo,重做的时候会执行 redo
interface CommandExecute {
    redo: () => void, // 默认执行,重做会调用
    undo?: () => void, // 撤销会调用
}

interface Command {
    name: string, // 命令的唯一标识
    execute: (...args: any[]) => CommandExecute, // 命令执行时候,所处理的内容
    keyboard?: string | string[], // 命令监听的快捷键
    followQueue?: boolean, // 命令执行之后,是否需要将命令执行得到的 undo,redo 存入命令队列(像全选、撤销、重做这中命令不需要存入命令队列的)
    init?: () => ((() => void) | undefined), // 命令初始化函数,如果返回的,则是一个销毁命令函数
    data?: any // 命令缓存所需的数据信息
}

export function useCommander() {

    const [state] = useState(() => ({
        current: -1, // 当前命令队列中,最后执行的命令返回的 CommandExecute 对象
        queue: [] as CommandExecute[], // 命令队列容器
        commandList: [] as { current: Command }[], // 预定义命令的容器
        commands: {} as Record<string, (...args: any[]) => void>, // 通过 command name 执行 command 动作的一个包装对象
        destroyList: [] as ((() => void) | undefined)[] // 所有命令在组件销毁之前,需要执行的消除副作用的函数容器
    }));

    /** * 注册命令 */
    const useRegistry = useCallback((command: Command) => {
        const commandRef = useRef<Command>(command);
        commandRef.current = command;
        useState(() => {
            // 判断命令是否存在
            if (state.commands[command.name]) {
                const existIndex = state.commandList.findIndex(item => item.current.name === command.name);
                state.commandList.splice(existIndex, 1);
            }
            state.commandList.push(commandRef);
            // 对应命令的方法 AAAAAAA
            state.commands[command.name] = (...args: any[]) => {
                const { redo, undo } = commandRef.current.execute(...args);
                // 默认执行重做
                redo();
                // 如果命令执行后,不需要进入命令队列,就直接结束
                if (commandRef.current.followQueue === false) {
                    return;
                }
                // 否则,将命令队列中剩余的命令都删除,保留 current 及其之前的命令
                let { queue, current } = state;
                if (queue.length > 0) {
                    queue = queue.slice(0, current + 1);
                    state.queue = queue;
                }
                // 将命令队列中最后一个命令为i当前执行的命令
                queue.push({ undo, redo });
                // 索引加 1, 指向队列中的最有一个命令
                state.current = current + 1;
            }
        });
    }, []);

    // 初始化注册命令(useRegistry)时的所有的 command 的 init 的方法
    const useInit = useCallback(() => {
        useState(() => {
            state.commandList.forEach(command => {
                command.current.init && state.destroyList.push(command.current.init());
            });
            // state.destroyList.push(keyboardEvent.init());
        });

        // 注册内置的撤回命令(撤回命令执行的结果是不需要进入命令队列的)
        useRegistry({
            name: 'undo',
            keyboard: 'ctrl+z',
            followQueue: false, // 标识不需要进入命令队列
            execute: () => {
                return {
                    redo: () => {
                        if (state.current === -1) return;
                        const queueItem = state.queue[state.current];
                        if (queueItem) {
                            queueItem.undo && queueItem.undo();
                            state.current--;
                        }
                    }
                }
            }
        });

        // 注册内置的重做命令(重做命令执行结果是不需要进入命令队列的)
        useRegistry({
            name: 'redo',
            keyboard: ['ctrl+y', 'ctrl+shift+z'],
            followQueue: false,
            execute: () => {
                return {
                    redo: () => {
                        const queueItem = state.queue[state.current + 1];
                        if (queueItem) {
                            queueItem.redo();
                            state.current++;
                        }
                    }
                }
            }
        });
    }, []);

    return {
        state,
        useInit,
        useRegistry
    }
}
  • 扩展命令注册并导出
// editor.command.tsx
import deepcopy from "deepcopy";
import { VisualEditorBlockData, VisualEditorValue } from "./editor.utils";
import { useCommander } from "./plugin/command.plugin";

export function useVisualCommand({ focusData, value, updateBlocks }: { focusData: { focus: VisualEditorBlockData[], unFocus: VisualEditorBlockData[] }, value: VisualEditorValue, updateBlocks: (blocks: VisualEditorBlockData[]) => void, }) {
    const commander = useCommander();

    // 注册一个删除命令操作
    commander.useRegistry({
        name: 'delete',
        keyboard: ['delete', 'ctrl+d', 'backspace'],
        execute: () => {
            const data = {
                before: (() => deepcopy(value.blocks))(),
                after: (() => deepcopy(focusData.unFocus))()
            }
            return {
                redo: () => { // 重做
                    updateBlocks(deepcopy(data.after));
                },
                undo: () => { // 撤销
                    updateBlocks(deepcopy(data.before));
                }
            }
        }
    });

    // 注册一个清空命令操作
    commander.useRegistry({
        name: 'clear',
        execute: () => {
            const data = {
                before: deepcopy(value.blocks),
                after: deepcopy([]),
            }
            return {
                redo: () => {
                    updateBlocks(deepcopy(data.after));
                },
                undo: () => {
                    updateBlocks(deepcopy(data.before));
                },
            }
        }
    })

     // 初始内置的命令 undo,redo
    commander.useInit(); // 在底部调用
    return {
        delete: () => commander.state.commands.delete(),
        clear: () => commander.state.commands.clear(),
        undo: () => commander.state.commands.undo(),
        redo: () => commander.state.commands.redo(),
    }
}

传送门

十、绑定键盘快捷键事件

快捷键的组合处理函数

// command.plugin.ts

// 快捷键
const [keyboardEvent] = useState(() => {
  const onKeydown = (ev: KeyboardEvent) => {
    // 对于容器是否在空白区域或时操作某个组件的命令区分操作,比如空白区域时全选或全中所有的组件组件,在操作某个输入框组件时,全选就只会选中输入框中的文字
    if (document.activeElement !== document.body) {
        return;
    }
    const { keyCode, shiftKey, altKey, ctrlKey, metaKey } = ev;

    let keyString: string[] = [];

    if (ctrlKey || metaKey) {
        keyString.push('ctrl');
    }
    if (shiftKey) {
        keyString.push('shift');
    }
    if (altKey) {
        keyString.push('alt');
    }
    keyString.push(KeyboardCode[keyCode]);

    // 快捷键格式 'ctrl+alt+s'
    const keyNames = keyString.join('+');

    state.commandList.forEach(({ current: { keyboard, name } }) => {
        if (!keyboard) return;

        const keys = Array.isArray(keyboard) ? keyboard : [keyboard];

        if (keys.indexOf(keyNames) > -1) {
            state.commands[name](); // 执行对应的命令的方法 AAAAAAA
            ev.stopPropagation();
            ev.preventDefault();
        }
    })
}

传送门

十一、注册拖拽事件命令到命令队列中去

拖拽开始和结束都需要派发出事件(发布订阅的模式),在画布容器移动的过程中也需要派发事件,拖拽结束鼠标抬起也要派发结束事件。

{
    const dragData = useRef({ before: null as null | VisualEditorBlockData[] });

    const handler = {
        // 拖拽开始或结束就会通过已经订阅的事件来触发这个 dragstart、dragend 函数,执行对应的函数逻辑
        dragstart: useCallbackRef(() => dragData.current.before = deepcopy(value.blocks)),
        dragend: useCallbackRef(() => commander.state.commands.drag())
    }
    /** * 注册拖拽命令 * 适用于如下三种情况: * 1. 从左侧菜单拖拽组件到容器画布; * 2. 在容器中拖拽组件调整位置; * 3. 拖动调整组件的高度和宽度。 */
    commander.useRegistry({
        name: 'drag',
        init: () => {
            dragData.current = { before: null };
            dragstart.on(handler.dragstart);
            dragend.on(handler.dragend);
            return () => {
                dragstart.off(handler.dragstart);
                dragend.off(handler.dragend);
            }
        },
        execute: () => {
            const data = {
                before: deepcopy(dragData.current.before),
                after: deepcopy(value.blocks)
            };
            return {
                redo: () => {
                    updateBlocks(deepcopy(data.after));
                },
                undo: () => {
                    updateBlocks(deepcopy(data.before) || []);
                }
            }
        }
    });
}

传送门

十二、顶部操作栏的置顶、置底

注册置顶,置底命令

// VisualEditor.command.tsx

// 注册置顶命令
commander.useRegistry({
    name: 'placeTop',
    keyboard: 'ctrl+up',
    execute: () => {
        const data = {
            before: (() => deepcopy(value.blocks))(),
            after: (() => deepcopy(() => {
                const { focus, unFocus } = focusData;
                // 计算出 focus 选中的最大的 zIndex 值,unFocus 未选中的最小的 zIndex 值,计算它们的差值就是当前元素置顶的 zIndex 值
                const maxUnFocusIndex = unFocus.reduce((prev, item) => {
                    return Math.max(prev, item.zIndex);
                }, -Infinity);
                const minFocusIndex = focus.reduce((prev, item) => {
                    return Math.min(prev, item.zIndex);
                }, Infinity);
                let dur = maxUnFocusIndex - minFocusIndex + 1;
                if (dur >= 0) {
                    dur++;
                    focus.forEach(block => block.zIndex = block.zIndex + dur);
                }
                return value.blocks;
            }))()()
        };
        return {
            redo: () => updateBlocks(deepcopy(data.after) || []),
            undo: () => updateBlocks(deepcopy(data.before))
        };
    }
});

// 注册置顶命令
commander.useRegistry({
    name: 'placeBottom',
    keyboard: 'ctrl+down',
    execute: () => {

        const data = {
            before: (() => deepcopy(value.blocks))(),
            after: (() => deepcopy(() => {
                // 这的置顶算法需要优化一下
                const { focus, unFocus } = focusData;
                // 计算出 focus 选中的最大的 zIndex 值,unFocus 未选中的最小的 zIndex 值,计算它们的差值就是当前元素置顶的 zIndex 值
                const minUnFocusIndex = unFocus.reduce((prev, item) => {
                    return Math.min(prev, item.zIndex);
                }, Infinity);
                const maxFocusIndex = focus.reduce((prev, item) => {
                    return Math.max(prev, item.zIndex);
                }, -Infinity);
                const minFocusIndex = focus.reduce((prev, item) => {
                    return Math.min(prev, item.zIndex);
                }, Infinity);
                let dur = maxFocusIndex - minUnFocusIndex + 1;
                if (dur >= 0) {
                    dur++;
                    focus.forEach(block => block.zIndex = block.zIndex - dur);
                    if (minFocusIndex - dur < 0) {
                        dur = dur - minFocusIndex;
                        value.blocks.forEach(block => block.zIndex = block.zIndex + dur);
                    }
                }
                return value.blocks;
            }))()()
        };

        return {
            redo: () => updateBlocks(deepcopy(data.after)),
            undo: () => updateBlocks(deepcopy(data.before))
        };
    }
});

传送门

十三、注册全选快捷键命令

// VisualEditor.command.tsx

// 注册全选快捷键命令
commander.useRegistry({
    name: 'selectAll',
    keyboard: ['ctrl+a'],
    followQueue: false,
    execute: () => {
        return {
            redo: () => {
                value.blocks.forEach(block => block.focus = true);
                updateBlocks(value.blocks);
            }
        }
    }
});

传送门

十四、操作栏导入、导出

  • 注册导入更新命令
// VisualEditor.command.tsx

// 注册导入数据时,更新数据命令
commander.useRegistry({
    name: 'updateValue',
    execute: (newModelValue: VisualEditorValue) => {
        const data = {
            before: deepcopy(value),
            after: deepcopy(newModelValue)
        };
        return {
            redo: () => updateValue(data.after),
            undo: () => updateValue(data.before)
        }
    }
});
  • 导入、导出弹框
// $$dialog.tsx

export enum DialogEdit {
  input = 'input',
  textarea = 'textarea',
}

interface DialogOption {
  title?: string,
  message?: string | (() => any),
  confirmButton?: boolean
  cancelButton?: boolean
  editType?: DialogEdit,
  editValue?: string,
  editReadonly?: boolean
  width?: string,
  onConfirm?: (editValue?: string) => void,
  onCancel?: () => void,
}

interface DialogInstance {
  show: (option?: DialogOption) => void,
  close: () => void,
}

const DialogComponent: React.FC<{ option?: DialogOption, onRef?: (ins: DialogInstance) => void }> = (props) => {

  let [option, setOption] = useState(props.option || {});
  const [showFlag, setShowFlag] = useState(false);
  const [editValue, setEditValue] = useState(option ? option.editValue : '');

  const methods = {
    show: (option?: DialogOption) => {
      setOption(deepcopy(option || {}));
      setEditValue(!option ? '' : (option.editValue || ''));
      setShowFlag(true);
    },
    close: () => setShowFlag(false),
  }

  props.onRef && props.onRef(methods);

  const handler = {
    onConfirm: () => {
      option.onConfirm && option.onConfirm(editValue)
      methods.close()
    },
    onCancel: () => {
      option.onCancel && option.onCancel()
      methods.close()
    },
  };

  const inputProps = {
    value: editValue,
    onChange: (e: React.ChangeEvent<any>) => setEditValue(e.target.value),
    readOnly: option.editReadonly === true,
  };

  return (
    <Modal maskClosable={true} closable={true} title={option.title || '系统提示'} visible={showFlag} onCancel={handler.onCancel} footer={(option.confirmButton || option.cancelButton) ? ( <> {option.cancelButton && <Button onClick={handler.onCancel}>取消</Button>} {option.confirmButton && <Button type="primary" onClick={handler.onConfirm}>确定</Button>} </>
      ) : null}
    >
      {option.message}
      {option.editType === DialogEdit.input && (
        <Input {...inputProps} />
      )}
      {option.editType === DialogEdit.textarea && (
        <Input.TextArea {...inputProps} rows={15} />
      )}
    </Modal>
  )

}

const getInstance = (() => {
  let ins: null | DialogInstance = null;
  return (option?: DialogOption) => {
    if (!ins) {
      const el = document.createElement('div');
      document.body.appendChild(el);
      ReactDOM.render(<DialogComponent option={option} onRef={val => ins = val} />, el);
    }
    return ins!;
  }
})();

const DialogService = (option?: DialogOption) => {
  const ins = getInstance(option);
  ins.show(option);
}

export const $$dialog = Object.assign(DialogService, {
  textarea: (val?: string, option?: DialogOption) => {
    const dfd = defer<string | undefined>();
    option = option || {};
    option.editType = DialogEdit.textarea;
    option.editValue = val;
    if (option.editReadonly !== true) {
      option.confirmButton = true;
      option.cancelButton = true;
      option.onConfirm = dfd.resolve;
    }
    DialogService(option);
    return dfd.promise;
  },
  input: (val?: string, option?: DialogOption) => {
    // TODO
    console.log(val, option)
  },
})

传送门

十五、右键菜单

  • 菜单实现
// src/packages/service/dropdown/$$dropdown.tsx

import ReactDOM from "react-dom";
import { MouseEvent, useContext, useEffect, useMemo, useRef, useState } from 'react';
import './$$dropdown.scss';
import { useCallbackRef } from '../../hook/useCallbackRef';

type Reference = { x: number, y: number } | MouseEvent | HTMLElement;
type Render = JSX.Element | JSX.Element[] | React.ReactFragment;

interface DropdownOption {
  reference: Reference,
  render: () => Render,
}

const DropdownContext = React.createContext<{onClick: () => void}>({} as any);

const DropdownComponent: React.FC<{
  option: DropdownOption,
  onRef: (ins: { show: (opt: DropdownOption) => void, close: () => void }) => void
}> = (props) => {

  const elRef = useRef({} as HTMLDivElement);
  
  const [option, setOption] = useState(props.option);
  const [showFlag, setShowFlag] = useState(false);

  const styles = useMemo(() => {
    let x = 0, y = 0;
    const reference = option.reference
    if ('target' in reference) {
      x = reference.clientX - 20;
      y = reference.clientY - 20;
    } else if ('addEventListener' in reference) {
      const { top, left, height } = reference.getBoundingClientRect();
      x = left;
      y = top + height;
    } else {
      x = reference.x;
      y = reference.y;
    }
    return {
      left: `${x + 20}px`,
      top: `${y + 20}px`,
      display: showFlag ? 'inline-block' : 'none'
    }
  }, [option?.reference, showFlag]);

  const methods = {
    show: (opt: DropdownOption) => {
      setOption(opt);
      setShowFlag(true);
    },
    close: () => {
      setShowFlag(false);
    }
  };

  const handler = {
    onClickBody: useCallbackRef((ev: MouseEvent) => {
      if (elRef.current.contains(ev.target as Node)) {
        /*点击了dropdown content*/
        return;
      } else {
        methods.close();
      }
    }),
    onClickDropItem: useCallbackRef(() => {
      methods.close();
    })
  };

  props.onRef!(methods);

  useEffect(() => {
    document.body.addEventListener('click', handler.onClickBody as any);
    return () => {
      document.body.removeEventListener('click', handler.onClickBody as any);
    }
}, [])

  return (<> <DropdownContext.Provider value={{onClick: handler.onClickDropItem}}> <div className="dropdown-service" style={styles} ref={elRef}> {option?.render()} </div> </DropdownContext.Provider> </>);
}

export const DropdownOption: React.FC<{
  onClick?: (ev: React.MouseEvent<HTMLDivElement>) => void
  icon?: string,
  label?: string
}> = (props) => {

  const dropdown = useContext(DropdownContext);
  const handler = {
    onClick: (e: React.MouseEvent<HTMLDivElement>) => {
      dropdown.onClick();
      props.onClick && props.onClick(e);
    }
  };

  return (<div className="dropdown-item" onClick={handler.onClick}> {props.icon && <i className={`iconfont ${props.icon}`}/>} {props.label && <span>{props.label}</span>} {/* {props.children} 这个时元素默认的标签内的内容*/} </div>);
}

export const $$dropdown = (() => {

  let ins: any;
  return (options: DropdownOption) => {
    if (!ins) {
      const el = document.createElement('div');
      document.body.appendChild(el);
      ReactDOM.render(<DropdownComponent option={options} onRef={val => ins = val} />, el);
    }

    ins.show(options);
  }
})();
  • 注册事件监听并显示
// src/packages/VisualEditor.tsx

const handler = {
    onContextMenuBlock: (ev: React.MouseEvent<HTMLDivElement>, block: VisualEditorBlockData) => {
        ev.preventDefault();
        ev.stopPropagation();


        $$dropdown({
            reference: ev.nativeEvent,
            render: () => {
                return (<> <DropdownOption label="置顶节点" icon="icon-place-top" onClick={commander.placeTop}/> <DropdownOption label="置底节点" icon="icon-place-bottom" onClick={commander.placeBottom}/> <DropdownOption label="删除节点" icon="icon-delete" onClick={commander.delete}/> <DropdownOption label="查看数据" icon="icon-browse" {...{onClick: () => methods.showBlockData(block)}}/> <DropdownOption label="导入节点" icon="icon-import" {...{onClick: () => methods.importBlockData(block)}}/> </>)
            }
        });
    }
};

传送门

右键菜单.png

Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(三)

今天的文章Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(二)分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/21080.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注