Skip to content

Commit 228bb40

Browse files
committed
feat(js): canvas editors auto-fit + clamp pan/zoom; add MEC clipboard
Canvas editors (points_bbox_editor.js, spline_mask_editor.js): - Remove mouse-wheel zoom (bad UX inside ComfyUI outer canvas). - Add toolbar zoom-in / zoom-out / fit buttons. - Keyboard: +/-/0 for zoom in/out/fit; F still fits. - minZoom() prevents zooming out beyond image bounds. - clampPan() keeps the image inside the viewport at all times. - setZoomAround() honours both clamps. MEC clipboard (js/mec_clipboard.js, NEW): - Nuke-style portable copy/paste with full metadata. - Ctrl+Alt+C / Ctrl+Alt+V via OS clipboard, JSON payload tagged with header so settings survive across workflows / installs. - Captures every widget value by NAME plus title, color, bgcolor, flags, properties, size, and intra-selection links. - Right-click canvas + node menus expose the same actions. - Self-contained, no server endpoint required.
1 parent 181a0af commit 228bb40

3 files changed

Lines changed: 322 additions & 38 deletions

File tree

js/mec_clipboard.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// MEC clipboard — Nuke-style portable copy/paste for ComfyUI nodes.
2+
//
3+
// Ports the spirit of NukeMax's NkScript clipboard, but is fully
4+
// self-contained: no server endpoint, no TCL grammar. The payload is
5+
// plain JSON wrapped in a recognisable header so it round-trips through
6+
// the OS clipboard between workflows / instances / machines.
7+
//
8+
// Hotkeys (chosen to avoid clashing with NukeMax's Ctrl+Shift+C/V and
9+
// LiteGraph's own Ctrl+C/V):
10+
// Ctrl+Alt+C → copy selected nodes (all widget values, links,
11+
// relative positions, sizes, colours) to the clipboard.
12+
// Ctrl+Alt+V → paste the clipboard payload at the cursor.
13+
//
14+
// The payload includes EVERY widget value by name, so even if the user
15+
// pastes into a different workflow / different ComfyUI install (as long
16+
// as the same node packs are installed), the settings come across.
17+
18+
import { app } from "../../scripts/app.js";
19+
20+
const CLIP_HEADER = "# MEC.clipboard 1.0";
21+
22+
function _toast(msg, severity = "info") {
23+
try {
24+
app.extensionManager?.toast?.add({
25+
severity,
26+
summary: "MEC Clipboard",
27+
detail: msg,
28+
life: 3500,
29+
});
30+
} catch (_) { console.log("[MEC.clipboard]", msg); }
31+
}
32+
33+
function _selectedNodes() {
34+
const sel = app.canvas?.selected_nodes;
35+
if (!sel) return [];
36+
return Object.values(sel);
37+
}
38+
39+
function _serializeNode(n) {
40+
// Capture every widget value by NAME (so widget-order changes don't
41+
// break the paste).
42+
const widgets = {};
43+
for (const w of n.widgets || []) {
44+
if (!w.name || w.value === undefined) continue;
45+
// Skip DOM-widget values that aren't JSON-serialisable (canvas
46+
// payloads, typed arrays). serializeValue() if available.
47+
let v = w.value;
48+
try {
49+
if (typeof w.serializeValue === "function") {
50+
v = w.serializeValue(n, n.widgets.indexOf(w));
51+
}
52+
JSON.stringify(v);
53+
} catch (_) { continue; }
54+
widgets[w.name] = v;
55+
}
56+
return {
57+
id: n.id,
58+
type: n.comfyClass || n.type,
59+
title: n.title,
60+
pos: [Math.round(n.pos?.[0] ?? 0), Math.round(n.pos?.[1] ?? 0)],
61+
size: n.size ? [Math.round(n.size[0]), Math.round(n.size[1])] : undefined,
62+
color: n.color,
63+
bgcolor: n.bgcolor,
64+
flags: n.flags,
65+
properties: n.properties,
66+
widgets,
67+
};
68+
}
69+
70+
function _gatherSubgraph(nodes) {
71+
const ids = new Set(nodes.map((n) => n.id));
72+
const out_nodes = nodes.map(_serializeNode);
73+
const out_links = [];
74+
const links = app.graph?.links || {};
75+
for (const k in links) {
76+
const l = links[k];
77+
if (!l) continue;
78+
if (ids.has(l.origin_id) && ids.has(l.target_id)) {
79+
out_links.push({
80+
src_id: l.origin_id, src_slot: l.origin_slot,
81+
dst_id: l.target_id, dst_slot: l.target_slot,
82+
type: l.type,
83+
});
84+
}
85+
}
86+
return { version: 1, nodes: out_nodes, links: out_links };
87+
}
88+
89+
async function _copy() {
90+
const nodes = _selectedNodes();
91+
if (!nodes.length) { _toast("Nothing selected", "warn"); return; }
92+
const payload = _gatherSubgraph(nodes);
93+
const text = CLIP_HEADER + "\n" + JSON.stringify(payload, null, 2);
94+
try {
95+
await navigator.clipboard.writeText(text);
96+
_toast(`Copied ${nodes.length} node(s) with full metadata`, "success");
97+
} catch (e) {
98+
_toast("Clipboard write denied: " + (e?.message || e), "error");
99+
}
100+
}
101+
102+
function _findHeader(text) {
103+
if (!text) return null;
104+
const i = text.indexOf(CLIP_HEADER);
105+
if (i < 0) return null;
106+
const j = text.indexOf("{", i);
107+
if (j < 0) return null;
108+
try { return JSON.parse(text.slice(j)); } catch (_) { return null; }
109+
}
110+
111+
async function _paste() {
112+
let text = "";
113+
try { text = await navigator.clipboard.readText(); }
114+
catch (e) { _toast("Clipboard read denied (browser permission)", "error"); return; }
115+
const data = _findHeader(text);
116+
if (!data || !Array.isArray(data.nodes)) {
117+
_toast("Clipboard does not contain MEC payload", "warn"); return;
118+
}
119+
const cursor = app.canvas?.graph_mouse || [0, 0];
120+
let originX = null, originY = null;
121+
const idMap = {}; // old_id -> new node
122+
for (const nd of data.nodes) {
123+
const node = LiteGraph.createNode(nd.type);
124+
if (!node) {
125+
console.warn("[MEC.clipboard] unknown node type:", nd.type);
126+
_toast(`Unknown node type "${nd.type}" — install the pack`, "warn");
127+
continue;
128+
}
129+
if (originX === null) { originX = nd.pos[0]; originY = nd.pos[1]; }
130+
node.pos = [
131+
cursor[0] + (nd.pos[0] - originX),
132+
cursor[1] + (nd.pos[1] - originY),
133+
];
134+
if (nd.title) node.title = nd.title;
135+
if (nd.color) node.color = nd.color;
136+
if (nd.bgcolor) node.bgcolor = nd.bgcolor;
137+
if (nd.flags) node.flags = { ...(node.flags || {}), ...nd.flags };
138+
if (nd.properties) node.properties = { ...(node.properties || {}), ...nd.properties };
139+
app.graph.add(node);
140+
if (Array.isArray(nd.size)) {
141+
// Apply AFTER add() so LiteGraph doesn't auto-resize on top of us.
142+
node.size = [nd.size[0], nd.size[1]];
143+
}
144+
// Apply widgets by NAME.
145+
for (const [wname, wval] of Object.entries(nd.widgets || {})) {
146+
const w = (node.widgets || []).find((x) => x.name === wname);
147+
if (!w) continue;
148+
try {
149+
w.value = wval;
150+
w.callback?.(wval, app.canvas, node);
151+
} catch (err) {
152+
console.warn("[MEC.clipboard] widget set failed:", wname, err);
153+
}
154+
}
155+
idMap[nd.id] = node;
156+
}
157+
// Re-wire links by old IDs.
158+
for (const l of data.links || []) {
159+
const src = idMap[l.src_id], dst = idMap[l.dst_id];
160+
if (!src || !dst) continue;
161+
try { src.connect(l.src_slot, dst, l.dst_slot); }
162+
catch (err) { console.warn("[MEC.clipboard] link failed", err); }
163+
}
164+
// Select the freshly pasted nodes.
165+
try {
166+
app.canvas.deselectAllNodes?.();
167+
for (const n of Object.values(idMap)) app.canvas.selectNode?.(n, true);
168+
} catch (_) {}
169+
app.graph.setDirtyCanvas(true, true);
170+
_toast(`Pasted ${Object.keys(idMap).length} node(s)`, "success");
171+
}
172+
173+
window.addEventListener("keydown", (ev) => {
174+
if (!ev.ctrlKey || !ev.altKey || ev.shiftKey) return;
175+
// Ignore if focus is in a text input — let the user paste into fields.
176+
const t = ev.target;
177+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
178+
const k = ev.key.toLowerCase();
179+
if (k === "c") { ev.preventDefault(); _copy(); }
180+
else if (k === "v") { ev.preventDefault(); _paste(); }
181+
});
182+
183+
app.registerExtension({
184+
name: "MEC.Clipboard",
185+
async setup() {
186+
console.log("[MEC.clipboard] portable JSON copy/paste loaded (Ctrl+Alt+C / Ctrl+Alt+V)");
187+
const origMenu = LGraphCanvas.prototype.getCanvasMenuOptions;
188+
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
189+
const opts = origMenu.apply(this, arguments) || [];
190+
opts.push(null);
191+
opts.push({ content: "MEC: Copy node(s) with metadata (Ctrl+Alt+C)", callback: _copy });
192+
opts.push({ content: "MEC: Paste node(s) from clipboard (Ctrl+Alt+V)", callback: _paste });
193+
return opts;
194+
};
195+
// Also expose on per-node right-click menu.
196+
const origNodeMenu = LGraphCanvas.prototype.getNodeMenuOptions;
197+
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
198+
const opts = origNodeMenu.apply(this, arguments) || [];
199+
opts.push(null);
200+
opts.push({ content: "MEC: Copy with metadata", callback: () => {
201+
// Ensure this node is in the selection.
202+
try { app.canvas.selectNode?.(node, true); } catch (_) {}
203+
_copy();
204+
}});
205+
return opts;
206+
};
207+
},
208+
});

js/points_bbox_editor.js

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,45 @@ class Editor {
9090
return { x: (vx - this.panX) / this.zoom, y: (vy - this.panY) / this.zoom };
9191
}
9292

93+
// Smallest zoom that still fits the image inside the viewport (no
94+
// scrolling past the borders). Used to clamp every zoom-out.
95+
minZoom(viewW, viewH) {
96+
if (viewW <= 0 || viewH <= 0) return 0.05;
97+
return Math.min(viewW / this.canvasW, viewH / this.canvasH);
98+
}
99+
100+
// Stop pan from moving the image off-screen. Anchors:
101+
// - if the image is wider than the viewport, allow X pan only inside
102+
// the overflow region; otherwise centre it horizontally.
103+
// - same for Y.
104+
clampPan(viewW, viewH) {
105+
const dW = this.canvasW * this.zoom;
106+
const dH = this.canvasH * this.zoom;
107+
if (dW <= viewW) {
108+
this.panX = (viewW - dW) / 2;
109+
} else {
110+
this.panX = Math.min(0, Math.max(viewW - dW, this.panX));
111+
}
112+
if (dH <= viewH) {
113+
this.panY = (viewH - dH) / 2;
114+
} else {
115+
this.panY = Math.min(0, Math.max(viewH - dH, this.panY));
116+
}
117+
}
118+
119+
setZoomAround(newZ, anchorX, anchorY, viewW, viewH) {
120+
const minZ = this.minZoom(viewW, viewH);
121+
newZ = Math.max(minZ, Math.min(8, newZ));
122+
const k = newZ / this.zoom;
123+
this.panX = anchorX - (anchorX - this.panX) * k;
124+
this.panY = anchorY - (anchorY - this.panY) * k;
125+
this.zoom = newZ;
126+
this.clampPan(viewW, viewH);
127+
}
128+
93129
fitView(viewW, viewH) {
94130
if (viewW <= 0 || viewH <= 0) return;
95-
const pad = 16;
131+
const pad = 8;
96132
const sx = (viewW - pad * 2) / this.canvasW;
97133
const sy = (viewH - pad * 2) / this.canvasH;
98134
this.zoom = Math.max(0.05, Math.min(sx, sy, 8));
@@ -403,7 +439,15 @@ function installEditor(node) {
403439

404440
tb.appendChild(mkIcon("↶", "Undo (Ctrl+Z)", () => { if (ed.undo()) ed.save(); }));
405441
tb.appendChild(mkIcon("↷", "Redo (Ctrl+Y)", () => { if (ed.redo()) ed.save(); }));
406-
tb.appendChild(mkIcon("⛶", "Fit view (F)", () => {
442+
tb.appendChild(mkIcon("\u2796", "Zoom out (-)", () => {
443+
const r = canvasWrap.getBoundingClientRect();
444+
ed.setZoomAround(ed.zoom * 0.85, r.width / 2, r.height / 2, r.width, r.height);
445+
}));
446+
tb.appendChild(mkIcon("\u2795", "Zoom in (+)", () => {
447+
const r = canvasWrap.getBoundingClientRect();
448+
ed.setZoomAround(ed.zoom * 1.15, r.width / 2, r.height / 2, r.width, r.height);
449+
}));
450+
tb.appendChild(mkIcon("\u2B1B", "Fit image (0 / F)", () => {
407451
const r = canvasWrap.getBoundingClientRect();
408452
ed.fitView(r.width, r.height);
409453
}));
@@ -585,6 +629,7 @@ function installEditor(node) {
585629
const sx1 = canvas.width / (r1.width || 1), sy1 = canvas.height / (r1.height || 1);
586630
ed.panX = (e.clientX - r1.left) * sx1 - ed.drag.startX;
587631
ed.panY = (e.clientY - r1.top) * sy1 - ed.drag.startY;
632+
ed.clampPan(canvas.width, canvas.height);
588633
render(); return;
589634
}
590635
if (ed.drag?.kind === "point") {
@@ -667,26 +712,15 @@ function installEditor(node) {
667712

668713
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
669714

715+
// Mouse wheel inside the editor does NOTHING. Zoom is via toolbar
716+
// buttons or +/-/0 keys; this prevents accidental zoom while panning
717+
// the parent ComfyUI canvas. Wheel events are not even prevented so
718+
// the outer ComfyUI canvas can still receive them via bubbling.
670719
canvas.addEventListener("wheel", (e) => {
671-
e.preventDefault(); e.stopPropagation();
672-
const r = canvas.getBoundingClientRect();
673-
const sx = canvas.width / (r.width || 1);
674-
const sy = canvas.height / (r.height || 1);
675-
const mx = (e.clientX - r.left) * sx, my = (e.clientY - r.top) * sy;
676-
if (e.ctrlKey || e.metaKey) {
677-
const f = e.deltaY < 0 ? 1.15 : 0.87;
678-
const newZ = Math.max(0.05, Math.min(40, ed.zoom * f));
679-
const k = newZ / ed.zoom;
680-
ed.panX = mx - (mx - ed.panX) * k;
681-
ed.panY = my - (my - ed.panY) * k;
682-
ed.zoom = newZ;
683-
} else {
684-
ed.radius = Math.max(0.5, Math.min(256, ed.radius + (e.deltaY < 0 ? 0.5 : -0.5)));
685-
const rW = node.widgets?.find(x => x.name === "default_radius");
686-
if (rW) rW.value = ed.radius;
687-
}
688-
render();
689-
}, { passive: false });
720+
// Eat the event only when the user is over our canvas AND wants
721+
// to scroll the parent — we don't want to fight ComfyUI's zoom,
722+
// so let it bubble.
723+
}, { passive: true });
690724

691725
canvas.addEventListener("keydown", (e) => {
692726
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
@@ -699,9 +733,20 @@ function installEditor(node) {
699733
if (ed.hover.kind === "point") { ed.pushUndo(); ed.points.splice(ed.hover.idx, 1); ed.save(); }
700734
else if (ed.hover.kind === "bbox") { ed.pushUndo(); ed.bboxes.splice(ed.hover.idx, 1); ed.save(); }
701735
ed.hover = { kind: null, idx: -1 }; render();
702-
} else if (e.key.toLowerCase() === "f") {
736+
} else if (e.key.toLowerCase() === "f" || e.key === "0") {
737+
e.preventDefault();
703738
const r = canvasWrap.getBoundingClientRect();
704739
ed.fitView(r.width, r.height); render();
740+
} else if (e.key === "+" || e.key === "=") {
741+
e.preventDefault();
742+
const r = canvasWrap.getBoundingClientRect();
743+
ed.setZoomAround(ed.zoom * 1.15, r.width / 2, r.height / 2, r.width, r.height);
744+
render();
745+
} else if (e.key === "-" || e.key === "_") {
746+
e.preventDefault();
747+
const r = canvasWrap.getBoundingClientRect();
748+
ed.setZoomAround(ed.zoom * 0.85, r.width / 2, r.height / 2, r.width, r.height);
749+
render();
705750
}
706751
});
707752

0 commit comments

Comments
 (0)