feat(inpaint): paste/drag into the mask editor, richer Help, kept-strokes hint

- the open mask editor now accepts paste + drag-drop to load a source image
  (window-capture handlers gated on the overlay, preempting ComfyUI's own
  image-drop), same as the Load Image button. Unbound on close.
- the saved-mask loader now shows "Kept your new strokes - the saved mask wasn't
  reloaded." when it skips a late mask load because you already started painting
  (was a silent skip).
- the Help popup is rewritten into a short guide: what the node does, painting,
  crop-size modes, the seam (Softness + Blend mode Mask vs Whole crop), and keys.
  framework layout.mjs gains an optional helpTitle (defaults to the old
  "<name> - Shortcuts", so other editors are unchanged) so it reads
  "Inpaint Crop - Guide".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pixaroma
2026-06-16 15:26:02 +03:00
co-authored by Claude Opus 4.8
parent 11afe8369a
commit 718960a8bf
3 changed files with 83 additions and 11 deletions
+2 -1
View File
@@ -47,6 +47,7 @@ export function createEditorLayout(config) {
onZoomOut,
onZoomFit,
helpContent = "",
helpTitle = "", // optional; defaults to "<editorName> — Shortcuts" (back-compat)
} = config;
// ── Overlay ──
@@ -186,7 +187,7 @@ export function createEditorLayout(config) {
if (helpContent) {
helpPanel.innerHTML = `
<div class="pxf-help-header">
<h3>${editorName} — Shortcuts</h3>
<h3>${helpTitle || `${editorName} — Shortcuts`}</h3>
<button class="pxf-btn-sm" style="flex-shrink:0;">✕</button>
</div>
<div class="pxf-help-content">${helpContent}</div>
+28 -9
View File
@@ -123,9 +123,10 @@ export class InpaintCropEditor {
}
});
} else {
this._setStatus("No source loaded. Wire an IMAGE input and run the workflow once, or click Load Image.");
this._setStatus("No source loaded. Wire an IMAGE input and run the workflow once, or click Load Image — or just paste / drag an image in here.");
}
this._bindKeys();
this._bindDropPaste(); // paste / drag-drop an image straight into the editor
}
_close() { this.layout?.unmount(); }
@@ -141,17 +142,35 @@ export class InpaintCropEditor {
onClose: () => this._close(),
onUndo: () => this._doUndo(),
onRedo: () => this._doRedo(),
helpTitle: "Inpaint Crop — Guide",
helpContent: `
<b>Paint the mask:</b> drag on the image to mark the area to fix<br>
<b>Brush / Erase:</b> pick a tool, or press <kbd>B</kbd> / <kbd>E</kbd><br>
<b style="color:#f66744;">WHAT THIS DOES</b><br>
Paint over the part of the image you want the model to redo. The node finds
the box around your paint, adds a margin, and crops a clean piece to inpaint.
The orange box is exactly what gets sent to the model.<br><br>
<b style="color:#f66744;">PAINTING</b><br>
<b>Brush / Erase:</b> pick a tool or press <kbd>B</kbd> / <kbd>E</kbd> (hold <kbd>X</kbd> to flip briefly)<br>
<b>Brush size:</b> <kbd>[</kbd> / <kbd>]</kbd> or the mouse wheel<br>
<b>Toggle erase:</b> hold <kbd>X</kbd><br>
<b>Show / hide mask:</b> <kbd>H</kbd><br>
<b>Invert / Clear:</b> buttons in the sidebar<br>
<b>Undo / Redo:</b> <kbd>Ctrl+Z</kbd> / <kbd>Ctrl+Shift+Z</kbd><br>
The orange box is the crop that goes to the model. Adjust the context
margin in the sidebar (or on the node) to include more surroundings.<br>
<b>Save:</b> <kbd>Ctrl+S</kbd> · <b>Close:</b> <kbd>Escape</kbd>`,
<b>Invert / Clear:</b> buttons in the sidebar (Invert = swap painted &harr; unpainted)<br>
<b>Load a different image:</b> the Load Image button, or just <b>paste</b> / <b>drag</b> an image straight in here<br><br>
<b style="color:#f66744;">CROP SIZE</b> (sidebar + node)<br>
<b>Keep shape:</b> scales your area so its long side hits Target, no stretching (best quality)<br>
<b>Force square:</b> always a Target&times;Target square<br>
<b>Free:</b> natural size, just rounded to the Multiple<br>
<b>Context margin:</b> how much of the surroundings to include around your paint<br><br>
<b style="color:#f66744;">SEAM — HOW IT BLENDS BACK</b><br>
<b>Softness:</b> how far the paste fades into the original at the edge (the live orange tint previews it)<br>
<b>Mask grow:</b> expands the painted area a little before cropping<br>
<b>Blend mode — Mask:</b> only the area you painted is replaced, the rest of the crop keeps the original (the normal inpaint)<br>
<b>Blend mode — Whole crop:</b> the <i>entire</i> box is replaced with the model's version — use when the model also relit / changed the surroundings, or for an img2img pass<br><br>
<b style="color:#f66744;">KEYS</b><br>
<b>Undo / Redo:</b> <kbd>Ctrl+Z</kbd> / <kbd>Ctrl+Shift+Z</kbd> &middot;
<b>Save:</b> <kbd>Ctrl+S</kbd> &middot; <b>Close:</b> <kbd>Escape</kbd>`,
});
this.layout = layout;
layout.onSaveToDisk = () => { this._diskSavePending = true; this._save(); };
+53 -1
View File
@@ -212,7 +212,10 @@ proto._loadMaskFromURL = function (url) {
// bail if a newer open/load superseded this, OR the user already started working
// (painting / has undo history / a painted bbox) - don't clobber fresh strokes.
if (token !== this._loadToken) return;
if (this._painting || this._undo?.length || this._bbox) return;
if (this._painting || this._undo?.length || this._bbox) {
this._setStatus("Kept your new strokes - the saved mask wasn't reloaded.");
return;
}
this._ensureMaskCanvas();
const tmp = document.createElement("canvas");
tmp.width = this.imgW; tmp.height = this.imgH;
@@ -334,9 +337,58 @@ proto._stopBrushHold = function () {
if (this._holdRaf) { cancelAnimationFrame(this._holdRaf); this._holdRaf = null; }
};
// ── paste / drag-drop a source image INTO the open editor ──────────────────
// Loads the dropped/pasted image as the new source (same as the Load Image
// button: _loadImageFromDataURL + onLoadImage to disconnect the upstream wire).
// All three listen at window-capture, gated on the overlay being live, and
// preventDefault + stop so ComfyUI doesn't spawn its own Load Image node behind us.
proto._bindDropPaste = function () {
const alive = () => !!this.el.overlay?.isConnected;
const loadFile = (file) => {
if (!file || !file.type?.startsWith("image/")) return false;
const reader = new FileReader();
reader.onload = (ev) => { this._loadImageFromDataURL(ev.target.result); this.onLoadImage?.(); };
reader.readAsDataURL(file);
return true;
};
this._pasteHandler = (e) => {
if (!alive()) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const it = Array.from(e.clipboardData?.items || []).find((x) => x.type?.startsWith("image/"));
if (!it) return;
const blob = it.getAsFile();
if (!blob) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(blob);
};
this._dragOverHandler = (e) => {
if (!alive()) return;
if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); e.stopImmediatePropagation(); }
};
this._dropHandler = (e) => {
if (!alive()) return;
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type?.startsWith("image/")) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(file);
};
window.addEventListener("paste", this._pasteHandler, true);
window.addEventListener("dragover", this._dragOverHandler, true);
window.addEventListener("drop", this._dropHandler, true);
};
proto._unbindDropPaste = function () {
if (this._pasteHandler) window.removeEventListener("paste", this._pasteHandler, true);
if (this._dragOverHandler) window.removeEventListener("dragover", this._dragOverHandler, true);
if (this._dropHandler) window.removeEventListener("drop", this._dropHandler, true);
this._pasteHandler = this._dragOverHandler = this._dropHandler = null;
};
proto._unbindKeys = function () {
this._stopBrushHold();
this._detachStroke?.(); // detach any live stroke window-listeners (close mid-drag)
this._unbindDropPaste();
if (this._keyHandler) window.removeEventListener("keydown", this._keyHandler, { capture: true });
if (this._keyUpHandler) window.removeEventListener("keyup", this._keyUpHandler, { capture: true });
};