安装Element-plus 组件库,并在项目中使用
yarn add element-plus
复制代码
import ElementPlus from "element-plus";
import "element-plus/lib/theme-chalk/index.css";
const app = createApp(App);
app.use(ElementPlus);
app.mount("#app");
复制代码
配置信息
- 在
visual-editor.utils.ts
中创建createVisualEditorConfig函数,用于创建编辑器配置
// 组件结构
export interface VisualEditorComponent {
key: string;
label: string;
preview: () => JSX.Element;
render: () => JSX.Element;
}
// 创建编辑器配置
export function createVisualEditorConfig() {
const componentList: VisualEditorComponent[] = [];
const componentMap: Record<string, VisualEditorComponent> = {};
return {
componentList,
componentMap,
registry: (key: string, component: Omit<VisualEditorComponent, "key">) => {
const comp = { ...component, key };
componentList.push(comp);
componentMap[key] = comp;
},
};
}
// 配置类型
export type VisualEditorConfig = ReturnType<typeof createVisualEditorConfig>;
复制代码
- 新建编辑器配置文件
visual-editor.tsx
- 创建配置,并在配置对象中注册文本、按钮、输入框三个组件
- 注册的组件对象放在
componentList
(用于渲染组件菜单)和componentMap
(方便查找)里。 - label-组件名,preview-在组件菜单中显示的内容,render-组件拖拽到容器中显示的内容
import { createVisualEditorConfig } from "./visual-editor.utils";
import { ElButton, ElInput } from "element-plus";
const visualConfig = createVisualEditorConfig();
visualConfig.registry("text", {
label: "文本",
preview: () => "预览文本",
render: () => "渲染文本",
});
visualConfig.registry("button", {
label: "按钮",
preview: () => <ElButton>按钮</ElButton>,
render: () => <ElButton>渲染按钮</ElButton>,
});
visualConfig.registry("input", {
label: "输入框",
preview: () => <ElInput />,
render: () => <ElInput />,
});
export default visualConfig;
复制代码
- 将配置导\传入
visual-editor
中,并进行组件菜单渲染
<div class="menu">
{props.config?.componentList.map((component) => (
<div class="menu-item">
<span class="menu-item-label">{component.label}</span>
{component.preview()}
</div>
))}
</div>
复制代码
五、组件拖拽到容器,并进行渲染
菜单拖拽到容器
- 给容器添加
containerRef
- 给左侧渲染的组件添加
draggable
属性(拖拽效果),并监听onDragstart
、onDragend
事件
<div class="visual-editor">
<div class="menu">
{props.config?.componentList.map((component) => (
<div
class="menu-item"
draggable
onDragend={menuDragger.dragend}
onDragstart={(e) => menuDragger.dragstart(e, component)}
>
<span class="menu-item-label">{component.label}</span>
{component.preview()}
</div>
))}
</div>
<div class="head">head</div>
<div class="operator">operator</div>
<div class="body">
<div class="content">
<div
class="container"
ref={containerRef}
style={containerStyles.value}
>
{(dataModel.value?.blocks || []).map((block, index: number) => (
<VisualEditorBlock block={block} key={index} />
))}
</div>
</div>
</div>
</div>
复制代码
- dataModel编辑器数据源,通过useModel方法来实现双向绑定
-
containerRef
画布容器的dom引用 -
menuDragger
鼠标拖拽事件监听- 当鼠标在组件按下时,触发组件的
dragstart
, 在dragstart中监听容器containerRef
的dragenter
dragover
dragleave
和drop
事件 - 当组件拖拽进容器时触发
dragenter
事件,将鼠标状态dropEffect
设置为move
(可放置的效果) - 当组件拖拽离开容器时触发
dragleave
事件,将鼠标状态dropEffect
设置为none
(不可放置的效果) - 当组件拖拽进画布并松开鼠标时,触发容器的
drop
事件和组件的dragend
事件 - 在
drop
事件中获取位置信息,并在数据源中添加新的block - 在
dragend
事件中移除容器监听的拖拽事件
- 当鼠标在组件按下时,触发组件的
// 编辑器数据源
const dataModel = useModel(
() => props.modelValue,
(val) => ctx.emit("update:modelValue", val)
);
const containerStyles = computed(() => ({
width: `${props.modelValue?.container.width}px`,
height: `${props.modelValue?.container.height}px`,
}));
const containerRef = ref({} as HTMLElement);
const menuDragger = {
current: {
component: null as null | VisualEditorComponent,
},
dragstart: (e: DragEvent, component: VisualEditorComponent) => {
containerRef.value.addEventListener("dragenter", menuDragger.dragenter);
containerRef.value.addEventListener("dragover", menuDragger.dragover);
containerRef.value.addEventListener("dragleave", menuDragger.dragleave);
containerRef.value.addEventListener("drop", menuDragger.drop);
menuDragger.current.component = component;
},
dragenter: (e: DragEvent) => {
e.dataTransfer!.dropEffect = "move";
},
dragover: (e: DragEvent) => {
e.preventDefault();
},
dragleave: (e: DragEvent) => {
e.dataTransfer!.dropEffect = "none";
},
dragend: (e: DragEvent) => {
containerRef.value.removeEventListener(
"dragenter",
menuDragger.dragenter
);
containerRef.value.removeEventListener(
"dragover",
menuDragger.dragover
);
containerRef.value.removeEventListener(
"dragleave",
menuDragger.dragleave
);
containerRef.value.removeEventListener("drop", menuDragger.drop);
menuDragger.current.component = null;
},
drop: (e: DragEvent) => {
console.log("drop", menuDragger.current.component);
const blocks = dataModel.value?.blocks || [];
blocks.push({
top: e.offsetY,
left: e.offsetX,
});
console.log("x", e.offsetX);
console.log("y", e.offsetY);
dataModel.value = {
...dataModel.value,
blocks,
} as VisualEditorModelValue;
},
};
复制代码
组件在容器内渲染
- 将编辑器配置传入block中
- 根据block的
componentKey
属性在componentMap
中查到注册的组件,调用render方法进行渲染
import { computed, defineComponent, PropType } from "vue";
import {
VisualEditorBlockData,
VisualEditorConfig,
} from "./visual-editor.utils";
export const VisualEditorBlock = defineComponent({
props: {
block: {
type: Object as PropType<VisualEditorBlockData>,
},
config: {
type: Object as PropType<VisualEditorConfig>,
},
},
setup(props) {
const styles = computed(() => ({
top: `${props.block?.top}px`,
left: `${props.block?.left}px`,
}));
return () => {
const component = props.config?.componentMap[props.block!.componentKey];
const Render = component?.render();
return (
<div class="visual-editor-block" style={styles.value}>
{Render}
</div>
);
};
},
});
复制代码
实现效果
六、组件的选中和拖拽移动
组件选中
- 组件在容器渲染后会有默认行为,给组件添加伪元素,禁止组件的响应,选中状态下给元素添加虚线边框
.visual-editor-block {
position: absolute;
&::after {
$space: -3px;
position: absolute;
top: $space;
left: $space;
right: $space;
bottom: $space;
content: "";
}
&-focus {
&::after {
// 边框显示在伪元素上面
border: 1px dashed $primary;
}
}
}
复制代码
- 使用block中定义的focus属性,来控制组件的选中状态
- 在
setup
函数中声明focusData
和focusHandler
来处理,组件的选中
// 计算选中与未选中的block数据
const focusData = computed(() => {
const focus: VisualEditorBlockData[] =
dataModel.value?.blocks.filter((v) => v.focus) || [];
const unfocus: VisualEditorBlockData[] =
dataModel.value?.blocks.filter((v) => !v.focus) || [];
return {
focus, // 此时选中的数据
unfocus, // 此时未选中的数据
};
});
// 对外暴露的一些方法
const methods = {
clearFocus: (block?: VisualEditorBlockData) => {
let blocks = dataModel.value?.blocks || [];
if (blocks.length === 0) return;
if (block) {
blocks = blocks.filter((v) => v !== block);
}
blocks.forEach((block) => (block.focus = false));
},
};
// 处理组件的选中状态
const focusHandler = (() => {
return {
container: {
onMousedown: (e: MouseEvent) => {
e.stopPropagation();
methods.clearFocus();
},
},
block: {
onMousedown: (e: MouseEvent, block: VisualEditorBlockData) => {
e.stopPropagation();
e.preventDefault();
// 只有元素未选中状态下, 才去处理
if (!block.focus) {
if (!e.shiftKey) {
block.focus = !block.focus;
methods.clearFocus(block);
} else {
block.focus = true;
}
}
// 处理组件的选中移动
blockDragger.mousedown(e);
},
},
};
})();
复制代码
- 给容器和block组件添加
onMousedown
事件监听 - 点击组件时触发,block的
onMousedown
事件,将当前组件选中,其他组件取消选中 - 如果
e.shiftKey
有值,说明是多选,不需要将之前选中的组件 取消选中 - 点击容器时,取消组件的选中状态
<div class="content">
<div
class="container"
ref={containerRef}
style={containerStyles.value}
{...focusHandler.container}
>
{(dataModel.value?.blocks || []).map((block, index: number) => (
<VisualEditorBlock
block={block}
key={index}
config={props.config}
{...{
onMousedown: (e: MouseEvent) =>
focusHandler.block.onMousedown(e, block),
}}
/>
))}
</div>
</div>
复制代码
组件拖拽移动
-
blockDragger
来处理组件的拖拽 - 在
focusHandler
的blockonMousedown
方法中,最后我们将事件传给了blockDragger
的mousedown
方法 - 在
mousedown
中,通过dragState
先记录一下鼠标按下的最初位置信息,并拖拽组件的监听mousemove
和mouseup
事件 - 在
mousemove
中通过计算鼠标 按下时位置 与 当前位置 的差值,计算出最新的选中的组件坐标。
// 处理组件在画布上他拖拽
const blockDragger = (() => {
let dragState = {
startX: 0,
startY: 0,
startPos: [] as { left: number; top: number }[],
};
const mousemove = (e: MouseEvent) => {
const durX = e.clientX - dragState.startX;
const durY = e.clientY - dragState.startY;
focusData.value.focus.forEach((block, i) => {
block.top = dragState.startPos[i].top + durY;
block.left = dragState.startPos[i].left + durX;
});
};
const mouseup = (e: MouseEvent) => {
document.removeEventListener("mousemove", mousemove);
document.removeEventListener("mouseup", mouseup);
};
const mousedown = (e: MouseEvent) => {
dragState = {
startX: e.clientX,
startY: e.clientY,
startPos: focusData.value.focus.map(({ top, left }) => ({
top,
left,
})),
};
document.addEventListener("mousemove", mousemove);
document.addEventListener("mouseup", mouseup);
};
return { mousedown };
})();
复制代码
实现效果