diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png index ac2f2b066..cffe23ed1 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png index 562ec32e6..5cd2eacc4 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png index ac2f2b066..cffe23ed1 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png differ diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index 1fd851420..cfcf5c809 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -1,4 +1,5 @@ import { st, te } from '@/i18n' +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' import type { IContextMenuOptions, IContextMenuValue, @@ -6,18 +7,40 @@ import type { IWidget } from '@/lib/litegraph/src/litegraph' import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { app } from '@/scripts/app' import { normalizeI18nKey } from '@/utils/formatUtil' /** * Add translation for litegraph context menu. */ export const useContextMenuTranslation = () => { - const f = LGraphCanvas.prototype.getCanvasMenuOptions + // Install compatibility layer BEFORE any extensions load + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + const { getCanvasMenuOptions } = LGraphCanvas.prototype const getCanvasCenterMenuOptions = function ( this: LGraphCanvas, - ...args: Parameters + ...args: Parameters ) { - const res = f.apply(this, args) as ReturnType + const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args) + + // Add items from new extension API + const newApiItems = app.collectCanvasMenuItems(this) + for (const item of newApiItems) { + res.push(item) + } + + // Add legacy monkey-patched items + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + this, + ...args + ) + for (const item of legacyItems) { + res.push(item) + } + + // Translate all items for (const item of res) { if (item?.content) { item.content = st(`contextMenu.${item.content}`, item.content) @@ -28,6 +51,33 @@ export const useContextMenuTranslation = () => { LGraphCanvas.prototype.getCanvasMenuOptions = getCanvasCenterMenuOptions + legacyMenuCompat.registerWrapper( + 'getCanvasMenuOptions', + getCanvasCenterMenuOptions, + getCanvasMenuOptions, + LGraphCanvas.prototype + ) + + // Wrap getNodeMenuOptions to add new API items + const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions + const getNodeMenuOptionsWithExtensions = function ( + this: LGraphCanvas, + ...args: Parameters + ) { + const res = nodeMenuFn.apply(this, args) + + // Add items from new extension API + const node = args[0] + const newApiItems = app.collectNodeMenuItems(node) + for (const item of newApiItems) { + res.push(item) + } + + return res + } + + LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions + function translateMenus( values: readonly (IContextMenuValue | string | null)[] | undefined, options: IContextMenuOptions diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index 2be2980eb..42408fa0d 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -1,10 +1,10 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' import { t } from '@/i18n' import { type NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' import { type ExecutableLGraphNode, type ExecutionId, - LGraphCanvas, LGraphNode, LiteGraph, SubgraphNode @@ -1630,57 +1630,6 @@ export class GroupNodeHandler { } } -function addConvertToGroupOptions() { - // @ts-expect-error fixme ts strict error - function addConvertOption(options, index) { - const selected = Object.values(app.canvas.selected_nodes ?? {}) - const disabled = - selected.length < 2 || - selected.find((n) => GroupNodeHandler.isGroupNode(n)) - options.splice(index, null, { - content: `Convert to Group Node (Deprecated)`, - disabled, - callback: convertSelectedNodesToGroupNode - }) - } - - // @ts-expect-error fixme ts strict error - function addManageOption(options, index) { - const groups = app.graph.extra?.groupNodes - const disabled = !groups || !Object.keys(groups).length - options.splice(index, null, { - content: `Manage Group Nodes`, - disabled, - callback: () => manageGroupNodes() - }) - } - - // Add to canvas - const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions - LGraphCanvas.prototype.getCanvasMenuOptions = function () { - // @ts-expect-error fixme ts strict error - const options = getCanvasMenuOptions.apply(this, arguments) - const index = options.findIndex((o) => o?.content === 'Add Group') - const insertAt = index === -1 ? options.length - 1 : index + 2 - addConvertOption(options, insertAt) - addManageOption(options, insertAt + 1) - return options - } - - // Add to nodes - const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions - LGraphCanvas.prototype.getNodeMenuOptions = function (node) { - // @ts-expect-error fixme ts strict error - const options = getNodeMenuOptions.apply(this, arguments) - if (!GroupNodeHandler.isGroupNode(node)) { - const index = options.findIndex((o) => o?.content === 'Properties') - const insertAt = index === -1 ? options.length - 1 : index - addConvertOption(options, insertAt) - } - return options - } -} - const replaceLegacySeparators = (nodes: ComfyNode[]): void => { for (const node of nodes) { if (typeof node.type === 'string' && node.type.startsWith('workflow/')) { @@ -1718,6 +1667,9 @@ async function convertSelectedNodesToGroupNode() { return await GroupNodeHandler.fromNodes(nodes) } +const convertDisabled = (selected: LGraphNode[]) => + selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n)) + function ungroupSelectedGroupNodes() { const nodes = Object.values(app.canvas.selected_nodes ?? {}) for (const node of nodes) { @@ -1776,8 +1728,46 @@ const ext: ComfyExtension = { } } ], - setup() { - addConvertToGroupOptions() + + getCanvasMenuItems(canvas): IContextMenuValue[] { + const items: IContextMenuValue[] = [] + const selected = Object.values(canvas.selected_nodes ?? {}) + const convertEnabled = !convertDisabled(selected) + + items.push({ + content: `Convert to Group Node (Deprecated)`, + disabled: !convertEnabled, + // @ts-expect-error fixme ts strict error - async callback + callback: () => convertSelectedNodesToGroupNode() + }) + + const groups = canvas.graph?.extra?.groupNodes + const manageDisabled = !groups || !Object.keys(groups).length + items.push({ + content: `Manage Group Nodes`, + disabled: manageDisabled, + callback: () => manageGroupNodes() + }) + + return items + }, + + getNodeMenuItems(node): IContextMenuValue[] { + if (GroupNodeHandler.isGroupNode(node)) { + return [] + } + + const selected = Object.values(app.canvas.selected_nodes ?? {}) + const convertEnabled = !convertDisabled(selected) + + return [ + { + content: `Convert to Group Node (Deprecated)`, + disabled: !convertEnabled, + // @ts-expect-error fixme ts strict error - async callback + callback: () => convertSelectedNodesToGroupNode() + } + ] }, async beforeConfigureGraph( graphData: ComfyWorkflowJSON, diff --git a/src/extensions/core/groupOptions.ts b/src/extensions/core/groupOptions.ts index 5d49e0444..7e3240bcf 100644 --- a/src/extensions/core/groupOptions.ts +++ b/src/extensions/core/groupOptions.ts @@ -1,8 +1,14 @@ -import type { Positionable } from '@/lib/litegraph/src/interfaces' -import { LGraphGroup } from '@/lib/litegraph/src/litegraph' -import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + IContextMenuValue, + Positionable +} from '@/lib/litegraph/src/interfaces' +import { + LGraphCanvas, + LGraphGroup, + type LGraphNode +} from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' +import type { ComfyExtension } from '@/types/comfy' import { app } from '../../scripts/app' @@ -16,220 +22,218 @@ function addNodesToGroup(group: LGraphGroup, items: Iterable) { group.resizeTo([...group.children, ...items], padding) } -app.registerExtension({ +const ext: ComfyExtension = { name: 'Comfy.GroupOptions', - setup() { - const orig = LGraphCanvas.prototype.getCanvasMenuOptions - // graph_mouse - LGraphCanvas.prototype.getCanvasMenuOptions = function ( - this: LGraphCanvas - ) { - // @ts-expect-error fixme ts strict error - const options = orig.apply(this, arguments) - // @ts-expect-error fixme ts strict error - const group = this.graph.getGroupOnPos( - this.graph_mouse[0], - this.graph_mouse[1] - ) - if (!group) { - if (this.selectedItems.size > 0) { - options.push({ - content: 'Add Group For Selected Nodes', - callback: () => { - const group = new LGraphGroup() - addNodesToGroup(group, this.selectedItems) - // @ts-expect-error fixme ts strict error - this.graph.add(group) - // @ts-expect-error fixme ts strict error - this.graph.change() - group.recomputeInsideNodes() + getCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] { + const items: IContextMenuValue[] = [] + + // @ts-expect-error fixme ts strict error + const group = canvas.graph.getGroupOnPos( + canvas.graph_mouse[0], + canvas.graph_mouse[1] + ) + + if (!group) { + if (canvas.selectedItems.size > 0) { + items.push({ + content: 'Add Group For Selected Nodes', + callback: () => { + const group = new LGraphGroup() + addNodesToGroup(group, canvas.selectedItems) + // @ts-expect-error fixme ts strict error + canvas.graph.add(group) + // @ts-expect-error fixme ts strict error + canvas.graph.change() + + group.recomputeInsideNodes() + } + }) + } + + return items + } + + // Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date + group.recomputeInsideNodes() + const nodesInGroup = group.nodes + + items.push({ + content: 'Add Selected Nodes To Group', + disabled: !canvas.selectedItems?.size, + callback: () => { + addNodesToGroup(group, canvas.selectedItems) + // @ts-expect-error fixme ts strict error + canvas.graph.change() + } + }) + + // No nodes in group, return default options + if (nodesInGroup.length === 0) { + return items + } else { + // Add a separator between the default options and the group options + // @ts-expect-error fixme ts strict error + items.push(null) + } + + // Check if all nodes are the same mode + let allNodesAreSameMode = true + for (let i = 1; i < nodesInGroup.length; i++) { + if (nodesInGroup[i].mode !== nodesInGroup[0].mode) { + allNodesAreSameMode = false + break + } + } + + items.push({ + content: 'Fit Group To Nodes', + callback: () => { + group.recomputeInsideNodes() + const padding = useSettingStore().get( + 'Comfy.GroupSelectedNodes.Padding' + ) + group.resizeTo(group.children, padding) + // @ts-expect-error fixme ts strict error + canvas.graph.change() + } + }) + + items.push({ + content: 'Select Nodes', + callback: () => { + canvas.selectNodes(nodesInGroup) + // @ts-expect-error fixme ts strict error + canvas.graph.change() + canvas.canvas.focus() + } + }) + + // Modes + // 0: Always + // 1: On Event + // 2: Never + // 3: On Trigger + // 4: Bypass + // If all nodes are the same mode, add a menu option to change the mode + if (allNodesAreSameMode) { + const mode = nodesInGroup[0].mode + switch (mode) { + case 0: + // All nodes are always, option to disable, and bypass + items.push({ + content: 'Set Group Nodes to Never', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 2) + } + } + }) + items.push({ + content: 'Bypass Group Nodes', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 4) + } + } + }) + break + case 2: + // All nodes are never, option to enable, and bypass + items.push({ + content: 'Set Group Nodes to Always', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 0) + } + } + }) + items.push({ + content: 'Bypass Group Nodes', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 4) + } + } + }) + break + case 4: + // All nodes are bypass, option to enable, and disable + items.push({ + content: 'Set Group Nodes to Always', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 0) + } + } + }) + items.push({ + content: 'Set Group Nodes to Never', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 2) + } + } + }) + break + default: + // All nodes are On Trigger or On Event(Or other?), option to disable, set to always, or bypass + items.push({ + content: 'Set Group Nodes to Always', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 0) + } + } + }) + items.push({ + content: 'Set Group Nodes to Never', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 2) + } + } + }) + items.push({ + content: 'Bypass Group Nodes', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 4) + } } }) - } - - return options - } - - // Group nodes aren't recomputed until the group is moved, this ensures the nodes are up-to-date - group.recomputeInsideNodes() - const nodesInGroup = group.nodes - - options.push({ - content: 'Add Selected Nodes To Group', - disabled: !this.selectedItems?.size, - callback: () => { - addNodesToGroup(group, this.selectedItems) - // @ts-expect-error fixme ts strict error - this.graph.change() - } - }) - - // No nodes in group, return default options - if (nodesInGroup.length === 0) { - return options - } else { - // Add a separator between the default options and the group options - // @ts-expect-error fixme ts strict error - options.push(null) - } - - // Check if all nodes are the same mode - let allNodesAreSameMode = true - for (let i = 1; i < nodesInGroup.length; i++) { - if (nodesInGroup[i].mode !== nodesInGroup[0].mode) { - allNodesAreSameMode = false break - } } - - options.push({ - content: 'Fit Group To Nodes', + } else { + // Nodes are not all the same mode, add a menu option to change the mode to always, never, or bypass + items.push({ + content: 'Set Group Nodes to Always', callback: () => { - group.recomputeInsideNodes() - const padding = useSettingStore().get( - 'Comfy.GroupSelectedNodes.Padding' - ) - group.resizeTo(group.children, padding) - // @ts-expect-error fixme ts strict error - this.graph.change() + for (const node of nodesInGroup) { + setNodeMode(node, 0) + } } }) - - options.push({ - content: 'Select Nodes', + items.push({ + content: 'Set Group Nodes to Never', callback: () => { - this.selectNodes(nodesInGroup) - // @ts-expect-error fixme ts strict error - this.graph.change() - this.canvas.focus() + for (const node of nodesInGroup) { + setNodeMode(node, 2) + } } }) - - // Modes - // 0: Always - // 1: On Event - // 2: Never - // 3: On Trigger - // 4: Bypass - // If all nodes are the same mode, add a menu option to change the mode - if (allNodesAreSameMode) { - const mode = nodesInGroup[0].mode - switch (mode) { - case 0: - // All nodes are always, option to disable, and bypass - options.push({ - content: 'Set Group Nodes to Never', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 2) - } - } - }) - options.push({ - content: 'Bypass Group Nodes', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 4) - } - } - }) - break - case 2: - // All nodes are never, option to enable, and bypass - options.push({ - content: 'Set Group Nodes to Always', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 0) - } - } - }) - options.push({ - content: 'Bypass Group Nodes', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 4) - } - } - }) - break - case 4: - // All nodes are bypass, option to enable, and disable - options.push({ - content: 'Set Group Nodes to Always', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 0) - } - } - }) - options.push({ - content: 'Set Group Nodes to Never', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 2) - } - } - }) - break - default: - // All nodes are On Trigger or On Event(Or other?), option to disable, set to always, or bypass - options.push({ - content: 'Set Group Nodes to Always', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 0) - } - } - }) - options.push({ - content: 'Set Group Nodes to Never', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 2) - } - } - }) - options.push({ - content: 'Bypass Group Nodes', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 4) - } - } - }) - break + items.push({ + content: 'Bypass Group Nodes', + callback: () => { + for (const node of nodesInGroup) { + setNodeMode(node, 4) + } } - } else { - // Nodes are not all the same mode, add a menu option to change the mode to always, never, or bypass - options.push({ - content: 'Set Group Nodes to Always', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 0) - } - } - }) - options.push({ - content: 'Set Group Nodes to Never', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 2) - } - } - }) - options.push({ - content: 'Bypass Group Nodes', - callback: () => { - for (const node of nodesInGroup) { - setNodeMode(node, 4) - } - } - }) - } - - return options + }) } + + return items } -}) +} + +app.registerExtension(ext) diff --git a/src/extensions/core/nodeTemplates.ts b/src/extensions/core/nodeTemplates.ts index 528f73a5f..c16ebed72 100644 --- a/src/extensions/core/nodeTemplates.ts +++ b/src/extensions/core/nodeTemplates.ts @@ -1,8 +1,10 @@ import { downloadBlob } from '@/base/common/downloadUtil' import { t } from '@/i18n' -import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' +import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' +import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import { useToastStore } from '@/platform/updates/common/toastStore' import { useDialogService } from '@/services/dialogService' +import type { ComfyExtension } from '@/types/comfy' import { deserialiseAndCreate } from '@/utils/vintageClipboard' import { api } from '../../scripts/api' @@ -328,110 +330,107 @@ class ManageTemplates extends ComfyDialog { } } -app.registerExtension({ +const manage = new ManageTemplates() + +// @ts-expect-error fixme ts strict error +const clipboardAction = async (cb) => { + // We use the clipboard functions but dont want to overwrite the current user clipboard + // Restore it after we've run our callback + const old = localStorage.getItem('litegrapheditor_clipboard') + await cb() + // @ts-expect-error fixme ts strict error + localStorage.setItem('litegrapheditor_clipboard', old) +} + +const ext: ComfyExtension = { name: id, - setup() { - const manage = new ManageTemplates() + + getCanvasMenuItems(_canvas: LGraphCanvas): IContextMenuValue[] { + const items: IContextMenuValue[] = [] // @ts-expect-error fixme ts strict error - const clipboardAction = async (cb) => { - // We use the clipboard functions but dont want to overwrite the current user clipboard - // Restore it after we've run our callback - const old = localStorage.getItem('litegrapheditor_clipboard') - await cb() - // @ts-expect-error fixme ts strict error - localStorage.setItem('litegrapheditor_clipboard', old) - } + items.push(null) + items.push({ + content: `Save Selected as Template`, + disabled: !Object.keys(app.canvas.selected_nodes || {}).length, + callback: async () => { + const name = await useDialogService().prompt({ + title: t('nodeTemplates.saveAsTemplate'), + message: t('nodeTemplates.enterName'), + defaultValue: '' + }) + if (!name?.trim()) return - const orig = LGraphCanvas.prototype.getCanvasMenuOptions - LGraphCanvas.prototype.getCanvasMenuOptions = function () { - // @ts-expect-error fixme ts strict error - const options = orig.apply(this, arguments) + clipboardAction(() => { + app.canvas.copyToClipboard() + let data = localStorage.getItem('litegrapheditor_clipboard') + data = JSON.parse(data || '{}') + const nodeIds = Object.keys(app.canvas.selected_nodes) + for (let i = 0; i < nodeIds.length; i++) { + const node = app.graph.getNodeById(nodeIds[i]) + const nodeData = node?.constructor.nodeData - // @ts-expect-error fixme ts strict error - options.push(null) - options.push({ - content: `Save Selected as Template`, - disabled: !Object.keys(app.canvas.selected_nodes || {}).length, - // @ts-expect-error fixme ts strict error - callback: async () => { - const name = await useDialogService().prompt({ - title: t('nodeTemplates.saveAsTemplate'), - message: t('nodeTemplates.enterName'), - defaultValue: '' - }) - if (!name?.trim()) return - - clipboardAction(() => { - app.canvas.copyToClipboard() - let data = localStorage.getItem('litegrapheditor_clipboard') - // @ts-expect-error fixme ts strict error - data = JSON.parse(data) - const nodeIds = Object.keys(app.canvas.selected_nodes) - for (let i = 0; i < nodeIds.length; i++) { - const node = app.graph.getNodeById(nodeIds[i]) - const nodeData = node?.constructor.nodeData - - let groupData = GroupNodeHandler.getGroupData(node) - if (groupData) { - groupData = groupData.nodeData + let groupData = GroupNodeHandler.getGroupData(node) + if (groupData) { + groupData = groupData.nodeData + // @ts-expect-error + if (!data.groupNodes) { // @ts-expect-error - if (!data.groupNodes) { - // @ts-expect-error - data.groupNodes = {} - } - if (nodeData == null) throw new TypeError('nodeData is not set') - // @ts-expect-error - data.groupNodes[nodeData.name] = groupData - // @ts-expect-error - data.nodes[i].type = nodeData.name + data.groupNodes = {} } + if (nodeData == null) throw new TypeError('nodeData is not set') + // @ts-expect-error + data.groupNodes[nodeData.name] = groupData + // @ts-expect-error + data.nodes[i].type = nodeData.name } + } - manage.templates.push({ - name, - data: JSON.stringify(data) - }) - manage.store() + manage.templates.push({ + name, + data: JSON.stringify(data) + }) + manage.store() + }) + } + }) + + // Map each template to a menu item + const subItems = manage.templates.map((t) => { + return { + content: t.name, + callback: () => { + clipboardAction(async () => { + const data = JSON.parse(t.data) + await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {}) + + // Check for old clipboard format + if (!data.reroutes) { + deserialiseAndCreate(t.data, app.canvas) + } else { + localStorage.setItem('litegrapheditor_clipboard', t.data) + app.canvas.pasteFromClipboard() + } }) } - }) + } + }) - // Map each template to a menu item - const subItems = manage.templates.map((t) => { - return { - content: t.name, - callback: () => { - clipboardAction(async () => { - const data = JSON.parse(t.data) - await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {}) + // @ts-expect-error fixme ts strict error + subItems.push(null, { + content: 'Manage', + callback: () => manage.show() + }) - // Check for old clipboard format - if (!data.reroutes) { - deserialiseAndCreate(t.data, app.canvas) - } else { - localStorage.setItem('litegrapheditor_clipboard', t.data) - app.canvas.pasteFromClipboard() - } - }) - } - } - }) + items.push({ + content: 'Node Templates', + submenu: { + options: subItems + } + }) - // @ts-expect-error fixme ts strict error - subItems.push(null, { - content: 'Manage', - callback: () => manage.show() - }) - - options.push({ - content: 'Node Templates', - submenu: { - options: subItems - } - }) - - return options - } + return items } -}) +} + +app.registerExtension(ext) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 4bac98402..bae17e74b 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1262,7 +1262,7 @@ export class LGraphCanvas if (!node) return // TODO: This is a static method, so the below "that" appears broken. - if (v.callback) v.callback.call(this, node, v, e, prev) + if (v.callback) void v.callback.call(this, node, v, e, prev) if (!v.value) return @@ -8042,7 +8042,7 @@ export class LGraphCanvas } } - getCanvasMenuOptions(): IContextMenuValue[] { + getCanvasMenuOptions(): IContextMenuValue[] { let options: IContextMenuValue[] if (this.getMenuOptions) { options = this.getMenuOptions() diff --git a/src/lib/litegraph/src/contextMenuCompat.ts b/src/lib/litegraph/src/contextMenuCompat.ts new file mode 100644 index 000000000..5d2cd09ad --- /dev/null +++ b/src/lib/litegraph/src/contextMenuCompat.ts @@ -0,0 +1,148 @@ +import type { LGraphCanvas } from './LGraphCanvas' +import type { IContextMenuValue } from './interfaces' + +/** + * Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches. + * To disable legacy support, set ENABLE_LEGACY_SUPPORT = false + */ +const ENABLE_LEGACY_SUPPORT = true + +type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[] + +class LegacyMenuCompat { + private originalMethods = new Map() + private hasWarned = new Set() + private currentExtension: string | null = null + private isExtracting = false + private readonly wrapperMethods = new Map() + private readonly preWrapperMethods = new Map< + string, + ContextMenuValueProvider + >() + private readonly wrapperInstalled = new Map() + + /** + * Set the name of the extension that is currently being set up. + * This allows us to track which extension is monkey-patching. + * @param extensionName The name of the extension + */ + setCurrentExtension(extensionName: string | null) { + this.currentExtension = extensionName + } + + /** + * Register a wrapper method that should NOT be treated as a legacy monkey-patch. + * @param methodName The method name + * @param wrapperFn The wrapper function + * @param preWrapperFn The method that existed before the wrapper + * @param prototype The prototype to verify wrapper installation + */ + registerWrapper( + methodName: keyof LGraphCanvas, + wrapperFn: ContextMenuValueProvider, + preWrapperFn: ContextMenuValueProvider, + prototype?: LGraphCanvas + ) { + this.wrapperMethods.set(methodName, wrapperFn) + this.preWrapperMethods.set(methodName, preWrapperFn) + const isInstalled = prototype && prototype[methodName] === wrapperFn + this.wrapperInstalled.set(methodName, !!isInstalled) + } + + /** + * Install compatibility layer to detect monkey-patching + * @param prototype The prototype to install on + * @param methodName The method name to track + */ + install(prototype: LGraphCanvas, methodName: keyof LGraphCanvas) { + if (!ENABLE_LEGACY_SUPPORT) return + + const originalMethod = prototype[methodName] + this.originalMethods.set(methodName, originalMethod) + + let currentImpl = originalMethod + + Object.defineProperty(prototype, methodName, { + get() { + return currentImpl + }, + set: (newImpl: ContextMenuValueProvider) => { + const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}` + if (!this.hasWarned.has(fnKey) && this.currentExtension) { + this.hasWarned.add(fnKey) + + console.warn( + `%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated. (Extension: "${this.currentExtension}")\n` + + `Please use the new context menu API instead.\n\n` + + `See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`, + 'color: orange; font-weight: bold', + 'color: inherit' + ) + } + currentImpl = newImpl + } + }) + } + + /** + * Extract items that were added by legacy monkey patches + * @param methodName The method name that was monkey-patched + * @param context The context to call methods with + * @param args Arguments to pass to the methods + * @returns Array of menu items added by monkey patches + */ + extractLegacyItems( + methodName: keyof LGraphCanvas, + context: LGraphCanvas, + ...args: unknown[] + ): IContextMenuValue[] { + if (!ENABLE_LEGACY_SUPPORT) return [] + if (this.isExtracting) return [] + + const originalMethod = this.originalMethods.get(methodName) + if (!originalMethod) return [] + + try { + this.isExtracting = true + + const originalItems = originalMethod.apply(context, args) as + | IContextMenuValue[] + | undefined + if (!originalItems) return [] + + const currentMethod = context.constructor.prototype[methodName] + if (!currentMethod || currentMethod === originalMethod) return [] + + const registeredWrapper = this.wrapperMethods.get(methodName) + if (registeredWrapper && currentMethod === registeredWrapper) return [] + + const preWrapperMethod = this.preWrapperMethods.get(methodName) + const wrapperWasInstalled = this.wrapperInstalled.get(methodName) + + const shouldSkipWrapper = + preWrapperMethod && + wrapperWasInstalled && + currentMethod !== preWrapperMethod + + const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod + + const patchedItems = methodToCall.apply(context, args) as + | IContextMenuValue[] + | undefined + if (!patchedItems) return [] + + if (patchedItems.length > originalItems.length) { + return patchedItems.slice(originalItems.length) as IContextMenuValue[] + } + + return [] + } catch (e) { + console.error('[Context Menu Compat] Failed to extract legacy items:', e) + return [] + } finally { + this.isExtracting = false + } + } +} + +export const legacyMenuCompat = new LegacyMenuCompat() diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index f4b4888b9..3ab56a112 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -393,7 +393,7 @@ export interface IContextMenuOptions event?: MouseEvent, previous_menu?: ContextMenu, extra?: unknown - ): void | boolean + ): void | boolean | Promise } export interface IContextMenuValue< @@ -416,7 +416,7 @@ export interface IContextMenuValue< event?: MouseEvent, previous_menu?: ContextMenu, extra?: TExtra - ): void | boolean + ): void | boolean | Promise } interface IContextMenuSubmenu diff --git a/src/services/extensionService.ts b/src/services/extensionService.ts index 1e51991d6..4f3f523d3 100644 --- a/src/services/extensionService.ts +++ b/src/services/extensionService.ts @@ -1,5 +1,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useErrorHandling } from '@/composables/useErrorHandling' +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' import { useSettingStore } from '@/platform/settings/settingStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -136,8 +137,25 @@ export const useExtensionService = () => { extensionStore.enabledExtensions.map(async (ext) => { if (method in ext) { try { - return await ext[method](...args, app) + // Set current extension name for legacy compatibility tracking + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(ext.name) + } + + const result = await ext[method](...args, app) + + // Clear current extension after setup + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(null) + } + + return result } catch (error) { + // Clear current extension on error too + if (method === 'setup') { + legacyMenuCompat.setCurrentExtension(null) + } + console.error( `Error calling extension '${ext.name}' method '${method}'`, { error }, diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 9bc35e0b9..3fe45fcea 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -658,7 +658,6 @@ export const useLitegraphService = () => { return [ { content: 'Copy Image', - // @ts-expect-error: async callback is not accepted by litegraph callback: async () => { const url = new URL(img.src) url.searchParams.delete('preview') diff --git a/tests-ui/tests/extensions/contextMenuExtension.test.ts b/tests-ui/tests/extensions/contextMenuExtension.test.ts index e6766b0da..5cd9d3664 100644 --- a/tests-ui/tests/extensions/contextMenuExtension.test.ts +++ b/tests-ui/tests/extensions/contextMenuExtension.test.ts @@ -77,9 +77,9 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(ext1) extensionStore.registerExtension(ext2) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() as IContextMenuValue[] + .flat() expect(items).toHaveLength(3) expect(items[0]).toMatchObject({ content: 'Canvas Item 1' }) @@ -105,9 +105,9 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(extension) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() as IContextMenuValue[] + .flat() expect(items).toHaveLength(3) expect(items[0].content).toBe('Menu with Submenu') @@ -127,13 +127,44 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(canvasExtension) extensionStore.registerExtension(extensionWithoutCanvasMenu) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getCanvasMenuItems', mockCanvas) - .flat() as IContextMenuValue[] + .flat() expect(items).toHaveLength(1) expect(items[0].content).toBe('Canvas Item 1') }) + + it('should not duplicate menu items when collected multiple times', () => { + const extension = createCanvasMenuExtension('Test Extension', [ + canvasMenuItem1, + canvasMenuItem2 + ]) + + extensionStore.registerExtension(extension) + + // Collect items multiple times (simulating repeated menu opens) + const items1: IContextMenuValue[] = extensionService + .invokeExtensions('getCanvasMenuItems', mockCanvas) + .flat() + + const items2: IContextMenuValue[] = extensionService + .invokeExtensions('getCanvasMenuItems', mockCanvas) + .flat() + + // Both collections should have the same items (no duplication) + expect(items1).toHaveLength(2) + expect(items2).toHaveLength(2) + + // Verify items are unique by checking their content + const contents1 = items1.map((item) => item.content) + const uniqueContents1 = new Set(contents1) + expect(uniqueContents1.size).toBe(contents1.length) + + const contents2 = items2.map((item) => item.content) + const uniqueContents2 = new Set(contents2) + expect(uniqueContents2.size).toBe(contents2.length) + }) }) describe('collectNodeMenuItems', () => { @@ -147,9 +178,9 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(ext1) extensionStore.registerExtension(ext2) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() as IContextMenuValue[] + .flat() expect(items).toHaveLength(3) expect(items[0]).toMatchObject({ content: 'Node Item 1' }) @@ -172,9 +203,9 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(extension) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() as IContextMenuValue[] + .flat() expect(items[0].content).toBe('Node Menu with Submenu') expect(items[0].submenu?.options).toHaveLength(2) @@ -189,9 +220,9 @@ describe('Context Menu Extension API', () => { extensionStore.registerExtension(nodeExtension) extensionStore.registerExtension(extensionWithoutNodeMenu) - const items = extensionService + const items: IContextMenuValue[] = extensionService .invokeExtensions('getNodeMenuItems', mockNode) - .flat() as IContextMenuValue[] + .flat() expect(items).toHaveLength(1) expect(items[0].content).toBe('Node Item 1') diff --git a/tests-ui/tests/extensions/contextMenuExtensionName.test.ts b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts new file mode 100644 index 000000000..1fb0b5fd5 --- /dev/null +++ b/tests-ui/tests/extensions/contextMenuExtensionName.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' + +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' +import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' + +/** + * Test that demonstrates the extension name appearing in deprecation warnings + */ +describe('Context Menu Extension Name in Warnings', () => { + it('should include extension name in deprecation warning', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // Simulate what happens during extension setup + legacyMenuCompat.setCurrentExtension('MyCustomExtension') + + // Extension monkey-patches the method + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'My Custom Menu Item', callback: () => {} }) + return items + } + + // Clear extension (happens after setup completes) + legacyMenuCompat.setCurrentExtension(null) + + // Verify the warning includes the extension name + expect(warnSpy).toHaveBeenCalled() + const warningMessage = warnSpy.mock.calls[0][0] + + expect(warningMessage).toContain('[DEPRECATED]') + expect(warningMessage).toContain('getCanvasMenuOptions') + expect(warningMessage).toContain('"MyCustomExtension"') + + vi.restoreAllMocks() + }) + + it('should include extension name for node menu patches', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions') + + // Simulate what happens during extension setup + legacyMenuCompat.setCurrentExtension('AnotherExtension') + + // Extension monkey-patches the method + const original = LGraphCanvas.prototype.getNodeMenuOptions + LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'My Node Menu Item', callback: () => {} }) + return items + } + + // Clear extension (happens after setup completes) + legacyMenuCompat.setCurrentExtension(null) + + // Verify the warning includes extension info + expect(warnSpy).toHaveBeenCalled() + const warningMessage = warnSpy.mock.calls[0][0] + + expect(warningMessage).toContain('[DEPRECATED]') + expect(warningMessage).toContain('getNodeMenuOptions') + expect(warningMessage).toContain('"AnotherExtension"') + + vi.restoreAllMocks() + }) +}) diff --git a/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts new file mode 100644 index 000000000..623082c68 --- /dev/null +++ b/tests-ui/tests/litegraph/core/contextMenuCompat.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat' +import { LGraphCanvas } from '@/lib/litegraph/src/litegraph' + +describe('contextMenuCompat', () => { + let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions + let mockCanvas: LGraphCanvas + + beforeEach(() => { + // Save original method + originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions + + // Create mock canvas + mockCanvas = { + constructor: { + prototype: LGraphCanvas.prototype + } + } as unknown as LGraphCanvas + + // Clear console warnings + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + // Restore original method + LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions + vi.restoreAllMocks() + }) + + describe('install', () => { + it('should install compatibility layer on prototype', () => { + const methodName = 'getCanvasMenuOptions' + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, methodName) + + // The method should still be callable + expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe( + 'function' + ) + }) + + it('should detect monkey patches and warn', () => { + const methodName = 'getCanvasMenuOptions' + const warnSpy = vi.spyOn(console, 'warn') + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, methodName) + + // Set current extension before monkey-patching + legacyMenuCompat.setCurrentExtension('Test Extension') + + // Simulate extension monkey-patching + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'Custom Item', callback: () => {} }) + return items + } + + // Should have logged a warning with extension name + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[DEPRECATED]'), + expect.any(String), + expect.any(String) + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"Test Extension"'), + expect.any(String), + expect.any(String) + ) + + // Clear extension + legacyMenuCompat.setCurrentExtension(null) + }) + + it('should only warn once per unique function', () => { + const methodName = 'getCanvasMenuOptions' + const warnSpy = vi.spyOn(console, 'warn') + + legacyMenuCompat.install(LGraphCanvas.prototype, methodName) + legacyMenuCompat.setCurrentExtension('test.extension') + + const patchFunction = function (this: LGraphCanvas, ...args: any[]) { + const items = (originalGetCanvasMenuOptions as any).apply(this, args) + items.push({ content: 'Custom', callback: () => {} }) + return items + } + + // Patch twice with same function + LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction + LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction + + // Should only warn once + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('extractLegacyItems', () => { + beforeEach(() => { + // Setup a mock original method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [ + { content: 'Item 1', callback: () => {} }, + { content: 'Item 2', callback: () => {} } + ] + } + + // Install compatibility layer + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + }) + + it('should extract items added by monkey patches', () => { + // Monkey-patch to add items + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'Custom Item 1', callback: () => {} }) + items.push({ content: 'Custom Item 2', callback: () => {} }) + return items + } + + // Extract legacy items + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(2) + expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' }) + expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' }) + }) + + it('should return empty array when no items added', () => { + // No monkey-patching, so no extra items + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(0) + }) + + it('should return empty array when patched method returns same count', () => { + // Monkey-patch that replaces items but keeps same count + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [ + { content: 'Replaced 1', callback: () => {} }, + { content: 'Replaced 2', callback: () => {} } + ] + } + + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(0) + }) + + it('should handle errors gracefully', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Monkey-patch that throws error + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + throw new Error('Test error') + } + + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to extract legacy items'), + expect.any(Error) + ) + }) + }) + + describe('integration', () => { + it('should work with multiple extensions patching', () => { + // Setup base method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [{ content: 'Base Item', callback: () => {} }] + } + + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // First extension patches + const original1 = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original1 as any).apply(this, args) + items.push({ content: 'Extension 1 Item', callback: () => {} }) + return items + } + + // Second extension patches + const original2 = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original2 as any).apply(this, args) + items.push({ content: 'Extension 2 Item', callback: () => {} }) + return items + } + + // Extract legacy items + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + // Should extract both items added by extensions + expect(legacyItems).toHaveLength(2) + expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' }) + expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' }) + }) + + it('should extract legacy items only once even when called multiple times', () => { + // Setup base method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [ + { content: 'Base Item 1', callback: () => {} }, + { content: 'Base Item 2', callback: () => {} } + ] + } + + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // Simulate legacy extension monkey-patching the prototype + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'Legacy Item 1', callback: () => {} }) + items.push({ content: 'Legacy Item 2', callback: () => {} }) + return items + } + + // Extract legacy items multiple times (simulating repeated menu opens) + const legacyItems1 = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + const legacyItems2 = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + const legacyItems3 = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + // Each extraction should return the same items (no accumulation) + expect(legacyItems1).toHaveLength(2) + expect(legacyItems2).toHaveLength(2) + expect(legacyItems3).toHaveLength(2) + + // Verify items are the expected ones + expect(legacyItems1[0]).toMatchObject({ content: 'Legacy Item 1' }) + expect(legacyItems1[1]).toMatchObject({ content: 'Legacy Item 2' }) + + expect(legacyItems2[0]).toMatchObject({ content: 'Legacy Item 1' }) + expect(legacyItems2[1]).toMatchObject({ content: 'Legacy Item 2' }) + + expect(legacyItems3[0]).toMatchObject({ content: 'Legacy Item 1' }) + expect(legacyItems3[1]).toMatchObject({ content: 'Legacy Item 2' }) + }) + + it('should not extract items from registered wrapper methods', () => { + // Setup base method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [{ content: 'Base Item', callback: () => {} }] + } + + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // Create a wrapper that adds new API items (simulating useContextMenuTranslation) + const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions + const wrapperMethod = function (this: LGraphCanvas) { + const items = (originalMethod as any).apply(this, []) + // Add new API items + items.push({ content: 'New API Item 1', callback: () => {} }) + items.push({ content: 'New API Item 2', callback: () => {} }) + return items + } + + // Set the wrapper as the current method + LGraphCanvas.prototype.getCanvasMenuOptions = wrapperMethod + + // Register the wrapper so it's not treated as a legacy patch + legacyMenuCompat.registerWrapper( + 'getCanvasMenuOptions', + wrapperMethod, + originalMethod, + LGraphCanvas.prototype // Wrapper is installed + ) + + // Extract legacy items - should return empty because current method is a registered wrapper + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(0) + }) + + it('should extract legacy items even when a wrapper is registered but not active', () => { + // Setup base method + LGraphCanvas.prototype.getCanvasMenuOptions = function () { + return [{ content: 'Base Item', callback: () => {} }] + } + + legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions') + + // Register a wrapper (but don't set it as the current method) + const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions + const wrapperMethod = function () { + return [{ content: 'Wrapper Item', callback: () => {} }] + } + legacyMenuCompat.registerWrapper( + 'getCanvasMenuOptions', + wrapperMethod, + originalMethod + // NOT passing prototype, so it won't be marked as installed + ) + + // Monkey-patch with a different function (legacy extension) + const original = LGraphCanvas.prototype.getCanvasMenuOptions + LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) { + const items = (original as any).apply(this, args) + items.push({ content: 'Legacy Item', callback: () => {} }) + return items + } + + // Extract legacy items - should return the legacy item because current method is NOT the wrapper + const legacyItems = legacyMenuCompat.extractLegacyItems( + 'getCanvasMenuOptions', + mockCanvas + ) + + expect(legacyItems).toHaveLength(1) + expect(legacyItems[0]).toMatchObject({ content: 'Legacy Item' }) + }) + }) +})