11 Commits

Author SHA1 Message Date
Dariusz L
f3027587d6 Improve release workflow to show commit history
The release workflow now fetches the full git history and displays all commit messages since the last tag in the release notes, instead of only the latest commit. Also bumps the project version to 1.3.8 in pyproject.toml.
2025-07-21 23:15:58 +02:00
Dariusz L
20d52b632a Refactor layer rendering into reusable methods
Moved layer drawing logic into CanvasLayers._drawLayer and _drawLayers methods, replacing repeated rendering code in CanvasIO and CanvasLayers. This improves maintainability and ensures consistent handling of layer properties such as blend mode, opacity, rotation, and flipping. Also, fixed layer serialization to only generate imageId when missing, and ensured new layers have flipH/flipV set when created from matted images.
2025-07-21 23:03:52 +02:00
Dariusz L
57bd1e1499 Add layer flipH/flipV properties and rendering support
Refactors horizontal and vertical mirroring to toggle flipH/flipV properties on layers instead of modifying image data. Updates rendering logic in CanvasLayers and CanvasRenderer to apply horizontal/vertical flipping via canvas transforms. Adds flipH and flipV to Layer type and includes them in state signature calculation.
2025-07-21 22:35:18 +02:00
Dariusz L
674879b497 Increase WebSocket max message size
Sets the max_msg_size parameter to 32MB for the WebSocketResponse in handle_canvas_websocket to support larger messages.
2025-07-21 21:52:21 +02:00
Dariusz L
98d4769ba1 Fix preview visibility initialization in Canvas
Replaces setPreviewVisibility(false) with direct assignment to previewVisible in Canvas and Canvas.ts. Adds initialization of preview state based on widget value in CanvasView and CanvasView.ts to ensure correct preview visibility on widget creation.
2025-07-21 20:45:13 +02:00
Dariusz L
5419acad27 Refactor auto-refresh toggle to node widget property
Replaces the local auto-refresh toggle with a node widget property 'auto_refresh_after_generation' in both JS and TS Canvas classes. Updates Python CanvasNode to include this property in the required config and removes 'hidden' from 'trigger' and 'node_id'. This change centralizes auto-refresh state in the node widget for better consistency and UI integration.
2025-07-21 20:31:46 +02:00
Dariusz L
db65c0c72e Improve bug report template with detailed log instructions
Expanded the bug report template to include step-by-step instructions for enabling DEBUG logs in both frontend and backend, and added a required field for backend logs. Clarified instructions for gathering browser console logs and emphasized the need for up-to-date versions before reporting. These changes aim to help users provide more actionable information for debugging.
2025-07-04 07:58:34 +02:00
Dariusz L
3c85b99167 Update pyproject.toml 2025-07-04 07:32:14 +02:00
Dariusz L
4e55bb25bc Improve matting error handling and user feedback
Adds specific handling for JSONDecodeError during model loading in Python, returning a clear error message if the model config is corrupted. Updates the JS/TS frontends to show a custom error dialog with details and copy-to-clipboard functionality instead of a simple alert, and ensures spinner removal is safe. This improves user experience and troubleshooting for matting model errors.
2025-07-04 07:31:33 +02:00
Dariusz L
5adc77471f project migration to typescript
Project migration to typescript
2025-07-04 04:22:51 +02:00
Dariusz L
3e4cdf10bc Refactor CanvasView: externalize styles and tooltips
Moved inline CSS from CanvasView.js to a dedicated canvas_view.css file and added dynamic stylesheet loading. Extracted tooltip and shortcut HTML into separate template files and implemented a ResourceManager utility for loading stylesheets and templates. Updated CanvasInteractions.js and CanvasView.js to use the new resource management and template loading approach, improving maintainability and modularity.
2025-07-03 17:27:00 +02:00
67 changed files with 13221 additions and 3650 deletions

View File

@@ -7,19 +7,46 @@ body:
attributes: attributes:
value: | value: |
**Thank you for reporting a bug!** **Thank you for reporting a bug!**
Please follow these steps to capture useful info: Please follow these steps to capture all necessary information:
### How to gather the necessary information: ### ✅ Before You Report:
🌐 **Browser & Version:** 1. Make sure you have the **latest versions**:
- Chrome: Click the three dots → Help → About Google Chrome - [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- Firefox: Click the three bars → Help → About Firefox - [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
- Edge: Click the three dots → Help and feedback → About Microsoft Edge 2. Gather the required logs:
🔗 **Where to find the latest versions of ComfyUI and LayerForge:** ### 🔍 Enable Debug Logs (for **full** logs):
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases/tag/v1.2.4) or [LayerForge from manager Comfyui](https://registry.comfy.org/publishers/azornes/nodes/layerforge) #### 1. Edit `config.js` (Frontend Logs):
Path:
```
ComfyUI/custom_nodes/Comfyui-LayerForge/js/config.js
```
Find:
```js
export const LOG_LEVEL = 'NONE';
```
Change to:
```js
export const LOG_LEVEL = 'DEBUG';
```
#### 2. Edit `config.py` (Backend Logs):
Path:
```
ComfyUI/custom_nodes/Comfyui-LayerForge/python/config.py
```
Find:
```python
LOG_LEVEL = 'NONE'
```
Change to:
```python
LOG_LEVEL = 'DEBUG'
```
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
Make sure you have the latest versions before reporting an issue.
- type: input - type: input
id: environment id: environment
attributes: attributes:
@@ -71,23 +98,36 @@ body:
validations: validations:
required: true required: true
- type: textarea
id: backend_logs
attributes:
label: ComfyUI (Backend) Logs
description: |
After enabling DEBUG logs, please:
1. Restart ComfyUI.
2. Reproduce the issue.
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
validations:
required: true
- type: textarea - type: textarea
id: console_logs id: console_logs
attributes: attributes:
label: Browser Console Logs label: Browser Console Logs
description: | description: |
**How to capture logs:** After enabling DEBUG logs:
- **Open console:** 1. Open Developer Tools → Console.
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J` - Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
Mac: `Cmd+Option+J` Mac: `Cmd+Option+J`
- Firefox (Win/Linux): `Ctrl+Shift+K` - Firefox (Win/Linux): `Ctrl+Shift+K`
Mac: `Cmd+Option+K` Mac: `Cmd+Option+K`
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C` - Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
- **Clear console** before reproducing: 2. Clear console (before reproducing):
- Chrome/Edge: click “🚫 Clear console” or press `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac) - Chrome/Edge: “🚫 Clear console” or `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac).
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux), Mac: `Cmd+K` - Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux) / `Cmd+K` (Mac).
- Safari: click 🗑 icon or press `Cmd+K` / `Ctrl+L` - Safari: 🗑 icon or `Cmd+K`.
- Reproduce the issue and paste new logs here. 3. Reproduce the issue.
4. Copy-paste the **TEXT** logs here (no screenshots).
validations: validations:
required: true required: true
@@ -95,4 +135,4 @@ body:
attributes: attributes:
value: | value: |
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually. **Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
To add media, simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files. Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.

View File

@@ -12,6 +12,8 @@ jobs:
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Pobierz pełną historię Git (potrzebne do git log)
- name: Extract base version from pyproject.toml - name: Extract base version from pyproject.toml
id: version id: version
@@ -33,12 +35,32 @@ jobs:
run: | run: |
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
- name: Get latest commit message # ZMIANA: Zamiast tylko ostatniego commita, pobierz historię commitów od ostatniego tagu
id: last_commit - name: Get commit history since last tag
id: commit_history
run: | run: |
msg=$(git log -1 --pretty=%B) # Znajdź ostatni tag (jeśli istnieje)
msg=${msg//$'\n'/\\n} LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "commit_msg=$msg" >> $GITHUB_OUTPUT
# Jeśli nie ma ostatniego tagu, użyj pustego (pobierz od początku repo)
if [ -z "$LAST_TAG" ]; then
RANGE="HEAD"
else
RANGE="$LAST_TAG..HEAD"
fi
# Pobierz listę commitów (tylko subject/tytuł, format: - Commit message)
HISTORY=$(git log --pretty=format:"- %s" $RANGE)
# Zastąp nowe linie na \\n, aby dobrze wyglądało w output
HISTORY=${HISTORY//$'\n'/\\n}
# Jeśli brak commitów, ustaw domyślną wiadomość
if [ -z "$HISTORY" ]; then
HISTORY="No changes since last release."
fi
echo "commit_history=$HISTORY" >> $GITHUB_OUTPUT
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
@@ -48,9 +70,9 @@ jobs:
body: | body: |
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}` 📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
📝 Last commit message: 📝 Changes since last release:
``` ```
${{ steps.last_commit.outputs.commit_msg }} ${{ steps.commit_history.outputs.commit_history }}
``` ```
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -175,8 +175,9 @@ class CanvasNode:
"required": { "required": {
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}), "fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}), "show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}), "auto_refresh_after_generation": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}),
"node_id": ("STRING", {"default": "0", "hidden": True}), "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
"node_id": ("STRING", {"default": "0"}),
}, },
"hidden": { "hidden": {
"prompt": ("PROMPT",), "prompt": ("PROMPT",),
@@ -238,7 +239,7 @@ class CanvasNode:
_processing_lock = threading.Lock() _processing_lock = threading.Lock()
def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None): def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
try: try:
@@ -391,7 +392,7 @@ class CanvasNode:
def setup_routes(cls): def setup_routes(cls):
@PromptServer.instance.routes.get("/layerforge/canvas_ws") @PromptServer.instance.routes.get("/layerforge/canvas_ws")
async def handle_canvas_websocket(request): async def handle_canvas_websocket(request):
ws = web.WebSocketResponse() ws = web.WebSocketResponse(max_msg_size=33554432)
await ws.prepare(request) await ws.prepare(request)
async for msg in ws: async for msg in ws:
@@ -612,11 +613,11 @@ class BiRefNetMatting:
"models") "models")
def load_model(self, model_path): def load_model(self, model_path):
from json.decoder import JSONDecodeError
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",
@@ -628,6 +629,13 @@ class BiRefNetMatting:
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")
except JSONDecodeError as e:
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
raise RuntimeError(
"The matting model's configuration file (config.json) appears to be corrupted. "
f"Please manually delete the directory '{full_model_path}' and try again. "
"This will force a fresh download of the model."
) from e
except Exception as e: except Exception as e:
log_error(f"Failed to load model from Hugging Face: {str(e)}") log_error(f"Failed to load model from Hugging Face: {str(e)}")
# Re-raise with a more informative message # Re-raise with a more informative message
@@ -799,6 +807,12 @@ async def matting(request):
"error": "Network Connection Error", "error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection." "details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
}, status=400) }, status=400)
except RuntimeError as e:
log_error(f"Runtime error during matting: {e}")
return web.json_response({
"error": "Matting Model Error",
"details": str(e)
}, status=500)
except Exception as e: except Exception as e:
log_exception(f"Error in matting endpoint: {e}") log_exception(f"Error in matting endpoint: {e}")
# Check for offline error message from Hugging Face # Check for offline error message from Hugging Face

View File

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

View File

@@ -1,33 +1,29 @@
import {app, ComfyApp} from "../../scripts/app.js"; // @ts-ignore
import {api} from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import {removeImage} from "./db.js"; import { MaskTool } from "./MaskTool.js";
import {MaskTool} from "./MaskTool.js"; import { CanvasState } from "./CanvasState.js";
import {CanvasState} from "./CanvasState.js"; import { CanvasInteractions } from "./CanvasInteractions.js";
import {CanvasInteractions} from "./CanvasInteractions.js"; import { CanvasLayers } from "./CanvasLayers.js";
import {CanvasLayers} from "./CanvasLayers.js"; import { CanvasLayersPanel } from "./CanvasLayersPanel.js";
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 {BatchPreviewManager} from "./BatchPreviewManager.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js"; import { debounce } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js"; import { CanvasMask } from "./CanvasMask.js";
import {CanvasSelection} from "./CanvasSelection.js"; import { CanvasSelection } from "./CanvasSelection.js";
const useChainCallback = (original, next) => { const useChainCallback = (original, next) => {
if (original === undefined || original === null) { if (original === undefined || original === null) {
return next; return next;
} }
return function(...args) { return function (...args) {
const originalReturn = original.apply(this, args); const originalReturn = original.apply(this, args);
const nextReturn = next.apply(this, args); const nextReturn = next.apply(this, args);
return nextReturn === undefined ? originalReturn : nextReturn; return nextReturn === undefined ? originalReturn : nextReturn;
}; };
}; };
const log = createModuleLogger('Canvas'); const log = createModuleLogger('Canvas');
/** /**
* Canvas - Fasada dla systemu rysowania * Canvas - Fasada dla systemu rysowania
* *
@@ -41,65 +37,72 @@ export class Canvas {
this.node = node; this.node = node;
this.widget = widget; this.widget = widget;
this.canvas = document.createElement('canvas'); this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d', {willReadFrequently: true}); const ctx = this.canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
this.ctx = ctx;
this.width = 512; this.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange;
this.lastMousePosition = {x: 0, y: 0}; this.onHistoryChange = callbacks.onHistoryChange;
this.lastMousePosition = { x: 0, y: 0 };
this.viewport = { this.viewport = {
x: -(this.width / 4), x: -(this.width / 4),
y: -(this.height / 4), y: -(this.height / 4),
zoom: 0.8, zoom: 0.8,
}; };
this.offscreenCanvas = document.createElement('canvas'); this.offscreenCanvas = document.createElement('canvas');
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false alpha: false
}); });
this.dataInitialized = false; this.dataInitialized = false;
this.pendingDataCheck = null; this.pendingDataCheck = null;
this.imageCache = new Map(); this.imageCache = new Map();
this.requestSaveState = () => { };
this._initializeModules(callbacks); this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
this.canvasMask = new CanvasMask(this);
this._setupCanvas(); this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(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);
this.batchPreviewManagers = [];
this.pendingBatchContext = null;
this.interaction = this.canvasInteractions.interaction; this.interaction = this.canvasInteractions.interaction;
this.previewVisible = false;
this.isMouseOver = false;
this._initializeModules();
this._setupCanvas();
log.debug('Canvas widget element:', this.node); log.debug('Canvas widget element:', this.node);
log.info('Canvas initialized', { log.info('Canvas initialized', {
nodeId: this.node.id, nodeId: this.node.id,
dimensions: {width: this.width, height: this.height}, dimensions: { width: this.width, height: this.height },
viewport: this.viewport viewport: this.viewport
}); });
this.previewVisible = false;
this.setPreviewVisibility(false);
} }
async waitForWidget(name, node, interval = 100, timeout = 20000) { async waitForWidget(name, node, interval = 100, timeout = 20000) {
const startTime = Date.now(); const startTime = Date.now();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const check = () => { const check = () => {
const widget = node.widgets.find(w => w.name === name); const widget = node.widgets.find((w) => w.name === name);
if (widget) { if (widget) {
resolve(widget); resolve(widget);
} else if (Date.now() - startTime > timeout) { }
else if (Date.now() - startTime > timeout) {
reject(new Error(`Widget "${name}" not found within timeout.`)); reject(new Error(`Widget "${name}" not found within timeout.`));
} else { }
else {
setTimeout(check, interval); setTimeout(check, interval);
} }
}; };
check(); check();
}); });
} }
/** /**
* Kontroluje widoczność podglądu canvas * Kontroluje widoczność podglądu canvas
* @param {boolean} visible - Czy podgląd ma być widoczny * @param {boolean} visible - Czy podgląd ma być widoczny
@@ -107,11 +110,9 @@ export class Canvas {
async setPreviewVisibility(visible) { async setPreviewVisibility(visible) {
this.previewVisible = visible; this.previewVisible = visible;
log.info("Canvas preview visibility set to:", visible); log.info("Canvas preview visibility set to:", visible);
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node); const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
if (imagePreviewWidget) { if (imagePreviewWidget) {
log.debug("Found $$canvas-image-preview widget, controlling visibility"); log.debug("Found $$canvas-image-preview widget, controlling visibility");
if (visible) { if (visible) {
if (imagePreviewWidget.options) { if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = false; imagePreviewWidget.options.hidden = false;
@@ -125,7 +126,8 @@ export class Canvas {
imagePreviewWidget.computeSize = function () { imagePreviewWidget.computeSize = function () {
return [0, 250]; // Szerokość 0 (auto), wysokość 250 return [0, 250]; // Szerokość 0 (auto), wysokość 250
}; };
} else { }
else {
if (imagePreviewWidget.options) { if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = true; imagePreviewWidget.options.hidden = true;
} }
@@ -135,44 +137,27 @@ export class Canvas {
if ('hidden' in imagePreviewWidget) { if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = true; imagePreviewWidget.hidden = true;
} }
imagePreviewWidget.computeSize = function () { imagePreviewWidget.computeSize = function () {
return [0, 0]; // Szerokość 0, wysokość 0 return [0, 0]; // Szerokość 0, wysokość 0
}; };
} }
this.render() this.render();
} else { }
else {
log.warn("$$canvas-image-preview widget not found in Canvas.js"); log.warn("$$canvas-image-preview widget not found in Canvas.js");
} }
} }
/** /**
* Inicjalizuje moduły systemu canvas * Inicjalizuje moduły systemu canvas
* @private * @private
*/ */
_initializeModules(callbacks) { _initializeModules() {
log.debug('Initializing Canvas modules...'); log.debug('Initializing Canvas modules...');
// 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(), 500);
this._setupAutoRefreshHandlers();
this._addAutoRefreshToggle();
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasMask = new CanvasMask(this);
this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(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);
this.batchPreviewManagers = [];
this.pendingBatchContext = null;
log.debug('Canvas modules initialized successfully'); log.debug('Canvas modules initialized successfully');
} }
/** /**
* Konfiguruje podstawowe właściwości canvas * Konfiguruje podstawowe właściwości canvas
* @private * @private
@@ -181,14 +166,11 @@ export class Canvas {
this.initCanvas(); this.initCanvas();
this.canvasInteractions.setupEventListeners(); this.canvasInteractions.setupEventListeners();
this.canvasIO.initNodeData(); this.canvasIO.initNodeData();
this.layers = this.layers.map((layer) => ({
this.layers = this.layers.map(layer => ({
...layer, ...layer,
opacity: 1 opacity: 1
})); }));
} }
/** /**
* Ładuje stan canvas z bazy danych * Ładuje stan canvas z bazy danych
*/ */
@@ -201,24 +183,21 @@ export class Canvas {
} }
this.saveState(); this.saveState();
this.render(); this.render();
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu // Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
} }
/** /**
* Zapisuje obecny stan * Zapisuje obecny stan
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii * @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
*/ */
saveState(replaceLast = false) { 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.canvasState.saveState(replaceLast);
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
} }
/** /**
* Cofnij ostatnią operację * Cofnij ostatnią operację
*/ */
@@ -226,21 +205,16 @@ export class Canvas {
log.info('Performing undo operation'); log.info('Performing undo operation');
const historyInfo = this.canvasState.getHistoryInfo(); const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before undo:', historyInfo); log.debug('History state before undo:', historyInfo);
this.canvasState.undo(); this.canvasState.undo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw // Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged(); this.canvasLayersPanel.onSelectionChanged();
} }
log.debug('Undo completed, layers count:', this.layers.length); log.debug('Undo completed, layers count:', this.layers.length);
} }
/** /**
* Ponów cofniętą operację * Ponów cofniętą operację
*/ */
@@ -248,27 +222,22 @@ export class Canvas {
log.info('Performing redo operation'); log.info('Performing redo operation');
const historyInfo = this.canvasState.getHistoryInfo(); const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before redo:', historyInfo); log.debug('History state before redo:', historyInfo);
this.canvasState.redo(); this.canvasState.redo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw // Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged(); this.canvasLayersPanel.onSelectionChanged();
} }
log.debug('Redo completed, layers count:', this.layers.length); log.debug('Redo completed, layers count:', this.layers.length);
} }
/** /**
* Renderuje canvas * Renderuje canvas
*/ */
render() { render() {
this.canvasRenderer.render(); this.canvasRenderer.render();
} }
/** /**
* Dodaje warstwę z obrazem * Dodaje warstwę z obrazem
* @param {Image} image - Obraz do dodania * @param {Image} image - Obraz do dodania
@@ -277,49 +246,40 @@ export class Canvas {
*/ */
async addLayer(image, layerProps = {}, addMode = 'default') { async addLayer(image, layerProps = {}, addMode = 'default') {
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode); const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
// Powiadom panel warstw o dodaniu nowej warstwy // Powiadom panel warstw o dodaniu nowej warstwy
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
return result; return result;
} }
/** /**
* Usuwa wybrane warstwy * Usuwa wybrane warstwy
*/ */
removeLayersByIds(layerIds) { removeLayersByIds(layerIds) {
if (!layerIds || layerIds.length === 0) return; if (!layerIds || layerIds.length === 0)
return;
const initialCount = this.layers.length; const initialCount = this.layers.length;
this.saveState(); this.saveState();
this.layers = this.layers.filter(l => !layerIds.includes(l.id)); this.layers = this.layers.filter((l) => !layerIds.includes(l.id));
// If the current selection was part of the removal, clear it // If the current selection was part of the removal, clear it
const newSelection = this.canvasSelection.selectedLayers.filter(l => !layerIds.includes(l.id)); const newSelection = this.canvasSelection.selectedLayers.filter((l) => !layerIds.includes(l.id));
this.canvasSelection.updateSelection(newSelection); this.canvasSelection.updateSelection(newSelection);
this.render(); this.render();
this.saveState(); this.saveState();
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`); log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
} }
removeSelectedLayers() { removeSelectedLayers() {
return this.canvasSelection.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() {
return this.canvasSelection.duplicateSelectedLayers(); return this.canvasSelection.duplicateSelectedLayers();
} }
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -328,14 +288,12 @@ export class Canvas {
updateSelection(newSelection) { updateSelection(newSelection) {
return this.canvasSelection.updateSelection(newSelection); return this.canvasSelection.updateSelection(newSelection);
} }
/** /**
* 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) {
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
} }
/** /**
* Zmienia rozmiar obszaru wyjściowego * Zmienia rozmiar obszaru wyjściowego
* @param {number} width - Nowa szerokość * @param {number} width - Nowa szerokość
@@ -345,34 +303,33 @@ export class Canvas {
updateOutputAreaSize(width, height, saveHistory = true) { updateOutputAreaSize(width, height, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
} }
/** /**
* Eksportuje spłaszczony canvas jako blob * Eksportuje spłaszczony canvas jako blob
*/ */
async getFlattenedCanvasAsBlob() { async getFlattenedCanvasAsBlob() {
return this.canvasLayers.getFlattenedCanvasAsBlob(); return this.canvasLayers.getFlattenedCanvasAsBlob();
} }
/** /**
* Eksportuje spłaszczony canvas z maską jako kanałem alpha * Eksportuje spłaszczony canvas z maską jako kanałem alpha
*/ */
async getFlattenedCanvasWithMaskAsBlob() { async getFlattenedCanvasWithMaskAsBlob() {
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
} }
/** /**
* Importuje najnowszy obraz * Importuje najnowszy obraz
*/ */
async importLatestImage() { async importLatestImage() {
return this.canvasIO.importLatestImage(); return this.canvasIO.importLatestImage();
} }
_setupAutoRefreshHandlers() {
_addAutoRefreshToggle() {
let autoRefreshEnabled = false;
let lastExecutionStartTime = 0; let lastExecutionStartTime = 0;
// Helper function to get auto-refresh value from node widget
const getAutoRefreshValue = () => {
const widget = this.node.widgets.find((w) => w.name === 'auto_refresh_after_generation');
return widget ? widget.value : false;
};
const handleExecutionStart = () => { const handleExecutionStart = () => {
if (autoRefreshEnabled) { if (getAutoRefreshValue()) {
lastExecutionStartTime = Date.now(); lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch // Store a snapshot of the context for the upcoming batch
this.pendingBatchContext = { this.pendingBatchContext = {
@@ -393,62 +350,35 @@ export class Canvas {
this.render(); // Trigger render to show the pending outline immediately this.render(); // Trigger render to show the pending outline immediately
} }
}; };
const handleExecutionSuccess = async () => { const handleExecutionSuccess = async () => {
if (autoRefreshEnabled) { if (getAutoRefreshValue()) {
log.info('Auto-refresh triggered, importing latest images.'); log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) { if (!this.pendingBatchContext) {
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution."); log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
return; return;
} }
// Use the captured output area for image import // Use the captured output area for image import
const newLayers = await this.canvasIO.importLatestImages( const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea);
lastExecutionStartTime,
this.pendingBatchContext.outputArea
);
if (newLayers && newLayers.length > 1) { if (newLayers && newLayers.length > 1) {
const newManager = new BatchPreviewManager( const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea);
this,
this.pendingBatchContext.spawnPosition,
this.pendingBatchContext.outputArea
);
this.batchPreviewManagers.push(newManager); this.batchPreviewManagers.push(newManager);
newManager.show(newLayers); newManager.show(newLayers);
} }
// Consume the context // Consume the context
this.pendingBatchContext = null; this.pendingBatchContext = null;
// Final render to clear the outline if it was the last one // Final render to clear the outline if it was the last one
this.render(); 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_start', handleExecutionStart);
api.addEventListener('execution_success', handleExecutionSuccess); api.addEventListener('execution_success', handleExecutionSuccess);
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => { this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
log.info('Node removed, cleaning up auto-refresh listeners.'); log.info('Node removed, cleaning up auto-refresh listeners.');
api.removeEventListener('execution_start', handleExecutionStart); api.removeEventListener('execution_start', handleExecutionStart);
api.removeEventListener('execution_success', handleExecutionSuccess); api.removeEventListener('execution_success', handleExecutionSuccess);
}); });
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
} }
/** /**
* Uruchamia edytor masek * Uruchamia edytor masek
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora * @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
@@ -457,8 +387,6 @@ export class Canvas {
async startMaskEditor(predefinedMask = null, sendCleanImage = true) { async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage); return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
} }
/** /**
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
@@ -473,29 +401,24 @@ export class Canvas {
this.canvas.tabIndex = 0; this.canvas.tabIndex = 0;
this.canvas.style.outline = 'none'; this.canvas.style.outline = 'none';
} }
/** /**
* Pobiera współrzędne myszy w układzie świata * Pobiera współrzędne myszy w układzie świata
* @param {MouseEvent} e - Zdarzenie myszy * @param {MouseEvent} e - Zdarzenie myszy
*/ */
getMouseWorldCoordinates(e) { getMouseWorldCoordinates(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left; const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top; const mouseY_DOM = e.clientY - rect.top;
if (!this.offscreenCanvas)
throw new Error("Offscreen canvas not initialized");
const scaleX = this.offscreenCanvas.width / rect.width; const scaleX = this.offscreenCanvas.width / rect.width;
const scaleY = this.offscreenCanvas.height / rect.height; const scaleY = this.offscreenCanvas.height / rect.height;
const mouseX_Buffer = mouseX_DOM * scaleX; const mouseX_Buffer = mouseX_DOM * scaleX;
const mouseY_Buffer = mouseY_DOM * scaleY; const mouseY_Buffer = mouseY_DOM * scaleY;
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x; const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y; const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
return { x: worldX, y: worldY };
return {x: worldX, y: worldY};
} }
/** /**
* Pobiera współrzędne myszy w układzie widoku * Pobiera współrzędne myszy w układzie widoku
* @param {MouseEvent} e - Zdarzenie myszy * @param {MouseEvent} e - Zdarzenie myszy
@@ -504,23 +427,18 @@ export class Canvas {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left; const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top; const mouseY_DOM = e.clientY - rect.top;
const scaleX = this.canvas.width / rect.width; const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height; const scaleY = this.canvas.height / rect.height;
const mouseX_Canvas = mouseX_DOM * scaleX; const mouseX_Canvas = mouseX_DOM * scaleX;
const mouseY_Canvas = mouseY_DOM * scaleY; const mouseY_Canvas = mouseY_DOM * scaleY;
return { x: mouseX_Canvas, y: mouseY_Canvas };
return {x: mouseX_Canvas, y: mouseY_Canvas};
} }
/** /**
* Aktualizuje zaznaczenie po operacji historii * Aktualizuje zaznaczenie po operacji historii
*/ */
updateSelectionAfterHistory() { updateSelectionAfterHistory() {
return this.canvasSelection.updateSelectionAfterHistory(); return this.canvasSelection.updateSelectionAfterHistory();
} }
/** /**
* Aktualizuje przyciski historii * Aktualizuje przyciski historii
*/ */
@@ -533,7 +451,6 @@ export class Canvas {
}); });
} }
} }
/** /**
* Zwiększa licznik operacji (dla garbage collection) * Zwiększa licznik operacji (dla garbage collection)
*/ */
@@ -542,7 +459,6 @@ export class Canvas {
this.imageReferenceManager.incrementOperationCount(); this.imageReferenceManager.incrementOperationCount();
} }
} }
/** /**
* Czyści zasoby canvas * Czyści zasoby canvas
*/ */
@@ -552,7 +468,6 @@ export class Canvas {
} }
log.info("Canvas destroyed"); log.info("Canvas destroyed");
} }
/** /**
* Powiadamia o zmianie stanu * Powiadamia o zmianie stanu
* @private * @private

View File

@@ -1,91 +1,70 @@
import {createCanvas} from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {webSocketManager} from "./utils/WebSocketManager.js"; import { webSocketManager } from "./utils/WebSocketManager.js";
const log = createModuleLogger('CanvasIO'); const log = createModuleLogger('CanvasIO');
export class CanvasIO { export class CanvasIO {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this._saveInProgress = null; this._saveInProgress = null;
} }
async saveToServer(fileName, outputMode = 'disk') { async saveToServer(fileName, outputMode = 'disk') {
if (outputMode === 'disk') { if (outputMode === 'disk') {
if (!window.canvasSaveStates) { if (!window.canvasSaveStates) {
window.canvasSaveStates = new Map(); window.canvasSaveStates = new Map();
} }
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const saveKey = `${nodeId}_${fileName}`; const saveKey = `${nodeId}_${fileName}`;
if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) { if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) {
log.warn(`Save already in progress for node ${nodeId}, waiting...`); log.warn(`Save already in progress for node ${nodeId}, waiting...`);
return this._saveInProgress || window.canvasSaveStates.get(saveKey); return this._saveInProgress || window.canvasSaveStates.get(saveKey);
} }
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`); log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
this._saveInProgress = this._performSave(fileName, outputMode); this._saveInProgress = this._performSave(fileName, outputMode);
window.canvasSaveStates.set(saveKey, this._saveInProgress); window.canvasSaveStates.set(saveKey, this._saveInProgress);
try { try {
return await this._saveInProgress; return await this._saveInProgress;
} finally { }
finally {
this._saveInProgress = null; this._saveInProgress = null;
window.canvasSaveStates.delete(saveKey); window.canvasSaveStates.delete(saveKey);
log.debug(`Save completed for node ${nodeId}, lock released`); log.debug(`Save completed for node ${nodeId}, lock released`);
} }
} else { }
else {
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`); log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
return this._performSave(fileName, outputMode); return this._performSave(fileName, outputMode);
} }
} }
async _performSave(fileName, outputMode) { async _performSave(fileName, outputMode) {
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0) {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true); return Promise.resolve(true);
} }
await this.canvas.canvasState.saveStateToDB(true); await this.canvas.canvasState.saveStateToDB();
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50; const delay = (nodeId % 10) * 50;
if (delay > 0) { if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas'); const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width; visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height; visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true}); const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx)
throw new Error("Could not create visibility context");
if (!maskCtx)
throw new Error("Could not create mask context");
if (!tempCtx)
throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; maskCtx.fillStyle = '#ffffff';
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
log.debug(`Canvas contexts created, starting layer rendering`); log.debug(`Canvas contexts created, starting layer rendering`);
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
log.debug(`Processing ${sortedLayers.length} layers in order`); this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
sortedLayers.forEach((layer, index) => { log.debug(`Finished rendering layers`);
log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`);
log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
tempCtx.restore();
log.debug(`Layer ${index} rendered successfully`);
visibilityCtx.save();
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
visibilityCtx.restore();
});
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) { for (let i = 0; i < visibilityData.data.length; i += 4) {
@@ -94,48 +73,35 @@ export class CanvasIO {
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; maskData.data[i + 3] = 255;
} }
maskCtx.putImageData(maskData, 0, 0); maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask(); const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) { if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx)
throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x; const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y; const maskY = this.canvas.maskTool.y;
log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`);
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
const sourceY = Math.max(0, -maskY); const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX); // Where in the output canvas to start writing const destX = Math.max(0, maskX); // Where in the output canvas to start writing
const destY = Math.max(0, maskY); const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, // Available width in source
const copyWidth = Math.min( this.canvas.width - destX // Available width in destination
toolMaskCanvas.width - sourceX, // Available width in source
this.canvas.width - destX // Available width in destination
); );
const copyHeight = Math.min( const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source
toolMaskCanvas.height - sourceY, // Available height in source this.canvas.height - destY // Available height in destination
this.canvas.height - destY // Available height in destination
); );
if (copyWidth > 0 && copyHeight > 0) { if (copyWidth > 0 && copyHeight > 0) {
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`);
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
tempMaskCtx.drawImage( destX, destY, copyWidth, copyHeight // Destination rectangle
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
destX, destY, copyWidth, copyHeight // Destination rectangle
); );
} }
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) { for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3]; const alpha = tempMaskData.data[i + 3];
@@ -143,7 +109,6 @@ export class CanvasIO {
tempMaskData.data[i + 3] = alpha; tempMaskData.data[i + 3] = alpha;
} }
tempMaskCtx.putImageData(tempMaskData, 0, 0); tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'source-over'; maskCtx.globalCompositeOperation = 'source-over';
maskCtx.drawImage(tempMaskCanvas, 0, 0); maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
@@ -151,60 +116,59 @@ export class CanvasIO {
const imageData = tempCanvas.toDataURL('image/png'); const imageData = tempCanvas.toDataURL('image/png');
const maskData = maskCanvas.toDataURL('image/png'); const maskData = maskCanvas.toDataURL('image/png');
log.info("Returning image and mask data as base64 for RAM mode."); log.info("Returning image and mask data as base64 for RAM mode.");
resolve({image: imageData, mask: maskData}); resolve({ image: imageData, mask: maskData });
return; return;
} }
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png'); const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
log.info(`Saving image without mask as: ${fileNameWithoutMask}`); log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
tempCanvas.toBlob(async (blobWithoutMask) => { tempCanvas.toBlob(async (blobWithoutMask) => {
if (!blobWithoutMask)
return;
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
const formDataWithoutMask = new FormData(); const formDataWithoutMask = new FormData();
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
formDataWithoutMask.append("overwrite", "true"); formDataWithoutMask.append("overwrite", "true");
try { try {
const response = await fetch("/upload/image", { const response = await fetch("/upload/image", {
method: "POST", method: "POST",
body: formDataWithoutMask, body: formDataWithoutMask,
}); });
log.debug(`Image without mask upload response: ${response.status}`); log.debug(`Image without mask upload response: ${response.status}`);
} catch (error) { }
catch (error) {
log.error(`Error uploading image without mask:`, error); log.error(`Error uploading image without mask:`, error);
} }
}, "image/png"); }, "image/png");
log.info(`Saving main image as: ${fileName}`); log.info(`Saving main image as: ${fileName}`);
tempCanvas.toBlob(async (blob) => { tempCanvas.toBlob(async (blob) => {
if (!blob)
return;
log.debug(`Created blob for main image, size: ${blob.size} bytes`); log.debug(`Created blob for main image, size: ${blob.size} bytes`);
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, fileName); formData.append("image", blob, fileName);
formData.append("overwrite", "true"); formData.append("overwrite", "true");
try { try {
const resp = await fetch("/upload/image", { const resp = await fetch("/upload/image", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
log.debug(`Main image upload response: ${resp.status}`); log.debug(`Main image upload response: ${resp.status}`);
if (resp.status === 200) { if (resp.status === 200) {
const maskFileName = fileName.replace('.png', '_mask.png'); const maskFileName = fileName.replace('.png', '_mask.png');
log.info(`Saving mask as: ${maskFileName}`); log.info(`Saving mask as: ${maskFileName}`);
maskCanvas.toBlob(async (maskBlob) => { maskCanvas.toBlob(async (maskBlob) => {
if (!maskBlob)
return;
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
const maskFormData = new FormData(); const maskFormData = new FormData();
maskFormData.append("image", maskBlob, maskFileName); maskFormData.append("image", maskBlob, maskFileName);
maskFormData.append("overwrite", "true"); maskFormData.append("overwrite", "true");
try { try {
const maskResp = await fetch("/upload/image", { const maskResp = await fetch("/upload/image", {
method: "POST", method: "POST",
body: maskFormData, body: maskFormData,
}); });
log.debug(`Mask upload response: ${maskResp.status}`); log.debug(`Mask upload response: ${maskResp.status}`);
if (maskResp.status === 200) { if (maskResp.status === 200) {
const data = await resp.json(); const data = await resp.json();
if (this.canvas.widget) { if (this.canvas.widget) {
@@ -212,57 +176,48 @@ export class CanvasIO {
} }
log.info(`All files saved successfully, widget value set to: ${fileName}`); log.info(`All files saved successfully, widget value set to: ${fileName}`);
resolve(true); resolve(true);
} else { }
else {
log.error(`Error saving mask: ${maskResp.status}`); log.error(`Error saving mask: ${maskResp.status}`);
resolve(false); resolve(false);
} }
} catch (error) { }
catch (error) {
log.error(`Error saving mask:`, error); log.error(`Error saving mask:`, error);
resolve(false); resolve(false);
} }
}, "image/png"); }, "image/png");
} else { }
else {
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`); log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
resolve(false); resolve(false);
} }
} catch (error) { }
catch (error) {
log.error(`Error uploading main image:`, error); log.error(`Error uploading main image:`, error);
resolve(false); resolve(false);
} }
}, "image/png"); }, "image/png");
}); });
} }
async _renderOutputData() { async _renderOutputData() {
return new Promise((resolve) => { return new Promise((resolve) => {
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas'); const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width; visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height; visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true}); const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx)
throw new Error("Could not create visibility context");
if (!maskCtx)
throw new Error("Could not create mask context");
if (!tempCtx)
throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked) maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
sortedLayers.forEach((layer) => {
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
tempCtx.restore();
visibilityCtx.save();
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
visibilityCtx.restore();
});
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) { for (let i = 0; i < visibilityData.data.length; i += 4) {
@@ -272,64 +227,45 @@ export class CanvasIO {
maskData.data[i + 3] = 255; // Solid mask maskData.data[i + 3] = 255; // Solid mask
} }
maskCtx.putImageData(maskData, 0, 0); maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask(); const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) { if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx)
throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x; const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y; const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`); log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX); const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY); const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX); const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY); const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX); const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY); const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) { if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage( tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
} }
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) { for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3]; const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha tempMaskData.data[i + 3] = 255; // Solid alpha
} }
tempMaskCtx.putImageData(tempMaskData, 0, 0); tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen'; maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0); maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
const imageDataUrl = tempCanvas.toDataURL('image/png'); const imageDataUrl = tempCanvas.toDataURL('image/png');
const maskDataUrl = maskCanvas.toDataURL('image/png'); const maskDataUrl = maskCanvas.toDataURL('image/png');
resolve({ image: imageDataUrl, mask: maskDataUrl });
resolve({image: imageDataUrl, mask: maskDataUrl});
}); });
} }
async sendDataViaWebSocket(nodeId) { async sendDataViaWebSocket(nodeId) {
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
const { image, mask } = await this._renderOutputData();
const {image, mask} = await this._renderOutputData();
try { try {
log.info(`Sending data for node ${nodeId}...`); log.info(`Sending data for node ${nodeId}...`);
await webSocketManager.sendMessage({ await webSocketManager.sendMessage({
@@ -338,205 +274,167 @@ export class CanvasIO {
image: image, image: image,
mask: mask, mask: mask,
}, true); // `true` requires an acknowledgment }, true); // `true` requires an acknowledgment
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`); log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
return true; return true;
} catch (error) { }
catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, error); log.error(`Failed to send data for node ${nodeId}:`, error);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`); throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
} }
} }
async addInputToCanvas(inputImage, inputMask) { async addInputToCanvas(inputImage, inputMask) {
try { try {
log.debug("Adding input to canvas:", {inputImage}); log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(inputImage.width, inputImage.height); if (!tempCtx)
throw new Error("Could not create temp context");
const imgData = new ImageData( const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
inputImage.data,
inputImage.width,
inputImage.height
);
tempCtx.putImageData(imgData, 0, 0); tempCtx.putImageData(imgData, 0, 0);
const image = new Image(); const image = new Image();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
image.onload = resolve; image.onload = resolve;
image.onerror = reject; image.onerror = reject;
image.src = tempCanvas.toDataURL(); image.src = tempCanvas.toDataURL();
}); });
const scale = Math.min(this.canvas.width / inputImage.width * 0.8, this.canvas.height / inputImage.height * 0.8);
const scale = Math.min(
this.canvas.width / inputImage.width * 0.8,
this.canvas.height / inputImage.height * 0.8
);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, { const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2, x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2, y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale, width: inputImage.width * scale,
height: inputImage.height * scale, height: inputImage.height * scale,
}); });
if (inputMask && layer) {
if (inputMask) {
layer.mask = inputMask.data; layer.mask = inputMask.data;
} }
log.info("Layer added successfully"); log.info("Layer added successfully");
return true; return true;
}
} catch (error) { catch (error) {
log.error("Error in addInputToCanvas:", error); log.error("Error in addInputToCanvas:", error);
throw error; throw error;
} }
} }
async convertTensorToImage(tensor) { async convertTensorToImage(tensor) {
try { try {
log.debug("Converting tensor to image:", tensor); log.debug("Converting tensor to image:", tensor);
if (!tensor || !tensor.data || !tensor.width || !tensor.height) { if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
throw new Error("Invalid tensor data"); throw new Error("Invalid tensor data");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
canvas.width = tensor.width; canvas.width = tensor.width;
canvas.height = tensor.height; canvas.height = tensor.height;
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
const imageData = new ImageData(
new Uint8ClampedArray(tensor.data),
tensor.width,
tensor.height
);
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e)); img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} catch (error) { }
catch (error) {
log.error("Error converting tensor to image:", error); log.error("Error converting tensor to image:", error);
throw error; throw error;
} }
} }
async convertTensorToMask(tensor) { async convertTensorToMask(tensor) {
if (!tensor || !tensor.data) { if (!tensor || !tensor.data) {
throw new Error("Invalid mask tensor"); throw new Error("Invalid mask tensor");
} }
try { try {
return new Float32Array(tensor.data); return new Float32Array(tensor.data);
} catch (error) { }
catch (error) {
throw new Error(`Mask conversion failed: ${error.message}`); throw new Error(`Mask conversion failed: ${error.message}`);
} }
} }
async initNodeData() { async initNodeData() {
try { try {
log.info("Starting node data initialization..."); log.info("Starting node data initialization...");
if (!this.canvas.node || !this.canvas.node.inputs) { if (!this.canvas.node || !this.canvas.node.inputs) {
log.debug("Node or inputs not ready"); log.debug("Node or inputs not ready");
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) { if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const imageLinkId = this.canvas.node.inputs[0].link; const imageLinkId = this.canvas.node.inputs[0].link;
const imageData = app.nodeOutputs[imageLinkId]; const imageData = window.app.nodeOutputs[imageLinkId];
if (imageData) { if (imageData) {
log.debug("Found image data:", imageData); log.debug("Found image data:", imageData);
await this.processImageData(imageData); await this.processImageData(imageData);
this.canvas.dataInitialized = true; this.canvas.dataInitialized = true;
} else { }
else {
log.debug("Image data not available yet"); log.debug("Image data not available yet");
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) { if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link; const maskLinkId = this.canvas.node.inputs[1].link;
const maskData = app.nodeOutputs[maskLinkId]; const maskData = window.app.nodeOutputs[maskLinkId];
if (maskData) { if (maskData) {
log.debug("Found mask data:", maskData); log.debug("Found mask data:", maskData);
await this.processMaskData(maskData); await this.processMaskData(maskData);
} }
} }
}
} catch (error) { catch (error) {
log.error("Error in initNodeData:", error); log.error("Error in initNodeData:", error);
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
scheduleDataCheck() { scheduleDataCheck() {
if (this.canvas.pendingDataCheck) { if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck); clearTimeout(this.canvas.pendingDataCheck);
} }
this.canvas.pendingDataCheck = window.setTimeout(() => {
this.canvas.pendingDataCheck = setTimeout(() => {
this.canvas.pendingDataCheck = null; this.canvas.pendingDataCheck = null;
if (!this.canvas.dataInitialized) { if (!this.canvas.dataInitialized) {
this.initNodeData(); this.initNodeData();
} }
}, 1000); }, 1000);
} }
async processImageData(imageData) { async processImageData(imageData) {
try { try {
if (!imageData) return; if (!imageData)
return;
log.debug("Processing image data:", { log.debug("Processing image data:", {
type: typeof imageData, type: typeof imageData,
isArray: Array.isArray(imageData), isArray: Array.isArray(imageData),
shape: imageData.shape, shape: imageData.shape,
hasData: !!imageData.data hasData: !!imageData.data
}); });
if (Array.isArray(imageData)) { if (Array.isArray(imageData)) {
imageData = imageData[0]; imageData = imageData[0];
} }
if (!imageData.shape || !imageData.data) { if (!imageData.shape || !imageData.data) {
throw new Error("Invalid image data format"); throw new Error("Invalid image data format");
} }
const originalWidth = imageData.shape[2]; const originalWidth = imageData.shape[2];
const originalHeight = imageData.shape[1]; const originalHeight = imageData.shape[1];
const scale = Math.min(this.canvas.width / originalWidth * 0.8, this.canvas.height / originalHeight * 0.8);
const scale = Math.min(
this.canvas.width / originalWidth * 0.8,
this.canvas.height / originalHeight * 0.8
);
const convertedData = this.convertTensorToImageData(imageData); const convertedData = this.convertTensorToImageData(imageData);
if (convertedData) { if (convertedData) {
const image = await this.createImageFromData(convertedData); const image = await this.createImageFromData(convertedData);
this.addScaledLayer(image, scale); this.addScaledLayer(image, scale);
log.info("Image layer added successfully with scale:", scale); log.info("Image layer added successfully with scale:", scale);
} }
} catch (error) { }
catch (error) {
log.error("Error processing image data:", error); log.error("Error processing image data:", error);
throw error; throw error;
} }
} }
addScaledLayer(image, scale) { addScaledLayer(image, scale) {
try { try {
const scaledWidth = image.width * scale; const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale; const scaledHeight = image.height * scale;
const layer = { const layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: image, image: image,
x: (this.canvas.width - scaledWidth) / 2, x: (this.canvas.width - scaledWidth) / 2,
y: (this.canvas.height - scaledHeight) / 2, y: (this.canvas.height - scaledHeight) / 2,
@@ -545,31 +443,30 @@ export class CanvasIO {
rotation: 0, rotation: 0,
zIndex: this.canvas.layers.length, zIndex: this.canvas.layers.length,
originalWidth: image.width, originalWidth: image.width,
originalHeight: image.height originalHeight: image.height,
blendMode: 'normal',
opacity: 1
}; };
this.canvas.layers.push(layer); this.canvas.layers.push(layer);
this.canvas.selectedLayer = layer; this.canvas.updateSelection([layer]);
this.canvas.render(); this.canvas.render();
log.debug("Scaled layer added:", { log.debug("Scaled layer added:", {
originalSize: `${image.width}x${image.height}`, originalSize: `${image.width}x${image.height}`,
scaledSize: `${scaledWidth}x${scaledHeight}`, scaledSize: `${scaledWidth}x${scaledHeight}`,
scale: scale scale: scale
}); });
} catch (error) { }
catch (error) {
log.error("Error adding scaled layer:", error); log.error("Error adding scaled layer:", error);
throw error; throw error;
} }
} }
convertTensorToImageData(tensor) { convertTensorToImageData(tensor) {
try { try {
const shape = tensor.shape; const shape = tensor.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
log.debug("Converting tensor:", { log.debug("Converting tensor:", {
shape: shape, shape: shape,
dataRange: { dataRange: {
@@ -577,56 +474,50 @@ export class CanvasIO {
max: tensor.max_val max: tensor.max_val
} }
}); });
const imageData = new ImageData(width, height); const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4); const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data; const flatData = tensor.data;
const pixelCount = width * height; const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) { for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4; const pixelIndex = i * 4;
const tensorIndex = i * channels; const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c]; const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255); data[pixelIndex + c] = Math.round(normalizedValue * 255);
} }
data[pixelIndex + 3] = 255; data[pixelIndex + 3] = 255;
} }
imageData.data.set(data); imageData.data.set(data);
return imageData; return imageData;
} catch (error) { }
catch (error) {
log.error("Error converting tensor:", error); log.error("Error converting tensor:", error);
return null; return null;
} }
} }
async createImageFromData(imageData) { async createImageFromData(imageData) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = imageData.width; canvas.width = imageData.width;
canvas.height = imageData.height; canvas.height = imageData.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = reject;
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} }
async retryDataLoad(maxRetries = 3, delay = 1000) { async retryDataLoad(maxRetries = 3, delay = 1000) {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
await this.initNodeData(); await this.initNodeData();
return; return;
} catch (error) { }
catch (error) {
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error); log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
if (i < maxRetries - 1) { if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
@@ -635,32 +526,28 @@ export class CanvasIO {
} }
log.error("Failed to load data after", maxRetries, "retries"); log.error("Failed to load data after", maxRetries, "retries");
} }
async processMaskData(maskData) { async processMaskData(maskData) {
try { try {
if (!maskData) return; if (!maskData)
return;
log.debug("Processing mask data:", maskData); log.debug("Processing mask data:", maskData);
if (Array.isArray(maskData)) { if (Array.isArray(maskData)) {
maskData = maskData[0]; maskData = maskData[0];
} }
if (!maskData.shape || !maskData.data) { if (!maskData.shape || !maskData.data) {
throw new Error("Invalid mask data format"); throw new Error("Invalid mask data format");
} }
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
if (this.canvas.selectedLayer) {
const maskTensor = await this.convertTensorToMask(maskData); const maskTensor = await this.convertTensorToMask(maskData);
this.canvas.selectedLayer.mask = maskTensor; this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor;
this.canvas.render(); this.canvas.render();
log.info("Mask applied to selected layer"); log.info("Mask applied to selected layer");
} }
} catch (error) { }
catch (error) {
log.error("Error processing mask data:", error); log.error("Error processing mask data:", error);
} }
} }
async loadImageFromCache(base64Data) { async loadImageFromCache(base64Data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@@ -669,72 +556,69 @@ export class CanvasIO {
img.src = base64Data; img.src = base64Data;
}); });
} }
async importImage(cacheData) { async importImage(cacheData) {
try { try {
log.info("Starting image import with cache data"); log.info("Starting image import with cache data");
const img = await this.loadImageFromCache(cacheData.image); const img = await this.loadImageFromCache(cacheData.image);
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null; const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
const scale = Math.min(this.canvas.width / img.width * 0.8, this.canvas.height / img.height * 0.8);
const scale = Math.min(
this.canvas.width / img.width * 0.8,
this.canvas.height / img.height * 0.8
);
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.width = img.width;
tempCanvas.height = img.height; tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create temp context");
tempCtx.drawImage(img, 0, 0); tempCtx.drawImage(img, 0, 0);
if (mask) { if (mask) {
const imageData = tempCtx.getImageData(0, 0, img.width, img.height); const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
const maskCanvas = document.createElement('canvas'); const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width; maskCanvas.width = img.width;
maskCanvas.height = img.height; maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx)
throw new Error("Could not create mask context");
maskCtx.drawImage(mask, 0, 0); maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height); const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
for (let i = 0; i < imageData.data.length; i += 4) { for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = maskData.data[i]; imageData.data[i + 3] = maskData.data[i];
} }
tempCtx.putImageData(imageData, 0, 0); tempCtx.putImageData(imageData, 0, 0);
} }
const finalImage = new Image(); const finalImage = new Image();
await new Promise((resolve) => { await new Promise((resolve) => {
finalImage.onload = resolve; finalImage.onload = resolve;
finalImage.src = tempCanvas.toDataURL(); finalImage.src = tempCanvas.toDataURL();
}); });
const layer = { const layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: finalImage, image: finalImage,
x: (this.canvas.width - img.width * scale) / 2, x: (this.canvas.width - img.width * scale) / 2,
y: (this.canvas.height - img.height * scale) / 2, y: (this.canvas.height - img.height * scale) / 2,
width: img.width * scale, width: img.width * scale,
height: img.height * scale, height: img.height * scale,
originalWidth: img.width,
originalHeight: img.height,
rotation: 0, rotation: 0,
zIndex: this.canvas.layers.length zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
}; };
this.canvas.layers.push(layer); this.canvas.layers.push(layer);
this.canvas.selectedLayer = layer; this.canvas.updateSelection([layer]);
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} catch (error) { }
catch (error) {
log.error('Error importing image:', error); log.error('Error importing image:', error);
} }
} }
async importLatestImage() { async importLatestImage() {
try { try {
log.info("Fetching latest image from server..."); log.info("Fetching latest image from server...");
const response = await fetch('/ycnode/get_latest_image'); const response = await fetch('/ycnode/get_latest_image');
const result = await response.json(); const result = await response.json();
if (result.success && result.image_data) { if (result.success && result.image_data) {
log.info("Latest image received, adding to canvas."); log.info("Latest image received, adding to canvas.");
const img = new Image(); const img = new Image();
@@ -743,30 +627,28 @@ export class CanvasIO {
img.onerror = reject; img.onerror = reject;
img.src = result.image_data; img.src = result.image_data;
}); });
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit'); await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
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 {
throw new Error(result.error || "Failed to fetch the latest image."); throw new Error(result.error || "Failed to fetch the latest image.");
} }
} catch (error) { }
catch (error) {
log.error("Error importing latest image:", error); log.error("Error importing latest image:", error);
alert(`Failed to import latest image: ${error.message}`); alert(`Failed to import latest image: ${error.message}`);
return false; return false;
} }
} }
async importLatestImages(sinceTimestamp, targetArea = null) { async importLatestImages(sinceTimestamp, targetArea = null) {
try { try {
log.info(`Fetching latest images since ${sinceTimestamp}...`); log.info(`Fetching latest images since ${sinceTimestamp}...`);
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`); const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
const result = await response.json(); const result = await response.json();
if (result.success && result.images && result.images.length > 0) { if (result.success && result.images && result.images.length > 0) {
log.info(`Received ${result.images.length} new images, adding to canvas.`); log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = []; const newLayers = [];
for (const imageData of result.images) { for (const imageData of result.images) {
const img = new Image(); const img = new Image();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -778,16 +660,17 @@ export class CanvasIO {
newLayers.push(newLayer); newLayers.push(newLayer);
} }
log.info("All new images imported and placed on canvas successfully."); log.info("All new images imported and placed on canvas successfully.");
return newLayers; return newLayers.filter(l => l !== null);
}
} else if (result.success) { else if (result.success) {
log.info("No new images found since last generation."); log.info("No new images found since last generation.");
return []; return [];
} }
else { else {
throw new Error(result.error || "Failed to fetch latest images."); throw new Error(result.error || "Failed to fetch latest images.");
} }
} catch (error) { }
catch (error) {
log.error("Error importing latest images:", error); log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`); alert(`Failed to import latest images: ${error.message}`);
return []; return [];

View File

@@ -1,42 +1,37 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {snapToGrid, getSnapAdjustment} from "./utils/CommonUtils.js"; import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasInteractions'); const log = createModuleLogger('CanvasInteractions');
export class CanvasInteractions { export class CanvasInteractions {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.interaction = { this.interaction = {
mode: 'none', mode: 'none',
panStart: {x: 0, y: 0}, panStart: { x: 0, y: 0 },
dragStart: {x: 0, y: 0}, dragStart: { x: 0, y: 0 },
transformOrigin: {}, transformOrigin: {},
resizeHandle: null, resizeHandle: null,
resizeAnchor: {x: 0, y: 0}, resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: {x: 0, y: 0}, canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false, isCtrlPressed: false,
isAltPressed: false, isAltPressed: false,
hasClonedInDrag: false, hasClonedInDrag: false,
lastClickTime: 0, lastClickTime: 0,
transformingLayer: null, transformingLayer: null,
keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
this.interaction.canvasResizeRect = null;
this.interaction.canvasMoveRect = null;
} }
setupEventListeners() { setupEventListeners() {
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this)); this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), {passive: false}); this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this)); this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this)); document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true; this.canvas.isMouseOver = true;
this.handleMouseEnter(e); this.handleMouseEnter(e);
@@ -45,15 +40,12 @@ export class CanvasInteractions {
this.canvas.isMouseOver = false; this.canvas.isMouseOver = false;
this.handleMouseLeave(e); this.handleMouseLeave(e);
}); });
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this)); this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this)); this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this)); this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this)); this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this)); this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
} }
resetInteractionState() { resetInteractionState() {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -64,20 +56,16 @@ export class CanvasInteractions {
this.interaction.transformingLayer = null; this.interaction.transformingLayer = null;
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
handleMouseDown(e) { handleMouseDown(e) {
this.canvas.canvas.focus(); this.canvas.canvas.focus();
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') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords); this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
this.canvas.render(); this.canvas.render();
return; 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)
if (e.shiftKey && e.ctrlKey) { if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords); this.startCanvasMove(worldCoords);
@@ -87,7 +75,6 @@ export class CanvasInteractions {
this.startCanvasResize(worldCoords); this.startCanvasResize(worldCoords);
return; return;
} }
// 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);
@@ -101,29 +88,24 @@ export class CanvasInteractions {
this.startPanning(e); this.startPanning(e);
return; return;
} }
// 3. Interakcje z elementami na płótnie (lewy przycisk) // 3. Interakcje z elementami na płótnie (lewy przycisk)
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return; return;
} }
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, worldCoords); this.prepareForDrag(clickedLayerResult.layer, worldCoords);
return; return;
} }
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e); this.startPanningOrClearSelection(e);
} }
handleMouseMove(e) { handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(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
if (this.interaction.mode === 'potential-drag') { if (this.interaction.mode === 'potential-drag') {
const dx = worldCoords.x - this.interaction.dragStart.x; const dx = worldCoords.x - this.interaction.dragStart.x;
@@ -131,12 +113,11 @@ 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.canvasSelection.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': case 'drawingMask':
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords); this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
@@ -165,7 +146,6 @@ export class CanvasInteractions {
break; break;
} }
} }
handleMouseUp(e) { handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
@@ -173,27 +153,22 @@ export class CanvasInteractions {
this.canvas.render(); this.canvas.render();
return; return;
} }
if (this.interaction.mode === 'resizingCanvas') { if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize(); this.finalizeCanvasResize();
} }
if (this.interaction.mode === 'movingCanvas') { if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove(); this.finalizeCanvasMove();
} }
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag; const duplicatedInDrag = this.interaction.hasClonedInDrag;
if (stateChangingInteraction || duplicatedInDrag) { if (stateChangingInteraction || duplicatedInDrag) {
this.canvas.saveState(); this.canvas.saveState();
this.canvas.canvasState.saveStateToDB(true); this.canvas.canvasState.saveStateToDB();
} }
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
handleMouseLeave(e) { handleMouseLeave(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -208,24 +183,19 @@ export class CanvasInteractions {
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = []; this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas"); log.info("Internal clipboard cleared - mouse left canvas");
} }
} }
handleMouseEnter(e) { handleMouseEnter(e) {
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseEnter(); this.canvas.maskTool.handleMouseEnter();
} }
} }
handleContextMenu(e) { handleContextMenu(e) {
e.preventDefault(); e.preventDefault();
} }
handleWheel(e) { handleWheel(e) {
e.preventDefault(); e.preventDefault();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -233,36 +203,36 @@ export class CanvasInteractions {
const rect = this.canvas.canvas.getBoundingClientRect(); const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor; const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.selectedLayer) { }
else if (this.canvas.canvasSelection.selectedLayers.length > 0) {
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.canvasSelection.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) {
const snapAngle = 5; const snapAngle = 5;
if (direction > 0) { // Obrót w górę/prawo if (direction > 0) { // Obrót w górę/prawo
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle; layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
} else { // Obrót w dół/lewo }
else { // Obrót w dół/lewo
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle; layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
} }
} else { }
else {
// Stara funkcjonalność: Shift + Kółko obraca o stały krok // Stara funkcjonalność: Shift + Kółko obraca o stały krok
layer.rotation += rotationStep; layer.rotation += rotationStep;
} }
} else { }
else {
const oldWidth = layer.width; const oldWidth = layer.width;
const oldHeight = layer.height; const oldHeight = layer.height;
let scaleFactor; let scaleFactor;
if (e.ctrlKey) { if (e.ctrlKey) {
const direction = e.deltaY > 0 ? -1 : 1; const direction = e.deltaY > 0 ? -1 : 1;
const baseDimension = Math.max(layer.width, layer.height); const baseDimension = Math.max(layer.width, layer.height);
@@ -271,26 +241,28 @@ export class CanvasInteractions {
return; return;
} }
scaleFactor = newBaseDimension / baseDimension; scaleFactor = newBaseDimension / baseDimension;
} else { }
else {
const gridSize = 64; const gridSize = 64;
const direction = e.deltaY > 0 ? -1 : 1; const direction = e.deltaY > 0 ? -1 : 1;
let targetHeight; let targetHeight;
if (direction > 0) { if (direction > 0) {
targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize; targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize;
} else { }
else {
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
} }
if (targetHeight < gridSize / 2) { if (targetHeight < gridSize / 2) {
targetHeight = gridSize / 2; targetHeight = gridSize / 2;
} }
if (Math.abs(oldHeight - targetHeight) < 1) { if (Math.abs(oldHeight - targetHeight) < 1) {
if (direction > 0) targetHeight += gridSize; if (direction > 0)
else targetHeight -= gridSize; targetHeight += gridSize;
else
if (targetHeight < gridSize / 2) return; targetHeight -= gridSize;
if (targetHeight < gridSize / 2)
return;
} }
scaleFactor = targetHeight / oldHeight; scaleFactor = targetHeight / oldHeight;
} }
if (scaleFactor && isFinite(scaleFactor)) { if (scaleFactor && isFinite(scaleFactor)) {
@@ -301,32 +273,30 @@ export class CanvasInteractions {
} }
} }
}); });
} else { }
else {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect(); const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor; const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} }
this.canvas.render(); this.canvas.render();
if (!this.canvas.maskTool.isActive) { if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
} }
} }
handleKeyDown(e) { handleKeyDown(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control')
this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true; this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
// Globalne skróty (Undo/Redo/Copy/Paste) // Globalne skróty (Undo/Redo/Copy/Paste)
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
let handled = true; let handled = true;
@@ -334,7 +304,8 @@ export class CanvasInteractions {
case 'z': case 'z':
if (e.shiftKey) { if (e.shiftKey) {
this.canvas.redo(); this.canvas.redo();
} else { }
else {
this.canvas.undo(); this.canvas.undo();
} }
break; break;
@@ -356,56 +327,54 @@ export class CanvasInteractions {
return; return;
} }
} }
// Skróty kontekstowe (zależne od zaznaczenia) // Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.canvasSelection.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;
// Używamy e.code dla spójności i niezależności od układu klawiatury // Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code)) { if (movementKeys.includes(e.code)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.interaction.keyMovementInProgress = true; this.interaction.keyMovementInProgress = true;
if (e.code === 'ArrowLeft')
if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x -= step); this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x += step); if (e.code === 'ArrowRight')
if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y -= step); this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x += step);
if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y += step); if (e.code === 'ArrowUp')
if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation -= step); this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y -= step);
if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation += step); if (e.code === 'ArrowDown')
this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y += step);
if (e.code === 'BracketLeft')
this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation -= step);
if (e.code === 'BracketRight')
this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation += step);
needsRender = true; needsRender = true;
} }
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.canvasSelection.removeSelectedLayers(); this.canvas.canvasSelection.removeSelectedLayers();
return; return;
} }
if (needsRender) { if (needsRender) {
this.canvas.render(); this.canvas.render();
} }
} }
} }
handleKeyUp(e) { handleKeyUp(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = false; if (e.key === 'Control')
if (e.key === 'Alt') this.interaction.isAltPressed = false; this.interaction.isCtrlPressed = false;
if (e.key === 'Alt')
this.interaction.isAltPressed = false;
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
this.interaction.keyMovementInProgress = false; this.interaction.keyMovementInProgress = false;
} }
} }
updateCursor(worldCoords) { updateCursor(worldCoords) {
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
const cursorMap = { const cursorMap = {
@@ -414,13 +383,14 @@ export class CanvasInteractions {
'rot': 'grab' 'rot': 'grab'
}; };
this.canvas.canvas.style.cursor = cursorMap[handleName]; this.canvas.canvas.style.cursor = cursorMap[handleName];
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { }
else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
this.canvas.canvas.style.cursor = 'move'; this.canvas.canvas.style.cursor = 'move';
} else { }
else {
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
} }
startLayerTransform(layer, handle, worldCoords) { startLayerTransform(layer, handle, worldCoords) {
this.interaction.transformingLayer = layer; this.interaction.transformingLayer = layer;
this.interaction.transformOrigin = { this.interaction.transformOrigin = {
@@ -430,43 +400,42 @@ export class CanvasInteractions {
centerX: layer.x + layer.width / 2, centerX: layer.x + layer.width / 2,
centerY: layer.y + layer.height / 2 centerY: layer.y + layer.height / 2
}; };
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
this.interaction.mode = 'rotating'; this.interaction.mode = 'rotating';
} else { }
else {
this.interaction.mode = 'resizing'; this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle; this.interaction.resizeHandle = handle;
const handles = this.canvas.canvasLayers.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey = { const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
}[handle]; };
this.interaction.resizeAnchor = handles[oppositeHandleKey]; this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]];
} }
this.canvas.render(); this.canvas.render();
} }
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.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else { }
const newSelection = this.canvas.canvasSelection.selectedLayers.filter(l => l !== layer); else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection); this.canvas.canvasSelection.updateSelection(newSelection);
} }
} else { }
else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
} }
} }
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
} }
startPanningOrClearSelection(e) { startPanningOrClearSelection(e) {
// 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.
@@ -474,75 +443,63 @@ export class CanvasInteractions {
this.canvas.canvasSelection.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 };
} }
startCanvasResize(worldCoords) { startCanvasResize(worldCoords) {
this.interaction.mode = 'resizingCanvas'; this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x); const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y); const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY}; this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render(); this.canvas.render();
} }
startCanvasMove(worldCoords) { startCanvasMove(worldCoords) {
this.interaction.mode = 'movingCanvas'; this.interaction.mode = 'movingCanvas';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2); const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2); const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = { this.interaction.canvasMoveRect = {
x: initialX, x: initialX,
y: initialY, y: initialY,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height height: this.canvas.height
}; };
this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render(); this.canvas.render();
} }
updateCanvasMove(worldCoords) { updateCanvasMove(worldCoords) {
if (!this.interaction.canvasMoveRect) return; if (!this.interaction.canvasMoveRect)
return;
const dx = worldCoords.x - this.interaction.dragStart.x; const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y; const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2); const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2); const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx); this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy); this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
this.canvas.render(); this.canvas.render();
} }
finalizeCanvasMove() { finalizeCanvasMove() {
const moveRect = this.interaction.canvasMoveRect; const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) { if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x; const finalX = moveRect.x;
const finalY = moveRect.y; const finalY = moveRect.y;
this.canvas.layers.forEach((layer) => {
this.canvas.layers.forEach(layer => {
layer.x -= finalX; layer.x -= finalX;
layer.y -= finalY; layer.y -= finalY;
}); });
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 a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) { if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY; this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative // Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY; this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext); log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
} }
// Also move any active batch preview menus // Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX; manager.worldX -= finalX;
manager.worldY -= finalY; manager.worldY -= finalY;
if (manager.generationArea) { if (manager.generationArea) {
@@ -551,62 +508,58 @@ export class CanvasInteractions {
} }
}); });
} }
this.canvas.viewport.x -= finalX; this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} }
startPanning(e) { startPanning(e) {
if (!this.interaction.isCtrlPressed) { if (!this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.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 };
} }
panViewport(e) { panViewport(e) {
const dx = e.clientX - this.interaction.panStart.x; const dx = e.clientX - this.interaction.panStart.x;
const dy = e.clientY - this.interaction.panStart.y; const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = { x: e.clientX, y: e.clientY };
this.canvas.render(); this.canvas.render();
} }
dragLayers(worldCoords) { dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.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.canvasSelection.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();
newLayers.forEach(l => { newLayers.forEach((l) => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.originalLayerPositions.set(l, { x: l.x, y: l.y });
}); });
this.interaction.hasClonedInDrag = true; this.interaction.hasClonedInDrag = true;
} }
const totalDx = worldCoords.x - this.interaction.dragStart.x; const totalDx = worldCoords.x - this.interaction.dragStart.x;
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.canvasSelection.selectedLayers.length > 0) {
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayer) { const firstLayer = this.canvas.canvasSelection.selectedLayers[0];
const originalPos = this.originalLayerPositions.get(this.canvas.canvasSelection.selectedLayer); const originalPos = this.originalLayerPositions.get(firstLayer);
if (originalPos) { if (originalPos) {
const tempLayerForSnap = { const tempLayerForSnap = {
...this.canvas.canvasSelection.selectedLayer, ...firstLayer,
x: originalPos.x + totalDx, x: originalPos.x + totalDx,
y: originalPos.y + totalDy y: originalPos.y + totalDy
}; };
const snapAdjustment = getSnapAdjustment(tempLayerForSnap); const snapAdjustment = getSnapAdjustment(tempLayerForSnap);
finalDx += snapAdjustment.dx; if (snapAdjustment) {
finalDy += snapAdjustment.dy; finalDx += snapAdjustment.x;
finalDy += snapAdjustment.y;
}
} }
} }
this.canvas.canvasSelection.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;
@@ -615,138 +568,121 @@ export class CanvasInteractions {
}); });
this.canvas.render(); this.canvas.render();
} }
resizeLayerFromHandle(worldCoords, isShiftPressed) { resizeLayerFromHandle(worldCoords, isShiftPressed) {
const layer = this.interaction.transformingLayer; const layer = this.interaction.transformingLayer;
if (!layer) return; if (!layer)
return;
let mouseX = worldCoords.x; let mouseX = worldCoords.x;
let mouseY = worldCoords.y; let mouseY = worldCoords.y;
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom; const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX); const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY); const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY; if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
mouseY = snappedMouseY;
} }
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
return;
const handle = this.interaction.resizeHandle; const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor; const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180; const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
const vecX = mouseX - anchor.x; const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y; const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin; let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin; let newHeight = vecY * cos - vecX * sin;
if (isShiftPressed) { if (isShiftPressed) {
const originalAspectRatio = o.width / o.height; const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
} else { }
else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
} }
} }
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
let signX = handle.includes('e') ? 1 : (handle.includes('w') ? -1 : 0); let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
let signY = handle.includes('s') ? 1 : (handle.includes('n') ? -1 : 0);
newWidth *= signX; newWidth *= signX;
newHeight *= signY; newHeight *= signY;
if (signX === 0)
if (signX === 0) newWidth = o.width; newWidth = o.width;
if (signY === 0) newHeight = o.height; if (signY === 0)
newHeight = o.height;
if (newWidth < 10) newWidth = 10; if (newWidth < 10)
if (newHeight < 10) newHeight = 10; newWidth = 10;
if (newHeight < 10)
newHeight = 10;
layer.width = newWidth; layer.width = newWidth;
layer.height = newHeight; layer.height = newHeight;
const deltaW = newWidth - o.width; const deltaW = newWidth - o.width;
const deltaH = newHeight - o.height; const deltaH = newHeight - o.height;
const shiftX = (deltaW / 2) * signX; const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY; const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin; const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos; const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX; const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY; const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2; layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2; layer.y = newCenterY - layer.height / 2;
this.canvas.render(); this.canvas.render();
} }
rotateLayerFromHandle(worldCoords, isShiftPressed) { rotateLayerFromHandle(worldCoords, isShiftPressed) {
const layer = this.interaction.transformingLayer; const layer = this.interaction.transformingLayer;
if (!layer) return; if (!layer)
return;
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
return;
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX); const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX); const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI; let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
let newRotation = o.rotation + angleDiff; let newRotation = o.rotation + angleDiff;
if (isShiftPressed) { if (isShiftPressed) {
newRotation = Math.round(newRotation / 15) * 15; newRotation = Math.round(newRotation / 15) * 15;
} }
layer.rotation = newRotation; layer.rotation = newRotation;
this.canvas.render(); this.canvas.render();
} }
updateCanvasResize(worldCoords) { updateCanvasResize(worldCoords) {
if (!this.interaction.canvasResizeRect)
return;
const snappedMouseX = snapToGrid(worldCoords.x); const snappedMouseX = snapToGrid(worldCoords.x);
const snappedMouseY = snapToGrid(worldCoords.y); const snappedMouseY = snapToGrid(worldCoords.y);
const start = this.interaction.canvasResizeStart; const start = this.interaction.canvasResizeStart;
this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x); this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x);
this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y); this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y);
this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x); this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x);
this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y); this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y);
this.canvas.render(); this.canvas.render();
} }
finalizeCanvasResize() { finalizeCanvasResize() {
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 finalX = this.interaction.canvasResizeRect.x; const finalX = this.interaction.canvasResizeRect.x;
const finalY = 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 -= finalX; layer.x -= finalX;
layer.y -= finalY; layer.y -= finalY;
}); });
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 a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) { if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY; this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative // Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY; this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext); log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
} }
// Also move any active batch preview menus // Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX; manager.worldX -= finalX;
manager.worldY -= finalY; manager.worldY -= finalY;
if (manager.generationArea) { if (manager.generationArea) {
@@ -755,117 +691,101 @@ export class CanvasInteractions {
} }
}); });
} }
this.canvas.viewport.x -= finalX; this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
} }
handleDragOver(e) { handleDragOver(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
e.dataTransfer.dropEffect = 'copy'; if (e.dataTransfer)
e.dataTransfer.dropEffect = 'copy';
} }
handleDragEnter(e) { handleDragEnter(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)'; this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0'; this.canvas.canvas.style.border = '2px dashed #2d5aa0';
} }
handleDragLeave(e) { handleDragLeave(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget)) { if (!this.canvas.canvas.contains(e.relatedTarget)) {
this.canvas.canvas.style.backgroundColor = ''; this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = ''; this.canvas.canvas.style.border = '';
} }
} }
async handleDrop(e) { async handleDrop(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading"); log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = ''; this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = ''; this.canvas.canvas.style.border = '';
if (!e.dataTransfer)
return;
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`); log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) { for (const file of files) {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
try { try {
await this.loadDroppedImageFile(file, worldCoords); await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`); log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) { }
catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error); log.error(`Failed to load dropped image ${file.name}:`, error);
} }
} else { }
else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`); log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
} }
} }
} }
async loadDroppedImageFile(file, worldCoords) { async loadDroppedImageFile(file, worldCoords) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
const img = new Image(); const img = new Image();
img.onload = async () => { img.onload = async () => {
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
await this.canvas.addLayer(img, {}, addMode);
}; };
img.onerror = () => { img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`); log.error(`Failed to load dropped image: ${file.name}`);
}; };
img.src = e.target.result; if (e.target?.result) {
img.src = e.target.result;
}
}; };
reader.onerror = () => { reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`); log.error(`Failed to read dropped file: ${file.name}`);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
async handlePasteEvent(e) { async handlePasteEvent(e) {
const shouldHandle = this.canvas.isMouseOver || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas ||
document.activeElement === document.body; document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;
} }
log.info("Paste event detected, checking clipboard preference"); log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference; const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') { if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager"); log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return; return;
} }
const clipboardData = e.clipboardData; const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) { if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) { for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) { if (item.type.startsWith('image/')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const file = item.getAsFile(); const file = item.getAsFile();
if (file) { if (file) {
log.info("Found direct image data in paste event"); log.info("Found direct image data in paste event");
@@ -875,7 +795,9 @@ export class CanvasInteractions {
img.onload = async () => { img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse'); await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
}; };
img.src = event.target.result; if (event.target?.result) {
img.src = event.target.result;
}
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
return; return;
@@ -883,7 +805,6 @@ export class CanvasInteractions {
} }
} }
} }
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasLayersPanel'); const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel { export class CanvasLayersPanel {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -11,22 +9,14 @@ export class CanvasLayersPanel {
this.dragInsertionLine = null; this.dragInsertionLine = null;
this.isMultiSelecting = false; this.isMultiSelecting = false;
this.lastSelectedIndex = -1; this.lastSelectedIndex = -1;
// Binding metod dla event handlerów
this.handleLayerClick = this.handleLayerClick.bind(this); this.handleLayerClick = this.handleLayerClick.bind(this);
this.handleDragStart = this.handleDragStart.bind(this); this.handleDragStart = this.handleDragStart.bind(this);
this.handleDragOver = this.handleDragOver.bind(this); this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this); this.handleDrop = this.handleDrop.bind(this);
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
/**
* Tworzy struktur&ecirc; HTML panelu warstw
*/
createPanelStructure() { createPanelStructure() {
// Główny kontener panelu
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'layers-panel'; this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
@@ -41,15 +31,10 @@ export class CanvasLayersPanel {
<!-- Lista warstw będzie renderowana tutaj --> <!-- Lista warstw będzie renderowana tutaj -->
</div> </div>
`; `;
this.layersContainer = this.container.querySelector('#layers-container'); this.layersContainer = this.container.querySelector('#layers-container');
// Dodanie stylów CSS
this.injectStyles(); this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e) => { this.container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
@@ -58,20 +43,14 @@ export class CanvasLayersPanel {
this.deleteSelectedLayers(); this.deleteSelectedLayers();
} }
}); });
log.debug('Panel structure created'); log.debug('Panel structure created');
return this.container; return this.container;
} }
/**
* Dodaje style CSS do panelu
*/
injectStyles() { injectStyles() {
const styleId = 'layers-panel-styles'; const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) { if (document.getElementById(styleId)) {
return; // Style już istnieją return; // Style już istnieją
} }
const style = document.createElement('style'); const style = document.createElement('style');
style.id = styleId; style.id = styleId;
style.textContent = ` style.textContent = `
@@ -253,404 +232,282 @@ export class CanvasLayersPanel {
background: #5a5a5a; background: #5a5a5a;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
log.debug('Styles injected'); log.debug('Styles injected');
} }
/**
* Konfiguruje event listenery dla przycisków kontrolnych
*/
setupControlButtons() { setupControlButtons() {
if (!this.container)
return;
const deleteBtn = this.container.querySelector('#delete-layer-btn'); const deleteBtn = this.container.querySelector('#delete-layer-btn');
deleteBtn?.addEventListener('click', () => { deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
} }
/**
* Renderuje listę warstw
*/
renderLayers() { renderLayers() {
if (!this.layersContainer) { if (!this.layersContainer) {
log.warn('Layers container not initialized'); log.warn('Layers container not initialized');
return; return;
} }
// Wyczyść istniejącą zawartość // Wyczyść istniejącą zawartość
this.layersContainer.innerHTML = ''; this.layersContainer.innerHTML = '';
// Usuń linię wstawiania jeśli istnieje // Usuń linię wstawiania jeśli istnieje
this.removeDragInsertionLine(); this.removeDragInsertionLine();
// Sortuj warstwy według zIndex (od najwyższej do najniższej) // Sortuj warstwy według zIndex (od najwyższej do najniższej)
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
sortedLayers.forEach((layer, index) => { sortedLayers.forEach((layer, index) => {
const layerElement = this.createLayerElement(layer, index); const layerElement = this.createLayerElement(layer, index);
this.layersContainer.appendChild(layerElement); if (this.layersContainer)
this.layersContainer.appendChild(layerElement);
}); });
log.debug(`Rendered ${sortedLayers.length} layers`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
/**
* Tworzy element HTML dla pojedynczej warstwy
*/
createLayerElement(layer, index) { createLayerElement(layer, index) {
const layerRow = document.createElement('div'); const layerRow = document.createElement('div');
layerRow.className = 'layer-row'; layerRow.className = 'layer-row';
layerRow.draggable = true; layerRow.draggable = true;
layerRow.dataset.layerIndex = index; layerRow.dataset.layerIndex = String(index);
// Sprawdź czy warstwa jest zaznaczona
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
if (isSelected) { if (isSelected) {
layerRow.classList.add('selected'); layerRow.classList.add('selected');
} }
// Ustawienie domyślnych właściwości jeśli nie istnieją // Ustawienie domyślnych właściwości jeśli nie istnieją
if (!layer.name) { if (!layer.name) {
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer); layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
} else { }
else {
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu) // Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
layer.name = this.ensureUniqueName(layer.name, layer); layer.name = this.ensureUniqueName(layer.name, layer);
} }
layerRow.innerHTML = ` layerRow.innerHTML = `
<div class="layer-thumbnail" data-layer-index="${index}"></div> <div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span> <span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`; `;
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
// Wygeneruj miniaturkę if (thumbnailContainer) {
this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail')); this.generateThumbnail(layer, thumbnailContainer);
}
// Event listenery
this.setupLayerEventListeners(layerRow, layer, index); this.setupLayerEventListeners(layerRow, layer, index);
return layerRow; return layerRow;
} }
/**
* Generuje miniaturkę warstwy
*/
generateThumbnail(layer, thumbnailContainer) { generateThumbnail(layer, thumbnailContainer) {
if (!layer.image) { if (!layer.image) {
thumbnailContainer.style.background = '#4a4a4a'; thumbnailContainer.style.background = '#4a4a4a';
return; return;
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
return;
canvas.width = 48; canvas.width = 48;
canvas.height = 48; canvas.height = 48;
// Oblicz skalę zachowując proporcje
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale; const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale; const scaledHeight = layer.image.height * scale;
// Wycentruj obraz // Wycentruj obraz
const x = (48 - scaledWidth) / 2; const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2; const y = (48 - scaledHeight) / 2;
// Narysuj obraz z wyższą jakością
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight); ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
thumbnailContainer.appendChild(canvas); thumbnailContainer.appendChild(canvas);
} }
/**
* Konfiguruje event listenery dla elementu warstwy
*/
setupLayerEventListeners(layerRow, layer, index) { setupLayerEventListeners(layerRow, layer, index) {
// Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku
layerRow.addEventListener('mousedown', (e) => { layerRow.addEventListener('mousedown', (e) => {
// Ignoruj, jeśli edytujemy nazwę
const nameElement = layerRow.querySelector('.layer-name'); const nameElement = layerRow.querySelector('.layer-name');
if (nameElement && nameElement.classList.contains('editing')) { if (nameElement && nameElement.classList.contains('editing')) {
return; return;
} }
this.handleLayerClick(e, layer, index); this.handleLayerClick(e, layer, index);
}); });
// Double click handler - edycja nazwy
layerRow.addEventListener('dblclick', (e) => { layerRow.addEventListener('dblclick', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nameElement = layerRow.querySelector('.layer-name'); const nameElement = layerRow.querySelector('.layer-name');
this.startEditingLayerName(nameElement, layer); if (nameElement) {
this.startEditingLayerName(nameElement, layer);
}
}); });
// Drag handlers
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index)); layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver); layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd); layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index)); 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) { handleLayerClick(e, layer, index) {
const isCtrlPressed = e.ctrlKey || e.metaKey; const isCtrlPressed = e.ctrlKey || e.metaKey;
const isShiftPressed = e.shiftKey; const isShiftPressed = e.shiftKey;
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// 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.canvasSelection.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
/**
* Rozpoczyna edycję nazwy warstwy
*/
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {
const currentName = layer.name; const currentName = layer.name;
nameElement.classList.add('editing'); nameElement.classList.add('editing');
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.value = currentName; input.value = currentName;
input.style.width = '100%'; input.style.width = '100%';
nameElement.innerHTML = ''; nameElement.innerHTML = '';
nameElement.appendChild(input); nameElement.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
const finishEditing = () => { const finishEditing = () => {
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`; let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
newName = this.ensureUniqueName(newName, layer); newName = this.ensureUniqueName(newName, layer);
layer.name = newName; layer.name = newName;
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = newName; nameElement.textContent = newName;
this.canvas.saveState(); this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`); log.info(`Layer renamed to: ${newName}`);
}; };
input.addEventListener('blur', finishEditing); input.addEventListener('blur', finishEditing);
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
finishEditing(); finishEditing();
} else if (e.key === 'Escape') { }
else if (e.key === 'Escape') {
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = currentName; nameElement.textContent = currentName;
} }
}); });
} }
/**
* Zapewnia unikalność nazwy warstwy
*/
ensureUniqueName(proposedName, currentLayer) { ensureUniqueName(proposedName, currentLayer) {
const existingNames = this.canvas.layers const existingNames = this.canvas.layers
.filter(layer => layer !== currentLayer) .filter((layer) => layer !== currentLayer)
.map(layer => layer.name); .map((layer) => layer.name);
if (!existingNames.includes(proposedName)) { if (!existingNames.includes(proposedName)) {
return proposedName; return proposedName;
} }
// Sprawdź czy nazwa już ma numerację w nawiasach // Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber; let baseName, startNumber;
if (match) { if (match) {
baseName = match[1].trim(); baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1; startNumber = parseInt(match[2]) + 1;
} else { }
else {
baseName = proposedName; baseName = proposedName;
startNumber = 1; startNumber = 1;
} }
// Znajdź pierwszą dostępną numerację // Znajdź pierwszą dostępną numerację
let counter = startNumber; let counter = startNumber;
let uniqueName; let uniqueName;
do { do {
uniqueName = `${baseName} (${counter})`; uniqueName = `${baseName} (${counter})`;
counter++; counter++;
} while (existingNames.includes(uniqueName)); } while (existingNames.includes(uniqueName));
return uniqueName; return uniqueName;
} }
/**
* Usuwa zaznaczone warstwy
*/
deleteSelectedLayers() { deleteSelectedLayers() {
if (this.canvas.canvasSelection.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.canvasSelection.selectedLayers.length} selected layers`); log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers(); this.canvas.removeSelectedLayers();
this.renderLayers(); this.renderLayers();
} }
/**
* Rozpoczyna przeciąganie warstwy
*/
handleDragStart(e, layer, index) { handleDragStart(e, layer, index) {
// Sprawdź czy jakakolwiek warstwa jest w trybie edycji if (!this.layersContainer || !e.dataTransfer)
return;
const editingElement = this.layersContainer.querySelector('.layer-name.editing'); const editingElement = this.layersContainer.querySelector('.layer-name.editing');
if (editingElement) { if (editingElement) {
e.preventDefault(); e.preventDefault();
return; return;
} }
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
if (!this.canvas.canvasSelection.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.canvasSelection.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', '');
// Dodaj klasę dragging do przeciąganych elementów
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => { this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
if (this.draggedElements.includes(sortedLayers[idx])) { if (this.draggedElements.includes(sortedLayers[idx])) {
row.classList.add('dragging'); row.classList.add('dragging');
} }
}); });
log.debug(`Started dragging ${this.draggedElements.length} layers`); log.debug(`Started dragging ${this.draggedElements.length} layers`);
} }
/**
* Obsługuje przeciąganie nad warstwą
*/
handleDragOver(e) { handleDragOver(e) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; if (e.dataTransfer)
e.dataTransfer.dropEffect = 'move';
const layerRow = e.currentTarget; const layerRow = e.currentTarget;
const rect = layerRow.getBoundingClientRect(); const rect = layerRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
this.showDragInsertionLine(layerRow, isUpperHalf); this.showDragInsertionLine(layerRow, isUpperHalf);
} }
/**
* Pokazuje linię wskaźnika wstawiania
*/
showDragInsertionLine(targetRow, isUpperHalf) { showDragInsertionLine(targetRow, isUpperHalf) {
this.removeDragInsertionLine(); this.removeDragInsertionLine();
const line = document.createElement('div'); const line = document.createElement('div');
line.className = 'drag-insertion-line'; line.className = 'drag-insertion-line';
if (isUpperHalf) { if (isUpperHalf) {
line.style.top = '-1px'; line.style.top = '-1px';
} else { }
else {
line.style.bottom = '-1px'; line.style.bottom = '-1px';
} }
targetRow.style.position = 'relative'; targetRow.style.position = 'relative';
targetRow.appendChild(line); targetRow.appendChild(line);
this.dragInsertionLine = line; this.dragInsertionLine = line;
} }
/**
* Usuwa linię wskaźnika wstawiania
*/
removeDragInsertionLine() { removeDragInsertionLine() {
if (this.dragInsertionLine) { if (this.dragInsertionLine) {
this.dragInsertionLine.remove(); this.dragInsertionLine.remove();
this.dragInsertionLine = null; this.dragInsertionLine = null;
} }
} }
/**
* Obsługuje upuszczenie warstwy
*/
handleDrop(e, targetIndex) { handleDrop(e, targetIndex) {
e.preventDefault(); e.preventDefault();
this.removeDragInsertionLine(); this.removeDragInsertionLine();
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement))
if (this.draggedElements.length === 0) return; return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks // Oblicz docelowy indeks
let insertIndex = targetIndex; let insertIndex = targetIndex;
if (!isUpperHalf) { if (!isUpperHalf) {
insertIndex = targetIndex + 1; insertIndex = targetIndex + 1;
} }
// Użyj nowej, centralnej funkcji do przesuwania warstw // Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
} }
/**
* Kończy przeciąganie
*/
handleDragEnd(e) { handleDragEnd(e) {
this.removeDragInsertionLine(); this.removeDragInsertionLine();
if (!this.layersContainer)
// Usuń klasę dragging ze wszystkich elementów return;
this.layersContainer.querySelectorAll('.layer-row').forEach(row => { this.layersContainer.querySelectorAll('.layer-row').forEach((row) => {
row.classList.remove('dragging'); row.classList.remove('dragging');
}); });
this.draggedElements = []; this.draggedElements = [];
} }
/**
* Aktualizuje panel gdy zmienią się warstwy
*/
onLayersChanged() { onLayersChanged() {
this.renderLayers(); this.renderLayers();
} }
/**
* Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania.
*/
updateSelectionAppearance() { updateSelectionAppearance() {
if (!this.layersContainer)
return;
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const layerRows = this.layersContainer.querySelectorAll('.layer-row'); const layerRows = this.layersContainer.querySelectorAll('.layer-row');
layerRows.forEach((row, index) => { layerRows.forEach((row, index) => {
const layer = sortedLayers[index]; const layer = sortedLayers[index];
if (this.canvas.canvasSelection.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');
} }
}); });
} }
/**
* 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). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
@@ -658,10 +515,6 @@ export class CanvasLayersPanel {
onSelectionChanged() { onSelectionChanged() {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
} }
/**
* Niszczy panel i czyści event listenery
*/
destroy() { destroy() {
if (this.container && this.container.parentNode) { if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container); this.container.parentNode.removeChild(this.container);
@@ -670,7 +523,6 @@ export class CanvasLayersPanel {
this.layersContainer = null; this.layersContainer = null;
this.draggedElements = []; this.draggedElements = [];
this.removeDragInsertionLine(); this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed'); log.info('CanvasLayersPanel destroyed');
} }
} }

View File

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

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasRenderer'); const log = createModuleLogger('CanvasRenderer');
export class CanvasRenderer { export class CanvasRenderer {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -10,7 +8,6 @@ export class CanvasRenderer {
this.renderInterval = 1000 / 60; this.renderInterval = 1000 / 60;
this.isDirty = false; this.isDirty = false;
} }
render() { render() {
if (this.renderAnimationFrame) { if (this.renderAnimationFrame) {
this.isDirty = true; this.isDirty = true;
@@ -23,16 +20,15 @@ export class CanvasRenderer {
this.actualRender(); this.actualRender();
this.isDirty = false; this.isDirty = false;
} }
if (this.isDirty) { if (this.isDirty) {
this.renderAnimationFrame = null; this.renderAnimationFrame = null;
this.render(); this.render();
} else { }
else {
this.renderAnimationFrame = null; this.renderAnimationFrame = null;
} }
}); });
} }
actualRender() { actualRender() {
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth || if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) { this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
@@ -41,21 +37,17 @@ export class CanvasRenderer {
this.canvas.offscreenCanvas.width = newWidth; this.canvas.offscreenCanvas.width = newWidth;
this.canvas.offscreenCanvas.height = newHeight; this.canvas.offscreenCanvas.height = newHeight;
} }
const ctx = this.canvas.offscreenCtx; const ctx = this.canvas.offscreenCtx;
ctx.fillStyle = '#606060'; ctx.fillStyle = '#606060';
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height); ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
ctx.save(); ctx.save();
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom); ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y); ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
this.drawGrid(ctx); this.drawGrid(ctx);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
if (!layer.image) return; if (!layer.image)
return;
ctx.save(); ctx.save();
const currentTransform = ctx.getTransform(); const currentTransform = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -66,13 +58,14 @@ export class CanvasRenderer {
const centerY = layer.y + layer.height / 2; const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY); ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180); ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage( ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
layer.image, -layer.width / 2, -layer.height / 2,
layer.width,
layer.height
);
if (layer.mask) { if (layer.mask) {
} }
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -80,51 +73,41 @@ export class CanvasRenderer {
} }
ctx.restore(); ctx.restore();
}); });
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask(); const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) { if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save(); ctx.save();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5; ctx.globalAlpha = 0.5;
} else { }
else {
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
} }
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
ctx.restore(); ctx.restore();
} }
this.renderInteractionElements(ctx); this.renderInteractionElements(ctx);
this.renderLayerInfo(ctx); this.renderLayerInfo(ctx);
ctx.restore(); ctx.restore();
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) { this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
this.canvas.canvas.width = this.canvas.offscreenCanvas.width; this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
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 // Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.updateScreenPosition(this.canvas.viewport); manager.updateScreenPosition(this.canvas.viewport);
}); });
} }
} }
renderInteractionElements(ctx) { renderInteractionElements(ctx) {
const interaction = this.canvas.interaction; const interaction = this.canvas.interaction;
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) { if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect; const rect = interaction.canvasResizeRect;
ctx.save(); ctx.save();
@@ -138,7 +121,6 @@ export class CanvasRenderer {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`; const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom); const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
@@ -156,7 +138,6 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
} }
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) { if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect; const rect = interaction.canvasMoveRect;
ctx.save(); ctx.save();
@@ -166,11 +147,9 @@ export class CanvasRenderer {
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.restore(); ctx.restore();
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`; const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom); const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
@@ -188,12 +167,11 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
} }
renderLayerInfo(ctx) { renderLayerInfo(ctx) {
if (this.canvas.canvasSelection.selectedLayer) { if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.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);
const currentWidth = Math.round(layer.width); const currentWidth = Math.round(layer.width);
const currentHeight = Math.round(layer.height); const currentHeight = Math.round(layer.height);
@@ -207,15 +185,13 @@ export class CanvasRenderer {
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
const halfW = layer.width / 2; const halfW = layer.width / 2;
const halfH = layer.height / 2; const halfH = layer.height / 2;
const localCorners = [ const localCorners = [
{x: -halfW, y: -halfH}, { x: -halfW, y: -halfH },
{x: halfW, y: -halfH}, { x: halfW, y: -halfH },
{x: halfW, y: halfH}, { x: halfW, y: halfH },
{x: -halfW, y: halfH} { x: -halfW, y: halfH }
]; ];
const worldCorners = localCorners.map(p => ({ const worldCorners = localCorners.map(p => ({
x: centerX + p.x * cos - p.y * sin, x: centerX + p.x * cos - p.y * sin,
@@ -232,10 +208,8 @@ export class CanvasRenderer {
const textWorldY = maxY + padding; const textWorldY = maxY + padding;
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif"; ctx.font = "14px sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
@@ -244,59 +218,46 @@ export class CanvasRenderer {
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10; const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
const lineHeight = 18; const lineHeight = 18;
const textBgHeight = lines.length * lineHeight + 4; const textBgHeight = lines.length * lineHeight + 4;
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight); ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
ctx.fillStyle = "white"; ctx.fillStyle = "white";
lines.forEach((line, index) => { lines.forEach((line, index) => {
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2; const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
ctx.fillText(line, screenX, yPos); ctx.fillText(line, screenX, yPos);
}); });
ctx.restore(); ctx.restore();
}); });
} }
} }
drawGrid(ctx) { drawGrid(ctx) {
const gridSize = 64; const gridSize = 64;
const lineWidth = 0.5 / this.canvas.viewport.zoom; const lineWidth = 0.5 / this.canvas.viewport.zoom;
const viewLeft = this.canvas.viewport.x; const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y; const viewTop = this.canvas.viewport.y;
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom; const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom; const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = '#707070'; ctx.strokeStyle = '#707070';
ctx.lineWidth = lineWidth; ctx.lineWidth = lineWidth;
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) { for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
ctx.moveTo(x, viewTop); ctx.moveTo(x, viewTop);
ctx.lineTo(x, viewBottom); ctx.lineTo(x, viewBottom);
} }
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) { for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
ctx.moveTo(viewLeft, y); ctx.moveTo(viewLeft, y);
ctx.lineTo(viewRight, y); ctx.lineTo(viewRight, y);
} }
ctx.stroke(); ctx.stroke();
} }
drawCanvasOutline(ctx) { drawCanvasOutline(ctx) {
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom; ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.rect(0, 0, this.canvas.width, this.canvas.height); ctx.rect(0, 0, this.canvas.width, this.canvas.height);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
} }
drawSelectionFrame(ctx, layer) { drawSelectionFrame(ctx, layer) {
const lineWidth = 2 / this.canvas.viewport.zoom; const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom;
@@ -313,44 +274,36 @@ export class CanvasRenderer {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const key in handles) { for (const key in handles) {
const point = handles[key]; const point = handles[key];
ctx.beginPath(); ctx.beginPath();
const localX = point.x - (layer.x + layer.width / 2); const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2); const localY = point.y - (layer.y + layer.height / 2);
const rad = -layer.rotation * Math.PI / 180; const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
} }
} }
drawPendingGenerationAreas(ctx) { drawPendingGenerationAreas(ctx) {
const areasToDraw = []; const areasToDraw = [];
// 1. Get areas from active managers // 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
if (manager.generationArea) { if (manager.generationArea) {
areasToDraw.push(manager.generationArea); areasToDraw.push(manager.generationArea);
} }
}); });
} }
// 2. Get the area from the pending context (if it exists) // 2. Get the area from the pending context (if it exists)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) { if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
areasToDraw.push(this.canvas.pendingBatchContext.outputArea); areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
} }
if (areasToDraw.length === 0) { if (areasToDraw.length === 0) {
return; return;
} }
// 3. Draw all collected areas // 3. Draw all collected areas
areasToDraw.forEach(area => { areasToDraw.forEach(area => {
ctx.save(); ctx.save();

View File

@@ -1,7 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasSelection'); const log = createModuleLogger('CanvasSelection');
export class CanvasSelection { export class CanvasSelection {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -9,16 +7,14 @@ export class CanvasSelection {
this.selectedLayer = null; this.selectedLayer = null;
this.onSelectionChange = null; this.onSelectionChange = null;
} }
/** /**
* 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 []; if (this.selectedLayers.length === 0)
return [];
const newLayers = []; const newLayers = [];
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex); const sortedLayers = [...this.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
const newLayer = { const newLayer = {
...layer, ...layer,
@@ -28,19 +24,15 @@ export class CanvasSelection {
this.canvas.layers.push(newLayer); this.canvas.layers.push(newLayer);
newLayers.push(newLayer); newLayers.push(newLayer);
}); });
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
this.updateSelection(newLayers); this.updateSelection(newLayers);
// Powiadom panel o zmianie struktury, aby się przerysował // Powiadom panel o zmianie struktury, aby się przerysował
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged(); this.canvas.canvasLayersPanel.onLayersChanged();
} }
log.info(`Duplicated ${newLayers.length} layers (in-memory).`); log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
return newLayers; return newLayers;
} }
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -50,47 +42,38 @@ export class CanvasSelection {
const previousSelection = this.selectedLayers.length; const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || []; this.selectedLayers = newSelection || [];
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length || const hasChanged = previousSelection !== this.selectedLayers.length ||
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
if (!hasChanged && previousSelection > 0) { if (!hasChanged && previousSelection > 0) {
// return; // Zablokowane na razie, może powodować problemy // return; // Zablokowane na razie, może powodować problemy
} }
log.debug('Selection updated', { log.debug('Selection updated', {
previousCount: previousSelection, previousCount: previousSelection,
newCount: this.selectedLayers.length, newCount: this.selectedLayers.length,
selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown') selectedLayerIds: this.selectedLayers.map((l) => l.id || 'unknown')
}); });
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
this.canvas.render(); this.canvas.render();
// 2. Powiadom inne części aplikacji (jeśli są) // 2. Powiadom inne części aplikacji (jeśli są)
if (this.onSelectionChange) { if (this.onSelectionChange) {
this.onSelectionChange(); this.onSelectionChange();
} }
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd // 3. Powiadom panel warstw, aby zaktualizował swój wygląd
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onSelectionChanged(); this.canvas.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]; let newSelection = [...this.selectedLayers];
let selectionChanged = false; let selectionChanged = false;
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
newSelection = []; newSelection = [];
for (let i = startIndex; i <= endIndex; i++) { for (let i = startIndex; i <= endIndex; i++) {
if (sortedLayers[i]) { if (sortedLayers[i]) {
@@ -98,16 +81,19 @@ export class CanvasSelection {
} }
} }
selectionChanged = true; selectionChanged = true;
} else if (isCtrlPressed) { }
else if (isCtrlPressed) {
const layerIndex = newSelection.indexOf(layer); const layerIndex = newSelection.indexOf(layer);
if (layerIndex === -1) { if (layerIndex === -1) {
newSelection.push(layer); newSelection.push(layer);
} else { }
else {
newSelection.splice(layerIndex, 1); newSelection.splice(layerIndex, 1);
} }
this.canvas.canvasLayersPanel.lastSelectedIndex = index; this.canvas.canvasLayersPanel.lastSelectedIndex = index;
selectionChanged = true; selectionChanged = true;
} else { }
else {
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
// wyczyść zaznaczenie i zaznacz tylko ją. // wyczyść zaznaczenie i zaznacz tylko ją.
if (!this.selectedLayers.includes(layer)) { if (!this.selectedLayers.includes(layer)) {
@@ -118,47 +104,41 @@ export class CanvasSelection {
// NIE rób nic, aby umożliwić przeciąganie całej grupy. // NIE rób nic, aby umożliwić przeciąganie całej grupy.
this.canvas.canvasLayersPanel.lastSelectedIndex = index; this.canvas.canvasLayersPanel.lastSelectedIndex = index;
} }
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
if (selectionChanged) { if (selectionChanged) {
this.updateSelection(newSelection); this.updateSelection(newSelection);
} }
} }
removeSelectedLayers() { removeSelectedLayers() {
if (this.selectedLayers.length > 0) { if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', { log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length, layersToRemove: this.selectedLayers.length,
totalLayers: this.canvas.layers.length totalLayers: this.canvas.layers.length
}); });
this.canvas.saveState(); this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.selectedLayers.includes(l)); this.canvas.layers = this.canvas.layers.filter((l) => !this.selectedLayers.includes(l));
this.updateSelection([]); this.updateSelection([]);
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged(); this.canvas.canvasLayersPanel.onLayersChanged();
} }
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
} else { }
else {
log.debug('No layers selected for removal'); log.debug('No layers selected for removal');
} }
} }
/** /**
* Aktualizuje zaznaczenie po operacji historii * Aktualizuje zaznaczenie po operacji historii
*/ */
updateSelectionAfterHistory() { updateSelectionAfterHistory() {
const newSelectedLayers = []; const newSelectedLayers = [];
if (this.selectedLayers) { if (this.selectedLayers) {
this.selectedLayers.forEach(sl => { this.selectedLayers.forEach((sl) => {
const found = this.canvas.layers.find(l => l.id === sl.id); const found = this.canvas.layers.find((l) => l.id === sl.id);
if (found) newSelectedLayers.push(found); if (found)
newSelectedLayers.push(found);
}); });
} }
this.updateSelection(newSelectedLayers); this.updateSelection(newSelectedLayers);

View File

@@ -1,10 +1,7 @@
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js"; import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js"; import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js";
import {withErrorHandling} from "./ErrorHandler.js";
const log = createModuleLogger('CanvasState'); const log = createModuleLogger('CanvasState');
export class CanvasState { export class CanvasState {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -16,289 +13,304 @@ export class CanvasState {
this.saveTimeout = null; this.saveTimeout = null;
this.lastSavedStateSignature = null; this.lastSavedStateSignature = null;
this._loadInProgress = null; this._loadInProgress = null;
this._debouncedSave = null;
// Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami
try { try {
// new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera // @ts-ignore
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' }); this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
log.info("State saver worker initialized successfully."); log.info("State saver worker initialized successfully.");
this.stateSaverWorker.onmessage = (e) => { this.stateSaverWorker.onmessage = (e) => {
log.info("Message from state saver worker:", e.data); log.info("Message from state saver worker:", e.data);
}; };
this.stateSaverWorker.onerror = (e) => { this.stateSaverWorker.onerror = (e) => {
log.error("Error in state saver worker:", e.message, e.filename, e.lineno); 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; this.stateSaverWorker = null;
}; };
} catch (e) { }
catch (e) {
log.error("Failed to initialize state saver worker:", e); log.error("Failed to initialize state saver worker:", e);
this.stateSaverWorker = null; this.stateSaverWorker = null;
} }
} }
async loadStateFromDB() { async loadStateFromDB() {
if (this._loadInProgress) { if (this._loadInProgress) {
log.warn("Load already in progress, waiting..."); log.warn("Load already in progress, waiting...");
return this._loadInProgress; return this._loadInProgress;
} }
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id); log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
if (!this.canvas.node.id) { const loadPromise = this._performLoad();
log.error("Node ID is not available for loading state from DB."); this._loadInProgress = loadPromise;
return false;
}
this._loadInProgress = this._performLoad();
try { try {
const result = await this._loadInProgress; const result = await loadPromise;
return result;
} finally {
this._loadInProgress = null; this._loadInProgress = null;
return result;
}
catch (error) {
this._loadInProgress = null;
throw error;
} }
} }
async _performLoad() {
_performLoad = withErrorHandling(async () => { try {
const savedState = await getCanvasState(this.canvas.node.id); if (!this.canvas.node.id) {
if (!savedState) { log.error("Node ID is not available for loading state from DB.");
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id); return false;
}
const savedState = await getCanvasState(String(this.canvas.node.id));
if (!savedState) {
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
return false;
}
log.info("Found saved state in IndexedDB.");
this.canvas.width = savedState.width || 512;
this.canvas.height = savedState.height || 512;
this.canvas.viewport = savedState.viewport || {
x: -(this.canvas.width / 4),
y: -(this.canvas.height / 4),
zoom: 0.8
};
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`);
if (this.canvas.layers.length === 0) {
log.warn("No valid layers loaded, state may be corrupted.");
return false;
}
this.canvas.updateSelectionAfterHistory();
this.canvas.render();
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
return true;
}
catch (error) {
log.error("Error during state load:", error);
return false; return false;
} }
log.info("Found saved state in IndexedDB."); }
this.canvas.width = savedState.width || 512;
this.canvas.height = savedState.height || 512;
this.canvas.viewport = savedState.viewport || {
x: -(this.canvas.width / 4),
y: -(this.canvas.height / 4),
zoom: 0.8
};
this.canvas.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter(l => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`);
if (this.canvas.layers.length === 0) {
log.warn("No valid layers loaded, state may be corrupted.");
return false;
}
this.canvas.updateSelectionAfterHistory();
this.canvas.render();
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
return true;
}, 'CanvasState._performLoad');
/** /**
* Ładuje warstwy z zapisanego stanu * Ładuje warstwy z zapisanego stanu
* @param {Array} layersData - Dane warstw do załadowania * @param {any[]} layersData - Dane warstw do załadowania
* @returns {Promise<Array>} Załadowane warstwy * @returns {Promise<(Layer | null)[]>} Załadowane warstwy
*/ */
async _loadLayers(layersData) { async _loadLayers(layersData) {
const imagePromises = layersData.map((layerData, index) => const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index));
this._loadSingleLayer(layerData, index)
);
return Promise.all(imagePromises); return Promise.all(imagePromises);
} }
/** /**
* Ładuje pojedynczą warstwę * Ładuje pojedynczą warstwę
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @returns {Promise<Object|null>} Załadowana warstwa lub null * @returns {Promise<Layer | null>} Załadowana warstwa lub null
*/ */
async _loadSingleLayer(layerData, index) { async _loadSingleLayer(layerData, index) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (layerData.imageId) { if (layerData.imageId) {
this._loadLayerFromImageId(layerData, index, resolve); this._loadLayerFromImageId(layerData, index, resolve);
} else if (layerData.imageSrc) { }
else if (layerData.imageSrc) {
this._convertLegacyLayer(layerData, index, resolve); this._convertLegacyLayer(layerData, index, resolve);
} else { }
else {
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`); log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
resolve(null); resolve(null);
} }
}); });
} }
/** /**
* Ładuje warstwę z imageId * Ładuje warstwę z imageId
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_loadLayerFromImageId(layerData, index, resolve) { _loadLayerFromImageId(layerData, index, resolve) {
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`); log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
if (this.canvas.imageCache.has(layerData.imageId)) { if (this.canvas.imageCache.has(layerData.imageId)) {
log.debug(`Layer ${index}: Image found in cache.`); log.debug(`Layer ${index}: Image found in cache.`);
const imageSrc = this.canvas.imageCache.get(layerData.imageId); const imageData = this.canvas.imageCache.get(layerData.imageId);
this._createLayerFromSrc(layerData, imageSrc, index, resolve); if (imageData) {
} else { const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
}
else {
resolve(null);
}
}
else {
getImage(layerData.imageId) getImage(layerData.imageId)
.then(imageSrc => { .then(imageSrc => {
if (imageSrc) { if (imageSrc) {
log.debug(`Layer ${index}: Loading image from data:URL...`); log.debug(`Layer ${index}: Loading image from data:URL...`);
this.canvas.imageCache.set(layerData.imageId, imageSrc); this._createLayerFromSrc(layerData, imageSrc, index, resolve);
this._createLayerFromSrc(layerData, imageSrc, index, resolve); }
} else { else {
log.error(`Layer ${index}: Image not found in IndexedDB.`); log.error(`Layer ${index}: Image not found in IndexedDB.`);
resolve(null);
}
})
.catch(err => {
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
resolve(null); resolve(null);
}); }
})
.catch(err => {
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
resolve(null);
});
} }
} }
/** /**
* Konwertuje starą warstwę z imageSrc na nowy format * Konwertuje starą warstwę z imageSrc na nowy format
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_convertLegacyLayer(layerData, index, resolve) { _convertLegacyLayer(layerData, index, resolve) {
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`); log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
const imageId = generateUUID(); const imageId = generateUUID();
saveImage(imageId, layerData.imageSrc) saveImage(imageId, layerData.imageSrc)
.then(() => { .then(() => {
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`); log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
this.canvas.imageCache.set(imageId, layerData.imageSrc); const newLayerData = { ...layerData, imageId };
const newLayerData = {...layerData, imageId}; delete newLayerData.imageSrc;
delete newLayerData.imageSrc; this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve); })
})
.catch(err => { .catch(err => {
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err); log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
resolve(null); resolve(null);
}); });
} }
/** /**
* Tworzy warstwę z src obrazu * Tworzy warstwę z src obrazu
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {string} imageSrc - Źródło obrazu * @param {string} imageSrc - Źródło obrazu
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_createLayerFromSrc(layerData, imageSrc, index, resolve) { _createLayerFromSrc(layerData, imageSrc, index, resolve) {
const img = new Image(); if (typeof imageSrc === 'string') {
img.onload = () => { const img = new Image();
log.debug(`Layer ${index}: Image loaded successfully.`); img.onload = () => {
const newLayer = {...layerData, image: img}; log.debug(`Layer ${index}: Image loaded successfully.`);
delete newLayer.imageId; const newLayer = { ...layerData, image: img };
resolve(newLayer); resolve(newLayer);
}; };
img.onerror = () => { img.onerror = () => {
log.error(`Layer ${index}: Failed to load image from src.`); log.error(`Layer ${index}: Failed to load image from src.`);
resolve(null); resolve(null);
}; };
img.src = imageSrc; img.src = imageSrc;
}
else {
const canvas = document.createElement('canvas');
canvas.width = imageSrc.width;
canvas.height = imageSrc.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(imageSrc, 0, 0);
const img = new Image();
img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer = { ...layerData, image: img };
resolve(newLayer);
};
img.onerror = () => {
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
resolve(null);
};
img.src = canvas.toDataURL();
}
else {
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
resolve(null);
}
}
} }
async saveStateToDB() { async saveStateToDB() {
if (!this.canvas.node.id) { if (!this.canvas.node.id) {
log.error("Node ID is not available for saving state to DB."); log.error("Node ID is not available for saving state to DB.");
return; return;
} }
log.info("Preparing state to be sent to worker..."); log.info("Preparing state to be sent to worker...");
const layers = await this._prepareLayers();
const state = { const state = {
layers: await this._prepareLayers(), layers: layers.filter(layer => layer !== null),
viewport: this.canvas.viewport, viewport: this.canvas.viewport,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
}; };
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) { if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping."); log.warn("No valid layers to save, skipping.");
return; return;
} }
if (this.stateSaverWorker) { if (this.stateSaverWorker) {
log.info("Posting state to worker for background saving."); log.info("Posting state to worker for background saving.");
this.stateSaverWorker.postMessage({ this.stateSaverWorker.postMessage({
nodeId: this.canvas.node.id, nodeId: String(this.canvas.node.id),
state: state state: state
}); });
this.canvas.render(); 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(String(this.canvas.node.id), state);
} }
} }
/** /**
* Przygotowuje warstwy do zapisu * Przygotowuje warstwy do zapisu
* @returns {Promise<Array>} Przygotowane warstwy * @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
*/ */
async _prepareLayers() { async _prepareLayers() {
return Promise.all(this.canvas.layers.map(async (layer, index) => { const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
const newLayer = {...layer}; const newLayer = { ...layer, imageId: layer.imageId || '' };
delete newLayer.image;
if (layer.image instanceof HTMLImageElement) { if (layer.image instanceof HTMLImageElement) {
log.debug(`Layer ${index}: Using imageId instead of serializing image.`); if (layer.imageId) {
if (!layer.imageId) { newLayer.imageId = layer.imageId;
layer.imageId = generateUUID();
await saveImage(layer.imageId, layer.image.src);
this.canvas.imageCache.set(layer.imageId, layer.image.src);
} }
newLayer.imageId = layer.imageId; else {
} else if (!layer.imageId) { log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
newLayer.imageId = generateUUID();
const imageBitmap = await createImageBitmap(layer.image);
await saveImage(newLayer.imageId, imageBitmap);
}
}
else if (!layer.imageId) {
log.error(`Layer ${index}: No image or imageId found, skipping layer.`); log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
return null; return null;
} }
delete newLayer.image;
return newLayer; return newLayer;
})); }));
return preparedLayers.filter((layer) => layer !== null);
} }
saveState(replaceLast = false) { saveState(replaceLast = false) {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.saveMaskState(replaceLast); this.saveMaskState(replaceLast);
} else { }
else {
this.saveLayersState(replaceLast); this.saveLayersState(replaceLast);
} }
} }
saveLayersState(replaceLast = false) { saveLayersState(replaceLast = false) {
if (replaceLast && this.layersUndoStack.length > 0) { if (replaceLast && this.layersUndoStack.length > 0) {
this.layersUndoStack.pop(); this.layersUndoStack.pop();
} }
const currentState = cloneLayers(this.canvas.layers); const currentState = cloneLayers(this.canvas.layers);
const currentStateSignature = getStateSignature(currentState); const currentStateSignature = getStateSignature(currentState);
if (this.layersUndoStack.length > 0) { if (this.layersUndoStack.length > 0) {
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
if (getStateSignature(lastState) === currentStateSignature) { if (getStateSignature(lastState) === currentStateSignature) {
return; return;
} }
} }
this.layersUndoStack.push(currentState); this.layersUndoStack.push(currentState);
if (this.layersUndoStack.length > this.historyLimit) { if (this.layersUndoStack.length > this.historyLimit) {
this.layersUndoStack.shift(); this.layersUndoStack.shift();
} }
this.layersRedoStack = []; this.layersRedoStack = [];
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
// Użyj debouncingu, aby zapobiec zbyt częstym zapisom
if (!this._debouncedSave) { if (!this._debouncedSave) {
this._debouncedSave = debounce(() => this.saveStateToDB(), 1000); this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
} }
this._debouncedSave(); this._debouncedSave();
} }
saveMaskState(replaceLast = false) { saveMaskState(replaceLast = false) {
if (!this.canvas.maskTool) return; if (!this.canvas.maskTool)
return;
if (replaceLast && this.maskUndoStack.length > 0) { if (replaceLast && this.maskUndoStack.length > 0) {
this.maskUndoStack.pop(); this.maskUndoStack.pop();
} }
@@ -307,89 +319,92 @@ export class CanvasState {
clonedCanvas.width = maskCanvas.width; clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height; clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true }); const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
clonedCtx.drawImage(maskCanvas, 0, 0); if (clonedCtx) {
clonedCtx.drawImage(maskCanvas, 0, 0);
}
this.maskUndoStack.push(clonedCanvas); this.maskUndoStack.push(clonedCanvas);
if (this.maskUndoStack.length > this.historyLimit) { if (this.maskUndoStack.length > this.historyLimit) {
this.maskUndoStack.shift(); this.maskUndoStack.shift();
} }
this.maskRedoStack = []; this.maskRedoStack = [];
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
undo() { undo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.undoMaskState(); this.undoMaskState();
} else { }
else {
this.undoLayersState(); this.undoLayersState();
} }
} }
redo() { redo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.redoMaskState(); this.redoMaskState();
} else { }
else {
this.redoLayersState(); this.redoLayersState();
} }
} }
undoLayersState() { undoLayersState() {
if (this.layersUndoStack.length <= 1) return; if (this.layersUndoStack.length <= 1)
return;
const currentState = this.layersUndoStack.pop(); const currentState = this.layersUndoStack.pop();
this.layersRedoStack.push(currentState); if (currentState) {
this.layersRedoStack.push(currentState);
}
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
this.canvas.layers = cloneLayers(prevState); this.canvas.layers = cloneLayers(prevState);
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
redoLayersState() { redoLayersState() {
if (this.layersRedoStack.length === 0) return; if (this.layersRedoStack.length === 0)
return;
const nextState = this.layersRedoStack.pop(); const nextState = this.layersRedoStack.pop();
this.layersUndoStack.push(nextState); if (nextState) {
this.canvas.layers = cloneLayers(nextState); this.layersUndoStack.push(nextState);
this.canvas.updateSelectionAfterHistory(); this.canvas.layers = cloneLayers(nextState);
this.canvas.render(); this.canvas.updateSelectionAfterHistory();
this.canvas.updateHistoryButtons(); this.canvas.render();
this.canvas.updateHistoryButtons();
}
} }
undoMaskState() { undoMaskState() {
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return; if (!this.canvas.maskTool || this.maskUndoStack.length <= 1)
return;
const currentState = this.maskUndoStack.pop(); const currentState = this.maskUndoStack.pop();
this.maskRedoStack.push(currentState); if (currentState) {
this.maskRedoStack.push(currentState);
}
if (this.maskUndoStack.length > 0) { if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); if (maskCtx) {
maskCtx.drawImage(prevState, 0, 0); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
}
this.canvas.render(); this.canvas.render();
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
redoMaskState() { redoMaskState() {
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return; if (!this.canvas.maskTool || this.maskRedoStack.length === 0)
return;
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
this.maskUndoStack.push(nextState); if (nextState) {
const maskCanvas = this.canvas.maskTool.getMask(); this.maskUndoStack.push(nextState);
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCanvas = this.canvas.maskTool.getMask();
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.drawImage(nextState, 0, 0); if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
this.canvas.render(); maskCtx.drawImage(nextState, 0, 0);
}
this.canvas.render();
}
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
/** /**
* Czyści historię undo/redo * Czyści historię undo/redo
*/ */
@@ -397,17 +412,17 @@ export class CanvasState {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.maskUndoStack = []; this.maskUndoStack = [];
this.maskRedoStack = []; this.maskRedoStack = [];
} else { }
else {
this.layersUndoStack = []; this.layersUndoStack = [];
this.layersRedoStack = []; this.layersRedoStack = [];
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
log.info("History cleared"); log.info("History cleared");
} }
/** /**
* Zwraca informacje o historii * Zwraca informacje o historii
* @returns {Object} Informacje o historii * @returns {HistoryInfo} Informacje o historii
*/ */
getHistoryInfo() { getHistoryInfo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
@@ -418,7 +433,8 @@ export class CanvasState {
canRedo: this.maskRedoStack.length > 0, canRedo: this.maskRedoStack.length > 0,
historyLimit: this.historyLimit historyLimit: this.historyLimit
}; };
} else { }
else {
return { return {
undoCount: this.layersUndoStack.length, undoCount: this.layersUndoStack.length,
redoCount: this.layersRedoStack.length, redoCount: this.layersRedoStack.length,

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,8 @@
* ErrorHandler - Centralna obsługa błędów * ErrorHandler - Centralna obsługa błędów
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie * Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
*/ */
import { createModuleLogger } from "./utils/LoggerUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('ErrorHandler'); const log = createModuleLogger('ErrorHandler');
/** /**
* Typy błędów w aplikacji * Typy błędów w aplikacji
*/ */
@@ -20,7 +17,6 @@ export const ErrorTypes = {
USER_INPUT: 'USER_INPUT_ERROR', USER_INPUT: 'USER_INPUT_ERROR',
SYSTEM: 'SYSTEM_ERROR' SYSTEM: 'SYSTEM_ERROR'
}; };
/** /**
* Klasa błędu aplikacji z dodatkowymi informacjami * Klasa błędu aplikacji z dodatkowymi informacjami
*/ */
@@ -37,7 +33,6 @@ export class AppError extends Error {
} }
} }
} }
/** /**
* Handler błędów z automatycznym logowaniem i kategoryzacją * Handler błędów z automatycznym logowaniem i kategoryzacją
*/ */
@@ -47,12 +42,11 @@ export class ErrorHandler {
this.errorHistory = []; this.errorHistory = [];
this.maxHistorySize = 100; this.maxHistorySize = 100;
} }
/** /**
* Obsługuje błąd z automatycznym logowaniem * Obsługuje błąd z automatycznym logowaniem
* @param {Error|AppError} error - Błąd do obsłużenia * @param {Error | AppError | string} error - Błąd do obsłużenia
* @param {string} context - Kontekst wystąpienia błędu * @param {string} context - Kontekst wystąpienia błędu
* @param {Object} additionalInfo - Dodatkowe informacje * @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd * @returns {AppError} Znormalizowany błąd
*/ */
handle(error, context = 'Unknown', additionalInfo = {}) { handle(error, context = 'Unknown', additionalInfo = {}) {
@@ -60,52 +54,33 @@ export class ErrorHandler {
this.logError(normalizedError, context); this.logError(normalizedError, context);
this.recordError(normalizedError); this.recordError(normalizedError);
this.incrementErrorCount(normalizedError.type); this.incrementErrorCount(normalizedError.type);
return normalizedError; return normalizedError;
} }
/** /**
* Normalizuje błąd do standardowego formatu * Normalizuje błąd do standardowego formatu
* @param {Error|AppError|string} error - Błąd do znormalizowania * @param {Error | AppError | string} error - Błąd do znormalizowania
* @param {string} context - Kontekst * @param {string} context - Kontekst
* @param {Object} additionalInfo - Dodatkowe informacje * @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd * @returns {AppError} Znormalizowany błąd
*/ */
normalizeError(error, context, additionalInfo) { normalizeError(error, context, additionalInfo) {
if (error instanceof AppError) { if (error instanceof AppError) {
return error; return error;
} }
if (error instanceof Error) { if (error instanceof Error) {
const type = this.categorizeError(error, context); const type = this.categorizeError(error, context);
return new AppError( return new AppError(error.message, type, { context, ...additionalInfo }, error);
error.message,
type,
{context, ...additionalInfo},
error
);
} }
if (typeof error === 'string') { if (typeof error === 'string') {
return new AppError( return new AppError(error, ErrorTypes.SYSTEM, { context, ...additionalInfo });
error,
ErrorTypes.SYSTEM,
{context, ...additionalInfo}
);
} }
return new AppError('Unknown error occurred', ErrorTypes.SYSTEM, { context, originalError: error, ...additionalInfo });
return new AppError(
'Unknown error occurred',
ErrorTypes.SYSTEM,
{context, originalError: error, ...additionalInfo}
);
} }
/** /**
* Kategoryzuje błąd na podstawie wiadomości i kontekstu * Kategoryzuje błąd na podstawie wiadomości i kontekstu
* @param {Error} error - Błąd do skategoryzowania * @param {Error} error - Błąd do skategoryzowania
* @param {string} context - Kontekst * @param {string} context - Kontekst
* @returns {string} Typ błędu * @returns {ErrorType} Typ błędu
*/ */
categorizeError(error, context) { categorizeError(error, context) {
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
@@ -132,10 +107,8 @@ export class ErrorHandler {
if (context.toLowerCase().includes('canvas')) { if (context.toLowerCase().includes('canvas')) {
return ErrorTypes.CANVAS; return ErrorTypes.CANVAS;
} }
return ErrorTypes.SYSTEM; return ErrorTypes.SYSTEM;
} }
/** /**
* Loguje błąd z odpowiednim poziomem * Loguje błąd z odpowiednim poziomem
* @param {AppError} error - Błąd do zalogowania * @param {AppError} error - Błąd do zalogowania
@@ -161,7 +134,6 @@ export class ErrorHandler {
log.error(logMessage, logDetails); log.error(logMessage, logDetails);
} }
} }
/** /**
* Zapisuje błąd w historii * Zapisuje błąd w historii
* @param {AppError} error - Błąd do zapisania * @param {AppError} error - Błąd do zapisania
@@ -177,36 +149,37 @@ export class ErrorHandler {
this.errorHistory.shift(); this.errorHistory.shift();
} }
} }
/** /**
* Zwiększa licznik błędów dla danego typu * Zwiększa licznik błędów dla danego typu
* @param {string} errorType - Typ błędu * @param {ErrorType} errorType - Typ błędu
*/ */
incrementErrorCount(errorType) { incrementErrorCount(errorType) {
const current = this.errorCounts.get(errorType) || 0; const current = this.errorCounts.get(errorType) || 0;
this.errorCounts.set(errorType, current + 1); this.errorCounts.set(errorType, current + 1);
} }
/** /**
* Zwraca statystyki błędów * Zwraca statystyki błędów
* @returns {Object} Statystyki błędów * @returns {ErrorStats} Statystyki błędów
*/ */
getErrorStats() { getErrorStats() {
const errorCountsObj = {};
for (const [key, value] of this.errorCounts.entries()) {
errorCountsObj[key] = value;
}
return { return {
totalErrors: this.errorHistory.length, totalErrors: this.errorHistory.length,
errorCounts: Object.fromEntries(this.errorCounts), errorCounts: errorCountsObj,
recentErrors: this.errorHistory.slice(-10), recentErrors: this.errorHistory.slice(-10),
errorsByType: this.groupErrorsByType() errorsByType: this.groupErrorsByType()
}; };
} }
/** /**
* Grupuje błędy według typu * Grupuje błędy według typu
* @returns {Object} Błędy pogrupowane według typu * @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
*/ */
groupErrorsByType() { groupErrorsByType() {
const grouped = {}; const grouped = {};
this.errorHistory.forEach(error => { this.errorHistory.forEach((error) => {
if (!grouped[error.type]) { if (!grouped[error.type]) {
grouped[error.type] = []; grouped[error.type] = [];
} }
@@ -214,7 +187,6 @@ export class ErrorHandler {
}); });
return grouped; return grouped;
} }
/** /**
* Czyści historię błędów * Czyści historię błędów
*/ */
@@ -224,9 +196,7 @@ export class ErrorHandler {
log.info('Error history cleared'); log.info('Error history cleared');
} }
} }
const errorHandler = new ErrorHandler(); const errorHandler = new ErrorHandler();
/** /**
* Wrapper funkcji z automatyczną obsługą błędów * Wrapper funkcji z automatyczną obsługą błędów
* @param {Function} fn - Funkcja do opakowania * @param {Function} fn - Funkcja do opakowania
@@ -237,7 +207,8 @@ export function withErrorHandling(fn, context) {
return async function (...args) { return async function (...args) {
try { try {
return await fn.apply(this, args); return await fn.apply(this, args);
} catch (error) { }
catch (error) {
const handledError = errorHandler.handle(error, context, { const handledError = errorHandler.handle(error, context, {
functionName: fn.name, functionName: fn.name,
arguments: args.length arguments: args.length
@@ -246,7 +217,6 @@ export function withErrorHandling(fn, context) {
} }
}; };
} }
/** /**
* Decorator dla metod klasy z automatyczną obsługą błędów * Decorator dla metod klasy z automatyczną obsługą błędów
* @param {string} context - Kontekst wykonania * @param {string} context - Kontekst wykonania
@@ -254,11 +224,11 @@ export function withErrorHandling(fn, context) {
export function handleErrors(context) { export function handleErrors(context) {
return function (target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function (...args) { descriptor.value = async function (...args) {
try { try {
return await originalMethod.apply(this, args); return await originalMethod.apply(this, args);
} catch (error) { }
catch (error) {
const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, { const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, {
className: target.constructor.name, className: target.constructor.name,
methodName: propertyKey, methodName: propertyKey,
@@ -267,86 +237,77 @@ export function handleErrors(context) {
throw handledError; throw handledError;
} }
}; };
return descriptor; return descriptor;
}; };
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów walidacji * Funkcja pomocnicza do tworzenia błędów walidacji
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły walidacji * @param {object} details - Szczegóły walidacji
* @returns {AppError} Błąd walidacji * @returns {AppError} Błąd walidacji
*/ */
export function createValidationError(message, details = {}) { export function createValidationError(message, details = {}) {
return new AppError(message, ErrorTypes.VALIDATION, details); return new AppError(message, ErrorTypes.VALIDATION, details);
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów sieciowych * Funkcja pomocnicza do tworzenia błędów sieciowych
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły sieci * @param {object} details - Szczegóły sieci
* @returns {AppError} Błąd sieciowy * @returns {AppError} Błąd sieciowy
*/ */
export function createNetworkError(message, details = {}) { export function createNetworkError(message, details = {}) {
return new AppError(message, ErrorTypes.NETWORK, details); return new AppError(message, ErrorTypes.NETWORK, details);
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów plików * Funkcja pomocnicza do tworzenia błędów plików
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły pliku * @param {object} details - Szczegóły pliku
* @returns {AppError} Błąd pliku * @returns {AppError} Błąd pliku
*/ */
export function createFileError(message, details = {}) { export function createFileError(message, details = {}) {
return new AppError(message, ErrorTypes.FILE_IO, details); return new AppError(message, ErrorTypes.FILE_IO, details);
} }
/** /**
* Funkcja pomocnicza do bezpiecznego wykonania operacji * Funkcja pomocnicza do bezpiecznego wykonania operacji
* @param {Function} operation - Operacja do wykonania * @param {() => Promise<T>} operation - Operacja do wykonania
* @param {*} fallbackValue - Wartość fallback w przypadku błędu * @param {T} fallbackValue - Wartość fallback w przypadku błędu
* @param {string} context - Kontekst operacji * @param {string} context - Kontekst operacji
* @returns {*} Wynik operacji lub wartość fallback * @returns {Promise<T>} Wynik operacji lub wartość fallback
*/ */
export async function safeExecute(operation, fallbackValue = null, context = 'SafeExecute') { export async function safeExecute(operation, fallbackValue, context = 'SafeExecute') {
try { try {
return await operation(); return await operation();
} catch (error) { }
catch (error) {
errorHandler.handle(error, context); errorHandler.handle(error, context);
return fallbackValue; return fallbackValue;
} }
} }
/** /**
* Funkcja do retry operacji z exponential backoff * Funkcja do retry operacji z exponential backoff
* @param {Function} operation - Operacja do powtórzenia * @param {() => Promise<T>} operation - Operacja do powtórzenia
* @param {number} maxRetries - Maksymalna liczba prób * @param {number} maxRetries - Maksymalna liczba prób
* @param {number} baseDelay - Podstawowe opóźnienie w ms * @param {number} baseDelay - Podstawowe opóźnienie w ms
* @param {string} context - Kontekst operacji * @param {string} context - Kontekst operacji
* @returns {*} Wynik operacji * @returns {Promise<T>} Wynik operacji
*/ */
export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') { export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') {
let lastError; let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
return await operation(); return await operation();
} catch (error) { }
catch (error) {
lastError = error; lastError = error;
if (attempt === maxRetries) { if (attempt === maxRetries) {
break; break;
} }
const delay = baseDelay * Math.pow(2, attempt); const delay = baseDelay * Math.pow(2, attempt);
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: error.message, context}); log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, { error: lastError.message, context });
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
throw errorHandler.handle(lastError, context, { attempts: maxRetries + 1 });
throw errorHandler.handle(lastError, context, {attempts: maxRetries + 1});
} }
export { errorHandler };
export {errorHandler};
export default errorHandler; export default errorHandler;

View File

@@ -1,27 +1,21 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('ImageCache'); const log = createModuleLogger('ImageCache');
export class ImageCache { export class ImageCache {
constructor() { constructor() {
this.cache = new Map(); this.cache = new Map();
} }
set(key, imageData) { set(key, imageData) {
log.info("Caching image data for key:", key); log.info("Caching image data for key:", key);
this.cache.set(key, imageData); this.cache.set(key, imageData);
} }
get(key) { get(key) {
const data = this.cache.get(key); const data = this.cache.get(key);
log.debug("Retrieved cached data for key:", key, !!data); log.debug("Retrieved cached data for key:", key, !!data);
return data; return data;
} }
has(key) { has(key) {
return this.cache.has(key); return this.cache.has(key);
} }
clear() { clear() {
log.info("Clearing image cache"); log.info("Clearing image cache");
this.cache.clear(); this.cache.clear();

View File

@@ -1,24 +1,18 @@
import {removeImage, getAllImageIds} from "./db.js"; import { removeImage, getAllImageIds } from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('ImageReferenceManager'); const log = createModuleLogger('ImageReferenceManager');
export class ImageReferenceManager { export class ImageReferenceManager {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.imageReferences = new Map(); // imageId -> count this.imageReferences = new Map(); // imageId -> count
this.imageLastUsed = new Map(); // imageId -> timestamp this.imageLastUsed = new Map(); // imageId -> timestamp
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
this.gcTimer = null; this.gcTimer = null;
this.isGcRunning = false; this.isGcRunning = false;
this.operationCount = 0; this.operationCount = 0;
this.operationThreshold = 500; // Uruchom GC po 500 operacjach this.operationThreshold = 500; // Uruchom GC po 500 operacjach
} }
/** /**
* Uruchamia automatyczne garbage collection * Uruchamia automatyczne garbage collection
*/ */
@@ -26,14 +20,11 @@ export class ImageReferenceManager {
if (this.gcTimer) { if (this.gcTimer) {
clearInterval(this.gcTimer); clearInterval(this.gcTimer);
} }
this.gcTimer = window.setInterval(() => {
this.gcTimer = setInterval(() => {
this.performGarbageCollection(); this.performGarbageCollection();
}, this.gcInterval); }, this.gcInterval);
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds"); log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
} }
/** /**
* Zatrzymuje automatyczne garbage collection * Zatrzymuje automatyczne garbage collection
*/ */
@@ -44,38 +35,35 @@ export class ImageReferenceManager {
} }
log.info("Garbage collection stopped"); log.info("Garbage collection stopped");
} }
/** /**
* Dodaje referencję do obrazu * Dodaje referencję do obrazu
* @param {string} imageId - ID obrazu * @param {string} imageId - ID obrazu
*/ */
addReference(imageId) { addReference(imageId) {
if (!imageId) return; if (!imageId)
return;
const currentCount = this.imageReferences.get(imageId) || 0; const currentCount = this.imageReferences.get(imageId) || 0;
this.imageReferences.set(imageId, currentCount + 1); this.imageReferences.set(imageId, currentCount + 1);
this.imageLastUsed.set(imageId, Date.now()); this.imageLastUsed.set(imageId, Date.now());
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`); log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
} }
/** /**
* Usuwa referencję do obrazu * Usuwa referencję do obrazu
* @param {string} imageId - ID obrazu * @param {string} imageId - ID obrazu
*/ */
removeReference(imageId) { removeReference(imageId) {
if (!imageId) return; if (!imageId)
return;
const currentCount = this.imageReferences.get(imageId) || 0; const currentCount = this.imageReferences.get(imageId) || 0;
if (currentCount <= 1) { if (currentCount <= 1) {
this.imageReferences.delete(imageId); this.imageReferences.delete(imageId);
log.debug(`Removed last reference to image ${imageId}`); log.debug(`Removed last reference to image ${imageId}`);
} else { }
else {
this.imageReferences.set(imageId, currentCount - 1); this.imageReferences.set(imageId, currentCount - 1);
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`); log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
} }
} }
/** /**
* Aktualizuje referencje na podstawie aktualnego stanu canvas * Aktualizuje referencje na podstawie aktualnego stanu canvas
*/ */
@@ -86,117 +74,100 @@ export class ImageReferenceManager {
usedImageIds.forEach(imageId => { usedImageIds.forEach(imageId => {
this.addReference(imageId); this.addReference(imageId);
}); });
log.info(`Updated references for ${usedImageIds.size} unique images`); log.info(`Updated references for ${usedImageIds.size} unique images`);
} }
/** /**
* Zbiera wszystkie używane imageId z różnych źródeł * Zbiera wszystkie używane imageId z różnych źródeł
* @returns {Set<string>} Zbiór używanych imageId * @returns {Set<string>} Zbiór używanych imageId
*/ */
collectAllUsedImageIds() { collectAllUsedImageIds() {
const usedImageIds = new Set(); const usedImageIds = new Set();
this.canvas.layers.forEach(layer => { this.canvas.layers.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) { if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
this.canvas.canvasState.layersUndoStack.forEach(layersState => { this.canvas.canvasState.layersUndoStack.forEach((layersState) => {
layersState.forEach(layer => { layersState.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
}); });
} }
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) { if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
this.canvas.canvasState.layersRedoStack.forEach(layersState => { this.canvas.canvasState.layersRedoStack.forEach((layersState) => {
layersState.forEach(layer => { layersState.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
}); });
} }
log.debug(`Collected ${usedImageIds.size} used image IDs`); log.debug(`Collected ${usedImageIds.size} used image IDs`);
return usedImageIds; return usedImageIds;
} }
/** /**
* Znajduje nieużywane obrazy * Znajduje nieużywane obrazy
* @param {Set<string>} usedImageIds - Zbiór używanych imageId * @param {Set<string>} usedImageIds - Zbiór używanych imageId
* @returns {Array<string>} Lista nieużywanych imageId * @returns {Promise<string[]>} Lista nieużywanych imageId
*/ */
async findUnusedImages(usedImageIds) { async findUnusedImages(usedImageIds) {
try { try {
const allImageIds = await getAllImageIds(); const allImageIds = await getAllImageIds();
const unusedImages = []; const unusedImages = [];
const now = Date.now(); const now = Date.now();
for (const imageId of allImageIds) { for (const imageId of allImageIds) {
if (!usedImageIds.has(imageId)) { if (!usedImageIds.has(imageId)) {
const lastUsed = this.imageLastUsed.get(imageId) || 0; const lastUsed = this.imageLastUsed.get(imageId) || 0;
const age = now - lastUsed; const age = now - lastUsed;
if (age > this.maxAge) { if (age > this.maxAge) {
unusedImages.push(imageId); unusedImages.push(imageId);
} else { }
else {
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`); log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
} }
} }
} }
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`); log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
return unusedImages; return unusedImages;
} catch (error) { }
catch (error) {
log.error("Error finding unused images:", error); log.error("Error finding unused images:", error);
return []; return [];
} }
} }
/** /**
* Czyści nieużywane obrazy * Czyści nieużywane obrazy
* @param {Array<string>} unusedImages - Lista nieużywanych imageId * @param {string[]} unusedImages - Lista nieużywanych imageId
*/ */
async cleanupUnusedImages(unusedImages) { async cleanupUnusedImages(unusedImages) {
if (unusedImages.length === 0) { if (unusedImages.length === 0) {
log.debug("No unused images to cleanup"); log.debug("No unused images to cleanup");
return; return;
} }
log.info(`Starting cleanup of ${unusedImages.length} unused images`); log.info(`Starting cleanup of ${unusedImages.length} unused images`);
let cleanedCount = 0; let cleanedCount = 0;
let errorCount = 0; let errorCount = 0;
for (const imageId of unusedImages) { for (const imageId of unusedImages) {
try { try {
await removeImage(imageId); await removeImage(imageId);
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) { if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
this.canvas.imageCache.delete(imageId); this.canvas.imageCache.delete(imageId);
} }
this.imageReferences.delete(imageId); this.imageReferences.delete(imageId);
this.imageLastUsed.delete(imageId); this.imageLastUsed.delete(imageId);
cleanedCount++; cleanedCount++;
log.debug(`Cleaned up image: ${imageId}`); log.debug(`Cleaned up image: ${imageId}`);
}
} catch (error) { catch (error) {
errorCount++; errorCount++;
log.error(`Error cleaning up image ${imageId}:`, error); log.error(`Error cleaning up image ${imageId}:`, error);
} }
} }
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`); log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
} }
/** /**
* Wykonuje pełne garbage collection * Wykonuje pełne garbage collection
*/ */
@@ -205,44 +176,35 @@ export class ImageReferenceManager {
log.debug("Garbage collection already running, skipping"); log.debug("Garbage collection already running, skipping");
return; return;
} }
this.isGcRunning = true; this.isGcRunning = true;
log.info("Starting garbage collection..."); log.info("Starting garbage collection...");
try { try {
this.updateReferences(); this.updateReferences();
const usedImageIds = this.collectAllUsedImageIds(); const usedImageIds = this.collectAllUsedImageIds();
const unusedImages = await this.findUnusedImages(usedImageIds); const unusedImages = await this.findUnusedImages(usedImageIds);
await this.cleanupUnusedImages(unusedImages); await this.cleanupUnusedImages(unusedImages);
}
} catch (error) { catch (error) {
log.error("Error during garbage collection:", error); log.error("Error during garbage collection:", error);
} finally { }
finally {
this.isGcRunning = false; this.isGcRunning = false;
} }
} }
/** /**
* Zwiększa licznik operacji i sprawdza czy uruchomić GC * Zwiększa licznik operacji i sprawdza czy uruchomić GC
*/ */
incrementOperationCount() { incrementOperationCount() {
this.operationCount++; this.operationCount++;
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`); log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
if (this.operationCount >= this.operationThreshold) { if (this.operationCount >= this.operationThreshold) {
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`); log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
this.operationCount = 0; // Reset counter this.operationCount = 0; // Reset counter
setTimeout(() => { setTimeout(() => {
this.performGarbageCollection(); this.performGarbageCollection();
}, 100); }, 100);
} }
} }
/** /**
* Resetuje licznik operacji * Resetuje licznik operacji
*/ */
@@ -250,7 +212,6 @@ export class ImageReferenceManager {
this.operationCount = 0; this.operationCount = 0;
log.debug("Operation count reset"); log.debug("Operation count reset");
} }
/** /**
* Ustawia próg operacji dla automatycznego GC * Ustawia próg operacji dla automatycznego GC
* @param {number} threshold - Nowy próg operacji * @param {number} threshold - Nowy próg operacji
@@ -259,7 +220,6 @@ export class ImageReferenceManager {
this.operationThreshold = Math.max(1, threshold); this.operationThreshold = Math.max(1, threshold);
log.info(`Operation threshold set to: ${this.operationThreshold}`); log.info(`Operation threshold set to: ${this.operationThreshold}`);
} }
/** /**
* Ręczne uruchomienie garbage collection * Ręczne uruchomienie garbage collection
*/ */
@@ -267,10 +227,9 @@ export class ImageReferenceManager {
log.info("Manual garbage collection triggered"); log.info("Manual garbage collection triggered");
await this.performGarbageCollection(); await this.performGarbageCollection();
} }
/** /**
* Zwraca statystyki garbage collection * Zwraca statystyki garbage collection
* @returns {Object} Statystyki * @returns {GarbageCollectionStats} Statystyki
*/ */
getStats() { getStats() {
return { return {
@@ -281,7 +240,6 @@ export class ImageReferenceManager {
maxAge: this.maxAge maxAge: this.maxAge
}; };
} }
/** /**
* Czyści wszystkie dane (przy usuwaniu canvas) * Czyści wszystkie dane (przy usuwaniu canvas)
*/ */

View File

@@ -1,18 +1,18 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('Mask_tool'); const log = createModuleLogger('Mask_tool');
export class MaskTool { export class MaskTool {
constructor(canvasInstance, callbacks = {}) { constructor(canvasInstance, callbacks = {}) {
this.canvasInstance = canvasInstance; this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas'); this.maskCanvas = document.createElement('canvas');
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx) {
throw new Error("Failed to get 2D context for mask canvas");
}
this.maskCtx = maskCtx;
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
this.isOverlayVisible = true; this.isOverlayVisible = true;
this.isActive = false; this.isActive = false;
this.brushSize = 20; this.brushSize = 20;
@@ -20,15 +20,16 @@ export class MaskTool {
this.brushHardness = 0.5; this.brushHardness = 0.5;
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
this.previewCanvas = document.createElement('canvas'); this.previewCanvas = document.createElement('canvas');
this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true }); const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
if (!previewCtx) {
throw new Error("Failed to get 2D context for preview canvas");
}
this.previewCtx = previewCtx;
this.previewVisible = false; this.previewVisible = false;
this.previewCanvasInitialized = false; this.previewCanvasInitialized = false;
this.initMaskCanvas(); this.initMaskCanvas();
} }
initPreviewCanvas() { initPreviewCanvas() {
if (this.previewCanvas.parentElement) { if (this.previewCanvas.parentElement) {
this.previewCanvas.parentElement.removeChild(this.previewCanvas); this.previewCanvas.parentElement.removeChild(this.previewCanvas);
@@ -40,27 +41,22 @@ export class MaskTool {
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`; this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
this.previewCanvas.style.pointerEvents = 'none'; this.previewCanvas.style.pointerEvents = 'none';
this.previewCanvas.style.zIndex = '10'; this.previewCanvas.style.zIndex = '10';
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas); if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
}
} }
setBrushHardness(hardness) { setBrushHardness(hardness) {
this.brushHardness = Math.max(0, Math.min(1, hardness)); this.brushHardness = Math.max(0, Math.min(1, hardness));
} }
initMaskCanvas() { initMaskCanvas() {
const extraSpace = 2000; // Allow for a generous drawing area outside the output area const extraSpace = 2000; // Allow for a generous drawing area outside the output area
this.maskCanvas.width = this.canvasInstance.width + extraSpace; this.maskCanvas.width = this.canvasInstance.width + extraSpace;
this.maskCanvas.height = this.canvasInstance.height + extraSpace; this.maskCanvas.height = this.canvasInstance.height + extraSpace;
this.x = -extraSpace / 2; this.x = -extraSpace / 2;
this.y = -extraSpace / 2; this.y = -extraSpace / 2;
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`); log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
} }
activate() { activate() {
if (!this.previewCanvasInitialized) { if (!this.previewCanvasInitialized) {
this.initPreviewCanvas(); this.initPreviewCanvas();
@@ -69,131 +65,108 @@ export class MaskTool {
this.isActive = true; this.isActive = true;
this.previewCanvas.style.display = 'block'; this.previewCanvas.style.display = 'block';
this.canvasInstance.interaction.mode = 'drawingMask'; this.canvasInstance.interaction.mode = 'drawingMask';
if (this.canvasInstance.canvasState && this.canvasInstance.canvasState.maskUndoStack.length === 0) { if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
this.canvasInstance.canvasState.saveMaskState(); this.canvasInstance.canvasState.saveMaskState();
} }
this.canvasInstance.updateHistoryButtons(); this.canvasInstance.updateHistoryButtons();
log.info("Mask tool activated"); log.info("Mask tool activated");
} }
deactivate() { deactivate() {
this.isActive = false; this.isActive = false;
this.previewCanvas.style.display = 'none'; this.previewCanvas.style.display = 'none';
this.canvasInstance.interaction.mode = 'none'; this.canvasInstance.interaction.mode = 'none';
this.canvasInstance.updateHistoryButtons(); this.canvasInstance.updateHistoryButtons();
log.info("Mask tool deactivated"); log.info("Mask tool deactivated");
} }
setBrushSize(size) { setBrushSize(size) {
this.brushSize = Math.max(1, size); this.brushSize = Math.max(1, size);
} }
setBrushStrength(strength) { setBrushStrength(strength) {
this.brushStrength = Math.max(0, Math.min(1, strength)); this.brushStrength = Math.max(0, Math.min(1, strength));
} }
handleMouseDown(worldCoords, viewCoords) { handleMouseDown(worldCoords, viewCoords) {
if (!this.isActive) return; if (!this.isActive)
return;
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
this.draw(worldCoords); this.draw(worldCoords);
this.clearPreview(); this.clearPreview();
} }
handleMouseMove(worldCoords, viewCoords) { handleMouseMove(worldCoords, viewCoords) {
if (this.isActive) { if (this.isActive) {
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
if (!this.isActive || !this.isDrawing) return; if (!this.isActive || !this.isDrawing)
return;
this.draw(worldCoords); this.draw(worldCoords);
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
handleMouseLeave() { handleMouseLeave() {
this.previewVisible = false; this.previewVisible = false;
this.clearPreview(); this.clearPreview();
} }
handleMouseEnter() { handleMouseEnter() {
this.previewVisible = true; this.previewVisible = true;
} }
handleMouseUp(viewCoords) { handleMouseUp(viewCoords) {
if (!this.isActive) return; if (!this.isActive)
return;
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
if (this.canvasInstance.canvasState) { this.canvasInstance.canvasState.saveMaskState();
this.canvasInstance.canvasState.saveMaskState();
}
if (this.onStateChange) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
} }
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
} }
draw(worldCoords) { draw(worldCoords) {
if (!this.lastPosition) { if (!this.lastPosition) {
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
const canvasLastX = this.lastPosition.x - this.x; const canvasLastX = this.lastPosition.x - this.x;
const canvasLastY = this.lastPosition.y - this.y; const canvasLastY = this.lastPosition.y - this.y;
const canvasX = worldCoords.x - this.x; const canvasX = worldCoords.x - this.x;
const canvasY = worldCoords.y - this.y; const canvasY = worldCoords.y - this.y;
const canvasWidth = this.maskCanvas.width; const canvasWidth = this.maskCanvas.width;
const canvasHeight = this.maskCanvas.height; const canvasHeight = this.maskCanvas.height;
if (canvasX >= 0 && canvasX < canvasWidth && if (canvasX >= 0 && canvasX < canvasWidth &&
canvasY >= 0 && canvasY < canvasHeight && canvasY >= 0 && canvasY < canvasHeight &&
canvasLastX >= 0 && canvasLastX < canvasWidth && canvasLastX >= 0 && canvasLastX < canvasWidth &&
canvasLastY >= 0 && canvasLastY < canvasHeight) { canvasLastY >= 0 && canvasLastY < canvasHeight) {
this.maskCtx.beginPath(); this.maskCtx.beginPath();
this.maskCtx.moveTo(canvasLastX, canvasLastY); this.maskCtx.moveTo(canvasLastX, canvasLastY);
this.maskCtx.lineTo(canvasX, canvasY); this.maskCtx.lineTo(canvasX, canvasY);
const gradientRadius = this.brushSize / 2; const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) { if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else { }
else {
const innerRadius = gradientRadius * this.brushHardness; const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient( const gradient = this.maskCtx.createRadialGradient(canvasX, canvasY, innerRadius, canvasX, canvasY, gradientRadius);
canvasX, canvasY, innerRadius,
canvasX, canvasY, gradientRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.maskCtx.strokeStyle = gradient; this.maskCtx.strokeStyle = gradient;
} }
this.maskCtx.lineWidth = this.brushSize; this.maskCtx.lineWidth = this.brushSize;
this.maskCtx.lineCap = 'round'; this.maskCtx.lineCap = 'round';
this.maskCtx.lineJoin = 'round'; this.maskCtx.lineJoin = 'round';
this.maskCtx.globalCompositeOperation = 'source-over'; this.maskCtx.globalCompositeOperation = 'source-over';
this.maskCtx.stroke(); this.maskCtx.stroke();
} else { }
else {
log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`);
} }
} }
drawBrushPreview(viewCoords) { drawBrushPreview(viewCoords) {
if (!this.previewVisible || this.isDrawing) { if (!this.previewVisible || this.isDrawing) {
this.clearPreview(); this.clearPreview();
return; return;
} }
this.clearPreview(); this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom; const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom; const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath(); this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI); this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
@@ -201,27 +174,26 @@ export class MaskTool {
this.previewCtx.setLineDash([2, 4]); this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke(); this.previewCtx.stroke();
} }
clearPreview() { clearPreview() {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
} }
clear() { clear() {
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
if (this.isActive && this.canvasInstance.canvasState) { if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState(); this.canvasInstance.canvasState.saveMaskState();
} }
} }
getMask() { getMask() {
return this.maskCanvas; return this.maskCanvas;
} }
getMaskImageWithAlpha() { getMaskImageWithAlpha() {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width; tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height; tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
throw new Error("Failed to get 2D context for temporary canvas");
}
tempCtx.drawImage(this.maskCanvas, 0, 0); tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data; const data = imageData.data;
@@ -237,7 +209,6 @@ export class MaskTool {
maskImage.src = tempCanvas.toDataURL(); maskImage.src = tempCanvas.toDataURL();
return maskImage; return maskImage;
} }
resize(width, height) { resize(width, height) {
this.initPreviewCanvas(); this.initPreviewCanvas();
const oldMask = this.maskCanvas; const oldMask = this.maskCanvas;
@@ -245,63 +216,46 @@ export class MaskTool {
const oldY = this.y; const oldY = this.y;
const oldWidth = oldMask.width; const oldWidth = oldMask.width;
const oldHeight = oldMask.height; const oldHeight = oldMask.height;
const isIncreasingWidth = width > this.canvasInstance.width;
const isIncreasingWidth = width > (this.canvasInstance.width); const isIncreasingHeight = height > this.canvasInstance.height;
const isIncreasingHeight = height > (this.canvasInstance.height);
this.maskCanvas = document.createElement('canvas'); this.maskCanvas = document.createElement('canvas');
const extraSpace = 2000; const extraSpace = 2000;
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
this.maskCanvas.width = newWidth; this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight; this.maskCanvas.height = newHeight;
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.maskCtx = newMaskCtx;
if (oldMask.width > 0 && oldMask.height > 0) { if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = this.x - oldX; const offsetX = this.x - oldX;
const offsetY = this.y - oldY; const offsetY = this.y - oldY;
this.maskCtx.drawImage(oldMask, offsetX, offsetY); this.maskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
} }
log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`); log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`);
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`); log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
} }
updatePosition(dx, dy) { updatePosition(dx, dy) {
this.x += dx; this.x += dx;
this.y += dy; this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`); log.info(`Mask position updated to (${this.x}, ${this.y})`);
} }
toggleOverlayVisibility() { toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible; this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
} }
setMask(image) { setMask(image) {
const destX = -this.x; const destX = -this.x;
const destY = -this.y; const destY = -this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY); this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
} }
this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę this.canvasInstance.render();
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`); log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
} }
} }

405
js/css/canvas_view.css Normal file
View File

@@ -0,0 +1,405 @@
.painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
text-align: center;
margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
}
.painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
transform: translateY(1px);
}
.painter-button:disabled,
.painter-button:disabled:hover {
background: #555;
color: #888;
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: #444;
}
.painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
border-color: #2a4cb4;
}
.painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
}
.painter-controls {
background: linear-gradient(to bottom, #404040, #383838);
border-bottom: 1px solid #2a2a2a;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
}
.painter-slider-container {
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 12px;
}
.painter-slider-container input[type="range"] {
width: 80px;
}
.painter-button-group {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(0,0,0,0.2);
padding: 4px;
border-radius: 6px;
}
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
}
.painter-separator {
width: 1px;
height: 28px;
background-color: #2a2a2a;
margin: 0 8px;
}
.painter-container {
background: #607080; /* 带蓝色的灰色背景 */
border: 1px solid #4a5a6a;
border-radius: 6px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
}
.painter-container.drag-over {
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
border-style: dashed;
}
.painter-dialog {
background: #404040;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
padding: 20px;
color: #ffffff;
}
.painter-dialog input {
background: #303030;
border: 1px solid #505050;
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
margin: 4px;
width: 80px;
}
.painter-dialog button {
background: #505050;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
padding: 4px 12px;
margin: 4px;
cursor: pointer;
}
.painter-dialog button:hover {
background: #606060;
}
.blend-opacity-slider {
width: 100%;
margin: 5px 0;
display: none;
}
.blend-mode-active .blend-opacity-slider {
display: block;
}
.blend-mode-item {
padding: 5px;
cursor: pointer;
position: relative;
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.painter-tooltip {
position: fixed;
display: none;
background: #3a3a3a;
color: #f0f0f0;
border: 1px solid #555;
border-radius: 8px;
padding: 12px 18px;
z-index: 9999;
font-size: 13px;
line-height: 1.7;
width: auto;
max-width: min(500px, calc(100vw - 40px));
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
pointer-events: none;
transform-origin: top left;
transition: transform 0.2s ease;
will-change: transform;
}
.painter-tooltip.scale-down {
transform: scale(0.9);
transform-origin: top;
}
.painter-tooltip.scale-down-more {
transform: scale(0.8);
transform-origin: top;
}
.painter-tooltip table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
}
.painter-tooltip table td {
padding: 2px 8px;
vertical-align: middle;
}
.painter-tooltip table td:first-child {
width: auto;
white-space: nowrap;
min-width: fit-content;
}
.painter-tooltip table td:last-child {
width: auto;
}
.painter-tooltip table tr:nth-child(odd) td {
background-color: rgba(0,0,0,0.1);
}
@media (max-width: 600px) {
.painter-tooltip {
font-size: 11px;
padding: 8px 12px;
}
.painter-tooltip table td {
padding: 2px 4px;
}
.painter-tooltip kbd {
padding: 1px 4px;
font-size: 10px;
}
.painter-tooltip table td:first-child {
width: 40%;
}
.painter-tooltip table td:last-child {
width: 60%;
}
.painter-tooltip h4 {
font-size: 12px;
margin-top: 8px;
margin-bottom: 4px;
}
}
@media (max-width: 400px) {
.painter-tooltip {
font-size: 10px;
padding: 6px 8px;
}
.painter-tooltip table td {
padding: 1px 3px;
}
.painter-tooltip kbd {
padding: 0px 3px;
font-size: 9px;
}
.painter-tooltip table td:first-child {
width: 35%;
}
.painter-tooltip table td:last-child {
width: 65%;
}
.painter-tooltip h4 {
font-size: 11px;
margin-top: 6px;
margin-bottom: 3px;
}
}
.painter-tooltip::-webkit-scrollbar {
width: 8px;
}
.painter-tooltip::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 4px;
}
.painter-tooltip::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.painter-tooltip::-webkit-scrollbar-thumb:hover {
background: #666;
}
.painter-tooltip h4 {
margin-top: 10px;
margin-bottom: 5px;
color: #4a90e2; /* Jasnoniebieski akcent */
border-bottom: 1px solid #555;
padding-bottom: 4px;
}
.painter-tooltip ul {
list-style: none;
padding-left: 10px;
margin: 0;
}
.painter-tooltip kbd {
background-color: #2a2a2a;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
color: #d0d0d0;
}
.painter-container.has-focus {
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
która nie wpłynie na rozmiar ani pozycję elementu. */
box-shadow: 0 0 0 2px white;
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
/* border-color: white; */
}
.painter-button.matting-button {
position: relative;
transition: all 0.3s ease;
}
.painter-button.matting-button.loading {
padding-right: 36px; /* Make space for spinner */
cursor: wait;
}
.painter-button.matting-button .matting-spinner {
display: none;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: matting-spin 1s linear infinite;
}
.painter-button.matting-button.loading .matting-spinner {
display: block;
}
@keyframes matting-spin {
to {
transform: translateY(-50%) rotate(360deg);
}
}
.painter-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
z-index: 111;
display: flex;
align-items: center;
justify-content: center;
}
.painter-modal-content {
width: 90vw;
height: 90vh;
background-color: #353535;
border: 1px solid #222;
border-radius: 8px;
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
position: relative;
}
.painterMainContainer {
display: flex;
flex-direction: column;
height: 100%;
flex-grow: 1;
}
.painterCanvasContainer {
flex-grow: 1;
position: relative;
}

View File

@@ -1,21 +1,17 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('db'); const log = createModuleLogger('db');
const DB_NAME = 'CanvasNodeDB'; const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState'; const STATE_STORE_NAME = 'CanvasState';
const IMAGE_STORE_NAME = 'CanvasImages'; const IMAGE_STORE_NAME = 'CanvasImages';
const DB_VERSION = 3; const DB_VERSION = 3;
let db = null;
let db;
/** /**
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
* @param {IDBObjectStore} store - Store IndexedDB * @param {IDBObjectStore} store - Store IndexedDB
* @param {string} operation - Nazwa operacji (get, put, delete, clear) * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
* @param {*} data - Dane dla operacji (opcjonalne) * @param {any} data - Dane dla operacji (opcjonalne)
* @param {string} errorMessage - Wiadomość błędu * @param {string} errorMessage - Wiadomość błędu
* @returns {Promise} Promise z wynikiem operacji * @returns {Promise<any>} Promise z wynikiem operacji
*/ */
function createDBRequest(store, operation, data, errorMessage) { function createDBRequest(store, operation, data, errorMessage) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -37,130 +33,107 @@ function createDBRequest(store, operation, data, errorMessage) {
reject(new Error(`Unknown operation: ${operation}`)); reject(new Error(`Unknown operation: ${operation}`));
return; return;
} }
request.onerror = (event) => { request.onerror = (event) => {
log.error(errorMessage, event.target.error); log.error(errorMessage, event.target.error);
reject(errorMessage); reject(errorMessage);
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
resolve(event.target.result); resolve(event.target.result);
}; };
}); });
} }
function openDB() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (db) { if (db) {
resolve(db); resolve(db);
return; return;
} }
log.info("Opening IndexedDB..."); log.info("Opening IndexedDB...");
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => { request.onerror = (event) => {
log.error("IndexedDB error:", event.target.error); log.error("IndexedDB error:", event.target.error);
reject("Error opening IndexedDB."); reject("Error opening IndexedDB.");
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
db = event.target.result; db = event.target.result;
log.info("IndexedDB opened successfully."); log.info("IndexedDB opened successfully.");
resolve(db); resolve(db);
}; };
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
log.info("Upgrading IndexedDB..."); log.info("Upgrading IndexedDB...");
const db = event.target.result; const dbInstance = event.target.result;
if (!db.objectStoreNames.contains(STATE_STORE_NAME)) { if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
db.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
log.info("Object store created:", STATE_STORE_NAME); log.info("Object store created:", STATE_STORE_NAME);
} }
if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
db.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'}); dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' });
log.info("Object store created:", IMAGE_STORE_NAME); log.info("Object store created:", IMAGE_STORE_NAME);
} }
}; };
}); });
} }
export async function getCanvasState(id) { export async function getCanvasState(id) {
log.info(`Getting state for id: ${id}`); log.info(`Getting state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
const result = await createDBRequest(store, 'get', id, "Error getting canvas state"); const result = await createDBRequest(store, 'get', id, "Error getting canvas state");
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
return result ? result.state : null; return result ? result.state : null;
} }
export async function setCanvasState(id, state) { export async function setCanvasState(id, state) {
log.info(`Setting state for id: ${id}`); log.info(`Setting state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'put', { id, state }, "Error setting canvas state");
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
log.debug(`Set success for id: ${id}`); log.debug(`Set success for id: ${id}`);
} }
export async function removeCanvasState(id) { export async function removeCanvasState(id) {
log.info(`Removing state for id: ${id}`); log.info(`Removing state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'delete', id, "Error removing canvas state"); await createDBRequest(store, 'delete', id, "Error removing canvas state");
log.debug(`Remove success for id: ${id}`); log.debug(`Remove success for id: ${id}`);
} }
export async function saveImage(imageId, imageSrc) { export async function saveImage(imageId, imageSrc) {
log.info(`Saving image with id: ${imageId}`); log.info(`Saving image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
await createDBRequest(store, 'put', { imageId, imageSrc }, "Error saving image");
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
log.debug(`Image saved successfully for id: ${imageId}`); log.debug(`Image saved successfully for id: ${imageId}`);
} }
export async function getImage(imageId) { export async function getImage(imageId) {
log.info(`Getting image with id: ${imageId}`); log.info(`Getting image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
const result = await createDBRequest(store, 'get', imageId, "Error getting image"); const result = await createDBRequest(store, 'get', imageId, "Error getting image");
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
return result ? result.imageSrc : null; return result ? result.imageSrc : null;
} }
export async function removeImage(imageId) { export async function removeImage(imageId) {
log.info(`Removing image with id: ${imageId}`); log.info(`Removing image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
await createDBRequest(store, 'delete', imageId, "Error removing image"); await createDBRequest(store, 'delete', imageId, "Error removing image");
log.debug(`Remove image success for id: ${imageId}`); log.debug(`Remove image success for id: ${imageId}`);
} }
export async function getAllImageIds() { export async function getAllImageIds() {
log.info("Getting all image IDs..."); log.info("Getting all image IDs...");
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.getAllKeys(); const request = store.getAllKeys();
request.onerror = (event) => { request.onerror = (event) => {
log.error("Error getting all image IDs:", event.target.error); log.error("Error getting all image IDs:", event.target.error);
reject("Error getting all image IDs"); reject("Error getting all image IDs");
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
const imageIds = event.target.result; const imageIds = event.target.result;
log.debug(`Found ${imageIds.length} image IDs in database`); log.debug(`Found ${imageIds.length} image IDs in database`);
@@ -168,13 +141,11 @@ export async function getAllImageIds() {
}; };
}); });
} }
export async function clearAllCanvasStates() { export async function clearAllCanvasStates() {
log.info("Clearing all canvas states..."); log.info("Clearing all canvas states...");
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'clear', null, "Error clearing canvas states"); await createDBRequest(store, 'clear', null, "Error clearing canvas states");
log.info("All canvas states cleared successfully."); log.info("All canvas states cleared successfully.");
} }

View File

@@ -8,6 +8,20 @@
* - Możliwość zapisywania logów do localStorage * - Możliwość zapisywania logów do localStorage
* - Możliwość eksportu logów * - Możliwość eksportu logów
*/ */
function padStart(str, targetLength, padString) {
targetLength = targetLength >> 0;
padString = String(padString || ' ');
if (str.length > targetLength) {
return String(str);
}
else {
targetLength = targetLength - str.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
return padString.slice(0, targetLength) + String(str);
}
}
export const LogLevel = { export const LogLevel = {
DEBUG: 0, DEBUG: 0,
INFO: 1, INFO: 1,
@@ -36,25 +50,22 @@ const LEVEL_NAMES = {
[LogLevel.WARN]: 'WARN', [LogLevel.WARN]: 'WARN',
[LogLevel.ERROR]: 'ERROR', [LogLevel.ERROR]: 'ERROR',
}; };
class Logger { class Logger {
constructor() { constructor() {
this.config = {...DEFAULT_CONFIG}; this.config = { ...DEFAULT_CONFIG };
this.logs = []; this.logs = [];
this.enabled = true; this.enabled = true;
this.loadConfig(); this.loadConfig();
} }
/** /**
* Konfiguracja loggera * Konfiguracja loggera
* @param {Object} config - Obiekt konfiguracyjny * @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
*/ */
configure(config) { configure(config) {
this.config = {...this.config, ...config}; this.config = { ...this.config, ...config };
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Włącz/wyłącz logger globalnie * Włącz/wyłącz logger globalnie
* @param {boolean} enabled - Czy logger ma być włączony * @param {boolean} enabled - Czy logger ma być włączony
@@ -63,42 +74,39 @@ class Logger {
this.enabled = enabled; this.enabled = enabled;
return this; return this;
} }
/** /**
* Ustaw globalny poziom logowania * Ustaw globalny poziom logowania
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
*/ */
setGlobalLevel(level) { setGlobalLevel(level) {
this.config.globalLevel = level; this.config.globalLevel = level;
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Ustaw poziom logowania dla konkretnego modułu * Ustaw poziom logowania dla konkretnego modułu
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
*/ */
setModuleLevel(module, level) { setModuleLevel(module, level) {
this.config.moduleSettings[module] = level; this.config.moduleSettings[module] = level;
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu * Sprawdź, czy dany poziom logowania jest aktywny dla modułu
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {LogLevel} level - Poziom logowania do sprawdzenia * @param {LogLevels} level - Poziom logowania do sprawdzenia
* @returns {boolean} - Czy poziom jest aktywny * @returns {boolean} - Czy poziom jest aktywny
*/ */
isLevelEnabled(module, level) { isLevelEnabled(module, level) {
if (!this.enabled) return false; if (!this.enabled)
return false;
if (this.config.moduleSettings[module] !== undefined) { if (this.config.moduleSettings[module] !== undefined) {
return level >= this.config.moduleSettings[module]; return level >= this.config.moduleSettings[module];
} }
return level >= this.config.globalLevel; return level >= this.config.globalLevel;
} }
/** /**
* Formatuj znacznik czasu * Formatuj znacznik czasu
* @returns {string} - Sformatowany znacznik czasu * @returns {string} - Sformatowany znacznik czasu
@@ -107,21 +115,20 @@ class Logger {
const now = new Date(); const now = new Date();
const format = this.config.timestampFormat; const format = this.config.timestampFormat;
return format return format
.replace('HH', String(now.getHours()).padStart(2, '0')) .replace('HH', padStart(String(now.getHours()), 2, '0'))
.replace('mm', String(now.getMinutes()).padStart(2, '0')) .replace('mm', padStart(String(now.getMinutes()), 2, '0'))
.replace('ss', String(now.getSeconds()).padStart(2, '0')) .replace('ss', padStart(String(now.getSeconds()), 2, '0'))
.replace('SSS', String(now.getMilliseconds()).padStart(3, '0')); .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
} }
/** /**
* Zapisz log * Zapisz log
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
* @param {Array} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
log(module, level, ...args) { log(module, level, ...args) {
if (!this.isLevelEnabled(module, level)) return; if (!this.isLevelEnabled(module, level))
return;
const timestamp = this.formatTimestamp(); const timestamp = this.formatTimestamp();
const levelName = LEVEL_NAMES[level]; const levelName = LEVEL_NAMES[level];
const logData = { const logData = {
@@ -141,13 +148,12 @@ class Logger {
} }
this.printToConsole(logData); this.printToConsole(logData);
} }
/** /**
* Wyświetl log w konsoli * Wyświetl log w konsoli
* @param {Object} logData - Dane logu * @param {LogData} logData - Dane logu
*/ */
printToConsole(logData) { printToConsole(logData) {
const {timestamp, module, level, levelName, args} = logData; const { timestamp, module, level, levelName, args } = logData;
const prefix = `[${timestamp}] [${module}] [${levelName}]`; const prefix = `[${timestamp}] [${module}] [${levelName}]`;
if (this.config.useColors && typeof console.log === 'function') { if (this.config.useColors && typeof console.log === 'function') {
const color = COLORS[level] || '#000000'; const color = COLORS[level] || '#000000';
@@ -156,36 +162,35 @@ class Logger {
} }
console.log(prefix, ...args); console.log(prefix, ...args);
} }
/** /**
* Zapisz logi do localStorage * Zapisz logi do localStorage
*/ */
saveLogs() { saveLogs() {
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
try { try {
const simplifiedLogs = this.logs.map(log => ({ const simplifiedLogs = this.logs.map((log) => ({
t: log.timestamp, t: log.timestamp,
m: log.module, m: log.module,
l: log.level, l: log.level,
a: log.args.map(arg => { a: log.args.map((arg) => {
if (typeof arg === 'object') { if (typeof arg === 'object') {
try { try {
return JSON.stringify(arg); return JSON.stringify(arg);
} catch (e) { }
catch (e) {
return String(arg); return String(arg);
} }
} }
return arg; return arg;
}) })
})); }));
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
} catch (e) { }
catch (e) {
console.error('Failed to save logs to localStorage:', e); console.error('Failed to save logs to localStorage:', e);
} }
} }
} }
/** /**
* Załaduj logi z localStorage * Załaduj logi z localStorage
*/ */
@@ -196,12 +201,12 @@ class Logger {
if (storedLogs) { if (storedLogs) {
this.logs = JSON.parse(storedLogs); this.logs = JSON.parse(storedLogs);
} }
} catch (e) { }
catch (e) {
console.error('Failed to load logs from localStorage:', e); console.error('Failed to load logs from localStorage:', e);
} }
} }
} }
/** /**
* Zapisz konfigurację do localStorage * Zapisz konfigurację do localStorage
*/ */
@@ -209,12 +214,12 @@ class Logger {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
try { try {
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
} catch (e) { }
catch (e) {
console.error('Failed to save logger config to localStorage:', e); console.error('Failed to save logger config to localStorage:', e);
} }
} }
} }
/** /**
* Załaduj konfigurację z localStorage * Załaduj konfigurację z localStorage
*/ */
@@ -223,14 +228,14 @@ class Logger {
try { try {
const storedConfig = localStorage.getItem('layerforge_logger_config'); const storedConfig = localStorage.getItem('layerforge_logger_config');
if (storedConfig) { if (storedConfig) {
this.config = {...this.config, ...JSON.parse(storedConfig)}; this.config = { ...this.config, ...JSON.parse(storedConfig) };
} }
} catch (e) { }
catch (e) {
console.error('Failed to load logger config from localStorage:', e); console.error('Failed to load logger config from localStorage:', e);
} }
} }
} }
/** /**
* Wyczyść wszystkie logi * Wyczyść wszystkie logi
*/ */
@@ -241,33 +246,29 @@ class Logger {
} }
return this; return this;
} }
/** /**
* Eksportuj logi do pliku * Eksportuj logi do pliku
* @param {string} format - Format eksportu ('json' lub 'txt') * @param {'json' | 'txt'} format - Format eksportu
*/ */
exportLogs(format = 'json') { exportLogs(format = 'json') {
if (this.logs.length === 0) { if (this.logs.length === 0) {
console.warn('No logs to export'); console.warn('No logs to export');
return; return;
} }
let content; let content;
let mimeType; let mimeType;
let extension; let extension;
if (format === 'json') { if (format === 'json') {
content = JSON.stringify(this.logs, null, 2); content = JSON.stringify(this.logs, null, 2);
mimeType = 'application/json'; mimeType = 'application/json';
extension = 'json'; extension = 'json';
} else { }
content = this.logs.map(log => else {
`[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}` content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
).join('\n');
mimeType = 'text/plain'; mimeType = 'text/plain';
extension = 'txt'; extension = 'txt';
} }
const blob = new Blob([content], {type: mimeType}); const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -277,44 +278,39 @@ class Logger {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/** /**
* Log na poziomie DEBUG * Log na poziomie DEBUG
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
debug(module, ...args) { debug(module, ...args) {
this.log(module, LogLevel.DEBUG, ...args); this.log(module, LogLevel.DEBUG, ...args);
} }
/** /**
* Log na poziomie INFO * Log na poziomie INFO
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
info(module, ...args) { info(module, ...args) {
this.log(module, LogLevel.INFO, ...args); this.log(module, LogLevel.INFO, ...args);
} }
/** /**
* Log na poziomie WARN * Log na poziomie WARN
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
warn(module, ...args) { warn(module, ...args) {
this.log(module, LogLevel.WARN, ...args); this.log(module, LogLevel.WARN, ...args);
} }
/** /**
* Log na poziomie ERROR * Log na poziomie ERROR
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
error(module, ...args) { error(module, ...args) {
this.log(module, LogLevel.ERROR, ...args); this.log(module, LogLevel.ERROR, ...args);
} }
} }
export const logger = new Logger(); export const logger = new Logger();
export const debug = (module, ...args) => logger.debug(module, ...args); export const debug = (module, ...args) => logger.debug(module, ...args);
export const info = (module, ...args) => logger.info(module, ...args); export const info = (module, ...args) => logger.info(module, ...args);
@@ -323,5 +319,4 @@ export const error = (module, ...args) => logger.error(module, ...args);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.LayerForgeLogger = logger; window.LayerForgeLogger = logger;
} }
export default logger; export default logger;

View File

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

View File

@@ -0,0 +1,13 @@
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>

View File

@@ -0,0 +1,9 @@
<h4>Mask Mode</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
</table>

View File

@@ -0,0 +1,40 @@
<h4>Canvas Control</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
<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>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table>
<h4>Clipboard & I/O</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<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>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° 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>
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
</table>
<h4>Transform Handles (on selected layer)</h4>
<table>
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
</table>

View File

@@ -0,0 +1,16 @@
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>

1
js/types.js Normal file
View File

@@ -0,0 +1 @@
export {};

View File

@@ -1,31 +1,28 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import {api} from "../../../scripts/api.js"; // @ts-ignore
import {ComfyApp} from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager'); const log = createModuleLogger('ClipboardManager');
export class ClipboardManager { export class ClipboardManager {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace' this.clipboardPreference = 'system'; // 'system', 'clipspace'
} }
/** /**
* Main paste handler that delegates to appropriate methods * Main paste handler that delegates to appropriate methods
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @param {string} preference - Clipboard preference ('system' or 'clipspace') * @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async handlePaste(addMode = 'mouse', preference = 'system') { async handlePaste(addMode = 'mouse', preference = 'system') {
try { try {
log.info(`ClipboardManager handling paste with preference: ${preference}`); log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
return true; return true;
} }
if (preference === 'clipspace') { if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace"); log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode); const success = await this.tryClipspacePaste(addMode);
@@ -34,26 +31,23 @@ export class ClipboardManager {
} }
log.info("No image found in ComfyUI Clipspace"); log.info("No image found in ComfyUI Clipspace");
} }
log.info("Attempting paste from system clipboard"); log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode); return await this.trySystemClipboardPaste(addMode);
}
} catch (err) { catch (err) {
log.error("ClipboardManager paste operation failed:", err); log.error("ClipboardManager paste operation failed:", err);
return false; return false;
} }
} }
/** /**
* Attempts to paste from ComfyUI Clipspace * Attempts to paste from ComfyUI Clipspace
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async tryClipspacePaste(addMode) { async tryClipspacePaste(addMode) {
try { try {
log.info("Attempting to paste from ComfyUI Clipspace"); log.info("Attempting to paste from ComfyUI Clipspace");
const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node); ComfyApp.pasteFromClipspace(this.canvas.node);
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) { if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0]; const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) { if (clipspaceImage && clipspaceImage.src) {
@@ -67,27 +61,24 @@ export class ClipboardManager {
} }
} }
return false; return false;
} catch (clipspaceError) { }
catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError); log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false; return false;
} }
} }
/** /**
* System clipboard paste - handles both image data and text paths * System clipboard paste - handles both image data and text paths
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async trySystemClipboardPaste(addMode) { async trySystemClipboardPaste(addMode) {
log.info("ClipboardManager: Checking system clipboard for images and paths"); log.info("ClipboardManager: Checking system clipboard for images and paths");
if (navigator.clipboard?.read) { if (navigator.clipboard?.read) {
try { try {
const clipboardItems = await navigator.clipboard.read(); const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) { for (const item of clipboardItems) {
log.debug("Clipboard item types:", item.types); log.debug("Clipboard item types:", item.types);
const imageType = item.types.find(type => type.startsWith('image/')); const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) { if (imageType) {
try { try {
@@ -99,23 +90,24 @@ export class ClipboardManager {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
}; };
img.src = event.target.result; if (event.target?.result) {
img.src = event.target.result;
}
}; };
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
log.info("Found image data in system clipboard"); log.info("Found image data in system clipboard");
return true; return true;
} catch (error) { }
catch (error) {
log.debug("Error reading image data:", error); log.debug("Error reading image data:", error);
} }
} }
const textTypes = ['text/plain', 'text/uri-list']; const textTypes = ['text/plain', 'text/uri-list'];
for (const textType of textTypes) { for (const textType of textTypes) {
if (item.types.includes(textType)) { if (item.types.includes(textType)) {
try { try {
const textBlob = await item.getType(textType); const textBlob = await item.getType(textType);
const text = await textBlob.text(); const text = await textBlob.text();
if (this.isValidImagePath(text)) { if (this.isValidImagePath(text)) {
log.info("Found image path in clipboard:", text); log.info("Found image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode); const success = await this.loadImageFromPath(text, addMode);
@@ -123,22 +115,22 @@ export class ClipboardManager {
return true; return true;
} }
} }
} catch (error) { }
catch (error) {
log.debug(`Error reading ${textType}:`, error); log.debug(`Error reading ${textType}:`, error);
} }
} }
} }
} }
} catch (error) { }
catch (error) {
log.debug("Modern clipboard API failed:", error); log.debug("Modern clipboard API failed:", error);
} }
} }
if (navigator.clipboard?.readText) { if (navigator.clipboard?.readText) {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text && this.isValidImagePath(text)) {
log.info("Found valid image path in clipboard:", text); log.info("Found valid image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode); const success = await this.loadImageFromPath(text, addMode);
@@ -146,16 +138,14 @@ export class ClipboardManager {
return true; return true;
} }
} }
} catch (error) { }
catch (error) {
log.debug("Could not read text from clipboard:", error); log.debug("Could not read text from clipboard:", error);
} }
} }
log.debug("No images or valid image paths found in system clipboard"); log.debug("No images or valid image paths found in system clipboard");
return false; return false;
} }
/** /**
* Validates if a text string is a valid image file path or URL * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -165,67 +155,53 @@ export class ClipboardManager {
if (!text || typeof text !== 'string') { if (!text || typeof text !== 'string') {
return false; return false;
} }
text = text.trim(); text = text.trim();
if (!text) { if (!text) {
return false; return false;
} }
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) { if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
try { try {
new URL(text); new URL(text);
log.debug("Detected valid URL:", text); log.debug("Detected valid URL:", text);
return true; return true;
} catch (e) { }
catch (e) {
log.debug("Invalid URL format:", text); log.debug("Invalid URL format:", text);
return false; return false;
} }
} }
const imageExtensions = [ const imageExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
'.svg', '.tiff', '.tif', '.ico', '.avif' '.svg', '.tiff', '.tif', '.ico', '.avif'
]; ];
const hasImageExtension = imageExtensions.some(ext => text.toLowerCase().endsWith(ext));
const hasImageExtension = imageExtensions.some(ext =>
text.toLowerCase().endsWith(ext)
);
if (!hasImageExtension) { if (!hasImageExtension) {
log.debug("No valid image extension found in:", text); log.debug("No valid image extension found in:", text);
return false; return false;
} }
const pathPatterns = [ const pathPatterns = [
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...) /^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
/^[\\\/]/, // Unix absolute path (/...) /^[\\\/]/, // Unix absolute path (/...)
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...) /^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
/^[^\\\/]*[\\\/]/ // Contains path separators /^[^\\\/]*[\\\/]/ // Contains path separators
]; ];
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) || const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename (!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
if (isValidPath) { if (isValidPath) {
log.debug("Detected valid local file path:", text); log.debug("Detected valid local file path:", text);
} else { }
else {
log.debug("Invalid local file path format:", text); log.debug("Invalid local file path format:", text);
} }
return isValidPath; return isValidPath;
} }
/** /**
* Attempts to load an image from a file path using simplified methods * Attempts to load an image from a file path using simplified methods
* @param {string} filePath - The file path to load * @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async loadImageFromPath(filePath, addMode) { async loadImageFromPath(filePath, addMode) {
if (filePath.startsWith('http://') || filePath.startsWith('https://')) { if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try { try {
const img = new Image(); const img = new Image();
@@ -242,46 +218,44 @@ export class ClipboardManager {
}; };
img.src = filePath; img.src = filePath;
}); });
} catch (error) { }
catch (error) {
log.warn("Error loading image from URL:", error); log.warn("Error loading image from URL:", error);
return false; return false;
} }
} }
try { try {
log.info("Attempting to load local file via backend"); log.info("Attempting to load local file via backend");
const success = await this.loadFileViaBackend(filePath, addMode); const success = await this.loadFileViaBackend(filePath, addMode);
if (success) { if (success) {
return true; return true;
} }
} catch (error) { }
catch (error) {
log.warn("Backend loading failed:", error); log.warn("Backend loading failed:", error);
} }
try { try {
log.info("Falling back to file picker"); log.info("Falling back to file picker");
const success = await this.promptUserForFile(filePath, addMode); const success = await this.promptUserForFile(filePath, addMode);
if (success) { if (success) {
return true; return true;
} }
} catch (error) { }
catch (error) {
log.warn("File picker failed:", error); log.warn("File picker failed:", error);
} }
this.showFilePathMessage(filePath); this.showFilePathMessage(filePath);
return false; return false;
} }
/** /**
* Loads a local file via the ComfyUI backend endpoint * Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load * @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async loadFileViaBackend(filePath, addMode) { async loadFileViaBackend(filePath, addMode) {
try { try {
log.info("Loading file via ComfyUI backend:", filePath); log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", { const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST", method: "POST",
headers: { headers: {
@@ -291,22 +265,17 @@ export class ClipboardManager {
file_path: filePath file_path: filePath
}) })
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error); log.debug("Backend failed to load image:", errorData.error);
return false; return false;
} }
const data = await response.json(); const data = await response.json();
if (!data.success) { if (!data.success) {
log.debug("Backend returned error:", data.error); log.debug("Backend returned error:", data.error);
return false; return false;
} }
log.info("Successfully loaded image via ComfyUI backend:", filePath); log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image(); const img = new Image();
const success = await new Promise((resolve) => { const success = await new Promise((resolve) => {
img.onload = async () => { img.onload = async () => {
@@ -318,36 +287,31 @@ export class ClipboardManager {
log.warn("Failed to load image from backend response"); log.warn("Failed to load image from backend response");
resolve(false); resolve(false);
}; };
img.src = data.image_data; img.src = data.image_data;
}); });
return success; return success;
}
} catch (error) { catch (error) {
log.debug("Error loading file via ComfyUI backend:", error); log.debug("Error loading file via ComfyUI backend:", error);
return false; return false;
} }
} }
/** /**
* Prompts the user to select a file when a local path is detected * Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard * @param {string} originalPath - The original file path from clipboard
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async promptUserForFile(originalPath, addMode) { async promptUserForFile(originalPath, addMode) {
return new Promise((resolve) => { return new Promise((resolve) => {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.accept = 'image/*'; fileInput.accept = 'image/*';
fileInput.style.display = 'none'; fileInput.style.display = 'none';
const fileName = originalPath.split(/[\\\/]/).pop(); const fileName = originalPath.split(/[\\\/]/).pop();
fileInput.onchange = async (event) => { fileInput.onchange = async (event) => {
const file = event.target.files[0]; const target = event.target;
const file = target.files?.[0];
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
try { try {
const reader = new FileReader(); const reader = new FileReader();
@@ -362,38 +326,37 @@ export class ClipboardManager {
log.warn("Failed to load selected image"); log.warn("Failed to load selected image");
resolve(false); resolve(false);
}; };
img.src = e.target.result; if (e.target?.result) {
img.src = e.target.result;
}
}; };
reader.onerror = () => { reader.onerror = () => {
log.warn("Failed to read selected file"); log.warn("Failed to read selected file");
resolve(false); resolve(false);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} catch (error) { }
catch (error) {
log.warn("Error processing selected file:", error); log.warn("Error processing selected file:", error);
resolve(false); resolve(false);
} }
} else { }
else {
log.warn("Selected file is not an image"); log.warn("Selected file is not an image");
resolve(false); resolve(false);
} }
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
}; };
fileInput.oncancel = () => { fileInput.oncancel = () => {
log.info("File selection cancelled by user"); log.info("File selection cancelled by user");
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
resolve(false); resolve(false);
}; };
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
}); });
} }
/** /**
* Shows a message to the user about file path limitations * Shows a message to the user about file path limitations
* @param {string} filePath - The file path that couldn't be loaded * @param {string} filePath - The file path that couldn't be loaded
@@ -404,14 +367,12 @@ export class ClipboardManager {
this.showNotification(message, 5000); this.showNotification(message, 5000);
log.info("Showed file path limitation message to user"); log.info("Showed file path limitation message to user");
} }
/** /**
* Shows a helpful message when clipboard appears empty and offers file picker * Shows a helpful message when clipboard appears empty and offers file picker
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
*/ */
showEmptyClipboardMessage(addMode) { showEmptyClipboardMessage(addMode) {
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`; const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `
position: fixed; position: fixed;
@@ -440,7 +401,6 @@ export class ClipboardManager {
💡 Tip: You can also drag & drop files directly onto the canvas 💡 Tip: You can also drag & drop files directly onto the canvas
</div> </div>
`; `;
notification.onmouseenter = () => { notification.onmouseenter = () => {
notification.style.backgroundColor = '#3d6bb0'; notification.style.backgroundColor = '#3d6bb0';
notification.style.borderColor = '#5a8bd8'; notification.style.borderColor = '#5a8bd8';
@@ -451,7 +411,6 @@ export class ClipboardManager {
notification.style.borderColor = '#4a7bc8'; notification.style.borderColor = '#4a7bc8';
notification.style.transform = 'translateY(0)'; notification.style.transform = 'translateY(0)';
}; };
notification.onclick = async () => { notification.onclick = async () => {
document.body.removeChild(notification); document.body.removeChild(notification);
try { try {
@@ -459,29 +418,25 @@ export class ClipboardManager {
if (success) { if (success) {
log.info("Successfully loaded image via empty clipboard file picker"); log.info("Successfully loaded image via empty clipboard file picker");
} }
} catch (error) { }
catch (error) {
log.warn("Error with empty clipboard file picker:", error); log.warn("Error with empty clipboard file picker:", error);
} }
}; };
document.body.appendChild(notification); document.body.appendChild(notification);
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (notification.parentNode) {
notification.parentNode.removeChild(notification); notification.parentNode.removeChild(notification);
} }
}, 12000); }, 12000);
log.info("Showed enhanced empty clipboard message with file picker option"); log.info("Showed enhanced empty clipboard message with file picker option");
} }
/** /**
* Shows a temporary notification to the user * Shows a temporary notification to the user
* @param {string} message - The message to show * @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds * @param {number} duration - Duration in milliseconds
*/ */
showNotification(message, duration = 3000) { showNotification(message, duration = 3000) {
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `
position: fixed; position: fixed;
@@ -498,9 +453,7 @@ export class ClipboardManager {
line-height: 1.4; line-height: 1.4;
`; `;
notification.textContent = message; notification.textContent = message;
document.body.appendChild(notification); document.body.appendChild(notification);
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (notification.parentNode) {
notification.parentNode.removeChild(notification); notification.parentNode.removeChild(notification);

View File

@@ -1,8 +1,3 @@
/**
* CommonUtils - Wspólne funkcje pomocnicze
* Eliminuje duplikację funkcji używanych w różnych modułach
*/
/** /**
* Generuje unikalny identyfikator UUID * Generuje unikalny identyfikator UUID
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
@@ -13,7 +8,6 @@ export function generateUUID() {
return v.toString(16); return v.toString(16);
}); });
} }
/** /**
* Funkcja snap do siatki * Funkcja snap do siatki
* @param {number} value - Wartość do przyciągnięcia * @param {number} value - Wartość do przyciągnięcia
@@ -23,58 +17,48 @@ export function generateUUID() {
export function snapToGrid(value, gridSize = 64) { export function snapToGrid(value, gridSize = 64) {
return Math.round(value / gridSize) * gridSize; return Math.round(value / gridSize) * gridSize;
} }
/** /**
* Oblicza dostosowanie snap dla warstwy * Oblicza dostosowanie snap dla warstwy
* @param {Object} layer - Obiekt warstwy * @param {Object} layer - Obiekt warstwy
* @param {number} gridSize - Rozmiar siatki * @param {number} gridSize - Rozmiar siatki
* @param {number} snapThreshold - Próg przyciągania * @param {number} snapThreshold - Próg przyciągania
* @returns {Object} Obiekt z dx i dy * @returns {Point} Obiekt z dx i dy
*/ */
export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) { export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
if (!layer) { if (!layer) {
return {dx: 0, dy: 0}; return { x: 0, y: 0 };
} }
const layerEdges = { const layerEdges = {
left: layer.x, left: layer.x,
right: layer.x + layer.width, right: layer.x + layer.width,
top: layer.y, top: layer.y,
bottom: layer.y + layer.height bottom: layer.y + layer.height
}; };
const x_adjustments = [ const x_adjustments = [
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left}, { type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left },
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right} { type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right }
]; ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
const y_adjustments = [ const y_adjustments = [
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top}, { type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top },
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom} { type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom }
]; ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
const bestXSnap = x_adjustments const bestXSnap = x_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
const bestYSnap = y_adjustments const bestYSnap = y_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
return { return {
dx: bestXSnap ? bestXSnap.delta : 0, x: bestXSnap ? bestXSnap.delta : 0,
dy: bestYSnap ? bestYSnap.delta : 0 y: bestYSnap ? bestYSnap.delta : 0
}; };
} }
/** /**
* Konwertuje współrzędne świata na lokalne * Konwertuje współrzędne świata na lokalne
* @param {number} worldX - Współrzędna X w świecie * @param {number} worldX - Współrzędna X w świecie
* @param {number} worldY - Współrzędna Y w świecie * @param {number} worldY - Współrzędna Y w świecie
* @param {Object} layerProps - Właściwości warstwy * @param {any} layerProps - Właściwości warstwy
* @returns {Object} Lokalne współrzędne {x, y} * @returns {Point} Lokalne współrzędne {x, y}
*/ */
export function worldToLocal(worldX, worldY, layerProps) { export function worldToLocal(worldX, worldY, layerProps) {
const dx = worldX - layerProps.centerX; const dx = worldX - layerProps.centerX;
@@ -82,46 +66,38 @@ export function worldToLocal(worldX, worldY, layerProps) {
const rad = -layerProps.rotation * Math.PI / 180; const rad = -layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
return { return {
x: dx * cos - dy * sin, x: dx * cos - dy * sin,
y: dx * sin + dy * cos y: dx * sin + dy * cos
}; };
} }
/** /**
* Konwertuje współrzędne lokalne na świat * Konwertuje współrzędne lokalne na świat
* @param {number} localX - Lokalna współrzędna X * @param {number} localX - Lokalna współrzędna X
* @param {number} localY - Lokalna współrzędna Y * @param {number} localY - Lokalna współrzędna Y
* @param {Object} layerProps - Właściwości warstwy * @param {any} layerProps - Właściwości warstwy
* @returns {Object} Współrzędne świata {x, y} * @returns {Point} Współrzędne świata {x, y}
*/ */
export function localToWorld(localX, localY, layerProps) { export function localToWorld(localX, localY, layerProps) {
const rad = layerProps.rotation * Math.PI / 180; const rad = layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
return { return {
x: layerProps.centerX + localX * cos - localY * sin, x: layerProps.centerX + localX * cos - localY * sin,
y: layerProps.centerY + localX * sin + localY * cos y: layerProps.centerY + localX * sin + localY * cos
}; };
} }
/** /**
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci) * Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
* @param {Array} layers - Tablica warstw do sklonowania * @param {Layer[]} layers - Tablica warstw do sklonowania
* @returns {Array} Sklonowane warstwy * @returns {Layer[]} Sklonowane warstwy
*/ */
export function cloneLayers(layers) { export function cloneLayers(layers) {
return layers.map(layer => { return layers.map(layer => ({ ...layer }));
const newLayer = {...layer};
return newLayer;
});
} }
/** /**
* Tworzy sygnaturę stanu warstw (dla porównań) * Tworzy sygnaturę stanu warstw (dla porównań)
* @param {Array} layers - Tablica warstw * @param {Layer[]} layers - Tablica warstw
* @returns {string} Sygnatura JSON * @returns {string} Sygnatura JSON
*/ */
export function getStateSignature(layers) { export function getStateSignature(layers) {
@@ -135,47 +111,47 @@ export function getStateSignature(layers) {
rotation: Math.round((layer.rotation || 0) * 100) / 100, rotation: Math.round((layer.rotation || 0) * 100) / 100,
zIndex: layer.zIndex, zIndex: layer.zIndex,
blendMode: layer.blendMode || 'normal', blendMode: layer.blendMode || 'normal',
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1 opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
flipH: !!layer.flipH,
flipV: !!layer.flipV
}; };
if (layer.imageId) { if (layer.imageId) {
sig.imageId = layer.imageId; sig.imageId = layer.imageId;
} }
if (layer.image && layer.image.src) { if (layer.image && layer.image.src) {
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
} }
return sig; return sig;
})); }));
} }
/** /**
* Debounce funkcja - opóźnia wykonanie funkcji * Debounce funkcja - opóźnia wykonanie funkcji
* @param {Function} func - Funkcja do wykonania * @param {Function} func - Funkcja do wykonania
* @param {number} wait - Czas oczekiwania w ms * @param {number} wait - Czas oczekiwania w ms
* @param {boolean} immediate - Czy wykonać natychmiast * @param {boolean} immediate - Czy wykonać natychmiast
* @returns {Function} Funkcja z debounce * @returns {(...args: any[]) => void} Funkcja z debounce
*/ */
export function debounce(func, wait, immediate) { export function debounce(func, wait, immediate) {
let timeout; let timeout;
return function executedFunction(...args) { return function executedFunction(...args) {
const later = () => { const later = () => {
timeout = null; timeout = null;
if (!immediate) func(...args); if (!immediate)
func.apply(this, args);
}; };
const callNow = immediate && !timeout; const callNow = immediate && !timeout;
clearTimeout(timeout); if (timeout)
timeout = setTimeout(later, wait); clearTimeout(timeout);
if (callNow) func(...args); timeout = window.setTimeout(later, wait);
if (callNow)
func.apply(this, args);
}; };
} }
/** /**
* Throttle funkcja - ogranicza częstotliwość wykonania * Throttle funkcja - ogranicza częstotliwość wykonania
* @param {Function} func - Funkcja do wykonania * @param {Function} func - Funkcja do wykonania
* @param {number} limit - Limit czasu w ms * @param {number} limit - Limit czasu w ms
* @returns {Function} Funkcja z throttle * @returns {(...args: any[]) => void} Funkcja z throttle
*/ */
export function throttle(func, limit) { export function throttle(func, limit) {
let inThrottle; let inThrottle;
@@ -187,7 +163,6 @@ export function throttle(func, limit) {
} }
}; };
} }
/** /**
* Ogranicza wartość do zakresu * Ogranicza wartość do zakresu
* @param {number} value - Wartość do ograniczenia * @param {number} value - Wartość do ograniczenia
@@ -198,7 +173,6 @@ export function throttle(func, limit) {
export function clamp(value, min, max) { export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
/** /**
* Interpolacja liniowa między dwoma wartościami * Interpolacja liniowa między dwoma wartościami
* @param {number} start - Wartość początkowa * @param {number} start - Wartość początkowa
@@ -209,7 +183,6 @@ export function clamp(value, min, max) {
export function lerp(start, end, factor) { export function lerp(start, end, factor) {
return start + (end - start) * factor; return start + (end - start) * factor;
} }
/** /**
* Konwertuje stopnie na radiany * Konwertuje stopnie na radiany
* @param {number} degrees - Stopnie * @param {number} degrees - Stopnie
@@ -218,7 +191,6 @@ export function lerp(start, end, factor) {
export function degreesToRadians(degrees) { export function degreesToRadians(degrees) {
return degrees * Math.PI / 180; return degrees * Math.PI / 180;
} }
/** /**
* Konwertuje radiany na stopnie * Konwertuje radiany na stopnie
* @param {number} radians - Radiany * @param {number} radians - Radiany
@@ -227,23 +199,23 @@ export function degreesToRadians(degrees) {
export function radiansToDegrees(radians) { export function radiansToDegrees(radians) {
return radians * 180 / Math.PI; return radians * 180 / Math.PI;
} }
/** /**
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie * Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
* @param {number} width - Szerokość canvas * @param {number} width - Szerokość canvas
* @param {number} height - Wysokość canvas * @param {number} height - Wysokość canvas
* @param {string} contextType - Typ kontekstu (domyślnie '2d') * @param {string} contextType - Typ kontekstu (domyślnie '2d')
* @param {Object} contextOptions - Opcje kontekstu * @param {object} contextOptions - Opcje kontekstu
* @returns {Object} Obiekt z canvas i ctx * @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
*/ */
export function createCanvas(width, height, contextType = '2d', contextOptions = {}) { export function createCanvas(width, height, contextType = '2d', contextOptions = {}) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
if (width) canvas.width = width; if (width)
if (height) canvas.height = height; canvas.width = width;
if (height)
canvas.height = height;
const ctx = canvas.getContext(contextType, contextOptions); const ctx = canvas.getContext(contextType, contextOptions);
return {canvas, ctx}; return { canvas, ctx };
} }
/** /**
* Normalizuje wartość do zakresu Uint8 (0-255) * Normalizuje wartość do zakresu Uint8 (0-255)
* @param {number} value - Wartość do znormalizowania (0-1) * @param {number} value - Wartość do znormalizowania (0-1)
@@ -252,11 +224,10 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
export function normalizeToUint8(value) { export function normalizeToUint8(value) {
return Math.max(0, Math.min(255, Math.round(value * 255))); return Math.max(0, Math.min(255, Math.round(value * 255)));
} }
/** /**
* Generuje unikalną nazwę pliku z identyfikatorem node-a * Generuje unikalną nazwę pliku z identyfikatorem node-a
* @param {string} baseName - Podstawowa nazwa pliku * @param {string} baseName - Podstawowa nazwa pliku
* @param {string|number} nodeId - Identyfikator node-a * @param {string | number} nodeId - Identyfikator node-a
* @returns {string} Unikalna nazwa pliku * @returns {string} Unikalna nazwa pliku
*/ */
export function generateUniqueFileName(baseName, nodeId) { export function generateUniqueFileName(baseName, nodeId) {
@@ -271,7 +242,6 @@ export function generateUniqueFileName(baseName, nodeId) {
const nameWithoutExt = baseName.replace(`.${extension}`, ''); const nameWithoutExt = baseName.replace(`.${extension}`, '');
return `${nameWithoutExt}_node_${nodeId}.${extension}`; return `${nameWithoutExt}_node_${nodeId}.${extension}`;
} }
/** /**
* Sprawdza czy punkt jest w prostokącie * Sprawdza czy punkt jest w prostokącie
* @param {number} pointX - X punktu * @param {number} pointX - X punktu

View File

@@ -1,8 +1,6 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
export function validateImageData(data) { export function validateImageData(data) {
log.debug("Validating data structure:", { log.debug("Validating data structure:", {
hasData: !!data, hasData: !!data,
@@ -13,306 +11,222 @@ export function validateImageData(data) {
dataType: data?.data ? data.data.constructor.name : null, dataType: data?.data ? data.data.constructor.name : null,
fullData: data fullData: data
}); });
if (!data) { if (!data) {
log.info("Data is null or undefined"); log.info("Data is null or undefined");
return false; return false;
} }
if (Array.isArray(data)) { if (Array.isArray(data)) {
log.debug("Data is array, getting first element"); log.debug("Data is array, getting first element");
data = data[0]; data = data[0];
} }
if (!data || typeof data !== 'object') { if (!data || typeof data !== 'object') {
log.info("Invalid data type"); log.info("Invalid data type");
return false; return false;
} }
if (!data.data) { if (!data.data) {
log.info("Missing data property"); log.info("Missing data property");
return false; return false;
} }
if (!(data.data instanceof Float32Array)) { if (!(data.data instanceof Float32Array)) {
try { try {
data.data = new Float32Array(data.data); data.data = new Float32Array(data.data);
} catch (e) { }
catch (e) {
log.error("Failed to convert data to Float32Array:", e); log.error("Failed to convert data to Float32Array:", e);
return false; return false;
} }
} }
return true; return true;
} }
export function convertImageData(data) { export function convertImageData(data) {
log.info("Converting image data:", data); log.info("Converting image data:", data);
if (Array.isArray(data)) { if (Array.isArray(data)) {
data = data[0]; data = data[0];
} }
const shape = data.shape; const shape = data.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
const floatData = new Float32Array(data.data); const floatData = new Float32Array(data.data);
log.debug("Processing dimensions:", { height, width, channels });
log.debug("Processing dimensions:", {height, width, channels});
const rgbaData = new Uint8ClampedArray(width * height * 4); const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels; const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c]; const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
} }
rgbaData[pixelIndex + 3] = 255; rgbaData[pixelIndex + 3] = 255;
} }
} }
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
} }
export function applyMaskToImageData(imageData, maskData) { export function applyMaskToImageData(imageData, maskData) {
log.info("Applying mask to image data"); log.info("Applying mask to image data");
const rgbaData = new Uint8ClampedArray(imageData.data); const rgbaData = new Uint8ClampedArray(imageData.data);
const width = imageData.width; const width = imageData.width;
const height = imageData.height; const height = imageData.height;
const maskShape = maskData.shape; const maskShape = maskData.shape;
const maskFloatData = new Float32Array(maskData.data); const maskFloatData = new Float32Array(maskData.data);
log.debug(`Applying mask of shape: ${maskShape}`); log.debug(`Applying mask of shape: ${maskShape}`);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const maskIndex = h * width + w; const maskIndex = h * width + w;
const alpha = maskFloatData[maskIndex]; const alpha = maskFloatData[maskIndex];
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
} }
} }
log.info("Mask application completed"); log.info("Mask application completed");
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
} }
export const prepareImageForCanvas = withErrorHandling(function (inputImage) { export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
log.info("Preparing image for canvas:", inputImage); log.info("Preparing image for canvas:", inputImage);
if (Array.isArray(inputImage)) { if (Array.isArray(inputImage)) {
inputImage = inputImage[0]; inputImage = inputImage[0];
} }
if (!inputImage || !inputImage.shape || !inputImage.data) { if (!inputImage || !inputImage.shape || !inputImage.data) {
throw createValidationError("Invalid input image format", {inputImage}); throw createValidationError("Invalid input image format", { inputImage });
} }
const shape = inputImage.shape; const shape = inputImage.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
const floatData = new Float32Array(inputImage.data); const floatData = new Float32Array(inputImage.data);
log.debug("Image dimensions:", { height, width, channels });
log.debug("Image dimensions:", {height, width, channels});
const rgbaData = new Uint8ClampedArray(width * height * 4); const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels; const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c]; const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
} }
rgbaData[pixelIndex + 3] = 255; rgbaData[pixelIndex + 3] = 255;
} }
} }
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
}, 'prepareImageForCanvas'); }, 'prepareImageForCanvas');
/**
* Konwertuje obraz PIL/Canvas na tensor
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
* @returns {Promise<Object>} Tensor z danymi obrazu
*/
export const imageToTensor = withErrorHandling(async function (image) { export const imageToTensor = withErrorHandling(async function (image) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width;
canvas.width = image.width || image.naturalWidth; canvas.height = image.height;
canvas.height = image.height || image.naturalHeight; if (ctx) {
ctx.drawImage(image, 0, 0);
ctx.drawImage(image, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = new Float32Array(canvas.width * canvas.height * 3);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); for (let i = 0; i < imageData.data.length; i += 4) {
const data = new Float32Array(canvas.width * canvas.height * 3); const pixelIndex = i / 4;
data[pixelIndex * 3] = imageData.data[i] / 255;
for (let i = 0; i < imageData.data.length; i += 4) { data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
const pixelIndex = i / 4; data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
data[pixelIndex * 3] = imageData.data[i] / 255; }
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; return {
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; data: data,
shape: [1, canvas.height, canvas.width, 3],
width: canvas.width,
height: canvas.height
};
} }
throw new Error("Canvas context not available");
return {
data: data,
shape: [1, canvas.height, canvas.width, 3],
width: canvas.width,
height: canvas.height
};
}, 'imageToTensor'); }, 'imageToTensor');
/**
* Konwertuje tensor na obraz HTML
* @param {Object} tensor - Tensor z danymi obrazu
* @returns {Promise<HTMLImageElement>} Obraz HTML
*/
export const tensorToImage = withErrorHandling(async function (tensor) { export const tensorToImage = withErrorHandling(async function (tensor) {
if (!tensor || !tensor.data || !tensor.shape) { if (!tensor || !tensor.data || !tensor.shape) {
throw createValidationError("Invalid tensor format", {tensor}); throw createValidationError("Invalid tensor format", { tensor });
} }
const [, height, width, channels] = tensor.shape; const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
if (ctx) {
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
const data = tensor.data; const data = tensor.data;
for (let i = 0; i < width * height; i++) {
for (let i = 0; i < width * height; i++) { const pixelIndex = i * 4;
const pixelIndex = i * 4; const tensorIndex = i * channels;
const tensorIndex = i * channels; imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255); imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); imageData.data[pixelIndex + 3] = 255;
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255); }
imageData.data[pixelIndex + 3] = 255; ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
} }
throw new Error("Canvas context not available");
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}, 'tensorToImage'); }, 'tensorToImage');
/**
* Zmienia rozmiar obrazu z zachowaniem proporcji
* @param {HTMLImageElement} image - Obraz do przeskalowania
* @param {number} maxWidth - Maksymalna szerokość
* @param {number} maxHeight - Maksymalna wysokość
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz
*/
export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) { export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width;
const originalWidth = image.width || image.naturalWidth; const originalHeight = image.height;
const originalHeight = image.height || image.naturalHeight;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale); const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale); const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth; canvas.width = newWidth;
canvas.height = newHeight; canvas.height = newHeight;
ctx.imageSmoothingEnabled = true; if (ctx) {
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newWidth, newHeight); ctx.drawImage(image, 0, 0, newWidth, newHeight);
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => { const img = new Image();
const img = new Image(); img.onload = () => resolve(img);
img.onload = () => resolve(img); img.onerror = (err) => reject(err);
img.onerror = reject; img.src = canvas.toDataURL();
img.src = canvas.toDataURL(); });
}); }
throw new Error("Canvas context not available");
}, 'resizeImage'); }, 'resizeImage');
/**
* Tworzy miniaturę obrazu
* @param {HTMLImageElement} image - Obraz źródłowy
* @param {number} size - Rozmiar miniatury (kwadrat)
* @returns {Promise<HTMLImageElement>} Miniatura
*/
export const createThumbnail = withErrorHandling(async function (image, size = 128) { export const createThumbnail = withErrorHandling(async function (image, size = 128) {
return resizeImage(image, size, size); return resizeImage(image, size, size);
}, 'createThumbnail'); }, 'createThumbnail');
/**
* Konwertuje obraz na base64
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
* @param {string} format - Format obrazu (png, jpeg, webp)
* @param {number} quality - Jakość (0-1) dla formatów stratnych
* @returns {string} Base64 string
*/
export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) { export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
canvas.width = image.width || image.naturalWidth; canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
canvas.height = image.height || image.naturalHeight; if (ctx) {
ctx.drawImage(image, 0, 0);
ctx.drawImage(image, 0, 0); const mimeType = `image/${format}`;
return canvas.toDataURL(mimeType, quality);
const mimeType = `image/${format}`; }
return canvas.toDataURL(mimeType, quality); throw new Error("Canvas context not available");
}, 'imageToBase64'); }, 'imageToBase64');
/**
* Konwertuje base64 na obraz
* @param {string} base64 - Base64 string
* @returns {Promise<HTMLImageElement>} Obraz
*/
export const base64ToImage = withErrorHandling(function (base64) { export const base64ToImage = withErrorHandling(function (base64) {
if (!base64) { if (!base64) {
throw createValidationError("Base64 string is required"); throw createValidationError("Base64 string is required");
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
@@ -320,74 +234,49 @@ export const base64ToImage = withErrorHandling(function (base64) {
img.src = base64; img.src = base64;
}); });
}, 'base64ToImage'); }, 'base64ToImage');
/**
* Sprawdza czy obraz jest prawidłowy
* @param {HTMLImageElement} image - Obraz do sprawdzenia
* @returns {boolean} Czy obraz jest prawidłowy
*/
export function isValidImage(image) { export function isValidImage(image) {
return image && return image &&
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
image.width > 0 && image.width > 0 &&
image.height > 0; image.height > 0;
} }
/**
* Pobiera informacje o obrazie
* @param {HTMLImageElement} image - Obraz
* @returns {Object} Informacje o obrazie
*/
export function getImageInfo(image) { export function getImageInfo(image) {
if (!isValidImage(image)) { if (!isValidImage(image)) {
return null; return null;
} }
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
return { return {
width: image.width || image.naturalWidth, width,
height: image.height || image.naturalHeight, height,
aspectRatio: (image.width || image.naturalWidth) / (image.height || image.naturalHeight), aspectRatio: width / height,
area: (image.width || image.naturalWidth) * (image.height || image.naturalHeight) area: width * height
}; };
} }
/**
* Tworzy obraz z podanego źródła - eliminuje duplikaty w kodzie
* @param {string} source - Źródło obrazu (URL, data URL, etc.)
* @returns {Promise<HTMLImageElement>} Promise z obrazem
*/
export function createImageFromSource(source) { export function createImageFromSource(source) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = source; img.src = source;
}); });
} }
/**
* Tworzy pusty obraz o podanych wymiarach
* @param {number} width - Szerokość
* @param {number} height - Wysokość
* @param {string} color - Kolor tła (CSS color)
* @returns {Promise<HTMLImageElement>} Pusty obraz
*/
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') { export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
if (ctx) {
if (color !== 'transparent') { if (color !== 'transparent') {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
} }
throw new Error("Canvas context not available");
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}, 'createEmptyImage'); }, 'createEmptyImage');

View File

@@ -2,19 +2,15 @@
* LoggerUtils - Centralizacja inicjalizacji loggerów * LoggerUtils - Centralizacja inicjalizacji loggerów
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
*/ */
import { logger, LogLevel } from "../logger.js";
import {logger, LogLevel} from "../logger.js";
import { LOG_LEVEL } from '../config.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
* @param {string} moduleName - Nazwa modułu * @param {string} moduleName - Nazwa modułu
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) * @returns {Logger} Obiekt z metodami logowania
* @returns {Object} Obiekt z metodami logowania
*/ */
export function createModuleLogger(moduleName) { export function createModuleLogger(moduleName) {
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]); logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
return { return {
debug: (...args) => logger.debug(moduleName, ...args), debug: (...args) => logger.debug(moduleName, ...args),
info: (...args) => logger.info(moduleName, ...args), info: (...args) => logger.info(moduleName, ...args),
@@ -22,24 +18,20 @@ export function createModuleLogger(moduleName) {
error: (...args) => logger.error(moduleName, ...args) error: (...args) => logger.error(moduleName, ...args)
}; };
} }
/** /**
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
* @param {LogLevel} level - Poziom logowania * @returns {Logger} Obiekt z metodami logowania
* @returns {Object} Obiekt z metodami logowania
*/ */
export function createAutoLogger(level = LogLevel.DEBUG) { export function createAutoLogger() {
const stack = new Error().stack; const stack = new Error().stack;
const match = stack.match(/\/([^\/]+)\.js/); const match = stack?.match(/\/([^\/]+)\.js/);
const moduleName = match ? match[1] : 'Unknown'; const moduleName = match ? match[1] : 'Unknown';
return createModuleLogger(moduleName);
return createModuleLogger(moduleName, level);
} }
/** /**
* Wrapper dla operacji z automatycznym logowaniem błędów * Wrapper dla operacji z automatycznym logowaniem błędów
* @param {Function} operation - Operacja do wykonania * @param {Function} operation - Operacja do wykonania
* @param {Object} log - Obiekt loggera * @param {Logger} log - Obiekt loggera
* @param {string} operationName - Nazwa operacji (dla logów) * @param {string} operationName - Nazwa operacji (dla logów)
* @returns {Function} Opakowana funkcja * @returns {Function} Opakowana funkcja
*/ */
@@ -50,34 +42,33 @@ export function withErrorLogging(operation, log, operationName) {
const result = await operation.apply(this, args); const result = await operation.apply(this, args);
log.debug(`Completed ${operationName}`); log.debug(`Completed ${operationName}`);
return result; return result;
} catch (error) { }
catch (error) {
log.error(`Error in ${operationName}:`, error); log.error(`Error in ${operationName}:`, error);
throw error; throw error;
} }
}; };
} }
/** /**
* Decorator dla metod klasy z automatycznym logowaniem * Decorator dla metod klasy z automatycznym logowaniem
* @param {Object} log - Obiekt loggera * @param {Logger} log - Obiekt loggera
* @param {string} methodName - Nazwa metody * @param {string} methodName - Nazwa metody
*/ */
export function logMethod(log, methodName) { export function logMethod(log, methodName) {
return function (target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function (...args) { descriptor.value = async function (...args) {
try { try {
log.debug(`${methodName || propertyKey} started`); log.debug(`${methodName || propertyKey} started`);
const result = await originalMethod.apply(this, args); const result = await originalMethod.apply(this, args);
log.debug(`${methodName || propertyKey} completed`); log.debug(`${methodName || propertyKey} completed`);
return result; return result;
} catch (error) { }
catch (error) {
log.error(`${methodName || propertyKey} failed:`, error); log.error(`${methodName || propertyKey} failed:`, error);
throw error; throw error;
} }
}; };
return descriptor; return descriptor;
}; };
} }

View File

@@ -0,0 +1,30 @@
// @ts-ignore
import { $el } from "../../../scripts/ui.js";
export function addStylesheet(url) {
if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css";
}
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url.startsWith("http") ? url : getUrl(url),
});
}
export function getUrl(path, baseUrl) {
if (baseUrl) {
return new URL(path, baseUrl).toString();
}
else {
// @ts-ignore
return new URL("../" + path, import.meta.url).toString();
}
}
export async function loadTemplate(path, baseUrl) {
const url = getUrl(path, baseUrl);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load template: ${url}`);
}
return await response.text();
}

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('WebSocketManager'); const log = createModuleLogger('WebSocketManager');
class WebSocketManager { class WebSocketManager {
constructor(url) { constructor(url) {
this.url = url; this.url = url;
@@ -11,41 +9,33 @@ class WebSocketManager {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10; this.maxReconnectAttempts = 10;
this.reconnectInterval = 5000; // 5 seconds this.reconnectInterval = 5000; // 5 seconds
this.ackCallbacks = new Map(); // Store callbacks for messages awaiting ACK this.ackCallbacks = new Map();
this.messageIdCounter = 0; this.messageIdCounter = 0;
this.connect(); this.connect();
} }
connect() { connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
log.debug("WebSocket is already open."); log.debug("WebSocket is already open.");
return; return;
} }
if (this.isConnecting) { if (this.isConnecting) {
log.debug("Connection attempt already in progress."); log.debug("Connection attempt already in progress.");
return; return;
} }
this.isConnecting = true; this.isConnecting = true;
log.info(`Connecting to WebSocket at ${this.url}...`); log.info(`Connecting to WebSocket at ${this.url}...`);
try { try {
this.socket = new WebSocket(this.url); this.socket = new WebSocket(this.url);
this.socket.onopen = () => { this.socket.onopen = () => {
this.isConnecting = false; this.isConnecting = false;
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
log.info("WebSocket connection established."); log.info("WebSocket connection established.");
this.flushMessageQueue(); this.flushMessageQueue();
}; };
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
log.debug("Received message:", data); log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) { if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId); const callback = this.ackCallbacks.get(data.nodeId);
if (callback) { if (callback) {
@@ -54,65 +44,59 @@ class WebSocketManager {
this.ackCallbacks.delete(data.nodeId); this.ackCallbacks.delete(data.nodeId);
} }
} }
}
} catch (error) { catch (error) {
log.error("Error parsing incoming WebSocket message:", error); log.error("Error parsing incoming WebSocket message:", error);
} }
}; };
this.socket.onclose = (event) => { this.socket.onclose = (event) => {
this.isConnecting = false; this.isConnecting = false;
if (event.wasClean) { if (event.wasClean) {
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
} else { }
else {
log.warn("WebSocket connection died. Attempting to reconnect..."); log.warn("WebSocket connection died. Attempting to reconnect...");
this.handleReconnect(); this.handleReconnect();
} }
}; };
this.socket.onerror = (error) => { this.socket.onerror = (error) => {
this.isConnecting = false; this.isConnecting = false;
log.error("WebSocket error:", error); log.error("WebSocket error:", error);
}; };
} catch (error) { }
catch (error) {
this.isConnecting = false; this.isConnecting = false;
log.error("Failed to create WebSocket connection:", error); log.error("Failed to create WebSocket connection:", error);
this.handleReconnect(); this.handleReconnect();
} }
} }
handleReconnect() { handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) { if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++; this.reconnectAttempts++;
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
setTimeout(() => this.connect(), this.reconnectInterval); setTimeout(() => this.connect(), this.reconnectInterval);
} else { }
else {
log.error("Max reconnect attempts reached. Giving up."); log.error("Max reconnect attempts reached. Giving up.");
} }
} }
sendMessage(data, requiresAck = false) { sendMessage(data, requiresAck = false) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const nodeId = data.nodeId; const nodeId = data.nodeId;
if (requiresAck && !nodeId) { if (requiresAck && !nodeId) {
return reject(new Error("A nodeId is required for messages that need acknowledgment.")); return reject(new Error("A nodeId is required for messages that need acknowledgment."));
} }
const message = JSON.stringify(data); const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message); this.socket.send(message);
log.debug("Sent message:", data); log.debug("Sent message:", data);
if (requiresAck) { if (requiresAck && nodeId) {
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId); this.ackCallbacks.delete(nodeId);
reject(new Error(`ACK timeout for nodeId ${nodeId}`)); reject(new Error(`ACK timeout for nodeId ${nodeId}`));
log.warn(`ACK timeout for nodeId ${nodeId}.`); log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout }, 10000); // 10-second timeout
this.ackCallbacks.set(nodeId, { this.ackCallbacks.set(nodeId, {
resolve: (responseData) => { resolve: (responseData) => {
clearTimeout(timeout); clearTimeout(timeout);
@@ -123,35 +107,35 @@ class WebSocketManager {
reject(error); reject(error);
} }
}); });
} else { }
else {
resolve(); // Resolve immediately if no ACK is needed resolve(); // Resolve immediately if no ACK is needed
} }
} else { }
else {
log.warn("WebSocket not open. Queuing message."); log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message); this.messageQueue.push(message);
if (!this.isConnecting) { if (!this.isConnecting) {
this.connect(); this.connect();
} }
if (requiresAck) { if (requiresAck) {
reject(new Error("Cannot send message with ACK required while disconnected.")); reject(new Error("Cannot send message with ACK required while disconnected."));
} }
else {
resolve();
}
} }
}); });
} }
flushMessageQueue() { flushMessageQueue() {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`); log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift(); const message = this.messageQueue.shift();
this.socket.send(message); if (this.socket && message) {
this.socket.send(message);
}
} }
} }
} }
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`; const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
export const webSocketManager = new WebSocketManager(wsUrl); export const webSocketManager = new WebSocketManager(wsUrl);

View File

@@ -1,39 +1,36 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('MaskUtils'); const log = createModuleLogger('MaskUtils');
export function new_editor(app) { export function new_editor(app) {
if (!app) return false; if (!app)
return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor') return false;
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
} }
function get_mask_editor_element(app) { function get_mask_editor_element(app) {
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null;
} }
export function mask_editor_showing(app) { export function mask_editor_showing(app) {
const editor = get_mask_editor_element(app); const editor = get_mask_editor_element(app);
return editor && editor.style.display !== "none"; return !!editor && editor.style.display !== "none";
} }
export function hide_mask_editor(app) {
export function hide_mask_editor() { if (mask_editor_showing(app)) {
if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none' const editor = document.getElementById('maskEditor');
if (editor) {
editor.style.display = 'none';
}
}
} }
function get_mask_editor_cancel_button(app) { function get_mask_editor_cancel_button(app) {
const cancelButton = document.getElementById("maskEditor_topBarCancelButton"); const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
if (cancelButton) { if (cancelButton) {
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton"); log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
return cancelButton; return cancelButton;
} }
const cancelSelectors = [ const cancelSelectors = [
'button[onclick*="cancel"]', 'button[onclick*="cancel"]',
'button[onclick*="Cancel"]', 'button[onclick*="Cancel"]',
'input[value="Cancel"]' 'input[value="Cancel"]'
]; ];
for (const selector of cancelSelectors) { for (const selector of cancelSelectors) {
try { try {
const button = document.querySelector(selector); const button = document.querySelector(selector);
@@ -41,11 +38,11 @@ function get_mask_editor_cancel_button(app) {
log.debug("Found cancel button with selector:", selector); log.debug("Found cancel button with selector:", selector);
return button; return button;
} }
} catch (e) { }
catch (e) {
log.warn("Invalid selector:", selector, e); log.warn("Invalid selector:", selector, e);
} }
} }
const allButtons = document.querySelectorAll('button, input[type="button"]'); const allButtons = document.querySelectorAll('button, input[type="button"]');
for (const button of allButtons) { for (const button of allButtons) {
const text = button.textContent || button.value || ''; const text = button.textContent || button.value || '';
@@ -54,72 +51,78 @@ function get_mask_editor_cancel_button(app) {
return button; return button;
} }
} }
const editorElement = get_mask_editor_element(app); const editorElement = get_mask_editor_element(app);
if (editorElement) { if (editorElement) {
return editorElement?.parentElement?.lastChild?.childNodes[2]; const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
return childNodes[2];
}
} }
return null; return null;
} }
function get_mask_editor_save_button(app) { function get_mask_editor_save_button(app) {
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton") const saveButton = document.getElementById("maskEditor_topBarSaveButton");
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2] if (saveButton) {
return saveButton;
}
const editorElement = get_mask_editor_element(app);
if (editorElement) {
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
return childNodes[2];
}
}
return null;
} }
export function mask_editor_listen_for_cancel(app, callback) { export function mask_editor_listen_for_cancel(app, callback) {
let attempts = 0; let attempts = 0;
const maxAttempts = 50; // 5 sekund const maxAttempts = 50; // 5 sekund
const findAndAttachListener = () => { const findAndAttachListener = () => {
attempts++; attempts++;
const cancel_button = get_mask_editor_cancel_button(app); const cancel_button = get_mask_editor_cancel_button(app);
if (cancel_button instanceof HTMLElement && !cancel_button.filter_listener_added) {
if (cancel_button && !cancel_button.filter_listener_added) {
log.info("Cancel button found, attaching listener"); log.info("Cancel button found, attaching listener");
cancel_button.addEventListener('click', callback); cancel_button.addEventListener('click', callback);
cancel_button.filter_listener_added = true; cancel_button.filter_listener_added = true;
return true; // Znaleziono i podłączono }
} else if (attempts < maxAttempts) { else if (attempts < maxAttempts) {
setTimeout(findAndAttachListener, 100); setTimeout(findAndAttachListener, 100);
} else { }
else {
log.warn("Could not find cancel button after", maxAttempts, "attempts"); log.warn("Could not find cancel button after", maxAttempts, "attempts");
const globalClickHandler = (event) => { const globalClickHandler = (event) => {
const target = event.target; const target = event.target;
const text = target.textContent || target.value || ''; const text = target.textContent || target.value || '';
if (text.toLowerCase().includes('cancel') || if (target && (text.toLowerCase().includes('cancel') ||
target.id.toLowerCase().includes('cancel') || target.id.toLowerCase().includes('cancel') ||
target.className.toLowerCase().includes('cancel')) { target.className.toLowerCase().includes('cancel'))) {
log.info("Cancel detected via global click handler"); log.info("Cancel detected via global click handler");
callback(); callback();
document.removeEventListener('click', globalClickHandler); document.removeEventListener('click', globalClickHandler);
} }
}; };
document.addEventListener('click', globalClickHandler); document.addEventListener('click', globalClickHandler);
log.debug("Added global click handler for cancel detection"); log.debug("Added global click handler for cancel detection");
} }
}; };
findAndAttachListener(); findAndAttachListener();
} }
export function press_maskeditor_save(app) { export function press_maskeditor_save(app) {
get_mask_editor_save_button(app)?.click() const button = get_mask_editor_save_button(app);
if (button instanceof HTMLElement) {
button.click();
}
} }
export function press_maskeditor_cancel(app) { export function press_maskeditor_cancel(app) {
get_mask_editor_cancel_button(app)?.click() const button = get_mask_editor_cancel_button(app);
if (button instanceof HTMLElement) {
button.click();
}
} }
/** /**
* Uruchamia mask editor z predefiniowaną maską * Uruchamia mask editor z predefiniowaną maską
* @param {Object} canvasInstance - Instancja Canvas * @param {Canvas} canvasInstance - Instancja Canvas
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/ */
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) { export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) {
@@ -127,48 +130,42 @@ export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage
log.error('Canvas instance and mask image are required'); log.error('Canvas instance and mask image are required');
return; return;
} }
canvasInstance.startMaskEditor(maskImage, sendCleanImage); canvasInstance.startMaskEditor(maskImage, sendCleanImage);
} }
/** /**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Object} canvasInstance - Instancja Canvas * @param {Canvas} canvasInstance - Instancja Canvas
*/ */
export function start_mask_editor_auto(canvasInstance) { export function start_mask_editor_auto(canvasInstance) {
if (!canvasInstance) { if (!canvasInstance) {
log.error('Canvas instance is required'); log.error('Canvas instance is required');
return; return;
} }
canvasInstance.startMaskEditor(null, true);
canvasInstance.startMaskEditor();
} }
/** /**
* Tworzy maskę z obrazu dla użycia w mask editorze * Tworzy maskę z obrazu dla użycia w mask editorze
* @param {string} imageSrc - Źródło obrazu (URL lub data URL) * @param {string} imageSrc - Źródło obrazu (URL lub data URL)
* @returns {Promise<Image>} Promise zwracający obiekt Image * @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/ */
export function create_mask_from_image_src(imageSrc) { export function create_mask_from_image_src(imageSrc) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = imageSrc; img.src = imageSrc;
}); });
} }
/** /**
* Konwertuje canvas do Image dla użycia jako maska * Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji * @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<Image>} Promise zwracający obiekt Image * @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/ */
export function canvas_to_mask_image(canvas) { export function canvas_to_mask_image(canvas) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} }

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.6" version = "1.3.8"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

277
src/BatchPreviewManager.ts Normal file
View File

@@ -0,0 +1,277 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Point } from './types';
const log = createModuleLogger('BatchPreviewManager');
interface GenerationArea {
x: number;
y: number;
width: number;
height: number;
}
export class BatchPreviewManager {
public active: boolean;
private canvas: Canvas;
private counterElement: HTMLSpanElement | null;
private currentIndex: number;
private element: HTMLDivElement | null;
public generationArea: GenerationArea | null;
private isDragging: boolean;
private layers: Layer[];
private maskWasVisible: boolean;
private uiInitialized: boolean;
private worldX: number;
private worldY: number;
constructor(canvas: Canvas, initialPosition: Point = { x: 0, y: 0 }, generationArea: GenerationArea | null = null) {
this.canvas = canvas;
this.active = false;
this.layers = [];
this.currentIndex = 0;
this.element = null;
this.counterElement = null;
this.uiInitialized = false;
this.maskWasVisible = false;
this.worldX = initialPosition.x;
this.worldY = initialPosition.y;
this.isDragging = false;
this.generationArea = generationArea;
}
updateScreenPosition(viewport: { x: number, y: number, zoom: number }): void {
if (!this.active || !this.element) return;
const screenX = (this.worldX - viewport.x) * viewport.zoom;
const screenY = (this.worldY - viewport.y) * viewport.zoom;
const scale = 1;
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
}
private _createUI(): void {
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: MouseEvent) => {
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (this.isDragging) {
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('&#9664;', 'Previous'); // Left arrow
const nextButton = this._createButton('&#9654;', 'Next'); // Right arrow
const confirmButton = this._createButton('&#10004;', 'Confirm'); // Checkmark
const cancelButton = this._createButton('&#10006;', 'Cancel All');
const closeButton = this._createButton('&#10162;', 'Close');
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.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.element);
} else {
log.error("Could not find parent node to attach batch preview UI.");
}
this.uiInitialized = true;
}
private _createButton(innerHTML: string, title: string): HTMLButtonElement {
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: Layer[]): void {
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;
if (this.element) {
this.element.style.display = 'flex';
}
this.active = true;
if (this.element) {
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
const paddingInWorld = 20 / this.canvas.viewport.zoom;
this.worldX -= menuWidthInWorld / 2;
this.worldY += paddingInWorld;
}
this._update();
}
hide(): void {
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);
}
this.canvas.render();
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
if (toggleBtn) {
toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask";
}
}
this.maskWasVisible = false;
this.canvas.layers.forEach((l: Layer) => (l as any).visible = true);
this.canvas.render();
}
navigate(direction: number): void {
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(): void {
const layerToKeep = this.layers[this.currentIndex];
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
const layersToDelete = this.layers.filter((l: Layer) => l.id !== layerToKeep.id);
const layerIdsToDelete = layersToDelete.map((l: Layer) => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted ${layersToDelete.length} other layers.`);
this.hide();
}
cancelAndRemoveAll(): void {
log.info('Cancel clicked. Removing all new layers.');
const layerIdsToDelete = this.layers.map((l: Layer) => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
this.hide();
}
private _update(): void {
if (this.counterElement) {
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
}
this._focusOnLayer(this.layers[this.currentIndex]);
}
private _focusOnLayer(layer: Layer): void {
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();
}
}

605
src/Canvas.ts Normal file
View File

@@ -0,0 +1,605 @@
// @ts-ignore
import {api} from "../../scripts/api.js";
// @ts-ignore
import {app} from "../../scripts/app.js";
// @ts-ignore
import {ComfyApp} from "../../scripts/app.js";
import {removeImage} from "./db.js";
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 {BatchPreviewManager} from "./BatchPreviewManager.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js";
import {CanvasSelection} from "./CanvasSelection.js";
import type { ComfyNode, Layer, Viewport, Point, AddMode } from './types';
const useChainCallback = (original: any, next: any) => {
if (original === undefined || original === null) {
return next;
}
return function(this: any, ...args: any[]) {
const originalReturn = original.apply(this, args);
const nextReturn = next.apply(this, args);
return nextReturn === undefined ? originalReturn : nextReturn;
};
};
const log = createModuleLogger('Canvas');
/**
* Canvas - Fasada dla systemu rysowania
*
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
* gdy potrzebna jest bardziej szczegółowa kontrola.
*/
export class Canvas {
batchPreviewManagers: BatchPreviewManager[];
canvas: HTMLCanvasElement;
canvasIO: CanvasIO;
canvasInteractions: CanvasInteractions;
canvasLayers: CanvasLayers;
canvasLayersPanel: CanvasLayersPanel;
canvasMask: CanvasMask;
canvasRenderer: CanvasRenderer;
canvasSelection: CanvasSelection;
canvasState: CanvasState;
ctx: CanvasRenderingContext2D;
dataInitialized: boolean;
height: number;
imageCache: Map<string, any>;
imageReferenceManager: ImageReferenceManager;
interaction: any;
isMouseOver: boolean;
lastMousePosition: Point;
layers: Layer[];
maskTool: MaskTool;
node: ComfyNode;
offscreenCanvas: HTMLCanvasElement;
offscreenCtx: CanvasRenderingContext2D | null;
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
onStateChange: (() => void) | undefined;
pendingBatchContext: any;
pendingDataCheck: number | null;
previewVisible: boolean;
requestSaveState: () => void;
viewport: Viewport;
widget: any;
width: number;
constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) {
this.node = node;
this.widget = widget;
this.canvas = document.createElement('canvas');
const ctx = this.canvas.getContext('2d', {willReadFrequently: true});
if (!ctx) throw new Error("Could not create canvas context");
this.ctx = ctx;
this.width = 512;
this.height = 512;
this.layers = [];
this.onStateChange = callbacks.onStateChange;
this.onHistoryChange = callbacks.onHistoryChange;
this.lastMousePosition = {x: 0, y: 0};
this.viewport = {
x: -(this.width / 4),
y: -(this.height / 4),
zoom: 0.8,
};
this.offscreenCanvas = document.createElement('canvas');
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false
});
this.dataInitialized = false;
this.pendingDataCheck = null;
this.imageCache = new Map();
this.requestSaveState = () => {};
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasMask = new CanvasMask(this);
this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(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);
this.batchPreviewManagers = [];
this.pendingBatchContext = null;
this.interaction = this.canvasInteractions.interaction;
this.previewVisible = false;
this.isMouseOver = false;
this._initializeModules();
this._setupCanvas();
log.debug('Canvas widget element:', this.node);
log.info('Canvas initialized', {
nodeId: this.node.id,
dimensions: {width: this.width, height: this.height},
viewport: this.viewport
});
this.previewVisible = false;
}
async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
const widget = node.widgets.find((w: any) => w.name === name);
if (widget) {
resolve(widget);
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Widget "${name}" not found within timeout.`));
} else {
setTimeout(check, interval);
}
};
check();
});
}
/**
* Kontroluje widoczność podglądu canvas
* @param {boolean} visible - Czy podgląd ma być widoczny
*/
async setPreviewVisibility(visible: boolean) {
this.previewVisible = visible;
log.info("Canvas preview visibility set to:", visible);
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node) as any;
if (imagePreviewWidget) {
log.debug("Found $$canvas-image-preview widget, controlling visibility");
if (visible) {
if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = false;
}
if ('visible' in imagePreviewWidget) {
imagePreviewWidget.visible = true;
}
if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = false;
}
imagePreviewWidget.computeSize = function () {
return [0, 250]; // Szerokość 0 (auto), wysokość 250
};
} else {
if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = true;
}
if ('visible' in imagePreviewWidget) {
imagePreviewWidget.visible = false;
}
if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = true;
}
imagePreviewWidget.computeSize = function () {
return [0, 0]; // Szerokość 0, wysokość 0
};
}
this.render()
} else {
log.warn("$$canvas-image-preview widget not found in Canvas.js");
}
}
/**
* Inicjalizuje moduły systemu canvas
* @private
*/
_initializeModules() {
log.debug('Initializing Canvas modules...');
// Stwórz opóźnioną wersję funkcji zapisu stanu
this.requestSaveState = debounce(() => this.saveState(), 500);
this._setupAutoRefreshHandlers();
log.debug('Canvas modules initialized successfully');
}
/**
* Konfiguruje podstawowe właściwości canvas
* @private
*/
_setupCanvas() {
this.initCanvas();
this.canvasInteractions.setupEventListeners();
this.canvasIO.initNodeData();
this.layers = this.layers.map((layer: Layer) => ({
...layer,
opacity: 1
}));
}
/**
* Ładuje stan canvas z bazy danych
*/
async loadInitialState() {
log.info("Loading initial state for node:", this.node.id);
const loaded = await this.canvasState.loadStateFromDB();
if (!loaded) {
log.info("No saved state found, initializing from node data.");
await this.canvasIO.initNodeData();
}
this.saveState();
this.render();
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
}
/**
* Zapisuje obecny stan
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
*/
saveState(replaceLast = false) {
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
this.canvasState.saveState(replaceLast);
this.incrementOperationCount();
this._notifyStateChange();
}
/**
* Cofnij ostatnią operację
*/
undo() {
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);
}
/**
* Ponów cofniętą operację
*/
redo() {
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);
}
/**
* Renderuje canvas
*/
render() {
this.canvasRenderer.render();
}
/**
* Dodaje warstwę z obrazem
* @param {Image} image - Obraz do dodania
* @param {Object} layerProps - Właściwości warstwy
* @param {string} addMode - Tryb dodawania
*/
async addLayer(image: HTMLImageElement, layerProps = {}, addMode: AddMode = 'default') {
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
// Powiadom panel warstw o dodaniu nowej warstwy
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
return result;
}
/**
* Usuwa wybrane warstwy
*/
removeLayersByIds(layerIds: string[]) {
if (!layerIds || layerIds.length === 0) return;
const initialCount = this.layers.length;
this.saveState();
this.layers = this.layers.filter((l: Layer) => !layerIds.includes(l.id));
// If the current selection was part of the removal, clear it
const newSelection = this.canvasSelection.selectedLayers.filter((l: Layer) => !layerIds.includes(l.id));
this.canvasSelection.updateSelection(newSelection);
this.render();
this.saveState();
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
}
removeSelectedLayers() {
return this.canvasSelection.removeSelectedLayers();
}
/**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/
duplicateSelectedLayers() {
return this.canvasSelection.duplicateSelectedLayers();
}
/**
* 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: any) {
return this.canvasSelection.updateSelection(newSelection);
}
/**
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
*/
updateSelectionLogic(layer: Layer, isCtrlPressed: boolean, isShiftPressed: boolean, index: number) {
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
}
/**
* Zmienia rozmiar obszaru wyjściowego
* @param {number} width - Nowa szerokość
* @param {number} height - Nowa wysokość
* @param {boolean} saveHistory - Czy zapisać w historii
*/
updateOutputAreaSize(width: number, height: number, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
}
/**
* Eksportuje spłaszczony canvas jako blob
*/
async getFlattenedCanvasAsBlob() {
return this.canvasLayers.getFlattenedCanvasAsBlob();
}
/**
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
*/
async getFlattenedCanvasWithMaskAsBlob() {
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
}
/**
* Importuje najnowszy obraz
*/
async importLatestImage() {
return this.canvasIO.importLatestImage();
}
_setupAutoRefreshHandlers() {
let lastExecutionStartTime = 0;
// Helper function to get auto-refresh value from node widget
const getAutoRefreshValue = (): boolean => {
const widget = this.node.widgets.find((w: any) => w.name === 'auto_refresh_after_generation');
return widget ? widget.value : false;
};
const handleExecutionStart = () => {
if (getAutoRefreshValue()) {
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 (getAutoRefreshValue()) {
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();
}
};
api.addEventListener('execution_start', handleExecutionStart);
api.addEventListener('execution_success', handleExecutionSuccess);
(this.node as any).onRemoved = useChainCallback((this.node as any).onRemoved, () => {
log.info('Node removed, cleaning up auto-refresh listeners.');
api.removeEventListener('execution_start', handleExecutionStart);
api.removeEventListener('execution_success', handleExecutionSuccess);
});
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
}
/**
* 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: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) {
return this.canvasMask.startMaskEditor(predefinedMask as any, sendCleanImage);
}
/**
* Inicjalizuje podstawowe właściwości canvas
*/
initCanvas() {
this.canvas.width = this.width;
this.canvas.height = this.height;
this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.canvas.tabIndex = 0;
this.canvas.style.outline = 'none';
}
/**
* Pobiera współrzędne myszy w układzie świata
* @param {MouseEvent} e - Zdarzenie myszy
*/
getMouseWorldCoordinates(e: any) {
const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top;
if (!this.offscreenCanvas) throw new Error("Offscreen canvas not initialized");
const scaleX = this.offscreenCanvas.width / rect.width;
const scaleY = this.offscreenCanvas.height / rect.height;
const mouseX_Buffer = mouseX_DOM * scaleX;
const mouseY_Buffer = mouseY_DOM * scaleY;
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
return {x: worldX, y: worldY};
}
/**
* Pobiera współrzędne myszy w układzie widoku
* @param {MouseEvent} e - Zdarzenie myszy
*/
getMouseViewCoordinates(e: any) {
const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top;
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const mouseX_Canvas = mouseX_DOM * scaleX;
const mouseY_Canvas = mouseY_DOM * scaleY;
return {x: mouseX_Canvas, y: mouseY_Canvas};
}
/**
* Aktualizuje zaznaczenie po operacji historii
*/
updateSelectionAfterHistory() {
return this.canvasSelection.updateSelectionAfterHistory();
}
/**
* Aktualizuje przyciski historii
*/
updateHistoryButtons() {
if (this.onHistoryChange) {
const historyInfo = this.canvasState.getHistoryInfo();
this.onHistoryChange({
canUndo: historyInfo.canUndo,
canRedo: historyInfo.canRedo
});
}
}
/**
* Zwiększa licznik operacji (dla garbage collection)
*/
incrementOperationCount() {
if (this.imageReferenceManager) {
this.imageReferenceManager.incrementOperationCount();
}
}
/**
* Czyści zasoby canvas
*/
destroy() {
if (this.imageReferenceManager) {
this.imageReferenceManager.destroy();
}
log.info("Canvas destroyed");
}
/**
* Powiadamia o zmianie stanu
* @private
*/
_notifyStateChange() {
if (this.onStateChange) {
this.onStateChange();
}
}
}

796
src/CanvasIO.ts Normal file
View File

@@ -0,0 +1,796 @@
import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js";
import type { Canvas } from './Canvas';
import type { Layer } from './types';
const log = createModuleLogger('CanvasIO');
export class CanvasIO {
private _saveInProgress: Promise<any> | null;
private canvas: Canvas;
constructor(canvas: Canvas) {
this.canvas = canvas;
this._saveInProgress = null;
}
async saveToServer(fileName: string, outputMode = 'disk'): Promise<any> {
if (outputMode === 'disk') {
if (!(window as any).canvasSaveStates) {
(window as any).canvasSaveStates = new Map();
}
const nodeId = this.canvas.node.id;
const saveKey = `${nodeId}_${fileName}`;
if (this._saveInProgress || (window as any).canvasSaveStates.get(saveKey)) {
log.warn(`Save already in progress for node ${nodeId}, waiting...`);
return this._saveInProgress || (window as any).canvasSaveStates.get(saveKey);
}
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
this._saveInProgress = this._performSave(fileName, outputMode);
(window as any).canvasSaveStates.set(saveKey, this._saveInProgress);
try {
return await this._saveInProgress;
} finally {
this._saveInProgress = null;
(window as any).canvasSaveStates.delete(saveKey);
log.debug(`Save completed for node ${nodeId}, lock released`);
}
} else {
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
return this._performSave(fileName, outputMode);
}
}
async _performSave(fileName: string, outputMode: string): Promise<any> {
if (this.canvas.layers.length === 0) {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true);
}
await this.canvas.canvasState.saveStateToDB();
const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50;
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
return new Promise((resolve) => {
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) throw new Error("Could not create visibility context");
if (!maskCtx) throw new Error("Could not create mask context");
if (!tempCtx) throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff';
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
log.debug(`Canvas contexts created, starting layer rendering`);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
log.debug(`Finished rendering layers`);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha;
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`);
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
const destY = Math.max(0, maskY);
const copyWidth = Math.min(
toolMaskCanvas.width - sourceX, // Available width in source
this.canvas.width - destX // Available width in destination
);
const copyHeight = Math.min(
toolMaskCanvas.height - sourceY, // Available height in source
this.canvas.height - destY // Available height in destination
);
if (copyWidth > 0 && copyHeight > 0) {
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`);
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
destX, destY, copyWidth, copyHeight // Destination rectangle
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
if (outputMode === 'ram') {
const imageData = tempCanvas.toDataURL('image/png');
const maskData = maskCanvas.toDataURL('image/png');
log.info("Returning image and mask data as base64 for RAM mode.");
resolve({image: imageData, mask: maskData});
return;
}
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
tempCanvas.toBlob(async (blobWithoutMask) => {
if (!blobWithoutMask) return;
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
const formDataWithoutMask = new FormData();
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
formDataWithoutMask.append("overwrite", "true");
try {
const response = await fetch("/upload/image", {
method: "POST",
body: formDataWithoutMask,
});
log.debug(`Image without mask upload response: ${response.status}`);
} catch (error) {
log.error(`Error uploading image without mask:`, error);
}
}, "image/png");
log.info(`Saving main image as: ${fileName}`);
tempCanvas.toBlob(async (blob) => {
if (!blob) return;
log.debug(`Created blob for main image, size: ${blob.size} bytes`);
const formData = new FormData();
formData.append("image", blob, fileName);
formData.append("overwrite", "true");
try {
const resp = await fetch("/upload/image", {
method: "POST",
body: formData,
});
log.debug(`Main image upload response: ${resp.status}`);
if (resp.status === 200) {
const maskFileName = fileName.replace('.png', '_mask.png');
log.info(`Saving mask as: ${maskFileName}`);
maskCanvas.toBlob(async (maskBlob) => {
if (!maskBlob) return;
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
const maskFormData = new FormData();
maskFormData.append("image", maskBlob, maskFileName);
maskFormData.append("overwrite", "true");
try {
const maskResp = await fetch("/upload/image", {
method: "POST",
body: maskFormData,
});
log.debug(`Mask upload response: ${maskResp.status}`);
if (maskResp.status === 200) {
const data = await resp.json();
if (this.canvas.widget) {
this.canvas.widget.value = fileName;
}
log.info(`All files saved successfully, widget value set to: ${fileName}`);
resolve(true);
} else {
log.error(`Error saving mask: ${maskResp.status}`);
resolve(false);
}
} catch (error) {
log.error(`Error saving mask:`, error);
resolve(false);
}
}, "image/png");
} else {
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
resolve(false);
}
} catch (error) {
log.error(`Error uploading main image:`, error);
resolve(false);
}
}, "image/png");
});
}
async _renderOutputData(): Promise<{ image: string, mask: string }> {
return new Promise((resolve) => {
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) throw new Error("Could not create visibility context");
if (!maskCtx) throw new Error("Could not create mask context");
if (!tempCtx) throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Invert alpha to create the mask
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solid mask
}
maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
const imageDataUrl = tempCanvas.toDataURL('image/png');
const maskDataUrl = maskCanvas.toDataURL('image/png');
resolve({image: imageDataUrl, mask: maskDataUrl});
});
}
async sendDataViaWebSocket(nodeId: number): Promise<boolean> {
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
const { image, mask } = await this._renderOutputData();
try {
log.info(`Sending data for node ${nodeId}...`);
await webSocketManager.sendMessage({
type: 'canvas_data',
nodeId: String(nodeId),
image: image,
mask: mask,
}, true); // `true` requires an acknowledgment
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
return true;
} catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, error);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
}
}
async addInputToCanvas(inputImage: any, inputMask: any): Promise<boolean> {
try {
log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
if (!tempCtx) throw new Error("Could not create temp context");
const imgData = new ImageData(
new Uint8ClampedArray(inputImage.data),
inputImage.width,
inputImage.height
);
tempCtx.putImageData(imgData, 0, 0);
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = tempCanvas.toDataURL();
});
const scale = Math.min(
this.canvas.width / inputImage.width * 0.8,
this.canvas.height / inputImage.height * 0.8
);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale,
height: inputImage.height * scale,
});
if (inputMask && layer) {
(layer as any).mask = inputMask.data;
}
log.info("Layer added successfully");
return true;
} catch (error) {
log.error("Error in addInputToCanvas:", error);
throw error;
}
}
async convertTensorToImage(tensor: any): Promise<HTMLImageElement> {
try {
log.debug("Converting tensor to image:", tensor);
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
throw new Error("Invalid tensor data");
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create canvas context");
canvas.width = tensor.width;
canvas.height = tensor.height;
const imageData = new ImageData(
new Uint8ClampedArray(tensor.data),
tensor.width,
tensor.height
);
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL();
});
} catch (error) {
log.error("Error converting tensor to image:", error);
throw error;
}
}
async convertTensorToMask(tensor: any): Promise<Float32Array> {
if (!tensor || !tensor.data) {
throw new Error("Invalid mask tensor");
}
try {
return new Float32Array(tensor.data);
} catch (error: any) {
throw new Error(`Mask conversion failed: ${error.message}`);
}
}
async initNodeData(): Promise<void> {
try {
log.info("Starting node data initialization...");
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
log.debug("Node or inputs not ready");
return this.scheduleDataCheck();
}
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
const imageLinkId = (this.canvas.node as any).inputs[0].link;
const imageData = (window as any).app.nodeOutputs[imageLinkId];
if (imageData) {
log.debug("Found image data:", imageData);
await this.processImageData(imageData);
this.canvas.dataInitialized = true;
} else {
log.debug("Image data not available yet");
return this.scheduleDataCheck();
}
}
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
const maskLinkId = (this.canvas.node as any).inputs[1].link;
const maskData = (window as any).app.nodeOutputs[maskLinkId];
if (maskData) {
log.debug("Found mask data:", maskData);
await this.processMaskData(maskData);
}
}
} catch (error) {
log.error("Error in initNodeData:", error);
return this.scheduleDataCheck();
}
}
scheduleDataCheck(): void {
if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck);
}
this.canvas.pendingDataCheck = window.setTimeout(() => {
this.canvas.pendingDataCheck = null;
if (!this.canvas.dataInitialized) {
this.initNodeData();
}
}, 1000);
}
async processImageData(imageData: any): Promise<void> {
try {
if (!imageData) return;
log.debug("Processing image data:", {
type: typeof imageData,
isArray: Array.isArray(imageData),
shape: imageData.shape,
hasData: !!imageData.data
});
if (Array.isArray(imageData)) {
imageData = imageData[0];
}
if (!imageData.shape || !imageData.data) {
throw new Error("Invalid image data format");
}
const originalWidth = imageData.shape[2];
const originalHeight = imageData.shape[1];
const scale = Math.min(
this.canvas.width / originalWidth * 0.8,
this.canvas.height / originalHeight * 0.8
);
const convertedData = this.convertTensorToImageData(imageData);
if (convertedData) {
const image = await this.createImageFromData(convertedData);
this.addScaledLayer(image, scale);
log.info("Image layer added successfully with scale:", scale);
}
} catch (error) {
log.error("Error processing image data:", error);
throw error;
}
}
addScaledLayer(image: HTMLImageElement, scale: number): void {
try {
const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale;
const layer: Layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: image,
x: (this.canvas.width - scaledWidth) / 2,
y: (this.canvas.height - scaledHeight) / 2,
width: scaledWidth,
height: scaledHeight,
rotation: 0,
zIndex: this.canvas.layers.length,
originalWidth: image.width,
originalHeight: image.height,
blendMode: 'normal',
opacity: 1
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
log.debug("Scaled layer added:", {
originalSize: `${image.width}x${image.height}`,
scaledSize: `${scaledWidth}x${scaledHeight}`,
scale: scale
});
} catch (error) {
log.error("Error adding scaled layer:", error);
throw error;
}
}
convertTensorToImageData(tensor: any): ImageData | null {
try {
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
log.debug("Converting tensor:", {
shape: shape,
dataRange: {
min: tensor.min_val,
max: tensor.max_val
}
});
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255);
}
data[pixelIndex + 3] = 255;
}
imageData.data.set(data);
return imageData;
} catch (error) {
log.error("Error converting tensor:", error);
return null;
}
}
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}
async retryDataLoad(maxRetries = 3, delay = 1000): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await this.initNodeData();
return;
} catch (error) {
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
log.error("Failed to load data after", maxRetries, "retries");
}
async processMaskData(maskData: any): Promise<void> {
try {
if (!maskData) return;
log.debug("Processing mask data:", maskData);
if (Array.isArray(maskData)) {
maskData = maskData[0];
}
if (!maskData.shape || !maskData.data) {
throw new Error("Invalid mask data format");
}
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const maskTensor = await this.convertTensorToMask(maskData);
(this.canvas.canvasSelection.selectedLayers[0] as any).mask = maskTensor;
this.canvas.render();
log.info("Mask applied to selected layer");
}
} catch (error) {
log.error("Error processing mask data:", error);
}
}
async loadImageFromCache(base64Data: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = base64Data;
});
}
async importImage(cacheData: { image: string, mask?: string }): Promise<void> {
try {
log.info("Starting image import with cache data");
const img = await this.loadImageFromCache(cacheData.image);
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
const scale = Math.min(
this.canvas.width / img.width * 0.8,
this.canvas.height / img.height * 0.8
);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width;
tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) throw new Error("Could not create temp context");
tempCtx.drawImage(img, 0, 0);
if (mask) {
const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width;
maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx) throw new Error("Could not create mask context");
maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = maskData.data[i];
}
tempCtx.putImageData(imageData, 0, 0);
}
const finalImage = new Image();
await new Promise((resolve) => {
finalImage.onload = resolve;
finalImage.src = tempCanvas.toDataURL();
});
const layer: Layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: finalImage,
x: (this.canvas.width - img.width * scale) / 2,
y: (this.canvas.height - img.height * scale) / 2,
width: img.width * scale,
height: img.height * scale,
originalWidth: img.width,
originalHeight: img.height,
rotation: 0,
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
this.canvas.saveState();
} catch (error) {
log.error('Error importing image:', error);
}
}
async importLatestImage(): Promise<boolean> {
try {
log.info("Fetching latest image from server...");
const response = await fetch('/ycnode/get_latest_image');
const result = await response.json();
if (result.success && result.image_data) {
log.info("Latest image received, adding to canvas.");
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = result.image_data;
});
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
log.info("Latest image imported and placed on canvas successfully.");
return true;
} else {
throw new Error(result.error || "Failed to fetch the latest image.");
}
} catch (error: any) {
log.error("Error importing latest image:", error);
alert(`Failed to import latest image: ${error.message}`);
return false;
}
}
async importLatestImages(sinceTimestamp: number, targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise<Layer[]> {
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: (Layer | null)[] = [];
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.filter(l => l !== null) as Layer[];
} 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: any) {
log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`);
return [];
}
}
}

923
src/CanvasInteractions.ts Normal file
View File

@@ -0,0 +1,923 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, Point } from './types';
const log = createModuleLogger('CanvasInteractions');
interface InteractionState {
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag';
panStart: Point;
dragStart: Point;
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
resizeHandle: string | null;
resizeAnchor: Point;
canvasResizeStart: Point;
isCtrlPressed: boolean;
isAltPressed: boolean;
hasClonedInDrag: boolean;
lastClickTime: number;
transformingLayer: Layer | null;
keyMovementInProgress: boolean;
canvasResizeRect: { x: number, y: number, width: number, height: number } | null;
canvasMoveRect: { x: number, y: number, width: number, height: number } | null;
}
export class CanvasInteractions {
private canvas: Canvas;
public interaction: InteractionState;
private originalLayerPositions: Map<Layer, Point>;
constructor(canvas: Canvas) {
this.canvas = canvas;
this.interaction = {
mode: 'none',
panStart: { x: 0, y: 0 },
dragStart: { x: 0, y: 0 },
transformOrigin: {},
resizeHandle: null,
resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false,
isAltPressed: false,
hasClonedInDrag: false,
lastClickTime: 0,
transformingLayer: null,
keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
};
this.originalLayerPositions = new Map();
}
setupEventListeners(): void {
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
this.canvas.isMouseOver = true;
this.handleMouseEnter(e);
});
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
this.canvas.isMouseOver = false;
this.handleMouseLeave(e);
});
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
}
resetInteractionState(): void {
this.interaction.mode = 'none';
this.interaction.resizeHandle = null;
this.originalLayerPositions.clear();
this.interaction.canvasResizeRect = null;
this.interaction.canvasMoveRect = null;
this.interaction.hasClonedInDrag = false;
this.interaction.transformingLayer = null;
this.canvas.canvas.style.cursor = 'default';
}
handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus();
const worldCoords = this.canvas.getMouseWorldCoordinates(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 ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords);
return;
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
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.canvasSelection.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);
return;
}
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
return;
}
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e);
}
handleMouseMove(e: MouseEvent): void {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
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.canvasSelection.selectedLayers.forEach((l: Layer) => {
this.originalLayerPositions.set(l, { x: l.x, y: l.y });
});
}
}
switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
this.canvas.render();
break;
case 'panning':
this.panViewport(e);
break;
case 'dragging':
this.dragLayers(worldCoords);
break;
case 'resizing':
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
break;
case 'rotating':
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
break;
case 'resizingCanvas':
this.updateCanvasResize(worldCoords);
break;
case 'movingCanvas':
this.updateCanvasMove(worldCoords);
break;
default:
this.updateCursor(worldCoords);
break;
}
}
handleMouseUp(e: MouseEvent): void {
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') {
this.finalizeCanvasResize();
}
if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove();
}
// 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();
}
this.resetInteractionState();
this.canvas.render();
}
handleMouseLeave(e: MouseEvent): void {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseLeave();
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleMouseUp(viewCoords);
}
this.canvas.render();
return;
}
if (this.interaction.mode !== 'none') {
this.resetInteractionState();
this.canvas.render();
}
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas");
}
}
handleMouseEnter(e: MouseEvent): void {
if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseEnter();
}
}
handleContextMenu(e: MouseEvent): void {
e.preventDefault();
}
handleWheel(e: WheelEvent): void {
e.preventDefault();
if (this.canvas.maskTool.isActive) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
if (e.shiftKey) {
// 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;
let scaleFactor;
if (e.ctrlKey) {
const direction = e.deltaY > 0 ? -1 : 1;
const baseDimension = Math.max(layer.width, layer.height);
const newBaseDimension = baseDimension + direction;
if (newBaseDimension < 10) {
return;
}
scaleFactor = newBaseDimension / baseDimension;
} else {
const gridSize = 64;
const direction = e.deltaY > 0 ? -1 : 1;
let targetHeight;
if (direction > 0) {
targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize;
} else {
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
}
if (targetHeight < gridSize / 2) {
targetHeight = gridSize / 2;
}
if (Math.abs(oldHeight - targetHeight) < 1) {
if (direction > 0) targetHeight += gridSize;
else targetHeight -= gridSize;
if (targetHeight < gridSize / 2) return;
}
scaleFactor = targetHeight / oldHeight;
}
if (scaleFactor && isFinite(scaleFactor)) {
layer.width *= scaleFactor;
layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2;
}
}
});
} else {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
}
this.canvas.render();
if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
}
}
handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
// 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.canvasSelection.selectedLayers.length > 0) {
this.canvas.canvasLayers.copySelectedLayers();
}
break;
default:
handled = false;
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
return;
}
}
// Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1;
let needsRender = false;
// 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.canvasSelection.selectedLayers.forEach((l: Layer) => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.x += step);
if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y += step);
if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation += step);
needsRender = true;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasSelection.removeSelectedLayers();
return;
}
if (needsRender) {
this.canvas.render();
}
}
}
handleKeyUp(e: KeyboardEvent): void {
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: Point): void {
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
const handleName = transformTarget.handle;
const cursorMap: { [key: string]: string } = {
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize',
'nw': 'nwse-resize', 'se': 'nwse-resize', 'ne': 'nesw-resize', 'sw': 'nesw-resize',
'rot': 'grab'
};
this.canvas.canvas.style.cursor = cursorMap[handleName];
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
this.canvas.canvas.style.cursor = 'move';
} else {
this.canvas.canvas.style.cursor = 'default';
}
}
startLayerTransform(layer: Layer, handle: string, worldCoords: Point): void {
this.interaction.transformingLayer = layer;
this.interaction.transformOrigin = {
x: layer.x, y: layer.y,
width: layer.width, height: layer.height,
rotation: layer.rotation,
centerX: layer.x + layer.width / 2,
centerY: layer.y + layer.height / 2
};
this.interaction.dragStart = {...worldCoords};
if (handle === 'rot') {
this.interaction.mode = 'rotating';
} else {
this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle;
const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey: { [key: string]: string } = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
};
this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]];
}
this.canvas.render();
}
prepareForDrag(layer: Layer, worldCoords: Point): void {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection);
}
} else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]);
}
}
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords};
}
startPanningOrClearSelection(e: MouseEvent): void {
// 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.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY};
}
startCanvasResize(worldCoords: Point): void {
this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY};
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
this.canvas.render();
}
startCanvasMove(worldCoords: Point): void {
this.interaction.mode = 'movingCanvas';
this.interaction.dragStart = { ...worldCoords };
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = {
x: initialX,
y: initialY,
width: this.canvas.width,
height: this.canvas.height
};
this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render();
}
updateCanvasMove(worldCoords: Point): void {
if (!this.interaction.canvasMoveRect) return;
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
this.canvas.render();
}
finalizeCanvasMove(): void {
const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x;
const finalY = moveRect.y;
this.canvas.layers.forEach((layer: Layer) => {
layer.x -= finalX;
layer.y -= 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: any) => { // TODO: Type for 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;
}
this.canvas.render();
this.canvas.saveState();
}
startPanning(e: MouseEvent): void {
if (!this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]);
}
this.interaction.mode = 'panning';
this.interaction.panStart = { x: e.clientX, y: e.clientY };
}
panViewport(e: MouseEvent): void {
const dx = e.clientX - this.interaction.panStart.x;
const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY};
this.canvas.render();
}
dragLayers(worldCoords: Point): void {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
// Scentralizowana logika duplikowania
const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers();
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
this.originalLayerPositions.clear();
newLayers.forEach((l: Layer) => {
this.originalLayerPositions.set(l, { x: l.x, y: l.y });
});
this.interaction.hasClonedInDrag = true;
}
const totalDx = worldCoords.x - this.interaction.dragStart.x;
const totalDy = worldCoords.y - this.interaction.dragStart.y;
let finalDx = totalDx, finalDy = totalDy;
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) {
const firstLayer = this.canvas.canvasSelection.selectedLayers[0];
const originalPos = this.originalLayerPositions.get(firstLayer);
if (originalPos) {
const tempLayerForSnap = {
...firstLayer,
x: originalPos.x + totalDx,
y: originalPos.y + totalDy
};
const snapAdjustment = getSnapAdjustment(tempLayerForSnap);
if (snapAdjustment) {
finalDx += snapAdjustment.x;
finalDy += snapAdjustment.y;
}
}
}
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
const originalPos = this.originalLayerPositions.get(layer);
if (originalPos) {
layer.x = originalPos.x + finalDx;
layer.y = originalPos.y + finalDy;
}
});
this.canvas.render();
}
resizeLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
const layer = this.interaction.transformingLayer;
if (!layer) return;
let mouseX = worldCoords.x;
let mouseY = worldCoords.y;
if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
}
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
} else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
}
}
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
newWidth *= signX;
newHeight *= signY;
if (signX === 0) newWidth = o.width;
if (signY === 0) newHeight = o.height;
if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
const deltaW = newWidth - o.width;
const deltaH = newHeight - o.height;
const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2;
this.canvas.render();
}
rotateLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
const layer = this.interaction.transformingLayer;
if (!layer) return;
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
let newRotation = o.rotation + angleDiff;
if (isShiftPressed) {
newRotation = Math.round(newRotation / 15) * 15;
}
layer.rotation = newRotation;
this.canvas.render();
}
updateCanvasResize(worldCoords: Point): void {
if (!this.interaction.canvasResizeRect) return;
const snappedMouseX = snapToGrid(worldCoords.x);
const snappedMouseY = snapToGrid(worldCoords.y);
const start = this.interaction.canvasResizeStart;
this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x);
this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y);
this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x);
this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y);
this.canvas.render();
}
finalizeCanvasResize(): void {
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
const finalX = this.interaction.canvasResizeRect.x;
const finalY = this.interaction.canvasResizeRect.y;
this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach((layer: Layer) => {
layer.x -= finalX;
layer.y -= 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 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: any) => { // TODO: Type for 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;
}
}
handleDragOver(e: DragEvent): void {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
handleDragEnter(e: DragEvent): void {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
}
handleDragLeave(e: DragEvent): void {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget as Node)) {
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
}
}
async handleDrop(e: DragEvent): Promise<void> {
e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
if (!e.dataTransfer) return;
const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error);
}
} else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
}
}
}
async loadDroppedImageFile(file: File, worldCoords: Point): Promise<void> {
const reader = new FileReader();
reader.onload = async (e) => {
const img = new Image();
img.onload = async () => {
const fitOnAddWidget = this.canvas.node.widgets.find((w: any) => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`);
};
if (e.target?.result) {
img.src = e.target.result as string;
}
};
reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`);
};
reader.readAsDataURL(file);
}
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");
return;
}
log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault();
e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return;
}
const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const file = item.getAsFile();
if (file) {
log.info("Found direct image data in paste event");
const reader = new FileReader();
reader.onload = async (event) => {
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
};
if (event.target?.result) {
img.src = event.target.result as string;
}
};
reader.readAsDataURL(file);
return;
}
}
}
}
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
}
}

963
src/CanvasLayers.ts Normal file
View File

@@ -0,0 +1,963 @@
import {saveImage, removeImage} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
// @ts-ignore
import {app} from "../../scripts/app.js";
// @ts-ignore
import {ComfyApp} from "../../scripts/app.js";
import { ClipboardManager } from "./utils/ClipboardManager.js";
import type { Canvas } from './Canvas';
import type { Layer, Point, AddMode, ClipboardPreference } from './types';
const log = createModuleLogger('CanvasLayers');
interface BlendMode {
name: string;
label: string;
}
export class CanvasLayers {
private canvas: Canvas;
public clipboardManager: ClipboardManager;
private blendModes: BlendMode[];
private selectedBlendMode: string | null;
private blendOpacity: number;
private isAdjustingOpacity: boolean;
public internalClipboard: Layer[];
public clipboardPreference: ClipboardPreference;
constructor(canvas: Canvas) {
this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas as any);
this.blendModes = [
{ name: 'normal', label: 'Normal' },
{name: 'multiply', label: 'Multiply'},
{name: 'screen', label: 'Screen'},
{name: 'overlay', label: 'Overlay'},
{name: 'darken', label: 'Darken'},
{name: 'lighten', label: 'Lighten'},
{name: 'color-dodge', label: 'Color Dodge'},
{name: 'color-burn', label: 'Color Burn'},
{name: 'hard-light', label: 'Hard Light'},
{name: 'soft-light', label: 'Soft Light'},
{name: 'difference', label: 'Difference'},
{ name: 'exclusion', label: 'Exclusion' }
];
this.selectedBlendMode = null;
this.blendOpacity = 100;
this.isAdjustingOpacity = false;
this.internalClipboard = [];
this.clipboardPreference = 'system';
}
async copySelectedLayers(): Promise<void> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => ({ ...layer }));
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
const blob = await this.getFlattenedSelectionAsBlob();
if (!blob) {
log.warn("Failed to create flattened selection blob");
return;
}
if (this.clipboardPreference === 'clipspace') {
try {
const dataURL = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const img = new Image();
img.onload = () => {
if (!this.canvas.node.imgs) {
this.canvas.node.imgs = [];
}
this.canvas.node.imgs[0] = img;
if (ComfyApp.copyToClipspace) {
ComfyApp.copyToClipspace(this.canvas.node);
log.info("Flattened selection copied to ComfyUI Clipspace.");
} else {
log.warn("ComfyUI copyToClipspace not available");
}
};
img.src = dataURL;
} catch (error) {
log.error("Failed to copy image to ComfyUI Clipspace:", error);
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
log.info("Fallback: Flattened selection copied to system clipboard.");
} catch (fallbackError) {
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
}
}
} else {
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
log.info("Flattened selection copied to system clipboard.");
} catch (error) {
log.error("Failed to copy image to system clipboard:", error);
}
}
}
pasteLayers(): void {
if (this.internalClipboard.length === 0) return;
this.canvas.saveState();
const newLayers: Layer[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.internalClipboard.forEach((layer: Layer) => {
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + layer.width);
maxY = Math.max(maxY, layer.y + layer.height);
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition;
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
this.internalClipboard.forEach((clipboardLayer: Layer) => {
const newLayer: Layer = {
...clipboardLayer,
x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + offsetY,
zIndex: this.canvas.layers.length
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvas.updateSelection(newLayers);
this.canvas.render();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
}
async handlePaste(addMode: AddMode = 'mouse'): Promise<void> {
try {
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
} catch (err) {
log.error("Paste operation failed:", err);
}
}
addLayerWithImage = withErrorHandling(async (image: HTMLImageElement, layerProps: Partial<Layer> = {}, addMode: AddMode = 'default', targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise<Layer> => {
if (!image) {
throw createValidationError("Image is required for layer creation");
}
log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea);
const imageId = generateUUID();
await saveImage(imageId, image.src);
this.canvas.imageCache.set(imageId, image.src);
let finalWidth = image.width;
let finalHeight = image.height;
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') {
const scale = Math.min(area.width / image.width, area.height / image.height);
finalWidth = image.width * scale;
finalHeight = image.height * scale;
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
} else if (addMode === 'mouse') {
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
} else {
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
}
const layer: Layer = {
id: generateUUID(),
image: image,
imageId: imageId,
name: 'Layer',
x: finalX,
y: finalY,
width: finalWidth,
height: finalHeight,
originalWidth: image.width,
originalHeight: image.height,
rotation: 0,
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
...layerProps
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
this.canvas.saveState();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layer added successfully");
return layer;
}, 'CanvasLayers.addLayerWithImage');
async addLayer(image: HTMLImageElement): Promise<Layer> {
return this.addLayerWithImage(image);
}
moveLayers(layersToMove: Layer[], options: { direction?: 'up' | 'down', toIndex?: number } = {}): void {
if (!layersToMove || layersToMove.length === 0) return;
let finalLayers: Layer[];
if (options.direction) {
const allLayers = [...this.canvas.layers];
const selectedIndices = new Set(layersToMove.map((l: Layer) => allLayers.indexOf(l)));
if (options.direction === 'up') {
const sorted = Array.from(selectedIndices).sort((a, b) => b - a);
sorted.forEach((index: number) => {
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: number) => {
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) {
const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const reorderedFinal: Layer[] = [];
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;
}
const totalLayers = finalLayers.length;
finalLayers.forEach((layer, index) => {
const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index;
layer.zIndex = zIndex;
});
this.canvas.layers = finalLayers;
this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
this.canvas.requestSaveState();
log.info(`Moved ${layersToMove.length} layer(s).`);
}
moveLayerUp(): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' });
}
moveLayerDown(): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' });
}
resizeLayer(scale: number): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.width *= scale;
layer.height *= scale;
});
this.canvas.render();
this.canvas.requestSaveState();
}
rotateLayer(angle: number): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.rotation += angle;
});
this.canvas.render();
this.canvas.requestSaveState();
}
getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null {
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const dx = worldX - centerX;
const dy = worldY - centerY;
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) {
return {
layer: layer,
localX: rotatedX + layer.width / 2,
localY: rotatedY + layer.height / 2
};
}
}
return null;
}
private _drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, options: { offsetX?: number, offsetY?: number } = {}): void {
if (!layer.image) return;
const { offsetX = 0, offsetY = 0 } = options;
ctx.save();
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2 - offsetX;
const centerY = layer.y + layer.height / 2 - offsetY;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
layer.image,
-layer.width / 2, -layer.height / 2,
layer.width, layer.height
);
ctx.restore();
}
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
}
public drawLayersToContext(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
this._drawLayers(ctx, layers, options);
}
async mirrorHorizontal(): Promise<void> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.flipH = !layer.flipH;
});
this.canvas.render();
this.canvas.requestSaveState();
}
async mirrorVertical(): Promise<void> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.flipV = !layer.flipV;
});
this.canvas.render();
this.canvas.requestSaveState();
}
async getLayerImageData(layer: Layer): Promise<string> {
try {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) throw new Error("Could not create canvas context");
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
// by creating a temporary layer object for drawing.
const layerToDraw = {
...layer,
x: 0,
y: 0,
};
this._drawLayer(tempCtx, layerToDraw);
const dataUrl = tempCanvas.toDataURL('image/png');
if (!dataUrl.startsWith('data:image/png;base64,')) {
throw new Error("Invalid image data format");
}
return dataUrl;
} catch (error) {
log.error("Error getting layer image data:", error);
throw error;
}
}
updateOutputAreaSize(width: number, height: number, saveHistory = true): void {
if (saveHistory) {
this.canvas.saveState();
}
this.canvas.width = width;
this.canvas.height = height;
this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width;
this.canvas.canvas.height = height;
this.canvas.render();
if (saveHistory) {
this.canvas.canvasState.saveStateToDB();
}
}
getHandles(layer: Layer): Record<string, Point> {
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 localHandles: Record<string, Point> = {
'n': { x: 0, y: -halfH },
'ne': { x: halfW, y: -halfH },
'e': { x: halfW, y: 0 },
'se': { x: halfW, y: halfH },
's': { x: 0, y: halfH },
'sw': { x: -halfW, y: halfH },
'w': { x: -halfW, y: 0 },
'nw': { x: -halfW, y: -halfH },
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
};
const worldHandles: Record<string, Point> = {};
for (const key in localHandles) {
const p = localHandles[key];
worldHandles[key] = {
x: centerX + (p.x * cos - p.y * sin),
y: centerY + (p.x * sin + p.y * cos)
};
}
return worldHandles;
}
getHandleAtPosition(worldX: number, worldY: number): { layer: Layer, handle: string } | null {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return null;
const handleRadius = 8 / this.canvas.viewport.zoom;
for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvas.canvasSelection.selectedLayers[i];
const handles = this.getHandles(layer);
for (const key in handles) {
const handlePos = handles[key];
const dx = worldX - handlePos.x;
const dy = worldY - handlePos.y;
if (dx * dx + dy * dy <= handleRadius * handleRadius) {
return { layer: layer, handle: key };
}
}
}
return null;
}
showBlendModeMenu(x: number, y: number): void {
this.closeBlendModeMenu();
const menu = document.createElement('div');
menu.id = 'blend-mode-menu';
menu.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
`;
const titleBar = document.createElement('div');
titleBar.style.cssText = `
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
`;
titleBar.textContent = 'Blend Mode';
const content = document.createElement('div');
content.style.cssText = `padding: 5px;`;
menu.appendChild(titleBar);
menu.appendChild(content);
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
const maxX = window.innerWidth - menu.offsetWidth;
const maxY = window.innerHeight - menu.offsetHeight;
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
}
};
const handleMouseUp = () => {
if (isDragging) {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
};
titleBar.addEventListener('mousedown', (e: MouseEvent) => {
isDragging = true;
dragOffset.x = e.clientX - parseInt(menu.style.left, 10);
dragOffset.y = e.clientY - parseInt(menu.style.top, 10);
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
this.blendModes.forEach((mode: BlendMode) => {
const container = document.createElement('div');
container.className = 'blend-mode-container';
container.style.cssText = `margin-bottom: 5px;`;
const option = document.createElement('div');
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
option.textContent = `${mode.label} (${mode.name})`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
if (selectedLayer && selectedLayer.blendMode === mode.name) {
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
}
option.onclick = () => {
content.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach(s => s.style.display = 'none');
content.querySelectorAll<HTMLDivElement>('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
if (selectedLayer) {
selectedLayer.blendMode = mode.name;
this.canvas.render();
}
};
slider.addEventListener('input', () => {
if (selectedLayer) {
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
this.canvas.render();
}
});
slider.addEventListener('change', async () => {
if (selectedLayer) {
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
this.canvas.render();
const saveWithFallback = async (fileName: string) => {
try {
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
return await this.canvas.canvasIO.saveToServer(uniqueFileName);
} catch (error) {
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
return await this.canvas.canvasIO.saveToServer(fileName);
}
};
if (this.canvas.widget) {
await saveWithFallback(this.canvas.widget.value);
if (this.canvas.node) {
app.graph.runStep();
}
}
}
});
container.appendChild(option);
container.appendChild(slider);
content.appendChild(container);
});
const container = this.canvas.canvas.parentElement || document.body;
container.appendChild(menu);
const closeMenu = (e: MouseEvent) => {
if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) {
this.closeBlendModeMenu();
document.removeEventListener('mousedown', closeMenu);
}
};
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
}
closeBlendModeMenu(): void {
const menu = document.getElementById('blend-mode-menu');
if (menu && menu.parentNode) {
menu.parentNode.removeChild(menu);
}
}
showOpacitySlider(mode: string): void {
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = String(this.blendOpacity);
slider.className = 'blend-opacity-slider';
slider.addEventListener('input', (e) => {
this.blendOpacity = parseInt((e.target as HTMLInputElement).value, 10);
});
const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`);
if (modeElement) {
modeElement.appendChild(slider);
}
}
async getFlattenedCanvasWithMaskAsBlob(): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d');
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
resolve(null);
}
}, 'image/png');
});
}
async getFlattenedCanvasAsBlob(): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
resolve(null);
}
}, 'image/png');
});
}
async getFlattenedCanvasForMaskEditor(): Promise<Blob | null> {
return this.getFlattenedCanvasWithMaskAsBlob();
}
async getFlattenedSelectionAsBlob(): Promise<Blob | null> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null;
}
return new Promise((resolve, reject) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.selectedLayers.forEach((layer: 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 newWidth = Math.ceil(maxX - minX);
const newHeight = Math.ceil(maxY - minY);
if (newWidth <= 0 || newHeight <= 0) {
resolve(null);
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
tempCanvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
async fuseLayers(): Promise<void> {
if (this.canvas.canvasSelection.selectedLayers.length < 2) {
alert("Please select at least 2 layers to fuse.");
return;
}
log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
try {
this.canvas.saveState();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.selectedLayers.forEach((layer: 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;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = fusedWidth;
tempCanvas.height = fusedHeight;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) throw new Error("Could not create canvas context");
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
const fusedImage = new Image();
fusedImage.src = tempCanvas.toDataURL();
await new Promise((resolve, reject) => {
fusedImage.onload = resolve;
fusedImage.onerror = reject;
});
const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => layer.zIndex));
const imageId = generateUUID();
await saveImage(imageId, fusedImage.src);
this.canvas.imageCache.set(imageId, fusedImage.src);
const fusedLayer: Layer = {
id: generateUUID(),
image: fusedImage,
imageId: imageId,
name: 'Fused Layer',
x: minX,
y: minY,
width: fusedWidth,
height: fusedHeight,
originalWidth: fusedWidth,
originalHeight: fusedHeight,
rotation: 0,
zIndex: minZIndex,
blendMode: 'normal',
opacity: 1
};
this.canvas.layers = this.canvas.layers.filter((layer: Layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));
this.canvas.layers.push(fusedLayer);
this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
this.canvas.layers.forEach((layer: Layer, index: number) => {
layer.zIndex = index;
});
this.canvas.updateSelection([fusedLayer]);
this.canvas.render();
this.canvas.saveState();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layers fused successfully", {
originalLayerCount: this.canvas.canvasSelection.selectedLayers.length,
fusedDimensions: { width: fusedWidth, height: fusedHeight },
fusedPosition: { x: minX, y: minY }
});
} catch (error: any) {
log.error("Error during layer fusion:", error);
alert(`Error fusing layers: ${error.message}`);
}
}
}

613
src/CanvasLayersPanel.ts Normal file
View File

@@ -0,0 +1,613 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import type { Canvas } from './Canvas';
import type { Layer } from './types';
const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel {
private canvas: Canvas;
private container: HTMLElement | null;
private layersContainer: HTMLElement | null;
private draggedElements: Layer[];
private dragInsertionLine: HTMLElement | null;
private isMultiSelecting: boolean;
private lastSelectedIndex: number;
constructor(canvas: Canvas) {
this.canvas = canvas;
this.container = null;
this.layersContainer = null;
this.draggedElements = [];
this.dragInsertionLine = null;
this.isMultiSelecting = false;
this.lastSelectedIndex = -1;
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');
}
createPanelStructure(): HTMLElement {
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<HTMLElement>('#layers-container');
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: KeyboardEvent) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
this.deleteSelectedLayers();
}
});
log.debug('Panel structure created');
return this.container;
}
injectStyles(): void {
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');
}
setupControlButtons(): void {
if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
});
}
renderLayers(): void {
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: Layer, b: Layer) => b.zIndex - a.zIndex);
sortedLayers.forEach((layer: Layer, index: number) => {
const layerElement = this.createLayerElement(layer, index);
if(this.layersContainer)
this.layersContainer.appendChild(layerElement);
});
log.debug(`Rendered ${sortedLayers.length} layers`);
}
createLayerElement(layer: Layer, index: number): HTMLElement {
const layerRow = document.createElement('div');
layerRow.className = 'layer-row';
layerRow.draggable = true;
layerRow.dataset.layerIndex = String(index);
const isSelected = this.canvas.canvasSelection.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>
`;
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail');
if (thumbnailContainer) {
this.generateThumbnail(layer, thumbnailContainer);
}
this.setupLayerEventListeners(layerRow, layer, index);
return layerRow;
}
generateThumbnail(layer: Layer, thumbnailContainer: HTMLElement): void {
if (!layer.image) {
thumbnailContainer.style.background = '#4a4a4a';
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
canvas.width = 48;
canvas.height = 48;
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;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
thumbnailContainer.appendChild(canvas);
}
setupLayerEventListeners(layerRow: HTMLElement, layer: Layer, index: number): void {
layerRow.addEventListener('mousedown', (e: MouseEvent) => {
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
if (nameElement && nameElement.classList.contains('editing')) {
return;
}
this.handleLayerClick(e, layer, index);
});
layerRow.addEventListener('dblclick', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
if (nameElement) {
this.startEditingLayerName(nameElement, layer);
}
});
layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
layerRow.addEventListener('drop', (e: DragEvent) => this.handleDrop(e, index));
}
handleLayerClick(e: MouseEvent, layer: Layer, index: number): void {
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.canvasSelection.selectedLayers.length}`);
}
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
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;
}
});
}
ensureUniqueName(proposedName: string, currentLayer: Layer): string {
const existingNames = this.canvas.layers
.filter((layer: Layer) => layer !== currentLayer)
.map((layer: 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;
}
deleteSelectedLayers(): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion');
return;
}
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers();
this.renderLayers();
}
handleDragStart(e: DragEvent, layer: Layer, index: number): void {
if (!this.layersContainer || !e.dataTransfer) return;
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.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]);
this.renderLayers();
}
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element, idx: number) => {
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
if (this.draggedElements.includes(sortedLayers[idx])) {
row.classList.add('dragging');
}
});
log.debug(`Started dragging ${this.draggedElements.length} layers`);
}
handleDragOver(e: DragEvent): void {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const layerRow = e.currentTarget as HTMLElement;
const rect = layerRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint;
this.showDragInsertionLine(layerRow, isUpperHalf);
}
showDragInsertionLine(targetRow: HTMLElement, isUpperHalf: boolean): void {
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;
}
removeDragInsertionLine(): void {
if (this.dragInsertionLine) {
this.dragInsertionLine.remove();
this.dragInsertionLine = null;
}
}
handleDrop(e: DragEvent, targetIndex: number): void {
e.preventDefault();
this.removeDragInsertionLine();
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement)) 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}`);
}
handleDragEnd(e: DragEvent): void {
this.removeDragInsertionLine();
if (!this.layersContainer) return;
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element) => {
row.classList.remove('dragging');
});
this.draggedElements = [];
}
onLayersChanged(): void {
this.renderLayers();
}
updateSelectionAppearance(): void {
if (!this.layersContainer) return;
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
layerRows.forEach((row: Element, index: number) => {
const layer = sortedLayers[index];
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
}
/**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/
onSelectionChanged(): void {
this.updateSelectionAppearance();
}
destroy(): void {
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');
}
}

564
src/CanvasMask.ts Normal file
View File

@@ -0,0 +1,564 @@
// @ts-ignore
import {app} from "../../scripts/app.js";
// @ts-ignore
import {ComfyApp} from "../../scripts/app.js";
// @ts-ignore
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 {
canvas: any;
editorWasShowing: any;
maskEditorCancelled: any;
maskTool: any;
node: any;
pendingMask: any;
savedMaskState: any;
constructor(canvas: any) {
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: any = 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 as 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') as HTMLCanvasElement;
if (maskCanvas) {
editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 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: any) {
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: any) {
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: any) {
const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement;
if (!maskCanvas) {
throw new Error("Old mask editor canvas not found");
}
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
if (!maskCtx) {
throw new Error("Old mask editor context not found");
}
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: any, targetWidth: any, targetHeight: any, maskColor: any) {
// 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;
if (tempCtx) {
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
if (tempCtx) {
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});
if (savedCtx) {
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: any) {
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});
if (tempCtx) {
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");
}
}

376
src/CanvasRenderer.ts Normal file
View File

@@ -0,0 +1,376 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasRenderer');
export class CanvasRenderer {
canvas: any;
isDirty: any;
lastRenderTime: any;
renderAnimationFrame: any;
renderInterval: any;
constructor(canvas: any) {
this.canvas = canvas;
this.renderAnimationFrame = null;
this.lastRenderTime = 0;
this.renderInterval = 1000 / 60;
this.isDirty = false;
}
render() {
if (this.renderAnimationFrame) {
this.isDirty = true;
return;
}
this.renderAnimationFrame = requestAnimationFrame(() => {
const now = performance.now();
if (now - this.lastRenderTime >= this.renderInterval) {
this.lastRenderTime = now;
this.actualRender();
this.isDirty = false;
}
if (this.isDirty) {
this.renderAnimationFrame = null;
this.render();
} else {
this.renderAnimationFrame = null;
}
});
}
actualRender() {
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
const newWidth = Math.max(1, this.canvas.canvas.clientWidth);
const newHeight = Math.max(1, this.canvas.canvas.clientHeight);
this.canvas.offscreenCanvas.width = newWidth;
this.canvas.offscreenCanvas.height = newHeight;
}
const ctx = this.canvas.offscreenCtx;
ctx.fillStyle = '#606060';
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
ctx.save();
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
this.drawGrid(ctx);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
ctx.save();
const currentTransform = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.setTransform(currentTransform);
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
layer.image, -layer.width / 2, -layer.height / 2,
layer.width,
layer.height
);
if (layer.mask) {
}
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.drawSelectionFrame(ctx, layer);
}
ctx.restore();
});
this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save();
if (this.canvas.maskTool.isActive) {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5;
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
}
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
ctx.globalAlpha = 1.0;
ctx.restore();
}
this.renderInteractionElements(ctx);
this.renderLayerInfo(ctx);
ctx.restore();
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
}
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: any) => {
manager.updateScreenPosition(this.canvas.viewport);
});
}
}
renderInteractionElements(ctx: any) {
const interaction = this.canvas.interaction;
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
if (rect.width > 0 && rect.height > 0) {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 22;
ctx.fillStyle = "rgba(0, 128, 0, 0.7)";
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
ctx.fillStyle = "white";
ctx.fillText(text, screenX, screenY);
ctx.restore();
}
}
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect;
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]);
ctx.restore();
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 22;
ctx.fillStyle = "rgba(0, 100, 170, 0.7)";
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
ctx.fillStyle = "white";
ctx.fillText(text, screenX, screenY);
ctx.restore();
}
}
renderLayerInfo(ctx: any) {
if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.selectedLayers.forEach((layer: any) => {
if (!layer.image) return;
const layerIndex = this.canvas.layers.indexOf(layer);
const currentWidth = Math.round(layer.width);
const currentHeight = Math.round(layer.height);
const rotation = Math.round(layer.rotation % 360);
let text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`;
if (layer.originalWidth && layer.originalHeight) {
text += `\nOriginal: ${layer.originalWidth}x${layer.originalHeight}`;
}
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 localCorners = [
{x: -halfW, y: -halfH},
{x: halfW, y: -halfH},
{x: halfW, y: halfH},
{x: -halfW, y: halfH}
];
const worldCorners = localCorners.map(p => ({
x: centerX + p.x * cos - p.y * sin,
y: centerY + p.x * sin + p.y * cos
}));
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
worldCorners.forEach(p => {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
});
const padding = 20 / this.canvas.viewport.zoom;
const textWorldX = (minX + maxX) / 2;
const textWorldY = maxY + padding;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const lines = text.split('\n');
const textMetrics = lines.map(line => ctx.measureText(line));
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
const lineHeight = 18;
const textBgHeight = lines.length * lineHeight + 4;
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
ctx.fillStyle = "white";
lines.forEach((line, index) => {
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
ctx.fillText(line, screenX, yPos);
});
ctx.restore();
});
}
}
drawGrid(ctx: any) {
const gridSize = 64;
const lineWidth = 0.5 / this.canvas.viewport.zoom;
const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y;
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
ctx.beginPath();
ctx.strokeStyle = '#707070';
ctx.lineWidth = lineWidth;
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
ctx.moveTo(x, viewTop);
ctx.lineTo(x, viewBottom);
}
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
ctx.moveTo(viewLeft, y);
ctx.lineTo(viewRight, y);
}
ctx.stroke();
}
drawCanvasOutline(ctx: any) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.rect(0, 0, this.canvas.width, this.canvas.height);
ctx.stroke();
ctx.setLineDash([]);
}
drawSelectionFrame(ctx: any, layer: any) {
const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.rect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke();
const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const key in handles) {
const point = handles[key];
ctx.beginPath();
const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2);
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
drawPendingGenerationAreas(ctx: any) {
const areasToDraw = [];
// 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager: any) => {
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();
});
}
}

170
src/CanvasSelection.ts Normal file
View File

@@ -0,0 +1,170 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasSelection');
export class CanvasSelection {
canvas: any;
onSelectionChange: any;
selectedLayer: any;
selectedLayers: any;
constructor(canvas: any) {
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: any = [];
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: any) {
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: any, i: any) => 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: any) => 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: any, isCtrlPressed: any, isShiftPressed: any, index: any) {
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: any) => !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: any = [];
if (this.selectedLayers) {
this.selectedLayers.forEach((sl: any) => {
const found = this.canvas.layers.find((l: any) => l.id === sl.id);
if (found) newSelectedLayers.push(found);
});
}
this.updateSelection(newSelectedLayers);
}
}

498
src/CanvasState.ts Normal file
View File

@@ -0,0 +1,498 @@
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js";
import {withErrorHandling} from "./ErrorHandler.js";
import type { Canvas } from './Canvas';
import type { Layer, ComfyNode } from './types';
const log = createModuleLogger('CanvasState');
interface HistoryInfo {
undoCount: number;
redoCount: number;
canUndo: boolean;
canRedo: boolean;
historyLimit: number;
}
export class CanvasState {
private _debouncedSave: (() => void) | null;
private _loadInProgress: Promise<boolean> | null;
private canvas: Canvas & { node: ComfyNode, layers: Layer[] };
private historyLimit: number;
private lastSavedStateSignature: string | null;
public layersRedoStack: Layer[][];
public layersUndoStack: Layer[][];
public maskRedoStack: HTMLCanvasElement[];
public maskUndoStack: HTMLCanvasElement[];
private saveTimeout: number | null;
private stateSaverWorker: Worker | null;
constructor(canvas: Canvas & { node: ComfyNode, layers: Layer[] }) {
this.canvas = canvas;
this.layersUndoStack = [];
this.layersRedoStack = [];
this.maskUndoStack = [];
this.maskRedoStack = [];
this.historyLimit = 100;
this.saveTimeout = null;
this.lastSavedStateSignature = null;
this._loadInProgress = null;
this._debouncedSave = null;
try {
// @ts-ignore
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: MessageEvent) => {
log.info("Message from state saver worker:", e.data);
};
this.stateSaverWorker.onerror = (e: ErrorEvent) => {
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
this.stateSaverWorker = null;
};
} catch (e) {
log.error("Failed to initialize state saver worker:", e);
this.stateSaverWorker = null;
}
}
async loadStateFromDB(): Promise<boolean> {
if (this._loadInProgress) {
log.warn("Load already in progress, waiting...");
return this._loadInProgress;
}
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
const loadPromise = this._performLoad();
this._loadInProgress = loadPromise;
try {
const result = await loadPromise;
this._loadInProgress = null;
return result;
} catch (error) {
this._loadInProgress = null;
throw error;
}
}
async _performLoad(): Promise<boolean> {
try {
if (!this.canvas.node.id) {
log.error("Node ID is not available for loading state from DB.");
return false;
}
const savedState = await getCanvasState(String(this.canvas.node.id));
if (!savedState) {
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
return false;
}
log.info("Found saved state in IndexedDB.");
this.canvas.width = savedState.width || 512;
this.canvas.height = savedState.height || 512;
this.canvas.viewport = savedState.viewport || {
x: -(this.canvas.width / 4),
y: -(this.canvas.height / 4),
zoom: 0.8
};
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`);
if (this.canvas.layers.length === 0) {
log.warn("No valid layers loaded, state may be corrupted.");
return false;
}
this.canvas.updateSelectionAfterHistory();
this.canvas.render();
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
return true;
} catch (error) {
log.error("Error during state load:", error);
return false;
}
}
/**
* Ładuje warstwy z zapisanego stanu
* @param {any[]} layersData - Dane warstw do załadowania
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
*/
async _loadLayers(layersData: any[]): Promise<(Layer | null)[]> {
const imagePromises = layersData.map((layerData: any, index: number) =>
this._loadSingleLayer(layerData, index)
);
return Promise.all(imagePromises);
}
/**
* Ładuje pojedynczą warstwę
* @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
*/
async _loadSingleLayer(layerData: Layer, index: number): Promise<Layer | null> {
return new Promise((resolve) => {
if (layerData.imageId) {
this._loadLayerFromImageId(layerData, index, resolve);
} else if ((layerData as any).imageSrc) {
this._convertLegacyLayer(layerData, index, resolve);
} else {
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
resolve(null);
}
});
}
/**
* Ładuje warstwę z imageId
* @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/
_loadLayerFromImageId(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
if (this.canvas.imageCache.has(layerData.imageId)) {
log.debug(`Layer ${index}: Image found in cache.`);
const imageData = this.canvas.imageCache.get(layerData.imageId);
if (imageData) {
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
} else {
resolve(null);
}
} else {
getImage(layerData.imageId)
.then(imageSrc => {
if (imageSrc) {
log.debug(`Layer ${index}: Loading image from data:URL...`);
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
} else {
log.error(`Layer ${index}: Image not found in IndexedDB.`);
resolve(null);
}
})
.catch(err => {
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
resolve(null);
});
}
}
/**
* Konwertuje starą warstwę z imageSrc na nowy format
* @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/
_convertLegacyLayer(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
const imageId = generateUUID();
saveImage(imageId, (layerData as any).imageSrc)
.then(() => {
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
const newLayerData = {...layerData, imageId};
delete (newLayerData as any).imageSrc;
this._createLayerFromSrc(newLayerData, (layerData as any).imageSrc, index, resolve);
})
.catch(err => {
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
resolve(null);
});
}
/**
* Tworzy warstwę z src obrazu
* @param {any} layerData - Dane warstwy
* @param {string} imageSrc - Źródło obrazu
* @param {number} index - Indeks warstwy
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
if (typeof imageSrc === 'string') {
const img = new Image();
img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully.`);
const newLayer: Layer = {...layerData, image: img};
resolve(newLayer);
};
img.onerror = () => {
log.error(`Layer ${index}: Failed to load image from src.`);
resolve(null);
};
img.src = imageSrc;
} else {
const canvas = document.createElement('canvas');
canvas.width = imageSrc.width;
canvas.height = imageSrc.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(imageSrc, 0, 0);
const img = new Image();
img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer: Layer = {...layerData, image: img};
resolve(newLayer);
};
img.onerror = () => {
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
resolve(null);
};
img.src = canvas.toDataURL();
} else {
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
resolve(null);
}
}
}
async saveStateToDB(): Promise<void> {
if (!this.canvas.node.id) {
log.error("Node ID is not available for saving state to DB.");
return;
}
log.info("Preparing state to be sent to worker...");
const layers = await this._prepareLayers();
const state = {
layers: layers.filter(layer => layer !== null),
viewport: this.canvas.viewport,
width: this.canvas.width,
height: this.canvas.height,
};
if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping.");
return;
}
if (this.stateSaverWorker) {
log.info("Posting state to worker for background saving.");
this.stateSaverWorker.postMessage({
nodeId: String(this.canvas.node.id),
state: state
});
this.canvas.render();
} else {
log.warn("State saver worker not available. Saving on main thread.");
await setCanvasState(String(this.canvas.node.id), state);
}
}
/**
* Przygotowuje warstwy do zapisu
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
*/
async _prepareLayers(): Promise<(Omit<Layer, 'image'> & { imageId: string })[]> {
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
delete (newLayer as any).image;
if (layer.image instanceof HTMLImageElement) {
if (layer.imageId) {
newLayer.imageId = layer.imageId;
} else {
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
newLayer.imageId = generateUUID();
const imageBitmap = await createImageBitmap(layer.image);
await saveImage(newLayer.imageId, imageBitmap);
}
} else if (!layer.imageId) {
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
return null;
}
return newLayer;
}));
return preparedLayers.filter((layer): layer is Omit<Layer, 'image'> & { imageId: string } => layer !== null);
}
saveState(replaceLast = false): void {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.saveMaskState(replaceLast);
} else {
this.saveLayersState(replaceLast);
}
}
saveLayersState(replaceLast = false): void {
if (replaceLast && this.layersUndoStack.length > 0) {
this.layersUndoStack.pop();
}
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(lastState) === currentStateSignature) {
return;
}
}
this.layersUndoStack.push(currentState);
if (this.layersUndoStack.length > this.historyLimit) {
this.layersUndoStack.shift();
}
this.layersRedoStack = [];
this.canvas.updateHistoryButtons();
if (!this._debouncedSave) {
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
}
this._debouncedSave();
}
saveMaskState(replaceLast = false): void {
if (!this.canvas.maskTool) return;
if (replaceLast && this.maskUndoStack.length > 0) {
this.maskUndoStack.pop();
}
const maskCanvas = this.canvas.maskTool.getMask();
const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
if (clonedCtx) {
clonedCtx.drawImage(maskCanvas, 0, 0);
}
this.maskUndoStack.push(clonedCanvas);
if (this.maskUndoStack.length > this.historyLimit) {
this.maskUndoStack.shift();
}
this.maskRedoStack = [];
this.canvas.updateHistoryButtons();
}
undo(): void {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.undoMaskState();
} else {
this.undoLayersState();
}
}
redo(): void {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.redoMaskState();
} else {
this.redoLayersState();
}
}
undoLayersState(): void {
if (this.layersUndoStack.length <= 1) return;
const currentState = this.layersUndoStack.pop();
if (currentState) {
this.layersRedoStack.push(currentState);
}
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
this.canvas.layers = cloneLayers(prevState);
this.canvas.updateSelectionAfterHistory();
this.canvas.render();
this.canvas.updateHistoryButtons();
}
redoLayersState(): void {
if (this.layersRedoStack.length === 0) return;
const nextState = this.layersRedoStack.pop();
if (nextState) {
this.layersUndoStack.push(nextState);
this.canvas.layers = cloneLayers(nextState);
this.canvas.updateSelectionAfterHistory();
this.canvas.render();
this.canvas.updateHistoryButtons();
}
}
undoMaskState(): void {
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return;
const currentState = this.maskUndoStack.pop();
if (currentState) {
this.maskRedoStack.push(currentState);
}
if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
}
this.canvas.render();
}
this.canvas.updateHistoryButtons();
}
redoMaskState(): void {
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return;
const nextState = this.maskRedoStack.pop();
if (nextState) {
this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0);
}
this.canvas.render();
}
this.canvas.updateHistoryButtons();
}
/**
* Czyści historię undo/redo
*/
clearHistory(): void {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.maskUndoStack = [];
this.maskRedoStack = [];
} else {
this.layersUndoStack = [];
this.layersRedoStack = [];
}
this.canvas.updateHistoryButtons();
log.info("History cleared");
}
/**
* Zwraca informacje o historii
* @returns {HistoryInfo} Informacje o historii
*/
getHistoryInfo(): HistoryInfo {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
return {
undoCount: this.maskUndoStack.length,
redoCount: this.maskRedoStack.length,
canUndo: this.maskUndoStack.length > 1,
canRedo: this.maskRedoStack.length > 0,
historyLimit: this.historyLimit
};
} else {
return {
undoCount: this.layersUndoStack.length,
redoCount: this.layersRedoStack.length,
canUndo: this.layersUndoStack.length > 1,
canRedo: this.layersRedoStack.length > 0,
historyLimit: this.historyLimit
};
}
}
}

1044
src/CanvasView.ts Normal file

File diff suppressed because it is too large Load Diff

383
src/ErrorHandler.ts Normal file
View File

@@ -0,0 +1,383 @@
/**
* ErrorHandler - Centralna obsługa błędów
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
*/
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('ErrorHandler');
/**
* Typy błędów w aplikacji
*/
export const ErrorTypes = {
VALIDATION: 'VALIDATION_ERROR',
NETWORK: 'NETWORK_ERROR',
FILE_IO: 'FILE_IO_ERROR',
CANVAS: 'CANVAS_ERROR',
IMAGE_PROCESSING: 'IMAGE_PROCESSING_ERROR',
STATE_MANAGEMENT: 'STATE_MANAGEMENT_ERROR',
USER_INPUT: 'USER_INPUT_ERROR',
SYSTEM: 'SYSTEM_ERROR'
} as const;
export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes];
interface ErrorHistoryEntry {
timestamp: string;
type: ErrorType;
message: string;
context?: string;
}
interface ErrorStats {
totalErrors: number;
errorCounts: { [key: string]: number };
recentErrors: ErrorHistoryEntry[];
errorsByType: { [key: string]: ErrorHistoryEntry[] };
}
/**
* Klasa błędu aplikacji z dodatkowymi informacjami
*/
export class AppError extends Error {
details: any;
originalError: Error | null;
timestamp: string;
type: ErrorType;
constructor(message: string, type: ErrorType = ErrorTypes.SYSTEM, details: any = null, originalError: Error | null = null) {
super(message);
this.name = 'AppError';
this.type = type;
this.details = details;
this.originalError = originalError;
this.timestamp = new Date().toISOString();
if ((Error as any).captureStackTrace) {
(Error as any).captureStackTrace(this, AppError);
}
}
}
/**
* Handler błędów z automatycznym logowaniem i kategoryzacją
*/
export class ErrorHandler {
private errorCounts: Map<ErrorType, number>;
private errorHistory: ErrorHistoryEntry[];
private maxHistorySize: number;
constructor() {
this.errorCounts = new Map();
this.errorHistory = [];
this.maxHistorySize = 100;
}
/**
* Obsługuje błąd z automatycznym logowaniem
* @param {Error | AppError | string} error - Błąd do obsłużenia
* @param {string} context - Kontekst wystąpienia błędu
* @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd
*/
handle(error: Error | AppError | string, context = 'Unknown', additionalInfo: object = {}): AppError {
const normalizedError = this.normalizeError(error, context, additionalInfo);
this.logError(normalizedError, context);
this.recordError(normalizedError);
this.incrementErrorCount(normalizedError.type);
return normalizedError;
}
/**
* Normalizuje błąd do standardowego formatu
* @param {Error | AppError | string} error - Błąd do znormalizowania
* @param {string} context - Kontekst
* @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd
*/
normalizeError(error: Error | AppError | string, context: string, additionalInfo: object): AppError {
if (error instanceof AppError) {
return error;
}
if (error instanceof Error) {
const type = this.categorizeError(error, context);
return new AppError(
error.message,
type,
{context, ...additionalInfo},
error
);
}
if (typeof error === 'string') {
return new AppError(
error,
ErrorTypes.SYSTEM,
{context, ...additionalInfo}
);
}
return new AppError(
'Unknown error occurred',
ErrorTypes.SYSTEM,
{context, originalError: error, ...additionalInfo}
);
}
/**
* Kategoryzuje błąd na podstawie wiadomości i kontekstu
* @param {Error} error - Błąd do skategoryzowania
* @param {string} context - Kontekst
* @returns {ErrorType} Typ błędu
*/
categorizeError(error: Error, context: string): ErrorType {
const message = error.message.toLowerCase();
if (message.includes('fetch') || message.includes('network') ||
message.includes('connection') || message.includes('timeout')) {
return ErrorTypes.NETWORK;
}
if (message.includes('file') || message.includes('read') ||
message.includes('write') || message.includes('path')) {
return ErrorTypes.FILE_IO;
}
if (message.includes('invalid') || message.includes('required') ||
message.includes('validation') || message.includes('format')) {
return ErrorTypes.VALIDATION;
}
if (message.includes('image') || message.includes('canvas') ||
message.includes('blob') || message.includes('tensor')) {
return ErrorTypes.IMAGE_PROCESSING;
}
if (message.includes('state') || message.includes('cache') ||
message.includes('storage')) {
return ErrorTypes.STATE_MANAGEMENT;
}
if (context.toLowerCase().includes('canvas')) {
return ErrorTypes.CANVAS;
}
return ErrorTypes.SYSTEM;
}
/**
* Loguje błąd z odpowiednim poziomem
* @param {AppError} error - Błąd do zalogowania
* @param {string} context - Kontekst
*/
logError(error: AppError, context: string): void {
const logMessage = `[${error.type}] ${error.message}`;
const logDetails = {
context,
timestamp: error.timestamp,
details: error.details,
stack: error.stack
};
switch (error.type) {
case ErrorTypes.VALIDATION:
case ErrorTypes.USER_INPUT:
log.warn(logMessage, logDetails);
break;
case ErrorTypes.NETWORK:
log.error(logMessage, logDetails);
break;
default:
log.error(logMessage, logDetails);
}
}
/**
* Zapisuje błąd w historii
* @param {AppError} error - Błąd do zapisania
*/
recordError(error: AppError): void {
this.errorHistory.push({
timestamp: error.timestamp,
type: error.type,
message: error.message,
context: error.details?.context
});
if (this.errorHistory.length > this.maxHistorySize) {
this.errorHistory.shift();
}
}
/**
* Zwiększa licznik błędów dla danego typu
* @param {ErrorType} errorType - Typ błędu
*/
incrementErrorCount(errorType: ErrorType): void {
const current = this.errorCounts.get(errorType) || 0;
this.errorCounts.set(errorType, current + 1);
}
/**
* Zwraca statystyki błędów
* @returns {ErrorStats} Statystyki błędów
*/
getErrorStats(): ErrorStats {
const errorCountsObj: { [key: string]: number } = {};
for (const [key, value] of this.errorCounts.entries()) {
errorCountsObj[key] = value;
}
return {
totalErrors: this.errorHistory.length,
errorCounts: errorCountsObj,
recentErrors: this.errorHistory.slice(-10),
errorsByType: this.groupErrorsByType()
};
}
/**
* Grupuje błędy według typu
* @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
*/
groupErrorsByType(): { [key: string]: ErrorHistoryEntry[] } {
const grouped: { [key: string]: ErrorHistoryEntry[] } = {};
this.errorHistory.forEach((error) => {
if (!grouped[error.type]) {
grouped[error.type] = [];
}
grouped[error.type].push(error);
});
return grouped;
}
/**
* Czyści historię błędów
*/
clearHistory(): void {
this.errorHistory = [];
this.errorCounts.clear();
log.info('Error history cleared');
}
}
const errorHandler = new ErrorHandler();
/**
* Wrapper funkcji z automatyczną obsługą błędów
* @param {Function} fn - Funkcja do opakowania
* @param {string} context - Kontekst wykonania
* @returns {Function} Opakowana funkcja
*/
export function withErrorHandling<T extends (...args: any[]) => any>(
fn: T,
context: string
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
try {
return await fn.apply(this, args);
} catch (error) {
const handledError = errorHandler.handle(error as Error, context, {
functionName: fn.name,
arguments: args.length
});
throw handledError;
}
};
}
/**
* Decorator dla metod klasy z automatyczną obsługą błędów
* @param {string} context - Kontekst wykonania
*/
export function handleErrors(context: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
const handledError = errorHandler.handle(error as Error, `${context}.${propertyKey}`, {
className: target.constructor.name,
methodName: propertyKey,
arguments: args.length
});
throw handledError;
}
};
return descriptor;
};
}
/**
* Funkcja pomocnicza do tworzenia błędów walidacji
* @param {string} message - Wiadomość błędu
* @param {object} details - Szczegóły walidacji
* @returns {AppError} Błąd walidacji
*/
export function createValidationError(message: string, details: object = {}): AppError {
return new AppError(message, ErrorTypes.VALIDATION, details);
}
/**
* Funkcja pomocnicza do tworzenia błędów sieciowych
* @param {string} message - Wiadomość błędu
* @param {object} details - Szczegóły sieci
* @returns {AppError} Błąd sieciowy
*/
export function createNetworkError(message: string, details: object = {}): AppError {
return new AppError(message, ErrorTypes.NETWORK, details);
}
/**
* Funkcja pomocnicza do tworzenia błędów plików
* @param {string} message - Wiadomość błędu
* @param {object} details - Szczegóły pliku
* @returns {AppError} Błąd pliku
*/
export function createFileError(message: string, details: object = {}): AppError {
return new AppError(message, ErrorTypes.FILE_IO, details);
}
/**
* Funkcja pomocnicza do bezpiecznego wykonania operacji
* @param {() => Promise<T>} operation - Operacja do wykonania
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
* @param {string} context - Kontekst operacji
* @returns {Promise<T>} Wynik operacji lub wartość fallback
*/
export async function safeExecute<T>(operation: () => Promise<T>, fallbackValue: T, context = 'SafeExecute'): Promise<T> {
try {
return await operation();
} catch (error) {
errorHandler.handle(error as Error, context);
return fallbackValue;
}
}
/**
* Funkcja do retry operacji z exponential backoff
* @param {() => Promise<T>} operation - Operacja do powtórzenia
* @param {number} maxRetries - Maksymalna liczba prób
* @param {number} baseDelay - Podstawowe opóźnienie w ms
* @param {string} context - Kontekst operacji
* @returns {Promise<T>} Wynik operacji
*/
export async function retryWithBackoff<T>(operation: () => Promise<T>, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation'): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
break;
}
const delay = baseDelay * Math.pow(2, attempt);
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: lastError.message, context});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw errorHandler.handle(lastError!, context, {attempts: maxRetries + 1});
}
export {errorHandler};
export default errorHandler;

32
src/ImageCache.ts Normal file
View File

@@ -0,0 +1,32 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
import type { ImageDataPixel } from './types';
const log = createModuleLogger('ImageCache');
export class ImageCache {
private cache: Map<string, ImageDataPixel>;
constructor() {
this.cache = new Map();
}
set(key: string, imageData: ImageDataPixel): void {
log.info("Caching image data for key:", key);
this.cache.set(key, imageData);
}
get(key: string): ImageDataPixel | undefined {
const data = this.cache.get(key);
log.debug("Retrieved cached data for key:", key, !!data);
return data;
}
has(key: string): boolean {
return this.cache.has(key);
}
clear(): void {
log.info("Clearing image cache");
this.cache.clear();
}
}

View File

@@ -0,0 +1,309 @@
import {removeImage, getAllImageIds} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import type { Canvas } from './Canvas';
import type { Layer, CanvasState } from './types';
const log = createModuleLogger('ImageReferenceManager');
interface GarbageCollectionStats {
trackedImages: number;
totalReferences: number;
isRunning: boolean;
gcInterval: number;
maxAge: number;
}
export class ImageReferenceManager {
private canvas: Canvas & { canvasState: CanvasState };
private gcInterval: number;
private gcTimer: number | null;
private imageLastUsed: Map<string, number>;
private imageReferences: Map<string, number>;
private isGcRunning: boolean;
private maxAge: number;
public operationCount: number;
public operationThreshold: number;
constructor(canvas: Canvas & { canvasState: CanvasState }) {
this.canvas = canvas;
this.imageReferences = new Map(); // imageId -> count
this.imageLastUsed = new Map(); // imageId -> timestamp
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
this.gcTimer = null;
this.isGcRunning = false;
this.operationCount = 0;
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
}
/**
* Uruchamia automatyczne garbage collection
*/
startGarbageCollection(): void {
if (this.gcTimer) {
clearInterval(this.gcTimer);
}
this.gcTimer = window.setInterval(() => {
this.performGarbageCollection();
}, this.gcInterval);
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
}
/**
* Zatrzymuje automatyczne garbage collection
*/
stopGarbageCollection(): void {
if (this.gcTimer) {
clearInterval(this.gcTimer);
this.gcTimer = null;
}
log.info("Garbage collection stopped");
}
/**
* Dodaje referencję do obrazu
* @param {string} imageId - ID obrazu
*/
addReference(imageId: string): void {
if (!imageId) return;
const currentCount = this.imageReferences.get(imageId) || 0;
this.imageReferences.set(imageId, currentCount + 1);
this.imageLastUsed.set(imageId, Date.now());
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
}
/**
* Usuwa referencję do obrazu
* @param {string} imageId - ID obrazu
*/
removeReference(imageId: string): void {
if (!imageId) return;
const currentCount = this.imageReferences.get(imageId) || 0;
if (currentCount <= 1) {
this.imageReferences.delete(imageId);
log.debug(`Removed last reference to image ${imageId}`);
} else {
this.imageReferences.set(imageId, currentCount - 1);
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
}
}
/**
* Aktualizuje referencje na podstawie aktualnego stanu canvas
*/
updateReferences(): void {
log.debug("Updating image references...");
this.imageReferences.clear();
const usedImageIds = this.collectAllUsedImageIds();
usedImageIds.forEach(imageId => {
this.addReference(imageId);
});
log.info(`Updated references for ${usedImageIds.size} unique images`);
}
/**
* Zbiera wszystkie używane imageId z różnych źródeł
* @returns {Set<string>} Zbiór używanych imageId
*/
collectAllUsedImageIds(): Set<string> {
const usedImageIds = new Set<string>();
this.canvas.layers.forEach((layer: Layer) => {
if (layer.imageId) {
usedImageIds.add(layer.imageId);
}
});
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
this.canvas.canvasState.layersUndoStack.forEach((layersState: Layer[]) => {
layersState.forEach((layer: Layer) => {
if (layer.imageId) {
usedImageIds.add(layer.imageId);
}
});
});
}
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
this.canvas.canvasState.layersRedoStack.forEach((layersState: Layer[]) => {
layersState.forEach((layer: Layer) => {
if (layer.imageId) {
usedImageIds.add(layer.imageId);
}
});
});
}
log.debug(`Collected ${usedImageIds.size} used image IDs`);
return usedImageIds;
}
/**
* Znajduje nieużywane obrazy
* @param {Set<string>} usedImageIds - Zbiór używanych imageId
* @returns {Promise<string[]>} Lista nieużywanych imageId
*/
async findUnusedImages(usedImageIds: Set<string>): Promise<string[]> {
try {
const allImageIds = await getAllImageIds();
const unusedImages: string[] = [];
const now = Date.now();
for (const imageId of allImageIds) {
if (!usedImageIds.has(imageId)) {
const lastUsed = this.imageLastUsed.get(imageId) || 0;
const age = now - lastUsed;
if (age > this.maxAge) {
unusedImages.push(imageId);
} else {
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
}
}
}
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
return unusedImages;
} catch (error) {
log.error("Error finding unused images:", error);
return [];
}
}
/**
* Czyści nieużywane obrazy
* @param {string[]} unusedImages - Lista nieużywanych imageId
*/
async cleanupUnusedImages(unusedImages: string[]): Promise<void> {
if (unusedImages.length === 0) {
log.debug("No unused images to cleanup");
return;
}
log.info(`Starting cleanup of ${unusedImages.length} unused images`);
let cleanedCount = 0;
let errorCount = 0;
for (const imageId of unusedImages) {
try {
await removeImage(imageId);
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
this.canvas.imageCache.delete(imageId);
}
this.imageReferences.delete(imageId);
this.imageLastUsed.delete(imageId);
cleanedCount++;
log.debug(`Cleaned up image: ${imageId}`);
} catch (error) {
errorCount++;
log.error(`Error cleaning up image ${imageId}:`, error);
}
}
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
}
/**
* Wykonuje pełne garbage collection
*/
async performGarbageCollection(): Promise<void> {
if (this.isGcRunning) {
log.debug("Garbage collection already running, skipping");
return;
}
this.isGcRunning = true;
log.info("Starting garbage collection...");
try {
this.updateReferences();
const usedImageIds = this.collectAllUsedImageIds();
const unusedImages = await this.findUnusedImages(usedImageIds);
await this.cleanupUnusedImages(unusedImages);
} catch (error) {
log.error("Error during garbage collection:", error);
} finally {
this.isGcRunning = false;
}
}
/**
* Zwiększa licznik operacji i sprawdza czy uruchomić GC
*/
incrementOperationCount(): void {
this.operationCount++;
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
if (this.operationCount >= this.operationThreshold) {
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
this.operationCount = 0; // Reset counter
setTimeout(() => {
this.performGarbageCollection();
}, 100);
}
}
/**
* Resetuje licznik operacji
*/
resetOperationCount(): void {
this.operationCount = 0;
log.debug("Operation count reset");
}
/**
* Ustawia próg operacji dla automatycznego GC
* @param {number} threshold - Nowy próg operacji
*/
setOperationThreshold(threshold: number): void {
this.operationThreshold = Math.max(1, threshold);
log.info(`Operation threshold set to: ${this.operationThreshold}`);
}
/**
* Ręczne uruchomienie garbage collection
*/
async manualGarbageCollection(): Promise<void> {
log.info("Manual garbage collection triggered");
await this.performGarbageCollection();
}
/**
* Zwraca statystyki garbage collection
* @returns {GarbageCollectionStats} Statystyki
*/
getStats(): GarbageCollectionStats {
return {
trackedImages: this.imageReferences.size,
totalReferences: Array.from(this.imageReferences.values()).reduce((sum, count) => sum + count, 0),
isRunning: this.isGcRunning,
gcInterval: this.gcInterval,
maxAge: this.maxAge
};
}
/**
* Czyści wszystkie dane (przy usuwaniu canvas)
*/
destroy(): void {
this.stopGarbageCollection();
this.imageReferences.clear();
this.imageLastUsed.clear();
log.info("ImageReferenceManager destroyed");
}
}

339
src/MaskTool.ts Normal file
View File

@@ -0,0 +1,339 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
import type { Canvas } from './Canvas';
import type { Point, CanvasState } from './types';
const log = createModuleLogger('Mask_tool');
interface MaskToolCallbacks {
onStateChange?: () => void;
}
export class MaskTool {
private brushHardness: number;
private brushSize: number;
private brushStrength: number;
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
public isActive: boolean;
public isDrawing: boolean;
public isOverlayVisible: boolean;
private lastPosition: Point | null;
private mainCanvas: HTMLCanvasElement;
private maskCanvas: HTMLCanvasElement;
private maskCtx: CanvasRenderingContext2D;
private onStateChange: (() => void) | null;
private previewCanvas: HTMLCanvasElement;
private previewCanvasInitialized: boolean;
private previewCtx: CanvasRenderingContext2D;
private previewVisible: boolean;
public x: number;
public y: number;
constructor(canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }, callbacks: MaskToolCallbacks = {}) {
this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas');
const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx) {
throw new Error("Failed to get 2D context for mask canvas");
}
this.maskCtx = maskCtx;
this.x = 0;
this.y = 0;
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this.brushStrength = 0.5;
this.brushHardness = 0.5;
this.isDrawing = false;
this.lastPosition = null;
this.previewCanvas = document.createElement('canvas');
const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
if (!previewCtx) {
throw new Error("Failed to get 2D context for preview canvas");
}
this.previewCtx = previewCtx;
this.previewVisible = false;
this.previewCanvasInitialized = false;
this.initMaskCanvas();
}
initPreviewCanvas(): void {
if (this.previewCanvas.parentElement) {
this.previewCanvas.parentElement.removeChild(this.previewCanvas);
}
this.previewCanvas.width = this.canvasInstance.canvas.width;
this.previewCanvas.height = this.canvasInstance.canvas.height;
this.previewCanvas.style.position = 'absolute';
this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`;
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
this.previewCanvas.style.pointerEvents = 'none';
this.previewCanvas.style.zIndex = '10';
if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
}
}
setBrushHardness(hardness: number): void {
this.brushHardness = Math.max(0, Math.min(1, hardness));
}
initMaskCanvas(): void {
const extraSpace = 2000; // Allow for a generous drawing area outside the output area
this.maskCanvas.width = this.canvasInstance.width + extraSpace;
this.maskCanvas.height = this.canvasInstance.height + extraSpace;
this.x = -extraSpace / 2;
this.y = -extraSpace / 2;
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
}
activate(): void {
if (!this.previewCanvasInitialized) {
this.initPreviewCanvas();
this.previewCanvasInitialized = true;
}
this.isActive = true;
this.previewCanvas.style.display = 'block';
this.canvasInstance.interaction.mode = 'drawingMask';
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
this.canvasInstance.canvasState.saveMaskState();
}
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool activated");
}
deactivate(): void {
this.isActive = false;
this.previewCanvas.style.display = 'none';
this.canvasInstance.interaction.mode = 'none';
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool deactivated");
}
setBrushSize(size: number): void {
this.brushSize = Math.max(1, size);
}
setBrushStrength(strength: number): void {
this.brushStrength = Math.max(0, Math.min(1, strength));
}
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
if (!this.isActive) return;
this.isDrawing = true;
this.lastPosition = worldCoords;
this.draw(worldCoords);
this.clearPreview();
}
handleMouseMove(worldCoords: Point, viewCoords: Point): void {
if (this.isActive) {
this.drawBrushPreview(viewCoords);
}
if (!this.isActive || !this.isDrawing) return;
this.draw(worldCoords);
this.lastPosition = worldCoords;
}
handleMouseLeave(): void {
this.previewVisible = false;
this.clearPreview();
}
handleMouseEnter(): void {
this.previewVisible = true;
}
handleMouseUp(viewCoords: Point): void {
if (!this.isActive) return;
if (this.isDrawing) {
this.isDrawing = false;
this.lastPosition = null;
this.canvasInstance.canvasState.saveMaskState();
if (this.onStateChange) {
this.onStateChange();
}
this.drawBrushPreview(viewCoords);
}
}
draw(worldCoords: Point): void {
if (!this.lastPosition) {
this.lastPosition = worldCoords;
}
const canvasLastX = this.lastPosition.x - this.x;
const canvasLastY = this.lastPosition.y - this.y;
const canvasX = worldCoords.x - this.x;
const canvasY = worldCoords.y - this.y;
const canvasWidth = this.maskCanvas.width;
const canvasHeight = this.maskCanvas.height;
if (canvasX >= 0 && canvasX < canvasWidth &&
canvasY >= 0 && canvasY < canvasHeight &&
canvasLastX >= 0 && canvasLastX < canvasWidth &&
canvasLastY >= 0 && canvasLastY < canvasHeight) {
this.maskCtx.beginPath();
this.maskCtx.moveTo(canvasLastX, canvasLastY);
this.maskCtx.lineTo(canvasX, canvasY);
const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else {
const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient(
canvasX, canvasY, innerRadius,
canvasX, canvasY, gradientRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.maskCtx.strokeStyle = gradient;
}
this.maskCtx.lineWidth = this.brushSize;
this.maskCtx.lineCap = 'round';
this.maskCtx.lineJoin = 'round';
this.maskCtx.globalCompositeOperation = 'source-over';
this.maskCtx.stroke();
} else {
log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`);
}
}
drawBrushPreview(viewCoords: Point): void {
if (!this.previewVisible || this.isDrawing) {
this.clearPreview();
return;
}
this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
this.previewCtx.lineWidth = 1;
this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke();
}
clearPreview(): void {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
}
clear(): void {
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState();
}
}
getMask(): HTMLCanvasElement {
return this.maskCanvas;
}
getMaskImageWithAlpha(): HTMLImageElement {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
throw new Error("Failed to get 2D context for temporary canvas");
}
tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = alpha;
}
tempCtx.putImageData(imageData, 0, 0);
const maskImage = new Image();
maskImage.src = tempCanvas.toDataURL();
return maskImage;
}
resize(width: number, height: number): void {
this.initPreviewCanvas();
const oldMask = this.maskCanvas;
const oldX = this.x;
const oldY = this.y;
const oldWidth = oldMask.width;
const oldHeight = oldMask.height;
const isIncreasingWidth = width > this.canvasInstance.width;
const isIncreasingHeight = height > this.canvasInstance.height;
this.maskCanvas = document.createElement('canvas');
const extraSpace = 2000;
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight;
const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.maskCtx = newMaskCtx;
if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = this.x - oldX;
const offsetY = this.y - oldY;
this.maskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
}
log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`);
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
}
updatePosition(dx: number, dy: number): void {
this.x += dx;
this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`);
}
toggleOverlayVisibility(): void {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image: HTMLImageElement): void {
const destX = -this.x;
const destY = -this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
}
}

5
src/config.ts Normal file
View File

@@ -0,0 +1,5 @@
import { LogLevel } from "./logger";
// Log level for development.
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
export const LOG_LEVEL: keyof typeof LogLevel = 'DEBUG';

405
src/css/canvas_view.css Normal file
View File

@@ -0,0 +1,405 @@
.painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
text-align: center;
margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
}
.painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
transform: translateY(1px);
}
.painter-button:disabled,
.painter-button:disabled:hover {
background: #555;
color: #888;
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: #444;
}
.painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
border-color: #2a4cb4;
}
.painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
}
.painter-controls {
background: linear-gradient(to bottom, #404040, #383838);
border-bottom: 1px solid #2a2a2a;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
}
.painter-slider-container {
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 12px;
}
.painter-slider-container input[type="range"] {
width: 80px;
}
.painter-button-group {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(0,0,0,0.2);
padding: 4px;
border-radius: 6px;
}
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
}
.painter-separator {
width: 1px;
height: 28px;
background-color: #2a2a2a;
margin: 0 8px;
}
.painter-container {
background: #607080; /* 带蓝色的灰色背景 */
border: 1px solid #4a5a6a;
border-radius: 6px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
}
.painter-container.drag-over {
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
border-style: dashed;
}
.painter-dialog {
background: #404040;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
padding: 20px;
color: #ffffff;
}
.painter-dialog input {
background: #303030;
border: 1px solid #505050;
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
margin: 4px;
width: 80px;
}
.painter-dialog button {
background: #505050;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
padding: 4px 12px;
margin: 4px;
cursor: pointer;
}
.painter-dialog button:hover {
background: #606060;
}
.blend-opacity-slider {
width: 100%;
margin: 5px 0;
display: none;
}
.blend-mode-active .blend-opacity-slider {
display: block;
}
.blend-mode-item {
padding: 5px;
cursor: pointer;
position: relative;
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.painter-tooltip {
position: fixed;
display: none;
background: #3a3a3a;
color: #f0f0f0;
border: 1px solid #555;
border-radius: 8px;
padding: 12px 18px;
z-index: 9999;
font-size: 13px;
line-height: 1.7;
width: auto;
max-width: min(500px, calc(100vw - 40px));
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
pointer-events: none;
transform-origin: top left;
transition: transform 0.2s ease;
will-change: transform;
}
.painter-tooltip.scale-down {
transform: scale(0.9);
transform-origin: top;
}
.painter-tooltip.scale-down-more {
transform: scale(0.8);
transform-origin: top;
}
.painter-tooltip table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
}
.painter-tooltip table td {
padding: 2px 8px;
vertical-align: middle;
}
.painter-tooltip table td:first-child {
width: auto;
white-space: nowrap;
min-width: fit-content;
}
.painter-tooltip table td:last-child {
width: auto;
}
.painter-tooltip table tr:nth-child(odd) td {
background-color: rgba(0,0,0,0.1);
}
@media (max-width: 600px) {
.painter-tooltip {
font-size: 11px;
padding: 8px 12px;
}
.painter-tooltip table td {
padding: 2px 4px;
}
.painter-tooltip kbd {
padding: 1px 4px;
font-size: 10px;
}
.painter-tooltip table td:first-child {
width: 40%;
}
.painter-tooltip table td:last-child {
width: 60%;
}
.painter-tooltip h4 {
font-size: 12px;
margin-top: 8px;
margin-bottom: 4px;
}
}
@media (max-width: 400px) {
.painter-tooltip {
font-size: 10px;
padding: 6px 8px;
}
.painter-tooltip table td {
padding: 1px 3px;
}
.painter-tooltip kbd {
padding: 0px 3px;
font-size: 9px;
}
.painter-tooltip table td:first-child {
width: 35%;
}
.painter-tooltip table td:last-child {
width: 65%;
}
.painter-tooltip h4 {
font-size: 11px;
margin-top: 6px;
margin-bottom: 3px;
}
}
.painter-tooltip::-webkit-scrollbar {
width: 8px;
}
.painter-tooltip::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 4px;
}
.painter-tooltip::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.painter-tooltip::-webkit-scrollbar-thumb:hover {
background: #666;
}
.painter-tooltip h4 {
margin-top: 10px;
margin-bottom: 5px;
color: #4a90e2; /* Jasnoniebieski akcent */
border-bottom: 1px solid #555;
padding-bottom: 4px;
}
.painter-tooltip ul {
list-style: none;
padding-left: 10px;
margin: 0;
}
.painter-tooltip kbd {
background-color: #2a2a2a;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
color: #d0d0d0;
}
.painter-container.has-focus {
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
która nie wpłynie na rozmiar ani pozycję elementu. */
box-shadow: 0 0 0 2px white;
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
/* border-color: white; */
}
.painter-button.matting-button {
position: relative;
transition: all 0.3s ease;
}
.painter-button.matting-button.loading {
padding-right: 36px; /* Make space for spinner */
cursor: wait;
}
.painter-button.matting-button .matting-spinner {
display: none;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: matting-spin 1s linear infinite;
}
.painter-button.matting-button.loading .matting-spinner {
display: block;
}
@keyframes matting-spin {
to {
transform: translateY(-50%) rotate(360deg);
}
}
.painter-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
z-index: 111;
display: flex;
align-items: center;
justify-content: center;
}
.painter-modal-content {
width: 90vw;
height: 90vh;
background-color: #353535;
border: 1px solid #222;
border-radius: 8px;
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
position: relative;
}
.painterMainContainer {
display: flex;
flex-direction: column;
height: 100%;
flex-grow: 1;
}
.painterCanvasContainer {
flex-grow: 1;
position: relative;
}

192
src/db.ts Normal file
View File

@@ -0,0 +1,192 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('db');
const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState';
const IMAGE_STORE_NAME = 'CanvasImages';
const DB_VERSION = 3;
let db: IDBDatabase | null = null;
type DBRequestOperation = 'get' | 'put' | 'delete' | 'clear';
interface CanvasStateDB {
id: string;
state: any;
}
interface CanvasImageDB {
imageId: string;
imageSrc: string;
}
/**
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
* @param {IDBObjectStore} store - Store IndexedDB
* @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
* @param {any} data - Dane dla operacji (opcjonalne)
* @param {string} errorMessage - Wiadomość błędu
* @returns {Promise<any>} Promise z wynikiem operacji
*/
function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise<any> {
return new Promise((resolve, reject) => {
let request: IDBRequest;
switch (operation) {
case 'get':
request = store.get(data);
break;
case 'put':
request = store.put(data);
break;
case 'delete':
request = store.delete(data);
break;
case 'clear':
request = store.clear();
break;
default:
reject(new Error(`Unknown operation: ${operation}`));
return;
}
request.onerror = (event) => {
log.error(errorMessage, (event.target as IDBRequest).error);
reject(errorMessage);
};
request.onsuccess = (event) => {
resolve((event.target as IDBRequest).result);
};
});
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (db) {
resolve(db);
return;
}
log.info("Opening IndexedDB...");
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => {
log.error("IndexedDB error:", (event.target as IDBOpenDBRequest).error);
reject("Error opening IndexedDB.");
};
request.onsuccess = (event) => {
db = (event.target as IDBOpenDBRequest).result;
log.info("IndexedDB opened successfully.");
resolve(db);
};
request.onupgradeneeded = (event) => {
log.info("Upgrading IndexedDB...");
const dbInstance = (event.target as IDBOpenDBRequest).result;
if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
dbInstance.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
log.info("Object store created:", STATE_STORE_NAME);
}
if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
dbInstance.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'});
log.info("Object store created:", IMAGE_STORE_NAME);
}
};
});
}
export async function getCanvasState(id: string): Promise<any | null> {
log.info(`Getting state for id: ${id}`);
const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
const store = transaction.objectStore(STATE_STORE_NAME);
const result = await createDBRequest(store, 'get', id, "Error getting canvas state") as CanvasStateDB;
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
return result ? result.state : null;
}
export async function setCanvasState(id: string, state: any): Promise<void> {
log.info(`Setting state for id: ${id}`);
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");
log.debug(`Set success for id: ${id}`);
}
export async function removeCanvasState(id: string): Promise<void> {
log.info(`Removing state for id: ${id}`);
const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'delete', id, "Error removing canvas state");
log.debug(`Remove success for id: ${id}`);
}
export async function saveImage(imageId: string, imageSrc: string | ImageBitmap): Promise<void> {
log.info(`Saving image with id: ${imageId}`);
const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME);
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
log.debug(`Image saved successfully for id: ${imageId}`);
}
export async function getImage(imageId: string): Promise<string | ImageBitmap | null> {
log.info(`Getting image with id: ${imageId}`);
const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME);
const result = await createDBRequest(store, 'get', imageId, "Error getting image") as CanvasImageDB;
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
return result ? result.imageSrc : null;
}
export async function removeImage(imageId: string): Promise<void> {
log.info(`Removing image with id: ${imageId}`);
const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME);
await createDBRequest(store, 'delete', imageId, "Error removing image");
log.debug(`Remove image success for id: ${imageId}`);
}
export async function getAllImageIds(): Promise<string[]> {
log.info("Getting all image IDs...");
const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onerror = (event) => {
log.error("Error getting all image IDs:", (event.target as IDBRequest).error);
reject("Error getting all image IDs");
};
request.onsuccess = (event) => {
const imageIds = (event.target as IDBRequest).result;
log.debug(`Found ${imageIds.length} image IDs in database`);
resolve(imageIds);
};
});
}
export async function clearAllCanvasStates(): Promise<void> {
log.info("Clearing all canvas states...");
const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'clear', null, "Error clearing canvas states");
log.info("All canvas states cleared successfully.");
}

374
src/logger.ts Normal file
View File

@@ -0,0 +1,374 @@
/**
* Logger - Centralny system logowania dla ComfyUI-LayerForge
*
* Funkcje:
* - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR)
* - Możliwość włączania/wyłączania logów globalnie lub per moduł
* - Kolorowe logi w konsoli
* - Możliwość zapisywania logów do localStorage
* - Możliwość eksportu logów
*/
function padStart(str: string, targetLength: number, padString: string): string {
targetLength = targetLength >> 0;
padString = String(padString || ' ');
if (str.length > targetLength) {
return String(str);
} else {
targetLength = targetLength - str.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
return padString.slice(0, targetLength) + String(str);
}
}
export const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4
} as const;
export type LogLevels = typeof LogLevel[keyof typeof LogLevel];
interface LoggerConfig {
globalLevel: LogLevels;
moduleSettings: { [key: string]: LogLevels };
useColors: boolean;
saveToStorage: boolean;
maxStoredLogs: number;
timestampFormat: string;
storageKey: string;
}
interface LogData {
timestamp: string;
module: string;
level: LogLevels;
levelName: string;
args: any[];
time: Date;
}
const DEFAULT_CONFIG: LoggerConfig = {
globalLevel: LogLevel.INFO,
moduleSettings: {},
useColors: true,
saveToStorage: false,
maxStoredLogs: 1000,
timestampFormat: 'HH:mm:ss',
storageKey: 'layerforge_logs'
};
const COLORS: { [key: number]: string } = {
[LogLevel.DEBUG]: '#9e9e9e',
[LogLevel.INFO]: '#2196f3',
[LogLevel.WARN]: '#ff9800',
[LogLevel.ERROR]: '#f44336',
};
const LEVEL_NAMES: { [key: number]: string } = {
[LogLevel.DEBUG]: 'DEBUG',
[LogLevel.INFO]: 'INFO',
[LogLevel.WARN]: 'WARN',
[LogLevel.ERROR]: 'ERROR',
};
class Logger {
private config: LoggerConfig;
private enabled: boolean;
private logs: LogData[];
constructor() {
this.config = {...DEFAULT_CONFIG};
this.logs = [];
this.enabled = true;
this.loadConfig();
}
/**
* Konfiguracja loggera
* @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
*/
configure(config: Partial<LoggerConfig>): this {
this.config = {...this.config, ...config};
this.saveConfig();
return this;
}
/**
* Włącz/wyłącz logger globalnie
* @param {boolean} enabled - Czy logger ma być włączony
*/
setEnabled(enabled: boolean): this {
this.enabled = enabled;
return this;
}
/**
* Ustaw globalny poziom logowania
* @param {LogLevels} level - Poziom logowania
*/
setGlobalLevel(level: LogLevels): this {
this.config.globalLevel = level;
this.saveConfig();
return this;
}
/**
* Ustaw poziom logowania dla konkretnego modułu
* @param {string} module - Nazwa modułu
* @param {LogLevels} level - Poziom logowania
*/
setModuleLevel(module: string, level: LogLevels): this {
this.config.moduleSettings[module] = level;
this.saveConfig();
return this;
}
/**
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu
* @param {string} module - Nazwa modułu
* @param {LogLevels} level - Poziom logowania do sprawdzenia
* @returns {boolean} - Czy poziom jest aktywny
*/
isLevelEnabled(module: string, level: LogLevels): boolean {
if (!this.enabled) return false;
if (this.config.moduleSettings[module] !== undefined) {
return level >= this.config.moduleSettings[module];
}
return level >= this.config.globalLevel;
}
/**
* Formatuj znacznik czasu
* @returns {string} - Sformatowany znacznik czasu
*/
formatTimestamp(): string {
const now = new Date();
const format = this.config.timestampFormat;
return format
.replace('HH', padStart(String(now.getHours()), 2, '0'))
.replace('mm', padStart(String(now.getMinutes()), 2, '0'))
.replace('ss', padStart(String(now.getSeconds()), 2, '0'))
.replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
}
/**
* Zapisz log
* @param {string} module - Nazwa modułu
* @param {LogLevels} level - Poziom logowania
* @param {any[]} args - Argumenty do zalogowania
*/
log(module: string, level: LogLevels, ...args: any[]): void {
if (!this.isLevelEnabled(module, level)) return;
const timestamp = this.formatTimestamp();
const levelName = LEVEL_NAMES[level];
const logData: LogData = {
timestamp,
module,
level,
levelName,
args,
time: new Date()
};
if (this.config.saveToStorage) {
this.logs.push(logData);
if (this.logs.length > this.config.maxStoredLogs) {
this.logs.shift();
}
this.saveLogs();
}
this.printToConsole(logData);
}
/**
* Wyświetl log w konsoli
* @param {LogData} logData - Dane logu
*/
printToConsole(logData: LogData): void {
const {timestamp, module, level, levelName, args} = logData;
const prefix = `[${timestamp}] [${module}] [${levelName}]`;
if (this.config.useColors && typeof console.log === 'function') {
const color = COLORS[level] || '#000000';
console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args);
return;
}
console.log(prefix, ...args);
}
/**
* Zapisz logi do localStorage
*/
saveLogs(): void {
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
try {
const simplifiedLogs = this.logs.map((log) => ({
t: log.timestamp,
m: log.module,
l: log.level,
a: log.args.map((arg: any) => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg);
} catch (e) {
return String(arg);
}
}
return arg;
})
}));
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
} catch (e) {
console.error('Failed to save logs to localStorage:', e);
}
}
}
/**
* Załaduj logi z localStorage
*/
loadLogs(): void {
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
try {
const storedLogs = localStorage.getItem(this.config.storageKey);
if (storedLogs) {
this.logs = JSON.parse(storedLogs);
}
} catch (e) {
console.error('Failed to load logs from localStorage:', e);
}
}
}
/**
* Zapisz konfigurację do localStorage
*/
saveConfig(): void {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
} catch (e) {
console.error('Failed to save logger config to localStorage:', e);
}
}
}
/**
* Załaduj konfigurację z localStorage
*/
loadConfig(): void {
if (typeof localStorage !== 'undefined') {
try {
const storedConfig = localStorage.getItem('layerforge_logger_config');
if (storedConfig) {
this.config = {...this.config, ...JSON.parse(storedConfig)};
}
} catch (e) {
console.error('Failed to load logger config from localStorage:', e);
}
}
}
/**
* Wyczyść wszystkie logi
*/
clearLogs(): this {
this.logs = [];
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(this.config.storageKey);
}
return this;
}
/**
* Eksportuj logi do pliku
* @param {'json' | 'txt'} format - Format eksportu
*/
exportLogs(format: 'json' | 'txt' = 'json'): void {
if (this.logs.length === 0) {
console.warn('No logs to export');
return;
}
let content: string;
let mimeType: string;
let extension: string;
if (format === 'json') {
content = JSON.stringify(this.logs, null, 2);
mimeType = 'application/json';
extension = 'json';
} else {
content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
mimeType = 'text/plain';
extension = 'txt';
}
const blob = new Blob([content], {type: mimeType});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Log na poziomie DEBUG
* @param {string} module - Nazwa modułu
* @param {any[]} args - Argumenty do zalogowania
*/
debug(module: string, ...args: any[]): void {
this.log(module, LogLevel.DEBUG, ...args);
}
/**
* Log na poziomie INFO
* @param {string} module - Nazwa modułu
* @param {any[]} args - Argumenty do zalogowania
*/
info(module: string, ...args: any[]): void {
this.log(module, LogLevel.INFO, ...args);
}
/**
* Log na poziomie WARN
* @param {string} module - Nazwa modułu
* @param {any[]} args - Argumenty do zalogowania
*/
warn(module: string, ...args: any[]): void {
this.log(module, LogLevel.WARN, ...args);
}
/**
* Log na poziomie ERROR
* @param {string} module - Nazwa modułu
* @param {any[]} args - Argumenty do zalogowania
*/
error(module: string, ...args: any[]): void {
this.log(module, LogLevel.ERROR, ...args);
}
}
export const logger = new Logger();
export const debug = (module: string, ...args: any[]) => logger.debug(module, ...args);
export const info = (module: string, ...args: any[]) => logger.info(module, ...args);
export const warn = (module: string, ...args: any[]) => logger.warn(module, ...args);
export const error = (module: string, ...args: any[]) => logger.error(module, ...args);
declare global {
interface Window {
LayerForgeLogger: Logger;
}
}
if (typeof window !== 'undefined') {
window.LayerForgeLogger = logger;
}
export default logger;

93
src/state-saver.worker.ts 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: IDBDatabase | null;
function log(...args: any[]): void {
console.log('[StateWorker]', ...args);
}
function error(...args: any[]): void {
console.error('[StateWorker]', ...args);
}
function createDBRequest(store: IDBObjectStore, operation: 'put', data: any, errorMessage: string): Promise<any> {
return new Promise((resolve, reject) => {
let request: IDBRequest;
switch (operation) {
case 'put':
request = store.put(data);
break;
default:
reject(new Error(`Unknown operation: ${operation}`));
return;
}
request.onerror = (event) => {
error(errorMessage, (event.target as IDBRequest).error);
reject(errorMessage);
};
request.onsuccess = (event) => {
resolve((event.target as IDBRequest).result);
};
});
}
function openDB(): Promise<IDBDatabase> {
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 as IDBOpenDBRequest).error);
reject("Error opening IndexedDB.");
};
request.onsuccess = (event) => {
db = (event.target as IDBOpenDBRequest).result;
log("IndexedDB opened successfully in worker.");
resolve(db);
};
request.onupgradeneeded = (event) => {
log("Upgrading IndexedDB in worker...");
const tempDb = (event.target as IDBOpenDBRequest).result;
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
}
};
});
}
async function setCanvasState(id: string, state: any): Promise<void> {
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: MessageEvent<{ state: any, nodeId: string }>): Promise<void> {
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

@@ -0,0 +1,13 @@
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>

View File

@@ -0,0 +1,9 @@
<h4>Mask Mode</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
</table>

View File

@@ -0,0 +1,40 @@
<h4>Canvas Control</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
<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>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table>
<h4>Clipboard & I/O</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<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>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° 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>
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
</table>
<h4>Transform Handles (on selected layer)</h4>
<table>
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
</table>

View File

@@ -0,0 +1,16 @@
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>

149
src/types.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { Canvas as CanvasClass } from './Canvas';
import type { CanvasLayers } from './CanvasLayers';
export interface Layer {
id: string;
image: HTMLImageElement;
imageId: string;
name: string;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
rotation: number;
zIndex: number;
blendMode: string;
opacity: number;
mask?: Float32Array;
flipH?: boolean;
flipV?: boolean;
}
export interface ComfyNode {
id: number;
imgs?: HTMLImageElement[];
widgets: any[];
size: [number, number];
graph: any;
canvasWidget?: any;
onResize?: () => void;
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
}
declare global {
interface Window {
MaskEditorDialog?: {
instance?: {
getMessageBroker: () => any;
};
};
}
interface HTMLElement {
getContext?(contextId: '2d', options?: any): CanvasRenderingContext2D | null;
width: number;
height: number;
}
}
export interface Canvas {
layers: Layer[];
selectedLayer: Layer | null;
canvasSelection: any;
lastMousePosition: Point;
width: number;
height: number;
node: ComfyNode;
viewport: { x: number, y: number, zoom: number };
canvas: HTMLCanvasElement;
offscreenCanvas: HTMLCanvasElement;
isMouseOver: boolean;
maskTool: any;
canvasLayersPanel: any;
canvasState: any;
widget?: { value: string };
imageReferenceManager: any;
imageCache: any;
dataInitialized: boolean;
pendingDataCheck: number | null;
pendingBatchContext: any;
canvasLayers: any;
saveState: () => void;
render: () => void;
updateSelection: (layers: Layer[]) => void;
requestSaveState: (immediate?: boolean) => void;
saveToServer: (fileName: string) => Promise<any>;
removeLayersByIds: (ids: string[]) => void;
batchPreviewManagers: any[];
getMouseWorldCoordinates: (e: MouseEvent) => Point;
getMouseViewCoordinates: (e: MouseEvent) => Point;
updateOutputAreaSize: (width: number, height: number) => void;
undo: () => void;
redo: () => void;
}
// A simplified interface for the Canvas class, containing only what ClipboardManager needs.
export interface CanvasForClipboard {
canvasLayers: CanvasLayersForClipboard;
node: ComfyNode;
}
// A simplified interface for the CanvasLayers class.
export interface CanvasLayersForClipboard {
internalClipboard: Layer[];
pasteLayers(): void;
addLayerWithImage(image: HTMLImageElement, layerProps: Partial<Layer>, addMode: string): Promise<Layer | null>;
}
export type AddMode = 'mouse' | 'fit' | 'center' | 'default';
export type ClipboardPreference = 'system' | 'clipspace';
export interface WebSocketMessage {
type: string;
nodeId?: string;
[key: string]: any;
}
export interface AckCallback {
resolve: (value: WebSocketMessage | PromiseLike<WebSocketMessage>) => void;
reject: (reason?: any) => void;
}
export type AckCallbacks = Map<string, AckCallback>;
export interface CanvasState {
layersUndoStack: Layer[][];
layersRedoStack: Layer[][];
maskUndoStack: HTMLCanvasElement[];
maskRedoStack: HTMLCanvasElement[];
saveMaskState(): void;
}
export interface Point {
x: number;
y: number;
}
export interface Viewport {
x: number;
y: number;
zoom: number;
}
export interface Tensor {
data: Float32Array;
shape: number[];
width: number;
height: number;
}
export interface ImageDataPixel {
data: Uint8ClampedArray;
width: number;
height: number;
}

View File

@@ -0,0 +1,524 @@
import {createModuleLogger} from "./LoggerUtils.js";
// @ts-ignore
import {api} from "../../../scripts/api.js";
// @ts-ignore
import {app} from "../../../scripts/app.js";
// @ts-ignore
import {ComfyApp} from "../../../scripts/app.js";
import type { AddMode, CanvasForClipboard, ClipboardPreference } from "../types.js";
const log = createModuleLogger('ClipboardManager');
export class ClipboardManager {
canvas: CanvasForClipboard;
clipboardPreference: ClipboardPreference;
constructor(canvas: CanvasForClipboard) {
this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace'
}
/**
* Main paste handler that delegates to appropriate methods
* @param {AddMode} addMode - The mode for adding the layer
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> {
try {
log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers();
return true;
}
if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode);
if (success) {
return true;
}
log.info("No image found in ComfyUI Clipspace");
}
log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode);
} catch (err) {
log.error("ClipboardManager paste operation failed:", err);
return false;
}
}
/**
* Attempts to paste from ComfyUI Clipspace
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async tryClipspacePaste(addMode: AddMode): Promise<boolean> {
try {
log.info("Attempting to paste from ComfyUI Clipspace");
ComfyApp.pasteFromClipspace(this.canvas.node);
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) {
log.info("Successfully got image from ComfyUI Clipspace");
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = clipspaceImage.src;
return true;
}
}
return false;
} catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false;
}
}
/**
* System clipboard paste - handles both image data and text paths
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async trySystemClipboardPaste(addMode: AddMode): Promise<boolean> {
log.info("ClipboardManager: Checking system clipboard for images and paths");
if (navigator.clipboard?.read) {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
log.debug("Clipboard item types:", item.types);
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
try {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
if (event.target?.result) {
img.src = event.target.result as string;
}
};
reader.readAsDataURL(blob);
log.info("Found image data in system clipboard");
return true;
} catch (error) {
log.debug("Error reading image data:", error);
}
}
const textTypes = ['text/plain', 'text/uri-list'];
for (const textType of textTypes) {
if (item.types.includes(textType)) {
try {
const textBlob = await item.getType(textType);
const text = await textBlob.text();
if (this.isValidImagePath(text)) {
log.info("Found image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
}
} catch (error) {
log.debug(`Error reading ${textType}:`, error);
}
}
}
}
} catch (error) {
log.debug("Modern clipboard API failed:", error);
}
}
if (navigator.clipboard?.readText) {
try {
const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) {
log.info("Found valid image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
}
} catch (error) {
log.debug("Could not read text from clipboard:", error);
}
}
log.debug("No images or valid image paths found in system clipboard");
return false;
}
/**
* Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate
* @returns {boolean} - True if the text appears to be a valid image file path or URL
*/
isValidImagePath(text: string): boolean {
if (!text || typeof text !== 'string') {
return false;
}
text = text.trim();
if (!text) {
return false;
}
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
try {
new URL(text);
log.debug("Detected valid URL:", text);
return true;
} catch (e) {
log.debug("Invalid URL format:", text);
return false;
}
}
const imageExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
'.svg', '.tiff', '.tif', '.ico', '.avif'
];
const hasImageExtension = imageExtensions.some(ext =>
text.toLowerCase().endsWith(ext)
);
if (!hasImageExtension) {
log.debug("No valid image extension found in:", text);
return false;
}
const pathPatterns = [
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
/^[\\\/]/, // Unix absolute path (/...)
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
/^[^\\\/]*[\\\/]/ // Contains path separators
];
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
if (isValidPath) {
log.debug("Detected valid local file path:", text);
} else {
log.debug("Invalid local file path format:", text);
}
return isValidPath;
}
/**
* Attempts to load an image from a file path using simplified methods
* @param {string} filePath - The file path to load
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadImageFromPath(filePath: string, addMode: AddMode): Promise<boolean> {
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from URL:", filePath);
resolve(false);
};
img.src = filePath;
});
} catch (error) {
log.warn("Error loading image from URL:", error);
return false;
}
}
try {
log.info("Attempting to load local file via backend");
const success = await this.loadFileViaBackend(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("Backend loading failed:", error);
}
try {
log.info("Falling back to file picker");
const success = await this.promptUserForFile(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("File picker failed:", error);
}
this.showFilePathMessage(filePath);
return false;
}
/**
* Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadFileViaBackend(filePath: string, addMode: AddMode): Promise<boolean> {
try {
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error);
return false;
}
const data = await response.json();
if (!data.success) {
log.debug("Backend returned error:", data.error);
return false;
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success: boolean = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
} catch (error) {
log.debug("Error loading file via ComfyUI backend:", error);
return false;
}
}
/**
* Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async promptUserForFile(originalPath: string, addMode: AddMode): Promise<boolean> {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
const fileName = originalPath.split(/[\\\/]/).pop();
fileInput.onchange = async (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file && file.type.startsWith('image/')) {
try {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load selected image");
resolve(false);
};
if (e.target?.result) {
img.src = e.target.result as string;
}
};
reader.onerror = () => {
log.warn("Failed to read selected file");
resolve(false);
};
reader.readAsDataURL(file);
} catch (error) {
log.warn("Error processing selected file:", error);
resolve(false);
}
} else {
log.warn("Selected file is not an image");
resolve(false);
}
document.body.removeChild(fileInput);
};
fileInput.oncancel = () => {
log.info("File selection cancelled by user");
document.body.removeChild(fileInput);
resolve(false);
};
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput);
fileInput.click();
});
}
/**
* Shows a message to the user about file path limitations
* @param {string} filePath - The file path that couldn't be loaded
*/
showFilePathMessage(filePath: string): void {
const fileName = filePath.split(/[\\\/]/).pop();
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
this.showNotification(message, 5000);
log.info("Showed file path limitation message to user");
}
/**
* Shows a helpful message when clipboard appears empty and offers file picker
* @param {AddMode} addMode - The mode for adding the layer
*/
showEmptyClipboardMessage(addMode: AddMode): void {
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #2d5aa0;
color: white;
padding: 14px 18px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 10001;
max-width: 320px;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
border: 2px solid #4a7bc8;
transition: all 0.2s ease;
font-weight: 500;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">📁</span>
<span>${message}</span>
</div>
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
💡 Tip: You can also drag & drop files directly onto the canvas
</div>
`;
notification.onmouseenter = () => {
notification.style.backgroundColor = '#3d6bb0';
notification.style.borderColor = '#5a8bd8';
notification.style.transform = 'translateY(-1px)';
};
notification.onmouseleave = () => {
notification.style.backgroundColor = '#2d5aa0';
notification.style.borderColor = '#4a7bc8';
notification.style.transform = 'translateY(0)';
};
notification.onclick = async () => {
document.body.removeChild(notification);
try {
const success = await this.promptUserForFile('image_file.jpg', addMode);
if (success) {
log.info("Successfully loaded image via empty clipboard file picker");
}
} catch (error) {
log.warn("Error with empty clipboard file picker:", error);
}
};
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 12000);
log.info("Showed enhanced empty clipboard message with file picker option");
}
/**
* Shows a temporary notification to the user
* @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds
*/
showNotification(message: string, duration = 3000): void {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
font-size: 14px;
line-height: 1.4;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
}

291
src/utils/CommonUtils.ts Normal file
View File

@@ -0,0 +1,291 @@
import type { Layer } from '../types';
/**
* CommonUtils - Wspólne funkcje pomocnicze
* Eliminuje duplikację funkcji używanych w różnych modułach
*/
export interface Point {
x: number;
y: number;
}
/**
* Generuje unikalny identyfikator UUID
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Funkcja snap do siatki
* @param {number} value - Wartość do przyciągnięcia
* @param {number} gridSize - Rozmiar siatki (domyślnie 64)
* @returns {number} Wartość przyciągnięta do siatki
*/
export function snapToGrid(value: number, gridSize = 64): number {
return Math.round(value / gridSize) * gridSize;
}
/**
* Oblicza dostosowanie snap dla warstwy
* @param {Object} layer - Obiekt warstwy
* @param {number} gridSize - Rozmiar siatki
* @param {number} snapThreshold - Próg przyciągania
* @returns {Point} Obiekt z dx i dy
*/
export function getSnapAdjustment(layer: Layer, gridSize = 64, snapThreshold = 10): Point {
if (!layer) {
return {x: 0, y: 0};
}
const layerEdges = {
left: layer.x,
right: layer.x + layer.width,
top: layer.y,
bottom: layer.y + layer.height
};
const x_adjustments = [
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
const y_adjustments = [
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
const bestXSnap = x_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0];
const bestYSnap = y_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0];
return {
x: bestXSnap ? bestXSnap.delta : 0,
y: bestYSnap ? bestYSnap.delta : 0
};
}
/**
* Konwertuje współrzędne świata na lokalne
* @param {number} worldX - Współrzędna X w świecie
* @param {number} worldY - Współrzędna Y w świecie
* @param {any} layerProps - Właściwości warstwy
* @returns {Point} Lokalne współrzędne {x, y}
*/
export function worldToLocal(worldX: number, worldY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
const dx = worldX - layerProps.centerX;
const dy = worldY - layerProps.centerY;
const rad = -layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: dx * cos - dy * sin,
y: dx * sin + dy * cos
};
}
/**
* Konwertuje współrzędne lokalne na świat
* @param {number} localX - Lokalna współrzędna X
* @param {number} localY - Lokalna współrzędna Y
* @param {any} layerProps - Właściwości warstwy
* @returns {Point} Współrzędne świata {x, y}
*/
export function localToWorld(localX: number, localY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
const rad = layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: layerProps.centerX + localX * cos - localY * sin,
y: layerProps.centerY + localX * sin + localY * cos
};
}
/**
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
* @param {Layer[]} layers - Tablica warstw do sklonowania
* @returns {Layer[]} Sklonowane warstwy
*/
export function cloneLayers(layers: Layer[]): Layer[] {
return layers.map(layer => ({ ...layer }));
}
/**
* Tworzy sygnaturę stanu warstw (dla porównań)
* @param {Layer[]} layers - Tablica warstw
* @returns {string} Sygnatura JSON
*/
export function getStateSignature(layers: Layer[]): string {
return JSON.stringify(layers.map((layer, index) => {
const sig: any = {
index: index,
x: Math.round(layer.x * 100) / 100, // Round to avoid floating point precision issues
y: Math.round(layer.y * 100) / 100,
width: Math.round(layer.width * 100) / 100,
height: Math.round(layer.height * 100) / 100,
rotation: Math.round((layer.rotation || 0) * 100) / 100,
zIndex: layer.zIndex,
blendMode: layer.blendMode || 'normal',
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
flipH: !!layer.flipH,
flipV: !!layer.flipV
};
if (layer.imageId) {
sig.imageId = layer.imageId;
}
if (layer.image && layer.image.src) {
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
}
return sig;
}));
}
/**
* Debounce funkcja - opóźnia wykonanie funkcji
* @param {Function} func - Funkcja do wykonania
* @param {number} wait - Czas oczekiwania w ms
* @param {boolean} immediate - Czy wykonać natychmiast
* @returns {(...args: any[]) => void} Funkcja z debounce
*/
export function debounce(func: (...args: any[]) => void, wait: number, immediate?: boolean): (...args: any[]) => void {
let timeout: number | null;
return function executedFunction(this: any, ...args: any[]) {
const later = () => {
timeout = null;
if (!immediate) func.apply(this, args);
};
const callNow = immediate && !timeout;
if (timeout) clearTimeout(timeout);
timeout = window.setTimeout(later, wait);
if (callNow) func.apply(this, args);
};
}
/**
* Throttle funkcja - ogranicza częstotliwość wykonania
* @param {Function} func - Funkcja do wykonania
* @param {number} limit - Limit czasu w ms
* @returns {(...args: any[]) => void} Funkcja z throttle
*/
export function throttle(func: (...args: any[]) => void, limit: number): (...args: any[]) => void {
let inThrottle: boolean;
return function(this: any, ...args: any[]) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* Ogranicza wartość do zakresu
* @param {number} value - Wartość do ograniczenia
* @param {number} min - Minimalna wartość
* @param {number} max - Maksymalna wartość
* @returns {number} Ograniczona wartość
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Interpolacja liniowa między dwoma wartościami
* @param {number} start - Wartość początkowa
* @param {number} end - Wartość końcowa
* @param {number} factor - Współczynnik interpolacji (0-1)
* @returns {number} Interpolowana wartość
*/
export function lerp(start: number, end: number, factor: number): number {
return start + (end - start) * factor;
}
/**
* Konwertuje stopnie na radiany
* @param {number} degrees - Stopnie
* @returns {number} Radiany
*/
export function degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180;
}
/**
* Konwertuje radiany na stopnie
* @param {number} radians - Radiany
* @returns {number} Stopnie
*/
export function radiansToDegrees(radians: number): number {
return radians * 180 / Math.PI;
}
/**
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
* @param {number} width - Szerokość canvas
* @param {number} height - Wysokość canvas
* @param {string} contextType - Typ kontekstu (domyślnie '2d')
* @param {object} contextOptions - Opcje kontekstu
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
*/
export function createCanvas(width: number, height: number, contextType = '2d', contextOptions: any = {}): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null } {
const canvas = document.createElement('canvas');
if (width) canvas.width = width;
if (height) canvas.height = height;
const ctx = canvas.getContext(contextType, contextOptions) as CanvasRenderingContext2D | null;
return { canvas, ctx };
}
/**
* Normalizuje wartość do zakresu Uint8 (0-255)
* @param {number} value - Wartość do znormalizowania (0-1)
* @returns {number} Wartość w zakresie 0-255
*/
export function normalizeToUint8(value: number): number {
return Math.max(0, Math.min(255, Math.round(value * 255)));
}
/**
* Generuje unikalną nazwę pliku z identyfikatorem node-a
* @param {string} baseName - Podstawowa nazwa pliku
* @param {string | number} nodeId - Identyfikator node-a
* @returns {string} Unikalna nazwa pliku
*/
export function generateUniqueFileName(baseName: string, nodeId: string | number): string {
const nodePattern = new RegExp(`_node_${nodeId}(?:_node_\\d+)*`);
if (nodePattern.test(baseName)) {
const cleanName = baseName.replace(/_node_\d+/g, '');
const extension = cleanName.split('.').pop();
const nameWithoutExt = cleanName.replace(`.${extension}`, '');
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
}
const extension = baseName.split('.').pop();
const nameWithoutExt = baseName.replace(`.${extension}`, '');
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
}
/**
* Sprawdza czy punkt jest w prostokącie
* @param {number} pointX - X punktu
* @param {number} pointY - Y punktu
* @param {number} rectX - X prostokąta
* @param {number} rectY - Y prostokąta
* @param {number} rectWidth - Szerokość prostokąta
* @param {number} rectHeight - Wysokość prostokąta
* @returns {boolean} Czy punkt jest w prostokącie
*/
export function isPointInRect(pointX: number, pointY: number, rectX: number, rectY: number, rectWidth: number, rectHeight: number): boolean {
return pointX >= rectX && pointX <= rectX + rectWidth &&
pointY >= rectY && pointY <= rectY + rectHeight;
}

353
src/utils/ImageUtils.ts Normal file
View File

@@ -0,0 +1,353 @@
import {createModuleLogger} from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
import type { Tensor, ImageDataPixel } from '../types';
const log = createModuleLogger('ImageUtils');
export function validateImageData(data: any): boolean {
log.debug("Validating data structure:", {
hasData: !!data,
type: typeof data,
isArray: Array.isArray(data),
keys: data ? Object.keys(data) : null,
shape: data?.shape,
dataType: data?.data ? data.data.constructor.name : null,
fullData: data
});
if (!data) {
log.info("Data is null or undefined");
return false;
}
if (Array.isArray(data)) {
log.debug("Data is array, getting first element");
data = data[0];
}
if (!data || typeof data !== 'object') {
log.info("Invalid data type");
return false;
}
if (!data.data) {
log.info("Missing data property");
return false;
}
if (!(data.data instanceof Float32Array)) {
try {
data.data = new Float32Array(data.data);
} catch (e) {
log.error("Failed to convert data to Float32Array:", e);
return false;
}
}
return true;
}
export function convertImageData(data: any): ImageDataPixel {
log.info("Converting image data:", data);
if (Array.isArray(data)) {
data = data[0];
}
const shape = data.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
const floatData = new Float32Array(data.data);
log.debug("Processing dimensions:", {height, width, channels});
const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
}
rgbaData[pixelIndex + 3] = 255;
}
}
return {
data: rgbaData,
width: width,
height: height
};
}
export function applyMaskToImageData(imageData: ImageDataPixel, maskData: Tensor): ImageDataPixel {
log.info("Applying mask to image data");
const rgbaData = new Uint8ClampedArray(imageData.data);
const width = imageData.width;
const height = imageData.height;
const maskShape = maskData.shape;
const maskFloatData = new Float32Array(maskData.data);
log.debug(`Applying mask of shape: ${maskShape}`);
for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4;
const maskIndex = h * width + w;
const alpha = maskFloatData[maskIndex];
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
}
}
log.info("Mask application completed");
return {
data: rgbaData,
width: width,
height: height
};
}
export const prepareImageForCanvas = withErrorHandling(function (inputImage: any): ImageDataPixel {
log.info("Preparing image for canvas:", inputImage);
if (Array.isArray(inputImage)) {
inputImage = inputImage[0];
}
if (!inputImage || !inputImage.shape || !inputImage.data) {
throw createValidationError("Invalid input image format", {inputImage});
}
const shape = inputImage.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
const floatData = new Float32Array(inputImage.data);
log.debug("Image dimensions:", {height, width, channels});
const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
}
rgbaData[pixelIndex + 3] = 255;
}
}
return {
data: rgbaData,
width: width,
height: height
};
}, 'prepareImageForCanvas');
export const imageToTensor = withErrorHandling(async function (image: HTMLImageElement | HTMLCanvasElement): Promise<Tensor> {
if (!image) {
throw createValidationError("Image is required");
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width;
canvas.height = image.height;
if (ctx) {
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = new Float32Array(canvas.width * canvas.height * 3);
for (let i = 0; i < imageData.data.length; i += 4) {
const pixelIndex = i / 4;
data[pixelIndex * 3] = imageData.data[i] / 255;
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
}
return {
data: data,
shape: [1, canvas.height, canvas.width, 3],
width: canvas.width,
height: canvas.height
};
}
throw new Error("Canvas context not available");
}, 'imageToTensor');
export const tensorToImage = withErrorHandling(async function (tensor: Tensor): Promise<HTMLImageElement> {
if (!tensor || !tensor.data || !tensor.shape) {
throw createValidationError("Invalid tensor format", {tensor});
}
const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) {
const imageData = ctx.createImageData(width, height);
const data = tensor.data;
for (let i = 0; i < width * height; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
imageData.data[pixelIndex + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}
throw new Error("Canvas context not available");
}, 'tensorToImage');
export const resizeImage = withErrorHandling(async function (image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise<HTMLImageElement> {
if (!image) {
throw createValidationError("Image is required");
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width;
const originalHeight = image.height;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth;
canvas.height = newHeight;
if (ctx) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newWidth, newHeight);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}
throw new Error("Canvas context not available");
}, 'resizeImage');
export const createThumbnail = withErrorHandling(async function (image: HTMLImageElement, size = 128): Promise<HTMLImageElement> {
return resizeImage(image, size, size);
}, 'createThumbnail');
export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement | HTMLCanvasElement, format = 'png', quality = 0.9): string {
if (!image) {
throw createValidationError("Image is required");
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
if (ctx) {
ctx.drawImage(image, 0, 0);
const mimeType = `image/${format}`;
return canvas.toDataURL(mimeType, quality);
}
throw new Error("Canvas context not available");
}, 'imageToBase64');
export const base64ToImage = withErrorHandling(function (base64: string): Promise<HTMLImageElement> {
if (!base64) {
throw createValidationError("Base64 string is required");
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Failed to load image from base64"));
img.src = base64;
});
}, 'base64ToImage');
export function isValidImage(image: any): image is HTMLImageElement | HTMLCanvasElement {
return image &&
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
image.width > 0 &&
image.height > 0;
}
export function getImageInfo(image: HTMLImageElement | HTMLCanvasElement): {width: number, height: number, aspectRatio: number, area: number} | null {
if (!isValidImage(image)) {
return null;
}
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
return {
width,
height,
aspectRatio: width / height,
area: width * height
};
}
export function createImageFromSource(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = source;
});
}
export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) {
if (color !== 'transparent') {
ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height);
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}
throw new Error("Canvas context not available");
}, 'createEmptyImage');

92
src/utils/LoggerUtils.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* LoggerUtils - Centralizacja inicjalizacji loggerów
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
*/
import {logger, LogLevel} from "../logger.js";
import { LOG_LEVEL } from '../config.js';
export interface Logger {
debug: (...args: any[]) => void;
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
}
/**
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
* @param {string} moduleName - Nazwa modułu
* @returns {Logger} Obiekt z metodami logowania
*/
export function createModuleLogger(moduleName: string): Logger {
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]);
return {
debug: (...args: any[]) => logger.debug(moduleName, ...args),
info: (...args: any[]) => logger.info(moduleName, ...args),
warn: (...args: any[]) => logger.warn(moduleName, ...args),
error: (...args: any[]) => logger.error(moduleName, ...args)
};
}
/**
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
* @returns {Logger} Obiekt z metodami logowania
*/
export function createAutoLogger(): Logger {
const stack = new Error().stack;
const match = stack?.match(/\/([^\/]+)\.js/);
const moduleName = match ? match[1] : 'Unknown';
return createModuleLogger(moduleName);
}
/**
* Wrapper dla operacji z automatycznym logowaniem błędów
* @param {Function} operation - Operacja do wykonania
* @param {Logger} log - Obiekt loggera
* @param {string} operationName - Nazwa operacji (dla logów)
* @returns {Function} Opakowana funkcja
*/
export function withErrorLogging<T extends (...args: any[]) => any>(
operation: T,
log: Logger,
operationName: string
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
try {
log.debug(`Starting ${operationName}`);
const result = await operation.apply(this, args);
log.debug(`Completed ${operationName}`);
return result;
} catch (error) {
log.error(`Error in ${operationName}:`, error);
throw error;
}
};
}
/**
* Decorator dla metod klasy z automatycznym logowaniem
* @param {Logger} log - Obiekt loggera
* @param {string} methodName - Nazwa metody
*/
export function logMethod(log: Logger, methodName?: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
try {
log.debug(`${methodName || propertyKey} started`);
const result = await originalMethod.apply(this, args);
log.debug(`${methodName || propertyKey} completed`);
return result;
} catch (error) {
log.error(`${methodName || propertyKey} failed:`, error);
throw error;
}
};
return descriptor;
};
}

View File

@@ -0,0 +1,32 @@
// @ts-ignore
import { $el } from "../../../scripts/ui.js";
export function addStylesheet(url: string): void {
if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css";
}
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url.startsWith("http") ? url : getUrl(url),
});
}
export function getUrl(path: string, baseUrl?: string | URL): string {
if (baseUrl) {
return new URL(path, baseUrl).toString();
} else {
// @ts-ignore
return new URL("../" + path, import.meta.url).toString();
}
}
export async function loadTemplate(path: string, baseUrl?: string | URL): Promise<string> {
const url = getUrl(path, baseUrl);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load template: ${url}`);
}
return await response.text();
}

View File

@@ -0,0 +1,166 @@
import {createModuleLogger} from "./LoggerUtils.js";
import type { WebSocketMessage, AckCallbacks } from "../types.js";
const log = createModuleLogger('WebSocketManager');
class WebSocketManager {
private socket: WebSocket | null;
private messageQueue: string[];
private isConnecting: boolean;
private reconnectAttempts: number;
private readonly maxReconnectAttempts: number;
private readonly reconnectInterval: number;
private ackCallbacks: AckCallbacks;
private messageIdCounter: number;
constructor(private url: string) {
this.socket = null;
this.messageQueue = [];
this.isConnecting = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectInterval = 5000; // 5 seconds
this.ackCallbacks = new Map();
this.messageIdCounter = 0;
this.connect();
}
connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
log.debug("WebSocket is already open.");
return;
}
if (this.isConnecting) {
log.debug("Connection attempt already in progress.");
return;
}
this.isConnecting = true;
log.info(`Connecting to WebSocket at ${this.url}...`);
try {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
log.info("WebSocket connection established.");
this.flushMessageQueue();
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const data: WebSocketMessage = JSON.parse(event.data);
log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId);
if (callback) {
log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`);
callback.resolve(data);
this.ackCallbacks.delete(data.nodeId);
}
}
} catch (error) {
log.error("Error parsing incoming WebSocket message:", error);
}
};
this.socket.onclose = (event: CloseEvent) => {
this.isConnecting = false;
if (event.wasClean) {
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
} else {
log.warn("WebSocket connection died. Attempting to reconnect...");
this.handleReconnect();
}
};
this.socket.onerror = (error: Event) => {
this.isConnecting = false;
log.error("WebSocket error:", error);
};
} catch (error) {
this.isConnecting = false;
log.error("Failed to create WebSocket connection:", error);
this.handleReconnect();
}
}
handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
setTimeout(() => this.connect(), this.reconnectInterval);
} else {
log.error("Max reconnect attempts reached. Giving up.");
}
}
sendMessage(data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> {
return new Promise((resolve, reject) => {
const nodeId = data.nodeId;
if (requiresAck && !nodeId) {
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
}
const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
log.debug("Sent message:", data);
if (requiresAck && nodeId) {
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId);
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout
this.ackCallbacks.set(nodeId, {
resolve: (responseData: WebSocketMessage | PromiseLike<WebSocketMessage>) => {
clearTimeout(timeout);
resolve(responseData);
},
reject: (error: any) => {
clearTimeout(timeout);
reject(error);
}
});
} else {
resolve(); // Resolve immediately if no ACK is needed
}
} else {
log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message);
if (!this.isConnecting) {
this.connect();
}
if (requiresAck) {
reject(new Error("Cannot send message with ACK required while disconnected."));
} else {
resolve();
}
}
});
}
flushMessageQueue() {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (this.socket && message) {
this.socket.send(message);
}
}
}
}
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
export const webSocketManager = new WebSocketManager(wsUrl);

196
src/utils/mask_utils.ts Normal file
View File

@@ -0,0 +1,196 @@
import {createModuleLogger} from "./LoggerUtils.js";
import type { Canvas } from '../Canvas.js';
// @ts-ignore
import {ComfyApp} from "../../../scripts/app.js";
const log = createModuleLogger('MaskUtils');
export function new_editor(app: ComfyApp): boolean {
if (!app) return false;
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
}
function get_mask_editor_element(app: ComfyApp): HTMLElement | null {
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null;
}
export function mask_editor_showing(app: ComfyApp): boolean {
const editor = get_mask_editor_element(app);
return !!editor && editor.style.display !== "none";
}
export function hide_mask_editor(app: ComfyApp): void {
if (mask_editor_showing(app)) {
const editor = document.getElementById('maskEditor');
if (editor) {
editor.style.display = 'none';
}
}
}
function get_mask_editor_cancel_button(app: ComfyApp): HTMLElement | null {
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
if (cancelButton) {
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
return cancelButton;
}
const cancelSelectors = [
'button[onclick*="cancel"]',
'button[onclick*="Cancel"]',
'input[value="Cancel"]'
];
for (const selector of cancelSelectors) {
try {
const button = document.querySelector<HTMLElement>(selector);
if (button) {
log.debug("Found cancel button with selector:", selector);
return button;
}
} catch (e) {
log.warn("Invalid selector:", selector, e);
}
}
const allButtons = document.querySelectorAll('button, input[type="button"]');
for (const button of allButtons) {
const text = (button as HTMLElement).textContent || (button as HTMLInputElement).value || '';
if (text.toLowerCase().includes('cancel')) {
log.debug("Found cancel button by text content:", text);
return button as HTMLElement;
}
}
const editorElement = get_mask_editor_element(app);
if (editorElement) {
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
return childNodes[2];
}
}
return null;
}
function get_mask_editor_save_button(app: ComfyApp): HTMLElement | null {
const saveButton = document.getElementById("maskEditor_topBarSaveButton");
if (saveButton) {
return saveButton;
}
const editorElement = get_mask_editor_element(app);
if (editorElement) {
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
return childNodes[2];
}
}
return null;
}
export function mask_editor_listen_for_cancel(app: ComfyApp, callback: () => void): void {
let attempts = 0;
const maxAttempts = 50; // 5 sekund
const findAndAttachListener = () => {
attempts++;
const cancel_button = get_mask_editor_cancel_button(app);
if (cancel_button instanceof HTMLElement && !(cancel_button as any).filter_listener_added) {
log.info("Cancel button found, attaching listener");
cancel_button.addEventListener('click', callback);
(cancel_button as any).filter_listener_added = true;
} else if (attempts < maxAttempts) {
setTimeout(findAndAttachListener, 100);
} else {
log.warn("Could not find cancel button after", maxAttempts, "attempts");
const globalClickHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const text = target.textContent || (target as HTMLInputElement).value || '';
if (target && (text.toLowerCase().includes('cancel') ||
target.id.toLowerCase().includes('cancel') ||
target.className.toLowerCase().includes('cancel'))) {
log.info("Cancel detected via global click handler");
callback();
document.removeEventListener('click', globalClickHandler);
}
};
document.addEventListener('click', globalClickHandler);
log.debug("Added global click handler for cancel detection");
}
};
findAndAttachListener();
}
export function press_maskeditor_save(app: ComfyApp): void {
const button = get_mask_editor_save_button(app);
if (button instanceof HTMLElement) {
button.click();
}
}
export function press_maskeditor_cancel(app: ComfyApp): void {
const button = get_mask_editor_cancel_button(app);
if (button instanceof HTMLElement) {
button.click();
}
}
/**
* Uruchamia mask editor z predefiniowaną maską
* @param {Canvas} canvasInstance - Instancja Canvas
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/
export function start_mask_editor_with_predefined_mask(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void {
if (!canvasInstance || !maskImage) {
log.error('Canvas instance and mask image are required');
return;
}
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
}
/**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Canvas} canvasInstance - Instancja Canvas
*/
export function start_mask_editor_auto(canvasInstance: Canvas): void {
if (!canvasInstance) {
log.error('Canvas instance is required');
return;
}
canvasInstance.startMaskEditor(null, true);
}
/**
* Tworzy maskę z obrazu dla użycia w mask editorze
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
}
/**
* Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}