zs
2025-05-05 11bff3e93067768199bab9bfff37e8eae17d8d92
HIAWms/web/src/components/vue3-context-menu/ContextSubMenu.vue
对比新文件
@@ -0,0 +1,555 @@
<template>
  <div
    :class="'mx-context-menu ' + (options.customClass ? options.customClass : '') + ' ' + globalTheme"
    :style="{
      maxWidth: (maxWidth ? solveNumberOrStringSize(maxWidth) : `${constOptions.defaultMaxWidth}px`),
      minWidth: minWidth ? solveNumberOrStringSize(minWidth) : `${constOptions.defaultMinWidth}px`,
      maxHeight: overflow && maxHeight > 0 ? `${maxHeight}px` : undefined,
      zIndex: zIndex,
      left: `${position.x}px`,
      top: `${position.y}px`,
    }"
    data-type="ContextSubMenu"
    @click="onSubMenuBodyClick"
    @wheel="onMouseWhell"
  >
    <!--Child menu items-->
    <div
      :class="[ 'mx-context-menu-items' ]"
      ref="menu"
      :style="{
        top: `${scrollValue}px`,
      }"
    >
      <slot>
        <div v-if="overflow && options.updownButtonSpaceholder" class="mx-context-menu-updown placeholder"></div>
        <template v-for="(item, i) in items" :key="i" >
          <ContextMenuSeparator v-if="item.hidden !== true && item.divided === 'up'" />
          <ContextMenuSeparator v-if="item.hidden !== true && item.divided === 'self'" />
          <!--Menu Item-->
          <ContextMenuItem
            v-else
            :clickHandler="item.onClick ? (e) => item.onClick!(e) : undefined"
            :disabled="item.disabled"
            :hidden="item.hidden"
            :icon="item.icon"
            :iconFontClass="item.iconFontClass"
            :svgIcon="item.svgIcon"
            :svgProps="item.svgProps"
            :label="item.label"
            :customRender="(item.customRender as Function)"
            :customClass="item.customClass"
            :checked="item.checked"
            :shortcut="item.shortcut"
            :clickClose="item.clickClose"
            :clickableWhenHasChildren="item.clickableWhenHasChildren"
            :preserveIconWidth="item.preserveIconWidth !== undefined ? item.preserveIconWidth : options.preserveIconWidth"
            :showRightArrow="item.children && item.children.length > 0"
            :hasChildren="item.children && item.children.length > 0"
            :rawMenuItem="item"
            @sub-menu-open="item.onSubMenuOpen"
            @sub-menu-close="item.onSubMenuClose"
          >
            <template v-if="item.children && item.children.length > 0" #submenu>
              <!--Sub menu-->
              <ContextSubMenu
                :items="item.children"
                :maxWidth="item.maxWidth"
                :minWidth="item.minWidth"
                :adjustPosition="item.adjustSubMenuPosition !== undefined ? item.adjustSubMenuPosition : options.adjustPosition"
                :direction="item.direction !== undefined ? item.direction : options.direction"
              />
            </template>
          </ContextMenuItem>
          <!--Separator-->
          <!--Custom render-->
          <ContextMenuSeparator v-if="item.hidden !== true && (item.divided === 'down' || item.divided === true)" />
        </template>
        <div v-if="overflow && options.updownButtonSpaceholder" class="mx-context-menu-updown placeholder"></div>
      </slot>
    </div>
    <!--Scroll button host-->
    <div
      class="mx-context-menu-scroll"
      ref="scroll"
    >
      <!--Updown scroll button-->
      <div
        v-show="overflow"
        ref="upScrollButton"
        :class="'mx-context-menu-updown mx-context-no-clickable up' + (overflow && scrollValue < 0 ? '' : ' disabled')"
        @click="onScroll(false)"
        @wheel="onMouseWhellMx"
      >
        <ContextMenuIconRight />
      </div>
      <div
        v-show="overflow"
        :class="'mx-context-menu-updown mx-context-no-clickable down' + (overflow && scrollValue > -scrollHeight ? '' : ' disabled')"
        @click="onScroll(true)"
        @wheel="onMouseWhellMx"
      >
        <ContextMenuIconRight />
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import { defineComponent, inject, nextTick, onMounted, type PropType, provide, ref, toRefs } from 'vue'
import type { MenuOptions, MenuItem, ContextMenuPositionData, MenuPopDirection } from './ContextMenuDefine'
import type { GlobalHasSlot, GlobalRenderSlot } from './ContextMenu.vue'
import { MenuConstOptions } from './ContextMenuDefine'
import { getLeft, getTop, solveNumberOrStringSize } from './ContextMenuUtils'
import ContextMenuItem from './ContextMenuItem.vue'
import ContextMenuSeparator from './ContextMenuSeparator.vue'
import ContextMenuIconRight from './ContextMenuIconRight.vue'
//The internal info context for menu item
export interface MenuItemContext {
  focus: () => void,
  blur: () => void,
  showSubMenu: () => boolean,
  getElement: () => HTMLElement|undefined,
  isDisabledOrHidden: () => boolean,
  click: (e: MouseEvent|KeyboardEvent) => void,
}
//The internal info context for submenu instance
export interface SubMenuContext {
  isTopLevel: () => boolean;
  closeSelfAndActiveParent: () => boolean,
  openCurrentItemSubMenu: () => boolean,
  closeCurrentSubMenu: () => void,
  moveCurrentItemFirst: () => void,
  moveCurrentItemLast: () => void,
  moveCurrentItemDown: () => void,
  moveCurrentItemUp: () => void,
  focusCurrentItem: () => void,
  triggerCurrentItemClick: (e: KeyboardEvent|MouseEvent) => void,
}
//The internal info context for submenu
export interface SubMenuParentContext {
  //Props
  container: HTMLElement;
  zIndex: number;
  adjustPadding: { x: number, y: number },
  //Position control
  getParentWidth: () => number;
  getParentHeight: () => number;
  getParentX: () => number;
  getParentY: () => number;
  getParentAbsX: () => number;
  getParentAbsY: () => number;
  getPositon: () => [number,number];
  //SubMenu mutex
  addOpenedSubMenu: (closeFn: () => void) => void;
  closeOtherSubMenu: () => void;
  closeOtherSubMenuWithTimeOut: () => void;
  checkCloseOtherSubMenuTimeOut: () => boolean;
  //Item control
  addChildMenuItem: (item: MenuItemContext, index?: number) => void;
  removeChildMenuItem: (item: MenuItemContext) => void;
  markActiveMenuItem: (item: MenuItemContext, updateState?: boolean) => void;
  markThisOpenedByKeyBoard: () => void;
  isOpenedByKeyBoardFlag: () => boolean;
  isMenuItemDataCollectedFlag: () => boolean;
  //Other
  getSubMenuInstanceContext: () => SubMenuContext|null;
  getParentContext: () => SubMenuParentContext|null;
  getElement: () => HTMLElement|null;
}
/**
 * Submenu container
 */
export default defineComponent({
  name: 'ContextSubMenu',
  components: {
    ContextMenuItem,
    ContextMenuSeparator,
    ContextMenuIconRight
  },
  props: {
    /**
     * Items from options
     */
    items: {
      type: Object as PropType<Array<MenuItem>>,
      default: null
    },
    /**
     * Max width for this submenu
     */
    maxWidth: {
      type: [String, Number],
      default: 0,
    },
    /**
     * Min width for this submenu
     */
    minWidth: {
      type: [String, Number],
      default: 0,
    },
    /**
     * Specifies should submenu adjust it position
     * when the menu exceeds the screen. The default is true
     */
    adjustPosition: {
      type: Boolean,
      default: true,
    },
    /**
     * Menu direction
     */
    direction: {
      type: String as PropType<MenuPopDirection>,
      default: 'br',
    },
  },
  setup(props) {
    //#region Injects
    const parentContext = inject('menuContext') as SubMenuParentContext;
    const options = inject('globalOptions') as MenuOptions;
    const globalHasSlot = inject('globalHasSlot') as GlobalHasSlot;
    const globalRenderSlot = inject('globalRenderSlot') as GlobalRenderSlot;
    const globalTheme = inject('globalTheme') as string;
    //#endregion
    const { zIndex, getParentWidth, getParentHeight } = parentContext;
    const { adjustPosition } = toRefs(props);
    const menu = ref<HTMLElement>();
    const scroll = ref<HTMLElement>();
    const upScrollButton = ref<HTMLElement>();
    const openedSubMenuClose = [] as (() => void)[];
    //#region Keyboard control context
    const globalSetCurrentSubMenu = inject('globalSetCurrentSubMenu') as (menu: SubMenuContext|null) => void;
    const menuItems = [] as MenuItemContext[];
    let currentItem = null as MenuItemContext|null;
    let leaveTimeout = 0;
    function blurCurrentMenu() {
      if (currentItem)
        currentItem.blur();
    }
    function setAndFocusNotDisableItem(isDown: boolean, startIndex?: number) {
      if (isDown) {
        for(let i = startIndex !== undefined ? startIndex : 0; i < menuItems.length; i++) {
          if (!menuItems[i].isDisabledOrHidden()) {
            setAndFocusCurrentMenu(i);
            break;
          }
        }
      } else {
        for(let i = startIndex !== undefined ? startIndex : (menuItems.length - 1); i >= 0; i--) {
          if (!menuItems[i].isDisabledOrHidden()) {
            setAndFocusCurrentMenu(i);
            break;
          }
        }
      }
    }
    function setAndFocusCurrentMenu(index?: number) {
      if (currentItem)
        blurCurrentMenu();
      if (index !== undefined)
        currentItem = menuItems[Math.max(0, Math.min(index, menuItems.length - 1))];
      if (!currentItem)
        return;
      //Focus item
      currentItem.focus();
      //Scroll to current item
      if (overflow.value) {
        const element = currentItem.getElement();
        if (element) {
          scrollValue.value = Math.min(Math.max(-scrollHeight.value, -element.offsetTop - element.offsetHeight + maxHeight.value), 0);
        }
      }
    }
    function onSubMenuBodyClick() {
      //Mouse click can set current focused submenu
      globalSetCurrentSubMenu(thisMenuInsContext);
    }
    const thisMenuInsContext : SubMenuContext = {
      isTopLevel: () => parentContext.getParentContext() === null,
      closeSelfAndActiveParent: () => {
        const parent = thisMenuContext.getParentContext();
        if (parent) {
          parent.closeOtherSubMenu();
          const conext = parent.getSubMenuInstanceContext()
          if (conext) {
            conext.focusCurrentItem();
            return true;
          }
        }
        return false;
      },
      closeCurrentSubMenu: () => thisMenuContext.getParentContext()?.closeOtherSubMenu(),
      moveCurrentItemFirst: () => setAndFocusNotDisableItem(true),
      moveCurrentItemLast: () => setAndFocusNotDisableItem(false),
      moveCurrentItemDown: () => setAndFocusNotDisableItem(true, (currentItem ? (menuItems.indexOf(currentItem) + 1) : 0)),
      moveCurrentItemUp: () => setAndFocusNotDisableItem(false, (currentItem ? (menuItems.indexOf(currentItem) - 1) : 0)),
      focusCurrentItem: () => setAndFocusCurrentMenu(),
      openCurrentItemSubMenu: () => {
        if (currentItem)
          return currentItem?.showSubMenu()
        return false;
      },
      triggerCurrentItemClick: (e) => currentItem?.click(e),
    };
    let isOpenedByKeyBoardFlag = false;
    let isMenuItemDataCollectedFlag = false;
    //#endregion
    //#region Menu control context
    //provide menuContext for child use
    const thisMenuContext : SubMenuParentContext = {
      zIndex: zIndex + 1,
      container: parentContext.container,
      adjustPadding: options.adjustPadding as { x: number, y: number } || MenuConstOptions.defaultAdjustPadding,
      getParentWidth: () => menu.value?.offsetWidth || 0,
      getParentHeight: () => menu.value?.offsetHeight || 0,
      getParentX: () => position.value.x,
      getParentY: () => position.value.y,
      getParentAbsX: () => menu.value ? getLeft(menu.value, parentContext.container) : 0,
      getParentAbsY: () => menu.value ? getTop(menu.value, parentContext.container) : 0,
      getPositon: () => [0,0],
      addOpenedSubMenu(closeFn: () => void) {
        openedSubMenuClose.push(closeFn);
      },
      closeOtherSubMenu() {
        openedSubMenuClose.forEach(k => k());
        openedSubMenuClose.splice(0, openedSubMenuClose.length);
        globalSetCurrentSubMenu(thisMenuInsContext);
      },
      checkCloseOtherSubMenuTimeOut() {
        if (leaveTimeout) {
          clearTimeout(leaveTimeout);
          leaveTimeout = 0;
          return true;
        }
        return false;
      },
      closeOtherSubMenuWithTimeOut() {
        leaveTimeout = setTimeout(() => {
          leaveTimeout = 0;
          this.closeOtherSubMenu();
        }, 200) as unknown as number; //Add a delay, the user will not hide the menu when moving too fast
      },
      addChildMenuItem: (item: MenuItemContext, index?: number) => {
        if (index === undefined)
          menuItems.push(item);
        else
          menuItems.splice(index, 0, item);
      },
      removeChildMenuItem: (item: MenuItemContext) => {
        menuItems.splice(menuItems.indexOf(item), 1);
      },
      markActiveMenuItem: (item: MenuItemContext, updateState = false) => {
        blurCurrentMenu();
        currentItem = item;
        if (updateState)
          setAndFocusCurrentMenu();
      },
      markThisOpenedByKeyBoard: () => {
        isOpenedByKeyBoardFlag = true;
      },
      isOpenedByKeyBoardFlag: () => {
        if (isOpenedByKeyBoardFlag) {
          isOpenedByKeyBoardFlag = false;
          return true;
        }
        return false;
      },
      isMenuItemDataCollectedFlag: () => isMenuItemDataCollectedFlag,
      getElement: () => menu.value || null,
      getParentContext: () => parentContext,
      getSubMenuInstanceContext: () => thisMenuInsContext,
    };
    provide('menuContext', thisMenuContext);
    //#endregion
    const scrollValue = ref(0);
    const scrollHeight = ref(0);
    //Scroll the items
    function onScroll(down : boolean) {
      if (down)
        scrollValue.value = Math.min(Math.max(scrollValue.value - 50, -scrollHeight.value), 0);
      else
        scrollValue.value = Math.min(scrollValue.value + 50, 0);
    }
    function onMouseWhellMx(e: WheelEvent) {
      e.preventDefault();
      e.stopPropagation();
      onScroll (e.deltaY > 0);
    }
    function onMouseWhell(e: WheelEvent) {
      if (options.mouseScroll) {
        e.preventDefault();
        e.stopPropagation();
        onScroll (e.deltaY > 0);
      }
    }
    const overflow = ref(false);
    const position = ref({ x: 0, y: 0 } as ContextMenuPositionData)
    const maxHeight = ref(0);
    onMounted(() => {
      const pos = parentContext.getPositon();
      position.value = {
        x: pos[0] ?? options.xOffset ?? 0,
        y: pos[1] ?? options.yOffset ?? 0,
      };
      //Mark current item submenu is open
      globalSetCurrentSubMenu(thisMenuInsContext);
      nextTick(() => {
        const menuEl = menu.value;
        //adjust submenu position
        if (menuEl && scroll.value) {
          const { container } = parentContext;
          const parentWidth = getParentWidth?.() ?? 0;
          const parentHeight = getParentHeight?.() ?? 0;
          const fillPaddingX = typeof parentContext.adjustPadding === 'number' ? parentContext.adjustPadding : (parentContext.adjustPadding?.x ?? 0);
          const fillPaddingYAlways = typeof parentContext.adjustPadding === 'number' ? parentContext.adjustPadding : (parentContext.adjustPadding?.y ?? 0);
          const fillPaddingY = parentHeight > 0 ? fillPaddingYAlways : 0;
          const windowHeight = document.documentElement.scrollHeight;
          const windowWidth = document.documentElement.scrollWidth;
          const avliableWidth = Math.min(windowWidth, container.offsetWidth);
          const avliableHeight = Math.min(windowHeight, container.offsetHeight);
          let absX = getLeft(menuEl, container),
            absY = getTop(menuEl, container);
          //set x positon
          if (props.direction.includes('l')) {
            position.value.x -= menuEl.offsetWidth + fillPaddingX; //left
          }
          else if (props.direction.includes('r')) {
            position.value.x += parentWidth + fillPaddingX; //right
          }
          else {
            position.value.x += parentWidth / 2;
            position.value.x -= (menuEl.offsetWidth + fillPaddingX) / 2; //center
          }
          //set y positon
          if (props.direction.includes('t')) {
            position.value.y -= menuEl.offsetHeight + fillPaddingYAlways * 2; //top
          }
          else if (props.direction.includes('b')) {
            position.value.y -= fillPaddingYAlways;  //bottom
          }
          else {
            position.value.y -= (menuEl.offsetHeight + fillPaddingYAlways) / 2; //center
          }
          //Overflow adjust
          if (adjustPosition.value) {
            nextTick(() => {
              absX = getLeft(menuEl, container);
              absY = getTop(menuEl, container);
              const xOverflow = (absX + menuEl.offsetWidth) - (avliableWidth);
              const yOverflow = (absY + menuEl.offsetHeight + fillPaddingY * 2) - (avliableHeight);
              overflow.value = yOverflow > 0;
              scrollHeight.value = menuEl.offsetHeight - avliableHeight + fillPaddingY * 2 /* Padding */;
              if (xOverflow > 0) {//X overflow
                const ox = parentWidth + menuEl.offsetWidth - fillPaddingX;
                const maxSubWidth = absX;
                if (ox > maxSubWidth)
                  position.value.x -= maxSubWidth;
                else
                  position.value.x -= ox;
              }
              if (overflow.value) { //Y overflow
                const oy = yOverflow;
                const maxSubHeight = absY;
                if (oy > maxSubHeight)
                  position.value.y -= maxSubHeight - fillPaddingY;
                else
                  position.value.y -= oy - fillPaddingY;
                maxHeight.value = (avliableHeight - fillPaddingY * 2);
              } else {
                maxHeight.value = 0;
              }
            });
          }
        }
        //Focus this submenu
        menuEl?.focus({
          preventScroll: true
        });
        //Is this submenu opened by keyboard? If yes then select first item
        if (parentContext.isOpenedByKeyBoardFlag())
          setAndFocusNotDisableItem(true);
        isMenuItemDataCollectedFlag = true;
      });
    });
    return {
      menu,
      scroll,
      options,
      zIndex,
      constOptions: MenuConstOptions,
      scrollValue,
      upScrollButton,
      overflow,
      position,
      scrollHeight,
      maxHeight,
      globalHasSlot,
      globalRenderSlot,
      globalTheme,
      onScroll,
      onSubMenuBodyClick,
      onMouseWhell,
      onMouseWhellMx,
      solveNumberOrStringSize,
    }
  }
})
</script>
<style lang="scss">
@import "./ContextMenu.scss";
</style>