feat: initial release of LinkModeToggle (hotkeys + toolbar button)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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 = {}
|
||||
@@ -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 <canvas> 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();
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user