我们这一部分主要是对最右侧图层面板功能进行剖析,完成对应的功能的开发:
每个图层都对应编辑器上面的元素,有多少个元素就对应多少个图层,主要的功能如下:
回车确认,点击esc退出,点击外部区域确定。
图层和编辑器中的元素都是一一对应的,
// editor.ts
export interface EditorProps {
// 供中间编辑器渲染的数组
components: ComponentData[];
// 当前编辑的是哪个元素,uuid
currentElement: string
}
export interface ComponentData {
// 这个元素的 属性,属性请详见下面
props: Partial<AllComponentProps>;
// id,uuid v4 生成
id: string;
// 业务组件库名称 l-text,l-image 等等
name: 'l-text' | 'l-image' | 'l-shape';
}
在editor.ts中,components
其实就是对应的图层,有对应的一些属性ComponentData
,对于不同的状态,我们来添加对应的标识符来添加特定的标识符来表示他的状态即可。
{
…
isLocked: boolean;
isHidden: boolean;
}
// LayerList.vue
<ul :list="list" class="ant-list-items ant-list-border">
<li class="ant-list-item" v-for="item in list" :key="item.id">
<a-tooltip :title="item.isHidden ? '显示' : '隐藏'">
<a-button shape="circle">
<template v-slot:icon v-if="item.isHidden"
><EyeInvisibleOutlined />
</template>
<template v-slot:icon v-else><EyeOutlined /> </template>
</a-button>
</a-tooltip>
<a-tooltip :title="item.isLocked ? '解锁' : '锁定'">
<a-button shape="circle">
<template v-slot:icon v-if="item.isLocked"
><LockOutlined />
</template>
<template v-slot:icon v-else><UnlockOutlined /> </template>
</a-button>
</a-tooltip>
<span>{{ item.layerName }}</span>
</li>
</ul>
// list的数据来源:在点击左侧组件模板库的时候,会在store中发射一个事件:
// Editor.vue
// 右侧图层设置组件(其中components就是store中的components)
// const components = computed(() => store.state.editor.components);
<layer-list
:list="components"
:selectedId="currentElement && currentElement.id"
@change="handleChange"
@select="setActive"
>
</layer-list>
// 点击左侧模板库某个组件触发的事件
const addItem = (component: any) => {
store.commit('addComponent', component);
};
// editor.ts
addComponent: setDirtyWrapper((state, component: ComponentData) => {
component.layerName = '图层' + (state.components.length + 1);
state.components.push(component);
}),
// 比如点击大标题,在addItem中对应的参数如下:
component: {
// 通过pageUUid生成的唯一主键
id: '3c78b476-7a8d-4ad1-b944-9b163993595d',
// 动态需要渲染的组件
name: "l-text",
props: {
actionType: "";
backgroundColor: "";
borderColor: "#000";
borderRadius: "0";
borderStyle: "none";
borderWidth: "0";
boxShadow: "0 0 0 #000000";
color: "#000000";
fontFamily: "";
fontSize: "30px";
fontStyle: "normal";
fontWeight: "bold";
height: "";
left: "0";
lineHeight: "1";
opacity: "1";
paddingBottom: "0px";
paddingLeft: "0px";
paddingRight: "0px";
paddingTop: "0px";
position: "absolute";
right: "0";
tag: "h2";
text: "大标题";
textAlign: "left";
textDecoration: "none";
top: "0";
url: "";
width: "100px";
}
// 隐藏
<a-tooltip :title="item.isHidden ? '显示' : '隐藏'">
<a-button
shape="circle"
@click.stop="handleChange(item.id, 'isHidden', !item.isHidden)"
>
<template v-slot:icon v-if="item.isHidden"
><EyeInvisibleOutlined />
</template>
<template v-slot:icon v-else><EyeOutlined /> </template>
</a-button>
</a-tooltip>
// 锁定
<a-tooltip :title="item.isLocked ? '解锁' : '锁定'">
<a-button
shape="circle"
@click.stop="handleChange(item.id, 'isLocked', !item.isLocked)"
>
<template v-slot:icon v-if="item.isLocked"
><LockOutlined />
</template>
<template v-slot:icon v-else><UnlockOutlined /> </template>
</a-button>
</a-tooltip>
const handleChange = (id: string, key: string, value: boolean) => {
const data = {
id,
key,
value,
isRoot: true,
};
context.emit("change", data);
};
// 最终在子组件中emit chang事件,父组件中触发该方法,
const handleChange = (e: any) => {
console.log('event', e);
store.commit('updateComponent', e);
};
// 对store中的updateComponent进行稍微的改造
// 原来的updateComponent
// 这个主要针对于最右侧面板设置区域中的属性设置进行更新的,改变的是props的值。
updateComponent(state, { key, value }) {
const updatedComponent = state.components.find(
(component) => component.id === state.currentElement
);
if(updatedComponent) {
updatedComponent.props[key as keyof TextComponentProps] = value;
}
}
// 现在的
updateComponent(state, { key, value, id, isRoot }) {
const updatedComponent = state.components.find(
(component) => component.id === (id || state.currentElement)
);
if(updatedComponent) {
if(isRoot) {
(updatedComponent as any)[key as string] = value;
}
updatedComponent.props[key as keyof TextComponentProps] = value;
}
}
// 增加isRoot主要用来判断改变的是否是props中的某一项的值,我们进行的是展示隐藏,锁定不锁定的功能,所以直接改变key值就行:
export interface ComponentData {
// 这个元素的 属性,属性请详见下面
props: Partial<AllComponentProps>;
// id,uuid v4 生成
id: string;
// 业务组件库名称 l-text,l-image 等等
name: 'l-text' | 'l-image' | 'l-shape';
// 图层是否隐藏
isHidden?: boolean;
// 图层是否锁定
isLocked?: boolean;
// 图层名称
layerName?: string;
}
// Editor.vue
// 根据isLocked来判断右侧面板设置区域属性设置是否可以进行编辑
<a-tab-pane key="component" tab="属性设置" class="no-top-radius">
<div v-if="currentElement">
<edit-group
v-if="!currentElement.isLocked"
:props="currentElement.props"
@change="handleChange"
></edit-group>
<div v-else>
<a-empty>
<template #description>
该元素已被锁定,无法被编辑
</template>
</a-empty>
</div>
</div>
<pre>
{{ currentElement && currentElement.props }}
</pre>
</a-tab-pane>
// 根据hidden属性来控制中间画布区域是否可以进行显示与隐藏
// EditorWrapper.vue
:class="{ active: active, hidden: hidden }"
图层重命名组件,就是在右侧面板设置中的图层设置区域,点击图层名称,变成可输入的输入框形式,可以完成图层名称的更新,并且可以添加一些键盘事件,点击回车可以显示新的值,点击esc后显示刚开始的旧的值。在点击input区域外侧恢复文本区域,并且显示新的值。基于这些,我们可以抽离出一个InlineEdit
组件
InlineEdit
显示默认文本区域,点击以后显示为 Input
Input 中的值显示为文本中的值
更新值以后,键盘事件 - (useKeyPress)
- 点击回车以后恢复文本区域,并且显示新的值
- 点击 ESC 后恢复文本区域,并且显示刚开始的旧的值,更新值以后,点击事件 - (useClickOutside)
- 点击 Input 区域外侧恢复文本区域,并且显示新的值
简单验证
- 当 Input值为空的时候,不恢复,并且显示错误。
最初的InlineEdit组件
// InlineEdit.vue
<template>
<div class="inline-edit" @click.stop="handleClick" ref="wrapper">
<input
v-model="innerValue"
v-if="isEditing"
placeholder="文本不能为空"
ref="inputRef"
/>
<slot v-else :text="innerValue"><span>{{innerValue}}</span></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, nextTick, ref, watch } from 'vue'
export default defineComponent({
name: 'inline-edit',
props: {
value: {
type: String,
required: true
}
},
emits: ['change'],
setup (props, context) {
const innerValue = ref(props.value)
const isEditing = ref(false)
const handleClick = () => {
isEditing.value = true
}
return {
handleClick,
innerValue,
isEditing
}
}
})
</script>
<style>
.inline-edit {
cursor: pointer;
}
.ant-input.input-error {
border: 1px solid #f5222d;
}
.ant-input.input-error:focus {
border-color: #f5222d;
}
.ant-input.input-error::placeholder {
color: #f5222d;
}
</style>
// hooks/useKeyPress.ts
import { onMounted, onUnmounted } from 'vue'
const useKeyPress = (key: string, cb: () => any) => {
const trigger = (event: KeyboardEvent) => {
if (event.key === key) {
cb()
}
}
onMounted(() => {
document.addEventListener('keydown', trigger)
})
onUnmounted(() => {
document.removeEventListener('keydown', trigger)
})
}
// 组件中使用 InlineEdit.vue
// 缓存之前编辑的值
watch(isEditing, (isEditing) => {
if (isEditing) {
cachedOldValue = innerValue.value
}
})
useKeyPress("Enter", () => {
if (isEditing.value) {
isEditing.value = false;
context.emit("change", innerValue.value);
}
});
useKeyPress("Escape", () => {
if (isEditing.value) {
isEditing.value = false;
innerValue.value = cachedOldValue;
}
});
// 父组件接受change事件
<inline-edit
class="edit-area"
:value="item.layerName"
@change="
(value) => {
handleChange(item.id, 'layerName', value)
}
"
></inline-edit>
键盘响应的功能常规做法其实就是向document.addEventListener
上添加各种一系列的回调,在项目后期还会遇到各种复杂的键盘响应,比如组合键,ctrl+c,ctrl+v
,我们可能会进化到第三方库来完成对应的需求,先使用实际代码演示一个比较简单的功能,然后再使用第三方库的解决方案,这样能让我们了解第三方库的基本原理。上面就是按键响应的基本原理。
后来增加一个需求:在点击编辑,变成输入框的时候,增加自动聚焦的功能:
//这样写有问题
watch(isEditing, isEditing => {
if (isEditing) {
cachedOldValue = innerValue.value
if (inputRef.value) {
inputRef.value.focus()
}
}
})
这样写的话,发现不起任何作用,input没有自动聚焦。
watchEffect
在vue3的官网api中,我们可以看到:
watchEffect的flush默认是pre
,默认是在dom生成之前执行的,所以拿不到dom。但是vue没有提供可以改变flush的选项,没有办法在post
中执行。所以我们这里可以vue提供的nextTick,等待dom生成完毕后,再运行,改写后的:
watch(isEditing, async (isEditing) => {
if (isEditing) {
cachedOldValue = innerValue.value
await nextTick()
if (inputRef.value) {
inputRef.value.focus()
}
}
})
// hooks/useClickOutside.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue';
const useClickOutside = (elementRef: Ref<null | HTMLElement>) => {
const isClickOutside = ref(false)
const handler = (e: MouseEvent) => {
if (elementRef.value && e.target) {
// 检查当前元素是否在目标元素范围内
if (elementRef.value.contains(e.target as HTMLElement)) {
isClickOutside.value = false
} else {
isClickOutside.value = true
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return isClickOutside
}
// 组件中使用 InlineEdit.vue
const inputRef = ref<null | HTMLInputElement>(null)
const isOutside = useClickOutside(wrapper)
watch(isOutside, (newValue) => {
if (newValue && isEditing.value) {
isEditing.value = false
context.emit('change', innerValue.value)
}
// 这里不这样做会有点问题,后面会将。
isOutside.value = false;
})
判断是否点击到了对应的dom节点的功能是比较常见的,比如说下拉菜单的关闭,点击下拉菜单的外面,会关闭下拉菜单使用的是同一个思想。