Files
PaintPlus/frontend/src/js/tools/selection_actions.js
T
Claude 59b26b6d5e Fix diffusers/torch compat, SAM docs, and SelectionActions UX
- Pin diffusers to >=0.28.0,<0.29.0 to avoid AttributeError on torch.xpu
  (diffusers 0.29+ requires PyTorch 2.4 but base image ships 2.1.2)
- Pin transformers to <4.40.0 to match
- README: add SAM offline download troubleshooting for DNS-blocked containers
- SelectionActions panel: 'Selection ready' title + subtitle makes clear
  nothing has fired yet; AI actions show inline description (not just tooltip);
  section labels 'AI Actions' / 'Classic Tools'; scale hint updates live;
  Enter key submits AI edit prompt; _actionCard hover border for clickability

https://claude.ai/code/session_01WVDg7amsy1TTtxvpku7bcM
2026-06-13 23:53:21 +00:00

414 lines
17 KiB
JavaScript

/**
* SelectionActions — floating quick-action panel that appears after a SAM selection.
*
* Surfaces high-value real-world workflows directly in the UI:
* • Scale by % — make object 3% (or any %) bigger/smaller, gap AI-filled
* • Make less symmetrical — AI redraws the region with organic variation
* • Replace with clipboard — paste clipboard image into the selection shape
* • Copy / Cut to layer — classic Photoshop workflow
* • AI Edit (custom prompt) — full inpaint with user text
*
* Usage:
* this.selectionActions = new SelectionActions(this);
* // after successful selection:
* this.selectionActions.show(imageBase64, maskBase64);
*/
import app from './../app.js';
import config from './../config.js';
import Base_layers_class from './../core/base-layers.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../libs/progress_overlay.js';
const BASE = window.API_BASE_URL || '';
export class SelectionActions {
constructor(tool) {
this.tool = tool;
this.Base_layers = new Base_layers_class();
this._panel = null;
this._imageData = null;
this._maskData = null;
this._escHandler = null;
}
show(imageBase64, maskBase64) {
this.hide();
this._imageData = imageBase64;
this._maskData = maskBase64;
var panel = document.createElement('div');
panel.id = 'sel-actions-panel';
panel.style.cssText = [
'position:fixed',
'bottom:80px',
'left:50%',
'transform:translateX(-50%)',
'background:#1a1a2e',
'border:1px solid #3a3a6a',
'border-radius:12px',
'padding:14px 16px',
'z-index:10000',
'font-family:sans-serif',
'font-size:12px',
'color:#d0d0e0',
'min-width:360px',
'max-width:420px',
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
'display:flex',
'flex-direction:column',
'gap:6px',
].join(';');
// ── Title row ────────────────────────────────────────────────────────
var titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:2px';
var titleBlock = document.createElement('div');
var title = document.createElement('div');
title.textContent = 'Selection ready';
title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff;line-height:1.3';
var subtitle = document.createElement('div');
subtitle.textContent = 'Nothing has changed yet — choose an action below';
subtitle.style.cssText = 'font-size:10px;color:#7777aa;margin-top:1px';
titleBlock.appendChild(title);
titleBlock.appendChild(subtitle);
var closeX = document.createElement('button');
closeX.textContent = '✕';
closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 0 0 8px;line-height:1;flex-shrink:0';
closeX.title = 'Dismiss (keeps your selection active)';
closeX.onclick = () => this.hide();
titleRow.appendChild(titleBlock);
titleRow.appendChild(closeX);
panel.appendChild(titleRow);
// ── Section: AI Actions ──────────────────────────────────────────────
panel.appendChild(_sectionLabel('AI Actions'));
// Scale by %
var scaleWrap = document.createElement('div');
scaleWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
var scaleRow = document.createElement('div');
scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px';
var scaleLabel = document.createElement('span');
scaleLabel.textContent = 'Scale object by';
scaleLabel.style.color = '#aaa';
var scaleInput = document.createElement('input');
scaleInput.type = 'number';
scaleInput.value = '103';
scaleInput.min = '1';
scaleInput.max = '500';
scaleInput.style.cssText = 'width:52px;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:2px 5px;font-size:12px';
var scaleUnit = document.createElement('span');
scaleUnit.textContent = '%';
scaleUnit.style.color = '#888';
var scaleHint = document.createElement('span');
scaleHint.style.cssText = 'color:#6688aa;font-size:10px;margin-left:2px';
scaleHint.textContent = '= 3% bigger';
var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
scaleBtn.style.marginLeft = 'auto';
scaleInput.addEventListener('input', () => {
var v = parseFloat(scaleInput.value);
if (isNaN(v) || v === 100) scaleHint.textContent = '= no change';
else if (v > 100) scaleHint.textContent = '= ' + (v - 100).toFixed(0) + '% bigger';
else scaleHint.textContent = '= ' + (100 - v).toFixed(0) + '% smaller';
});
scaleBtn.onclick = () => {
var pct = parseFloat(scaleInput.value) || 103;
this._scaleSelection(pct);
};
scaleRow.appendChild(scaleLabel);
scaleRow.appendChild(scaleInput);
scaleRow.appendChild(scaleUnit);
scaleRow.appendChild(scaleHint);
scaleRow.appendChild(scaleBtn);
var scaleDesc = document.createElement('div');
scaleDesc.textContent = 'Moves the selected object, then AI fills the vacated area';
scaleDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
scaleWrap.appendChild(scaleRow);
scaleWrap.appendChild(scaleDesc);
panel.appendChild(scaleWrap);
// Make less symmetrical
panel.appendChild(_actionCard(
'Make less symmetrical',
'#1c1a2e', '#cc99ff',
'AI redraws the selection with subtle, natural imperfections',
() => this._makeAsymmetric()
));
// Replace with clipboard
panel.appendChild(_actionCard(
'Replace with clipboard',
'#1a2a1a', '#88dd88',
'Scales your clipboard image to fit inside the selection shape',
() => this._pasteFromClipboard()
));
// Custom AI edit prompt
var aiWrap = document.createElement('div');
aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
var aiRow = document.createElement('div');
aiRow.style.cssText = 'display:flex;align-items:center;gap:6px';
var aiInput = document.createElement('input');
aiInput.type = 'text';
aiInput.placeholder = '"add a scar", "make it look aged", "blue eyes" …';
aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px';
var aiBtn = _btn('AI Edit', '#1a2a4a', '#8aacff');
aiBtn.onclick = () => {
var instruction = aiInput.value.trim();
if (!instruction) { alertify.warning('Enter an instruction first — describe what to change.'); return; }
this._aiEditRegion(instruction);
};
aiInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') aiBtn.click();
});
var aiDesc = document.createElement('div');
aiDesc.textContent = 'Inpaints the selected region according to your description';
aiDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px';
aiRow.appendChild(aiInput);
aiRow.appendChild(aiBtn);
aiWrap.appendChild(aiRow);
aiWrap.appendChild(aiDesc);
panel.appendChild(aiWrap);
// ── Section: Classic Tools ────────────────────────────────────────────
panel.appendChild(_sectionLabel('Classic Tools'));
var classicRow = document.createElement('div');
classicRow.style.cssText = 'display:flex;gap:6px';
var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
copyBtn.style.flex = '1';
copyBtn.title = 'Lift a copy of the selection onto a new layer (non-destructive)';
copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
cutBtn.style.flex = '1';
cutBtn.title = 'Cut the selection to a new layer (erases from original)';
cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
delBtn.style.flex = '0 0 auto';
delBtn.title = 'Delete the selected pixels (transparent / background color)';
delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
classicRow.appendChild(copyBtn);
classicRow.appendChild(cutBtn);
classicRow.appendChild(delBtn);
panel.appendChild(classicRow);
document.body.appendChild(panel);
this._panel = panel;
this._escHandler = (e) => { if (e.key === 'Escape') this.hide(); };
document.addEventListener('keydown', this._escHandler);
}
hide() {
if (this._panel) { this._panel.remove(); this._panel = null; }
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
this._escHandler = null;
}
}
// ── Actions ─────────────────────────────────────────────────────────────
async _scaleSelection(scalePct) {
if (!this._check()) return;
this.hide();
showProgress('Scaling object and AI-filling the gap…', 30);
try {
var res = await _post('/api/image/scale-selection', {
image: this._imageData,
mask: this._maskData,
scale_pct: scalePct,
});
this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection();
hideProgress();
alertify.success('Scaled by ' + scalePct + '%!');
} catch (e) {
hideProgress();
alertify.error('Scale failed: ' + e.message);
}
}
async _makeAsymmetric() {
if (!this._check()) return;
this.hide();
connectProgressSSE('inpaint', window.API_BASE_URL || '');
showProgress('AI is adding natural asymmetry…', 60);
try {
var res = await _post('/api/image/ai-edit-region', {
image: this._imageData,
mask: this._maskData,
instruction: 'natural asymmetry, slight organic variation, realistic, subtle imperfection',
negative_prompt:'perfectly symmetric, mirror image, artificial, identical halves',
steps: 30,
cfg_scale: 7.5,
});
this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection();
disconnectProgressSSE();
hideProgress();
alertify.success('Made less symmetrical!');
} catch (e) {
disconnectProgressSSE();
hideProgress();
alertify.error('AI edit failed: ' + e.message);
}
}
async _aiEditRegion(instruction) {
if (!this._check()) return;
this.hide();
connectProgressSSE('inpaint', window.API_BASE_URL || '');
showProgress('AI is editing the region…', 60);
try {
var res = await _post('/api/image/ai-edit-region', {
image: this._imageData,
mask: this._maskData,
instruction: instruction,
steps: 30,
cfg_scale: 7.5,
});
this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection();
disconnectProgressSSE();
hideProgress();
alertify.success('Done!');
} catch (e) {
disconnectProgressSSE();
hideProgress();
alertify.error('AI edit failed: ' + e.message);
}
}
async _pasteFromClipboard() {
if (!this._check()) return;
if (!navigator.clipboard || !navigator.clipboard.read) {
alertify.error('Clipboard API not available. Use HTTPS or enable clipboard permissions.');
return;
}
try {
var items = await navigator.clipboard.read();
var clipBlob = null;
for (var item of items) {
for (var type of item.types) {
if (type.startsWith('image/')) {
clipBlob = await item.getType(type);
break;
}
}
if (clipBlob) break;
}
if (!clipBlob) {
alertify.error('No image in clipboard. Copy an image first (e.g., right-click → Copy image).');
return;
}
var clipBase64 = await _blobToBase64(clipBlob);
this.hide();
showProgress('Pasting clipboard into selection…', 10);
var res = await _post('/api/image/paste-into-selection', {
image: this._imageData,
mask: this._maskData,
paste_image: clipBase64,
});
this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection();
hideProgress();
alertify.success('Clipboard pasted into selection!');
} catch (e) {
hideProgress();
alertify.error('Paste failed: ' + e.message);
}
}
_check() {
if (!this._imageData || !this._maskData) {
alertify.error('No selection data. Make a new selection first.');
return false;
}
return true;
}
}
// ── Shared method: patch into both smart_select and brush_select instances ───
/**
* Update the active layer canvas with a base64 result image from the backend.
* Call as `this.updateLayerWithResult(base64)` on any tool that extends Base_tools_class.
*/
export function updateLayerWithResult(base64, tool) {
var img = new Image();
img.onload = function () {
var canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0);
app.State.do_action(
new app.Actions.Bundle_action('ai_transform', 'AI Transform', [
new app.Actions.Update_layer_image_action(canvas, config.layer.id)
])
);
// Trigger re-render
config.need_render = true;
};
img.src = 'data:image/png;base64,' + base64;
}
// ── Private helpers ──────────────────────────────────────────────────────────
function _btn(text, bg, color) {
var b = document.createElement('button');
b.textContent = text;
b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;white-space:nowrap';
return b;
}
function _actionCard(text, bg, color, description, handler) {
var wrap = document.createElement('div');
wrap.style.cssText = 'background:' + bg + ';border-radius:7px;padding:7px 10px;cursor:pointer;border:1px solid transparent';
wrap.addEventListener('mouseenter', () => { wrap.style.borderColor = color; });
wrap.addEventListener('mouseleave', () => { wrap.style.borderColor = 'transparent'; });
wrap.onclick = handler;
var label = document.createElement('div');
label.textContent = text;
label.style.cssText = 'color:' + color + ';font-size:12px;font-weight:500;pointer-events:none';
var desc = document.createElement('div');
desc.textContent = description;
desc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:3px;pointer-events:none';
wrap.appendChild(label);
wrap.appendChild(desc);
return wrap;
}
function _sectionLabel(text) {
var el = document.createElement('div');
el.style.cssText = 'font-size:9px;font-weight:bold;letter-spacing:0.08em;color:#555577;text-transform:uppercase;margin-top:2px';
el.textContent = text;
return el;
}
async function _post(path, body) {
var r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
var err = await r.json().catch(() => ({ detail: r.statusText }));
throw new Error(err.detail || 'Request failed');
}
return r.json();
}
function _blobToBase64(blob) {
return new Promise((resolve, reject) => {
var reader = new FileReader();
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}