紧接着前面的Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(一)
- 主页面结构:左侧菜单栏可选组件列表、中间容器画布、右侧编辑组件定义的属性;
- 左侧菜单栏可选组件列表渲染;
- 从菜单栏拖拽组件到容器;
- 组件(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>
效果图
九、撤销,重做,删除、清空命令实现
- 基础命令初始化
// 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)}}/> </>)
}
});
}
};
Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(三)
今天的文章Vite2 + React17 + Typescript4 + Ant Design 4 低代码可视化拖拽页面编辑器(二)分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/21080.html