可视化拖拽页面编辑器 四__Vue.js
发布于 3 年前 作者 banyungong 1285 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

主题列表:juejin, github, smartblue, cyanosis, channing-cyan, fancy, hydrogen, condensed-night-purple, greenwillow, v-green, vue-pro, healer-readable, mk-cute, jzman, geek-black, awesome-green, qklhk-chocolate

贡献主题:https://github.com/xitu/juejin-markdown-themes

theme: juejin highlight:

一、搭建框架

二、组件拖拽与渲染

三、拖拽调整组件大小

十、组件拖拽辅助线对齐

1、数据准备

  • 声明 VisualEditorMarkLine 作为辅助线的数据类型
export interface VisualEditorMarkLine {
  x: { left: number; showLeft: number }[];
  y: { top: number; showTop: number }[];
}
  • 先画一下辅助线的样式, 在container中添加
<div class="mark-line-x" style={{ left: `200px` }}></div>
<div class="mark-line-y" style={{ top: `200px` }}></div>

样式

	.mark-line-y {
          position: absolute;
          left: 0;
          right: 0;
          border-top: 1px dashed $primary;
        }
        .mark-line-x {
          position: absolute;
          top: 0;
          bottom: 0;
          border-left: 1px dashed $primary;
        }

2.缓存当前选中的block

  • 定义存储对象
    const state = reactive({
      selectBlock: null as null | VisualEditorBlockData,
    });

  • 在block的 onMousedown监听事件中给 selectBlock赋值
  • containeronMousedown监听事件中将 selectBlock置为空

3.辅助线显示逻辑

  • blockDragger内定义mark对象,并返回给外部使用
  • 在拖动block时,匹配到对应坐标,就赋值给mark显示对应的对齐辅助线
    // 用于视图展示的辅助线
      const mark = reactive({
        x: null as null | number,
        y: null as null | number,
      });
      
    // 视图: 如果mark的x坐标有值,就显示垂直辅助线,如果y坐标有值,就显示水平辅助线
	    {blockDragger.mark.x && (
                <div
                  class="mark-line-x"
                  style={{ left: `${blockDragger.mark.x}px` }}
                ></div>
              )}
              {blockDragger.mark.y && (
                <div
                  class="mark-line-y"
                  style={{ top: `${blockDragger.mark.y}px` }}
                ></div>
              )}

  • 当选中block后 mousedown事件,在dragState中记住初始位置,计算出选中block与所有未选中block,左右、上下对齐的各种情况的坐标位置,并进行缓存。
    • 上下对齐:顶对顶、中对中、底对底、顶对底、底对顶
    • 左右对齐:左对左、中对中、右对右、左对右、右对左
      let dragState = {
        startX: 0,
        startY: 0,
        startPos: [] as { left: number; top: number }[],

+        startLeft: 0,
+        startTop: 0,
+        markLines: {} as VisualEditorMarkLine,
      };
      
      
	const mousedown = (e: MouseEvent) => {
        dragState = {
          startX: e.clientX,
          startY: e.clientY,
          startPos: focusData.value.focus.map(({ top, left }) => ({
            top,
            left,
          })),
+         startTop: state.selectBlock!.top,
+         startLeft: state.selectBlock!.left,
+         markLines: (() => {
+           const { focus, unfocus } = focusData.value;
+           // 当前选中的block
+           const { top, left, width, height } = state.selectBlock!;
+           let lines = { x: [], y: [] } as VisualEditorMarkLine;
+           unfocus.forEach((block) => {
+             const { top: t, left: l, width: w, height: h } = block;+
+             // y轴对齐方
+             lines.y.push({ top: t, showTop: t }); // 顶对顶
+             lines.y.push({ top: t + h, showTop: t + h }); // 底对底
+             lines.y.push({ top: t + h / 2 - height / 2, showTop: t + h / 2 }); // 中对中
+             lines.y.push({ top: t - height, showTop: t }); // 顶对底
+             lines.y.push({ top: t + h - height, showTop: t + h }); //

+             // x轴对齐方式
+             lines.x.push({ left: l, showLeft: l }); // 顶对顶
+             lines.x.push({ left: l + w, showLeft: l + w }); // 底对底
+             lines.x.push({
+               left: l + w / 2 - width / 2,
+               showLeft: l + w / 2,
+             }); // 中对中
+             lines.x.push({ left: l - width, showLeft: l }); // 顶对底
+             lines.x.push({ left: l + w - width, showLeft: l + w }); // 中对中
+           });
+           return lines;
+         })(),
        };
        document.addEventListener("mousemove", mousemove);
        document.addEventListener("mouseup", mouseup);
      };

  • 拖动选中的block时,将block的当前位置与 缓存的计算好的 跟其他block的对齐位置进行匹配,如果相对位置在5以内就显示辅助线位置
    const mousemove = (e: MouseEvent) => {
        let { clientX: moveX, clientY: moveY } = e;
        const { startX, startY } = dragState;

        // 按下shift键时,组件只能横向或纵向移动
        if (e.shiftKey) {
          // 当鼠标横向移动的距离 大于 纵向移动的距离,将纵向的偏移置为0
          if (Math.abs(e.clientX - startX) > Math.abs(e.clientY - startY)) {
            moveY = startY;
          } else {
            moveX = startX;
          }
        }
		// 当前block的位置
        const currentLeft = dragState.startLeft + moveX - startX;
        const currentTop = dragState.startTop + moveY - startY;
        const currentMark = {
          x: null as null | number,
          y: null as null | number,
        };
		
        // 在缓存位置中查找,是否有匹配坐标
        for (let i = 0; i < dragState.markLines.y.length; i++) {
          const { top, showTop } = dragState.markLines.y[i];
          // 相对位置在5以内就显示
          if (Math.abs(top - currentTop) < 5) {
            moveY = top + startY - dragState.startTop;
            currentMark.y = showTop;
            break;
          }
        }

        for (let i = 0; i < dragState.markLines.x.length; i++) {
          const { left, showLeft } = dragState.markLines.x[i];
          if (Math.abs(left - currentLeft) < 5) {
            moveX = left + startX - dragState.startLeft;
            currentMark.x = showLeft;
            break;
          }
        }

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

        focusData.value.focus.forEach((block, i) => {
          block.top = dragState.startPos[i].top + durY;
          block.left = dragState.startPos[i].left + durX;
        });
		// 赋值给mark对象,在视图中显示
        mark.x = currentMark.x;
        mark.y = currentMark.y;
      };

commit代码

十一、属性面板与组件属性设置

1. 数据准备

  • 给block添加 props属性,用于控制组件属性

  • 新建visual-editor.props.tsx文件,用户 属性值类型定义

    • VisualEditorPropsType定义属性值 展示的几种类型,如按钮标题 需要录入用input,按钮类型用select下拉选择
    • createEditorInputProps createEditorSelectProps 定义创建输入框和下拉选择框属性的方法,统一进行创建
export enum VisualEditorPropsType {
  input = "input",
  color = "color",
  select = "select",
}

export interface VisualEditorProps {
  type: VisualEditorPropsType;
  label: string;
  options?: VisualEditorSelectOptions;
}

/** ------input------- */
export function createEditorInputProps(label: string): VisualEditorProps {
  return {
    type: VisualEditorPropsType.input,
    label,
  };
}

/** ------select------- */
export type VisualEditorSelectOptions = {
  label: string;
  val: string;
}[];

export function createEditorSelectProps(
  label: string,
  options: VisualEditorSelectOptions
): VisualEditorProps {
  return {
    type: VisualEditorPropsType.select,
    label,
    options,
  };
}

2.在visual.config.tsx配置中定义组件属性和渲染规则

  • 渲染组件时,将block中定义的props数据传入,将props传入组件进行渲染
visualConfig.registry("button", {
  label: "按钮",
  preview: () => <ElButton>按钮</ElButton>,
  render: ({ props, size }) => {
    return (
      <ElButton
        type={props.type}
        size={props.size}
        style={{
          width: size.width ? `${size.width}px` : undefined,
          height: size.height ? `${size.height}px` : undefined,
        }}
      >
        {props.text || "按钮"}
      </ElButton>
    );
  },
  resize: { width: true, height: true },
  props: {
    text: createEditorInputProps("显示文本"),
    type: createEditorSelectProps("按钮类型", [
      { label: "基础", val: "primary" },
      { label: "成功", val: "success" },
      { label: "警告", val: "warning" },
      { label: "危险", val: "danger" },
      { label: "提示", val: "info" },
      { label: "文本", val: "text" },
    ]),
    size: createEditorSelectProps("按钮大小", [
      { label: "默认", val: "" },
      { label: "中等", val: "medium" },
      { label: "小", val: "small" },
      { label: "极小", val: "mini" },
    ]),
  },
});
  • visual-editor-block.tsx中将block的属性传给render函数
    const renderProps = {
        size: props.block?.hasResize
          ? {
              width: props.block.width,
              height: props.block.height,
            }
          : {},
+        props: props.block?.props || {},
      };
      const Render = component?.render(renderProps);

3. 新建一个属性面板组件 visual-editor-operator.tsxvisual-editor中引用

  • 将当前选中的block和编辑器配置config传入
  • updateBlockupdateModelValue用来更新block和容器的属性
	<VisualOperatorEditor
          block={state.selectBlock!}
          config={props.config}
          dataModel={dataModel as any}
          updateBlock={updateBlockProps}
          updateModelValue={updateModelValue}
        />
visual-editor-operator.tsx代码如下:
import deepcopy from "deepcopy";
import {
  ElButton,
  ElColorPicker,
  ElForm,
  ElFormItem,
  ElInput,
  ElInputNumber,
  ElOption,
  ElSelect,
} from "element-plus";
import { defineComponent, PropType, reactive, watch } from "vue";
import {
  VisualEditorProps,
  VisualEditorPropsType,
} from "./visual-editor.props";
import {
  VisualEditorBlockData,
  VisualEditorConfig,
  VisualEditorModelValue,
} from "./visual-editor.utils";

export const VisualOperatorEditor = defineComponent({
  props: {
    block: { type: Object as PropType<VisualEditorBlockData> },
    config: { type: Object as PropType<VisualEditorConfig> },
    dataModel: {
      type: Object as PropType<VisualEditorModelValue>,
      required: true,
    },
    updateBlock: {
      type: Function as PropType<
        (
          newBlock: VisualEditorBlockData,
          oldBlock: VisualEditorBlockData
        ) => void
      >,
      required: true,
    },
    updateModelValue: {
      type: Function as PropType<(...args: any[]) => void>,
      required: true,
    },
  },

  setup(props) {
    const state = reactive({
      editData: {} as any,
    });

    const methods = {
      apply: () => {
        if (!props.block) {
          // 当前编辑容器属性
          props.updateModelValue({
            ...(props.dataModel as any).value,
            container: state.editData,
          });
        } else {
          // 当前编辑block数据属性
          const newBlock = state.editData;
          debugger;
          props.updateBlock(newBlock, props.block);
        }
      },
      reset: () => {
        if (!props.block) {
          state.editData = deepcopy((props.dataModel as any).value.container);
        } else {
          state.editData = deepcopy(props.block);
        }
      },
    };

    watch(
      () => props.block,
      () => {
        methods.reset();
      },
      {
        immediate: true,
      }
    );

    const renderEditor = (propName: string, propConfig: VisualEditorProps) => {
      return {
        [VisualEditorPropsType.input]: () => (
          <ElInput v-model={state.editData.props[propName]} />
        ),
        [VisualEditorPropsType.color]: () => (
          <ElColorPicker v-model={state.editData.props[propName]} />
        ),
        [VisualEditorPropsType.select]: () => (
          <ElSelect
            placeholder="请选择"
            v-model={state.editData.props[propName]}
          >
            {(() => {
              return propConfig.options!.map((opt, i) => (
                <ElOption key={i} label={opt.label} value={opt.val} />
              ));
            })()}
          </ElSelect>
        ),
      }[propConfig.type]();
    };

    return () => {
      let content: JSX.Element[] = [];
      if (!props.block) {
        content.push(
          <>
            <ElFormItem label="容器宽度">
              <ElInputNumber
                v-model={state.editData.width}
                {...{ step: 100 }}
              />
            </ElFormItem>
            <ElFormItem label="容器高度">
              <ElInputNumber
                v-model={state.editData.height}
                {...{ step: 100 }}
              />
            </ElFormItem>
          </>
        );
      } else {
        const { componentKey } = props.block;
        const component = props.config?.componentMap[componentKey];

        if (component) {
          content.push(
            <ElFormItem label="组件标识">
              <ElInput v-model={state.editData.slotName} />
            </ElFormItem>
          );
          if (component.props) {
            content.push(
              <>
                {Object.entries(component.props).map(
                  ([propName, propConfig]) => (
                    <ElFormItem
                      {...{ labelPosition: "top" }}
                      label={propConfig.label}
                      key={propName}
                    >
                      {renderEditor(propName, propConfig)}
                    </ElFormItem>
                  )
                )}
              </>
            );
          }
        }
      }
      return (
        <div class="operator">
          <ElForm>
            {content.map((el) => el)}
            <ElFormItem>
              <ElButton type="primary" {...({ onClick: methods.apply } as any)}>
                应用
              </ElButton>
              <ElButton {...({ onClick: methods.reset } as any)}>重置</ElButton>
            </ElFormItem>
          </ElForm>
        </div>
      );
    };
  },
});
  • 当未选中block时,默认渲染容器的宽度和高度属性,可以修改容器的宽高
  • 选中block时, 根据block的componentKey,获取在config注册的组件属性,渲染block的属性列表(renderEditor方法)
	const { componentKey } = props.block;
        const component = props.config?.componentMap[componentKey];

        if (component) {
          content.push(
            <ElFormItem label="组件标识">
              <ElInput v-model={state.editData.slotName} />
            </ElFormItem>
          );
          if (component.props) {
            content.push(
              <>
                {Object.entries(component.props).map(
                  ([propName, propConfig]) => (
                    <ElFormItem
                      {...{ labelPosition: "top" }}
                      label={propConfig.label}
                      key={propName}
                    >
                      {renderEditor(propName, propConfig)}
                    </ElFormItem>
                  )
                )}
              </>
            );
          }
        }
  • 将 block对象深拷贝到 editData中,根据propName将属性值和组件进行双向数据绑定,修改的属性值会保存在 editData
const renderEditor = (propName: string, propConfig: VisualEditorProps) => {
      return {
        [VisualEditorPropsType.input]: () => (
          <ElInput v-model={state.editData.props[propName]} />
        ),
        [VisualEditorPropsType.color]: () => (
          <ElColorPicker v-model={state.editData.props[propName]} />
        ),
        [VisualEditorPropsType.select]: () => (
          <ElSelect
            placeholder="请选择"
            v-model={state.editData.props[propName]}
          >
            {(() => {
              return propConfig.options!.map((opt, i) => (
                <ElOption key={i} label={opt.label} value={opt.val} />
              ));
            })()}
          </ElSelect>
        ),
      }[propConfig.type]();
    };
  • 点击应用时调用 传入的updateBlock方法更新选中的block,调用updateModelValue更新容器的属性值
    // 更新block属性
    const updateBlockProps = (
      newBlock: VisualEditorBlockData,
      oldBlock: VisualEditorBlockData
    ) => {
      const blocks = [...dataModel.value!.blocks];
      const index = dataModel.value!.blocks.indexOf(state.selectBlock!);
      if (index > -1) {
        blocks.splice(index, 1, newBlock);
        dataModel.value!.blocks = deepcopy(blocks);
        state.selectBlock = dataModel.value!.blocks[index];
      }
    };

    // 更新容器属性值
    const updateModelValue = (newVal: VisualEditorModelValue) => {
      props.modelValue!.container = { ...newVal.container };
    };

commit代码

完整代码 GitHub

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: Miller 原文链接:https://juejin.im/post/6932778086903578632

回到顶部