右键菜单(不同场景)——客户端开发(electron)

2,158 阅读3分钟

场景

最近在做项目前后端分离,新项目使用Vue3 + TS + Electron构建UI层,本人负责菜单、快捷键以及与中间层对接action,之前没有做过右键菜单,这里用文章记录下。本文只提供思路及demo,没有可靠可运行的代码,请见谅。项目中有导航栏菜单、画布(svg)菜单、工具条菜单、属性菜单以及自定义菜单,大致长这样: image.png

难点分析

  1. 快捷键 直接操作菜单的快捷键包括:上(ArrowUp)、下(ArrowDown)、左(ArrowLeft)、右(ArrowRight)、确定(Enter)、取消(cancel)。对于菜单项的快捷键,我这边解决方法是匹配到快捷键之后,取消右键菜单显示,称之为假装在右键菜单内执行了快捷键但是却是在外部响应快捷键执行法。快捷键执行的代码本篇暂不记录

  2. 执行 我所在的项目中每一个菜单项对应一个action,有一个公共接口,理论上获取到指定action后执行即可,但是这种写法会导致整个组件不容易扩展,所以推荐返回选中的菜单项,在外部通过回调函数执行

解决方案

采用原生的方式实现的,有些复杂,特别是快捷键那部分。有俩个组件参与,外层组件index.vue负责popup、确定位置、确定子菜单展开方向、监听快捷键左右确定返回,内层组件Menu.vue负责样式、显示子菜单、监听快捷键上下、监听鼠标移入。方案确定之前我还试用了另外俩种方案,可以直接到最下面品尝。

index.vue-template

<template>
  <div
    v-show="visible"
    ref="contextMenuDev"
    v-click-outside="handleClickOutSide"
    class="c-contextmenu"
    :class="poperClass"
    :style="{left: x - moveLeft + 'px',top: y - moveTop + 'px', zIndex}"
    @contextmenu.prevent
  >
    <new-menu
      v-model:pipe="Pipe"
      :menus="menuItems"
      :isRight="isRight"
      :getNotDisabledMenuItemRelayRefence="getNotDisabledMenuItemRelayRefence"
      @click-menu-item="handleClickMenuItem"
    />
  </div>
</template>

index.vue-script

<script lang="ts" setup>
import NewMenu from './newMenu.vue';
import { ClickOutside as vClickOutside } from 'element-plus';
import { ref, watch, nextTick, onMounted, onUnmounted, reactive } from 'vue';
import { MenuItem } from '@/constants/types';
import { useRoute } from 'vue-router';
import { last } from 'lodash';

const props = defineProps<{
  x: number,
  y:number,
  menuItems:MenuItem[],
  zIndex?:number,
  poperClass?:string,
  closeOnScroll?:boolean
}>();

const emit = defineEmits<{
  (event: 'sure', res:any): void
  (event: 'close', res: any): void
}>();

const { visible, handleOk, handleCancel } = usePortal(emit);
const moveTop = ref(0);
const moveLeft = ref(0);
const isRight = ref(false);
const route = useRoute();
// 每一层选中效果实现中间件
const Pipe: Array<MenuItem> = reactive([]);
// 路由变化的时候,cancel
watch(() => route.path, () => {
  handleCancel();
});
// 点击菜单外部,cancel
function handleClickOutSide(){
  const lastItems = props.menuItems;
  setTimeout(() => {
    if (lastItems ===  props.menuItems) handleCancel();
  }, 50);
}
// 执行选中菜单项,ok
function handleClickMenuItem(item:MenuItem){
  if (item.disabled || (item.children && item.children.length)){
    return;
  }
  handleOk(item);
}
// 更新位置
const contextMenuDev = ref<HTMLDivElement>();
function updatePosition(){
  nextTick(() => {
    const element = contextMenuDev.value;
    if (!element) return;
    const overY = element.clientHeight + element.offsetTop - window.innerHeight;
    if (overY > 0){
      moveTop.value = overY + 2;
    }
    const overX = element.clientWidth + element.offsetLeft - window.innerWidth;
    if (overX > 0){
      moveLeft.value = overX + 2;
    }
  });
}
onMounted(() => {
  updatePosition();
});
/**
 * 从一个参考点开始,向上/下开始获取第一个disabled不为true的menuitem
 * @param Refence 参考索引
 * @param Menus 菜单项
 * @param Step 步长,如果为负数,表示向上开始遍历数组
 * @returns 如果没有满足项,返回一个伪MenuItem
 */
function getNotDisabledMenuItemRelayRefence(
  Refence: number, 
  Menus: MenuItem[], 
  Step: number
): MenuItem {
  if (Menus.length == 1 && !Menus[0].disabled) return Menus[0];
  let result: MenuItem = {
    value: '',
    disabled: true
  };
  const MenusLength = Menus.length;
  // if (MenusLength < Refence + 1) return result;
  let index = Refence;
  do index = (index + Step + MenusLength) % MenusLength;
  while (Menus[index].disabled && index != Refence);
  // 如果回到原地,表示没有找到
  if (index == Refence) return result;
  result = Menus[index];
  return result;
}
/**
 * 快捷键左右、enter、esc(原地修改Pipe)
 */
function handleKeyLeft() {
  if (Pipe.length < 2) return;
  Pipe.pop();
}
function handleKeyRight() {
  const LastMenuItem = last(Pipe);
  if (LastMenuItem == undefined) return;
  let children = LastMenuItem.children;
  if (!children?.length) return;
  Pipe.push(getNotDisabledMenuItemRelayRefence(children.length - 1, children, 1));
}
function handleKeyEnter() {
  const LastMenuItem = last(Pipe);
  if (LastMenuItem == undefined) return;
  if (LastMenuItem.children) return handleKeyRight();
  handleClickMenuItem(LastMenuItem);
}
// 挂载键盘监听事件
const KeyFunction = function(event: KeyboardEvent) {
  let e = event || window.event;
  if (!e) return;
  else if (e.key == 'ArrowLeft') handleKeyLeft();
  else if (e.key == 'ArrowRight') handleKeyRight();
  else if (e.key == 'Enter') handleKeyEnter();
  else if (e.key == 'Escape') handleCancel();
};
onMounted(() => window.addEventListener('keydown', KeyFunction));
// 卸载键盘监听
onUnmounted(() => window.removeEventListener('keydown', KeyFunction));

</script>

<style lang="scss">
.c-contextmenu {
  position: fixed;
  user-select: none;
}
</style>

为了执行菜单内快捷键(上下左右确定返回),使用了一个中间数据结构Pipe记录展开的菜单项,然后猛地一顿push、pop操作(只能这样操作)。上面有一个getNotDisabledMenuItemRelayRefence的方法,这个方法其实是用来优化快捷键使用体验的,大致解释为快捷键不会选中disabled为true的菜单项。

Menu.vue-template

<template>
  <div
    v-if="menus && menus.length"
    class="v-menu"
    :class="{
      '_child-menu': isChild,
      '_child-menu-is-right': isRight
    }"
  >
    <el-row
      v-for="item in menus"
      :key="item.label"
      class="_menu-item g-relative"
      :class="{
        _disabled: item.disabled,
        _selected: pipe.includes(item)
      }"
      @click.stop="handleClickMenuItem(item)"
      @mouseenter="handleMouseEnter(item)"
    >
      <!-- 我的右键菜单暂时不需要图标,可自行补上,注意span -->
      <!-- <el-col :span="4">
        <img v-if="item.icon" :src="getIcon(item.icon)"/>
      </el-col> -->
      <el-col :span="22">
        <span v-if="item.label" v-text="item.label"></span>
      </el-col>
      <el-col :span="2">
        <!-- 这里是为了让svg图标在选中和未选中之间切换 -->
        <img 
          v-if="item.children?.length" 
          :src="'statics/icons/app/listarrow' + (pipe.includes(item) ? 'White' : 'Black') + '.svg'"
        />
      </el-col>
      <context-menu
        v-if="item.children?.length && pipe.includes(item)"
        v-model:pipe="pipe"
        :menus="item.children"
        isChild
        :isRight="isRight"
        :deep="deep + 1"
        :getNotDisabledMenuItemRelayRefence="getNotDisabledMenuItemRelayRefence"
        @click-menu-item="handleClickMenuItem"
      />
    </el-row>

  </div>
</template>

Menu.vue-script

<script lang="ts">
export default {
  name: 'context-menu'
};
</script>

<script lang="ts" setup>
import { MenuItem } from '@/constants/types';
import { onMounted, onUnmounted, PropType } from 'vue';

const props = defineProps({
  // 传进来的菜单
  menus: {
    required: true,
    type: Array as PropType<MenuItem[]>
  },
  // 是否是子菜单
  isChild: Boolean,
  // 子菜单显示位置(点击事件clientX/Y的左右侧)
  isRight: {
    type: Boolean,
    default: false
  },
  // 深度
  deep: {
    type: Number,
    default: 1
  },
  // 选中的菜单项
  pipe: {
    required: true,
    type: Array as PropType<Array<MenuItem>>
  },
  // 优化快捷键体验
  getNotDisabledMenuItemRelayRefence: {
    required: true,
    type: Function
  }
}
);

const emit = defineEmits<{
  // 执行选中的菜单项,一层层向上传递
  (e: 'click-menu-item', data: MenuItem): void,
  // 更新选中的菜单项
  (e: 'update:pipe', data: Array<MenuItem>): void
}>();

function handleClickMenuItem(item: MenuItem) {
  emit('click-menu-item', item);
}

/**
 * 鼠标移入
 */
function handleMouseEnter(item: MenuItem) {
  if (item.disabled) return;
  const Pipe = props.pipe;
  while (Pipe.length && Pipe.length >= props.deep) {
    Pipe.pop();
  }
  Pipe.push(item);
  emit('update:pipe', Pipe);
}
/**
 * 快捷键上下(这样写会挨打的,之前是俩个方法的,我洁癖发作,合并成了一个)
 */
function handleKey(Refence: number, Step: number) {
  const Pipe = props.pipe;
  if (props.deep < Pipe.length) return;
  const Menus = props.menus;
  let lastMenuItem = Pipe.pop();
  let refence: number;
  if (lastMenuItem == undefined) refence = Refence || Menus.length - 1;
  else refence = Math.max(Menus.indexOf(lastMenuItem), 0);
  Pipe.push(props.getNotDisabledMenuItemRelayRefence(refence, Menus, Step));
  emit('update:pipe', Pipe);
}
// 挂载键盘监听事件
const KeyFunction = function(event: KeyboardEvent) {
  let e = event || window.event;
  if (!e) return;
  else if (e.key == "ArrowUp") handleKey(1, -1);
  else if (e.key == "ArrowDown") handleKey(0, 1);
};
onMounted(() => window.addEventListener('keydown', KeyFunction));
// 卸载键盘监听事件
onUnmounted(() => window.removeEventListener('keydown', KeyFunction));

</script>

Menu.vue-style

<style lang="scss" scoped>
.v-menu {
  box-shadow: 0px 9px 28px 8px rgba(0, 0, 0, 0.05),
		0px 6px 16px 0px rgba(0, 0, 0, 0.08),
		0px 3px 6px -4px rgba(0, 0, 0, 0.12);
	border-radius: 0px;
	border: 1px solid #c2d1da;
	background-color: #fff;
  width: 170px;
  color: rgba(0, 0, 0, 0.85);
  padding: 4px;
  ._menu-item {
    width: 100%;
    height: 28px;
		font-size: 12px;
		cursor: pointer;
    padding: 2px;
    padding-left: 8px;
    .el-col {
      margin: auto;
    }
  }
  ._disabled {
    color: #979797;
    cursor: not-allowed;
  }
  ._selected {
    background-color: rgba(24, 144, 255, 1);
    color: rgba(255, 255, 255, 1);
  }
}
._child-menu {
  position: absolute;
  top: -2px;
  left: calc(100% + 5px);
}
._child-menu-is-right {
  left: 0;
  right: calc(100% + 5px);
}
</style>

代码不直观,执行效果和vscode右键菜单很相似。这里补充上下左右对pipe的操作。

  1. 快捷键上:pipe会先弹出一个MenuItem1,然后结合menus找到它上一个且disabled不为true的MenuItem2
  2. 快捷键下:和快捷键上相似
  3. 快捷键左:直接pipe.pop()
  4. 快捷键右:获取pipe末端的MenuItem1,然后获取MenuItem1的children,再pipe.push(children[0])
  5. 快捷键确定:获取pipe末端的MenuItem1,然后emit出去

未选用方案

electron右键菜单

electron支持三种菜单,header菜单(macos效果是显示在mac工具条上的)、右键菜单、dock(macos、linux)),但没能找到使用css自定义样式的方法(感兴趣的同学可以参考vscode,他们用的也是remote.Menu)。不过这种菜单用来做右下角程序图标菜单很方便,因为可以在窗口外显示。

// Electron Menu
// App.vue
<script lang="ts" setup>
import { remote } from 'electron';
const Template = [
  {
    label: 'test1'
  },
  {
    label: 'test2'
  }
]; 
// 主角
const Menu = remote.Menu;
// 监听右键
window.addEventListener('contextmenu', (event) => {
  event.preventDefault();
  // 通过菜单模板定义右键菜单
  remote.Menu.buildFromTemplate(Template).popup({
  	// 然后渲染到当前窗口(进程)下
        window: remote.getCurrentWindow()
  });
}, false);
</script>

element-plus级联面板(el-cascader-panel)

对我现在的需求来说,有一些问题:

  1. 这个快捷键操作必须要点一下才可使用:可以通过v-model响应keydown解决;

  2. 然后返回值只能是对象的一个属性:如果想获取整个MenuItem对象,可以预处理Menus; image.png

  3. 右侧图标好像是没法自定义:我没试,不是很确定;

  4. 超出界面之后会被隐藏,没有控制展开方向的接口:需要js调整,很麻烦; 但是毕竟是组件库,还是很推荐使用的,可以去这element-plus.gitee.io/zh-CN/compo… 点里面的playground试试。