diff --git a/src/ContextMenu.ts b/src/ContextMenu.ts new file mode 100644 index 0000000000..afa3de1fb0 --- /dev/null +++ b/src/ContextMenu.ts @@ -0,0 +1,420 @@ +// @ts-nocheck +import { LiteGraph } from "./litegraph"; + +/* LiteGraph GUI elements used for canvas editing *************************************/ +/** + * ContextMenu from LiteGUI + * + * @class ContextMenu + * @constructor + * @param {Array} values (allows object { title: "Nice text", callback: function ... }) + * @param {Object} options [optional] Some options:\ + * - title: title to show on top of the menu + * - callback: function to call when an option is clicked, it receives the item information + * - ignore_item_callbacks: ignores the callback inside the item, it just calls the options.callback + * - event: you can pass a MouseEvent, this way the ContextMenu appears in that position + */ +export class ContextMenu { + constructor(values, options) { + options = options || {}; + this.options = options; + var that = this; + + //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; + } else { + this.parentMenu = options.parentMenu; + this.parentMenu.lock = true; + this.parentMenu.current_submenu = this; + } + if (options.parentMenu.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; + 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; + } + + 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"; + setTimeout(function () { + 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) { + //console.log("pointerevents: ContextMenu up root prevent"); + e.preventDefault(); + return true; + }, + true + ); + root.addEventListener( + "contextmenu", + function (e) { + if (e.button != 2) { + //right button + return false; + } + e.preventDefault(); + return false; + }, + true + ); + + LiteGraph.pointerListenerAdd(root, "down", + function (e) { + //console.log("pointerevents: ContextMenu down"); + if (e.button == 2) { + that.close(); + e.preventDefault(); + return true; + } + }, + true + ); + + function on_mouse_wheel(e) { + var pos = parseInt(root.style.top); + root.style.top = + (pos + e.deltaY * options.scroll_speed).toFixed() + "px"; + e.preventDefault(); + return true; + } + + if (!options.scroll_speed) { + options.scroll_speed = 0.1; + } + + root.addEventListener("wheel", on_mouse_wheel, true); + root.addEventListener("mousewheel", on_mouse_wheel, true); + + this.root = root; + + //title + if (options.title) { + var 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; + } + var value = values[i]; + this.addItem(name, value, options); + num++; + } + + //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; + } + if (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; + } + + if (root_document.fullscreenElement) + root_document.fullscreenElement.appendChild(root); + + + else + root_document.body.appendChild(root); + + //compute best position + var left = options.left || 0; + var top = options.top || 0; + if (options.event) { + left = options.event.clientX - 10; + top = options.event.clientY - 10; + if (options.title) { + top -= 20; + } + + 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(); + if (body_rect.height == 0) + 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; + } + } + + root.style.left = left + "px"; + root.style.top = top + "px"; + + if (options.scale) { + root.style.transform = "scale(" + options.scale + ")"; + } + } + + addItem(name, value, options) { + var that = this; + options = options || {}; + + var element = document.createElement("div"); + element.className = "litemenu-entry submenu"; + + var disabled = false; + + if (value === null) { + element.classList.add("separator"); + //element.innerHTML = "
" + //continue; + } else { + element.innerHTML = value && value.title ? value.title : name; + element.value = value; + element.setAttribute("role", "menuitem"); + + if (value) { + if (value.disabled) { + 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"); + } + } + + if (typeof value == "function") { + element.dataset["value"] = name; + element.onclick_callback = value; + } else { + element.dataset["value"] = value; + } + + if (value.className) { + element.className += " " + value.className; + } + } + + 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"); + if (entries) { + for (let i = 0; i < entries.length; i++) { + entries[i].setAttribute("aria-expanded", "false"); + } + } + element.setAttribute("aria-expanded", "true"); + } + + function inner_over(e) { + var value = this.value; + if (!value || !value.has_submenu) { + return; + } + //if it is a submenu, autoopen like the item was clicked + inner_onclick.call(this, e); + setAriaExpanded(); + } + + //menu option clicked + function inner_onclick(e) { + var value = this.value; + var close_parent = true; + + if (that.current_submenu) { + that.current_submenu.close(e); + } + if (value?.has_submenu || value?.submenu) { + setAriaExpanded(); + } + + //global callback + if (options.callback) { + var r = options.callback.call( + this, + value, + options, + e, + that, + options.node + ); + if (r === true) { + close_parent = false; + } + } + + //special cases + if (value) { + if (value.callback && + !options.ignore_item_callbacks && + value.disabled !== true) { + //item callback + var r = value.callback.call( + this, + value, + options, + e, + that, + options.extra + ); + 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, { + callback: value.submenu.callback, + event: e, + parentMenu: that, + ignore_item_callbacks: value.submenu.ignore_item_callbacks, + title: value.submenu.title, + extra: value.submenu.extra, + autoopen: options.autoopen + }); + close_parent = false; + } + } + + if (close_parent && !that.lock) { + that.close(); + } + } + + return element; + } + + close(e, ignore_parent_menu) { + if (this.root.parentNode) { + this.root.parentNode.removeChild(this.root); + } + if (this.parentMenu && !ignore_parent_menu) { + this.parentMenu.lock = false; + this.parentMenu.current_submenu = null; + if (e === undefined) { + this.parentMenu.close(); + } else if (e && + !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { + ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e); + } + } + if (this.current_submenu) { + 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 + } + + //this code is used to trigger events easily (used in the context menu mouseleave + static trigger(element, event_name, params, origin) { + 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); + } + //else nothing seems binded here so nothing to do + return evt; + } + + //returns the top most menu + getTopMenu() { + if (this.options.parentMenu) { + return this.options.parentMenu.getTopMenu(); + } + return this; + } + + getFirstEvent() { + if (this.options.parentMenu) { + return this.options.parentMenu.getFirstEvent(); + } + return this.options.event; + } + + static isCursorOverElement(event, element) { + var left = event.clientX; + var top = event.clientY; + var 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 false; + } +} diff --git a/src/litegraph.js b/src/litegraph.js index 5f644b4e42..1a5f3ce036 100755 --- a/src/litegraph.js +++ b/src/litegraph.js @@ -5,8 +5,9 @@ import { LGraphNode } from "./LGraphNode"; import { LGraphGroup } from "./LGraphGroup"; import { DragAndScale } from "./DragAndScale"; import { LGraphCanvas } from "./LGraphCanvas"; +import { ContextMenu } from "./ContextMenu"; -export { LGraph, LLink, LGraphNode, LGraphGroup, DragAndScale, LGraphCanvas } +export { LGraph, LLink, LGraphNode, LGraphGroup, DragAndScale, LGraphCanvas, ContextMenu } export const LiteGraph = new LiteGraphGlobal() @@ -101,425 +102,6 @@ export const LiteGraph = new LiteGraphGlobal() }; }//if - /* LiteGraph GUI elements used for canvas editing *************************************/ - - /** - * ContextMenu from LiteGUI - * - * @class ContextMenu - * @constructor - * @param {Array} values (allows object { title: "Nice text", callback: function ... }) - * @param {Object} options [optional] Some options:\ - * - title: title to show on top of the menu - * - callback: function to call when an option is clicked, it receives the item information - * - ignore_item_callbacks: ignores the callback inside the item, it just calls the options.callback - * - event: you can pass a MouseEvent, this way the ContextMenu appears in that position - */ - export class ContextMenu { - constructor(values, options) { - options = options || {}; - this.options = options; - var that = this; - - //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; - } else { - this.parentMenu = options.parentMenu; - this.parentMenu.lock = true; - this.parentMenu.current_submenu = this; - } - if (options.parentMenu.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; - 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; - } - - 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"; - setTimeout(function () { - 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) { - //console.log("pointerevents: ContextMenu up root prevent"); - e.preventDefault(); - return true; - }, - true - ); - root.addEventListener( - "contextmenu", - function (e) { - if (e.button != 2) { - //right button - return false; - } - e.preventDefault(); - return false; - }, - true - ); - - LiteGraph.pointerListenerAdd(root, "down", - function (e) { - //console.log("pointerevents: ContextMenu down"); - if (e.button == 2) { - that.close(); - e.preventDefault(); - return true; - } - }, - true - ); - - function on_mouse_wheel(e) { - var pos = parseInt(root.style.top); - root.style.top = - (pos + e.deltaY * options.scroll_speed).toFixed() + "px"; - e.preventDefault(); - return true; - } - - if (!options.scroll_speed) { - options.scroll_speed = 0.1; - } - - root.addEventListener("wheel", on_mouse_wheel, true); - root.addEventListener("mousewheel", on_mouse_wheel, true); - - this.root = root; - - //title - if (options.title) { - var 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; - } - var value = values[i]; - this.addItem(name, value, options); - num++; - } - - //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; - } - if (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; - } - - if (root_document.fullscreenElement) - root_document.fullscreenElement.appendChild(root); - - else - root_document.body.appendChild(root); - - //compute best position - var left = options.left || 0; - var top = options.top || 0; - if (options.event) { - left = options.event.clientX - 10; - top = options.event.clientY - 10; - if (options.title) { - top -= 20; - } - - 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(); - if (body_rect.height == 0) - 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; - } - } - - root.style.left = left + "px"; - root.style.top = top + "px"; - - if (options.scale) { - root.style.transform = "scale(" + options.scale + ")"; - } - } - - addItem(name, value, options) { - var that = this; - options = options || {}; - - var element = document.createElement("div"); - element.className = "litemenu-entry submenu"; - - var disabled = false; - - if (value === null) { - element.classList.add("separator"); - //element.innerHTML = "
" - //continue; - } else { - element.innerHTML = value && value.title ? value.title : name; - element.value = value; - element.setAttribute("role", "menuitem"); - - if (value) { - if (value.disabled) { - 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"); - } - } - - if (typeof value == "function") { - element.dataset["value"] = name; - element.onclick_callback = value; - } else { - element.dataset["value"] = value; - } - - if (value.className) { - element.className += " " + value.className; - } - } - - 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"); - if (entries) { - for (let i = 0; i < entries.length; i++) { - entries[i].setAttribute("aria-expanded", "false"); - } - } - element.setAttribute("aria-expanded", "true"); - } - - function inner_over(e) { - var value = this.value; - if (!value || !value.has_submenu) { - return; - } - //if it is a submenu, autoopen like the item was clicked - inner_onclick.call(this, e); - setAriaExpanded(); - } - - //menu option clicked - function inner_onclick(e) { - var value = this.value; - var close_parent = true; - - if (that.current_submenu) { - that.current_submenu.close(e); - } - if (value?.has_submenu || value?.submenu) { - setAriaExpanded(); - } - - //global callback - if (options.callback) { - var r = options.callback.call( - this, - value, - options, - e, - that, - options.node - ); - if (r === true) { - close_parent = false; - } - } - - //special cases - if (value) { - if (value.callback && - !options.ignore_item_callbacks && - value.disabled !== true) { - //item callback - var r = value.callback.call( - this, - value, - options, - e, - that, - options.extra - ); - 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, { - callback: value.submenu.callback, - event: e, - parentMenu: that, - ignore_item_callbacks: value.submenu.ignore_item_callbacks, - title: value.submenu.title, - extra: value.submenu.extra, - autoopen: options.autoopen - }); - close_parent = false; - } - } - - if (close_parent && !that.lock) { - that.close(); - } - } - - return element; - } - - close(e, ignore_parent_menu) { - if (this.root.parentNode) { - this.root.parentNode.removeChild(this.root); - } - if (this.parentMenu && !ignore_parent_menu) { - this.parentMenu.lock = false; - this.parentMenu.current_submenu = null; - if (e === undefined) { - this.parentMenu.close(); - } else if (e && - !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { - ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e); - } - } - if (this.current_submenu) { - 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 - } - - //this code is used to trigger events easily (used in the context menu mouseleave - static trigger(element, event_name, params, origin) { - 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); - } - //else nothing seems binded here so nothing to do - return evt; - } - - //returns the top most menu - getTopMenu() { - if (this.options.parentMenu) { - return this.options.parentMenu.getTopMenu(); - } - return this; - } - - getFirstEvent() { - if (this.options.parentMenu) { - return this.options.parentMenu.getFirstEvent(); - } - return this.options.event; - } - - static isCursorOverElement(event, element) { - var left = event.clientX; - var top = event.clientY; - var 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 false; - } - } - LiteGraph.ContextMenu = ContextMenu; LiteGraph.closeAllContextMenus = function(ref_window) {