Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/dnd/DragDrop.js
T

549 lines
18 KiB
JavaScript

/**
* Drag & Drop support for staging assets to input.
*/
import { getComfyApp } from "../../app/comfyApiBridge.js";
import { get, post } from "../../api/client.js";
import { ENDPOINTS, buildCustomViewURL, buildViewURL } from "../../api/endpoints.js";
import { comfyToast } from "../../app/toast.js";
import { pickRootId } from "../../utils/ids.js";
import { DND_MIME } from "./utils/constants.js";
import { dndLog } from "./utils/log.js";
import { buildPayloadViewURL, getDraggedAsset } from "./utils/payload.js";
import { isManagedPayload } from "./utils/video.js";
import {
clearAssetDragStartCleanup,
getAssetDragStartCleanup,
markInternalDropOccurred,
resetDndRuntimeState,
setAssetDragStartCleanup,
} from "./runtimeState.js";
import { isCanvasDropTarget, markCanvasDirty } from "./targets/canvas.js";
import { applyDragOutToOS, handleDragEnd } from "./out/DragOut.js";
import {
applyHighlight,
clearHighlight,
ensureComboHasValue,
getNodeUnderClientXY,
pickBestMediaPathWidget,
} from "./targets/node.js";
import { stageToInput, stageToInputDetailed } from "./staging/stageToInput.js";
const _resolveApp = () => {
const app = getComfyApp();
return app && typeof app === "object" ? app : null;
};
const buildURL = (payload) => buildPayloadViewURL(payload, { buildCustomViewURL, buildViewURL });
// Workflow cache: filename -> { workflow, at }
const _workflowCache = new Map();
const WORKFLOW_CACHE_TTL_MS = 60_000; // 1 minute
const WORKFLOW_CACHE_MAX = 20; // Keep small to limit memory (workflows can be up to 5MB each)
const MAX_WORKFLOW_BYTES = 5 * 1024 * 1024;
const MAX_WORKFLOW_NODE_COUNT = 5000;
const MAX_WORKFLOW_LINK_COUNT = 20000;
const MAX_WORKFLOW_NODE_TYPE_LENGTH = 256;
const MAX_WORKFLOW_WIDGET_STRING_LENGTH = 8192;
// eslint-disable-next-line no-control-regex
const _NODE_TYPE_CTRL_RE = /[\u0000-\u001f\u007f]/;
// eslint-disable-next-line no-control-regex
const _NUL_RE = /\u0000/;
const _hasNodeTypeControlChars = (value) => _NODE_TYPE_CTRL_RE.test(String(value || ""));
const _hasNulByte = (value) => _NUL_RE.test(String(value || ""));
const _isSafeWorkflowNode = (node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
const id = Number(node.id);
if (!Number.isFinite(id)) return false;
const nodeType = node.type == null ? "" : String(node.type);
if (nodeType) {
if (nodeType.length > MAX_WORKFLOW_NODE_TYPE_LENGTH) return false;
if (_hasNodeTypeControlChars(nodeType)) return false;
}
const widgetsValues = node.widgets_values;
if (Array.isArray(widgetsValues)) {
for (const value of widgetsValues) {
if (typeof value === "string") {
if (value.length > MAX_WORKFLOW_WIDGET_STRING_LENGTH) return false;
if (_hasNulByte(value)) return false;
}
}
}
return true;
};
const _isSafeWorkflowLink = (link) => {
if (Array.isArray(link)) return link.length >= 4;
if (link && typeof link === "object") {
const id = Number(link.id);
if (!Number.isFinite(id)) return false;
return true;
}
return false;
};
const isValidWorkflowShape = (workflow) => {
if (!workflow || typeof workflow !== "object") return false;
if (!Array.isArray(workflow.nodes) || !Array.isArray(workflow.links)) return false;
if (workflow.nodes.length > MAX_WORKFLOW_NODE_COUNT) return false;
if (workflow.links.length > MAX_WORKFLOW_LINK_COUNT) return false;
if (!workflow.nodes.every(_isSafeWorkflowNode)) return false;
if (!workflow.links.every(_isSafeWorkflowLink)) return false;
try {
const size = JSON.stringify(workflow).length;
if (!Number.isFinite(size) || size <= 0 || size > MAX_WORKFLOW_BYTES) return false;
} catch {
return false;
}
return true;
};
const cleanupWorkflowCache = () => {
const now = Date.now();
// Always remove expired entries
for (const [key, value] of _workflowCache.entries()) {
if (now - (value?.at || 0) > WORKFLOW_CACHE_TTL_MS) {
_workflowCache.delete(key);
}
}
// If still too large, remove oldest
if (_workflowCache.size > WORKFLOW_CACHE_MAX) {
const entries = Array.from(_workflowCache.entries()).sort(
(a, b) => (a[1]?.at || 0) - (b[1]?.at || 0),
);
const toRemove = entries.slice(0, _workflowCache.size - WORKFLOW_CACHE_MAX);
toRemove.forEach(([key]) => _workflowCache.delete(key));
}
};
const _applyWorkflowTabName = (app, name) => {
if (!name) return;
try {
const wf = app?.extensionManager?.workflow?.activeWorkflow;
if (wf && typeof wf === "object") {
const base = String(name).replace(/\.[^.]+$/, "");
wf.filename = base;
wf.path = `workflows/${base}.json`;
}
} catch (e) {
console.debug?.(e);
}
};
const tryLoadWorkflowToCanvas = async (payload, fallbackAbsPath = null) => {
const pl = payload && typeof payload === "object" ? payload : null;
const rootId = pickRootId(pl);
const displayName = pl?.filename || (fallbackAbsPath ? String(fallbackAbsPath).split(/[\\/]/).pop() : null);
const app = _resolveApp();
// Check cache first
const cacheKey = pl?.filename
? `${pl.type || "output"}:${pl.filename}:${pl.subfolder || ""}:${rootId}`
: fallbackAbsPath
? `path:${fallbackAbsPath}`
: null;
if (cacheKey) {
const cached = _workflowCache.get(cacheKey);
const now = Date.now();
if (cached && now - (cached.at || 0) < WORKFLOW_CACHE_TTL_MS) {
if (cached.workflow && isValidWorkflowShape(cached.workflow)) {
try {
if (typeof app?.loadGraphData === "function") {
app.loadGraphData(cached.workflow);
_applyWorkflowTabName(app, displayName);
return true;
}
if (typeof app?.canvas?.graph?.configure === "function") {
app.canvas.graph.configure(cached.workflow);
_applyWorkflowTabName(app, displayName);
try {
app.canvas.setDirty?.(true, true);
} catch (e) {
console.debug?.(e);
}
return true;
}
if (typeof app?.graph?.configure === "function") {
app.graph.configure(cached.workflow);
_applyWorkflowTabName(app, displayName);
return true;
}
} catch (e) {
console.debug?.(e);
}
}
// Cached but no workflow (already tried and failed)
return false;
}
}
try {
// Try ultra-fast workflow-quick endpoint first (direct SQL, no self-heal)
// Falls back to slower metadata endpoint with workflow_only=1 if needed
let url = null;
let workflow = null;
if (pl?.filename) {
// First: try the fast endpoint (no self-heal, direct SQL)
const quickUrl =
`${ENDPOINTS.WORKFLOW_QUICK}?type=${encodeURIComponent(pl.type || "output")}` +
`&filename=${encodeURIComponent(pl.filename)}` +
`&subfolder=${encodeURIComponent(pl.subfolder || "")}` +
(rootId ? `&root_id=${encodeURIComponent(rootId)}` : "");
const quickRes = await get(quickUrl);
if (quickRes?.ok && quickRes.workflow) {
workflow = quickRes.workflow;
}
// Fallback to slower metadata endpoint if quick lookup didn't find it
if (!workflow) {
url =
`${ENDPOINTS.METADATA}?workflow_only=1&type=${encodeURIComponent(pl.type || "output")}` +
`&filename=${encodeURIComponent(pl.filename)}` +
`&subfolder=${encodeURIComponent(pl.subfolder || "")}` +
`&root_id=${encodeURIComponent(rootId)}`;
}
} else if (fallbackAbsPath) {
// For absolute paths, use metadata endpoint (can't use quick lookup)
url = `${ENDPOINTS.METADATA}?workflow_only=1&path=${encodeURIComponent(String(fallbackAbsPath))}`;
}
// If we need the fallback metadata endpoint
if (!workflow && url) {
const res = await get(url);
if (!res?.ok || !res.data) {
return false;
}
workflow = res.data?.workflow;
}
if (!isValidWorkflowShape(workflow)) {
return false;
}
// Cache the workflow
if (cacheKey) {
_workflowCache.set(cacheKey, { workflow, at: Date.now() });
cleanupWorkflowCache();
}
// Prefer ComfyUI helper if available; otherwise use LiteGraph configure.
if (typeof app?.loadGraphData === "function") {
app.loadGraphData(workflow);
_applyWorkflowTabName(app, displayName);
return true;
}
if (typeof app?.canvas?.graph?.configure === "function") {
app.canvas.graph.configure(workflow);
_applyWorkflowTabName(app, displayName);
try {
app.canvas.setDirty?.(true, true);
} catch (e) {
console.debug?.(e);
}
return true;
}
if (typeof app?.graph?.configure === "function") {
app.graph.configure(workflow);
_applyWorkflowTabName(app, displayName);
return true;
}
} catch (e) {
console.debug?.(e);
}
return false;
};
export function createAssetDragStartHandler(containerEl) {
return (event) => {
const dt = event?.dataTransfer;
if (!dt) return;
const card = event?.target?.closest?.(".mjr-asset-card");
if (!card) return;
const asset = card._mjrAsset;
if (!asset || typeof asset !== "object") return;
const kind = String(asset?.kind || "").toLowerCase();
const type = String(asset?.type || "output").toLowerCase();
const payload = {
filename: asset.filename,
subfolder: asset.subfolder || "",
type,
root_id: pickRootId(asset) || undefined,
kind,
};
try {
dt.setData(DND_MIME, JSON.stringify(payload));
dt.setData("text/plain", String(asset.filename || ""));
// Apply OS drag-out (DownloadURL + batch ZIP) for all asset kinds.
// applyDragOutToOS handles single-file and multi-selection ZIP internally.
const viewUrl = buildURL(payload);
applyDragOutToOS({ dt, asset, containerEl, card, viewUrl });
} catch (e) {
console.debug?.(e);
}
// Listen for dragend to detect external drops and offer metadata stripping.
try {
card.addEventListener(
"dragend",
(endEvent) => {
handleDragEnd(endEvent, { asset, containerEl, card });
},
{ once: true },
);
} catch (e) {
console.debug?.(e);
}
const preview =
card.querySelector("img") ||
card.querySelector("video") ||
card.querySelector("canvas");
if (preview && preview instanceof HTMLElement) {
try {
dt.setDragImage(preview, 10, 10);
} catch (e) {
console.debug?.(e);
}
}
};
}
export const bindAssetDragStart = (containerEl) => {
if (!containerEl) return () => {};
const existingCleanup = getAssetDragStartCleanup(containerEl);
if (typeof existingCleanup === "function") {
return existingCleanup;
}
const onDragStart = createAssetDragStartHandler(containerEl);
containerEl.addEventListener("dragstart", onDragStart, true);
const cleanup = () => {
try {
containerEl.removeEventListener("dragstart", onDragStart, true);
} catch (e) {
console.debug?.(e);
}
clearAssetDragStartCleanup(containerEl);
};
return setAssetDragStartCleanup(containerEl, cleanup);
};
let _dragDropRuntime = null;
let _dragDropRuntimeRefCount = 0;
export function createDragDropRuntimeHandlers() {
const onDragOver = (event) => {
const app = _resolveApp();
const types = Array.from(event?.dataTransfer?.types || []);
if (!types.includes(DND_MIME)) return;
const payload = getDraggedAsset(event, DND_MIME);
if (!isManagedPayload(payload)) return;
const node = getNodeUnderClientXY(app, event.clientX, event.clientY);
const droppedExt =
String(payload?.filename || "")
.split(".")
.pop() || "";
const widget = node ? pickBestMediaPathWidget(node, payload, droppedExt) : null;
if (node && widget) {
event.preventDefault();
event.stopImmediatePropagation?.();
event.stopPropagation();
applyHighlight(app, node, markCanvasDirty);
event.dataTransfer.dropEffect = "copy";
dndLog("dragover", { node: node?.title, widget: widget?.name });
return;
}
clearHighlight(app, markCanvasDirty);
if (!isCanvasDropTarget(app, event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
const onDrop = async (event) => {
// Mark that an internal drop occurred so dragend can distinguish
// internal drops from external (OS/Explorer) drops.
markInternalDropOccurred();
const app = _resolveApp();
const types = Array.from(event?.dataTransfer?.types || []);
if (!types.includes(DND_MIME)) return;
const payload = getDraggedAsset(event, DND_MIME);
if (!isManagedPayload(payload)) return;
if (!isCanvasDropTarget(app, event)) return;
const node = getNodeUnderClientXY(app, event.clientX, event.clientY);
const droppedExt =
String(payload?.filename || "")
.split(".")
.pop() || "";
const widget = node ? pickBestMediaPathWidget(node, payload, droppedExt) : null;
if (!node || !widget) {
clearHighlight(app, markCanvasDirty);
event.preventDefault();
event.stopImmediatePropagation?.();
event.stopPropagation();
// Run staging + workflow extraction in TRUE parallel for best UX.
const stagePromise = stageToInputDetailed({
post,
endpoint: ENDPOINTS.STAGE_TO_INPUT,
payload,
index: false,
});
const workflowPromise = tryLoadWorkflowToCanvas(payload);
// Execute both in parallel (max performance)
const [loaded, staged] = await Promise.all([workflowPromise, stagePromise]);
if (loaded) {
dndLog("drop canvas loaded workflow", { file: payload?.filename });
// Staging has already completed in parallel; no further UI needed here.
return;
}
const relativePath = staged?.relativePath;
if (!relativePath) {
dndLog("drop canvas stage failed");
comfyToast(`Failed to load file: "${payload?.filename}". Staging failed.`, "error");
return;
}
dndLog("drop canvas staged", { value: relativePath });
comfyToast(`Staged to input: ${relativePath}`, "success", 4000);
return;
}
event.preventDefault();
event.stopImmediatePropagation?.();
event.stopPropagation();
clearHighlight(app, markCanvasDirty);
const relativePath = await stageToInput({
post,
endpoint: ENDPOINTS.STAGE_TO_INPUT,
payload,
index: false,
});
if (!relativePath) return;
if (widget.type === "combo") ensureComboHasValue(widget, relativePath);
widget.value = relativePath;
try {
widget.callback?.(widget.value);
} catch (e) {
console.debug?.(e);
}
markCanvasDirty(app);
dndLog("drop inject", { node: node?.title, widget: widget?.name, value: relativePath });
};
const onDragLeave = () => {
const app = _resolveApp();
clearHighlight(app, markCanvasDirty);
dndLog("dragleave");
};
return {
onDragOver,
onDrop,
onDragLeave,
};
}
function createDragDropRuntime() {
if (typeof window === "undefined" || !window?.addEventListener) {
return { dispose() {} };
}
const { onDragOver, onDrop, onDragLeave } = createDragDropRuntimeHandlers();
window.addEventListener("dragover", onDragOver, true);
window.addEventListener("drop", onDrop, true);
window.addEventListener("dragleave", onDragLeave, true);
const dispose = () => {
try {
window.removeEventListener("dragover", onDragOver, true);
window.removeEventListener("drop", onDrop, true);
window.removeEventListener("dragleave", onDragLeave, true);
} catch (e) {
console.debug?.(e);
}
try {
clearHighlight(_resolveApp(), markCanvasDirty);
} catch (e) {
console.debug?.(e);
}
};
return { dispose };
}
export function installDragDropRuntime() {
if (!_dragDropRuntime) {
_dragDropRuntime = createDragDropRuntime();
_dragDropRuntimeRefCount = 0;
}
_dragDropRuntimeRefCount += 1;
let disposed = false;
return ({ force = false } = {}) => {
if (disposed) return;
disposed = true;
teardownDragDropRuntime({ force });
};
}
export function teardownDragDropRuntime({ force = false } = {}) {
if (!_dragDropRuntime) {
_dragDropRuntimeRefCount = 0;
resetDndRuntimeState();
return;
}
if (!force && _dragDropRuntimeRefCount > 1) {
_dragDropRuntimeRefCount -= 1;
return;
}
_dragDropRuntimeRefCount = 0;
try {
_dragDropRuntime.dispose?.();
} catch (e) {
console.debug?.(e);
}
_dragDropRuntime = null;
resetDndRuntimeState();
}
export function getDragDropRuntimeState() {
return {
installed: !!_dragDropRuntime,
refCount: _dragDropRuntimeRefCount,
};
}
export const initDragDrop = installDragDropRuntime;