Fix the Inspector highlight recursing after a Nodes 2.0 toggle

The frontend wraps canvas.onDrawForeground again whenever its DOM widgets
mount, and the wrapper calls the highlighter's hook, which then called the
wrapper. The hook now keeps every function it replaced and goes one level
down per call, so each runs once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 11:20:26 -08:00
co-authored by Claude Opus 5.5
parent f27a2e20f0
commit b325ea9d4c
4 changed files with 66 additions and 12 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Pop-out navigation and editing**: The viewer shows the Inspector's header, rows, breadcrumb and navigation (Back/Forward, Previous/Next, Centre, Lock, Go to node; ←/→, L and Ctrl+K). Edits go through the Inspector's rules: "Lock to edit", read-only linked widgets, the canvas's number steps, and combos opened on the current value. Status shows Connected, Glass hidden, Glass off, ComfyUI tab inactive or Disconnected.
### Fixed
- **Nodes 2.0 (Vue nodes)**: The glass shows the nodes, painted from the page: boxes, borders, text, widget values and prompts (icons are left out). The Inspector finds the node under the cursor, also in pan mode, and the highlight fits collapsed nodes.
- **Nodes 2.0 (Vue nodes)**: The glass shows the nodes, painted from the page: boxes, borders, text, widget values and prompts (icons are left out). The Inspector finds the node under the cursor, also in pan mode, and the highlight fits collapsed nodes. Turning Nodes 2.0 on and off no longer makes the highlight recurse without end.
- **Node Title Emphasis** sits on the title bar instead of the first slot row, and is not drawn on nodes without a title bar such as Reroute.
- The glass background matches the page past the canvas edge and at canvas zoom of 1.5 and above.
- **Subgraphs**: The panel, picker, highlight and editing use the graph on screen, so they work inside subgraphs. Title bars and collapsed nodes are hit.
+16 -6
View File
@@ -10,7 +10,10 @@ import { ComfyApp, ComfyNode } from '../types/comfyui';
declare const app: ComfyApp;
export class CanvasHighlighter {
private originalOnDrawForeground: ((ctx: CanvasRenderingContext2D, visible_nodes: any) => void) | null = null;
// Each onDrawForeground this hook replaced, oldest first. The frontend wraps the hook again whenever its
// DOM widgets mount (e.g. on a Nodes 2.0 toggle), so a later one calls back here, and each call goes a level down.
private originals: (((ctx: CanvasRenderingContext2D, visible_nodes: any) => void) | undefined)[] = [];
private depth = 0;
private highlightedNodeId: number | null = null;
private highlightEl: HTMLDivElement | null = null;
@@ -62,17 +65,23 @@ export class CanvasHighlighter {
// If our hook was overwritten (e.g. by another extension), re-hook
if (app.canvas.onDrawForeground !== this.boundOnDrawForeground) {
this.originalOnDrawForeground = app.canvas.onDrawForeground;
this.originals.push(app.canvas.onDrawForeground);
app.canvas.onDrawForeground = this.boundOnDrawForeground;
}
}
// Bound method to preserve 'this' and allow equality check
private boundOnDrawForeground = (ctx: CanvasRenderingContext2D, visible_nodes: any) => {
// Call original first
if (this.originalOnDrawForeground) {
this.originalOnDrawForeground.call((window as any).app.canvas, ctx, visible_nodes);
const original = this.originals[this.originals.length - 1 - this.depth];
if (original) {
this.depth++;
try {
original.call((window as any).app.canvas, ctx, visible_nodes);
} finally {
this.depth--;
}
}
if (this.depth > 0) return;
// Update DOM Highlight Position
this.updateHighlightPosition();
@@ -153,8 +162,9 @@ export class CanvasHighlighter {
*/
cleanup(): void {
const app = (window as any).app;
// A wrapper of this hook that is still in the chain keeps reaching the ones below it through here
if (app && app.canvas && app.canvas.onDrawForeground === this.boundOnDrawForeground) {
app.canvas.onDrawForeground = this.originalOnDrawForeground;
app.canvas.onDrawForeground = this.originals.pop();
}
if (this.highlightEl) {
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { CanvasHighlighter } from '../../src/info-panel/CanvasHighlighter';
describe('CanvasHighlighter', () => {
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
it('survives the frontend wrapping its hook again, as on a Nodes 2.0 toggle', () => {
const frontendDraw = vi.fn();
const canvas: any = { onDrawForeground: frontendDraw, setDirty: vi.fn(), graph: null };
vi.stubGlobal('app', { canvas });
const highlighter = new CanvasHighlighter({ '🔍MagnifyGlass.NodeHighlightEnabled': true });
highlighter.setHighlightedNode(1);
// The frontend's DOM widgets mount again and chain onto the hook they find, which is ours
const updateWidgets = vi.fn();
const ours = canvas.onDrawForeground;
canvas.onDrawForeground = function (ctx: unknown, area: unknown) { ours.call(this, ctx, area); updateWidgets(); };
highlighter.setHighlightedNode(2);
// Every hook in the chain runs once
canvas.onDrawForeground({}, []);
expect(updateWidgets).toHaveBeenCalledTimes(1);
expect(frontendDraw).toHaveBeenCalledTimes(1);
// Removed, it leaves the frontend's wrapper on top, still reaching the hook below
highlighter.cleanup();
canvas.onDrawForeground({}, []);
expect(updateWidgets).toHaveBeenCalledTimes(2);
expect(frontendDraw).toHaveBeenCalledTimes(2);
});
});
+15 -5
View File
@@ -3,7 +3,10 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
class CanvasHighlighter {
constructor(settings) {
__publicField(this, "originalOnDrawForeground", null);
// Each onDrawForeground this hook replaced, oldest first. The frontend wraps the hook again whenever its
// DOM widgets mount (e.g. on a Nodes 2.0 toggle), so a later one calls back here, and each call goes a level down.
__publicField(this, "originals", []);
__publicField(this, "depth", 0);
__publicField(this, "highlightedNodeId", null);
__publicField(this, "highlightEl", null);
// Cache for DOM updates to minimize thrashing
@@ -12,9 +15,16 @@ class CanvasHighlighter {
__publicField(this, "settings");
// Bound method to preserve 'this' and allow equality check
__publicField(this, "boundOnDrawForeground", (ctx, visible_nodes) => {
if (this.originalOnDrawForeground) {
this.originalOnDrawForeground.call(window.app.canvas, ctx, visible_nodes);
const original = this.originals[this.originals.length - 1 - this.depth];
if (original) {
this.depth++;
try {
original.call(window.app.canvas, ctx, visible_nodes);
} finally {
this.depth--;
}
}
if (this.depth > 0) return;
this.updateHighlightPosition();
});
this.settings = settings;
@@ -48,7 +58,7 @@ class CanvasHighlighter {
const app = window.app;
if (!app || !app.canvas) return;
if (app.canvas.onDrawForeground !== this.boundOnDrawForeground) {
this.originalOnDrawForeground = app.canvas.onDrawForeground;
this.originals.push(app.canvas.onDrawForeground);
app.canvas.onDrawForeground = this.boundOnDrawForeground;
}
}
@@ -107,7 +117,7 @@ class CanvasHighlighter {
cleanup() {
const app = window.app;
if (app && app.canvas && app.canvas.onDrawForeground === this.boundOnDrawForeground) {
app.canvas.onDrawForeground = this.originalOnDrawForeground;
app.canvas.onDrawForeground = this.originals.pop();
}
if (this.highlightEl) {
this.highlightEl.remove();