Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

251 lines
9.8 KiB
JavaScript

// LiteGraph event-capture rules learned the hard way:
// 1. Pointer events isolated in bubble phase, not capture — capture-phase
// blocking breaks LiteGraph's own scrubbing inside the canvas
// 2. Never block pointermove/mousemove — kills text selection in the editor
// 3. onNodeSelected is hooked to immediately undo selections that originated
// inside our widget (LiteGraph selects on click regardless of stopPropagation)
// 4. Selection clearing is triple-fired (sync + microtask + RAF) — LiteGraph
// re-applies link highlighting in a deferred RAF that we'd otherwise miss
// 5. Wheel uses window capture phase to beat TransformPane's @wheel.capture
// in the 2.0 frontend
import { app } from "../../../scripts/app.js";
import { CONFIG, NODE_TYPE } from "./config.js";
import { closeActivePopup } from "./popup-menu.js";
// -- Single global wheel handler for all PromptChain editors -----------------
// Registered once, checks if target is inside any tracked container.
const wheelContainers = new Set();
let wheelListenerInstalled = false;
function installGlobalWheelHandler() {
if (wheelListenerInstalled) return;
wheelListenerInstalled = true;
window.addEventListener("wheel", (event) => {
// Let autocomplete dropdown handle its own scrolling natively
const autocomplete = event.target.closest(".cm-tooltip-autocomplete");
if (autocomplete) {
event.stopPropagation();
event.stopImmediatePropagation();
return;
}
// fullscreen overlay handles its own wheel routing
if (event.target.closest(".pcr-fs-overlay")) return;
// A docked 3D Poser viewport owns its wheel (camera dolly) via its own
// window-capture handler. Once docked, its container is a descendant of this
// node's editor container, so the loop below would match and the editor would
// swallow the scroll. Defer WITHOUT stopping so the Poser's dolly still fires.
if (event.target.closest(".pcr-pose-studio")) return;
let container = null;
for (const c of wheelContainers) {
if (c.contains(event.target)) { container = c; break; }
}
if (!container) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
// image panel: dispatch zoom event instead of scroll/font-size
const imagePanel = event.target.closest(".pcr-image-panel");
if (imagePanel) {
const rect = imagePanel.getBoundingClientRect();
imagePanel.dispatchEvent(new CustomEvent("pcr-zoom", {
detail: {
deltaY: event.deltaY,
mouseX: event.clientX - rect.left,
mouseY: event.clientY - rect.top,
containerWidth: rect.width,
containerHeight: rect.height,
},
}));
return;
}
if (event.ctrlKey) {
const delta = event.deltaY > 0 ? -1 : 1;
// Ctrl+scroll over generated gallery: zoom thumbnails
const generatedPanel = event.target.closest(".pcr-output-panel-generated");
if (generatedPanel) {
const outputPanel = event.target.closest(".pcr-output-panel");
outputPanel?._updateGalleryZoom?.(delta);
return;
}
// Ctrl+scroll over output panel content or console log: resize output font
if (event.target.closest(".pcr-output-panel-content, .pcr-console-log")) {
container._pcrUpdateOutputFontSize(container._pcrOutputFontSize + delta);
return;
}
container._pcrUpdateFontSize(container._pcrFontSize + delta);
return;
}
// scroll isolation — prevent LiteGraph canvas zoom, respect scroll boundaries
const scroller = event.target.closest(".cm-scroller, .pcr-scrollable, .pcr-ai-panel-body") || container.querySelector(".cm-scroller");
if (scroller) {
const atTop = scroller.scrollTop <= 0;
const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1;
if (!(atTop && event.deltaY < 0) && !(atBottom && event.deltaY > 0)) {
scroller.scrollTop += event.deltaY;
scroller.scrollLeft += event.deltaX;
}
}
}, { capture: true, passive: false });
}
// -- Element-level isolation --------------------------------------------------
// Blocks events from bubbling to LiteGraph. Sets up font size controls.
export function isolateEvents(container, node) {
const stopEvent = (event) => event.stopPropagation();
// Svelte 5 delegates click/dblclick/pointerdown/pointerup to the document —
// stopping them here kills all onclick handlers inside the node widget.
// Pointer isolation is handled per-component (OutputPanel, ImagePanel, etc.)
// and installGlobalIsolation handles node selection suppression.
for (const eventType of [
"keydown", "keyup", "keypress",
"mousedown", "mouseup",
"copy", "paste", "cut",
]) {
container.addEventListener(eventType, stopEvent);
}
// close popups on interaction inside the isolated container,
// since stopPropagation prevents document-level dismiss handlers from firing
container.addEventListener("pointerdown", () => closeActivePopup());
// font size state (persisted to node.properties.pcrFontSize)
container._pcrFontSize = node?.properties?.pcrFontSize || CONFIG.defaultFontSize;
container._pcrUpdateFontSize = (size) => {
size = Math.max(CONFIG.minFontSize, Math.min(CONFIG.maxFontSize, size));
container._pcrFontSize = size;
container.style.setProperty("--pcr-font-size", `${size}px`);
if (node) {
if (!node.properties) node.properties = {};
node.properties.pcrFontSize = size;
}
};
container.style.setProperty("--pcr-font-size", `${container._pcrFontSize}px`);
// output panel font size — independent from editor
container._pcrOutputFontSize = node?.properties?.pcrOutputFontSize || CONFIG.defaultFontSize;
container._pcrUpdateOutputFontSize = (size) => {
size = Math.max(CONFIG.minFontSize, Math.min(CONFIG.maxFontSize, size));
container._pcrOutputFontSize = size;
container.style.setProperty("--pcr-output-font-size", `${size}px`);
if (node) {
if (!node.properties) node.properties = {};
node.properties.pcrOutputFontSize = size;
}
};
container.style.setProperty("--pcr-output-font-size", `${container._pcrOutputFontSize}px`);
// register for global wheel handler
wheelContainers.add(container);
installGlobalWheelHandler();
// release pointer capture that LiteGraph sets on its canvas
container.addEventListener("pointerdown", (event) => {
const canvas = document.querySelector("canvas.lgraphcanvas");
if (canvas && typeof event.pointerId === "number") {
try {
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
} catch {} // capture may already be released
}
});
// allow middle-click canvas panning through our widget
container.addEventListener("pointerdown", (event) => {
if (event.button === 1) app.canvas?.processMouseDown(event);
});
container.addEventListener("pointermove", (event) => {
if ((event.buttons & 4) === 4) app.canvas?.processMouseMove(event);
});
// forward pointerup to the canvas when a link drag is in progress —
// the DOM widget sits on top of the canvas, so pointerup never reaches
// LiteGraph's processMouseUp, which means dropLinks never fires and
// connections silently fail when dropped over the widget area.
container.addEventListener("pointerup", (event) => {
if (app.canvas?.linkConnector?.isConnecting) {
app.canvas.processMouseUp(event);
}
});
}
// Remove a container from wheel tracking (call on node removal).
export function removeWheelContainer(container) {
wheelContainers.delete(container);
}
// -- Global node selection prevention (1.0 only) -----------------------------
// In 1.0, LiteGraph selects nodes on click regardless of stopPropagation.
// We hook onNodeSelected and immediately undo the selection if the click
// originated inside our editor. Uses triple-clear to beat LiteGraph's
// deferred link highlighting.
let clickedNodeId = null;
let globalIsolationInstalled = false;
// Clears selection at three timing points to catch all LiteGraph highlighting.
function clearSelectionAndHighlights() {
const clear = () => {
app.canvas?.deselectAll();
if (app.canvas) app.canvas.highlighted_links = {};
app.canvas?.setDirty(true, true);
};
clear();
Promise.resolve().then(clear);
requestAnimationFrame(clear);
}
export function installGlobalIsolation() {
if (globalIsolationInstalled) return;
if (!app.canvas) return;
globalIsolationInstalled = true;
// track clicks inside our UI (capture phase fires before LiteGraph)
window.addEventListener("pointerdown", (event) => {
const inEditor = event.target.closest(".pcr-editor, .cm-editor, .cm-content, .cm-line, .cm-scroller");
if (inEditor) {
const nodeElement = event.target.closest("[data-node-id]");
clickedNodeId = nodeElement ? parseInt(nodeElement.dataset.nodeId, 10) : findOwnerNodeId(event.target);
} else {
clickedNodeId = null;
}
}, true);
document.addEventListener("pointerup", () => {
requestAnimationFrame(() => { clickedNodeId = null; });
}, true);
// undo selection when the click was inside our editor
const originalOnNodeSelected = app.canvas.onNodeSelected;
app.canvas.onNodeSelected = function (node) {
if (node?.comfyClass === NODE_TYPE && clickedNodeId === node.id) {
clickedNodeId = null;
clearSelectionAndHighlights();
return;
}
clickedNodeId = null;
return originalOnNodeSelected?.call(this, node);
};
}
// Walks all graph nodes to find which PromptChain node owns a DOM element.
function findOwnerNodeId(element) {
if (!app.graph?._nodes) return null;
for (const node of app.graph._nodes) {
if (node.comfyClass !== NODE_TYPE) continue;
const widget = node.widgets?.find((w) => w.name === "pcr_editor");
if (widget?.element?.contains(element)) return node.id;
}
return null;
}