Compare commits

..

4 Commits

Author SHA1 Message Date
Will Miao
0ac10dfd42 fix(ui): prevent Launch LoRA Manager button from disappearing when opening properties panel in subgraph (#996) 2026-06-25 20:47:29 +08:00
Will Miao
9c95856b2f fix(trigger-wheel): prevent Vue render mode from intercepting strength wheel events
In Vue render mode, ComfyUI's TransformPane uses a capture-phase wheel
handler (@wheel.capture) that fires before the tag element's bubble-phase
strength-adjustment listener. It checks wheelCapturedByFocusedElement(),
which requires data-capture-wheel on a focused element. The tag divs had
data-capture-wheel but were not focusable, so the check failed, causing
the capture handler to forward the event to the canvas (triggering zoom)
and stopPropagation() which prevented the strength handler from running.

Fix: move data-capture-wheel from individual tags to the container, make
it focusable (tabIndex=-1), and add a window-level capture-phase wheel
listener that focuses the container before TransformPane checks it.
2026-06-25 14:58:20 +08:00
Will Miao
5ce4667d32 feat(node-marker): add 🎯 emoji prefix to Mark as context menu item 2026-06-24 22:36:45 +08:00
willmiao
be53fda6df docs: auto-update supporters list in README 2026-06-24 14:11:36 +00:00
4 changed files with 103 additions and 23 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
// =============================================================================
// Node Marker right-click node marking (no dedicated node required)
//
// Adds a "Mark as →" submenu with role options to any node's context menu.
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON.
//
@@ -107,7 +107,7 @@ function buildMenuItems(node) {
return [
null,
{
content: "Mark as",
content: "\uD83C\uDFAF Mark as",
has_submenu: true,
submenu: {
options: buildSubmenuOptions(node),

View File

@@ -260,7 +260,6 @@ function createTagElement({
}) {
const tagEl = document.createElement("div");
tagEl.className = "comfy-tag";
tagEl.dataset.captureWheel = "true";
const baseStyles = {
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
// Singleton window capture-phase wheel hook: focuses the tags container when a
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
/** @type {boolean} */
let tagWheelCaptureHookInstalled = false;
function installTagWheelCaptureHook() {
if (tagWheelCaptureHookInstalled) return;
tagWheelCaptureHookInstalled = true;
window.addEventListener(
"wheel",
(event) => {
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
if (event.ctrlKey || event.metaKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = /** @type {Element} */ (event.target);
if (!target?.closest) return;
const targetContainer = target.closest(
'.comfy-tags-container[data-capture-wheel="true"]'
);
if (!targetContainer) return;
targetContainer.focus({ preventScroll: true });
},
{ capture: true, passive: true }
);
}
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
const container = document.createElement("div");
container.className = "comfy-tags-container";
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container);
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
// For that check to return true (preventing canvas zoom and allowing our
// strength-adjustment wheel handler to fire), the container needs both
// data-capture-wheel AND document.activeElement inside it.
// We make the container focusable and auto-focus it on wheel events via a
// window capture-phase hook.
container.dataset.captureWheel = "true";
container.tabIndex = -1;
// Blur on mouseleave to avoid lingering focus side effects.
container.addEventListener("mouseleave", () => {
if (document.activeElement === container) {
container.blur();
}
});
// Singleton window capture-phase wheel handler: focuses our container when
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing our strength handler).
installTagWheelCaptureHook();
Object.assign(container.style, {
display: "flex",
flexWrap: "wrap",
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
overflow: "auto",
alignItems: "flex-start",
alignContent: "flex-start",
outline: "none",
});
const initialTagsData = opts?.defaultVal || [];

View File

@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
const applyIconToButton = (button) => {
// Skip if the SVG icon is already in place
if (button.querySelector('svg')) return;
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
};
requestAnimationFrame(replaceButtonIcon);
// Initial application — retry until the button is rendered by Vue
const pollUntilFound = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
if (buttons.length > 0) {
buttons.forEach(applyIconToButton);
} else {
requestAnimationFrame(pollUntilFound);
}
};
requestAnimationFrame(pollUntilFound);
// MutationObserver: keep the SVG icon in place after Vue re-renders
// (e.g. when the properties panel is toggled inside a subgraph)
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
// Only re-apply when Vue has reset innerHTML back to <i>
if (button.querySelector('i')) {
applyIconToButton(button);
}
});
});
// Watch the action bar and a broad ancestor so we cover re-mounts
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|| document.querySelector('.actionbar-container')
|| document.body;
observer.observe(watchNode, { childList: true, subtree: true });
// Store reference for potential cleanup
window.__lmIconObserver = observer;
}
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--alpha-l-box] size-4",
icon: "icon-[lucide--layers] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openLoraManager
}