fix(preview): Pointer events broken in Node 2.0.21+

- Wheel zoom and pan on middle-click stopped working due to recent
  changes in Node's graph canvas container.
- Implement capture-phase wheel event handling on the document to
  regain control of preview zoom independent of Node version.
- Add safety checks against zero-size or detached canvases.
- Ensure pointer events reach preview canvases even if parent
  containers have pointer-events:none applied.
- Improve compatibility with Node 2.0 by adding fallbacks for
  widgets without proper addDOMWidget support.
This commit is contained in:
Ewald
2026-04-20 16:47:51 +02:00
parent a9dc8ef193
commit ec7925bc1a
11 changed files with 174 additions and 101 deletions
+10 -6
View File
@@ -467,6 +467,7 @@ function registerImageOpsLivePreview() {
startLoopIfVideo(node);
}
for (const w of node.widgets ?? []) {
if (typeof w.callback !== "function" && typeof w.callback !== "undefined") continue;
const orig = w.callback;
w.callback = function() {
const r = orig?.apply(this, arguments);
@@ -575,12 +576,15 @@ function registerImageOpsLivePreview() {
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType, _nodeData) {
nodeType.prototype.onNodeCreated = /* @__PURE__ */ (function(orig) {
return function() {
orig?.apply(this, arguments);
hookNode(this);
};
})(nodeType.prototype.onNodeCreated);
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
origOnNodeCreated?.apply(this, arguments);
hookNode(this);
};
},
// Node 2.0 fallback: called per-instance after the node is fully constructed.
nodeCreated(node) {
hookNode(node);
}
});
}
+16 -8
View File
@@ -131,13 +131,17 @@ function attachInteractions(node, ctx) {
st.drawPressureSizeInput?.addEventListener("change", syncDrawDynamics);
st.drawPressureOpacityInput?.addEventListener("change", syncDrawDynamics);
st.drawTiltSizeInput?.addEventListener("change", syncDrawDynamics);
canvas.addEventListener("wheel", (event) => {
const handleDrawWheel = (event) => {
const target = event.target;
if (target !== canvas && !canvas.contains(target)) return;
if (!st.drawGeometry) return;
if (event.ctrlKey || event.metaKey) {
event.stopImmediatePropagation();
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const mx = (event.clientX - rect.left) * sx - canvas.width / 2;
const my = (event.clientY - rect.top) * sy - canvas.height / 2;
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
@@ -153,13 +157,15 @@ function attachInteractions(node, ctx) {
const point = getCanvasPointer(canvas, event);
const hover = canvasToSource(point.x, point.y);
if (!hover.inside) return;
event.stopImmediatePropagation();
event.preventDefault();
const current = widgetNumber(node, "brush_size", 10);
const step = event.shiftKey ? 8 : 2;
const direction = event.deltaY > 0 ? -step : step;
ctx.setDrawBrushSize(node, current + direction);
void ctx.renderDrawNode(node, 0);
}, { passive: false });
};
document.addEventListener("wheel", handleDrawWheel, { capture: true, passive: false });
canvas.addEventListener("keydown", (event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "z") return;
event.preventDefault();
@@ -186,8 +192,9 @@ function attachInteractions(node, ctx) {
} catch {
}
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
ctrlPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
@@ -235,8 +242,9 @@ function attachInteractions(node, ctx) {
canvas.addEventListener("pointermove", (event) => {
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = ctrlPanDrag.startPanX + (cx - ctrlPanDrag.startCanvasX);
+3 -2
View File
@@ -40,8 +40,9 @@ function drawOutputFormatBox(ctx, fit, label = "Output") {
}
function getCanvasPointer(canvas, event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / Math.max(1, rect.width);
const scaleY = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return { x: 0, y: 0 };
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY
+25 -18
View File
@@ -15,31 +15,35 @@ function attachPreviewNavigation(node, canvasSize) {
if (!st.previewLastSource) return;
blit(node, st, st.previewLastSource, canvasSize);
};
const safeRect = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return null;
return { rect, sx: canvas.width / rect.width, sy: canvas.height / rect.height };
};
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 1) return;
event.preventDefault();
event.stopPropagation();
try {
canvas.setPointerCapture(event.pointerId);
} catch {
}
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
const r = safeRect();
if (!r) return;
st.previewPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startCanvasX: (event.clientX - r.rect.left) * r.sx,
startCanvasY: (event.clientY - r.rect.top) * r.sy,
startPanX: st.previewPanX,
startPanY: st.previewPanY
};
});
canvas.addEventListener("pointermove", (event) => {
if (!st.previewPanDrag || st.previewPanDrag.pointerId !== event.pointerId) return;
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
const r = safeRect();
if (!r) return;
const cx = (event.clientX - r.rect.left) * r.sx;
const cy = (event.clientY - r.rect.top) * r.sy;
st.previewPanX = st.previewPanDrag.startPanX + (cx - st.previewPanDrag.startCanvasX);
st.previewPanY = st.previewPanDrag.startPanY + (cy - st.previewPanDrag.startCanvasY);
reblitNow();
@@ -58,14 +62,16 @@ function attachPreviewNavigation(node, canvasSize) {
st.previewPanDrag = null;
}
});
canvas.addEventListener("wheel", (event) => {
if (isInteractiveNode(node)) return;
const handleWheel = (event) => {
const target = event.target;
if (target !== canvas && !canvas.contains(target)) return;
event.stopImmediatePropagation();
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
const mx = (event.clientX - rect.left) * sx - canvas.width / 2;
const my = (event.clientY - rect.top) * sy - canvas.height / 2;
if (isInteractiveNode(node)) return;
const r = safeRect();
if (!r) return;
const mx = (event.clientX - r.rect.left) * r.sx - canvas.width / 2;
const my = (event.clientY - r.rect.top) * r.sy - canvas.height / 2;
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
const prevZoom = st.previewZoom ?? 1;
const newZoom = clampPreviewZoom(prevZoom * factor);
@@ -74,7 +80,8 @@ function attachPreviewNavigation(node, canvasSize) {
st.previewPanY = my - (my - (st.previewPanY ?? 0)) * ratio;
st.previewZoom = newZoom;
reblitNow();
}, { passive: false });
};
document.addEventListener("wheel", handleWheel, { capture: true, passive: false });
canvas.addEventListener("dblclick", () => {
if (isInteractiveNode(node)) return;
if (st.previewZoom === 1 && st.previewPanX === 0 && st.previewPanY === 0) return;
+24 -14
View File
@@ -623,22 +623,32 @@ function ensurePreviewWidget(node, progress, canvasSize) {
if (drawControls) root.appendChild(drawControls);
if (compControls) root.appendChild(compControls);
root.appendChild(progressWrap);
root.style.pointerEvents = "auto";
const domMinHeight = getNodePreviewMinHeight(node);
node.addDOMWidget("preview", "ImageOpsPreview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => {
const mediaH = mediaWrap.style.display !== "none" ? mediaWrap.offsetHeight : 0;
const imageH = Math.max(canvas.offsetHeight, mediaH);
if (imageH === 0) return domMinHeight;
const metaH = metaRow.offsetHeight + 6;
const activeControls = previewControls ?? colorControls ?? drawControls ?? compControls;
const controlsH = activeControls ? activeControls.offsetHeight + 8 : 0;
const progressH = progressWrap.offsetHeight;
const naturalH = imageH + metaH + controlsH + progressH + 12;
return Math.max(domMinHeight, naturalH);
if (typeof node.addDOMWidget === "function") {
node.addDOMWidget("preview", "ImageOpsPreview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => {
const mediaH = mediaWrap.style.display !== "none" ? mediaWrap.offsetHeight : 0;
const imageH = Math.max(canvas.offsetHeight, mediaH);
if (imageH === 0) return domMinHeight;
const metaH = metaRow.offsetHeight + 6;
const activeControls = previewControls ?? colorControls ?? drawControls ?? compControls;
const controlsH = activeControls ? activeControls.offsetHeight + 8 : 0;
const progressH = progressWrap.offsetHeight;
const naturalH = imageH + metaH + controlsH + progressH + 12;
return Math.max(domMinHeight, naturalH);
}
});
} else {
const domEl = node.domElement ?? node.element;
if (domEl instanceof HTMLElement) {
domEl.appendChild(root);
} else {
console.warn("[ImageOps] addDOMWidget unavailable and no DOM container found on node", node.id);
}
});
}
try {
const widgets = node.widgets ?? [];
const idx = widgets.findIndex((w) => w?.name === "preview");
+11 -7
View File
@@ -524,6 +524,7 @@ export function registerImageOpsLivePreview(): void {
}
for (const w of (node.widgets ?? [])) {
if (typeof w.callback !== 'function' && typeof w.callback !== 'undefined') continue;
const orig = w.callback;
w.callback = function (this: any) {
const r = orig?.apply(this, arguments as any);
@@ -635,12 +636,15 @@ export function registerImageOpsLivePreview(): void {
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType: ComfyNodeConstructor, _nodeData: any) {
nodeType.prototype.onNodeCreated = (function (orig: any) {
return function (this: ComfyNode) {
orig?.apply(this, arguments);
hookNode(this);
};
})(nodeType.prototype.onNodeCreated);
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
origOnNodeCreated?.apply(this, arguments as any);
hookNode(this);
};
},
});
// Node 2.0 fallback: called per-instance after the node is fully constructed.
nodeCreated(node: ComfyNode) {
hookNode(node);
},
} as any);
}
+18 -8
View File
@@ -168,15 +168,21 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
st.drawPressureOpacityInput?.addEventListener("change", syncDrawDynamics);
st.drawTiltSizeInput?.addEventListener("change", syncDrawDynamics);
canvas.addEventListener("wheel", (event: WheelEvent) => {
// ── Wheel: zoom (Ctrl+scroll) and brush size (scroll) ──
// Use document capture phase to intercept before Node 2.0 graph container.
const handleDrawWheel = (event: WheelEvent): void => {
const target = event.target as Node | null;
if (target !== canvas && !canvas.contains(target)) return;
if (!st.drawGeometry) return;
// ── Ctrl+scroll: zoom the preview viewport ──────────────────────────────
if (event.ctrlKey || event.metaKey) {
event.stopImmediatePropagation();
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
// Cursor position relative to canvas centre — zoom toward the cursor.
const mx = (event.clientX - rect.left) * sx - canvas.width / 2;
const my = (event.clientY - rect.top) * sy - canvas.height / 2;
@@ -195,13 +201,15 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
const point = getCanvasPointer(canvas, event as unknown as PointerEvent);
const hover = canvasToSource(point.x, point.y);
if (!hover.inside) return;
event.stopImmediatePropagation();
event.preventDefault();
const current = widgetNumber(node, "brush_size", 10);
const step = event.shiftKey ? 8 : 2;
const direction = event.deltaY > 0 ? -step : step;
ctx.setDrawBrushSize(node, current + direction);
void ctx.renderDrawNode(node, 0);
}, { passive: false });
};
document.addEventListener("wheel", handleDrawWheel, { capture: true, passive: false });
canvas.addEventListener("keydown", (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "z") return;
@@ -230,8 +238,9 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
canvas.focus();
try { canvas.setPointerCapture?.(event.pointerId); } catch {}
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
ctrlPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
@@ -286,8 +295,9 @@ export function attachInteractions(node: ComfyNode, ctx: DrawInteractionContext)
// ── Ctrl+drag pan in progress ────────────────────────────────────────────
if (ctrlPanDrag && ctrlPanDrag.pointerId === event.pointerId) {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (rect.width < 1 || rect.height < 1) return;
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
st.previewPanX = ctrlPanDrag.startPanX + (cx - ctrlPanDrag.startCanvasX);
+4 -2
View File
@@ -75,8 +75,10 @@ export function drawOutputFormatBox(ctx: CanvasRenderingContext2D, fit: FitPlace
export function getCanvasPointer(canvas: HTMLCanvasElement, event: PointerEvent): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / Math.max(1, rect.width);
const scaleY = canvas.height / Math.max(1, rect.height);
// Guard against detached or zero-size canvas (Node 2.0 deferred DOM insertion).
if (rect.width < 1 || rect.height < 1) return { x: 0, y: 0 };
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
+31 -18
View File
@@ -21,18 +21,25 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
blit(node, st, st.previewLastSource, canvasSize);
};
/** Safe rect helper — returns null if canvas is detached / zero-size. */
const safeRect = (): { rect: DOMRect; sx: number; sy: number } | null => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return null;
return { rect, sx: canvas.width / rect.width, sy: canvas.height / rect.height };
};
// ── Middle-button pan ──
canvas.addEventListener("pointerdown", (event: PointerEvent) => {
if (event.button !== 1) return;
event.preventDefault();
event.stopPropagation();
try { canvas.setPointerCapture(event.pointerId); } catch {}
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
const r = safeRect();
if (!r) return;
st.previewPanDrag = {
pointerId: event.pointerId,
startCanvasX: (event.clientX - rect.left) * sx,
startCanvasY: (event.clientY - rect.top) * sy,
startCanvasX: (event.clientX - r.rect.left) * r.sx,
startCanvasY: (event.clientY - r.rect.top) * r.sy,
startPanX: st.previewPanX,
startPanY: st.previewPanY,
};
@@ -40,11 +47,10 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
canvas.addEventListener("pointermove", (event: PointerEvent) => {
if (!st.previewPanDrag || st.previewPanDrag.pointerId !== event.pointerId) return;
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
const cx = (event.clientX - rect.left) * sx;
const cy = (event.clientY - rect.top) * sy;
const r = safeRect();
if (!r) return;
const cx = (event.clientX - r.rect.left) * r.sx;
const cy = (event.clientY - r.rect.top) * r.sy;
st.previewPanX = st.previewPanDrag.startPanX + (cx - st.previewPanDrag.startCanvasX);
st.previewPanY = st.previewPanDrag.startPanY + (cy - st.previewPanDrag.startCanvasY);
reblitNow();
@@ -64,15 +70,21 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
});
// ── Wheel: zoom toward cursor (disabled for interactive nodes — they handle wheel themselves) ──
canvas.addEventListener("wheel", (event: WheelEvent) => {
if (isInteractiveNode(node)) return;
// In Node 2.0, graph-canvas-container intercepts wheel events in capture phase
// before they reach our canvas. We therefore register our own capture-phase
// handler on the document so we can stop the event first.
const handleWheel = (event: WheelEvent): void => {
const target = event.target as Node | null;
if (target !== canvas && !canvas.contains(target)) return;
// Always prevent graph zoom when cursor is on our preview canvas
event.stopImmediatePropagation();
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
if (isInteractiveNode(node)) return;
const r = safeRect();
if (!r) return;
// Mouse position relative to canvas center
const mx = (event.clientX - rect.left) * sx - canvas.width / 2;
const my = (event.clientY - rect.top) * sy - canvas.height / 2;
const mx = (event.clientX - r.rect.left) * r.sx - canvas.width / 2;
const my = (event.clientY - r.rect.top) * r.sy - canvas.height / 2;
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
const prevZoom = st.previewZoom ?? 1;
const newZoom = clampPreviewZoom(prevZoom * factor);
@@ -82,7 +94,8 @@ export function attachPreviewNavigation(node: ComfyNode, canvasSize: number): vo
st.previewPanY = my - (my - (st.previewPanY ?? 0)) * ratio;
st.previewZoom = newZoom;
reblitNow();
}, { passive: false });
};
document.addEventListener("wheel", handleWheel, { capture: true, passive: false });
// ── Double-click: reset zoom/pan ──
canvas.addEventListener("dblclick", () => {
+30 -17
View File
@@ -735,24 +735,37 @@ export function ensurePreviewWidget(node: ComfyNode, progress: ProgressBus, canv
if (compControls) root.appendChild(compControls);
root.appendChild(progressWrap);
// Ensure pointer events reach our canvas even if Node 2.0 applies pointer-events:none on parent containers.
root.style.pointerEvents = "auto";
const domMinHeight = getNodePreviewMinHeight(node);
node.addDOMWidget("preview", "ImageOpsPreview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => {
// Use canvas/mediaWrap rendered height (determined by node WIDTH, not height) to avoid
// the vertical feedback loop where a tall node makes the DOM tall which makes the node tall.
const mediaH = mediaWrap.style.display !== "none" ? mediaWrap.offsetHeight : 0;
const imageH = Math.max(canvas.offsetHeight, mediaH);
if (imageH === 0) return domMinHeight;
const metaH = metaRow.offsetHeight + 6; // 6px marginTop
const activeControls = previewControls ?? colorControls ?? drawControls ?? compControls;
const controlsH = activeControls ? activeControls.offsetHeight + 8 : 0;
const progressH = progressWrap.offsetHeight;
const naturalH = imageH + metaH + controlsH + progressH + 12; // 12px root padding
return Math.max(domMinHeight, naturalH);
},
});
if (typeof node.addDOMWidget === "function") {
node.addDOMWidget("preview", "ImageOpsPreview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => {
// Use canvas/mediaWrap rendered height (determined by node WIDTH, not height) to avoid
// the vertical feedback loop where a tall node makes the DOM tall which makes the node tall.
const mediaH = mediaWrap.style.display !== "none" ? mediaWrap.offsetHeight : 0;
const imageH = Math.max(canvas.offsetHeight, mediaH);
if (imageH === 0) return domMinHeight;
const metaH = metaRow.offsetHeight + 6; // 6px marginTop
const activeControls = previewControls ?? colorControls ?? drawControls ?? compControls;
const controlsH = activeControls ? activeControls.offsetHeight + 8 : 0;
const progressH = progressWrap.offsetHeight;
const naturalH = imageH + metaH + controlsH + progressH + 12; // 12px root padding
return Math.max(domMinHeight, naturalH);
},
});
} else {
// Node 2.0 fallback: inject the root element directly into the node's DOM container.
const domEl = (node as any).domElement ?? (node as any).element;
if (domEl instanceof HTMLElement) {
domEl.appendChild(root);
} else {
console.warn("[ImageOps] addDOMWidget unavailable and no DOM container found on node", node.id);
}
}
// Force the preview widget to the top (before sliders), like KayTool.
try {
+2 -1
View File
@@ -10,6 +10,7 @@ export interface ComfyApp {
export interface ComfyExtension {
name: string;
beforeRegisterNodeDef?(nodeType: ComfyNodeConstructor, nodeData: any): Promise<void> | void;
nodeCreated?(node: ComfyNode): void;
}
export interface ComfyNodeConstructor {
@@ -86,7 +87,7 @@ export interface ComfyNode {
onExecuted?(...args: any[]): any;
onNodeCreated?(...args: any[]): any;
setSize?(size: [number, number]): void;
addDOMWidget(name: string, type: string, el: HTMLElement, opts: any): void;
addDOMWidget?(name: string, type: string, el: HTMLElement, opts: any): void;
addInput?(name: string, type?: string, extra_info?: any): void;
removeInput?(slot: number): void;
__imageops_state?: NodeState;