对比新文件 |
| | |
| | | <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> |