fix(dashboard): drag the Revise image onto Load Image / canvas, plus a Send to Load Image button (#22)

Rich dragstart (asset-info JSON, uri-list, plain, DownloadURL; reference not copy), a modal button that
fills the selected/only/new Load Image via the parent bridge, standalone Download image fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:58:31 -08:00
co-authored by Claude Fable 5.1
parent 525733d70b
commit e88b18a549
8 changed files with 288 additions and 1 deletions
+1
View File
@@ -22,6 +22,7 @@ def get_html_template(title, manifest_data, node_id, standalone=False):
"logic_state.js", # Global variables
"logic_utils.js", # API & Helper functions
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
"logic_dragdrop.js", # Revise-modal image drag/send helpers (issue #22; pure, node-testable)
"logic_ui.js", # DOM Creation, Modals, JSON Bars
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
+63
View File
@@ -0,0 +1,63 @@
/**
* Drag-and-drop / "Send to Load Image" helpers for the Revise modal image (issue #22).
* Pure functions only (no DOM): loaded as a plain script inlined into the dashboard HTML and, via the
* guarded export at the bottom, by node (tests/js/dragdrop.test.js). Top-level names are shared with every
* other logic_*.js (ONE inlined script), so everything here is prefixed `uscg`/`USCG_`.
*/
// false = drag/send REFERENCE the image in the benchmark folder (`subfolder/file [output]`, no copy in input/).
// true = additionally attach the image bytes as a File (drag) / upload to input/ (button).
const USCG_DRAG_UPLOAD = false;
const USCG_DRAG_MIME = { webp: 'image/webp', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
// Parse a manifest item's `file` (a /view?filename=..&type=..&subfolder=.. URL) into what the drop
// handlers need. Returns null for items that are not draggable images (videos, workflow cards, odd URLs).
function uscgImageRef(d, baseHref) {
if (!d || !d.file || d.media_type === 'video' || d.model_type === 'workflow') return null;
var u;
try { u = new URL(d.file, baseHref || (typeof document !== 'undefined' ? document.baseURI : 'http://localhost/')); }
catch (e) { return null; }
var filename = u.searchParams.get('filename');
if (!filename) return null;
var m = /\.([A-Za-z0-9]+)$/.exec(filename);
var mime = m ? USCG_DRAG_MIME[m[1].toLowerCase()] : null;
if (!mime) return null;
return {
absUrl: u.href,
filename: filename,
subfolder: u.searchParams.get('subfolder') || '',
type: u.searchParams.get('type') || 'output',
mime: mime
};
}
// The JSON ComfyUI's own Assets panel puts under `application/x-comfy-asset-info` (documented fields only).
function uscgImageAssetInfo(ref) {
return { filename: ref.filename, subfolder: ref.subfolder, type: ref.type, display_name: ref.filename };
}
// Widget value LoadImage accepts for a file that lives in output/ (annotated path, no copy).
function uscgAnnotatedPath(ref) {
return (ref.subfolder ? ref.subfolder + '/' : '') + ref.filename + ' [' + ref.type + ']';
}
// Fill a DataTransfer during `dragstart`. `file` (a File) is attached only when given.
function uscgFillDragData(dt, ref, file) {
dt.setData('application/x-comfy-asset-info', JSON.stringify(uscgImageAssetInfo(ref)));
dt.setData('text/uri-list', ref.absUrl);
dt.setData('text/plain', ref.absUrl);
dt.setData('DownloadURL', ref.mime + ':' + ref.filename + ':' + ref.absUrl);
if (file && dt.items && typeof dt.items.add === 'function') {
try { dt.items.add(file); } catch (e) { /* protected mode / unsupported: URL carriers still work */ }
}
try { dt.effectAllowed = 'copy'; } catch (e) { /* ignore */ }
return dt;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
USCG_DRAG_UPLOAD: USCG_DRAG_UPLOAD, uscgImageRef: uscgImageRef, uscgImageAssetInfo: uscgImageAssetInfo,
uscgAnnotatedPath: uscgAnnotatedPath, uscgFillDragData: uscgFillDragData
};
}
+6
View File
@@ -71,6 +71,12 @@ if (USCG_STANDALONE) {
var openBtn = document.getElementById('open-standalone-btn');
if (openBtn) openBtn.style.display = 'none';
var sendBtn = document.getElementById('modal-send-loadimage-btn');
if (sendBtn) {
sendBtn.textContent = '\u2B07 Download image';
sendBtn.title = 'Save this image (dragging it to the desktop also works)';
}
var pack = document.getElementById('pack-workflow-checkbox');
if (pack) {
pack.checked = false;
+83
View File
@@ -1423,12 +1423,95 @@ function createCard(d) {
return card;
}
// ---- Revise-modal image: drag onto a Load Image node / canvas, or "Send to Load Image" (issue #22) ----
// The drag carries ComfyUI's own asset-info JSON + a same-origin URL (what its Assets panel sends), so a node
// drop references the file in output/ and a canvas drop creates a Load Image node. Helpers: logic_dragdrop.js.
function _uscgModalRef() {
const d = activeData.find(x => x.id === window.currentModalId);
return d ? uscgImageRef(d) : null;
}
async function _uscgPrefetchDragFile(ref, id) {
if (!USCG_DRAG_UPLOAD || !ref) return;
if (window._uscgDragFile && window._uscgDragFile.id === id) return;
try {
const blob = await (await fetch(ref.absUrl)).blob();
if (window.currentModalId === id) window._uscgDragFile = { id: id, file: new File([blob], ref.filename, { type: blob.type || ref.mime }) };
} catch (e) { /* no File: the URL carriers still work */ }
}
function _uscgInstallModalDrag() {
if (window._uscgDragInstalled) return;
const img = document.getElementById('m-img');
if (!img) return;
window._uscgDragInstalled = true;
img.addEventListener('dragstart', function (e) {
const ref = _uscgModalRef();
if (!ref || !e.dataTransfer) { e.preventDefault(); return; }
const f = window._uscgDragFile && window._uscgDragFile.id === window.currentModalId ? window._uscgDragFile.file : null;
uscgFillDragData(e.dataTransfer, ref, USCG_DRAG_UPLOAD ? f : null);
});
}
function _uscgSyncModalDrag(d) {
_uscgInstallModalDrag();
const ref = uscgImageRef(d);
const img = document.getElementById('m-img');
if (img) img.draggable = !!ref;
const btn = document.getElementById('modal-send-loadimage-btn');
if (btn) {
btn.style.display = ref ? '' : 'none';
btn.textContent = (typeof USCG_STANDALONE !== 'undefined' && USCG_STANDALONE)
? '\u2B07 Download image' : '\uD83D\uDDBC Send to Load Image';
}
window._uscgDragFile = null;
_uscgPrefetchDragFile(ref, d.id);
}
function _uscgAskParentLoadImage(ref, upload) {
return new Promise(function (resolve) {
if (!window.parent || window.parent === window) { resolve(null); return; }
var done = false;
var onMsg = function (ev) {
if (!ev.data || ev.data.type !== 'uscg_send_to_load_image_result') return;
done = true;
window.removeEventListener('message', onMsg);
resolve({ ok: !!ev.data.ok, message: ev.data.message || '' });
};
window.addEventListener('message', onMsg);
window.parent.postMessage({
type: 'uscg_send_to_load_image', url: ref.absUrl, filename: ref.filename, subfolder: ref.subfolder,
imageType: ref.type, upload: !!upload, node_id: TARGET_NODE_ID
}, '*');
setTimeout(function () {
if (done) return;
window.removeEventListener('message', onMsg);
resolve(null);
}, 8000);
});
}
async function sendModalToLoadImage() {
const ref = _uscgModalRef();
if (!ref) { _uscgToast('This item is not an image.', false); return; }
if (typeof USCG_STANDALONE !== 'undefined' && USCG_STANDALONE) {
const a = document.createElement('a');
a.href = ref.absUrl; a.download = ref.filename;
document.body.appendChild(a); a.click(); a.remove();
return;
}
const res = await _uscgAskParentLoadImage(ref, USCG_DRAG_UPLOAD);
if (res) { _uscgToast(res.message || (res.ok ? 'Sent to Load Image.' : 'Could not send.'), res.ok); return; }
_uscgToast('The ComfyUI tab did not answer. Drag the image onto a Load Image node instead.', false);
}
// Open Revision Modal
function openM(id) {
window.currentModalId = id;
const d = activeData.find(x => x.id === id);
if (!d) return;
document.getElementById('m-img').src = d.file;
_uscgSyncModalDrag(d);
const previewNote = document.getElementById('m-preview-note');
if (previewNote) {
if (d.preview) {
+6 -1
View File
@@ -858,13 +858,18 @@
<span class="modal-kbd-hint" style="font-size: 9px; color: #444; margin-left: 8px;">← → Space navigate &nbsp;|&nbsp; 1-9 favorite</span>
</div>
<div class="modal-main">
<div class="preview"><img id="m-img" src=""><div id="m-preview-note" style="display: none; font-size: 11px; color: #cdb26a; padding: 4px 8px; text-align: center;"></div></div>
<div class="preview"><img id="m-img" src="" draggable="true" title="Drag onto a Load Image node or the canvas"><div id="m-preview-note" style="display: none; font-size: 11px; color: #cdb26a; padding: 4px 8px; text-align: center;"></div></div>
<div class="controls">
<h2 style="margin:0 0 10px 0; color:#fff;">Revise & Generate</h2>
<button class="modal-action-btn copy-comfy-btn" onclick="copyConfigsAsComfyNodes(window.currentModalId)"
style="background: #6366f1; margin-top: 10px;">
📋 Copy Configs To Clipboard As Comfy Nodes
</button>
<button id="modal-send-loadimage-btn" class="modal-action-btn" onclick="sendModalToLoadImage()"
title="Put this image into a Load Image node (the selected one, the only one, or a new one)"
style="background: #2b6cb0; margin-top: 6px;">
🖼 Send to Load Image
</button>
<div class="field"><label>Model</label><input id="f-model"></div>
<div class="field"><label>Seed</label><input id="f-seed" type="number">
</div>
+65
View File
@@ -0,0 +1,65 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const D = require('../../resources/logic_dragdrop.js');
const B = 'http://127.0.0.1:8188/';
const mk = (file, extra) => Object.assign({ id: 1, file: file }, extra || {});
test('uscgImageRef parses a /view URL (webp, nested subfolder)', () => {
const r = D.uscgImageRef(mk('/view?filename=img_1.webp&type=output&subfolder=benchmarks/Sess/images'), B);
assert.strictEqual(r.filename, 'img_1.webp');
assert.strictEqual(r.subfolder, 'benchmarks/Sess/images');
assert.strictEqual(r.type, 'output');
assert.strictEqual(r.mime, 'image/webp');
assert.strictEqual(r.absUrl, B + 'view?filename=img_1.webp&type=output&subfolder=benchmarks/Sess/images');
assert.strictEqual(D.uscgAnnotatedPath(r), 'benchmarks/Sess/images/img_1.webp [output]');
});
test('uscgImageRef: png/jpg mimes, url-encoded names, default type, empty subfolder', () => {
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.PNG'), B).mime, 'image/png');
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.jpeg'), B).mime, 'image/jpeg');
const r = D.uscgImageRef(mk('/view?filename=my%20pic%20%281%29.jpg&type=temp&subfolder='), B);
assert.strictEqual(r.filename, 'my pic (1).jpg');
assert.strictEqual(r.type, 'temp');
assert.strictEqual(D.uscgAnnotatedPath(r), 'my pic (1).jpg [temp]');
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.png'), B).type, 'output');
});
test('uscgImageRef returns null for videos, workflow cards, bad input', () => {
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.mp4', { media_type: 'video' }), B), null);
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.webp', { model_type: 'workflow' }), B), null);
assert.strictEqual(D.uscgImageRef(mk('/view?filename=a.mp4'), B), null);
assert.strictEqual(D.uscgImageRef(mk('/view?type=output'), B), null);
assert.strictEqual(D.uscgImageRef(null, B), null);
assert.strictEqual(D.uscgImageRef({}, B), null);
});
function fakeDt() {
const data = {}, added = [];
return { data, added, setData: (k, v) => { data[k] = v; }, items: { add: (f) => added.push(f) } };
}
test('uscgFillDragData sets the four carriers and no File by default', () => {
const ref = D.uscgImageRef(mk('/view?filename=img_1.webp&type=output&subfolder=b/s'), B);
const dt = fakeDt();
D.uscgFillDragData(dt, ref, null);
assert.deepStrictEqual(JSON.parse(dt.data['application/x-comfy-asset-info']),
{ filename: 'img_1.webp', subfolder: 'b/s', type: 'output', display_name: 'img_1.webp' });
assert.strictEqual(dt.data['text/uri-list'], ref.absUrl);
assert.strictEqual(dt.data['text/plain'], ref.absUrl);
assert.strictEqual(dt.data['DownloadURL'], 'image/webp:img_1.webp:' + ref.absUrl);
assert.strictEqual(dt.added.length, 0);
assert.strictEqual(dt.effectAllowed, 'copy');
});
test('uscgFillDragData attaches a File only when one is passed; survives items.add throwing', () => {
const ref = D.uscgImageRef(mk('/view?filename=a.png'), B);
const dt = fakeDt();
D.uscgFillDragData(dt, ref, { name: 'a.png' });
assert.strictEqual(dt.added.length, 1);
const bad = { setData() {}, items: { add() { throw new Error('protected'); } } };
assert.doesNotThrow(() => D.uscgFillDragData(bad, ref, {}));
});
test('upload mode is off by default', () => { assert.strictEqual(D.USCG_DRAG_UPLOAD, false); });
+9
View File
@@ -77,3 +77,12 @@ def test_concatenated_script_parses_as_one_script(standalone):
f.write(js)
out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
assert out.returncode == 0, out.stderr.decode()
def test_dragdrop_helpers_inlined_before_logic_ui():
html = _page()
assert "function uscgImageRef(" in html
assert 'id="modal-send-loadimage-btn"' in html
assert 'draggable="true"' in html
# node-only export guard stays harmless inline, helpers come before logic_ui's consumers
assert html.index("function uscgImageRef(") < html.index("function sendModalToLoadImage(")
+55
View File
@@ -161,6 +161,61 @@ app.registerExtension({
// Full pass for survivors (preview-pass workflow): flip the matching Config Builder to
// "Preview pass off" and queue the prompt. Feature-detected; the dashboard falls back to an
// instructions toast when this does not answer (or app.queuePrompt is unavailable).
// Revise-modal "Send to Load Image" (issue #22): fill the selected / only / a new Load Image node.
if (event.data?.type === 'uscg_send_to_load_image') {
const reply = (ok, message) => {
try { event.source?.postMessage({ type: 'uscg_send_to_load_image_result', ok, message }, '*'); } catch (e) { /* ignore */ }
};
(async () => {
try {
const d = event.data;
const u = new URL(String(d.url || ''), location.href);
if (u.origin !== location.origin || u.pathname !== '/view') {
reply(false, "Refused: the image URL is not this ComfyUI server's /view.");
return;
}
const filename = String(d.filename || u.searchParams.get('filename') || '');
const subfolder = String(d.subfolder || '');
const imgType = String(d.imageType || 'output');
if (!filename) { reply(false, 'No filename.'); return; }
const all = (app.graph?._nodes || []);
const loads = all.filter(n => n.type === 'LoadImage');
const sel = Object.values(app.canvas?.selected_nodes || {}).filter(n => n.type === 'LoadImage');
let node = sel[0] || (loads.length === 1 ? loads[0] : null);
if (!node) {
node = LiteGraph.createNode('LoadImage');
if (!node) { reply(false, 'Could not create a Load Image node.'); return; }
const dash = all.find(n => String(n.id) === String(d.node_id));
node.pos = dash ? [dash.pos[0] + dash.size[0] + 40, dash.pos[1]] : [60, 60];
app.graph.add(node);
}
const w = node.widgets?.find(x => x.name === 'image');
if (!w) { reply(false, 'The Load Image node has no image widget.'); return; }
let value = (subfolder ? subfolder + '/' : '') + filename + ' [' + imgType + ']';
if (d.upload) {
const blob = await (await fetch(u.href)).blob();
const fd = new FormData();
fd.append('image', new File([blob], filename, { type: blob.type }));
fd.append('type', 'input');
const r = await api.fetchApi('/upload/image', { method: 'POST', body: fd });
if (!r.ok) { reply(false, 'Upload failed (HTTP ' + r.status + ').'); return; }
const j = await r.json();
value = j.subfolder ? j.subfolder + '/' + j.name : j.name;
}
const old = w.value;
if (w.options?.values && !w.options.values.includes(value)) w.options.values.push(value);
w.value = value;
w.callback?.(value);
node.onWidgetChanged?.(w.name, value, old, w);
app.graph.setDirtyCanvas(true, true);
reply(true, `Loaded into "${node.title || 'Load Image'}" (#${node.id})`);
} catch (e) {
reply(false, 'Could not send the image: ' + (e?.message || e));
}
})();
return;
}
if (event.data?.type === 'uscg_run_full_pass') {
const reply = (ok, message) => {
try { event.source?.postMessage({ type: 'uscg_run_full_pass_result', ok, message }, '*'); } catch (e) { /* ignore */ }