19 Commits

Author SHA1 Message Date
Dariusz L
2f730c87fa Change default log level and bump version
Set the default log level to NONE in createModuleLogger to reduce logging output. Incremented the project version to 1.3.3.1 in pyproject.toml.
2025-07-02 11:18:47 +02:00
Dariusz L
aca1f4e422 Update pyproject.toml 2025-07-02 10:37:39 +02:00
Dariusz L
195e25437a Improve matting error handling and user feedback
Adds checks for missing 'transformers' dependency and network errors in the matting endpoint, returning clear error messages for common failure cases. Updates the frontend to display more informative alerts to users when matting fails, including details from server responses.
2025-07-02 10:36:22 +02:00
Dariusz L
d1004d5864 Notify layers panel on layer changes
Added calls to canvasLayersPanel.onLayersChanged() after pasting, adding, and fusing layers to ensure the layers panel updates its view in response to these actions.
2025-07-02 10:16:59 +02:00
Dariusz L
d2ccfc4e20 Update undo/redo button handlers in CanvasView
Changed the undo and redo button onclick handlers to call canvas.undo() and canvas.redo() instead of canvas.canvasState.undo() and canvas.canvasState.redo(). This aligns the button actions with the updated canvas API.
2025-07-02 10:08:32 +02:00
Dariusz L
2c313f43e8 Enable keyboard delete in layers panel
Added keyboard event listener to allow deleting selected layers using the Delete or Backspace keys when the layers panel is focused. This improves accessibility and user experience.
2025-07-02 10:05:54 +02:00
Dariusz L
2636521026 Remove keyboard event handling from layers panel
Eliminated tabindex and keydown event listener from the CanvasLayersPanel container. Keyboard interactions are no longer handled directly in this panel.
2025-07-02 10:00:30 +02:00
Dariusz L
e0a4549321 Refactor tooltip positioning logic in CanvasView
Extracted tooltip positioning code into reusable showTooltip and hideTooltip helper functions. Updated event handlers to use these helpers, reducing code duplication and improving maintainability.
2025-07-02 09:53:35 +02:00
Dariusz L
29ab916759 Refactor layers panel UI and improve resize handling
Changed UI text in CanvasLayersPanel from Polish to English and removed the add layer button and its logic. Moved and improved the ResizeObserver logic in CanvasView.js to update both the canvas and layers panel positions dynamically based on the controls' height.
2025-07-02 09:40:21 +02:00
Dariusz L
ac21aa9579 Enable keyboard interaction in layers panel
Added tabIndex to the panel container to allow keyboard focus and attached a keydown event listener to forward keyboard events to the main interaction handler. This improves accessibility and enables keyboard-based layer deletion from the panel.
2025-07-02 09:33:13 +02:00
Dariusz L
cae24310db Adjust canvas and panel layout widths
Reduced the right margin of the canvas from 320px to 270px and the panel width from 300px to 250px to optimize space allocation in the UI.
2025-07-02 09:25:33 +02:00
Dariusz L
7d8fd30bbf Add snap-to-5° rotation with Shift+Ctrl+Mouse Wheel
Introduces a new shortcut (Shift + Ctrl + Mouse Wheel) to snap layer rotation to the nearest 5° increment. Updates the CanvasView help table to document this new functionality and clarifies the description for existing rotation shortcuts.
2025-07-02 09:21:47 +02:00
Dariusz L
244d48728c Refactor canvas interactions and update keyboard shortcuts
Reorganizes mouse and keyboard event handling in CanvasInteractions for clearer priority and improved usability. Adds global keyboard shortcuts for undo, redo, copy, and paste. Updates context-sensitive shortcuts to support both Delete and Backspace for layer removal. Refactors CanvasLayers to use delayed state saving via requestSaveState. Updates CanvasView shortcut documentation to reflect these changes and clarify mouse/keyboard actions.
2025-07-02 09:16:16 +02:00
Dariusz L
ef01be3323 Add canvas move and resize interactions
Implemented new interaction modes for moving and resizing the canvas using shift and alt modifiers. Added corresponding handlers for mouse events to support these actions.
2025-07-02 08:48:41 +02:00
Dariusz L
b3d1206f3f Refactor layer selection and movement logic
Centralizes layer movement logic in CanvasLayers with a new moveLayers function, supporting both up/down and drag-and-drop reordering. Updates selection logic in Canvas to only trigger updates when selection changes, and improves event handling in CanvasLayersPanel for more responsive selection and drag operations. Removes redundant moveLayersToPosition method in favor of the new unified approach.
2025-07-02 08:41:18 +02:00
Dariusz L
a73a3dcf96 Add layers panel UI and improve layer management
Introduces a new CanvasLayersPanel component for managing layers visually, including selection, renaming, reordering via drag-and-drop, and deletion. Integrates the panel into the main Canvas and CanvasView, synchronizes selection and state changes, and adds logic for duplicating layers and debounced state saving. Moves IndexedDB state saving to a Web Worker for better performance. Also sets default logger level to DEBUG for improved diagnostics.
2025-07-02 08:09:49 +02:00
Dariusz L
53aa35491e Add layer fusion (flatten/merge) feature
Introduces a new 'Fuse' button to the canvas UI, allowing users to flatten and merge multiple selected layers into a single layer. The implementation handles bounding box calculation, z-index ordering, and updates the canvas state and selection accordingly. The fuse button is enabled only when at least two layers are selected.
2025-07-02 00:42:38 +02:00
Dariusz L
b3b901a8d6 Update pyproject.toml 2025-07-02 00:22:22 +02:00
Dariusz L
826f448af9 Add documentation for core modules and update Canvas.js
Added documentation files for ComfyApi, ComfyApp, LitegraphService, and MaskEditor, summarizing their main functions and usage. Refactored js/Canvas.js to improve mask processing logic, using viewport pan for cropping and applying mask color only to non-transparent pixels. Also made minor formatting and logging consistency improvements throughout Canvas.js.
2025-07-02 00:21:53 +02:00
13 changed files with 1841 additions and 395 deletions

96
Doc/ComfyApi Normal file
View File

@@ -0,0 +1,96 @@
# ComfyApi - Function Documentation Summary import { api } from "../../scripts/api.js";
## Basic Information
ComfyApi is a class for communication with ComfyUI backend via WebSocket and REST API.
## Main Functions:
### Connection and Initialization
- constructor() - Initializes API, sets host and base path
- init() - Starts WebSocket connection for real-time updates
- #createSocket() - Creates and manages WebSocket connection
### URL Management
- internalURL(route) - Generates URL for internal endpoints
- apiURL(route) - Generates URL for public API endpoints
- fileURL(route) - Generates URL for static files
- fetchApi(route, options) - Performs HTTP requests with automatic user headers
### Event Handling
- addEventListener(type, callback) - Listens for API events (status, executing, progress, etc.)
- removeEventListener(type, callback) - Removes event listeners
- dispatchCustomEvent(type, detail) - Emits custom events
### Queue and Prompt Management
- queuePrompt(number, data) - Adds prompt to execution queue
- getQueue() - Gets current queue state (Running/Pending)
- interrupt() - Interrupts currently executing prompt
- clearItems(type) - Clears queue or history
- deleteItem(type, id) - Removes item from queue or history
### History and Statistics
- getHistory(max_items) - Gets history of executed prompts
- getSystemStats() - Gets system statistics (Python, OS, GPU, etc.)
- getLogs() - Gets system logs
- getRawLogs() - Gets raw logs
- subscribeLogs(enabled) - Enables/disables log subscription
### Model and Resource Management
- getNodeDefs(options) - Gets definitions of available nodes
- getExtensions() - List of installed extensions
- getEmbeddings() - List of available embeddings
- getModelFolders() - List of model folders
- getModels(folder) - List of models in given folder
- viewMetadata(folder, model) - Metadata of specific model
### Workflow Templates
- getWorkflowTemplates() - Gets workflow templates from custom nodes
- getCoreWorkflowTemplates() - Gets core workflow templates
### User Management
- getUserConfig() - Gets user configuration
- createUser(username) - Creates new user
- getSettings() - Gets all user settings
- getSetting(id) - Gets specific setting
- storeSettings(settings) - Saves settings dictionary
- storeSetting(id, value) - Saves single setting
### User Data
- getUserData(file) - Gets user data file
- storeUserData(file, data, options) - Saves user data
- deleteUserData(file) - Deletes user data file
- moveUserData(source, dest) - Moves data file
- listUserDataFullInfo(dir) - Lists files with full information
### Other
- getFolderPaths() - Gets system folder paths
- getCustomNodesI18n() - Gets internationalization data for custom nodes
## Important Properties
- clientId - Client ID from WebSocket
- authToken - Authorization token for ComfyOrg account
- apiKey - API key for ComfyOrg account
- socket - Active WebSocket connection
## WebSocket Event Types
- status - System status
- executing - Currently executing node
- progress - Execution progress
- executed - Node executed
- execution_start/success/error/interrupted/cached - Execution events
- logs - System logs
- b_preview - Image preview (binary)
- reconnecting/reconnected - Connection events

72
Doc/ComfyApp Normal file
View File

@@ -0,0 +1,72 @@
## __Main ComfyApp Functions__ import { app, ComfyApp } from "../../scripts/app.js";
### __Application Management__
- `setup(canvasEl)` - Initializes the application on the page, loads extensions, registers nodes
- `resizeCanvas()` - Adjusts canvas size to window
- `clean()` - Clears application state (node outputs, image previews, errors)
### __Workflow Management__
- `loadGraphData(graphData, clean, restore_view, workflow, options)` - Loads workflow data from JSON
- `loadApiJson(apiData, fileName)` - Loads workflow from API format
- `graphToPrompt(graph, options)` - Converts graph to prompt for execution
- `handleFile(file)` - Handles file loading (PNG, WebP, JSON, MP3, MP4, SVG, etc.)
### __Execution__
- `queuePrompt(number, batchCount, queueNodeIds)` - Queues prompt for execution
- `registerNodes()` - Registers node definitions from backend
- `registerNodeDef(nodeId, nodeDef)` - Registers single node definition
- `refreshComboInNodes()` - Refreshes combo lists in nodes
### __Node Management__
- `registerExtension(extension)` - Registers ComfyUI extension
- `updateVueAppNodeDefs(defs)` - Updates node definitions in Vue app
- `revokePreviews(nodeId)` - Frees memory for node previews
### __Clipboard__
- `copyToClipspace(node)` - Copies node to clipboard
- `pasteFromClipspace(node)` - Pastes data from clipboard to node
### __Position Conversion__
- `clientPosToCanvasPos(pos)` - Converts client position to canvas position
- `canvasPosToClientPos(pos)` - Converts canvas position to client position
### __Error Handling__
- `showErrorOnFileLoad(file)` - Displays file loading error
- `#showMissingNodesError(missingNodeTypes)` - Shows missing nodes error
- `#showMissingModelsError(missingModels, paths)` - Shows missing models error
### __Internal Handlers__
- `#addDropHandler()` - Handles drag and drop of files
- `#addProcessKeyHandler()` - Handles keyboard input
- `#addDrawNodeHandler()` - Modifies node drawing behavior
- `#addApiUpdateHandlers()` - Handles API updates
- `#addConfigureHandler()` - Graph configuration flag
- `#addAfterConfigureHandler()` - Post-configuration handling
### __Deprecated Properties__
Many properties are marked as deprecated and redirect to appropriate stores:
- `lastNodeErrors` → `useExecutionStore().lastNodeErrors`
- `lastExecutionError` → `useExecutionStore().lastExecutionError`
- `runningNodeId` → `useExecutionStore().executingNodeId`
- `shiftDown` → `useWorkspaceStore().shiftDown`
- `widgets` → `useWidgetStore().widgets`
- `extensions` → `useExtensionStore().extensions`
### __Utility Functions__
- `sanitizeNodeName(string)` - Cleans node name from dangerous characters
- `getPreviewFormatParam()` - Returns preview format parameter
- `getRandParam()` - Returns random parameter for refresh
- `isApiJson(data)` - Checks if data is in API JSON format
This application uses Vue and TypeScript composition pattern, where many functionalities are separated into different services and stores (e.g., `useExecutionStore`, `useWorkflowService`, `useExtensionService`, etc.).

75
Doc/LitegraphService Normal file
View File

@@ -0,0 +1,75 @@
LitegraphService Documentation
Main functions of useLitegraphService()
Node Registration and Creation Functions:
registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1)
- Registers node definition in LiteGraph system
- Creates ComfyNode class with inputs, outputs and widgets
- Adds context menu, background drawing and keyboard handling
- Invokes extensions before registration
addNodeOnGraph(nodeDef, options)
- Adds new node to graph at specified position
- By default places node at canvas center
Navigation and View Functions:
getCanvasCenter(): Vector2
- Returns canvas center coordinates accounting for DPI
goToNode(nodeId: NodeId)
- Animates transition to specified node on canvas
resetView()
- Resets canvas view to default settings (scale 1, offset [0,0])
fitView()
- Fits canvas view to show all nodes
Node Handling Functions (internal):
addNodeContextMenuHandler(node)
- Adds context menu with options:
- Open/Copy/Save image (for image nodes)
- Bypass node
- Copy/Paste to Clipspace
- Open in MaskEditor (for image nodes)
addDrawBackgroundHandler(node)
- Adds node background drawing logic
- Handles image, animation and video previews
- Manages thumbnail display
addNodeKeyHandler(node)
- Adds keyboard handling:
- Left/Right arrows: navigate between images
- Escape: close image preview
ComfyNode Class (created by registerNodeDef):
Main methods:
- #addInputs() - adds inputs and widgets to node
- #addOutputs() - adds outputs to node
- configure() - configures node from serialized data
- #setupStrokeStyles() - sets border styles (errors, execution, etc.)
Properties:
- comfyClass - ComfyUI class name
- nodeData - node definition
- Automatic yellow coloring for API nodes

76
Doc/MaskEditor Normal file
View File

@@ -0,0 +1,76 @@
MASKEDITOR.TS FUNCTION DOCUMENTATION
MaskEditorDialog - Main mask editor class
- getInstance() - Singleton pattern, returns editor instance
- show() - Opens the mask editor
- save() - Saves mask to server
- destroy() - Closes and cleans up editor
- isOpened() - Checks if editor is open
CanvasHistory - Change history management
- saveState() - Saves current canvas state
- undo() - Undo last operation
- redo() - Redo undone operation
- clearStates() - Clears history
BrushTool - Brush tool
- setBrushSize(size) - Sets brush size
- setBrushOpacity(opacity) - Sets brush opacity
- setBrushHardness(hardness) - Sets brush hardness
- setBrushType(type) - Sets brush shape (circle/square)
- startDrawing() - Starts drawing
- handleDrawing() - Handles drawing during movement
- drawEnd() - Ends drawing
PaintBucketTool - Fill tool
- floodFill(point) - Fills area with color from point
- setTolerance(tolerance) - Sets color tolerance
- setFillOpacity(opacity) - Sets fill opacity
- invertMask() - Inverts mask
ColorSelectTool - Color selection tool
- fillColorSelection(point) - Selects similar colors
- setTolerance(tolerance) - Sets selection tolerance
- setLivePreview(enabled) - Enables/disables live preview
- setComparisonMethod(method) - Sets color comparison method
- setApplyWholeImage(enabled) - Applies to whole image
- setSelectOpacity(opacity) - Sets selection opacity
UIManager - Interface management
- updateBrushPreview() - Updates brush preview
- setBrushVisibility(visible) - Shows/hides brush
- screenToCanvas(coords) - Converts screen coordinates to canvas
- getMaskColor() - Returns mask color
- setSaveButtonEnabled(enabled) - Enables/disables save button
ToolManager - Tool management
- setTool(tool) - Sets active tool
- getCurrentTool() - Returns active tool
- handlePointerDown/Move/Up() - Handles mouse/touch events
PanAndZoomManager - View management
- zoom(event) - Zooms in/out canvas
- handlePanStart/Move() - Handles canvas panning
- initializeCanvasPanZoom() - Initializes canvas view
- smoothResetView() - Smoothly resets view
MessageBroker - Communication system
- publish(topic, data) - Publishes message
- subscribe(topic, callback) - Subscribes to topic
- pull(topic, data) - Pulls data from topic
- createPullTopic/PushTopic() - Creates communication topics
KeyboardManager - Keyboard handling
- addListeners() - Adds keyboard listeners
- removeListeners() - Removes listeners
- isKeyDown(key) - Checks if key is pressed

View File

@@ -10,7 +10,12 @@ import threading
import os
from tqdm import tqdm
from torchvision import transforms
from transformers import AutoModelForImageSegmentation, PretrainedConfig
try:
from transformers import AutoModelForImageSegmentation, PretrainedConfig
from requests.exceptions import ConnectionError as RequestsConnectionError
TRANSFORMERS_AVAILABLE = True
except ImportError:
TRANSFORMERS_AVAILABLE = False
import torch.nn.functional as F
import traceback
import uuid
@@ -712,25 +717,31 @@ _matting_lock = None
async def matting(request):
global _matting_lock
if not TRANSFORMERS_AVAILABLE:
log_error("Matting request failed: 'transformers' library is not installed.")
return web.json_response({
"error": "Dependency Not Found",
"details": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
}, status=400)
if _matting_lock is not None:
log_warn("Matting already in progress, rejecting request")
return web.json_response({
"error": "Another matting operation is in progress",
"details": "Please wait for the current operation to complete"
}, status=429) # 429 Too Many Requests
}, status=429)
_matting_lock = True
try:
log_info("Received matting request")
data = await request.json()
matting = BiRefNetMatting()
matting_instance = BiRefNetMatting()
image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
log_debug(f"Input image shape: {image_tensor.shape}")
matted_image, alpha_mask = matting.execute(
matted_image, alpha_mask = matting_instance.execute(
image_tensor,
"BiRefNet/model.safetensors",
threshold=data.get("threshold", 0.5),
@@ -745,14 +756,26 @@ async def matting(request):
"alpha_mask": result_mask
})
except Exception as e:
log_exception(f"Error in matting endpoint: {str(e)}")
except RequestsConnectionError as e:
log_error(f"Connection error during matting model download: {e}")
return web.json_response({
"error": str(e),
"error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
}, status=400)
except Exception as e:
log_exception(f"Error in matting endpoint: {e}")
# Check for offline error message from Hugging Face
if "Offline mode is enabled" in str(e) or "Can't load 'ZhengPeng7/BiRefNet' offline" in str(e):
return web.json_response({
"error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection and ensure you are not in offline mode."
}, status=400)
return web.json_response({
"error": "An unexpected error occurred",
"details": traceback.format_exc()
}, status=500)
finally:
_matting_lock = None
log_debug("Matting lock released")

View File

@@ -5,11 +5,13 @@ import {MaskTool} from "./MaskTool.js";
import {CanvasState} from "./CanvasState.js";
import {CanvasInteractions} from "./CanvasInteractions.js";
import {CanvasLayers} from "./CanvasLayers.js";
import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
import {CanvasRenderer} from "./CanvasRenderer.js";
import {CanvasIO} from "./CanvasIO.js";
import {ImageReferenceManager} from "./ImageReferenceManager.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";
const log = createModuleLogger('Canvas');
@@ -26,7 +28,7 @@ export class Canvas {
this.node = node;
this.widget = widget;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
this.ctx = this.canvas.getContext('2d', {willReadFrequently: true});
this.width = 512;
this.height = 512;
this.layers = [];
@@ -60,7 +62,7 @@ export class Canvas {
log.debug('Canvas widget element:', this.node);
log.info('Canvas initialized', {
nodeId: this.node.id,
dimensions: { width: this.width, height: this.height },
dimensions: {width: this.width, height: this.height},
viewport: this.viewport
});
@@ -141,10 +143,14 @@ export class Canvas {
_initializeModules(callbacks) {
log.debug('Initializing Canvas modules...');
// Stwórz opóźnioną wersję funkcji zapisu stanu
this.requestSaveState = debounce(this.saveState.bind(this), 500);
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasState = new CanvasState(this);
this.canvasInteractions = new CanvasInteractions(this);
this.canvasLayers = new CanvasLayers(this);
this.canvasLayersPanel = new CanvasLayersPanel(this);
this.canvasRenderer = new CanvasRenderer(this);
this.canvasIO = new CanvasIO(this);
this.imageReferenceManager = new ImageReferenceManager(this);
@@ -180,6 +186,11 @@ export class Canvas {
}
this.saveState();
this.render();
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
}
/**
@@ -187,7 +198,7 @@ export class Canvas {
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
*/
saveState(replaceLast = false) {
log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length });
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
this.canvasState.saveState(replaceLast);
this.incrementOperationCount();
this._notifyStateChange();
@@ -200,11 +211,17 @@ export class Canvas {
log.info('Performing undo operation');
const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before undo:', historyInfo);
this.canvasState.undo();
this.incrementOperationCount();
this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged();
}
log.debug('Undo completed, layers count:', this.layers.length);
}
@@ -216,11 +233,17 @@ export class Canvas {
log.info('Performing redo operation');
const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before redo:', historyInfo);
this.canvasState.redo();
this.incrementOperationCount();
this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged();
}
log.debug('Redo completed, layers count:', this.layers.length);
}
@@ -238,7 +261,14 @@ export class Canvas {
* @param {string} addMode - Tryb dodawania
*/
async addLayer(image, layerProps = {}, addMode = 'default') {
return this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
// Powiadom panel warstw o dodaniu nowej warstwy
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
return result;
}
/**
@@ -246,17 +276,23 @@ export class Canvas {
*/
removeSelectedLayers() {
if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', {
log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length,
totalLayers: this.layers.length
totalLayers: this.layers.length
});
this.saveState();
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
this.updateSelection([]);
this.updateSelection([]);
this.render();
this.saveState();
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
log.debug('Layers removed successfully, remaining layers:', this.layers.length);
} else {
log.debug('No layers selected for removal');
@@ -264,7 +300,39 @@ export class Canvas {
}
/**
* Aktualizuje zaznaczenie warstw
* 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.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;
}
/**
* 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) {
@@ -272,15 +340,78 @@ export class Canvas {
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.
*/
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
let newSelection = [...this.selectedLayers];
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);
}
}
/**
@@ -321,10 +452,10 @@ export class Canvas {
* @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,
log.info('Starting mask editor', {
hasPredefinedMask: !!predefinedMask,
sendCleanImage,
layersCount: this.layers.length
layersCount: this.layers.length
});
this.savedMaskState = await this.saveMaskState();
@@ -683,7 +814,7 @@ export class Canvas {
throw new Error("Old mask editor canvas not found");
}
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
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);
@@ -699,59 +830,58 @@ export class Canvas {
* @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/
async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
const originalWidth = maskData.width || maskData.naturalWidth || this.width;
const originalHeight = maskData.height || maskData.naturalHeight || this.height;
*/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:", {
originalSize: {width: originalWidth, height: originalHeight},
sourceSize: {width: maskData.width, height: maskData.height},
targetSize: {width: targetWidth, height: targetHeight},
canvasSize: {width: this.width, height: this.height}
viewportPan: {x: panX, y: panY}
});
const tempCanvas = document.createElement('canvas');
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
tempCtx.clearRect(0, 0, targetWidth, targetHeight);
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
);
const scaleToOriginal = Math.min(originalWidth / this.width, originalHeight / this.height);
const scaledWidth = this.width * scaleToOriginal;
const scaledHeight = this.height * scaleToOriginal;
const offsetX = (targetWidth - scaledWidth) / 2;
const offsetY = (targetHeight - scaledHeight) / 2;
tempCtx.drawImage(maskData, offsetX, offsetY, scaledWidth, scaledHeight);
log.info("Mask drawn scaled to original image size:", {
originalSize: {width: originalWidth, height: originalHeight},
targetSize: {width: targetWidth, height: targetHeight},
canvasSize: {width: this.width, height: this.height},
scaleToOriginal: scaleToOriginal,
finalSize: {width: scaledWidth, height: scaledHeight},
offset: {x: offsetX, y: offsetY}
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]; // Oryginalny kanał alpha
data[i] = maskColor.r; // R
data[i + 1] = maskColor.g; // G
data[i + 2] = maskColor.b; // B
data[i + 3] = alpha; // Zachowaj oryginalny alpha
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 - full size scaling applied");
log.info("Mask processing completed - color applied.");
return tempCanvas;
}
@@ -784,6 +914,7 @@ export class Canvas {
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
}
}
/**
* Zapisuje obecny stan maski przed otwarciem editora
* @returns {Object} Zapisany stan maski
@@ -797,7 +928,7 @@ export class Canvas {
const savedCanvas = document.createElement('canvas');
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true });
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
savedCtx.drawImage(maskCanvas, 0, 0);
return {
@@ -878,7 +1009,7 @@ export class Canvas {
resultImage.onload = resolve;
resultImage.onerror = reject;
});
log.debug("Result image loaded successfully", {
width: resultImage.width,
height: resultImage.height
@@ -893,7 +1024,7 @@ export class Canvas {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.width;
tempCanvas.height = this.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
tempCtx.drawImage(resultImage, 0, 0, this.width, this.height);
@@ -920,7 +1051,7 @@ export class Canvas {
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", { destX, destY });
log.debug("Applying mask to canvas", {destX, destY});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.width, this.height);

View File

@@ -19,6 +19,7 @@ export class CanvasInteractions {
hasClonedInDrag: false,
lastClickTime: 0,
transformingLayer: null,
keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami
};
this.originalLayerPositions = new Map();
this.interaction.canvasResizeRect = null;
@@ -69,47 +70,33 @@ export class CanvasInteractions {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
if (e.button === 1) {
this.startPanning(e);
} else {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
}
this.canvas.render();
return;
}
// --- Ostateczna, poprawna kolejność sprawdzania ---
const currentTime = Date.now();
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords);
this.canvas.render();
return;
}
if (currentTime - this.interaction.lastClickTime < 300) {
this.canvas.updateSelection([]);
this.canvas.selectedLayer = null;
this.resetInteractionState();
this.canvas.render();
return;
}
this.interaction.lastClickTime = currentTime;
if (e.button === 2) {
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault(); // Prevent context menu
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y);
return;
}
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
this.canvas.render();
return;
}
// 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault();
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
}
return;
}
if (e.button !== 0) { // Środkowy przycisk
this.startPanning(e);
return;
}
// 3. Interakcje z elementami na płótnie (lewy przycisk)
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
@@ -118,32 +105,31 @@ export class CanvasInteractions {
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) {
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
return;
}
this.startPanning(e);
this.canvas.render();
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e);
}
handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
this.canvas.lastMousePosition = worldCoords;
if (this.canvas.maskTool.isActive) {
if (this.interaction.mode === 'panning') {
this.panViewport(e);
return;
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie
if (this.interaction.mode === 'potential-drag') {
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
this.interaction.mode = 'dragging';
this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
});
}
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
if (this.canvas.maskTool.isDrawing) {
this.canvas.render();
}
return;
}
switch (this.interaction.mode) {
case 'panning':
this.panViewport(e);
@@ -170,31 +156,24 @@ export class CanvasInteractions {
}
handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
if (this.interaction.mode === 'panning') {
this.resetInteractionState();
} else {
this.canvas.maskTool.handleMouseUp(viewCoords);
}
this.canvas.render();
return;
}
const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning';
if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize();
} else if (this.interaction.mode === 'movingCanvas') {
}
if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove();
}
this.resetInteractionState();
this.canvas.render();
if (interactionEnded) {
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
if (stateChangingInteraction || duplicatedInDrag) {
this.canvas.saveState();
this.canvas.canvasState.saveStateToDB(true);
}
this.resetInteractionState();
this.canvas.render();
}
handleMouseLeave(e) {
@@ -245,10 +224,22 @@ export class CanvasInteractions {
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.selectedLayer) {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
this.canvas.selectedLayers.forEach(layer => {
if (e.shiftKey) {
layer.rotation += rotationStep;
// Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
if (e.ctrlKey) {
const snapAngle = 5;
if (direction > 0) { // Obrót w górę/prawo
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
} else { // Obrót w dół/lewo
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
}
} else {
// Stara funkcjonalność: Shift + Kółko obraca o stały krok
layer.rotation += rotationStep;
}
} else {
const oldWidth = layer.width;
const oldHeight = layer.height;
@@ -307,112 +298,81 @@ export class CanvasInteractions {
}
this.canvas.render();
if (!this.canvas.maskTool.isActive) {
this.canvas.saveState(true);
this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu
}
}
handleKeyDown(e) {
if (this.canvas.maskTool.isActive) {
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.canvasState.redo();
} else {
this.canvas.canvasState.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasState.redo();
return;
}
}
return;
}
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
// Globalne skróty (Undo/Redo/Copy/Paste)
if (e.ctrlKey || e.metaKey) {
let handled = true;
switch (e.key.toLowerCase()) {
case 'z':
if (e.shiftKey) {
this.canvas.redo();
} else {
this.canvas.undo();
}
break;
case 'y':
this.canvas.redo();
break;
case 'c':
if (this.canvas.selectedLayers.length > 0) {
this.canvas.canvasLayers.copySelectedLayers();
}
break;
case 'v':
this.canvas.canvasLayers.handlePaste('mouse');
break;
default:
handled = false;
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.canvasState.redo();
} else {
this.canvas.canvasState.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasState.redo();
return;
}
if (e.key.toLowerCase() === 'c') {
if (this.canvas.selectedLayers.length > 0) {
this.canvas.canvasLayers.copySelectedLayers();
}
return;
}
if (e.key.toLowerCase() === 'v') {
return;
}
}
if (this.canvas.selectedLayer) {
if (e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.canvas.selectedLayers.includes(l));
this.canvas.updateSelection([]);
this.canvas.render();
return;
}
// Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1;
let needsRender = false;
switch (e.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
case 'BracketLeft':
case 'BracketRight':
e.preventDefault();
e.stopPropagation();
// Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
this.interaction.keyMovementInProgress = true;
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step);
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step);
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step);
needsRender = true;
break;
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step);
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step);
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step);
needsRender = true;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
this.canvas.removeSelectedLayers();
return;
}
if (needsRender) {
this.canvas.render();
this.canvas.saveState();
}
}
}
@@ -420,6 +380,12 @@ export class CanvasInteractions {
handleKeyUp(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
if (e.key === 'Alt') this.interaction.isAltPressed = false;
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
this.interaction.keyMovementInProgress = false;
}
}
updateCursor(worldCoords) {
@@ -466,31 +432,34 @@ export class CanvasInteractions {
this.canvas.render();
}
startLayerDrag(layer, worldCoords) {
this.interaction.mode = 'dragging';
this.interaction.dragStart = {...worldCoords};
let currentSelection = [...this.canvas.selectedLayers];
prepareForDrag(layer, worldCoords) {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) {
const index = currentSelection.indexOf(layer);
const index = this.canvas.selectedLayers.indexOf(layer);
if (index === -1) {
currentSelection.push(layer);
this.canvas.updateSelection([...this.canvas.selectedLayers, layer]);
} else {
currentSelection.splice(index, 1);
const newSelection = this.canvas.selectedLayers.filter(l => l !== layer);
this.canvas.updateSelection(newSelection);
}
} else {
if (!currentSelection.includes(layer)) {
currentSelection = [layer];
if (!this.canvas.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]);
}
}
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords};
}
this.canvas.updateSelection(currentSelection);
this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
});
startPanningOrClearSelection(e) {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
if (!this.interaction.isCtrlPressed) {
this.canvas.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY};
}
startCanvasResize(worldCoords) {
@@ -549,6 +518,7 @@ export class CanvasInteractions {
this.canvas.viewport.y -= finalY;
}
this.canvas.render();
this.canvas.saveState();
}
startPanning(e) {
@@ -570,19 +540,12 @@ export class CanvasInteractions {
dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) {
const newLayers = [];
this.canvas.selectedLayers.forEach(layer => {
const newLayer = {
...layer,
zIndex: this.canvas.layers.length,
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvas.updateSelection(newLayers);
this.canvas.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null;
// Scentralizowana logika duplikowania
const newLayers = this.canvas.duplicateSelectedLayers();
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => {
newLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
});
this.interaction.hasClonedInDrag = true;

View File

@@ -128,6 +128,12 @@ export class CanvasLayers {
this.canvas.updateSelection(newLayers);
this.canvas.render();
// Notify the layers panel to update its view
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
}
@@ -192,6 +198,11 @@ export class CanvasLayers {
this.canvas.render();
this.canvas.saveState();
// Notify the layers panel to update its view
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layer added successfully");
return layer;
}, 'CanvasLayers.addLayerWithImage');
@@ -200,40 +211,93 @@ export class CanvasLayers {
return this.addLayerWithImage(image);
}
/**
* Centralna funkcja do przesuwania warstw.
* @param {Array} layersToMove - Tablica warstw do przesunięcia.
* @param {Object} options - Opcje przesunięcia, np. { direction: 'up' } lub { toIndex: 3 }
*/
moveLayers(layersToMove, options = {}) {
if (!layersToMove || layersToMove.length === 0) return;
let finalLayers;
if (options.direction) {
// Logika dla 'up' i 'down'
const allLayers = [...this.canvas.layers];
const selectedIndices = new Set(layersToMove.map(l => allLayers.indexOf(l)));
if (options.direction === 'up') {
const sorted = Array.from(selectedIndices).sort((a, b) => b - a);
sorted.forEach(index => {
const targetIndex = index + 1;
if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) {
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
}
});
} else if (options.direction === 'down') {
const sorted = Array.from(selectedIndices).sort((a, b) => a - b);
sorted.forEach(index => {
const targetIndex = index - 1;
if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) {
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
}
});
}
finalLayers = allLayers;
} else if (options.toIndex !== undefined) {
// Logika dla przeciągania i upuszczania (z panelu)
const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const reorderedFinal = [];
let inserted = false;
for (let i = 0; i < displayedLayers.length; i++) {
if (i === options.toIndex) {
reorderedFinal.push(...layersToMove);
inserted = true;
}
const currentLayer = displayedLayers[i];
if (!layersToMove.includes(currentLayer)) {
reorderedFinal.push(currentLayer);
}
}
if (!inserted) {
reorderedFinal.push(...layersToMove);
}
finalLayers = reorderedFinal;
} else {
log.warn("Invalid options for moveLayers", options);
return;
}
// Zunifikowana końcówka: aktualizacja zIndex i stanu aplikacji
const totalLayers = finalLayers.length;
finalLayers.forEach((layer, index) => {
// Jeśli przyszły z panelu, zIndex jest odwrócony
const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index;
layer.zIndex = zIndex;
});
this.canvas.layers = finalLayers;
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
log.info(`Moved ${layersToMove.length} layer(s).`);
}
moveLayerUp() {
if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
sortedIndices.forEach(index => {
const targetIndex = index + 1;
if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) {
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
}
});
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvas.render();
this.canvas.saveState();
this.moveLayers(this.canvas.selectedLayers, { direction: 'up' });
}
moveLayerDown() {
if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
sortedIndices.forEach(index => {
const targetIndex = index - 1;
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
}
});
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvas.render();
this.canvas.saveState();
this.moveLayers(this.canvas.selectedLayers, { direction: 'down' });
}
/**
@@ -248,7 +312,7 @@ export class CanvasLayers {
layer.height *= scale;
});
this.canvas.render();
this.canvas.saveState();
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
}
/**
@@ -262,7 +326,7 @@ export class CanvasLayers {
layer.rotation += angle;
});
this.canvas.render();
this.canvas.saveState();
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
}
getLayerAtPosition(worldX, worldY) {
@@ -318,7 +382,7 @@ export class CanvasLayers {
await Promise.all(promises);
this.canvas.render();
this.canvas.saveState();
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
}
async mirrorVertical() {
@@ -346,7 +410,7 @@ export class CanvasLayers {
await Promise.all(promises);
this.canvas.render();
this.canvas.saveState();
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
}
async getLayerImageData(layer) {
@@ -968,4 +1032,157 @@ export class CanvasLayers {
}, 'image/png');
});
}
/**
* Fuses (flattens and merges) selected layers into a single layer
*/
async fuseLayers() {
if (this.canvas.selectedLayers.length < 2) {
alert("Please select at least 2 layers to fuse.");
return;
}
log.info(`Fusing ${this.canvas.selectedLayers.length} selected layers`);
try {
// Save state for undo
this.canvas.saveState();
// Calculate bounding box of all selected layers
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.selectedLayers.forEach(layer => {
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const halfW = layer.width / 2;
const halfH = layer.height / 2;
const corners = [
{x: -halfW, y: -halfH},
{x: halfW, y: -halfH},
{x: halfW, y: halfH},
{x: -halfW, y: halfH}
];
corners.forEach(p => {
const worldX = centerX + (p.x * cos - p.y * sin);
const worldY = centerY + (p.x * sin + p.y * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
});
const fusedWidth = Math.ceil(maxX - minX);
const fusedHeight = Math.ceil(maxY - minY);
if (fusedWidth <= 0 || fusedHeight <= 0) {
log.warn("Calculated fused layer dimensions are invalid");
alert("Cannot fuse layers: invalid dimensions calculated.");
return;
}
// Create temporary canvas for flattening
const tempCanvas = document.createElement('canvas');
tempCanvas.width = fusedWidth;
tempCanvas.height = fusedHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
// Translate context to account for the bounding box offset
tempCtx.translate(-minX, -minY);
// Sort selected layers by z-index and render them
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedSelection.forEach(layer => {
if (!layer.image) return;
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
tempCtx.translate(centerX, centerY);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(
layer.image,
-layer.width / 2, -layer.height / 2,
layer.width, layer.height
);
tempCtx.restore();
});
// Convert flattened canvas to image
const fusedImage = new Image();
fusedImage.src = tempCanvas.toDataURL();
await new Promise((resolve, reject) => {
fusedImage.onload = resolve;
fusedImage.onerror = reject;
});
// Find the lowest z-index among selected layers to maintain visual order
const minZIndex = Math.min(...this.canvas.selectedLayers.map(layer => layer.zIndex));
// Generate unique ID for the new fused layer
const imageId = generateUUID();
await saveImage(imageId, fusedImage.src);
this.canvas.imageCache.set(imageId, fusedImage.src);
// Create the new fused layer
const fusedLayer = {
image: fusedImage,
imageId: imageId,
x: minX,
y: minY,
width: fusedWidth,
height: fusedHeight,
originalWidth: fusedWidth,
originalHeight: fusedHeight,
rotation: 0,
zIndex: minZIndex,
blendMode: 'normal',
opacity: 1
};
// Remove selected layers from canvas
this.canvas.layers = this.canvas.layers.filter(layer => !this.canvas.selectedLayers.includes(layer));
// Insert the fused layer at the correct position
this.canvas.layers.push(fusedLayer);
// Re-index all layers to maintain proper z-order
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
this.canvas.layers.forEach((layer, index) => {
layer.zIndex = index;
});
// Select the new fused layer
this.canvas.updateSelection([fusedLayer]);
// Render and save state
this.canvas.render();
this.canvas.saveState();
// Notify the layers panel to update its view
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layers fused successfully", {
originalLayerCount: sortedSelection.length,
fusedDimensions: { width: fusedWidth, height: fusedHeight },
fusedPosition: { x: minX, y: minY }
});
} catch (error) {
log.error("Error during layer fusion:", error);
alert(`Error fusing layers: ${error.message}`);
}
}
}

676
js/CanvasLayersPanel.js Normal file
View File

@@ -0,0 +1,676 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel {
constructor(canvas) {
this.canvas = canvas;
this.container = null;
this.layersContainer = null;
this.draggedElements = [];
this.dragInsertionLine = null;
this.isMultiSelecting = false;
this.lastSelectedIndex = -1;
// Binding metod dla event handlerów
this.handleLayerClick = this.handleLayerClick.bind(this);
this.handleDragStart = this.handleDragStart.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this);
log.info('CanvasLayersPanel initialized');
}
/**
* Tworzy struktur&ecirc; HTML panelu warstw
*/
createPanelStructure() {
// Główny kontener panelu
this.container = document.createElement('div');
this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = `
<div class="layers-panel-header">
<span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
</div>
</div>
<div class="layers-container" id="layers-container">
<!-- Lista warstw będzie renderowana tutaj -->
</div>
`;
this.layersContainer = this.container.querySelector('#layers-container');
// Dodanie stylów CSS
this.injectStyles();
// Setup event listeners dla przycisków
this.setupControlButtons();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
this.deleteSelectedLayers();
}
});
log.debug('Panel structure created');
return this.container;
}
/**
* Dodaje style CSS do panelu
*/
injectStyles() {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
/**
* Konfiguruje event listenery dla przycisków kontrolnych
*/
setupControlButtons() {
const deleteBtn = this.container.querySelector('#delete-layer-btn');
deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
});
}
/**
* Renderuje listę warstw
*/
renderLayers() {
if (!this.layersContainer) {
log.warn('Layers container not initialized');
return;
}
// Wyczyść istniejącą zawartość
this.layersContainer.innerHTML = '';
// Usuń linię wstawiania jeśli istnieje
this.removeDragInsertionLine();
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
sortedLayers.forEach((layer, index) => {
const layerElement = this.createLayerElement(layer, index);
this.layersContainer.appendChild(layerElement);
});
log.debug(`Rendered ${sortedLayers.length} layers`);
}
/**
* Tworzy element HTML dla pojedynczej warstwy
*/
createLayerElement(layer, index) {
const layerRow = document.createElement('div');
layerRow.className = 'layer-row';
layerRow.draggable = true;
layerRow.dataset.layerIndex = index;
// Sprawdź czy warstwa jest zaznaczona
const isSelected = this.canvas.selectedLayers.includes(layer);
if (isSelected) {
layerRow.classList.add('selected');
}
// Ustawienie domyślnych właściwości jeśli nie istnieją
if (!layer.name) {
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
} else {
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
layer.name = this.ensureUniqueName(layer.name, layer);
}
layerRow.innerHTML = `
<div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`;
// Wygeneruj miniaturkę
this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail'));
// Event listenery
this.setupLayerEventListeners(layerRow, layer, index);
return layerRow;
}
/**
* Generuje miniaturkę warstwy
*/
generateThumbnail(layer, thumbnailContainer) {
if (!layer.image) {
thumbnailContainer.style.background = '#4a4a4a';
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = 48;
canvas.height = 48;
// Oblicz skalę zachowując proporcje
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale;
// Wycentruj obraz
const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2;
// Narysuj obraz z wyższą jakością
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
thumbnailContainer.appendChild(canvas);
}
/**
* Konfiguruje event listenery dla elementu warstwy
*/
setupLayerEventListeners(layerRow, layer, index) {
// Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku
layerRow.addEventListener('mousedown', (e) => {
// Ignoruj, jeśli edytujemy nazwę
const nameElement = layerRow.querySelector('.layer-name');
if (nameElement && nameElement.classList.contains('editing')) {
return;
}
this.handleLayerClick(e, layer, index);
});
// Double click handler - edycja nazwy
layerRow.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
const nameElement = layerRow.querySelector('.layer-name');
this.startEditingLayerName(nameElement, layer);
});
// Drag handlers
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver);
layerRow.addEventListener('dragend', this.handleDragEnd);
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index));
}
/**
* Obsługuje kliknięcie na warstwę, aktualizując stan bez pełnego renderowania.
*/
handleLayerClick(e, layer, index) {
const isCtrlPressed = e.ctrlKey || e.metaKey;
const isShiftPressed = e.shiftKey;
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.selectedLayers.length}`);
}
/**
* Rozpoczyna edycję nazwy warstwy
*/
startEditingLayerName(nameElement, layer) {
const currentName = layer.name;
nameElement.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.style.width = '100%';
nameElement.innerHTML = '';
nameElement.appendChild(input);
input.focus();
input.select();
const finishEditing = () => {
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
newName = this.ensureUniqueName(newName, layer);
layer.name = newName;
nameElement.classList.remove('editing');
nameElement.textContent = newName;
this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`);
};
input.addEventListener('blur', finishEditing);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
finishEditing();
} else if (e.key === 'Escape') {
nameElement.classList.remove('editing');
nameElement.textContent = currentName;
}
});
}
/**
* Zapewnia unikalność nazwy warstwy
*/
ensureUniqueName(proposedName, currentLayer) {
const existingNames = this.canvas.layers
.filter(layer => layer !== currentLayer)
.map(layer => layer.name);
if (!existingNames.includes(proposedName)) {
return proposedName;
}
// Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber;
if (match) {
baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1;
} else {
baseName = proposedName;
startNumber = 1;
}
// Znajdź pierwszą dostępną numerację
let counter = startNumber;
let uniqueName;
do {
uniqueName = `${baseName} (${counter})`;
counter++;
} while (existingNames.includes(uniqueName));
return uniqueName;
}
/**
* Usuwa zaznaczone warstwy
*/
deleteSelectedLayers() {
if (this.canvas.selectedLayers.length === 0) {
log.debug('No layers selected for deletion');
return;
}
log.info(`Deleting ${this.canvas.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers();
this.renderLayers();
}
/**
* Rozpoczyna przeciąganie warstwy
*/
handleDragStart(e, layer, index) {
// Sprawdź czy jakakolwiek warstwa jest w trybie edycji
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
if (editingElement) {
e.preventDefault();
return;
}
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
if (!this.canvas.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]);
this.renderLayers();
}
this.draggedElements = [...this.canvas.selectedLayers];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard
// Dodaj klasę dragging do przeciąganych elementów
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
if (this.draggedElements.includes(sortedLayers[idx])) {
row.classList.add('dragging');
}
});
log.debug(`Started dragging ${this.draggedElements.length} layers`);
}
/**
* Obsługuje przeciąganie nad warstwą
*/
handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const layerRow = e.currentTarget;
const rect = layerRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint;
this.showDragInsertionLine(layerRow, isUpperHalf);
}
/**
* Pokazuje linię wskaźnika wstawiania
*/
showDragInsertionLine(targetRow, isUpperHalf) {
this.removeDragInsertionLine();
const line = document.createElement('div');
line.className = 'drag-insertion-line';
if (isUpperHalf) {
line.style.top = '-1px';
} else {
line.style.bottom = '-1px';
}
targetRow.style.position = 'relative';
targetRow.appendChild(line);
this.dragInsertionLine = line;
}
/**
* Usuwa linię wskaźnika wstawiania
*/
removeDragInsertionLine() {
if (this.dragInsertionLine) {
this.dragInsertionLine.remove();
this.dragInsertionLine = null;
}
}
/**
* Obsługuje upuszczenie warstwy
*/
handleDrop(e, targetIndex) {
e.preventDefault();
this.removeDragInsertionLine();
if (this.draggedElements.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks
let insertIndex = targetIndex;
if (!isUpperHalf) {
insertIndex = targetIndex + 1;
}
// Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
}
/**
* Kończy przeciąganie
*/
handleDragEnd(e) {
this.removeDragInsertionLine();
// Usuń klasę dragging ze wszystkich elementów
this.layersContainer.querySelectorAll('.layer-row').forEach(row => {
row.classList.remove('dragging');
});
this.draggedElements = [];
}
/**
* Aktualizuje panel gdy zmienią się warstwy
*/
onLayersChanged() {
this.renderLayers();
}
/**
* Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania.
*/
updateSelectionAppearance() {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
layerRows.forEach((row, index) => {
const layer = sortedLayers[index];
if (this.canvas.selectedLayers.includes(layer)) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
}
/**
* Aktualizuje panel gdy zmienią się warstwy (np. dodanie, usunięcie, zmiana kolejności)
* To jest jedyne miejsce, gdzie powinniśmy w pełni renderować panel.
*/
onLayersChanged() {
this.renderLayers();
}
/**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/
onSelectionChanged() {
this.updateSelectionAppearance();
}
/**
* Niszczy panel i czyści event listenery
*/
destroy() {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.container = null;
this.layersContainer = null;
this.draggedElements = [];
this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed');
}
}

View File

@@ -16,6 +16,25 @@ export class CanvasState {
this.saveTimeout = null;
this.lastSavedStateSignature = null;
this._loadInProgress = null;
// Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami
try {
// new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
log.info("State saver worker initialized successfully.");
this.stateSaverWorker.onmessage = (e) => {
log.info("Message from state saver worker:", e.data);
};
this.stateSaverWorker.onerror = (e) => {
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
// Zapobiegaj dalszym próbom, jeśli worker nie działa
this.stateSaverWorker = null;
};
} catch (e) {
log.error("Failed to initialize state saver worker:", e);
this.stateSaverWorker = null;
}
}
@@ -182,47 +201,35 @@ export class CanvasState {
img.src = imageSrc;
}
async saveStateToDB(immediate = false) {
log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id);
async saveStateToDB() {
if (!this.canvas.node.id) {
log.error("Node ID is not available for saving state to DB.");
return;
}
const currentStateSignature = getStateSignature(this.canvas.layers);
if (this.lastSavedStateSignature === currentStateSignature) {
log.debug("State unchanged, skipping save to IndexedDB.");
log.info("Preparing state to be sent to worker...");
const state = {
layers: await this._prepareLayers(),
viewport: this.canvas.viewport,
width: this.canvas.width,
height: this.canvas.height,
};
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping.");
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
const saveFunction = withErrorHandling(async () => {
const state = {
layers: await this._prepareLayers(),
viewport: this.canvas.viewport,
width: this.canvas.width,
height: this.canvas.height,
};
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping save to IndexedDB.");
return;
}
await setCanvasState(this.canvas.node.id, state);
log.info("Canvas state saved to IndexedDB.");
this.lastSavedStateSignature = currentStateSignature;
this.canvas.render();
}, 'CanvasState.saveStateToDB');
if (immediate) {
await saveFunction();
if (this.stateSaverWorker) {
log.info("Posting state to worker for background saving.");
this.stateSaverWorker.postMessage({
nodeId: this.canvas.node.id,
state: state
});
} else {
this.saveTimeout = setTimeout(saveFunction, 1000);
log.warn("State saver worker not available. Saving on main thread.");
await setCanvasState(this.canvas.node.id, state);
}
}
@@ -264,14 +271,15 @@ export class CanvasState {
}
const currentState = cloneLayers(this.canvas.layers);
const currentStateSignature = getStateSignature(currentState);
if (this.layersUndoStack.length > 0) {
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
if (getStateSignature(currentState) === getStateSignature(lastState)) {
return;
if (getStateSignature(lastState) === currentStateSignature) {
return;
}
}
this.layersUndoStack.push(currentState);
if (this.layersUndoStack.length > this.historyLimit) {
@@ -279,7 +287,11 @@ export class CanvasState {
}
this.layersRedoStack = [];
this.canvas.updateHistoryButtons();
this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500);
// Użyj debouncingu, aby zapobiec zbyt częstym zapisom
if (!this._debouncedSave) {
this._debouncedSave = debounce(() => this.saveStateToDB(), 1000);
}
this._debouncedSave();
}

View File

@@ -429,7 +429,7 @@ async function createCanvasWidget(node, widget, app) {
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Double Click (background)</kbd></td><td>Deselect all layers</td></tr>
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table>
<h4>Clipboard & I/O</h4>
@@ -444,10 +444,11 @@ async function createCanvasWidget(node, widget, app) {
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
<tr><td><kbd>Shift + Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5°</td></tr>
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
@@ -477,6 +478,41 @@ async function createCanvasWidget(node, widget, app) {
`;
document.body.appendChild(helpTooltip);
// Helper function for tooltip positioning
const showTooltip = (buttonElement, content) => {
helpTooltip.innerHTML = content;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = buttonElement.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
};
const hideTooltip = () => {
helpTooltip.style.display = 'none';
};
const controlPanel = $el("div.painterControlPanel", {}, [
$el("div.controls.painter-controls", {
style: {
@@ -508,43 +544,10 @@ async function createCanvasWidget(node, widget, app) {
fontWeight: "bold",
},
onmouseenter: (e) => {
if (canvas.maskTool.isActive) {
helpTooltip.innerHTML = maskShortcuts;
} else {
helpTooltip.innerHTML = standardShortcuts;
}
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target, content);
},
onmouseleave: () => {
helpTooltip.style.display = 'none';
}
onmouseleave: hideTooltip
}),
$el("button.painter-button.primary", {
textContent: "Add Image",
@@ -653,36 +656,9 @@ async function createCanvasWidget(node, widget, app) {
`;
}
helpTooltip.innerHTML = tooltipContent;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
showTooltip(e.target, tooltipContent);
},
onmouseleave: () => {
helpTooltip.style.display = 'none';
}
onmouseleave: hideTooltip
})
]),
]),
@@ -789,6 +765,11 @@ async function createCanvasWidget(node, widget, app) {
title: "Move selected layer(s) down",
onclick: () => canvas.canvasLayers.moveLayerDown()
}),
$el("button.painter-button.requires-selection", {
textContent: "Fuse",
title: "Flatten and merge selected layers into a single layer",
onclick: () => canvas.canvasLayers.fuseLayers()
}),
]),
$el("div.painter-separator"),
@@ -845,9 +826,15 @@ async function createCanvasWidget(node, widget, app) {
body: JSON.stringify({image: imageData})
});
if (!response.ok) throw new Error(`Server error: ${response.status} - ${response.statusText}`);
const result = await response.json();
if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) {
errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`;
}
throw new Error(errorMsg);
}
const mattedImage = new Image();
mattedImage.src = result.matted_image;
await mattedImage.decode();
@@ -859,7 +846,7 @@ async function createCanvasWidget(node, widget, app) {
canvas.saveState();
} catch (error) {
log.error("Matting error:", error);
alert(`Error during matting process: ${error.message}`);
alert(`Matting process failed:\n\n${error.message}`);
} finally {
button.classList.remove('loading');
button.removeChild(spinner);
@@ -871,14 +858,14 @@ async function createCanvasWidget(node, widget, app) {
textContent: "Undo",
title: "Undo last action",
disabled: true,
onclick: () => canvas.canvasState.undo()
onclick: () => canvas.undo()
}),
$el("button.painter-button", {
id: `redo-button-${node.id}`,
textContent: "Redo",
title: "Redo last undone action",
disabled: true,
onclick: () => canvas.canvasState.redo()
onclick: () => canvas.redo()
}),
]),
$el("div.painter-separator"),
@@ -1008,7 +995,12 @@ async function createCanvasWidget(node, widget, app) {
const selectionCount = canvas.selectedLayers.length;
const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
btn.disabled = !hasSelection;
// Special handling for Fuse button - requires at least 2 layers
if (btn.textContent === 'Fuse') {
btn.disabled = selectionCount < 2;
} else {
btn.disabled = !hasSelection;
}
});
const mattingBtn = controlPanel.querySelector('.matting-button');
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
@@ -1030,13 +1022,6 @@ async function createCanvasWidget(node, widget, app) {
canvas.updateHistoryButtons();
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
canvasContainer.style.top = (controlsHeight + 10) + "px";
});
resizeObserver.observe(controlPanel.querySelector('.controls'));
const triggerWidget = node.widgets.find(w => w.name === "trigger");
const updateOutput = async () => {
@@ -1058,18 +1043,41 @@ async function createCanvasWidget(node, widget, app) {
};
// Tworzenie panelu warstw
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
style: {
position: "absolute",
top: "60px",
top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
left: "10px",
right: "10px",
right: "270px",
bottom: "10px",
overflow: "hidden"
}
}, [canvas.canvas]);
// Kontener dla panelu warstw
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
style: {
position: "absolute",
top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
right: "10px",
width: "250px",
bottom: "10px",
overflow: "hidden"
}
}, [layersPanel]);
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
const newTop = (controlsHeight + 10) + "px";
canvasContainer.style.top = newTop;
layersPanelContainer.style.top = newTop;
});
resizeObserver.observe(controlPanel.querySelector('.controls'));
canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus');
});
@@ -1090,7 +1098,7 @@ async function createCanvasWidget(node, widget, app) {
width: "100%",
height: "100%"
}
}, [controlPanel, canvasContainer]);
}, [controlPanel, canvasContainer, layersPanelContainer]);
@@ -1157,6 +1165,10 @@ async function createCanvasWidget(node, widget, app) {
setTimeout(() => {
canvas.loadInitialState();
// Renderuj panel warstw po załadowaniu stanu
if (canvas.canvasLayersPanel) {
canvas.canvasLayersPanel.renderLayers();
}
}, 100);
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");

93
js/state-saver.worker.js Normal file
View File

@@ -0,0 +1,93 @@
console.log('[StateWorker] Worker script loaded and running.');
const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState';
const DB_VERSION = 3;
let db;
function log(...args) {
console.log('[StateWorker]', ...args);
}
function error(...args) {
console.error('[StateWorker]', ...args);
}
function createDBRequest(store, operation, data, errorMessage) {
return new Promise((resolve, reject) => {
let request;
switch (operation) {
case 'put':
request = store.put(data);
break;
default:
reject(new Error(`Unknown operation: ${operation}`));
return;
}
request.onerror = (event) => {
error(errorMessage, event.target.error);
reject(errorMessage);
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
});
}
function openDB() {
return new Promise((resolve, reject) => {
if (db) {
resolve(db);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => {
error("IndexedDB error:", event.target.error);
reject("Error opening IndexedDB.");
};
request.onsuccess = (event) => {
db = event.target.result;
log("IndexedDB opened successfully in worker.");
resolve(db);
};
request.onupgradeneeded = (event) => {
log("Upgrading IndexedDB in worker...");
const tempDb = event.target.result;
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
}
};
});
}
async function setCanvasState(id, state) {
const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
}
self.onmessage = async function(e) {
log('Message received from main thread:', e.data ? 'data received' : 'no data');
const { state, nodeId } = e.data;
if (!state || !nodeId) {
error('Invalid data received from main thread');
return;
}
try {
log(`Saving state for node: ${nodeId}`);
await setCanvasState(nodeId, state);
log(`State saved successfully for node: ${nodeId}`);
} catch (err) {
error(`Failed to save state for node: ${nodeId}`, err);
}
};

View File

@@ -1,7 +1,7 @@
[project]
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."
version = "1.3.0"
version = "1.3.3.1"
license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]