Skip to content

右键菜单组件的封装

Published:

前言

在网页上,我们经常需要为不同的区域设置不同的右键菜单。比如,在一个文档编辑器中,你右键点击文字区域,可能会弹出“复制”、“粘贴”等选项,而在图片区域右键点击时,弹出的选项可能是“保存图片”、“查看图片属性”等。这样,每个区域的右键菜单项都不一样。

为了方便管理和维护这些右键菜单,我们可以把右键菜单功能封装成一个通用的组件。我们只需要定义一次右键菜单的逻辑,就能在多个地方重复使用,并且可以根据需要灵活地配置每个区域的菜单项。

基本思路

设计方式

首先,我们需要考虑右键菜单应该挂载到哪个 DOM 元素。我们可以通过以下几种方式告诉 ContextMenu 组件应该绑定在哪些元素上:

  1. 将元素类名或元素对象作为 props 传递给 ContextMenu。
<template>
  <ContextMenu teleport=".container">
    <!-- items -->
  </ContextMenu>
</template>

这种方式会让 Vue.js 去直接操作 DOM 元素,不太适合我们想要的组件化设计。

  1. 将 ContextMenu 放在显示右键菜单的元素里,让 ContextMenu 读取父元素
<template>
  <div>
    <ContextMenu>
      <!-- items -->
    </ContextMenu>
  </div>
</template>

这种方法虽然可行,但仍然涉及直接操作 DOM,不够优雅。

  1. 将 ContextMenu 作为一个独立区域,并使用命名插槽传递菜单项目和激活区域
<div>
  <!-- 作为 image 右键菜单 -->
  <ContextMenu>
  	<template #default>
    	鼠标菜单项
    </template>
    <template #activator>
    	<Container>
      	<Image />
        ...
      </Container>
    </template>
  </ContextMenu>
  
  <!-- 另一个区域 -->
  <ContextMenu>
  	<template #default>
    	鼠标菜单项
    </template>
    <template #activator>
    	激活区域
    </template>
  </ContextMenu>
</div>

这种方式最灵活且符合组件化的思想。

菜单的定位

右键菜单通常是跟随鼠标的位置来显示,因此我们需要将菜单定位为 fixed。这样,菜单的位置会相对于视口进行调整。

<!-- ContextMenu.vue -->
<template>
  <div class="container">
    <slot />
  </div>

  <div class="context-menu" style="position: fixed"></div>
</template>
s

但问题来了,如果父元素使用了 transform 属性,固定定位的菜单就不再相对于视口,而是相对于父元素定位。这种情况下,菜单的位置可能会出现问题。

<!-- Parent.vue -->
<div class="container" style="transform:scale(1.1)">
  <!-- 右键菜单 -->
  <ContextMenu>
  	<template #default>
    	<Card>
      	<Image />
      </Card>
    </template>
    
    <template #activator="{ item }">
    	items
    </template>
  </ContextMenu>
  
</div>

一旦父组件使用了 transform 属性值,那么后代的固定定位元素就不会再是相对于视口了,而是相对于父组件的位置。那是不是会产生某些样式问题。而且我们在写 ContextMenu 组件的时候,并不知道上层组件是什么样子,它套了多少层,那么这些层级之间有没有使用 transform 属性 ContextMenu 是不知道的。

所以,为了解决这个问题,我们可以使用 Vue 的 Teleport 组件,将菜单的 DOM 结构直接传送到 body 元素下,从而确保菜单相对于视口定位。

<template>
  <div class="container">
    <slot />
  </div>

  <Teleport to="body">
    <div class="context-menu"></div>
  </Teleport>
</template>
s

控制菜单的位置和可见度

为了让菜单在不同的位置显示,并能根据需求显示或隐藏,我们可以使用一个 Vue 的 composable 来管理菜单的状态和位置:用一个 composable 来控制菜单项:

// ContextMenu.vue
const containerRef = ref(null);
const { x, y, showMenu } = useContextMenu(containerRef);
// useContextMenu.js
export function useContextMenu(container) {
  const x = ref(0);
  const y = ref(0);
  const showMenu = ref(false);

  const openMenu = e => {
    e.preventDefault(); // 阻止浏览器默认右键菜单
    showMenu.value = true;
    x.value = e.clientX; // 获取鼠标 X 坐标
    y.value = e.clientY; // 获取鼠标 Y 坐标
  };

  const closeMenu = () => {
    showMenu.value = false;
  };

  onMounted(() => {
    container.addEventListener("contextmenu", openMenu);
  });

  onUnmounted(() => {
    container.removeEventListener("contextmenu", openMenu);
  });

  return { x, y, showMenu };
}

现在,composable 内容已经基本有了菜单需要的状态、位置和事件。可问题来了,我们要考虑到什么时候会关闭菜单?我们要确保在点击其他地方时关闭菜单,可以给 window 添加事件监听:

window.addEventListener("click", closeMenu);
window.addEventListener("contextmenu", closeMenu);

然而,这里的逻辑还是存在着问题,考虑到事件的触发是先捕获然后再冒泡。而这样注册事件会导致,我们在容器A里打开了菜单,但是事件又冒泡到了window,而window的事件捕获到了之后会执行 closeMenu,将菜单关闭,这就相当于,打开了菜单但是又不完全打开。(逃

而我们希望的是,当我们在容器A打开了菜单时,先将其他菜单全部关闭,然后再给我们打开属于容器A的菜单。所以,我们需要让事件只在捕获执行,而不冒泡:

container.addEventListener("contextmenu", openMenu);
window.addEventListener("click", closeMenu, true);
window.addEventListener("contextmenu", closeMenu, true);

最后,让我们处理菜单的位置。在打开菜单的时候,container contextmenu 事件会将 event 对象传递给 openMenu,所以我们可以在该函数内获取鼠标位置(视口坐标):

const openMenu = e => {
  e.preventDefault();
  // 阻止冒泡,防止点击之后触发其他层级的菜单
  showMenu.value = true;
  x.value = e.clientX;
  y.value = e.clientY;
};

动画

我们使用 Transiiton 来包裹菜单项,做成一个 slideY 的效果,打开菜单时高度从 0 到某一个固定高度。这时候,我们需要使用钩子函数来处理菜单的高度。

这是因为我们不确定父组件通过插槽传递过来的菜单有多少个数量,二级菜单有多少个数量。所以,整个菜单高度是未知的,需要借助js来获取。

<Transition
  @beforeEnter="onBeforeEnter"
  @enter="onEnter"
  @afterEnter="onAfterEnter"
>
	<Teleport to="body">
  	<div class="context-menu">
      ....
    </div>
  </Teleport>
</Transition>
// 元素进入之前
const onBeforeEnter = el => {
  el.style.height = 0;
};
// 元素进入时
const onEnter = el => {
  // 先让高度取决于内容
  el.style.height = "auto";
  // 获取高度
  const height = el.clientHeight;
  el.style.height = 0;
  requestAnimationFrame(() => {
    el.style.height = height + "px";
    el.style.transition = ".5s";
  });
};
// 元素进入之后
const onAfterEnter = () => {
  el.style.transition = "none";
};

通过这些步骤,我们就可以封装出一个灵活的右键菜单组件,能够适应不同的使用场景。