Files
Comfyui-LayerForge/js/CustomShapeMenu.js
Dariusz L ccfa2b6cfb Add shape mask preview system for expansion and feather sliders
Implements a real-time blue outline preview when adjusting the expansion and feather sliders in the custom shape mask UI. The preview updates dynamically while dragging, and applies the mask only on mouse release. Adds a viewport change listener to keep the preview in sync during zooming or panning. Refactors mask expansion/contraction to use fast morphological operations for sharp edges and updates mask drawing to handle holes correctly.
2025-07-26 00:16:11 +02:00

393 lines
16 KiB
JavaScript

import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CustomShapeMenu');
export class CustomShapeMenu {
constructor(canvas) {
this.canvas = canvas;
this.element = null;
this.worldX = 0;
this.worldY = 0;
this.uiInitialized = false;
}
show() {
if (!this.canvas.outputAreaShape) {
return;
}
this._createUI();
if (this.element) {
this.element.style.display = 'block';
}
// Position in top-left corner of viewport (closer to edge)
const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y;
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
this.updateScreenPosition();
}
hide() {
if (this.element) {
this.element.remove();
this.element = null;
this.uiInitialized = false;
}
}
updateScreenPosition() {
if (!this.element)
return;
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
}
_createUI() {
if (this.uiInitialized)
return;
this.element = document.createElement('div');
this.element.id = 'layerforge-custom-shape-menu';
this.element.style.cssText = `
position: absolute;
top: 0;
left: 0;
background-color: #333;
color: white;
padding: 8px 15px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
display: none;
flex-direction: column;
gap: 4px;
font-family: sans-serif;
font-size: 12px;
z-index: 1001;
border: 1px solid #555;
user-select: none;
min-width: 200px;
`;
// Create menu content
const lines = [
"🎯 Custom Output Area Active",
"Press Shift+S to modify shape",
"Shape defines generation area"
];
lines.forEach(line => {
const lineElement = document.createElement('div');
lineElement.textContent = line;
lineElement.style.cssText = `
margin: 2px 0;
line-height: 18px;
`;
this.element.appendChild(lineElement);
});
// Add main auto-apply checkbox
const checkboxContainer = this._createCheckbox(() => `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`, () => {
this.canvas.autoApplyShapeMask = !this.canvas.autoApplyShapeMask;
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.applyShapeMask();
log.info("Auto-apply shape mask enabled - mask applied automatically");
}
else {
this.canvas.maskTool.removeShapeMask();
log.info("Auto-apply shape mask disabled - mask removed automatically");
}
this._updateUI();
this.canvas.render();
});
this.element.appendChild(checkboxContainer);
// Add expansion checkbox (only visible when auto-apply is enabled)
const expansionContainer = this._createCheckbox(() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`, () => {
this.canvas.shapeMaskExpansion = !this.canvas.shapeMaskExpansion;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
});
expansionContainer.id = 'expansion-checkbox';
this.element.appendChild(expansionContainer);
// Add expansion slider container (only visible when expansion is enabled)
const expansionSliderContainer = document.createElement('div');
expansionSliderContainer.id = 'expansion-slider-container';
expansionSliderContainer.style.cssText = `
margin: 6px 0;
padding: 4px 8px;
display: none;
`;
const expansionSliderLabel = document.createElement('div');
expansionSliderLabel.textContent = 'Expansion amount:';
expansionSliderLabel.style.cssText = `
font-size: 11px;
margin-bottom: 4px;
color: #ccc;
`;
const expansionSlider = document.createElement('input');
expansionSlider.type = 'range';
expansionSlider.min = '-300';
expansionSlider.max = '300';
expansionSlider.value = '0';
expansionSlider.style.cssText = `
width: 100%;
height: 4px;
background: #555;
outline: none;
border-radius: 2px;
`;
const expansionValueDisplay = document.createElement('div');
expansionValueDisplay.style.cssText = `
font-size: 10px;
text-align: center;
margin-top: 2px;
color: #aaa;
`;
const updateExpansionSliderDisplay = () => {
const value = parseInt(expansionSlider.value);
this.canvas.shapeMaskExpansionValue = value;
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
};
// Add preview system for expansion slider
let expansionTimeout = null;
let isExpansionDragging = false;
expansionSlider.onmousedown = () => {
isExpansionDragging = true;
};
expansionSlider.oninput = () => {
updateExpansionSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
// Clear previous timeout
if (expansionTimeout) {
clearTimeout(expansionTimeout);
}
if (isExpansionDragging) {
// Show blue preview line while dragging - NO mask application
const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0;
this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue);
}
else {
// Apply mask immediately for programmatic changes (not user dragging)
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
// Clear any pending timeout - we only apply mask on mouseup now
if (expansionTimeout) {
clearTimeout(expansionTimeout);
expansionTimeout = null;
}
}
};
expansionSlider.onmouseup = () => {
isExpansionDragging = false;
if (this.canvas.autoApplyShapeMask) {
// Apply final mask immediately when user releases slider
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true);
this.canvas.render();
}
};
updateExpansionSliderDisplay();
expansionSliderContainer.appendChild(expansionSliderLabel);
expansionSliderContainer.appendChild(expansionSlider);
expansionSliderContainer.appendChild(expansionValueDisplay);
this.element.appendChild(expansionSliderContainer);
// Add feather checkbox (only visible when auto-apply is enabled)
const featherContainer = this._createCheckbox(() => `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`, () => {
this.canvas.shapeMaskFeather = !this.canvas.shapeMaskFeather;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
});
featherContainer.id = 'feather-checkbox';
this.element.appendChild(featherContainer);
// Add feather slider container (only visible when feather is enabled)
const featherSliderContainer = document.createElement('div');
featherSliderContainer.id = 'feather-slider-container';
featherSliderContainer.style.cssText = `
margin: 6px 0;
padding: 4px 8px;
display: none;
`;
const featherSliderLabel = document.createElement('div');
featherSliderLabel.textContent = 'Feather amount:';
featherSliderLabel.style.cssText = `
font-size: 11px;
margin-bottom: 4px;
color: #ccc;
`;
const featherSlider = document.createElement('input');
featherSlider.type = 'range';
featherSlider.min = '0';
featherSlider.max = '300';
featherSlider.value = '0';
featherSlider.style.cssText = `
width: 100%;
height: 4px;
background: #555;
outline: none;
border-radius: 2px;
`;
const featherValueDisplay = document.createElement('div');
featherValueDisplay.style.cssText = `
font-size: 10px;
text-align: center;
margin-top: 2px;
color: #aaa;
`;
const updateFeatherSliderDisplay = () => {
const value = parseInt(featherSlider.value);
this.canvas.shapeMaskFeatherValue = value;
featherValueDisplay.textContent = `${value}px`;
};
// Add preview system for feather slider (mirrors expansion slider)
let featherTimeout = null;
let isFeatherDragging = false;
featherSlider.onmousedown = () => {
isFeatherDragging = true;
};
featherSlider.oninput = () => {
updateFeatherSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
if (isFeatherDragging) {
// Show blue preview line while dragging
const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0;
this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue);
}
else {
// Apply immediately for programmatic changes
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
}
};
featherSlider.onmouseup = () => {
isFeatherDragging = false;
if (this.canvas.autoApplyShapeMask) {
// Apply final mask when user releases slider
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true); // true = save state
this.canvas.render();
}
};
updateFeatherSliderDisplay();
featherSliderContainer.appendChild(featherSliderLabel);
featherSliderContainer.appendChild(featherSlider);
featherSliderContainer.appendChild(featherValueDisplay);
this.element.appendChild(featherSliderContainer);
// Add to DOM
if (this.canvas.canvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.element);
}
else {
log.error("Could not find parent node to attach custom shape menu.");
}
this.uiInitialized = true;
this._updateUI();
// Add viewport change listener to update shape preview when zooming/panning
this._addViewportChangeListener();
}
_createCheckbox(textFn, clickHandler) {
const container = document.createElement('div');
container.style.cssText = `
margin: 6px 0 2px 0;
padding: 4px 8px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
line-height: 18px;
`;
container.onmouseover = () => {
container.style.backgroundColor = '#555';
};
container.onmouseout = () => {
container.style.backgroundColor = 'transparent';
};
const updateText = () => {
container.textContent = textFn();
};
updateText();
container.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
clickHandler();
updateText();
};
return container;
}
_updateUI() {
if (!this.element)
return;
// Update expansion checkbox visibility
const expansionCheckbox = this.element.querySelector('#expansion-checkbox');
if (expansionCheckbox) {
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
}
// Update expansion slider container visibility
const expansionSliderContainer = this.element.querySelector('#expansion-slider-container');
if (expansionSliderContainer) {
expansionSliderContainer.style.display =
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
}
// Update feather checkbox visibility
const featherCheckbox = this.element.querySelector('#feather-checkbox');
if (featherCheckbox) {
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'block' : 'none';
}
// Update feather slider container visibility
const featherSliderContainer = this.element.querySelector('#feather-slider-container');
if (featherSliderContainer) {
featherSliderContainer.style.display =
(this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
}
// Update checkbox texts
const checkboxes = this.element.querySelectorAll('div[style*="cursor: pointer"]');
checkboxes.forEach((checkbox, index) => {
if (index === 0) { // Main checkbox
checkbox.textContent = `${this.canvas.autoApplyShapeMask ? "☑" : "☐"} Auto-apply shape mask`;
}
else if (index === 1) { // Expansion checkbox
checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`;
}
else if (index === 2) { // Feather checkbox
checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`;
}
});
}
/**
* Add viewport change listener to update shape preview when zooming/panning
*/
_addViewportChangeListener() {
// Store previous viewport state to detect changes
let previousViewport = {
x: this.canvas.viewport.x,
y: this.canvas.viewport.y,
zoom: this.canvas.viewport.zoom
};
// Check for viewport changes in render loop
const checkViewportChange = () => {
if (this.canvas.maskTool.shapePreviewVisible) {
const current = this.canvas.viewport;
// Check if viewport has changed
if (current.x !== previousViewport.x ||
current.y !== previousViewport.y ||
current.zoom !== previousViewport.zoom) {
// Update shape preview with current expansion/feather values
const expansionValue = this.canvas.shapeMaskExpansionValue || 0;
const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0;
this.canvas.maskTool.showShapePreview(expansionValue, featherValue);
// Update previous viewport state
previousViewport = {
x: current.x,
y: current.y,
zoom: current.zoom
};
}
}
// Continue checking if UI is still active
if (this.uiInitialized) {
requestAnimationFrame(checkViewportChange);
}
};
// Start the viewport change detection
requestAnimationFrame(checkViewportChange);
}
}