mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
committed by
GitHub
parent
6afdb9529d
commit
b3da6cf1b4
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 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
148
src/lib/litegraph/src/contextMenuCompat.ts
Normal file
148
src/lib/litegraph/src/contextMenuCompat.ts
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
71
tests-ui/tests/extensions/contextMenuExtensionName.test.ts
Normal file
71
tests-ui/tests/extensions/contextMenuExtensionName.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
346
tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Normal file
346
tests-ui/tests/litegraph/core/contextMenuCompat.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user