commit 5ce0309c8121431171d65b207c3570b54cf2c867 Author: mikesimone Date: Thu Nov 6 23:34:25 2025 -0800 feat: initial release of LinkModeToggle (hotkeys + toolbar button) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d86b897 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +__pycache__/ +*.pyc +.DS_Store +Thumbs.db diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a4f9c49 --- /dev/null +++ b/__init__.py @@ -0,0 +1,9 @@ +# LinkModeToggle – minimal server side stub so ComfyUI serves /web +# Place in: D:\AI\ComfyUI\custom_nodes\LinkModeToggle\__init__.py + +# ComfyUI loads this file and, seeing WEB_DIRECTORY, mounts /web automatically. +WEB_DIRECTORY = "./web" + +# No Python nodes here; purely a web extension. +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/web/extensions/linkmode-toggle.js b/web/extensions/linkmode-toggle.js new file mode 100644 index 0000000..d32e896 --- /dev/null +++ b/web/extensions/linkmode-toggle.js @@ -0,0 +1,195 @@ +// LinkModeToggle — front-end extension for ComfyUI +// Adds: F8/Ctrl+K hotkeys + a small toolbar button that cycles SPLINE/LINEAR/STRAIGHT. + +(() => { + const ORDER = ["SPLINE","LINEAR","STRAIGHT"]; + const KEY = "LinkModeToggle.mode"; + + // Robustly get the LiteGraph canvas object and the DOM elements + const getCanvasObj = () => window.app?.canvas || window.app?.graphcanvas || null; + + // Cycle & persistence + const setMode = (name) => { + const c = getCanvasObj(); + if (!c) return false; + const modes = { STRAIGHT:0, SPLINE:1, LINEAR:2 }; + + const ok = + (typeof c.setLinkRenderMode === "function" && (c.setLinkRenderMode(modes[name]), true)) || + ("links_render_mode" in c && (() => { + const K = c.constructor; + const KM = { + STRAIGHT: K?.LINK_RENDER_MODE_STRAIGHT ?? K?.STRAIGHT_LINKS ?? 0, + SPLINE: K?.LINK_RENDER_MODE_SPLINE ?? K?.SPLINE_LINKS ?? 1, + LINEAR: K?.LINK_RENDER_MODE_LINEAR ?? K?.LINEAR_LINKS ?? 2, + }; + c.links_render_mode = KM[name] ?? modes[name]; + c.dirty_canvas = true; c.draw(true, true); + return true; + })()) || + ("render_curved_links" in c && (() => { + c.render_curved_links = (name === "SPLINE"); + c.dirty_canvas = true; c.draw(true, true); + return true; + })()) || + ("render_link_curved" in c && (() => { + c.render_link_curved = (name === "SPLINE"); + c.redraw?.(true,true); c.draw?.(true,true); + return true; + })()); + + if (ok) localStorage.setItem(KEY, name); + updateButtonUI(name, ok); + return ok; + }; + + const cycle = () => { + const cur = localStorage.getItem(KEY) || "SPLINE"; + const idx = Math.max(0, ORDER.indexOf(cur)); + const next = ORDER[(idx + 1) % ORDER.length]; + setMode(next); + }; + + // Button + let btn, badge; + const updateButtonUI = (mode, ok) => { + if (!btn) return; + btn.dataset.mode = mode; + btn.title = `Link Mode: ${mode} (F8 / Ctrl+K to toggle)`; + if (badge) badge.textContent = mode[0]; // S/L/S + btn.classList.toggle("lmt-failed", !ok); + }; + + // --- build a Prime/Comfy-styled button like the others in the toolbar --- + const buildButton = () => { + if (btn) return btn; + + btn = document.createElement("button"); + // match existing toolbar buttons + btn.className = [ + "p-button", "p-component", "p-button-icon-only", "p-button-secondary", + "h-8", "w-8", "bg-interface-panel-surface", "p-0", "hover:bg-button-hover-surface!" + ].join(" "); + btn.type = "button"; + btn.setAttribute("aria-label", "Cycle Link Mode (F8 / Ctrl+K)"); + btn.setAttribute("data-pc-name", "button"); + btn.setAttribute("data-p-disabled", "false"); + btn.setAttribute("data-p-severity", "secondary"); + btn.style.borderRadius = "8px"; + btn.style.border = "none"; + + // icon (uses lucide class system like other buttons) + const icon = document.createElement("i"); + // looks like a curve; feel free to change to: lucide--bezier-curve / pen-tool / route + icon.className = "icon-[lucide--bezier-curve] h-4 w-4"; + btn.appendChild(icon); + + // tiny badge (S/L/…) in the corner so you know current mode at a glance + badge = document.createElement("span"); + badge.className = "lmt-badge"; + badge.textContent = (localStorage.getItem(KEY) || "SPLINE")[0]; + btn.appendChild(badge); + + btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); cycle(); }); + return btn; + }; + + + // --- find the bottom-right group and dock the button there --- + const attachButton = () => { + const b = buildButton(); + + // Find the toolbar group you pasted (bottom-right palette) + // We anchor off a known child button to be resilient to class churn. + const group = + document.querySelector('[data-testid="toggle-link-visibility-button"]') + ?.closest('span.p-buttongroup[role="group"]') + || document.querySelector('span.p-buttongroup[role="group"].right-0.bottom-0'); + + if (group) { + // Optional: put a divider before our button for symmetry + const divider = document.createElement("div"); + divider.className = "h-[27px] w-[1px] self-center bg-node-divider"; + + // If we don’t already exist, append divider + button to the group + if (!group.contains(b)) { + group.appendChild(divider); + group.appendChild(b); + } + b.classList.remove("lmt-floating", "lmt-bottom-right", "lmt-in-toolbar"); + // We’re in the toolbar now + return true; + } + + // Fallback: float at bottom-right (won’t block nodes; z-index is high) + if (!document.body.contains(b)) document.body.appendChild(b); + b.classList.add("lmt-floating", "lmt-bottom-right"); + return false; + }; + + + // Keep hotkeys the same; just add a MutationObserver so we reattach if UI changes + const observeUI = () => { + const obs = new MutationObserver(() => { + // if button got removed (UI re-render), re-attach + if (!document.querySelector(".lmt-btn")) attachButton(); + }); + obs.observe(document.body, { childList: true, subtree: true }); + return obs; + }; + + + + + // Hotkeys + const keyHandler = (e) => { + const ctrlK = e.code === "KeyK" && e.ctrlKey && !e.altKey && !e.shiftKey; + const f8 = e.code === "F8" && !e.ctrlKey && !e.altKey && !e.shiftKey; + if (!(ctrlK || f8)) return; + + const t = e.target; + const tag = (t && t.tagName) || ""; + if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return; + + e.preventDefault(); + e.stopPropagation(); + cycle(); + }; + + const bindHotkeys = () => { + const cobj = getCanvasObj(); + const targets = [window, document, document.body, cobj?.canvas, cobj?.bgcanvas] + .filter(t => t && typeof t.addEventListener === "function"); + targets.forEach(t => t.addEventListener("keydown", keyHandler, { capture: true })); + console.log("[LinkModeToggle] Hotkeys ready (F8 / Ctrl+K). Bound on:", targets.length, "targets."); + }; + + // Boot sequence with retries until app + canvas are ready +const boot = () => { + const tryInit = (attempt = 0) => { + const ready = window.app && getCanvasObj(); + if (!ready) { + if (attempt < 60) return void setTimeout(() => tryInit(attempt + 1), 250); + console.warn("[LinkModeToggle] Timed out waiting for ComfyUI app/canvas."); + return; + } + setMode(localStorage.getItem(KEY) || "SPLINE"); + attachButton(); + bindHotkeys(); + observeUI(); + }; + tryInit(); +}; + + // Use Comfy’s extension API if present + if (window.app?.registerExtension) { + app.registerExtension({ + name: "LinkModeToggle", + setup() { boot(); }, + }); + } else { + // Older frontends: best-effort boot + document.addEventListener("DOMContentLoaded", boot); + boot(); + } +})(); diff --git a/web/index.css b/web/index.css new file mode 100644 index 0000000..06d1a6c --- /dev/null +++ b/web/index.css @@ -0,0 +1,79 @@ +/* Small, theme-friendly button */ +.lmt-btn { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .25rem .5rem; + border: none; + border-radius: 6px; + cursor: pointer; + font: inherit; + line-height: 1; + opacity: .9; +} + + .lmt-btn:hover { + opacity: 1; + } + +/* tiny icon + mode badge */ +.lmt-icon { + font-weight: 700; +} + +/* subtle corner badge on the toolbar icon */ +.lmt-badge { + position: absolute; + right: -2px; + bottom: -2px; + font-size: 9px; + line-height: 1; + padding: 1px 3px; + border-radius: 6px; + background: var(--node-border, #444); + color: var(--descrip-text, #ccc); + pointer-events: none; +} + +/* fallback floating style (only used if toolbar not found) */ +.lmt-floating.lmt-bottom-right { + position: fixed; + right: 10px; + bottom: 10px; + z-index: 99999; +} +/* when we can’t find a toolbar, float it bottom-right */ +.lmt-floating { + position: fixed; + right: 12px; + bottom: 12px; + z-index: 99999; + pointer-events: auto; +} + +/* if we docked into a toolbar, keep it tidy */ +.lmt-in-toolbar { + margin-left: .5rem; +} + +/* visual hint if API failed (should be rare) */ +.lmt-failed { + outline: 2px solid #c33; +} + + +/* subtle corner badge on the toolbar icon */ +.lmt-badge { + position: absolute; + right: -2px; + bottom: -2px; + font-size: 9px; + line-height: 1; + padding: 1px 3px; + border-radius: 6px; + background: var(--node-border, #444); + color: var(--descrip-text, #ccc); + pointer-events: none; +} + + diff --git a/web/index.js b/web/index.js new file mode 100644 index 0000000..7224b13 --- /dev/null +++ b/web/index.js @@ -0,0 +1,5 @@ +// Entry point for LinkModeToggle (ComfyUI custom node) +// Loads CSS and the actual extension code. + +import "./index.css"; +import "./extensions/linkmode-toggle.js"; // your extension lives here diff --git a/web/linkmode-toggle.css b/web/linkmode-toggle.css new file mode 100644 index 0000000..5fac0f7 --- /dev/null +++ b/web/linkmode-toggle.css @@ -0,0 +1,53 @@ +/* Small, unobtrusive button that matches ComfyUI style */ +.lmt-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 2px 8px; + border-radius: 8px; + font-size: 12px; + line-height: 16px; + cursor: pointer; + opacity: 0.85; +} + + .lmt-btn:hover { + opacity: 1; + } + +.lmt-icon { + font-weight: 700; + font-size: 14px; + line-height: 14px; +} + +.lmt-badge { + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +/* When we can’t find the toolbar, dock to bottom-left without covering the canvas */ +.lmt-floating { + position: fixed; + right: 12px; /* bottom-right so it never hides nodes */ + bottom: 12px; + z-index: 99999; /* sit above overlays */ + pointer-events: auto; +} + +.lmt-bottom-right { + right: 12px; + bottom: 12px; +} + + +/* When placed in toolbar, make it visually consistent */ +.lmt-in-toolbar { + margin-left: 6px; +} + +/* Fail state (should basically never show) */ +.lmt-failed { + outline: 1px solid #e66; + box-shadow: 0 0 0 2px rgba(230,102,102,.2) inset; +}