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:
co-authored by
Claude Fable 5.1
parent
525733d70b
commit
e88b18a549
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -858,13 +858,18 @@
|
||||
<span class="modal-kbd-hint" style="font-size: 9px; color: #444; margin-left: 8px;">← → Space navigate | 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>
|
||||
|
||||
@@ -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); });
|
||||
@@ -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(")
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user