Fix the Inspector hit test at canvas zooms other than 100%

Over a DOM widget such as a prompt text box, node_over is empty and the
Inspector falls back to its own hit test. That divided the offset by the
scale, so it missed at any zoom but 100%. It now uses LiteGraph's
ds.convertCanvasToOffset, and the try/catch that hid this is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Æmotion Studio
2026-11-04 12:26:00 -08:00
co-authored by Claude Opus 5.5
parent bf4aecb581
commit 95060a4f3f
6 changed files with 29 additions and 49 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Subgraphs**: The panel, picker, highlight and editing use the graph on screen, so they work inside subgraphs. Title bars and collapsed nodes are hit. The fallback hit test converts the pointer the way LiteGraph draws, so prompt text boxes and other DOM widgets find their node at any canvas zoom.
- **Widget edits** run the widget's callback, redraw the canvas and record undo. CLIPLoader's `type` widget shows again. The frontend's internal `$$` preview widgets (e.g. on Load Image) are no longer listed.
- Dropdowns no longer get orphaned or keep writing after the panel re-renders, and they can be filtered by typing.
- The panel re-renders only when the shown node changes, so hover, focus and text selection survive.
+5 -22
View File
@@ -37,28 +37,11 @@ export class InformationGatherer {
return app.canvas.node_over;
}
try {
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left;
const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top;
let graphPos: [number, number];
if (app.canvas.convertOffsetToCanvasPos) {
graphPos = app.canvas.convertOffsetToCanvasPos([mouseX, mouseY]);
} else {
const ds = app.canvas.ds || { scale: 1, offset: [0, 0] };
graphPos = [
(mouseX / ds.scale) - (ds.offset[0] / ds.scale),
(mouseY / ds.scale) - (ds.offset[1] / ds.scale)
];
}
return graph.getNodeOnPos(graphPos[0], graphPos[1]) ?? null;
} catch (err) {
console.warn("Error in node detection:", err);
}
return null;
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const { x, y } = magnifyGlass.lastKnownMousePosition;
// LiteGraph draws a graph position at (pos + offset) * scale from the canvas corner
const [graphX, graphY] = app.canvas.ds.convertCanvasToOffset([x - canvasRect.left, y - canvasRect.top]);
return graph.getNodeOnPos(graphX, graphY) ?? null;
}
getDetailedNodeInfo(node: ComfyNode): NodeInfo {
+2 -1
View File
@@ -237,12 +237,13 @@ export interface ComfyCanvas {
ds: {
scale: number;
offset: [number, number];
/** A position on the canvas element to graph coordinates */
convertCanvasToOffset(pos: [number, number]): [number, number];
};
node_over: ComfyNode | null;
/** True in pan mode: space held or the hand tool */
read_only: boolean;
selected_nodes: Record<string, ComfyNode>;
convertOffsetToCanvasPos?(pos: [number, number]): [number, number];
centerOnNode(node: ComfyNode): void;
openSubgraph(subgraph: ComfyGraph, fromNode: ComfyNode): void;
}
+4 -3
View File
@@ -79,7 +79,8 @@ describe('Info Panel lifecycle', () => {
// Node's own localStorage global hides happy-dom's
vi.stubGlobal('localStorage', new Storage());
addSetting.mockClear();
(window as any).app = { canvas: { onDrawForeground: null, setDirty: vi.fn() }, graph: null };
const ds = { convertCanvasToOffset: (pos: [number, number]) => pos };
(window as any).app = { canvas: { canvas: document.createElement('canvas'), ds, onDrawForeground: null, setDirty: vi.fn() }, graph: null };
mg = new FakeGlass();
});
@@ -996,7 +997,7 @@ describe('Info Panel lifecycle', () => {
beforeEach(() => {
nodes = [makeNode(1, 'Loader'), makeNode(2, 'Sampler')];
const graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null };
const graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null, getNodeOnPos: () => null };
Object.assign((window as any).app.canvas, { graph, node_over: nodes[0], selected_nodes: {}, centerOnNode: vi.fn() });
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
(mg as any).config = { showCursorPreview: false };
@@ -1204,7 +1205,7 @@ describe('Info Panel lifecycle', () => {
panelManager().updateInfo();
expect(shown()).toBe('Loader (undone)');
canvas.graph = { _nodes: [], getNodeById: () => nodes[0] };
canvas.graph = { _nodes: [], getNodeById: () => nodes[0], getNodeOnPos: () => null };
panelManager().updateInfo();
expect(state().currentNodeId).toBeNull();
});
+11 -1
View File
@@ -17,7 +17,11 @@ const mockApp: any = {
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 })
},
node_over: null,
ds: { scale: 1, offset: [0, 0] }
ds: {
scale: 1,
offset: [0, 0],
convertCanvasToOffset(pos: number[]) { return [pos[0] / this.scale - this.offset[0], pos[1] / this.scale - this.offset[1]]; }
}
}
};
@@ -78,6 +82,12 @@ describe('InformationGatherer', () => {
expect(getNodeOnPos).toHaveBeenCalledWith(200, 200);
expect(hit).toBe(node);
// Zoomed and panned: graph = canvas / scale - offset, as LiteGraph draws (pos + offset) * scale
Object.assign(mockApp.canvas.ds, { scale: 2, offset: [10, 20] });
gatherer.getNodeUnderCursor();
expect(getNodeOnPos).toHaveBeenLastCalledWith(90, 80);
Object.assign(mockApp.canvas.ds, { scale: 1, offset: [0, 0] });
mockApp.canvas.graph = canvasGraph;
});
+6 -21
View File
@@ -9,32 +9,17 @@ class InformationGatherer {
return null;
}
if (((_b = window.LiteGraph) == null ? void 0 : _b.vueNodesMode) && !app.canvas.read_only) {
const { x, y } = magnifyGlass.lastKnownMousePosition;
const nodeEl = document.elementsFromPoint(x, y).map((el) => el.closest(".lg-node[data-node-id]")).find(Boolean);
const { x: x2, y: y2 } = magnifyGlass.lastKnownMousePosition;
const nodeEl = document.elementsFromPoint(x2, y2).map((el) => el.closest(".lg-node[data-node-id]")).find(Boolean);
return nodeEl ? graph.getNodeById(Number(nodeEl.dataset.nodeId)) ?? null : null;
}
if (app.canvas.node_over) {
return app.canvas.node_over;
}
try {
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const mouseX = magnifyGlass.lastKnownMousePosition.x - canvasRect.left;
const mouseY = magnifyGlass.lastKnownMousePosition.y - canvasRect.top;
let graphPos;
if (app.canvas.convertOffsetToCanvasPos) {
graphPos = app.canvas.convertOffsetToCanvasPos([mouseX, mouseY]);
} else {
const ds = app.canvas.ds || { scale: 1, offset: [0, 0] };
graphPos = [
mouseX / ds.scale - ds.offset[0] / ds.scale,
mouseY / ds.scale - ds.offset[1] / ds.scale
];
}
return graph.getNodeOnPos(graphPos[0], graphPos[1]) ?? null;
} catch (err) {
console.warn("Error in node detection:", err);
}
return null;
const canvasRect = app.canvas.canvas.getBoundingClientRect();
const { x, y } = magnifyGlass.lastKnownMousePosition;
const [graphX, graphY] = app.canvas.ds.convertCanvasToOffset([x - canvasRect.left, y - canvasRect.top]);
return graph.getNodeOnPos(graphX, graphY) ?? null;
}
getDetailedNodeInfo(node) {
var _a, _b, _c, _d, _e, _f;