فهرست منبع

feat: 新增通用右键菜单组件并优化页面缩略图右键菜单

1.  新增v-contextmenu2自定义右键菜单指令和配套组件
2.  新增多语言简短版菜单文案
3.  替换缩略列表右键指令为新组件,新增页面菜单按钮
lsc 5 روز پیش
والد
کامیت
e1c4940190

+ 143 - 0
src/components/Contextmenu2/MenuContent.vue

@@ -0,0 +1,143 @@
+<template>
+  <ul class="menu-content" 
+    :style="{width: lang.lang === 'en' ? '160px' : '120px'}"
+  >
+    <template v-for="(menu, index) in menus" :key="menu.text || index">
+      <li
+        v-if="!menu.hide"
+        class="menu-item"
+        @click.stop="handleClickMenuItem(menu)"
+        :class="{'divider': menu.divider, 'disable': menu.disable}"
+      >
+        <div 
+          class="menu-item-content" 
+          :class="{
+            'has-children': menu.children,
+            'has-handler': menu.handler,
+          }" 
+          v-if="!menu.divider"
+        >
+          <span class="text">{{menu.text}}</span>
+          <span class="sub-text" v-if="menu.subText && !menu.children">{{menu.subText}}</span>
+
+          <menu-content 
+            class="sub-menu"
+            :menus="menu.children" 
+            v-if="menu.children && menu.children.length"
+            :handleClickMenuItem="handleClickMenuItem" 
+          />
+        </div>
+      </li>
+    </template>
+  </ul>
+</template>
+
+<script lang="ts" setup>
+import type { ContextmenuItem } from './types'
+import { lang } from '@/main'
+defineProps<{
+  menus: ContextmenuItem[]
+  handleClickMenuItem: (item: ContextmenuItem) => void
+}>()
+</script>
+
+<style lang="scss" scoped>
+$menuWidth: 180px;
+$menuHeight: 30px;
+$subMenuWidth: 120px;
+
+.menu-content {
+  width: $menuWidth;
+  padding: 8px;
+  // background: #fff;
+  background: #fff;
+  box-shadow: $boxShadow;
+  border-radius: 10px;
+  list-style: none;
+  margin: 0;
+}
+.menu-item {
+  // padding: 0 20px;
+  color: #555;
+  font-size: 14px;
+  transition: all $transitionDelayFast;
+  white-space: nowrap;
+  height: 40px;
+  line-height: 40px;
+  // background-color: #fff;
+  background-color: #fff;
+  cursor: pointer;
+  border-radius: 10px;
+
+  &:not(.disable):hover > .menu-item-content > .sub-menu {
+    display: block;
+  }
+
+  &:not(.disable):hover > .has-children.has-handler::after {
+    transform: scale(1);
+  }
+
+  &:hover:not(.disable) {
+    // background-color: rgba($color: $themeColor, $alpha: .2);
+    background-color: rgba($color: #ff9300, $alpha: .2);
+  }
+
+  &.divider {
+    height: 1px;
+    overflow: hidden;
+    margin: 5px;
+    background-color: #e5e5e5;
+    line-height: 0;
+    padding: 0;
+  }
+
+  &.disable {
+    color: #b1b1b1;
+    cursor: no-drop;
+  }
+}
+.menu-item-content {
+  display: flex;
+  align-items: center;
+  // justify-content: space-between;
+  justify-content: center;
+  position: relative;
+
+  &.has-children::before {
+    content: '';
+    display: inline-block;
+    width: 8px;
+    height: 8px;
+    border-width: 1px;
+    border-style: solid;
+    border-color: #666 #666 transparent transparent;
+    position: absolute;
+    right: 0;
+    top: 50%;
+    transform: translateY(-50%) rotate(45deg);
+  }
+  &.has-children.has-handler::after {
+    content: '';
+    display: inline-block;
+    width: 1px;
+    height: 24px;
+    background-color: rgba($color: #fff, $alpha: .3);
+    position: absolute;
+    right: 18px;
+    top: 3px;
+    transform: scale(0);
+    transition: transform $transitionDelay;
+  }
+
+  .sub-text {
+    opacity: 0.6;
+  }
+  .sub-menu {
+    width: $subMenuWidth;
+    position: absolute;
+    display: none;
+    left: 112%;
+    top: -6px;
+  }
+}
+</style>

+ 80 - 0
src/components/Contextmenu2/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <div 
+    class="mask"
+    @contextmenu.prevent="removeContextmenu()"
+    @mousedown.left="removeContextmenu()"
+  ></div>
+
+  <div 
+    class="contextmenu"
+    :style="{
+      left: style.left + 'px',
+      top: style.top + 'px',
+    }"
+    @contextmenu.prevent
+  >
+    <MenuContent 
+      :menus="menus"
+      :handleClickMenuItem="handleClickMenuItem" 
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import type { ContextmenuItem, Axis } from './types'
+
+import MenuContent from './MenuContent.vue'
+
+const props = defineProps<{
+  axis: Axis
+  el: HTMLElement
+  menus: ContextmenuItem[]
+  removeContextmenu: () => void
+}>()
+
+const style = computed(() => {
+  const MENU_WIDTH = 180
+  const MENU_HEIGHT = 30
+  const DIVIDER_HEIGHT = 11
+  const PADDING = 5
+
+  const { x, y } = props.axis
+  const menuCount = props.menus.filter(menu => !(menu.divider || menu.hide)).length
+  const dividerCount = props.menus.filter(menu => menu.divider).length
+
+  const menuWidth = MENU_WIDTH
+  const menuHeight = menuCount * MENU_HEIGHT + dividerCount * DIVIDER_HEIGHT + PADDING * 2
+
+  const screenWidth = document.body.clientWidth
+  const screenHeight = document.body.clientHeight
+
+  return {
+    left: screenWidth <= x + menuWidth ? x - menuWidth : x,
+    top: screenHeight <= y + menuHeight ? y - menuHeight : y,
+  }
+})
+
+const handleClickMenuItem = (item: ContextmenuItem) => {
+  if (item.disable) return
+  if (item.children && !item.handler) return
+  if (item.handler) item.handler(props.el)
+  props.removeContextmenu()
+}
+</script>
+
+<style lang="scss">
+.mask {
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 9998;
+}
+.contextmenu {
+  position: fixed;
+  z-index: 9999;
+  user-select: none;
+}
+</style>

+ 14 - 0
src/components/Contextmenu2/types.ts

@@ -0,0 +1,14 @@
+export interface ContextmenuItem {
+  text?: string
+  subText?: string
+  divider?: boolean
+  disable?: boolean
+  hide?: boolean
+  children?: ContextmenuItem[]
+  handler?: (el: HTMLElement) => void
+}
+
+export interface Axis {
+  x: number
+  y: number
+}

+ 64 - 0
src/plugins/directive/contextmenu2.ts

@@ -0,0 +1,64 @@
+import { type Directive, type DirectiveBinding, createVNode, render } from 'vue'
+import ContextmenuComponent from '@/components/Contextmenu2/index.vue'
+
+const CTX_CONTEXTMENU_HANDLER = 'CTX_CONTEXTMENU_HANDLER'
+
+interface CustomHTMLElement extends HTMLElement {
+  [CTX_CONTEXTMENU_HANDLER]?: (event: MouseEvent) => void
+} 
+
+const contextmenuListener = (el: HTMLElement, event: MouseEvent, binding: DirectiveBinding) => {
+  event.stopPropagation()
+  event.preventDefault()
+
+  const menus = binding.value(el)
+  if (!menus) return
+
+  let container: HTMLDivElement | null = null
+
+  // 移除右键菜单并取消相关的事件监听
+  const removeContextmenu = () => {
+    if (container) {
+      document.body.removeChild(container)
+      container = null
+    }
+    el.classList.remove('contextmenu-active')
+    document.body.removeEventListener('scroll', removeContextmenu)  
+    window.removeEventListener('resize', removeContextmenu)
+  }
+
+  // 创建自定义菜单
+  const options = {
+    axis: { x: event.x, y: event.y },
+    el,
+    menus,
+    removeContextmenu,
+  }
+  container = document.createElement('div')
+  const vm = createVNode(ContextmenuComponent, options, null)
+  render(vm, container)
+  document.body.appendChild(container)
+
+  // 为目标节点添加菜单激活状态的className
+  el.classList.add('contextmenu-active')
+
+  // 页面变化时移除菜单
+  document.body.addEventListener('scroll', removeContextmenu)
+  window.addEventListener('resize', removeContextmenu)
+}
+
+const ContextmenuDirective: Directive = {
+  mounted(el: CustomHTMLElement, binding) {
+    el[CTX_CONTEXTMENU_HANDLER] = (event: MouseEvent) => contextmenuListener(el, event, binding)
+    el.addEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+  },
+
+  unmounted(el: CustomHTMLElement) {
+    if (el && el[CTX_CONTEXTMENU_HANDLER]) {
+      el.removeEventListener('contextmenu', el[CTX_CONTEXTMENU_HANDLER])
+      delete el[CTX_CONTEXTMENU_HANDLER]
+    }
+  },
+}
+
+export default ContextmenuDirective

+ 2 - 0
src/plugins/directive/index.ts

@@ -1,12 +1,14 @@
 import type { App } from 'vue'
 
 import Contextmenu from './contextmenu'
+import Contextmenu2 from './contextmenu2'
 import ClickOutside from './clickOutside'
 import Tooltip from './tooltip'
 
 export default {
   install(app: App) {
     app.directive('contextmenu', Contextmenu)
+    app.directive('contextmenu2', Contextmenu2)
     app.directive('click-outside', ClickOutside)
     app.directive('tooltip', Tooltip)
   }

+ 132 - 42
src/views/Editor/Thumbnails/index2.vue

@@ -56,11 +56,18 @@
               'selected': selectedSlidesIndex.includes(index),
             }"
             @mousedown="$event => handleClickSlideThumbnail($event, index)"
-            @dblclick="enterScreening()"
-            v-contextmenu="contextmenusThumbnailItem"
+            v-contextmenu2="contextmenusThumbnailItem"
           >
+            <!-- @dblclick="enterScreening()" -->
             <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
             <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
+            <div class="page_menu" @click.stop="$event => showPageMenu($event, index)">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <circle cx="12" cy="12" r="1"></circle>
+                  <circle cx="12" cy="5" r="1"></circle>
+                  <circle cx="12" cy="19" r="1"></circle>
+              </svg>
+            </div>
 
             <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
           </div>
@@ -73,7 +80,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
+import { computed, nextTick, ref, watch, useTemplateRef, createVNode, render } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
 import { fillDigit } from '@/utils/common'
@@ -90,6 +97,7 @@ import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 import Templates from './Templates.vue'
 import Popover from '@/components/Popover.vue'
 import Draggable from 'vuedraggable'
+import ContextmenuComponent from '@/components/Contextmenu2/index.vue'
 
 // 检测是否为移动设备(包括iPad和手机)
 const isMobileDevice = computed(() => {
@@ -315,55 +323,100 @@ const contextmenusThumbnails = (): ContextmenuItem[] => {
   ]
 }
 
+const showPageMenu = (event: MouseEvent, index: number) => {
+  event.stopPropagation()
+  
+  const menus = contextmenusThumbnailItem()
+  if (!menus) return
+
+  let container: HTMLDivElement | null = null
+
+  const removeContextmenu = () => {
+    if (container) {
+      document.body.removeChild(container)
+      container = null
+    }
+    document.body.removeEventListener('scroll', removeContextmenu)
+    window.removeEventListener('resize', removeContextmenu)
+  }
+
+  const options = {
+    axis: { x: event.clientX - 20, y: event.clientY - 20 },
+    el: event.currentTarget as HTMLElement,
+    menus,
+    removeContextmenu,
+  }
+  container = document.createElement('div')
+  const vm = createVNode(ContextmenuComponent, options, null)
+  render(vm, container)
+  document.body.appendChild(container)
+
+  document.body.addEventListener('scroll', removeContextmenu)
+  window.addEventListener('resize', removeContextmenu)
+}
+
 const contextmenusThumbnailItem = (): ContextmenuItem[] => {
   return [
+    // {
+    //   text: lang.ssCut,
+    //   subText: 'Ctrl + X',
+    //   handler: cutSlide,
+    // },
+    // {
+    //   text: lang.ssCopy,
+    //   subText: 'Ctrl + C',
+    //   handler: copySlide,
+    // },
+    // {
+    //   text: lang.ssPaste,
+    //   subText: 'Ctrl + V',
+    //   handler: pasteSlide,
+    // },
+    // {
+    //   text: lang.ssSelectAll,
+    //   subText: 'Ctrl + A',
+    //   handler: selectAllSlide,
+    // },
+    // { divider: true },
+    // {
+    //   text: lang.ssNewPage,
+    //   subText: 'Enter',
+    //   handler: createSlide,
+    // },
+    // {
+    //   text: lang.ssDupPage,
+    //   subText: 'Ctrl + D',
+    //   handler: copyAndPasteSlide,
+    // },
+    // {
+    //   text: lang.ssDelPage,
+    //   subText: 'Delete',
+    //   handler: () => deleteSlide(),
+    // },
+    // {
+    //   text: lang.ssAddSect,
+    //   handler: createSection,
+    //   disable: !!currentSlide.value.sectionTag,
+    // },
+    // { divider: true },
+    // {
+    //   text: lang.ssPlayFromCur,
+    //   subText: 'Shift + F5',
+    //   handler: enterScreening,
+    // },
+
     {
-      text: lang.ssCut,
-      subText: 'Ctrl + X',
-      handler: cutSlide,
-    },
-    {
-      text: lang.ssCopy,
-      subText: 'Ctrl + C',
-      handler: copySlide,
-    },
-    {
-      text: lang.ssPaste,
-      subText: 'Ctrl + V',
-      handler: pasteSlide,
-    },
-    {
-      text: lang.ssSelectAll,
-      subText: 'Ctrl + A',
-      handler: selectAllSlide,
+      text: lang.ssDupPage2,
+      handler: copyAndPasteSlide,
     },
-    { divider: true },
     {
-      text: lang.ssNewPage,
-      subText: 'Enter',
+      text: lang.ssNewPage2,
       handler: createSlide,
     },
     {
-      text: lang.ssDupPage,
-      subText: 'Ctrl + D',
-      handler: copyAndPasteSlide,
-    },
-    {
-      text: lang.ssDelPage,
-      subText: 'Delete',
+      text: lang.ssDelPage2,
       handler: () => deleteSlide(),
     },
-    {
-      text: lang.ssAddSect,
-      handler: createSection,
-      disable: !!currentSlide.value.sectionTag,
-    },
-    { divider: true },
-    {
-      text: lang.ssPlayFromCur,
-      subText: 'Shift + F5',
-      handler: enterScreening,
-    },
   ]
 }
 </script>
@@ -490,6 +543,10 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
   color: #999;
   width: 20px;
   cursor: grab;
+  position: absolute;
+  z-index: 999;
+  top: 10px;
+  left: 10px;
 
   &.offset-left {
     position: relative;
@@ -500,6 +557,39 @@ const contextmenusThumbnailItem = (): ContextmenuItem[] => {
     cursor: grabbing;
   }
 }
+
+.page_menu{
+  width: 24px;
+  height: 24px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  position: absolute;
+  z-index: 999;
+  bottom: 10px;
+  right: 10px;
+  background: rgba(255, 255, 255, 0.95);
+  border: 1px solid #e5e7eb;
+  transition: .3s;
+  border-radius: 5px;
+
+  svg{
+    color: #6b7280;
+    height: 15px;
+  }
+
+  .active,
+  &:hover{
+    border-color: $themeColor2;
+    background-color: $themeColor2;
+
+    svg{
+      color: #fff;
+    }
+  }
+}
+
 .page-number {
   /* height: 100%; */
   font-size: 12px;

+ 3 - 0
src/views/lang/cn.json

@@ -600,6 +600,9 @@
   "ssDupPage": "复制页面",
   "ssDelPage": "删除页面",
   "ssAddSect": "增加节",
+  "ssDupPage2": "复制",
+  "ssDelPage2": "删除",
+  "ssNewPage2": "新增",
   "ssPlayFromCur": "从当前放映",
   "ssDblClickEdit": "双击编辑",
   "ssElText": "文本",

+ 3 - 0
src/views/lang/en.json

@@ -601,6 +601,9 @@
   "ssDupPage": "Duplicate Slide",
   "ssDelPage": "Delete Slide",
   "ssAddSect": "Add Section",
+  "ssDupPage2": "Duplicate",
+  "ssDelPage2": "Delete",
+  "ssNewPage2": "Add",
   "ssPlayFromCur": "Present from Current Slide",
   "ssDblClickEdit": "Double-click to edit",
   "ssElText": "Text",

+ 3 - 0
src/views/lang/hk.json

@@ -601,6 +601,9 @@
   "ssDupPage": "複製頁面",
   "ssDelPage": "刪除頁面",
   "ssAddSect": "增加節",
+  "ssDupPage2": "複製",
+  "ssDelPage2": "刪除",
+  "ssNewPage2": "新增",
   "ssPlayFromCur": "從當前放映",
   "ssDblClickEdit": "雙擊編輯",
   "ssElText": "文本",