可视化拖拽页面编辑器 二

安装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属性(拖拽效果),并监听onDragstartonDragend事件
<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中监听容器containerRefdragenter 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函数中声明focusDatafocusHandler来处理,组件的选中
    // 计算选中与未选中的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的block onMousedown方法中,最后我们将事件传给了blockDraggermousedown方法
  •  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 };
    })();
复制代码

实现效果 

未完待续