Contextmenu extension migration (#5993)

This pull request refactors how context menu items are contributed by
extensions in the LiteGraph-based canvas. The legacy monkey-patching
approach for adding context menu options is replaced by a new, explicit
API (`getCanvasMenuItems` and `getNodeMenuItems`) for extensions. A
compatibility layer is added to support legacy extensions and warn
developers about deprecated usage. The changes improve maintainability,
extension interoperability, and migration to the new context menu
system.

### Context Menu System Refactor

* Introduced a new API for extensions to contribute context menu items
via `getCanvasMenuItems` and `getNodeMenuItems` methods, replacing
legacy monkey-patching of `LGraphCanvas.prototype.getCanvasMenuOptions`.
Major extension files (`groupNode.ts`, `groupOptions.ts`,
`nodeTemplates.ts`) now use this new API.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1779-R1771)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL232-R239)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL447-R458)
* Added a compatibility layer (`legacyMenuCompat` in
`contextMenuCompat.ts`) to detect and warn when legacy monkey-patching
is used, and to extract legacy-added menu items for backward
compatibility.
[[1]](diffhunk://#diff-2b724cb107c04e290369fb927e2ae9fad03be9e617a7d4de2487deab89d0d018R2-R45)
[[2]](diffhunk://#diff-d3a8284ec16ae3f9512e33abe44ae653ed1aa45c9926485ef6270cc8d2b94ae6R1-R115)

### Extension Migration

* Refactored core extensions (`groupNode`, `groupOptions`, and
`nodeTemplates`) to implement the new context menu API, moving menu item
logic out of monkey-patched methods and into explicit extension methods.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917L1633-L1683)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL19-R77)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL366-R373)

### Type and Import Cleanup

* Updated imports for context menu types (`IContextMenuValue`) across
affected files for consistency with the new API.
[[1]](diffhunk://#diff-b29f141b89433027e7bb7cde57fad84f9e97ffbe5c58040d3e0fdb7905022917R4-L7)
[[2]](diffhunk://#diff-91169f3a27ff8974d5c8fc3346bd99c07bdfb5399984484630125fdd647ff02fL1-R11)
[[3]](diffhunk://#diff-04c18583d2dbfc013888e4c02fd432c250acbcecdef82bf7f6d9fd888e632a6eL2-R6)
[[4]](diffhunk://#diff-bde0dce9fe2403685d27b0e94a938c3d72824d02d01d1fd6167a0dddc6e585ddR10)

### Backward Compatibility and Migration Guidance

* The compatibility layer logs a deprecation warning to the console when
legacy monkey-patching is detected, helping developers migrate to the
new API.

---

These changes collectively modernize the context menu extension
mechanism, improve code clarity, and provide a migration path for legacy
extensions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5993-Contextmenu-extension-migration-2876d73d3650813fae07c1141679637a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2025-10-28 04:02:28 +01:00
committed by GitHub
parent 6afdb9529d
commit b3da6cf1b4
15 changed files with 1030 additions and 374 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -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<typeof f>
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res = f.apply(this, args) as ReturnType<typeof f>
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<typeof nodeMenuFn>
) {
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

View File

@@ -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,

View File

@@ -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<Positionable>) {
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)

View File

@@ -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)

View File

@@ -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<string>[] {
getCanvasMenuOptions(): IContextMenuValue[] {
let options: IContextMenuValue<string>[]
if (this.getMenuOptions) {
options = this.getMenuOptions()

View File

@@ -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<string, ContextMenuValueProvider>()
private hasWarned = new Set<string>()
private currentExtension: string | null = null
private isExtracting = false
private readonly wrapperMethods = new Map<string, ContextMenuValueProvider>()
private readonly preWrapperMethods = new Map<
string,
ContextMenuValueProvider
>()
private readonly wrapperInstalled = new Map<string, boolean>()
/**
* 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()

View File

@@ -393,7 +393,7 @@ export interface IContextMenuOptions<TValue = unknown, TExtra = unknown>
event?: MouseEvent,
previous_menu?: ContextMenu<TValue>,
extra?: unknown
): void | boolean
): void | boolean | Promise<void | boolean>
}
export interface IContextMenuValue<
@@ -416,7 +416,7 @@ export interface IContextMenuValue<
event?: MouseEvent,
previous_menu?: ContextMenu<TValue>,
extra?: TExtra
): void | boolean
): void | boolean | Promise<void | boolean>
}
interface IContextMenuSubmenu<TValue = unknown>

View File

@@ -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 },

View File

@@ -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')

View File

@@ -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')

View File

@@ -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()
})
})

View File

@@ -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' })
})
})
})