web架构师编辑器内容-编辑器组件图层面板功能开发-锁定隐藏、键盘事件功能的开发

我们这一部分主要是对最右侧图层面板功能进行剖析,完成对应的功能的开发:

每个图层都对应编辑器上面的元素,有多少个元素就对应多少个图层,主要的功能如下:

  1. 锁定功能:点击锁定,在编辑器中没法编辑对应的组件属性,再次点击是取消锁定,恢复到可编辑的模式
  2. 可见化:点击隐藏,在编辑器中消失,再次点击,进行展示
  3. 最外层图层也是可以进行点击,单击图层就是选中的效果。在编辑器上就是自动选中的效果。
  4. 图层的文字也可以进行修改,单击图层的文字,会切换到编辑模式,展示成input输入框,可以进行文字的修改。回车确认,点击esc退出,点击外部区域确定。
  5. 比较复杂的功能:拖动排序,按住这个按钮拖动以后,可以改变图层的顺序。

图层属性需求分析

图层锁定和隐藏/显示以及选中

图层和编辑器中的元素都是一一对应的,

js 复制代码
// 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,对于不同的状态,我们来添加对应的标识符来添加特定的标识符来表示他的状态即可。

  • 在editor.ts的store中的components添加更多的标识符

{

...

isLocked: boolean;

isHidden: boolean;

}

  • 点击按钮切换为不同的值,使用这个值在页面上做判断
  • 点击选中,设置 currentElement的值

图层名称编辑

  • 添加更多属性 - layerName
  • 点击图层名称的时候,在input和普通标签之间切换
  • 添加按钮响应 - 对于 esc 和 enter 键的响应
    • 可能抽象一个通用的 hooks函数 - useKeyPress,可以处理与键盘相关的事件
  • 点击到input外部区域的响应
    • 可能抽象一个通用的 hooks函数 - useClickOutside

拖动改变顺序

  • 最有难度的一个需求,涉及到一个较复杂的交互
  • 最终目的其实就是改变store中components数组的顺序

代码实现

js 复制代码
// 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";
	}

最开始的样子

进行锁定隐藏操作

js 复制代码
// 隐藏
<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组件

js 复制代码
// 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函数:
js 复制代码
// 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,我们可能会进化到第三方库来完成对应的需求,先使用实际代码演示一个比较简单的功能,然后再使用第三方库的解决方案,这样能让我们了解第三方库的基本原理。上面就是按键响应的基本原理。

后来增加一个需求:在点击编辑,变成输入框的时候,增加自动聚焦的功能:

js 复制代码
//这样写有问题
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生成完毕后,再运行,改写后的:

js 复制代码
watch(isEditing, async (isEditing) => {
  if (isEditing) {
    cachedOldValue = innerValue.value
    await nextTick()
    if (inputRef.value) {
      inputRef.value.focus()
    }
  }
})
外侧点击整合hooks函数
js 复制代码
// 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节点的功能是比较常见的,比如说下拉菜单的关闭,点击下拉菜单的外面,会关闭下拉菜单使用的是同一个思想。

相关推荐
崔庆才丨静觅23 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax