diff --git a/src/ContextMenu.ts b/src/ContextMenu.ts index 41c4dc52a..39bc1e0df 100644 --- a/src/ContextMenu.ts +++ b/src/ContextMenu.ts @@ -1,8 +1,16 @@ -// @ts-nocheck import type { IContextMenuOptions, IContextMenuValue } from "./interfaces" -import { LiteGraph } from "./litegraph"; +import { LiteGraph } from "./litegraph" + +interface ContextMenuDivElement extends HTMLDivElement { + value?: IContextMenuValue | string + onclick_callback?: never + closing_timer?: number +} + +export interface ContextMenu { + constructor: new (...args: ConstructorParameters) => ContextMenu +} -/* LiteGraph GUI elements used for canvas editing *************************************/ /** * ContextMenu from LiteGUI * @@ -18,322 +26,277 @@ import { LiteGraph } from "./litegraph"; export class ContextMenu { options?: IContextMenuOptions parentMenu?: ContextMenu - root: HTMLDivElement + root: ContextMenuDivElement current_submenu?: ContextMenu lock?: boolean - constructor(values: IContextMenuValue[] | string[], options: IContextMenuOptions) { - options = options || {}; - this.options = options; - var that = this; + // TODO: Interface for values requires functionality change - currently accepts an array of strings, functions, objects, nulls, or undefined. + constructor(values: (IContextMenuValue | string)[], options: IContextMenuOptions) { + options ||= {} + this.options = options //to link a menu with its parent - if (options.parentMenu) { - if (!(options.parentMenu instanceof ContextMenu)) { - console.error( - "parentMenu must be of class ContextMenu, ignoring it" - ); - options.parentMenu = null; + const parent = options.parentMenu + if (parent) { + if (!(parent instanceof ContextMenu)) { + console.error("parentMenu must be of class ContextMenu, ignoring it") + options.parentMenu = null } else { - this.parentMenu = options.parentMenu; - this.parentMenu.lock = true; - this.parentMenu.current_submenu = this; + this.parentMenu = parent + this.parentMenu.lock = true + this.parentMenu.current_submenu = this } - if (options.parentMenu.options?.className === "dark") { - options.className = "dark"; + if (parent.options?.className === "dark") { + options.className = "dark" } } - var eventClass = null; - if (options.event) //use strings because comparing classes between windows doesnt work - eventClass = options.event.constructor.name; + //use strings because comparing classes between windows doesnt work + const eventClass = options.event + ? options.event.constructor.name + : null if (eventClass !== "MouseEvent" && eventClass !== "CustomEvent" && eventClass !== "PointerEvent") { - console.error( - "Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (" + eventClass + ")" - ); - options.event = null; + console.error(`Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (${eventClass})`) + options.event = null } - var root = document.createElement("div"); - root.className = "litegraph litecontextmenu litemenubar-panel"; - if (options.className) { - root.className += " " + options.className; - } - root.style.minWidth = 100; - root.style.minHeight = 100; - root.style.pointerEvents = "none"; + const root: ContextMenuDivElement = document.createElement("div") + let classes = "litegraph litecontextmenu litemenubar-panel" + if (options.className) classes += " " + options.className + root.className = classes + root.style.minWidth = "100" + root.style.minHeight = "100" + // TODO: Fix use of timer in place of events + root.style.pointerEvents = "none" setTimeout(function () { - root.style.pointerEvents = "auto"; - }, 100); //delay so the mouse up event is not caught by this element - - + root.style.pointerEvents = "auto" + }, 100) //delay so the mouse up event is not caught by this element //this prevents the default context browser menu to open in case this menu was created when pressing right button LiteGraph.pointerListenerAdd(root, "up", - function (e) { + function (e: MouseEvent) { //console.log("pointerevents: ContextMenu up root prevent"); - e.preventDefault(); - return true; + e.preventDefault() + return true }, true - ); + ) root.addEventListener( "contextmenu", - function (e) { - if (e.button != 2) { - //right button - return false; - } - e.preventDefault(); - return false; + function (e: MouseEvent) { + //right button + if (e.button != 2) return false + e.preventDefault() + return false }, true - ); + ) LiteGraph.pointerListenerAdd(root, "down", - function (e) { + (e: MouseEvent) => { //console.log("pointerevents: ContextMenu down"); if (e.button == 2) { - that.close(); - e.preventDefault(); - return true; + this.close() + e.preventDefault() + return true } }, true - ); + ) - function on_mouse_wheel(e) { - var pos = parseInt(root.style.top); + function on_mouse_wheel(e: WheelEvent) { + const pos = parseInt(root.style.top) root.style.top = - (pos + e.deltaY * options.scroll_speed).toFixed() + "px"; - e.preventDefault(); - return true; + (pos + e.deltaY * options.scroll_speed).toFixed() + "px" + e.preventDefault() + return true } if (!options.scroll_speed) { - options.scroll_speed = 0.1; + options.scroll_speed = 0.1 } - root.addEventListener("wheel", on_mouse_wheel, true); - root.addEventListener("mousewheel", on_mouse_wheel, true); + root.addEventListener("wheel", on_mouse_wheel, true) - this.root = root; + this.root = root //title if (options.title) { - var element = document.createElement("div"); - element.className = "litemenu-title"; - element.innerHTML = options.title; - root.appendChild(element); + const element = document.createElement("div") + element.className = "litemenu-title" + element.innerHTML = options.title + root.appendChild(element) } //entries - var num = 0; - for (var i = 0; i < values.length; i++) { - var name = values.constructor == Array ? values[i] : i; - if (name != null && name.constructor !== String) { - name = name.content === undefined ? String(name) : name.content; + for (let i = 0; i < values.length; i++) { + const value = values[i] + let name = Array.isArray(values) ? value : String(i) + + if (typeof name !== "string") { + name = name != null + ? name.content === undefined ? String(name) : name.content + : name as null | undefined } - var value = values[i]; - this.addItem(name, value, options); - num++; + + this.addItem(name, value, options) } - //close on leave? touch enabled devices won't work TODO use a global device detector and condition on that - /*LiteGraph.pointerListenerAdd(root,"leave", function(e) { - console.log("pointerevents: ContextMenu leave"); - if (that.lock) { - return; - } + LiteGraph.pointerListenerAdd(root, "enter", function () { if (root.closing_timer) { - clearTimeout(root.closing_timer); + clearTimeout(root.closing_timer) } - root.closing_timer = setTimeout(that.close.bind(that, e), 500); - //that.close(e); - });*/ - LiteGraph.pointerListenerAdd(root, "enter", function (e) { - //console.log("pointerevents: ContextMenu enter"); - if (root.closing_timer) { - clearTimeout(root.closing_timer); - } - }); + }) //insert before checking position - var root_document = document; - if (options.event) { - root_document = options.event.target.ownerDocument; - } - - if (!root_document) { - root_document = document; - } + const ownerDocument = (options.event?.target as Node).ownerDocument + const root_document = ownerDocument || document if (root_document.fullscreenElement) - root_document.fullscreenElement.appendChild(root); - - + root_document.fullscreenElement.appendChild(root) else - root_document.body.appendChild(root); + root_document.body.appendChild(root) //compute best position - var left = options.left || 0; - var top = options.top || 0; + let left = options.left || 0 + let top = options.top || 0 if (options.event) { - left = options.event.clientX - 10; - top = options.event.clientY - 10; - if (options.title) { - top -= 20; + left = options.event.clientX - 10 + top = options.event.clientY - 10 + if (options.title) top -= 20 + + if (parent) { + const rect = parent.root.getBoundingClientRect() + left = rect.left + rect.width } - if (options.parentMenu) { - var rect = options.parentMenu.root.getBoundingClientRect(); - left = rect.left + rect.width; - } - - var body_rect = document.body.getBoundingClientRect(); - var root_rect = root.getBoundingClientRect(); + const body_rect = document.body.getBoundingClientRect() + const root_rect = root.getBoundingClientRect() if (body_rect.height == 0) - console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }"); + console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }") - if (body_rect.width && left > body_rect.width - root_rect.width - 10) { - left = body_rect.width - root_rect.width - 10; - } - if (body_rect.height && top > body_rect.height - root_rect.height - 10) { - top = body_rect.height - root_rect.height - 10; - } + if (body_rect.width && left > body_rect.width - root_rect.width - 10) + left = body_rect.width - root_rect.width - 10 + if (body_rect.height && top > body_rect.height - root_rect.height - 10) + top = body_rect.height - root_rect.height - 10 } - root.style.left = left + "px"; - root.style.top = top + "px"; + root.style.left = left + "px" + root.style.top = top + "px" - if (options.scale) { - root.style.transform = "scale(" + options.scale + ")"; - } + if (options.scale) + root.style.transform = `scale(${options.scale})` } - addItem(name: string, value: IContextMenuValue, options: IContextMenuOptions): HTMLElement { - var that = this; - options = options || {}; + addItem(name: string, value: IContextMenuValue | string, options: IContextMenuOptions): HTMLElement { + options ||= {} - var element = document.createElement("div"); - element.className = "litemenu-entry submenu"; + const element: ContextMenuDivElement = document.createElement("div") + element.className = "litemenu-entry submenu" - var disabled = false; + let disabled = false if (value === null) { - element.classList.add("separator"); - //element.innerHTML = "
" - //continue; + element.classList.add("separator") } else { - element.innerHTML = value && value.title ? value.title : name; - element.value = value; - element.setAttribute("role", "menuitem"); + if (typeof value === "string") { + element.innerHTML = name + } else { + element.innerHTML = value?.title ?? name - if (value) { if (value.disabled) { - disabled = true; - element.classList.add("disabled"); - element.setAttribute("aria-disabled", "true"); + disabled = true + element.classList.add("disabled") + element.setAttribute("aria-disabled", "true") } if (value.submenu || value.has_submenu) { - element.classList.add("has_submenu"); - element.setAttribute("aria-haspopup", "true"); - element.setAttribute("aria-expanded", "false"); + element.classList.add("has_submenu") + element.setAttribute("aria-haspopup", "true") + element.setAttribute("aria-expanded", "false") } + if (value.className) + element.className += " " + value.className } + element.value = value + element.setAttribute("role", "menuitem") - if (typeof value == "function") { - element.dataset["value"] = name; - element.onclick_callback = value; + if (typeof value === "function") { + element.dataset["value"] = name + element.onclick_callback = value } else { - element.dataset["value"] = value; - } - - if (value.className) { - element.className += " " + value.className; + element.dataset["value"] = String(value) } } - this.root.appendChild(element); - if (!disabled) { - element.addEventListener("click", inner_onclick); - } - if (!disabled && options.autoopen) { - LiteGraph.pointerListenerAdd(element, "enter", inner_over); - } + this.root.appendChild(element) + if (!disabled) element.addEventListener("click", inner_onclick) + if (!disabled && options.autoopen) + LiteGraph.pointerListenerAdd(element, "enter", inner_over) - function setAriaExpanded() { - const entries = that.root.querySelectorAll("div.litemenu-entry.has_submenu"); + const setAriaExpanded = () => { + const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu") if (entries) { for (let i = 0; i < entries.length; i++) { - entries[i].setAttribute("aria-expanded", "false"); + entries[i].setAttribute("aria-expanded", "false") } } - element.setAttribute("aria-expanded", "true"); + element.setAttribute("aria-expanded", "true") } - function inner_over(e) { - var value = this.value; - if (!value || !value.has_submenu) { - return; - } + function inner_over(this: ContextMenuDivElement, e: MouseEvent) { + const value = this.value + if (!value || !(value as IContextMenuValue).has_submenu) return + //if it is a submenu, autoopen like the item was clicked - inner_onclick.call(this, e); - setAriaExpanded(); + inner_onclick.call(this, e) + setAriaExpanded() } //menu option clicked - function inner_onclick(e) { - var value = this.value; - var close_parent = true; + const that = this + function inner_onclick(this: ContextMenuDivElement, e: MouseEvent) { + const value = this.value + let close_parent = true - if (that.current_submenu) { - that.current_submenu.close(e); - } - if (value?.has_submenu || value?.submenu) { - setAriaExpanded(); - } + that.current_submenu?.close(e) + if ((value as IContextMenuValue)?.has_submenu || (value as IContextMenuValue)?.submenu) setAriaExpanded() //global callback if (options.callback) { - var r = options.callback.call( + const r = options.callback.call( this, value, options, e, that, options.node - ); - if (r === true) { - close_parent = false; - } + ) + if (r === true) close_parent = false } //special cases - if (value) { + if (typeof value === "object") { if (value.callback && !options.ignore_item_callbacks && value.disabled !== true) { //item callback - var r = value.callback.call( + const r = value.callback.call( this, value, options, e, that, options.extra - ); - if (r === true) { - close_parent = false; - } + ) + if (r === true) close_parent = false } if (value.submenu) { - if (!value.submenu.options) { - throw "ContextMenu submenu needs options"; - } - var submenu = new that.constructor(value.submenu.options, { + if (!value.submenu.options) + throw "ContextMenu submenu needs options" + + new that.constructor(value.submenu.options, { callback: value.submenu.callback, event: e, parentMenu: that, @@ -341,87 +304,74 @@ export class ContextMenu { title: value.submenu.title, extra: value.submenu.extra, autoopen: options.autoopen - }); - close_parent = false; + }) + close_parent = false } } - if (close_parent && !that.lock) { - that.close(); - } + if (close_parent && !that.lock) + that.close() } - return element; + return element } close(e?: MouseEvent, ignore_parent_menu?: boolean): void { - if (this.root.parentNode) { - this.root.parentNode.removeChild(this.root); - } + this.root.parentNode?.removeChild(this.root) if (this.parentMenu && !ignore_parent_menu) { - this.parentMenu.lock = false; - this.parentMenu.current_submenu = null; + this.parentMenu.lock = false + this.parentMenu.current_submenu = null if (e === undefined) { - this.parentMenu.close(); + this.parentMenu.close() } else if (e && !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { - ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e); + ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e) } } - if (this.current_submenu) { - this.current_submenu.close(e, true); - } + this.current_submenu?.close(e, true) - if (this.root.closing_timer) { - clearTimeout(this.root.closing_timer); - } - - // TODO implement : LiteGraph.contextMenuClosed(); :: keep track of opened / closed / current ContextMenu - // on key press, allow filtering/selecting the context menu elements + if (this.root.closing_timer) + clearTimeout(this.root.closing_timer) } //this code is used to trigger events easily (used in the context menu mouseleave - static trigger(element: HTMLDivElement, event_name: string, params: MouseEvent, origin?: undefined) { - var evt = document.createEvent("CustomEvent"); - evt.initCustomEvent(event_name, true, true, params); //canBubble, cancelable, detail - evt.srcElement = origin; - if (element.dispatchEvent) { - element.dispatchEvent(evt); - } else if (element.__events) { - element.__events.dispatchEvent(evt); - } + static trigger(element: HTMLDivElement, event_name: string, params: MouseEvent, origin?: unknown): CustomEvent { + const evt = document.createEvent("CustomEvent") + evt.initCustomEvent(event_name, true, true, params) //canBubble, cancelable, detail + // @ts-expect-error + evt.srcElement = origin + if (element.dispatchEvent) element.dispatchEvent(evt) + // @ts-expect-error + else if (element.__events) element.__events.dispatchEvent(evt) //else nothing seems binded here so nothing to do - return evt; + return evt } //returns the top most menu getTopMenu(): ContextMenu { - if (this.options.parentMenu) { - return this.options.parentMenu.getTopMenu(); - } - return this; + return this.options.parentMenu + ? this.options.parentMenu.getTopMenu() + : this } getFirstEvent(): MouseEvent { - if (this.options.parentMenu) { - return this.options.parentMenu.getFirstEvent(); - } - return this.options.event; + return this.options.parentMenu + ? this.options.parentMenu.getFirstEvent() + : this.options.event } static isCursorOverElement(event: MouseEvent, element: HTMLDivElement): boolean { - var left = event.clientX; - var top = event.clientY; - var rect = element.getBoundingClientRect(); - if (!rect) { - return false; - } + const left = event.clientX + const top = event.clientY + const rect = element.getBoundingClientRect() + if (!rect) return false + if (top > rect.top && top < rect.top + rect.height && left > rect.left && left < rect.left + rect.width) { - return true; + return true } - return false; + return false } } diff --git a/src/interfaces.ts b/src/interfaces.ts index f51e86aa9..133bc26c9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -106,12 +106,16 @@ export interface ConnectingLink extends IInputOrOutput { direction?: LinkDirection } -/** ContextMenu */ -export interface IContextMenuOptions { - ignore_item_callbacks?: boolean +interface IContextMenuBase { title?: string - parentMenu?: ContextMenu className?: string + callback?(value?: unknown, options?: unknown, event?: MouseEvent, previous_menu?: ContextMenu, node?: LGraphNode): void +} + +/** ContextMenu */ +export interface IContextMenuOptions extends IContextMenuBase { + ignore_item_callbacks?: boolean + parentMenu?: ContextMenu event?: MouseEvent extra?: unknown scroll_speed?: number @@ -120,19 +124,19 @@ export interface IContextMenuOptions { scale?: string node?: LGraphNode autoopen?: boolean - callback?(value?: unknown, options?: unknown, event?: MouseEvent, previous_menu?: ContextMenu, node?: LGraphNode): void } -export interface IContextMenuValue { - title?: string +export interface IContextMenuValue extends IContextMenuBase { value?: string content: string has_submenu?: boolean disabled?: boolean - className?: any - submenu?: unknown + submenu?: IContextMenuSubmenu property?: string type?: string slot?: IFoundSlot - callback?: IContextMenuOptions["callback"] +} + +export interface IContextMenuSubmenu extends IContextMenuOptions { + options: ConstructorParameters[0] }