251 lines
9.8 KiB
JavaScript
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;
|
|
}
|