14 Commits

Author SHA1 Message Date
Dariusz L
bd8007d8aa Remove 'v' key paste shortcut from canvas interactions
The 'v' key no longer triggers the paste action in CanvasInteractions. This change may be to prevent conflicts or to streamline keyboard shortcuts.
2025-07-03 16:15:42 +02:00
Dariusz L
af5e81c56b Initial commit
Add initial project files and setup.
2025-07-03 15:59:11 +02:00
Dariusz L
aa31a347d1 Add configurable log level for Python and JS modules
Introduced LOG_LEVEL configuration in both Python and JavaScript to control logging verbosity. Updated logger initialization in canvas_node.py and LoggerUtils.js to use the new LOG_LEVEL from config files.
2025-07-03 13:15:33 +02:00
Dariusz L
dfa7309132 Update pyproject.toml 2025-07-03 12:05:04 +02:00
Dariusz L
2ab406ebfd Improve error handling in BiRefNetMatting model loading
Refines exception handling in the load_model method to provide more informative error messages and re-raise exceptions for upstream handling. Removes boolean return values in favor of exception-based flow, and updates execute to rely on exceptions for error detection.
2025-07-03 12:04:28 +02:00
Dariusz L
d40f68b8c6 Preserve batch generation area during canvas changes
Introduces a 'generationArea' context for batch image generation, ensuring that batch preview outlines and image placement remain accurate when the canvas is moved or resized. Updates related logic in Canvas, CanvasInteractions, CanvasLayers, and CanvasRenderer to track and render the correct area, and synchronizes context updates across user interactions.
2025-07-03 11:52:16 +02:00
Dariusz L
e5060fd8c3 Support multiple batch preview menus on canvas
Refactored batch preview management to allow multiple BatchPreviewManager instances per canvas. Updated positioning logic to use an initial spawn position, adjusted UI updates, and ensured batch preview menus move correctly with canvas panning. Removed single-instance references and updated related event handling.
2025-07-03 03:55:04 +02:00
Dariusz L
f8eb91c4ad Make batch preview menu draggable and position-aware
Added draggable functionality to the batch preview menu, allowing users to reposition it within the canvas using world coordinates. The menu's position now updates with viewport changes, and its initial placement is centered below the output area. Also refactored logic to show the menu with new layers instead of adding to an existing batch.
2025-07-03 03:40:43 +02:00
Dariusz L
c4af745b2a Add addLayers method to BatchPreviewManager
Introduces an addLayers method to BatchPreviewManager for adding new layers to an active batch preview or showing the UI if inactive. Updates Canvas to use addLayers instead of show, and fixes a bug where new layers were only added if more than one was present.
2025-07-03 02:44:07 +02:00
Dariusz L
c9c0babf3c Add mask drawing mode to canvas interactions
Introduces support for a 'drawingMask' interaction mode in CanvasInteractions. Mouse events are now delegated to the maskTool when in this mode, and the canvas is re-rendered after each relevant event.
2025-07-03 02:40:07 +02:00
Dariusz L
152a3f7dff Auto-hide and restore mask overlay in batch preview
BatchPreviewManager now automatically hides the mask overlay when batch preview starts and restores its previous state when preview ends. The mask toggle button's state and label are updated accordingly. Also, mask toggle button IDs are now unique per canvas node.
2025-07-03 02:26:44 +02:00
Dariusz L
9f9a733731 Add batch preview manager and mask overlay toggle
Introduces BatchPreviewManager for reviewing and confirming multiple imported layers after auto-refresh. Adds a toggle button for mask overlay visibility in the UI and updates mask rendering logic to respect overlay visibility. Also refactors image import to return new layers and adds a utility for removing layers by ID.
2025-07-03 02:22:51 +02:00
Dariusz L
3419061b6c Add support for importing multiple latest images
Introduces a new backend route and method to fetch all images created since a given timestamp, and updates the frontend to import all new images as layers on auto-refresh. This improves workflow by allowing multiple images generated in a single execution to be imported at once, rather than only the most recent image.
2025-07-03 01:54:50 +02:00
Dariusz L
9e4da30b59 Add auto-refresh toggle after image generation
Introduces an 'Auto-refresh after generation' toggle to the Canvas. When enabled, the latest image is automatically imported after a successful execution event. Also ensures event listeners are properly cleaned up when the node is removed.
2025-07-03 01:02:35 +02:00
17 changed files with 1408 additions and 754 deletions

View File

@@ -28,8 +28,9 @@ import os
try: try:
from python.logger import logger, LogLevel, debug, info, warn, error, exception from python.logger import logger, LogLevel, debug, info, warn, error, exception
from python.config import LOG_LEVEL
logger.set_module_level('canvas_node', LogLevel.NONE) logger.set_module_level('canvas_node', LogLevel[LOG_LEVEL])
logger.configure({ logger.configure({
'log_to_file': True, 'log_to_file': True,
@@ -333,6 +334,24 @@ class CanvasNode:
latest_image_path = max(image_files, key=os.path.getctime) latest_image_path = max(image_files, key=os.path.getctime)
return latest_image_path return latest_image_path
@classmethod
def get_latest_images(cls, since_timestamp=0):
output_dir = folder_paths.get_output_directory()
files = []
for f_name in os.listdir(output_dir):
file_path = os.path.join(output_dir, f_name)
if os.path.isfile(file_path) and file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
try:
mtime = os.path.getmtime(file_path)
if mtime > since_timestamp:
files.append((mtime, file_path))
except OSError:
continue
files.sort(key=lambda x: x[0])
return [f[1] for f in files]
@classmethod @classmethod
def get_flow_status(cls, flow_id=None): def get_flow_status(cls, flow_id=None):
@@ -454,6 +473,30 @@ class CanvasNode:
'error': str(e) 'error': str(e)
}) })
@PromptServer.instance.routes.get("/layerforge/get-latest-images/{since}")
async def get_latest_images_route(request):
try:
since_timestamp = float(request.match_info.get('since', 0))
# JS Timestamps are in milliseconds, Python's are in seconds
latest_image_paths = cls.get_latest_images(since_timestamp / 1000.0)
images_data = []
for image_path in latest_image_paths:
with open(image_path, "rb") as f:
encoded_string = base64.b64encode(f.read()).decode('utf-8')
images_data.append(f"data:image/png;base64,{encoded_string}")
return web.json_response({
'success': True,
'images': images_data
})
except Exception as e:
log_error(f"Error in get_latest_images_route: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@PromptServer.instance.routes.get("/ycnode/get_latest_image") @PromptServer.instance.routes.get("/ycnode/get_latest_image")
async def get_latest_image_route(request): async def get_latest_image_route(request):
try: try:
@@ -571,42 +614,38 @@ class BiRefNetMatting:
def load_model(self, model_path): def load_model(self, model_path):
try: try:
if model_path not in self.model_cache: if model_path not in self.model_cache:
full_model_path = os.path.join(self.base_path, "BiRefNet") full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...") log_info(f"Loading BiRefNet model from {full_model_path}...")
try: try:
self.model = AutoModelForImageSegmentation.from_pretrained( self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet", "ZhengPeng7/BiRefNet",
trust_remote_code=True, trust_remote_code=True,
cache_dir=full_model_path cache_dir=full_model_path
) )
self.model.eval() self.model.eval()
if torch.cuda.is_available(): if torch.cuda.is_available():
self.model = self.model.cuda() self.model = self.model.cuda()
self.model_cache[model_path] = self.model self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face") log_info("Model loaded successfully from Hugging Face")
log_debug(f"Model type: {type(self.model)}")
log_debug(f"Model device: {next(self.model.parameters()).device}")
except Exception as e: except Exception as e:
log_error(f"Failed to load model: {str(e)}") log_error(f"Failed to load model from Hugging Face: {str(e)}")
raise # Re-raise with a more informative message
raise RuntimeError(
"Failed to download or load the matting model. "
"This could be due to a network issue, file permissions, or a corrupted model cache. "
f"Please check your internet connection and the model cache path: {full_model_path}. "
f"Original error: {str(e)}"
) from e
else: else:
self.model = self.model_cache[model_path] self.model = self.model_cache[model_path]
log_debug("Using cached model") log_debug("Using cached model")
return True
except Exception as e: except Exception as e:
# Catch the re-raised exception or any other error
log_error(f"Error loading model: {str(e)}") log_error(f"Error loading model: {str(e)}")
log_exception("Model loading failed") log_exception("Model loading failed")
return False raise # Re-raise the exception to be caught by the execute method
def preprocess_image(self, image): def preprocess_image(self, image):
@@ -636,11 +675,9 @@ class BiRefNetMatting:
def execute(self, image, model_path, threshold=0.5, refinement=1): def execute(self, image, model_path, threshold=0.5, refinement=1):
try: try:
PromptServer.instance.send_sync("matting_status", {"status": "processing"}) PromptServer.instance.send_sync("matting_status", {"status": "processing"})
if not self.load_model(model_path): self.load_model(model_path)
raise RuntimeError("Failed to load model")
if isinstance(image, torch.Tensor): if isinstance(image, torch.Tensor):
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:] original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]

258
js/BatchPreviewManager.js Normal file
View File

@@ -0,0 +1,258 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('BatchPreviewManager');
export class BatchPreviewManager {
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
this.canvas = canvas;
this.active = false;
this.layers = [];
this.currentIndex = 0;
this.element = null;
this.uiInitialized = false;
this.maskWasVisible = false;
// Position in canvas world coordinates
this.worldX = initialPosition.x;
this.worldY = initialPosition.y;
this.isDragging = false;
this.generationArea = generationArea; // Store the generation area
}
updateScreenPosition(viewport) {
if (!this.active || !this.element) return;
// Translate world coordinates to screen coordinates
const screenX = (this.worldX - viewport.x) * viewport.zoom;
const screenY = (this.worldY - viewport.y) * viewport.zoom;
// We can also scale the menu with zoom, but let's keep it constant for now for readability
const scale = 1; // viewport.zoom;
// Use transform for performance
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
}
_createUI() {
if (this.uiInitialized) return;
this.element = document.createElement('div');
this.element.id = 'layerforge-batch-preview';
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;
align-items: center;
gap: 15px;
font-family: sans-serif;
z-index: 1001;
border: 1px solid #555;
cursor: move;
user-select: none;
`;
this.element.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
const handleMouseMove = (moveEvent) => {
if (this.isDragging) {
// Convert screen pixel movement to world coordinate movement
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
this.worldX += deltaX;
this.worldY += deltaY;
// The render loop will handle updating the screen position, but we need to trigger it.
this.canvas.render();
}
};
const handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
const nextButton = this._createButton('▶', 'Next'); // Right arrow
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
const cancelButton = this._createButton('✖', 'Cancel All'); // X mark
const closeButton = this._createButton('➲', 'Close'); // Door icon
this.counterElement = document.createElement('span');
this.counterElement.style.minWidth = '40px';
this.counterElement.style.textAlign = 'center';
this.counterElement.style.fontWeight = 'bold';
prevButton.onclick = () => this.navigate(-1);
nextButton.onclick = () => this.navigate(1);
confirmButton.onclick = () => this.confirm();
cancelButton.onclick = () => this.cancelAndRemoveAll();
closeButton.onclick = () => this.hide();
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
if (this.canvas.canvas.parentNode) {
this.canvas.canvas.parentNode.appendChild(this.element);
} else {
log.error("Could not find parent node to attach batch preview UI.");
}
this.uiInitialized = true;
}
_createButton(innerHTML, title) {
const button = document.createElement('button');
button.innerHTML = innerHTML;
button.title = title;
button.style.cssText = `
background: #555;
color: white;
border: 1px solid #777;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
`;
button.onmouseover = () => button.style.background = '#666';
button.onmouseout = () => button.style.background = '#555';
return button;
}
show(layers) {
if (!layers || layers.length <= 1) {
return;
}
this._createUI();
// Auto-hide mask logic
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.remove('primary');
toggleBtn.textContent = "Hide Mask";
}
this.canvas.render();
}
log.info(`Showing batch preview for ${layers.length} layers.`);
this.layers = layers;
this.currentIndex = 0;
// Make the element visible BEFORE calculating its size
this.element.style.display = 'flex';
this.active = true;
// Now that it's visible, we can get its dimensions and adjust the position.
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
const paddingInWorld = 20 / this.canvas.viewport.zoom;
this.worldX -= menuWidthInWorld / 2; // Center horizontally
this.worldY += paddingInWorld; // Add padding below the output area
this._update();
}
hide() {
log.info('Hiding batch preview.');
if (this.element) {
this.element.remove();
}
this.active = false;
const index = this.canvas.batchPreviewManagers.indexOf(this);
if (index > -1) {
this.canvas.batchPreviewManagers.splice(index, 1);
}
// Trigger a final render to ensure the generation area outline is removed
this.canvas.render();
// Restore mask visibility if it was hidden by this manager
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask";
}
}
this.maskWasVisible = false; // Reset state
// Make all layers visible again upon closing
this.canvas.layers.forEach(l => l.visible = true);
this.canvas.render();
}
navigate(direction) {
this.currentIndex += direction;
if (this.currentIndex < 0) {
this.currentIndex = this.layers.length - 1;
} else if (this.currentIndex >= this.layers.length) {
this.currentIndex = 0;
}
this._update();
}
confirm() {
const layerToKeep = this.layers[this.currentIndex];
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
const layersToDelete = this.layers.filter(l => l.id !== layerToKeep.id);
const layerIdsToDelete = layersToDelete.map(l => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted ${layersToDelete.length} other layers.`);
this.hide();
}
cancelAndRemoveAll() {
log.info('Cancel clicked. Removing all new layers.');
const layerIdsToDelete = this.layers.map(l => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
this.hide();
}
_update() {
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
this._focusOnLayer(this.layers[this.currentIndex]);
}
_focusOnLayer(layer) {
if (!layer) return;
log.debug(`Focusing on layer ${layer.id}`);
// Move the selected layer to the top of the layer stack
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe
this.canvas.render();
}
}

View File

@@ -9,9 +9,22 @@ import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
import {CanvasRenderer} from "./CanvasRenderer.js"; import {CanvasRenderer} from "./CanvasRenderer.js";
import {CanvasIO} from "./CanvasIO.js"; import {CanvasIO} from "./CanvasIO.js";
import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {ImageReferenceManager} from "./ImageReferenceManager.js";
import {BatchPreviewManager} from "./BatchPreviewManager.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import {createModuleLogger} from "./utils/LoggerUtils.js";
import {mask_editor_showing, mask_editor_listen_for_cancel} from "./utils/mask_utils.js";
import { debounce } from "./utils/CommonUtils.js"; import { debounce } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js";
import {CanvasSelection} from "./CanvasSelection.js";
const useChainCallback = (original, next) => {
if (original === undefined || original === null) {
return next;
}
return function(...args) {
const originalReturn = original.apply(this, args);
const nextReturn = next.apply(this, args);
return nextReturn === undefined ? originalReturn : nextReturn;
};
};
const log = createModuleLogger('Canvas'); const log = createModuleLogger('Canvas');
@@ -32,9 +45,6 @@ export class Canvas {
this.width = 512; this.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
this.selectedLayer = null;
this.selectedLayers = [];
this.onSelectionChange = null;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
this.lastMousePosition = {x: 0, y: 0}; this.lastMousePosition = {x: 0, y: 0};
@@ -146,14 +156,19 @@ export class Canvas {
// Stwórz opóźnioną wersję funkcji zapisu stanu // Stwórz opóźnioną wersję funkcji zapisu stanu
this.requestSaveState = debounce(this.saveState.bind(this), 500); this.requestSaveState = debounce(this.saveState.bind(this), 500);
this._addAutoRefreshToggle();
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasMask = new CanvasMask(this);
this.canvasState = new CanvasState(this); this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(this);
this.canvasInteractions = new CanvasInteractions(this); this.canvasInteractions = new CanvasInteractions(this);
this.canvasLayers = new CanvasLayers(this); this.canvasLayers = new CanvasLayers(this);
this.canvasLayersPanel = new CanvasLayersPanel(this); this.canvasLayersPanel = new CanvasLayersPanel(this);
this.canvasRenderer = new CanvasRenderer(this); this.canvasRenderer = new CanvasRenderer(this);
this.canvasIO = new CanvasIO(this); this.canvasIO = new CanvasIO(this);
this.imageReferenceManager = new ImageReferenceManager(this); this.imageReferenceManager = new ImageReferenceManager(this);
this.batchPreviewManagers = [];
this.pendingBatchContext = null;
log.debug('Canvas modules initialized successfully'); log.debug('Canvas modules initialized successfully');
} }
@@ -274,60 +289,35 @@ export class Canvas {
/** /**
* Usuwa wybrane warstwy * Usuwa wybrane warstwy
*/ */
removeSelectedLayers() { removeLayersByIds(layerIds) {
if (this.selectedLayers.length > 0) { if (!layerIds || layerIds.length === 0) return;
log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length,
totalLayers: this.layers.length
});
this.saveState(); const initialCount = this.layers.length;
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l)); this.saveState();
this.layers = this.layers.filter(l => !layerIds.includes(l.id));
this.updateSelection([]);
// If the current selection was part of the removal, clear it
this.render(); const newSelection = this.canvasSelection.selectedLayers.filter(l => !layerIds.includes(l.id));
this.saveState(); this.canvasSelection.updateSelection(newSelection);
this.render();
this.saveState();
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
}
log.debug('Layers removed successfully, remaining layers:', this.layers.length);
} else {
log.debug('No layers selected for removal');
} }
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
}
removeSelectedLayers() {
return this.canvasSelection.removeSelectedLayers();
} }
/** /**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/ */
duplicateSelectedLayers() { duplicateSelectedLayers() {
if (this.selectedLayers.length === 0) return []; return this.canvasSelection.duplicateSelectedLayers();
const newLayers = [];
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
const newLayer = {
...layer,
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
zIndex: this.layers.length, // Nowa warstwa zawsze na wierzchu
};
this.layers.push(newLayer);
newLayers.push(newLayer);
});
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
this.updateSelection(newLayers);
// Powiadom panel o zmianie struktury, aby się przerysował
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
return newLayers;
} }
/** /**
@@ -336,82 +326,14 @@ export class Canvas {
* @param {Array} newSelection - Nowa lista zaznaczonych warstw * @param {Array} newSelection - Nowa lista zaznaczonych warstw
*/ */
updateSelection(newSelection) { updateSelection(newSelection) {
const previousSelection = this.selectedLayers.length; return this.canvasSelection.updateSelection(newSelection);
this.selectedLayers = newSelection || [];
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length ||
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
if (!hasChanged && previousSelection > 0) {
// return; // Zablokowane na razie, może powodować problemy
}
log.debug('Selection updated', {
previousCount: previousSelection,
newCount: this.selectedLayers.length,
selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown')
});
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
this.render();
// 2. Powiadom inne części aplikacji (jeśli są)
if (this.onSelectionChange) {
this.onSelectionChange();
}
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onSelectionChanged();
}
} }
/** /**
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
*/ */
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
let newSelection = [...this.selectedLayers]; return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
let selectionChanged = false;
if (isShiftPressed && this.canvasLayersPanel.lastSelectedIndex !== -1) {
const sortedLayers = [...this.layers].sort((a, b) => b.zIndex - a.zIndex);
const startIndex = Math.min(this.canvasLayersPanel.lastSelectedIndex, index);
const endIndex = Math.max(this.canvasLayersPanel.lastSelectedIndex, index);
newSelection = [];
for (let i = startIndex; i <= endIndex; i++) {
if (sortedLayers[i]) {
newSelection.push(sortedLayers[i]);
}
}
selectionChanged = true;
} else if (isCtrlPressed) {
const layerIndex = newSelection.indexOf(layer);
if (layerIndex === -1) {
newSelection.push(layer);
} else {
newSelection.splice(layerIndex, 1);
}
this.canvasLayersPanel.lastSelectedIndex = index;
selectionChanged = true;
} else {
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
// wyczyść zaznaczenie i zaznacz tylko ją.
if (!this.selectedLayers.includes(layer)) {
newSelection = [layer];
selectionChanged = true;
}
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
this.canvasLayersPanel.lastSelectedIndex = index;
}
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
if (selectionChanged) {
this.updateSelection(newSelection);
}
} }
/** /**
@@ -445,6 +367,87 @@ export class Canvas {
return this.canvasIO.importLatestImage(); return this.canvasIO.importLatestImage();
} }
_addAutoRefreshToggle() {
let autoRefreshEnabled = false;
let lastExecutionStartTime = 0;
const handleExecutionStart = () => {
if (autoRefreshEnabled) {
lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch
this.pendingBatchContext = {
// For the menu position
spawnPosition: {
x: this.width / 2,
y: this.height
},
// For the image placement
outputArea: {
x: 0,
y: 0,
width: this.width,
height: this.height
}
};
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
this.render(); // Trigger render to show the pending outline immediately
}
};
const handleExecutionSuccess = async () => {
if (autoRefreshEnabled) {
log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) {
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
return;
}
// Use the captured output area for image import
const newLayers = await this.canvasIO.importLatestImages(
lastExecutionStartTime,
this.pendingBatchContext.outputArea
);
if (newLayers && newLayers.length > 1) {
const newManager = new BatchPreviewManager(
this,
this.pendingBatchContext.spawnPosition,
this.pendingBatchContext.outputArea
);
this.batchPreviewManagers.push(newManager);
newManager.show(newLayers);
}
// Consume the context
this.pendingBatchContext = null;
// Final render to clear the outline if it was the last one
this.render();
}
};
this.node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value) => {
autoRefreshEnabled = value;
log.debug('Auto-refresh toggled:', value);
}, {
serialize: false
}
);
api.addEventListener('execution_start', handleExecutionStart);
api.addEventListener('execution_success', handleExecutionSuccess);
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
log.info('Node removed, cleaning up auto-refresh listeners.');
api.removeEventListener('execution_start', handleExecutionStart);
api.removeEventListener('execution_success', handleExecutionSuccess);
});
}
/** /**
* Uruchamia edytor masek * Uruchamia edytor masek
@@ -452,92 +455,7 @@ export class Canvas {
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
*/ */
async startMaskEditor(predefinedMask = null, sendCleanImage = true) { async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
log.info('Starting mask editor', { return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
hasPredefinedMask: !!predefinedMask,
sendCleanImage,
layersCount: this.layers.length
});
this.savedMaskState = await this.saveMaskState();
this.maskEditorCancelled = false;
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
try {
log.debug('Creating mask from current mask tool');
predefinedMask = await this.createMaskFromCurrentMask();
log.debug('Mask created from current mask tool successfully');
} catch (error) {
log.warn("Could not create mask from current mask:", error);
}
}
this.pendingMask = predefinedMask;
let blob;
if (sendCleanImage) {
log.debug('Getting flattened canvas as blob (clean image)');
blob = await this.canvasLayers.getFlattenedCanvasAsBlob();
} else {
log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvasLayers.getFlattenedCanvasForMaskEditor();
}
if (!blob) {
log.warn("Canvas is empty, cannot open mask editor.");
return;
}
log.debug('Canvas blob created successfully, size:', blob.size);
try {
const formData = new FormData();
const filename = `layerforge-mask-edit-${+new Date()}.png`;
formData.append("image", blob, filename);
formData.append("overwrite", "true");
formData.append("type", "temp");
log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload image: ${response.statusText}`);
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
});
this.node.imgs = [img];
log.info('Opening ComfyUI mask editor');
ComfyApp.copyToClipspace(this.node);
ComfyApp.clipspace_return_node = this.node;
ComfyApp.open_maskeditor();
this.editorWasShowing = false;
this.waitWhileMaskEditing();
this.setupCancelListener();
if (predefinedMask) {
log.debug('Will apply predefined mask when editor is ready');
this.waitForMaskEditorAndApplyMask();
}
} catch (error) {
log.error("Error preparing image for mask editor:", error);
alert(`Error: ${error.message}`);
}
} }
@@ -600,14 +518,7 @@ export class Canvas {
* Aktualizuje zaznaczenie po operacji historii * Aktualizuje zaznaczenie po operacji historii
*/ */
updateSelectionAfterHistory() { updateSelectionAfterHistory() {
const newSelectedLayers = []; return this.canvasSelection.updateSelectionAfterHistory();
if (this.selectedLayers) {
this.selectedLayers.forEach(sl => {
const found = this.layers.find(l => l.id === sl.id);
if (found) newSelectedLayers.push(found);
});
}
this.updateSelection(newSelectedLayers);
} }
/** /**
@@ -651,433 +562,4 @@ export class Canvas {
this.onStateChange(); this.onStateChange();
} }
} }
/**
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
*/
waitForMaskEditorAndApplyMask() {
let attempts = 0;
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
const checkEditor = () => {
attempts++;
if (mask_editor_showing(app)) {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
let editorReady = false;
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
try {
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
if (messageBroker) {
editorReady = true;
log.info("New mask editor detected as ready via MessageBroker");
}
} catch (e) {
editorReady = false;
}
}
if (!editorReady) {
const maskEditorElement = document.getElementById('maskEditor');
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
const canvas = maskEditorElement.querySelector('canvas');
if (canvas) {
editorReady = true;
log.info("New mask editor detected as ready via DOM element");
}
}
}
} else {
const maskCanvas = document.getElementById('maskCanvas');
editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0;
if (editorReady) {
log.info("Old mask editor detected as ready");
}
}
if (editorReady) {
log.info("Applying mask to editor after", attempts * 100, "ms wait");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 300);
} else if (attempts < maxAttempts) {
if (attempts % 10 === 0) {
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
}
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
log.info("Attempting to apply mask anyway...");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 100);
}
} else if (attempts < maxAttempts) {
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
this.pendingMask = null;
}
};
checkEditor();
}
/**
* Nakłada maskę na otwarty mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
*/
async applyMaskToEditor(maskData) {
try {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
await this.applyMaskToNewEditor(maskData);
} else {
log.warn("New editor setting enabled but instance not found, trying old editor");
await this.applyMaskToOldEditor(maskData);
}
} else {
await this.applyMaskToOldEditor(maskData);
}
log.info("Predefined mask applied to mask editor successfully");
} catch (error) {
log.error("Failed to apply predefined mask to editor:", error);
try {
log.info("Trying alternative mask application method...");
await this.applyMaskToOldEditor(maskData);
log.info("Alternative method succeeded");
} catch (fallbackError) {
log.error("Alternative method also failed:", fallbackError);
}
}
}
/**
* Nakłada maskę na nowy mask editor (przez MessageBroker)
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToNewEditor(maskData) {
const MaskEditorDialog = window.MaskEditorDialog;
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
throw new Error("New mask editor instance not found");
}
const editor = MaskEditorDialog.instance;
const messageBroker = editor.getMessageBroker();
const maskCanvas = await messageBroker.pull('maskCanvas');
const maskCtx = await messageBroker.pull('maskCtx');
const maskColor = await messageBroker.pull('getMaskColor');
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
messageBroker.publish('saveState');
}
/**
* Nakłada maskę na stary mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToOldEditor(maskData) {
const maskCanvas = document.getElementById('maskCanvas');
if (!maskCanvas) {
throw new Error("Old mask editor canvas not found");
}
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
const maskColor = {r: 255, g: 255, b: 255};
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
}
/**
* Przetwarza maskę do odpowiedniego formatu dla editora
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
* @param {number} targetWidth - Docelowa szerokość
* @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
// Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x;
const panY = this.maskTool.y;
log.info("Processing mask for editor:", {
sourceSize: {width: maskData.width, height: maskData.height},
targetSize: {width: targetWidth, height: targetHeight},
viewportPan: {x: panX, y: panY}
});
const tempCanvas = document.createElement('canvas');
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
const sourceX = -panX;
const sourceY = -panY;
tempCtx.drawImage(
maskData, // Źródło: pełna maska z "output area"
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
targetWidth, // dWidth: Szerokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu
);
log.info("Mask viewport cropped correctly.", {
source: "maskData",
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
});
// Reszta kodu (zmiana koloru) pozostaje bez zmian
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
log.info("Mask processing completed - color applied.");
return tempCanvas;
}
/**
* Tworzy obiekt Image z obecnej maski canvas
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
*/
async createMaskFromCurrentMask() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
throw new Error("No mask canvas available");
}
return new Promise((resolve, reject) => {
const maskImage = new Image();
maskImage.onload = () => resolve(maskImage);
maskImage.onerror = reject;
maskImage.src = this.maskTool.maskCanvas.toDataURL();
});
}
waitWhileMaskEditing() {
if (mask_editor_showing(app)) {
this.editorWasShowing = true;
}
if (!mask_editor_showing(app) && this.editorWasShowing) {
this.editorWasShowing = false;
setTimeout(() => this.handleMaskEditorClose(), 100);
} else {
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
}
}
/**
* Zapisuje obecny stan maski przed otwarciem editora
* @returns {Object} Zapisany stan maski
*/
async saveMaskState() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
return null;
}
const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas');
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
savedCtx.drawImage(maskCanvas, 0, 0);
return {
maskData: savedCanvas,
maskPosition: {
x: this.maskTool.x,
y: this.maskTool.y
}
};
}
/**
* Przywraca zapisany stan maski
* @param {Object} savedState - Zapisany stan maski
*/
async restoreMaskState(savedState) {
if (!savedState || !this.maskTool) {
return;
}
if (savedState.maskData) {
const maskCtx = this.maskTool.maskCtx;
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
maskCtx.drawImage(savedState.maskData, 0, 0);
}
if (savedState.maskPosition) {
this.maskTool.x = savedState.maskPosition.x;
this.maskTool.y = savedState.maskPosition.y;
}
this.render();
log.info("Mask state restored after cancel");
}
/**
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
*/
setupCancelListener() {
mask_editor_listen_for_cancel(app, () => {
log.info("Mask editor cancel button clicked");
this.maskEditorCancelled = true;
});
}
/**
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
*/
async handleMaskEditorClose() {
log.info("Handling mask editor close");
log.debug("Node object after mask editor close:", this.node);
if (this.maskEditorCancelled) {
log.info("Mask editor was cancelled - restoring original mask state");
if (this.savedMaskState) {
await this.restoreMaskState(this.savedMaskState);
}
this.maskEditorCancelled = false;
this.savedMaskState = null;
return;
}
if (!this.node.imgs || !this.node.imgs.length === 0 || !this.node.imgs[0].src) {
log.warn("Mask editor was closed without a result.");
return;
}
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
const resultImage = new Image();
resultImage.src = this.node.imgs[0].src;
try {
await new Promise((resolve, reject) => {
resultImage.onload = resolve;
resultImage.onerror = reject;
});
log.debug("Result image loaded successfully", {
width: resultImage.width,
height: resultImage.height
});
} catch (error) {
log.error("Failed to load image from mask editor.", error);
this.node.imgs = [];
return;
}
log.debug("Creating temporary canvas for mask processing");
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.width;
tempCanvas.height = this.height;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
tempCtx.drawImage(resultImage, 0, 0, this.width, this.height);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, this.width, this.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", {destX, destY});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.width, this.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.render();
this.saveState();
log.debug("Creating new preview image");
const new_preview = new Image();
const blob = await this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview];
log.debug("New preview image created successfully");
} else {
this.node.imgs = [];
log.warn("Failed to create preview blob");
}
this.render();
this.savedMaskState = null;
log.info("Mask editor result processed successfully");
}
} }

View File

@@ -744,12 +744,7 @@ export class CanvasIO {
img.src = result.image_data; img.src = result.image_data;
}); });
await this.canvas.canvasLayers.addLayerWithImage(img, { await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
x: 0,
y: 0,
width: this.canvas.width,
height: this.canvas.height,
});
log.info("Latest image imported and placed on canvas successfully."); log.info("Latest image imported and placed on canvas successfully.");
return true; return true;
} else { } else {
@@ -761,4 +756,41 @@ export class CanvasIO {
return false; return false;
} }
} }
async importLatestImages(sinceTimestamp, targetArea = null) {
try {
log.info(`Fetching latest images since ${sinceTimestamp}...`);
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
const result = await response.json();
if (result.success && result.images && result.images.length > 0) {
log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = [];
for (const imageData of result.images) {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageData;
});
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
newLayers.push(newLayer);
}
log.info("All new images imported and placed on canvas successfully.");
return newLayers;
} else if (result.success) {
log.info("No new images found since last generation.");
return [];
}
else {
throw new Error(result.error || "Failed to fetch latest images.");
}
} catch (error) {
log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`);
return [];
}
}
} }

View File

@@ -70,6 +70,12 @@ export class CanvasInteractions {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
this.canvas.render();
return;
}
// --- Ostateczna, poprawna kolejność sprawdzania --- // --- Ostateczna, poprawna kolejność sprawdzania ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
@@ -85,7 +91,7 @@ export class CanvasInteractions {
// 2. Inne przyciski myszy // 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy if (e.button === 2) { // Prawy przycisk myszy
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) { if (clickedLayerResult && this.canvas.canvasSelection.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault(); e.preventDefault();
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y); this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
} }
@@ -115,6 +121,7 @@ export class CanvasInteractions {
handleMouseMove(e) { handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie // Sprawdź, czy rozpocząć przeciąganie
@@ -124,13 +131,17 @@ export class CanvasInteractions {
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
this.interaction.mode = 'dragging'; this.interaction.mode = 'dragging';
this.originalLayerPositions.clear(); this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => { this.canvas.canvasSelection.selectedLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.originalLayerPositions.set(l, {x: l.x, y: l.y});
}); });
} }
} }
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
this.canvas.render();
break;
case 'panning': case 'panning':
this.panViewport(e); this.panViewport(e);
break; break;
@@ -156,6 +167,13 @@ export class CanvasInteractions {
} }
handleMouseUp(e) { handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(viewCoords);
this.canvas.render();
return;
}
if (this.interaction.mode === 'resizingCanvas') { if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize(); this.finalizeCanvasResize();
} }
@@ -226,7 +244,7 @@ export class CanvasInteractions {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
if (e.shiftKey) { if (e.shiftKey) {
// Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości // Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
if (e.ctrlKey) { if (e.ctrlKey) {
@@ -324,13 +342,10 @@ export class CanvasInteractions {
this.canvas.redo(); this.canvas.redo();
break; break;
case 'c': case 'c':
if (this.canvas.selectedLayers.length > 0) { if (this.canvas.canvasSelection.selectedLayers.length > 0) {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; break;
case 'v':
this.canvas.canvasLayers.handlePaste('mouse');
break;
default: default:
handled = false; handled = false;
break; break;
@@ -343,7 +358,7 @@ export class CanvasInteractions {
} }
// Skróty kontekstowe (zależne od zaznaczenia) // Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.selectedLayers.length > 0) { if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1; const step = e.shiftKey ? 10 : 1;
let needsRender = false; let needsRender = false;
@@ -354,12 +369,12 @@ export class CanvasInteractions {
e.stopPropagation(); e.stopPropagation();
this.interaction.keyMovementInProgress = true; this.interaction.keyMovementInProgress = true;
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step); if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step); if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x += step);
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step); if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step); if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y += step);
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step); if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step); if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation += step);
needsRender = true; needsRender = true;
} }
@@ -367,7 +382,7 @@ export class CanvasInteractions {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.removeSelectedLayers(); this.canvas.canvasSelection.removeSelectedLayers();
return; return;
} }
@@ -435,16 +450,16 @@ export class CanvasInteractions {
prepareForDrag(layer, worldCoords) { prepareForDrag(layer, worldCoords) {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu // Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const index = this.canvas.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
this.canvas.updateSelection([...this.canvas.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else { } else {
const newSelection = this.canvas.selectedLayers.filter(l => l !== layer); const newSelection = this.canvas.canvasSelection.selectedLayers.filter(l => l !== layer);
this.canvas.updateSelection(newSelection); this.canvas.canvasSelection.updateSelection(newSelection);
} }
} else { } else {
if (!this.canvas.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
} }
} }
@@ -456,7 +471,7 @@ export class CanvasInteractions {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów. // Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie. // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
if (!this.interaction.isCtrlPressed) { if (!this.interaction.isCtrlPressed) {
this.canvas.updateSelection([]); this.canvas.canvasSelection.updateSelection([]);
} }
this.interaction.mode = 'panning'; this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = {x: e.clientX, y: e.clientY};
@@ -514,6 +529,29 @@ export class CanvasInteractions {
this.canvas.maskTool.updatePosition(-finalX, -finalY); this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX; this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
@@ -523,7 +561,7 @@ export class CanvasInteractions {
startPanning(e) { startPanning(e) {
if (!this.interaction.isCtrlPressed) { if (!this.interaction.isCtrlPressed) {
this.canvas.updateSelection([]); this.canvas.canvasSelection.updateSelection([]);
} }
this.interaction.mode = 'panning'; this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = {x: e.clientX, y: e.clientY};
@@ -539,9 +577,9 @@ export class CanvasInteractions {
} }
dragLayers(worldCoords) { dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
// Scentralizowana logika duplikowania // Scentralizowana logika duplikowania
const newLayers = this.canvas.duplicateSelectedLayers(); const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers();
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
this.originalLayerPositions.clear(); this.originalLayerPositions.clear();
@@ -554,11 +592,11 @@ export class CanvasInteractions {
const totalDy = worldCoords.y - this.interaction.dragStart.y; const totalDy = worldCoords.y - this.interaction.dragStart.y;
let finalDx = totalDx, finalDy = totalDy; let finalDx = totalDx, finalDy = totalDy;
if (this.interaction.isCtrlPressed && this.canvas.selectedLayer) { if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayer) {
const originalPos = this.originalLayerPositions.get(this.canvas.selectedLayer); const originalPos = this.originalLayerPositions.get(this.canvas.canvasSelection.selectedLayer);
if (originalPos) { if (originalPos) {
const tempLayerForSnap = { const tempLayerForSnap = {
...this.canvas.selectedLayer, ...this.canvas.canvasSelection.selectedLayer,
x: originalPos.x + totalDx, x: originalPos.x + totalDx,
y: originalPos.y + totalDy y: originalPos.y + totalDy
}; };
@@ -568,7 +606,7 @@ export class CanvasInteractions {
} }
} }
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
const originalPos = this.originalLayerPositions.get(layer); const originalPos = this.originalLayerPositions.get(layer);
if (originalPos) { if (originalPos) {
layer.x = originalPos.x + finalDx; layer.x = originalPos.x + finalDx;
@@ -683,20 +721,43 @@ export class CanvasInteractions {
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) { if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
const newWidth = Math.round(this.interaction.canvasResizeRect.width); const newWidth = Math.round(this.interaction.canvasResizeRect.width);
const newHeight = Math.round(this.interaction.canvasResizeRect.height); const newHeight = Math.round(this.interaction.canvasResizeRect.height);
const rectX = this.interaction.canvasResizeRect.x; const finalX = this.interaction.canvasResizeRect.x;
const rectY = this.interaction.canvasResizeRect.y; const finalY = this.interaction.canvasResizeRect.y;
this.canvas.updateOutputAreaSize(newWidth, newHeight); this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach(layer => { this.canvas.layers.forEach(layer => {
layer.x -= rectX; layer.x -= finalX;
layer.y -= rectY; layer.y -= finalY;
}); });
this.canvas.maskTool.updatePosition(-rectX, -rectY); this.canvas.maskTool.updatePosition(-finalX, -finalY);
this.canvas.viewport.x -= rectX; // If a batch generation is in progress, update the captured context as well
this.canvas.viewport.y -= rectY; if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
} }
} }

View File

@@ -33,9 +33,9 @@ export class CanvasLayers {
} }
async copySelectedLayers() { async copySelectedLayers() {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer})); this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map(layer => ({...layer}));
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
const blob = await this.getFlattenedSelectionAsBlob(); const blob = await this.getFlattenedSelectionAsBlob();
@@ -149,12 +149,12 @@ export class CanvasLayers {
} }
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => { addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
if (!image) { if (!image) {
throw createValidationError("Image is required for layer creation"); throw createValidationError("Image is required for layer creation");
} }
log.debug("Adding layer with image:", image, "with mode:", addMode); log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea);
const imageId = generateUUID(); const imageId = generateUUID();
await saveImage(imageId, image.src); await saveImage(imageId, image.src);
this.canvas.imageCache.set(imageId, image.src); this.canvas.imageCache.set(imageId, image.src);
@@ -163,21 +163,25 @@ export class CanvasLayers {
let finalHeight = image.height; let finalHeight = image.height;
let finalX, finalY; let finalX, finalY;
// Use the targetArea if provided, otherwise default to the current canvas dimensions
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
if (addMode === 'fit') { if (addMode === 'fit') {
const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height); const scale = Math.min(area.width / image.width, area.height / image.height);
finalWidth = image.width * scale; finalWidth = image.width * scale;
finalHeight = image.height * scale; finalHeight = image.height * scale;
finalX = (this.canvas.width - finalWidth) / 2; finalX = area.x + (area.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2; finalY = area.y + (area.height - finalHeight) / 2;
} else if (addMode === 'mouse') { } else if (addMode === 'mouse') {
finalX = this.canvas.lastMousePosition.x - finalWidth / 2; finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvas.lastMousePosition.y - finalHeight / 2; finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
} else { // 'center' or 'default' } else { // 'center' or 'default'
finalX = (this.canvas.width - finalWidth) / 2; finalX = area.x + (area.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2; finalY = area.y + (area.height - finalHeight) / 2;
} }
const layer = { const layer = {
id: generateUUID(),
image: image, image: image,
imageId: imageId, imageId: imageId,
x: finalX, x: finalX,
@@ -291,13 +295,13 @@ export class CanvasLayers {
} }
moveLayerUp() { moveLayerUp() {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.moveLayers(this.canvas.selectedLayers, { direction: 'up' }); this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' });
} }
moveLayerDown() { moveLayerDown() {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.moveLayers(this.canvas.selectedLayers, { direction: 'down' }); this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' });
} }
/** /**
@@ -305,9 +309,9 @@ export class CanvasLayers {
* @param {number} scale - Skala zmiany rozmiaru * @param {number} scale - Skala zmiany rozmiaru
*/ */
resizeLayer(scale) { resizeLayer(scale) {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
layer.width *= scale; layer.width *= scale;
layer.height *= scale; layer.height *= scale;
}); });
@@ -320,9 +324,9 @@ export class CanvasLayers {
* @param {number} angle - Kąt obrotu w stopniach * @param {number} angle - Kąt obrotu w stopniach
*/ */
rotateLayer(angle) { rotateLayer(angle) {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
layer.rotation += angle; layer.rotation += angle;
}); });
this.canvas.render(); this.canvas.render();
@@ -358,9 +362,9 @@ export class CanvasLayers {
} }
async mirrorHorizontal() { async mirrorHorizontal() {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
const promises = this.canvas.selectedLayers.map(layer => { const promises = this.canvas.canvasSelection.selectedLayers.map(layer => {
return new Promise(resolve => { return new Promise(resolve => {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
@@ -386,9 +390,9 @@ export class CanvasLayers {
} }
async mirrorVertical() { async mirrorVertical() {
if (this.canvas.selectedLayers.length === 0) return; if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
const promises = this.canvas.selectedLayers.map(layer => { const promises = this.canvas.canvasSelection.selectedLayers.map(layer => {
return new Promise(resolve => { return new Promise(resolve => {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
@@ -500,11 +504,11 @@ export class CanvasLayers {
} }
getHandleAtPosition(worldX, worldY) { getHandleAtPosition(worldX, worldY) {
if (this.canvas.selectedLayers.length === 0) return null; if (this.canvas.canvasSelection.selectedLayers.length === 0) return null;
const handleRadius = 8 / this.canvas.viewport.zoom; const handleRadius = 8 / this.canvas.viewport.zoom;
for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) { for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvas.selectedLayers[i]; const layer = this.canvas.canvasSelection.selectedLayers[i];
const handles = this.getHandles(layer); const handles = this.getHandles(layer);
for (const key in handles) { for (const key in handles) {
@@ -959,13 +963,13 @@ export class CanvasLayers {
} }
async getFlattenedSelectionAsBlob() { async getFlattenedSelectionAsBlob() {
if (this.canvas.selectedLayers.length === 0) { if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null; return null;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
const centerX = layer.x + layer.width / 2; const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2; const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
@@ -1007,7 +1011,7 @@ export class CanvasLayers {
tempCtx.translate(-minX, -minY); tempCtx.translate(-minX, -minY);
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedSelection.forEach(layer => { sortedSelection.forEach(layer => {
if (!layer.image) return; if (!layer.image) return;
@@ -1037,12 +1041,12 @@ export class CanvasLayers {
* Fuses (flattens and merges) selected layers into a single layer * Fuses (flattens and merges) selected layers into a single layer
*/ */
async fuseLayers() { async fuseLayers() {
if (this.canvas.selectedLayers.length < 2) { if (this.canvas.canvasSelection.selectedLayers.length < 2) {
alert("Please select at least 2 layers to fuse."); alert("Please select at least 2 layers to fuse.");
return; return;
} }
log.info(`Fusing ${this.canvas.selectedLayers.length} selected layers`); log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
try { try {
// Save state for undo // Save state for undo
@@ -1050,7 +1054,7 @@ export class CanvasLayers {
// Calculate bounding box of all selected layers // Calculate bounding box of all selected layers
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
const centerX = layer.x + layer.width / 2; const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2; const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
@@ -1097,7 +1101,7 @@ export class CanvasLayers {
tempCtx.translate(-minX, -minY); tempCtx.translate(-minX, -minY);
// Sort selected layers by z-index and render them // Sort selected layers by z-index and render them
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedSelection.forEach(layer => { sortedSelection.forEach(layer => {
if (!layer.image) return; if (!layer.image) return;
@@ -1127,7 +1131,7 @@ export class CanvasLayers {
}); });
// Find the lowest z-index among selected layers to maintain visual order // Find the lowest z-index among selected layers to maintain visual order
const minZIndex = Math.min(...this.canvas.selectedLayers.map(layer => layer.zIndex)); const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map(layer => layer.zIndex));
// Generate unique ID for the new fused layer // Generate unique ID for the new fused layer
const imageId = generateUUID(); const imageId = generateUUID();
@@ -1151,7 +1155,7 @@ export class CanvasLayers {
}; };
// Remove selected layers from canvas // Remove selected layers from canvas
this.canvas.layers = this.canvas.layers.filter(layer => !this.canvas.selectedLayers.includes(layer)); this.canvas.layers = this.canvas.layers.filter(layer => !this.canvas.canvasSelection.selectedLayers.includes(layer));
// Insert the fused layer at the correct position // Insert the fused layer at the correct position
this.canvas.layers.push(fusedLayer); this.canvas.layers.push(fusedLayer);

View File

@@ -306,7 +306,7 @@ export class CanvasLayersPanel {
layerRow.dataset.layerIndex = index; layerRow.dataset.layerIndex = index;
// Sprawdź czy warstwa jest zaznaczona // Sprawdź czy warstwa jest zaznaczona
const isSelected = this.canvas.selectedLayers.includes(layer); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
if (isSelected) { if (isSelected) {
layerRow.classList.add('selected'); layerRow.classList.add('selected');
} }
@@ -407,7 +407,7 @@ export class CanvasLayersPanel {
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
@@ -492,12 +492,12 @@ export class CanvasLayersPanel {
* Usuwa zaznaczone warstwy * Usuwa zaznaczone warstwy
*/ */
deleteSelectedLayers() { deleteSelectedLayers() {
if (this.canvas.selectedLayers.length === 0) { if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion'); log.debug('No layers selected for deletion');
return; return;
} }
log.info(`Deleting ${this.canvas.selectedLayers.length} selected layers`); log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers(); this.canvas.removeSelectedLayers();
this.renderLayers(); this.renderLayers();
} }
@@ -514,12 +514,12 @@ export class CanvasLayersPanel {
} }
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
if (!this.canvas.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]); this.canvas.updateSelection([layer]);
this.renderLayers(); this.renderLayers();
} }
this.draggedElements = [...this.canvas.selectedLayers]; this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard
@@ -635,7 +635,7 @@ export class CanvasLayersPanel {
layerRows.forEach((row, index) => { layerRows.forEach((row, index) => {
const layer = sortedLayers[index]; const layer = sortedLayers[index];
if (this.canvas.selectedLayers.includes(layer)) { if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
row.classList.add('selected'); row.classList.add('selected');
} else { } else {
row.classList.remove('selected'); row.classList.remove('selected');

542
js/CanvasMask.js Normal file
View File

@@ -0,0 +1,542 @@
import { app, ComfyApp } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
const log = createModuleLogger('CanvasMask');
export class CanvasMask {
constructor(canvas) {
this.canvas = canvas;
this.node = canvas.node;
this.maskTool = canvas.maskTool;
this.savedMaskState = null;
this.maskEditorCancelled = false;
this.pendingMask = null;
this.editorWasShowing = false;
}
/**
* Uruchamia edytor masek
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
*/
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
log.info('Starting mask editor', {
hasPredefinedMask: !!predefinedMask,
sendCleanImage,
layersCount: this.canvas.layers.length
});
this.savedMaskState = await this.saveMaskState();
this.maskEditorCancelled = false;
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
try {
log.debug('Creating mask from current mask tool');
predefinedMask = await this.createMaskFromCurrentMask();
log.debug('Mask created from current mask tool successfully');
} catch (error) {
log.warn("Could not create mask from current mask:", error);
}
}
this.pendingMask = predefinedMask;
let blob;
if (sendCleanImage) {
log.debug('Getting flattened canvas as blob (clean image)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else {
log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
}
if (!blob) {
log.warn("Canvas is empty, cannot open mask editor.");
return;
}
log.debug('Canvas blob created successfully, size:', blob.size);
try {
const formData = new FormData();
const filename = `layerforge-mask-edit-${+new Date()}.png`;
formData.append("image", blob, filename);
formData.append("overwrite", "true");
formData.append("type", "temp");
log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload image: ${response.statusText}`);
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
});
this.node.imgs = [img];
log.info('Opening ComfyUI mask editor');
ComfyApp.copyToClipspace(this.node);
ComfyApp.clipspace_return_node = this.node;
ComfyApp.open_maskeditor();
this.editorWasShowing = false;
this.waitWhileMaskEditing();
this.setupCancelListener();
if (predefinedMask) {
log.debug('Will apply predefined mask when editor is ready');
this.waitForMaskEditorAndApplyMask();
}
} catch (error) {
log.error("Error preparing image for mask editor:", error);
alert(`Error: ${error.message}`);
}
}
/**
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
*/
waitForMaskEditorAndApplyMask() {
let attempts = 0;
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
const checkEditor = () => {
attempts++;
if (mask_editor_showing(app)) {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
let editorReady = false;
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
try {
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
if (messageBroker) {
editorReady = true;
log.info("New mask editor detected as ready via MessageBroker");
}
} catch (e) {
editorReady = false;
}
}
if (!editorReady) {
const maskEditorElement = document.getElementById('maskEditor');
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
const canvas = maskEditorElement.querySelector('canvas');
if (canvas) {
editorReady = true;
log.info("New mask editor detected as ready via DOM element");
}
}
}
} else {
const maskCanvas = document.getElementById('maskCanvas');
editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0;
if (editorReady) {
log.info("Old mask editor detected as ready");
}
}
if (editorReady) {
log.info("Applying mask to editor after", attempts * 100, "ms wait");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 300);
} else if (attempts < maxAttempts) {
if (attempts % 10 === 0) {
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
}
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
log.info("Attempting to apply mask anyway...");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 100);
}
} else if (attempts < maxAttempts) {
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
this.pendingMask = null;
}
};
checkEditor();
}
/**
* Nakłada maskę na otwarty mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
*/
async applyMaskToEditor(maskData) {
try {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
await this.applyMaskToNewEditor(maskData);
} else {
log.warn("New editor setting enabled but instance not found, trying old editor");
await this.applyMaskToOldEditor(maskData);
}
} else {
await this.applyMaskToOldEditor(maskData);
}
log.info("Predefined mask applied to mask editor successfully");
} catch (error) {
log.error("Failed to apply predefined mask to editor:", error);
try {
log.info("Trying alternative mask application method...");
await this.applyMaskToOldEditor(maskData);
log.info("Alternative method succeeded");
} catch (fallbackError) {
log.error("Alternative method also failed:", fallbackError);
}
}
}
/**
* Nakłada maskę na nowy mask editor (przez MessageBroker)
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToNewEditor(maskData) {
const MaskEditorDialog = window.MaskEditorDialog;
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
throw new Error("New mask editor instance not found");
}
const editor = MaskEditorDialog.instance;
const messageBroker = editor.getMessageBroker();
const maskCanvas = await messageBroker.pull('maskCanvas');
const maskCtx = await messageBroker.pull('maskCtx');
const maskColor = await messageBroker.pull('getMaskColor');
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
messageBroker.publish('saveState');
}
/**
* Nakłada maskę na stary mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToOldEditor(maskData) {
const maskCanvas = document.getElementById('maskCanvas');
if (!maskCanvas) {
throw new Error("Old mask editor canvas not found");
}
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
const maskColor = {r: 255, g: 255, b: 255};
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
}
/**
* Przetwarza maskę do odpowiedniego formatu dla editora
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
* @param {number} targetWidth - Docelowa szerokość
* @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
// Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x;
const panY = this.maskTool.y;
log.info("Processing mask for editor:", {
sourceSize: {width: maskData.width, height: maskData.height},
targetSize: {width: targetWidth, height: targetHeight},
viewportPan: {x: panX, y: panY}
});
const tempCanvas = document.createElement('canvas');
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
const sourceX = -panX;
const sourceY = -panY;
tempCtx.drawImage(
maskData, // Źródło: pełna maska z "output area"
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
targetWidth, // dWidth: Szerokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu
);
log.info("Mask viewport cropped correctly.", {
source: "maskData",
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
});
// Reszta kodu (zmiana koloru) pozostaje bez zmian
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
log.info("Mask processing completed - color applied.");
return tempCanvas;
}
/**
* Tworzy obiekt Image z obecnej maski canvas
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
*/
async createMaskFromCurrentMask() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
throw new Error("No mask canvas available");
}
return new Promise((resolve, reject) => {
const maskImage = new Image();
maskImage.onload = () => resolve(maskImage);
maskImage.onerror = reject;
maskImage.src = this.maskTool.maskCanvas.toDataURL();
});
}
waitWhileMaskEditing() {
if (mask_editor_showing(app)) {
this.editorWasShowing = true;
}
if (!mask_editor_showing(app) && this.editorWasShowing) {
this.editorWasShowing = false;
setTimeout(() => this.handleMaskEditorClose(), 100);
} else {
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
}
}
/**
* Zapisuje obecny stan maski przed otwarciem editora
* @returns {Object} Zapisany stan maski
*/
async saveMaskState() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
return null;
}
const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas');
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
savedCtx.drawImage(maskCanvas, 0, 0);
return {
maskData: savedCanvas,
maskPosition: {
x: this.maskTool.x,
y: this.maskTool.y
}
};
}
/**
* Przywraca zapisany stan maski
* @param {Object} savedState - Zapisany stan maski
*/
async restoreMaskState(savedState) {
if (!savedState || !this.maskTool) {
return;
}
if (savedState.maskData) {
const maskCtx = this.maskTool.maskCtx;
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
maskCtx.drawImage(savedState.maskData, 0, 0);
}
if (savedState.maskPosition) {
this.maskTool.x = savedState.maskPosition.x;
this.maskTool.y = savedState.maskPosition.y;
}
this.canvas.render();
log.info("Mask state restored after cancel");
}
/**
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
*/
setupCancelListener() {
mask_editor_listen_for_cancel(app, () => {
log.info("Mask editor cancel button clicked");
this.maskEditorCancelled = true;
});
}
/**
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
*/
async handleMaskEditorClose() {
log.info("Handling mask editor close");
log.debug("Node object after mask editor close:", this.node);
if (this.maskEditorCancelled) {
log.info("Mask editor was cancelled - restoring original mask state");
if (this.savedMaskState) {
await this.restoreMaskState(this.savedMaskState);
}
this.maskEditorCancelled = false;
this.savedMaskState = null;
return;
}
if (!this.node.imgs || !this.node.imgs.length === 0 || !this.node.imgs[0].src) {
log.warn("Mask editor was closed without a result.");
return;
}
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
const resultImage = new Image();
resultImage.src = this.node.imgs[0].src;
try {
await new Promise((resolve, reject) => {
resultImage.onload = resolve;
resultImage.onerror = reject;
});
log.debug("Result image loaded successfully", {
width: resultImage.width,
height: resultImage.height
});
} catch (error) {
log.error("Failed to load image from mask editor.", error);
this.node.imgs = [];
return;
}
log.debug("Creating temporary canvas for mask processing");
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", {destX, destY});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.canvas.render();
this.canvas.saveState();
log.debug("Creating new preview image");
const new_preview = new Image();
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview];
log.debug("New preview image created successfully");
} else {
this.node.imgs = [];
log.warn("Failed to create preview blob");
}
this.canvas.render();
this.savedMaskState = null;
log.info("Mask editor result processed successfully");
}
}

View File

@@ -75,15 +75,16 @@ export class CanvasRenderer {
); );
if (layer.mask) { if (layer.mask) {
} }
if (this.canvas.selectedLayers.includes(layer)) { if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.drawSelectionFrame(ctx, layer); this.drawSelectionFrame(ctx, layer);
} }
ctx.restore(); ctx.restore();
}); });
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask(); const maskImage = this.canvas.maskTool.getMask();
if (maskImage) { if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save(); ctx.save();
@@ -112,6 +113,13 @@ export class CanvasRenderer {
this.canvas.canvas.height = this.canvas.offscreenCanvas.height; this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
} }
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.updateScreenPosition(this.canvas.viewport);
});
}
} }
renderInteractionElements(ctx) { renderInteractionElements(ctx) {
@@ -182,8 +190,8 @@ export class CanvasRenderer {
} }
renderLayerInfo(ctx) { renderLayerInfo(ctx) {
if (this.canvas.selectedLayer) { if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach(layer => {
if (!layer.image) return; if (!layer.image) return;
const layerIndex = this.canvas.layers.indexOf(layer); const layerIndex = this.canvas.layers.indexOf(layer);
@@ -321,4 +329,36 @@ export class CanvasRenderer {
ctx.stroke(); ctx.stroke();
} }
} }
drawPendingGenerationAreas(ctx) {
const areasToDraw = [];
// 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
if (manager.generationArea) {
areasToDraw.push(manager.generationArea);
}
});
}
// 2. Get the area from the pending context (if it exists)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
}
if (areasToDraw.length === 0) {
return;
}
// 3. Draw all collected areas
areasToDraw.forEach(area => {
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(area.x, area.y, area.width, area.height);
ctx.restore();
});
}
} }

166
js/CanvasSelection.js Normal file
View File

@@ -0,0 +1,166 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasSelection');
export class CanvasSelection {
constructor(canvas) {
this.canvas = canvas;
this.selectedLayers = [];
this.selectedLayer = null;
this.onSelectionChange = null;
}
/**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/
duplicateSelectedLayers() {
if (this.selectedLayers.length === 0) return [];
const newLayers = [];
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
const newLayer = {
...layer,
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
this.updateSelection(newLayers);
// Powiadom panel o zmianie struktury, aby się przerysował
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
return newLayers;
}
/**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
*/
updateSelection(newSelection) {
const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || [];
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length ||
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
if (!hasChanged && previousSelection > 0) {
// return; // Zablokowane na razie, może powodować problemy
}
log.debug('Selection updated', {
previousCount: previousSelection,
newCount: this.selectedLayers.length,
selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown')
});
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
this.canvas.render();
// 2. Powiadom inne części aplikacji (jeśli są)
if (this.onSelectionChange) {
this.onSelectionChange();
}
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onSelectionChanged();
}
}
/**
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
*/
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
let newSelection = [...this.selectedLayers];
let selectionChanged = false;
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
newSelection = [];
for (let i = startIndex; i <= endIndex; i++) {
if (sortedLayers[i]) {
newSelection.push(sortedLayers[i]);
}
}
selectionChanged = true;
} else if (isCtrlPressed) {
const layerIndex = newSelection.indexOf(layer);
if (layerIndex === -1) {
newSelection.push(layer);
} else {
newSelection.splice(layerIndex, 1);
}
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
selectionChanged = true;
} else {
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
// wyczyść zaznaczenie i zaznacz tylko ją.
if (!this.selectedLayers.includes(layer)) {
newSelection = [layer];
selectionChanged = true;
}
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
}
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
if (selectionChanged) {
this.updateSelection(newSelection);
}
}
removeSelectedLayers() {
if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length,
totalLayers: this.canvas.layers.length
});
this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.selectedLayers.includes(l));
this.updateSelection([]);
this.canvas.render();
this.canvas.saveState();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
} else {
log.debug('No layers selected for removal');
}
}
/**
* Aktualizuje zaznaczenie po operacji historii
*/
updateSelectionAfterHistory() {
const newSelectedLayers = [];
if (this.selectedLayers) {
this.selectedLayers.forEach(sl => {
const found = this.canvas.layers.find(l => l.id === sl.id);
if (found) newSelectedLayers.push(found);
});
}
this.updateSelection(newSelectedLayers);
}
}

View File

@@ -227,6 +227,7 @@ export class CanvasState {
nodeId: this.canvas.node.id, nodeId: this.canvas.node.id,
state: state state: state
}); });
this.canvas.render();
} else { } else {
log.warn("State saver worker not available. Saving on main thread."); log.warn("State saver worker not available. Saving on main thread.");
await setCanvasState(this.canvas.node.id, state); await setCanvasState(this.canvas.node.id, state);

View File

@@ -815,9 +815,9 @@ async function createCanvasWidget(node, widget, app) {
button.classList.add('loading'); button.classList.add('loading');
try { try {
if (canvas.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting."); if (canvas.canvasSelection.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting.");
const selectedLayer = canvas.selectedLayers[0]; const selectedLayer = canvas.canvasSelection.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer); const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
const response = await fetch("/matting", { const response = await fetch("/matting", {
@@ -841,7 +841,7 @@ async function createCanvasWidget(node, widget, app) {
const newLayer = {...selectedLayer, image: mattedImage}; const newLayer = {...selectedLayer, image: mattedImage};
delete newLayer.imageId; delete newLayer.imageId;
canvas.layers[selectedLayerIndex] = newLayer; canvas.layers[selectedLayerIndex] = newLayer;
canvas.updateSelection([newLayer]); canvas.canvasSelection.updateSelection([newLayer]);
canvas.render(); canvas.render();
canvas.saveState(); canvas.saveState();
} catch (error) { } catch (error) {
@@ -870,6 +870,24 @@ async function createCanvasWidget(node, widget, app) {
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
$el("div.painter-button-group", {id: "mask-controls"}, [ $el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button.primary", {
id: `toggle-mask-btn-${node.id}`,
textContent: "Show Mask",
title: "Toggle mask overlay visibility",
onclick: (e) => {
const button = e.target;
canvas.maskTool.toggleOverlayVisibility();
canvas.render();
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
button.textContent = "Show Mask";
} else {
button.classList.remove('primary');
button.textContent = "Hide Mask";
}
}
}),
$el("button.painter-button", { $el("button.painter-button", {
textContent: "Edit Mask", textContent: "Edit Mask",
title: "Open the current canvas view in the mask editor", title: "Open the current canvas view in the mask editor",
@@ -992,7 +1010,7 @@ async function createCanvasWidget(node, widget, app) {
const updateButtonStates = () => { const updateButtonStates = () => {
const selectionCount = canvas.selectedLayers.length; const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0; const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach(btn => { controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
// Special handling for Fuse button - requires at least 2 layers // Special handling for Fuse button - requires at least 2 layers
@@ -1008,7 +1026,7 @@ async function createCanvasWidget(node, widget, app) {
} }
}; };
canvas.onSelectionChange = updateButtonStates; canvas.canvasSelection.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`); const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`); const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);

View File

@@ -13,6 +13,7 @@ export class MaskTool {
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
this.isOverlayVisible = true;
this.isActive = false; this.isActive = false;
this.brushSize = 20; this.brushSize = 20;
this.brushStrength = 0.5; this.brushStrength = 0.5;
@@ -280,6 +281,11 @@ export class MaskTool {
log.info(`Mask position updated to (${this.x}, ${this.y})`); log.info(`Mask position updated to (${this.x}, ${this.y})`);
} }
toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image) { setMask(image) {

3
js/config.js Normal file
View File

@@ -0,0 +1,3 @@
// Log level for development.
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
export const LOG_LEVEL = 'NONE';

View File

@@ -4,6 +4,7 @@
*/ */
import {logger, LogLevel} from "../logger.js"; import {logger, LogLevel} from "../logger.js";
import { LOG_LEVEL } from '../config.js';
/** /**
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
@@ -11,8 +12,8 @@ import {logger, LogLevel} from "../logger.js";
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) * @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
* @returns {Object} Obiekt z metodami logowania * @returns {Object} Obiekt z metodami logowania
*/ */
export function createModuleLogger(moduleName, level = LogLevel.NONE) { export function createModuleLogger(moduleName) {
logger.setModuleLevel(moduleName, level); logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
return { return {
debug: (...args) => logger.debug(moduleName, ...args), debug: (...args) => logger.debug(moduleName, ...args),

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "layerforge" name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing." description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.3.3.1" version = "1.3.6"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

3
python/config.py Normal file
View File

@@ -0,0 +1,3 @@
# Log level for development.
# Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
LOG_LEVEL = 'NONE'