一、搭建框架
二、组件拖拽与渲染
三、拖拽调整组件大小
十、组件拖拽辅助线对齐
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
赋值 - 在
container
的onMousedown
监听事件中将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.tsx
在visual-editor
中引用
- 将当前选中的block和编辑器配置
config
传入 updateBlock
和updateModelValue
用来更新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
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!