Rework overlay/dialog detection, add shift-speed and custom selectors

js/arrow_key_canvas_navigation.js:
- Replace the hardcoded CANVAS_BLOCKING_UI_WINDOW_CLASSES selector
  string with isDialogOpen(), which detects blocking UI more robustly:
  ARIA dialogs/modals via document.activeElement, PrimeVue overlay
  masks, native <dialog> elements, and a generic fallback that flags
  any full-viewport, high-z-index fixed-position div. This removes the
  need to hardcode every third-party node's CSS class.
- Add a "Manual Overlay CSS Selectors (to Disable Panning)" setting so
  users can add selectors for overlays the automatic detection misses,
  without editing the source file or restarting ComfyUI.
- Add a "Pan Speed Multiplier (Shift + Arrow Key)" setting (default 3x)
  for faster panning while holding Shift.
- Switch from the deprecated app.ui.settings API to
  app.extensionManager.setting.
- Replace the event.composedPath()[0] focus check with
  document.activeElement, and also treat contentEditable elements and
  litegraph's inline "property_value" span as focused inputs.
- Extract isInputFocused()/hasImageCarousel() into named helper
  functions and reorder the keydown handler into early-exit "fast
  path" checks for readability.

README.md:
- Update docs to match the above: mention the Shift multiplier, and
  replace the outdated instructions to edit the
  CANVAS_BLOCKING_UI_WINDOW_CLASSES constant with instructions to use
  the new Manual Overlay CSS Selectors setting instead.

Also normalized 6 files (.github/workflows/publish.yml, .gitignore,
LICENSE, README.md, __init__.py, pyproject.toml) back to LF line
endings — they had been accidentally converted to CRLF with no actual
content changes, which was producing a noisy ~800-line diff.
This commit is contained in:
codecringebinge
2026-07-20 17:00:53 -07:00
parent f6f651839b
commit 7c4c8f324a
2 changed files with 130 additions and 33 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
# ComfyUI-Arrow-Key-Canvas-Navigation
This custom "node" for ComfyUI enables pan navigation of the canvas using the arrow keys, with a customizable pan speed in ComfyUI's Settings, under the "codecringebinge" subsection of the Settings Dialog's left panel.
This custom "node" for ComfyUI enables pan navigation of the canvas using the arrow keys, with a customizable pan speed (and a Shift-key speed multiplier) in ComfyUI's Settings, under the "codecringebinge" subsection of the Settings Dialog's left panel.
**Note:**
- There are specific cases in which the arrow keys intentionally will not pan the canvas, such as when a node with more than one image is selected (as the arrow keys should retain their standard behavior of cycling through said images), or when the canvas is blocked by another UI window (e.g. the Comfy Manager, Comfy Settings, the pythongosssss Image Gallery, modals, etc.).
- If you encounter scenarios when the arrow keys are panning the canvas when they shouldn't (e.g. when a custom node opens a UI window blocking the canvas), you can report this in the GitHub Issues section of this repo and I might be able to exclude the UI windows in question via an update, or alternatively for a likely faster fix, you could identify a CSS class name for the window in question (e.g. by using your browser's Developer Tools), add its name to the `CANVAS_BLOCKING_UI_WINDOW_CLASSES` string constant in `arrow_key_canvas_navigation.js`, and then restart ComfyUI and refresh the page.
- There are specific cases in which the arrow keys intentionally will not pan the canvas, such as when a node with more than one image is selected (as the arrow keys should retain their standard behavior of cycling through said images), or when the canvas is blocked by another UI window (e.g. the Comfy Manager, Comfy Settings, the pythongosssss Image Gallery, modals, etc.). Text inputs and text areas are also excluded so typing still works as expected.
- Blocking UI windows are detected automatically in most cases (ARIA dialogs, native `<dialog>` elements, PrimeVue overlay masks, and full-viewport high-z-index overlays). If you encounter a scenario where the arrow keys are panning the canvas when they shouldn't (e.g. a custom node opens a UI window that isn't being detected), you can report this in the GitHub Issues section of this repo, or for a faster fix, identify a CSS selector for the window in question (e.g. via your browser's Developer Tools) and add it to the "Manual Overlay CSS Selectors (to Disable Panning)" setting (comma-separated list) — no restart required.
# Installation
+127 -30
View File
@@ -1,51 +1,148 @@
import { app } from '../../../scripts/app.js';
const CANVAS_BLOCKING_UI_WINDOW_CLASSES = '.comfy-modal, .p-dialog, .pysssss-lightbox';
const BASE_ARROW_KEY_NAV_SETTINGS = 'codecringebinge.Arrow Key Canvas Navigation';
const PAN_SPEED_SETTING = `${BASE_ARROW_KEY_NAV_SETTINGS}.Pan Speed`;
const SHIFT_MULTIPLIER_SETTING = `${BASE_ARROW_KEY_NAV_SETTINGS}.Shift Multiplier`;
const MANUAL_OVERLAY_SELECTORS_SETTING = `${BASE_ARROW_KEY_NAV_SETTINGS}.Manual Overlay CSS Selectors (to Disable Panning)`;
const DEFAULT_PAN_SPEED = 60;
const DEFAULT_SHIFT_MULTIPLIER = 3;
app.registerExtension({
name: 'codecringebinge.arrow.key.canvas.navigation',
async setup() {
const panSpeedSettingName = 'codecringebinge.Arrow Key Canvas Navigation';
const defaultPanSpeed = 60;
app.ui.settings.addSetting({
id: panSpeedSettingName,
settings: [
{
id: MANUAL_OVERLAY_SELECTORS_SETTING,
name: 'Manual Overlay CSS Selectors (to Disable Panning)',
type: 'string',
defaultValue: '',
tooltip: `Disables panning when comma-separated CSS selectors find open overlays`
+ ` (e.g. .some-custom-node-lightbox, .another-node-backdrop)`
},
{
id: SHIFT_MULTIPLIER_SETTING,
name: 'Pan Speed Multiplier (Shift + Arrow Key)',
type: 'number',
defaultValue: DEFAULT_SHIFT_MULTIPLIER,
tooltip: 'Multiplies pan speed by the provided multiplier when holding the shift key'
},
{
id: PAN_SPEED_SETTING,
name: 'Pan Speed',
type: 'number',
defaultValue: defaultPanSpeed,
});
defaultValue: DEFAULT_PAN_SPEED,
tooltip: 'Sets pan speed by pixels per step'
}
],
async setup() {
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const keybindListener = function (event) {
const isModifierPressed = !!(event.altKey || event.metaKey);
const isInputSelected = ['INPUT', 'TEXTAREA'].includes(event.composedPath()[0].tagName);
const uiWindows = Array.from(document.querySelectorAll(CANVAS_BLOCKING_UI_WINDOW_CLASSES));
const isCanvasBlocked = !!uiWindows.filter((uiw) => window.getComputedStyle(uiw).getPropertyValue('display') !== 'none').length;
const isArrowKeyPressed = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key);
/** Check if a text-like element currently has focus. */
function isInputFocused() {
const el = document.activeElement;
if (!el) return false;
if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true;
if (el.contentEditable === 'true') return true;
if (el.tagName === 'SPAN' && el.classList.contains('property_value')) return true;
return false;
}
if (isModifierPressed || isInputSelected || isCanvasBlocked || !isArrowKeyPressed) {
return;
function isDialogOpen() {
const active = document.activeElement;
// If focus is inside a dialog (ARIA-compliant or native), disable panning
if (active?.closest('[role="dialog"], [aria-modal="true"], .p-dialog, dialog')) return true;
// PrimeVue renders an overlay mask backdrop when any modal dialog is open.
const mask = document.querySelector('.p-overlay-mask[style*="display: flex"]');
if (mask) return true;
// Native HTML <dialog> elements that haven't been closed
const nativeDialogs = document.querySelectorAll('dialog:not([hidden])');
if (nativeDialogs.length > 0) return true;
// Custom user-defined CSS selectors for third-party overlays the built-in checks miss.
// Comma-separated list, e.g. ".pysssss-lightbox, .model-linker-backdrop"
const customSelectors = app.extensionManager.setting.get(MANUAL_OVERLAY_SELECTORS_SETTING);
if (customSelectors) {
for (const sel of customSelectors.split(',')) {
const trimmed = sel.trim();
if (!trimmed) continue;
try {
const el = document.querySelector(trimmed);
if (el && window.getComputedStyle(el).display !== 'none') return true;
} catch {
// Invalid CSS selector - skip silently
}
}
}
// Fallback: full-viewport fixed overlay with high z-index.
// Checks visibility and actual rendered dimensions to avoid false positives.
const overlays = document.querySelectorAll('div[style*="position: fixed"]');
for (const el of overlays) {
const cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
// Must cover the full viewport in rendered size
const w = el.clientWidth;
const h = el.clientHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;
if (w < vw * 0.95 || h < vh * 0.95) continue;
// Must have a high z-index to be an overlay
const z = parseInt(cs.zIndex, 10);
if (z < 9000) continue;
return true;
}
return false;
}
/** Check if the currently selected node has an image carousel to navigate. */
function hasImageCarousel(node) {
if (!node) return false;
const hasMultipleImgs = !!(node.imgs && node.imgs.length > 1);
const imgWidgetCount = node.widgets?.filter(widget => widget.name === 'image').length || 0;
// Need at least 2 images/widgets for a carousel to make sense
if (hasMultipleImgs) return true;
if (imgWidgetCount > 1) return true;
return false;
}
const keybindListener = function (event) {
// Fast path: not an arrow key
if (!ARROW_KEYS.has(event.key)) return;
// Fast path: modifier keys block or trigger speed boost
const isShiftPressed = event.shiftKey;
if (event.altKey || event.metaKey) return;
// Fast path: input has focus
if (isInputFocused()) return;
// Fast path: dialog/modal is open
if (isDialogOpen()) return;
// --- Canvas pan logic ---
const offset = app.canvas.ds.offset;
const newOffsetDelta = app.ui.settings.getSettingValue(panSpeedSettingName, defaultPanSpeed);
const baseDelta = app.extensionManager.setting.get(PAN_SPEED_SETTING);
const delta = isShiftPressed
? baseDelta * app.extensionManager.setting.get(SHIFT_MULTIPLIER_SETTING)
: baseDelta;
if (event.key === 'ArrowUp') {
offset[1] += newOffsetDelta;
offset[1] += delta;
} else if (event.key === 'ArrowDown') {
offset[1] -= newOffsetDelta;
offset[1] -= delta;
} else {
// Left/Right: check for image carousel on selected node
const { current_node } = app.canvas;
const isSelected = current_node && current_node.is_selected;
const hasImgs = current_node && (current_node.imgs || []).length > 1;
const hasImgWidgets = current_node && (current_node.widgets || []).filter((widget) => widget.name === 'image').length > 1;
const hasImageCarousel = current_node && (hasImgs || hasImgWidgets);
const isSelected = current_node?.is_selected;
if (!hasImageCarousel || !isSelected) {
if (event.key === 'ArrowLeft') {
offset[0] += newOffsetDelta;
} else if (event.key === 'ArrowRight') {
offset[0] -= newOffsetDelta;
}
if (!hasImageCarousel(current_node) || !isSelected) {
if (event.key === 'ArrowLeft') offset[0] += delta;
else offset[0] -= delta;
}
}
};