import { getAcceleratorLabelTokens, MenuItem } from "./MenuItem" import { animateWithClass } from "./animateWithClass" // Close if mouse is held down longer than this (ms). const closeOnReleaseThreshold = 500 // Add this many pixels of safe area around menus. const safeMargin = 5 export enum CloseBehavior { /** Immediately fade out. */ Immediate, /** Fade out after a small delay. */ Delayed, /** Disappear instantly. */ NoAnimation, } export interface MenuInstanceOptions { document?: Document className?: string isDarkMode?: boolean maxItemsPerMenu?: number enableTranslate?: boolean /** * Callback to be notified about the user's selection. * * This callback is guaranteed to be [triggered by user activation][1], so * use it for any code that requires it. * * [1]: https://html.spec.whatwg.org/multipage/interaction.html#triggered-by-user-activation */ onSelection?: (selection: Selection) => void } export interface PositionOptions { location: { x: number; y: number } within?: Area expandRight?: boolean } export interface Selection { path: number[] action?: string altKey: boolean ctrlKey: boolean metaKey: boolean shiftKey: boolean } interface Area { bottom: number left: number right: number top: number } const enum Flow { NoFit = 1 << 0, Down = 1 << 1, Up = 1 << 2, NoFitButDownIsBetter = NoFit | Down, NoFitButUpIsBetter = NoFit | Up, } enum MenuState { Initial, Open, Closing, Closed, } export class MenuInstance implements EventListenerObject { readonly items: MenuItem[] document: Document blocker: HTMLDivElement menu: HTMLUListElement private readonly originalItems: MenuItem[] constructor( items: MenuItem[], { className, document, isDarkMode, maxItemsPerMenu, enableTranslate }: MenuInstanceOptions ) { this.originalItems = items this.items = items = cleanUpSeparators(items) this.document = document || window.document this.blocker = createBlocker(this.document) this.menu = createMenu({ document: this.document, items, className, isDarkMode, maxItemsPerMenu, enableTranslate, }) } async close(behavior: CloseBehavior, selection?: Selection): Promise { if (!this.blocker.parentNode || !this.menu.parentNode) { return } if (this.state !== MenuState.Open) { throw Error(`Menu not in Open state (${this.state})`) } const { resolve, reject } = this if (!resolve || !reject) throw Error("Invalid internal state") this.state = MenuState.Closing // TODO: Ensure only one close is happening. // TODO: Immediately resolve/reject promise if behavior is NoAnimation. this.document.body.removeChild(this.blocker) window.removeEventListener("keydown", this.handleKeyDown, { capture: true }) if (behavior === CloseBehavior.Immediate || behavior === CloseBehavior.Delayed) { await this.fadeOut(behavior !== CloseBehavior.Delayed) } this.state = MenuState.Closed this.document.body.removeChild(this.menu) if (selection) { resolve(selection) } else { resolve(null) } } handleEvent(event: Event): void { switch (event.type) { case "animationend": const { animationName } = event as AnimationEvent if (animationName === "menu-active-item") { // The reason we do it this way is so that we can track hovers from a mouse event // that starts inside an iframe. Normally JavaScript cannot know about these // events, but CSS animations do trigger events so we can use that instead. this.setActiveLI(event.target instanceof HTMLLIElement ? event.target : null) } if (animationName === "menu-safe-area-timeout" && isSafeArea(event.target)) { event.target.classList.remove("enabled") } break case "contextmenu": event.preventDefault() event.stopPropagation() // TODO: Replicate the right click in Vekter. if (event.target === this.blocker && Date.now() - this.showTime > closeOnReleaseThreshold) { this.close(CloseBehavior.Immediate) } break case "mousedown": if ((event as MouseEvent).button !== 0) { break } this.mouseDownTime = Date.now() if (event.target === this.blocker) { this.close(CloseBehavior.Immediate) } break case "mouseout": if (event.currentTarget === this.menu) { this.setActiveLI(null) } break case "mouseover": if (event.target instanceof HTMLLIElement) { this.setActiveLI(event.target) } break case "mouseup": // Ignore if the menu was just opened. if (Date.now() - this.showTime < closeOnReleaseThreshold) break if (event.target === this.blocker) { this.close(CloseBehavior.Immediate) } else { const { altKey, ctrlKey, metaKey, shiftKey } = event as MouseEvent this.pick({ altKey, ctrlKey, metaKey, shiftKey }) } break case "mousemove": if (event.target != this.activeLI) return // Requires `:scope`, because querySelector searches depth-first and will first go into submenus otherwise. const safeArea = this.activeLI?.querySelector(":scope > .safearea") if (safeArea instanceof HTMLElement) { safeArea.classList.add("enabled") const menuRight = this.activeLI?.querySelector(":scope > ul.expand-left") === null const side = menuRight ? "100%" : "0" const areaRect = safeArea.getBoundingClientRect() const { clientX, clientY } = event as MouseEvent const x = clientX - areaRect.left + safeMargin * (menuRight ? 1 : -1) const y = clientY - areaRect.top safeArea.style.clipPath = `polygon(${side} 0, ${x}px ${y - safeMargin}px, ${x}px ${ y + safeMargin }px, ${side} 100%)` } break } } handleKeyDown = (event: KeyboardEvent): void => { if (this.state !== MenuState.Open) return if (event.key === "Escape" || event.key === "Esc") { this.close(CloseBehavior.Immediate) event.stopPropagation() } } isOpenWith(items: MenuItem[]): boolean { return this.activePromise !== null && this.originalItems === items } position({ location: { x, y }, within: maybeWithin, expandRight = true }: PositionOptions): void { if (!this.menu.parentNode) throw Error("Menu not in DOM") if (this.state !== MenuState.Open) { throw Error(`Menu not in Open state (${this.state})`) } // Default to document body area. const within = maybeWithin || this.document.body.getBoundingClientRect() // Inset area with a safe margin. const safeWithin = insetArea(within, safeMargin) // TODO: Early exit if values did not change. const { menu } = this menu.style.left = `${within.left + x + 2}px` menu.style.top = `${within.top + y}px` // Make sure the menu is measured without any transforms. menu.style.setProperty("transform", "none", "important") // Measure menu, then restore transforms. const rect = menu.getBoundingClientRect() menu.style.removeProperty("transform") // Don't let the menu go off screen. const fit = fitRectWithinArea(rect, safeWithin) menu.style.left = `${fit.left}px` menu.style.top = `${fit.top}px` // Make submenus expand in direction with space. Array.from(menu.querySelectorAll("li.submenu > ul")).forEach(subUL => { const anchorRect = subUL.parentElement?.getBoundingClientRect() if (!anchorRect) { // The